mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-09-01 17:10:24 +08:00
Compare commits
1 Commits
fix/user-a
...
chore/redu
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
42ecc35950 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -67,5 +67,4 @@ CLAUDE.md
|
||||
.spec-workflow
|
||||
|
||||
# edgeone
|
||||
.edgeone
|
||||
opencode.json
|
||||
.edgeone
|
||||
@@ -4,6 +4,7 @@ import { Suspense, useCallback, useEffect, useRef, useState } from "react"
|
||||
import { DrawIoEmbed } from "react-drawio"
|
||||
import type { ImperativePanelHandle } from "react-resizable-panels"
|
||||
import ChatPanel from "@/components/chat-panel"
|
||||
import { STORAGE_CLOSE_PROTECTION_KEY } from "@/components/settings-dialog"
|
||||
import {
|
||||
ResizableHandle,
|
||||
ResizablePanel,
|
||||
@@ -28,6 +29,7 @@ export default function Home() {
|
||||
const [darkMode, setDarkMode] = useState(false)
|
||||
const [isLoaded, setIsLoaded] = useState(false)
|
||||
const [isDrawioReady, setIsDrawioReady] = useState(false)
|
||||
const [closeProtection, setCloseProtection] = useState(false)
|
||||
|
||||
const chatPanelRef = useRef<ImperativePanelHandle>(null)
|
||||
const isMobileRef = useRef(false)
|
||||
@@ -64,6 +66,13 @@ export default function Home() {
|
||||
document.documentElement.classList.toggle("dark", prefersDark)
|
||||
}
|
||||
|
||||
const savedCloseProtection = localStorage.getItem(
|
||||
STORAGE_CLOSE_PROTECTION_KEY,
|
||||
)
|
||||
if (savedCloseProtection === "true") {
|
||||
setCloseProtection(true)
|
||||
}
|
||||
|
||||
setIsLoaded(true)
|
||||
}, [pathname, router])
|
||||
|
||||
@@ -137,6 +146,20 @@ export default function Home() {
|
||||
return () => window.removeEventListener("keydown", handleKeyDown)
|
||||
}, [])
|
||||
|
||||
// Show confirmation dialog when user tries to leave the page
|
||||
useEffect(() => {
|
||||
if (!closeProtection) return
|
||||
|
||||
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
|
||||
event.preventDefault()
|
||||
return ""
|
||||
}
|
||||
|
||||
window.addEventListener("beforeunload", handleBeforeUnload)
|
||||
return () =>
|
||||
window.removeEventListener("beforeunload", handleBeforeUnload)
|
||||
}, [closeProtection])
|
||||
|
||||
return (
|
||||
<div className="h-screen bg-background relative overflow-hidden">
|
||||
<ResizablePanelGroup
|
||||
@@ -220,6 +243,7 @@ export default function Home() {
|
||||
darkMode={darkMode}
|
||||
onToggleDarkMode={handleDarkModeChange}
|
||||
isMobile={isMobile}
|
||||
onCloseProtectionChange={setCloseProtection}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
|
||||
@@ -24,7 +24,6 @@ import { useDiagram } from "@/contexts/diagram-context"
|
||||
import { useDictionary } from "@/hooks/use-dictionary"
|
||||
import { formatMessage } from "@/lib/i18n/utils"
|
||||
import { isPdfFile, isTextFile } from "@/lib/pdf-utils"
|
||||
import { STORAGE_KEYS } from "@/lib/storage"
|
||||
import type { FlattenedModel } from "@/lib/types/model-config"
|
||||
import { extractUrlContent, type UrlData } from "@/lib/url-utils"
|
||||
import { FilePreviewList } from "./file-preview-list"
|
||||
@@ -193,7 +192,6 @@ export function ChatInput({
|
||||
const [showHistory, setShowHistory] = useState(false)
|
||||
const [showUrlDialog, setShowUrlDialog] = useState(false)
|
||||
const [isExtractingUrl, setIsExtractingUrl] = useState(false)
|
||||
const [sendShortcut, setSendShortcut] = useState("ctrl-enter")
|
||||
// Allow retry when there's an error (even if status is still "streaming" or "submitted")
|
||||
const isDisabled =
|
||||
(status === "streaming" || status === "submitted") && !error
|
||||
@@ -210,36 +208,13 @@ export function ChatInput({
|
||||
adjustTextareaHeight()
|
||||
}, [input, adjustTextareaHeight])
|
||||
|
||||
// Load send shortcut preference from localStorage and listen for changes
|
||||
useEffect(() => {
|
||||
const stored = localStorage.getItem(STORAGE_KEYS.sendShortcut)
|
||||
if (stored) setSendShortcut(stored)
|
||||
|
||||
const handleChange = (e: CustomEvent<string>) =>
|
||||
setSendShortcut(e.detail)
|
||||
window.addEventListener(
|
||||
"sendShortcutChange",
|
||||
handleChange as EventListener,
|
||||
)
|
||||
return () =>
|
||||
window.removeEventListener(
|
||||
"sendShortcutChange",
|
||||
handleChange as EventListener,
|
||||
)
|
||||
}, [])
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
onChange(e)
|
||||
adjustTextareaHeight()
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
const shouldSend =
|
||||
sendShortcut === "enter"
|
||||
? e.key === "Enter" && !e.shiftKey && !e.ctrlKey && !e.metaKey
|
||||
: (e.metaKey || e.ctrlKey) && e.key === "Enter"
|
||||
|
||||
if (shouldSend) {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
|
||||
e.preventDefault()
|
||||
const form = e.currentTarget.closest("form")
|
||||
if (form && input.trim() && !isDisabled) {
|
||||
|
||||
@@ -70,6 +70,7 @@ interface ChatPanelProps {
|
||||
darkMode: boolean
|
||||
onToggleDarkMode: () => void
|
||||
isMobile?: boolean
|
||||
onCloseProtectionChange?: (enabled: boolean) => void
|
||||
}
|
||||
|
||||
// Constants for tool states
|
||||
@@ -110,6 +111,7 @@ export default function ChatPanel({
|
||||
darkMode,
|
||||
onToggleDarkMode,
|
||||
isMobile = false,
|
||||
onCloseProtectionChange,
|
||||
}: ChatPanelProps) {
|
||||
const {
|
||||
loadDiagram: onDisplayChart,
|
||||
@@ -1294,6 +1296,7 @@ export default function ChatPanel({
|
||||
<SettingsDialog
|
||||
open={showSettingsDialog}
|
||||
onOpenChange={setShowSettingsDialog}
|
||||
onCloseProtectionChange={onCloseProtectionChange}
|
||||
drawioUi={drawioUi}
|
||||
onToggleDrawioUi={onToggleDrawioUi}
|
||||
darkMode={darkMode}
|
||||
|
||||
@@ -25,7 +25,6 @@ import { Switch } from "@/components/ui/switch"
|
||||
import { useDictionary } from "@/hooks/use-dictionary"
|
||||
import { getApiEndpoint } from "@/lib/base-path"
|
||||
import { i18n, type Locale } from "@/lib/i18n/config"
|
||||
import { STORAGE_KEYS } from "@/lib/storage"
|
||||
|
||||
// Reusable setting item component for consistent layout
|
||||
function SettingItem({
|
||||
@@ -61,6 +60,7 @@ const LANGUAGE_LABELS: Record<Locale, string> = {
|
||||
interface SettingsDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onCloseProtectionChange?: (enabled: boolean) => void
|
||||
drawioUi: "min" | "sketch"
|
||||
onToggleDrawioUi: () => void
|
||||
darkMode: boolean
|
||||
@@ -70,6 +70,7 @@ interface SettingsDialogProps {
|
||||
}
|
||||
|
||||
export const STORAGE_ACCESS_CODE_KEY = "next-ai-draw-io-access-code"
|
||||
export const STORAGE_CLOSE_PROTECTION_KEY = "next-ai-draw-io-close-protection"
|
||||
const STORAGE_ACCESS_CODE_REQUIRED_KEY = "next-ai-draw-io-access-code-required"
|
||||
|
||||
function getStoredAccessCodeRequired(): boolean | null {
|
||||
@@ -82,6 +83,7 @@ function getStoredAccessCodeRequired(): boolean | null {
|
||||
function SettingsContent({
|
||||
open,
|
||||
onOpenChange,
|
||||
onCloseProtectionChange,
|
||||
drawioUi,
|
||||
onToggleDrawioUi,
|
||||
darkMode,
|
||||
@@ -94,13 +96,13 @@ function SettingsContent({
|
||||
const pathname = usePathname() || "/"
|
||||
const search = useSearchParams()
|
||||
const [accessCode, setAccessCode] = useState("")
|
||||
const [closeProtection, setCloseProtection] = useState(true)
|
||||
const [isVerifying, setIsVerifying] = useState(false)
|
||||
const [error, setError] = useState("")
|
||||
const [accessCodeRequired, setAccessCodeRequired] = useState(
|
||||
() => getStoredAccessCodeRequired() ?? false,
|
||||
)
|
||||
const [currentLang, setCurrentLang] = useState("en")
|
||||
const [sendShortcut, setSendShortcut] = useState("ctrl-enter")
|
||||
|
||||
// Proxy settings state (Electron only)
|
||||
const [httpProxy, setHttpProxy] = useState("")
|
||||
@@ -147,10 +149,11 @@ function SettingsContent({
|
||||
localStorage.getItem(STORAGE_ACCESS_CODE_KEY) || ""
|
||||
setAccessCode(storedCode)
|
||||
|
||||
const storedSendShortcut = localStorage.getItem(
|
||||
STORAGE_KEYS.sendShortcut,
|
||||
const storedCloseProtection = localStorage.getItem(
|
||||
STORAGE_CLOSE_PROTECTION_KEY,
|
||||
)
|
||||
setSendShortcut(storedSendShortcut || "ctrl-enter")
|
||||
// Default to true if not set
|
||||
setCloseProtection(storedCloseProtection !== "false")
|
||||
|
||||
setError("")
|
||||
|
||||
@@ -384,6 +387,25 @@ function SettingsContent({
|
||||
</Button>
|
||||
</SettingItem>
|
||||
|
||||
{/* Close Protection */}
|
||||
<SettingItem
|
||||
label={dict.settings.closeProtection}
|
||||
description={dict.settings.closeProtectionDescription}
|
||||
>
|
||||
<Switch
|
||||
id="close-protection"
|
||||
checked={closeProtection}
|
||||
onCheckedChange={(checked) => {
|
||||
setCloseProtection(checked)
|
||||
localStorage.setItem(
|
||||
STORAGE_CLOSE_PROTECTION_KEY,
|
||||
checked.toString(),
|
||||
)
|
||||
onCloseProtectionChange?.(checked)
|
||||
}}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
{/* Diagram Style */}
|
||||
<SettingItem
|
||||
label={dict.settings.diagramStyle}
|
||||
@@ -403,43 +425,6 @@ function SettingsContent({
|
||||
</div>
|
||||
</SettingItem>
|
||||
|
||||
{/* Send Shortcut */}
|
||||
<SettingItem
|
||||
label={dict.settings.sendShortcut}
|
||||
description={dict.settings.sendShortcutDescription}
|
||||
>
|
||||
<Select
|
||||
value={sendShortcut}
|
||||
onValueChange={(value) => {
|
||||
setSendShortcut(value)
|
||||
localStorage.setItem(
|
||||
STORAGE_KEYS.sendShortcut,
|
||||
value,
|
||||
)
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("sendShortcutChange", {
|
||||
detail: value,
|
||||
}),
|
||||
)
|
||||
}}
|
||||
>
|
||||
<SelectTrigger
|
||||
id="send-shortcut-select"
|
||||
className="w-[170px] h-9 rounded-xl"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="enter">
|
||||
{dict.settings.enterToSend}
|
||||
</SelectItem>
|
||||
<SelectItem value="ctrl-enter">
|
||||
{dict.settings.ctrlEnterToSend}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</SettingItem>
|
||||
|
||||
{/* Proxy Settings - Electron only */}
|
||||
{typeof window !== "undefined" &&
|
||||
window.electronAPI?.isElectron && (
|
||||
|
||||
@@ -62,32 +62,6 @@ const ANTHROPIC_BETA_HEADERS = {
|
||||
"anthropic-beta": "fine-grained-tool-streaming-2025-05-14",
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve baseURL based on whether user is providing their own API key.
|
||||
* When user provides their own API key, we should NOT fall back to server's
|
||||
* baseURL environment variable - user credentials should only be sent to
|
||||
* user-specified endpoints or official provider endpoints.
|
||||
*
|
||||
* @param userApiKey - User-provided API key (if any)
|
||||
* @param userBaseUrl - User-provided base URL (if any)
|
||||
* @param serverBaseUrl - Server's base URL from environment variable
|
||||
* @param defaultBaseUrl - Provider's official/default base URL (optional)
|
||||
* @returns The resolved base URL to use
|
||||
*/
|
||||
export function resolveBaseURL(
|
||||
userApiKey: string | null | undefined,
|
||||
userBaseUrl: string | null | undefined,
|
||||
serverBaseUrl: string | undefined,
|
||||
defaultBaseUrl?: string,
|
||||
): string | undefined {
|
||||
if (userApiKey) {
|
||||
// User provides their own API key - only use user's baseUrl or default
|
||||
return userBaseUrl || defaultBaseUrl || undefined
|
||||
}
|
||||
// No user API key - fall back to server config
|
||||
return userBaseUrl || serverBaseUrl || defaultBaseUrl || undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely parse integer from environment variable with validation
|
||||
*/
|
||||
@@ -621,11 +595,7 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
|
||||
|
||||
case "openai": {
|
||||
const apiKey = overrides?.apiKey || process.env.OPENAI_API_KEY
|
||||
const baseURL = resolveBaseURL(
|
||||
overrides?.apiKey,
|
||||
overrides?.baseUrl,
|
||||
process.env.OPENAI_BASE_URL,
|
||||
)
|
||||
const baseURL = overrides?.baseUrl || process.env.OPENAI_BASE_URL
|
||||
if (baseURL) {
|
||||
// Custom base URL = third-party proxy, use Chat Completions API
|
||||
// for compatibility (most proxies don't support /responses endpoint)
|
||||
@@ -644,12 +614,10 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
|
||||
|
||||
case "anthropic": {
|
||||
const apiKey = overrides?.apiKey || process.env.ANTHROPIC_API_KEY
|
||||
const baseURL = resolveBaseURL(
|
||||
overrides?.apiKey,
|
||||
overrides?.baseUrl,
|
||||
process.env.ANTHROPIC_BASE_URL,
|
||||
"https://api.anthropic.com/v1",
|
||||
)
|
||||
const baseURL =
|
||||
overrides?.baseUrl ||
|
||||
process.env.ANTHROPIC_BASE_URL ||
|
||||
"https://api.anthropic.com/v1"
|
||||
const customProvider = createAnthropic({
|
||||
apiKey,
|
||||
baseURL,
|
||||
@@ -664,11 +632,7 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
|
||||
case "google": {
|
||||
const apiKey =
|
||||
overrides?.apiKey || process.env.GOOGLE_GENERATIVE_AI_API_KEY
|
||||
const baseURL = resolveBaseURL(
|
||||
overrides?.apiKey,
|
||||
overrides?.baseUrl,
|
||||
process.env.GOOGLE_BASE_URL,
|
||||
)
|
||||
const baseURL = overrides?.baseUrl || process.env.GOOGLE_BASE_URL
|
||||
if (baseURL || overrides?.apiKey) {
|
||||
const customGoogle = createGoogleGenerativeAI({
|
||||
apiKey,
|
||||
@@ -683,15 +647,8 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
|
||||
|
||||
case "azure": {
|
||||
const apiKey = overrides?.apiKey || process.env.AZURE_API_KEY
|
||||
const baseURL = resolveBaseURL(
|
||||
overrides?.apiKey,
|
||||
overrides?.baseUrl,
|
||||
process.env.AZURE_BASE_URL,
|
||||
)
|
||||
// Only use server's resourceName if user is NOT providing their own API key
|
||||
const resourceName = overrides?.apiKey
|
||||
? undefined
|
||||
: process.env.AZURE_RESOURCE_NAME
|
||||
const baseURL = overrides?.baseUrl || process.env.AZURE_BASE_URL
|
||||
const resourceName = process.env.AZURE_RESOURCE_NAME
|
||||
// Azure requires either baseURL or resourceName to construct the endpoint
|
||||
// resourceName constructs: https://{resourceName}.openai.azure.com/openai/v1{path}
|
||||
if (baseURL || resourceName || overrides?.apiKey) {
|
||||
@@ -721,11 +678,8 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
|
||||
|
||||
case "openrouter": {
|
||||
const apiKey = overrides?.apiKey || process.env.OPENROUTER_API_KEY
|
||||
const baseURL = resolveBaseURL(
|
||||
overrides?.apiKey,
|
||||
overrides?.baseUrl,
|
||||
process.env.OPENROUTER_BASE_URL,
|
||||
)
|
||||
const baseURL =
|
||||
overrides?.baseUrl || process.env.OPENROUTER_BASE_URL
|
||||
const openrouter = createOpenRouter({
|
||||
apiKey,
|
||||
...(baseURL && { baseURL }),
|
||||
@@ -736,11 +690,7 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
|
||||
|
||||
case "deepseek": {
|
||||
const apiKey = overrides?.apiKey || process.env.DEEPSEEK_API_KEY
|
||||
const baseURL = resolveBaseURL(
|
||||
overrides?.apiKey,
|
||||
overrides?.baseUrl,
|
||||
process.env.DEEPSEEK_BASE_URL,
|
||||
)
|
||||
const baseURL = overrides?.baseUrl || process.env.DEEPSEEK_BASE_URL
|
||||
if (baseURL || overrides?.apiKey) {
|
||||
const customDeepSeek = createDeepSeek({
|
||||
apiKey,
|
||||
@@ -755,12 +705,10 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
|
||||
|
||||
case "siliconflow": {
|
||||
const apiKey = overrides?.apiKey || process.env.SILICONFLOW_API_KEY
|
||||
const baseURL = resolveBaseURL(
|
||||
overrides?.apiKey,
|
||||
overrides?.baseUrl,
|
||||
process.env.SILICONFLOW_BASE_URL,
|
||||
"https://api.siliconflow.cn/v1",
|
||||
)
|
||||
const baseURL =
|
||||
overrides?.baseUrl ||
|
||||
process.env.SILICONFLOW_BASE_URL ||
|
||||
"https://api.siliconflow.cn/v1"
|
||||
const siliconflowProvider = createOpenAI({
|
||||
apiKey,
|
||||
baseURL,
|
||||
@@ -771,15 +719,11 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
|
||||
|
||||
case "sglang": {
|
||||
const apiKey = overrides?.apiKey || process.env.SGLANG_API_KEY
|
||||
const baseURL = resolveBaseURL(
|
||||
overrides?.apiKey,
|
||||
overrides?.baseUrl,
|
||||
process.env.SGLANG_BASE_URL,
|
||||
)
|
||||
const baseURL = overrides?.baseUrl || process.env.SGLANG_BASE_URL
|
||||
|
||||
const sglangProvider = createOpenAI({
|
||||
apiKey,
|
||||
...(baseURL && { baseURL }),
|
||||
baseURL,
|
||||
// Add a custom fetch wrapper to intercept and fix the stream from sglang
|
||||
fetch: async (url, options) => {
|
||||
const response = await fetch(url, options)
|
||||
@@ -884,11 +828,8 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
|
||||
// Model format: "provider/model" e.g., "openai/gpt-4o", "anthropic/claude-sonnet-4-5"
|
||||
// See: https://vercel.com/ai-gateway
|
||||
const apiKey = overrides?.apiKey || process.env.AI_GATEWAY_API_KEY
|
||||
const baseURL = resolveBaseURL(
|
||||
overrides?.apiKey,
|
||||
overrides?.baseUrl,
|
||||
process.env.AI_GATEWAY_BASE_URL,
|
||||
)
|
||||
const baseURL =
|
||||
overrides?.baseUrl || process.env.AI_GATEWAY_BASE_URL
|
||||
// Only use custom configuration if explicitly set (local dev or custom Gateway)
|
||||
// Otherwise undefined → AI SDK uses Vercel default (https://ai-gateway.vercel.sh/v1/ai) + OIDC
|
||||
if (baseURL || overrides?.apiKey) {
|
||||
@@ -920,12 +861,10 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
|
||||
|
||||
case "doubao": {
|
||||
const apiKey = overrides?.apiKey || process.env.DOUBAO_API_KEY
|
||||
const baseURL = resolveBaseURL(
|
||||
overrides?.apiKey,
|
||||
overrides?.baseUrl,
|
||||
process.env.DOUBAO_BASE_URL,
|
||||
"https://ark.cn-beijing.volces.com/api/v3",
|
||||
)
|
||||
const baseURL =
|
||||
overrides?.baseUrl ||
|
||||
process.env.DOUBAO_BASE_URL ||
|
||||
"https://ark.cn-beijing.volces.com/api/v3"
|
||||
const lowerModelId = modelId.toLowerCase()
|
||||
// Use DeepSeek provider for DeepSeek/Kimi models, OpenAI for others (multimodal support)
|
||||
if (
|
||||
@@ -949,12 +888,10 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
|
||||
|
||||
case "modelscope": {
|
||||
const apiKey = overrides?.apiKey || process.env.MODELSCOPE_API_KEY
|
||||
const baseURL = resolveBaseURL(
|
||||
overrides?.apiKey,
|
||||
overrides?.baseUrl,
|
||||
process.env.MODELSCOPE_BASE_URL,
|
||||
"https://api-inference.modelscope.cn/v1",
|
||||
)
|
||||
const baseURL =
|
||||
overrides?.baseUrl ||
|
||||
process.env.MODELSCOPE_BASE_URL ||
|
||||
"https://api-inference.modelscope.cn/v1"
|
||||
const modelscopeProvider = createOpenAI({
|
||||
apiKey,
|
||||
baseURL,
|
||||
|
||||
@@ -100,12 +100,10 @@
|
||||
"switchTo": "Switch to",
|
||||
"minimal": "Minimal",
|
||||
"sketch": "Sketch",
|
||||
"closeProtection": "Close Protection",
|
||||
"closeProtectionDescription": "Show confirmation when leaving the page.",
|
||||
"diagramStyle": "Diagram Style",
|
||||
"diagramStyleDescription": "Toggle between minimal and styled diagram output.",
|
||||
"sendShortcut": "Send Shortcut",
|
||||
"sendShortcutDescription": "Choose how to send messages.",
|
||||
"enterToSend": "Enter to send",
|
||||
"ctrlEnterToSend": "Cmd/Ctrl+Enter to send",
|
||||
"diagramActions": "Diagram Actions",
|
||||
"diagramActionsDescription": "Manage diagram history and exports",
|
||||
"history": "History",
|
||||
|
||||
@@ -100,12 +100,10 @@
|
||||
"switchTo": "切り替え",
|
||||
"minimal": "ミニマル",
|
||||
"sketch": "スケッチ",
|
||||
"closeProtection": "ページ離脱確認",
|
||||
"closeProtectionDescription": "ページを離れる際に確認を表示します。",
|
||||
"diagramStyle": "ダイアグラムスタイル",
|
||||
"diagramStyleDescription": "ミニマルとスタイル付きの出力を切り替えます。",
|
||||
"sendShortcut": "送信ショートカット",
|
||||
"sendShortcutDescription": "メッセージの送信方法を選択します。",
|
||||
"enterToSend": "Enterで送信",
|
||||
"ctrlEnterToSend": "Cmd/Ctrl+Enterで送信",
|
||||
"diagramActions": "ダイアグラム操作",
|
||||
"diagramActionsDescription": "ダイアグラムの履歴とエクスポートを管理",
|
||||
"history": "履歴",
|
||||
|
||||
@@ -100,12 +100,10 @@
|
||||
"switchTo": "切换到",
|
||||
"minimal": "简约",
|
||||
"sketch": "草图",
|
||||
"closeProtection": "关闭确认",
|
||||
"closeProtectionDescription": "离开页面时显示确认。",
|
||||
"diagramStyle": "图表样式",
|
||||
"diagramStyleDescription": "切换简约与精致图表输出模式。",
|
||||
"sendShortcut": "发送快捷键",
|
||||
"sendShortcutDescription": "选择发送消息的方式。",
|
||||
"enterToSend": "回车发送",
|
||||
"ctrlEnterToSend": "Cmd/Ctrl+回车发送",
|
||||
"diagramActions": "图表操作",
|
||||
"diagramActionsDescription": "管理图表历史记录和导出",
|
||||
"history": "历史记录",
|
||||
|
||||
@@ -12,6 +12,7 @@ export const STORAGE_KEYS = {
|
||||
|
||||
// Settings
|
||||
accessCode: "next-ai-draw-io-access-code",
|
||||
closeProtection: "next-ai-draw-io-close-protection",
|
||||
accessCodeRequired: "next-ai-draw-io-access-code-required",
|
||||
aiProvider: "next-ai-draw-io-ai-provider",
|
||||
aiBaseUrl: "next-ai-draw-io-ai-base-url",
|
||||
@@ -21,7 +22,4 @@ export const STORAGE_KEYS = {
|
||||
// Multi-model configuration
|
||||
modelConfigs: "next-ai-draw-io-model-configs",
|
||||
selectedModelId: "next-ai-draw-io-selected-model-id",
|
||||
|
||||
// Chat input preferences
|
||||
sendShortcut: "next-ai-draw-io-send-shortcut",
|
||||
} as const
|
||||
|
||||
@@ -1,141 +1,5 @@
|
||||
import { describe, expect, it } from "vitest"
|
||||
import {
|
||||
resolveBaseURL,
|
||||
supportsImageInput,
|
||||
supportsPromptCaching,
|
||||
} from "@/lib/ai-providers"
|
||||
|
||||
describe("resolveBaseURL", () => {
|
||||
const SERVER_BASE_URL = "https://server-proxy.example.com"
|
||||
const USER_BASE_URL = "https://user-proxy.example.com"
|
||||
const DEFAULT_BASE_URL = "https://api.provider.com/v1"
|
||||
const USER_API_KEY = "user-api-key-123"
|
||||
|
||||
describe("when user provides their own API key", () => {
|
||||
it("uses user's baseUrl when provided", () => {
|
||||
const result = resolveBaseURL(
|
||||
USER_API_KEY,
|
||||
USER_BASE_URL,
|
||||
SERVER_BASE_URL,
|
||||
DEFAULT_BASE_URL,
|
||||
)
|
||||
expect(result).toBe(USER_BASE_URL)
|
||||
})
|
||||
|
||||
it("uses default baseUrl when user provides no baseUrl", () => {
|
||||
const result = resolveBaseURL(
|
||||
USER_API_KEY,
|
||||
null,
|
||||
SERVER_BASE_URL,
|
||||
DEFAULT_BASE_URL,
|
||||
)
|
||||
expect(result).toBe(DEFAULT_BASE_URL)
|
||||
})
|
||||
|
||||
it("returns undefined when user provides no baseUrl and no default exists", () => {
|
||||
const result = resolveBaseURL(
|
||||
USER_API_KEY,
|
||||
null,
|
||||
SERVER_BASE_URL,
|
||||
undefined,
|
||||
)
|
||||
expect(result).toBeUndefined()
|
||||
})
|
||||
|
||||
it("does NOT use server's baseUrl even when available", () => {
|
||||
const result = resolveBaseURL(
|
||||
USER_API_KEY,
|
||||
undefined,
|
||||
SERVER_BASE_URL,
|
||||
undefined,
|
||||
)
|
||||
// Should NOT return SERVER_BASE_URL
|
||||
expect(result).not.toBe(SERVER_BASE_URL)
|
||||
expect(result).toBeUndefined()
|
||||
})
|
||||
|
||||
it("prefers user's baseUrl over default", () => {
|
||||
const result = resolveBaseURL(
|
||||
USER_API_KEY,
|
||||
USER_BASE_URL,
|
||||
SERVER_BASE_URL,
|
||||
DEFAULT_BASE_URL,
|
||||
)
|
||||
expect(result).toBe(USER_BASE_URL)
|
||||
})
|
||||
})
|
||||
|
||||
describe("when using server credentials (no user API key)", () => {
|
||||
it("uses user's baseUrl when provided (overrides server)", () => {
|
||||
const result = resolveBaseURL(
|
||||
null,
|
||||
USER_BASE_URL,
|
||||
SERVER_BASE_URL,
|
||||
DEFAULT_BASE_URL,
|
||||
)
|
||||
expect(result).toBe(USER_BASE_URL)
|
||||
})
|
||||
|
||||
it("falls back to server's baseUrl when no user baseUrl", () => {
|
||||
const result = resolveBaseURL(
|
||||
null,
|
||||
null,
|
||||
SERVER_BASE_URL,
|
||||
DEFAULT_BASE_URL,
|
||||
)
|
||||
expect(result).toBe(SERVER_BASE_URL)
|
||||
})
|
||||
|
||||
it("falls back to default when no user or server baseUrl", () => {
|
||||
const result = resolveBaseURL(
|
||||
null,
|
||||
null,
|
||||
undefined,
|
||||
DEFAULT_BASE_URL,
|
||||
)
|
||||
expect(result).toBe(DEFAULT_BASE_URL)
|
||||
})
|
||||
|
||||
it("returns undefined when no baseUrl available anywhere", () => {
|
||||
const result = resolveBaseURL(null, null, undefined, undefined)
|
||||
expect(result).toBeUndefined()
|
||||
})
|
||||
|
||||
it("handles undefined apiKey same as null", () => {
|
||||
const result = resolveBaseURL(
|
||||
undefined,
|
||||
null,
|
||||
SERVER_BASE_URL,
|
||||
DEFAULT_BASE_URL,
|
||||
)
|
||||
expect(result).toBe(SERVER_BASE_URL)
|
||||
})
|
||||
})
|
||||
|
||||
describe("edge cases", () => {
|
||||
it("handles empty string apiKey as falsy (uses server config)", () => {
|
||||
const result = resolveBaseURL(
|
||||
"",
|
||||
null,
|
||||
SERVER_BASE_URL,
|
||||
DEFAULT_BASE_URL,
|
||||
)
|
||||
// Empty string is falsy, so should use server config
|
||||
expect(result).toBe(SERVER_BASE_URL)
|
||||
})
|
||||
|
||||
it("handles empty string baseUrl as falsy", () => {
|
||||
const result = resolveBaseURL(
|
||||
USER_API_KEY,
|
||||
"",
|
||||
SERVER_BASE_URL,
|
||||
DEFAULT_BASE_URL,
|
||||
)
|
||||
// Empty string baseUrl is falsy, should fall back to default
|
||||
expect(result).toBe(DEFAULT_BASE_URL)
|
||||
})
|
||||
})
|
||||
})
|
||||
import { supportsImageInput, supportsPromptCaching } from "@/lib/ai-providers"
|
||||
|
||||
describe("supportsPromptCaching", () => {
|
||||
it("returns true for Claude models", () => {
|
||||
|
||||
Reference in New Issue
Block a user