Compare commits

...

11 Commits

Author SHA1 Message Date
copilot-swe-agent[bot]
2b5de8bbae Initial plan 2026-01-13 14:04:29 +00:00
dayuan.jiang
38a247c55d refactor: use EventAutoSave type for type safety 2026-01-13 23:02:19 +09:00
dayuan.jiang
a0e3130ac2 fix: enable autosave to sync user modifications in draw.io
Previously, user modifications made directly in draw.io (moving nodes,
editing text, etc.) were not captured by React state until an explicit
export was triggered.

This adds the autosave feature from react-drawio:
- Enable autosave={true} on DrawIoEmbed component
- Add onAutoSave callback to update chartXML on every change
- Keep React state in sync with draw.io editor state
2026-01-13 22:54:02 +09:00
Dayuan Jiang
6bd26c8bbd Merge pull request #578 from DayuanJiang/fix/user-apikey-baseurl-isolation
fix: prevent user API keys from using server's baseURL
2026-01-13 22:26:12 +09:00
dayuan.jiang
d2e51f159f refactor: use resolveBaseURL utility in all providers
Refactored all 11 providers to use the resolveBaseURL() utility function
instead of inline ternary expressions. This ensures:

1. The security fix is centralized in one testable function
2. Unit tests actually validate the production code path
3. Future changes only need to modify one location

Providers refactored: openai, anthropic, google, azure, openrouter,
deepseek, siliconflow, sglang, gateway, doubao, modelscope
2026-01-13 22:21:22 +09:00
dayuan.jiang
9677737745 test: add unit tests for baseURL isolation logic
Add comprehensive tests for the resolveBaseURL utility function:
- Tests for user-provided API key scenarios
- Tests for server credential scenarios
- Edge case tests for empty strings and undefined values

This addresses the Copilot review suggestion to add test coverage
for the critical security fix.
2026-01-13 22:14:45 +09:00
dayuan.jiang
d5774b336c fix: prevent user API keys from using server's baseURL
When users provide their own API key but not a custom baseURL,
the code was incorrectly falling back to the server's environment
variable for baseURL. This caused user API keys to be sent to
the server's custom proxy endpoint instead of the provider's
official endpoint, resulting in 'API key format incorrect' errors.

This fix ensures that when a user provides their own API key:
- Only the user's baseUrl is used (if provided)
- Otherwise, the provider's official/default endpoint is used
- Server's baseURL env vars are never mixed with user credentials

Affected providers: openai, anthropic, google, azure, openrouter,
deepseek, siliconflow, sglang, gateway, doubao, modelscope

Also fixes Azure's resourceName to not leak server config to user keys.

Fixes #577
2026-01-13 22:03:41 +09:00
Dayuan Jiang
0d79487b6c Merge pull request #573 from danqzq/enhance/remove-close-protection
enhance: remove close protection
2026-01-11 18:09:58 +09:00
danqzq
fbce1baf16 Remove Close Protection settings from language dictionaries 2026-01-10 22:47:03 -05:00
danqzq
c3d3afc202 Remove redundant Close Protection setting 2026-01-10 22:45:52 -05:00
Dayuan Jiang
651238529a Merge pull request #570 from DayuanJiang/chore/add-opencode-to-gitignore
chore: add opencode.json to gitignore
2026-01-11 11:05:45 +09:00
10 changed files with 246 additions and 94 deletions

View File

@@ -4,7 +4,6 @@ import { Suspense, useCallback, useEffect, useRef, useState } from "react"
import { DrawIoEmbed } from "react-drawio" import { DrawIoEmbed } from "react-drawio"
import type { ImperativePanelHandle } from "react-resizable-panels" import type { ImperativePanelHandle } from "react-resizable-panels"
import ChatPanel from "@/components/chat-panel" import ChatPanel from "@/components/chat-panel"
import { STORAGE_CLOSE_PROTECTION_KEY } from "@/components/settings-dialog"
import { import {
ResizableHandle, ResizableHandle,
ResizablePanel, ResizablePanel,
@@ -17,8 +16,13 @@ const drawioBaseUrl =
process.env.NEXT_PUBLIC_DRAWIO_BASE_URL || "https://embed.diagrams.net" process.env.NEXT_PUBLIC_DRAWIO_BASE_URL || "https://embed.diagrams.net"
export default function Home() { export default function Home() {
const { drawioRef, handleDiagramExport, onDrawioLoad, resetDrawioReady } = const {
useDiagram() drawioRef,
handleDiagramExport,
handleAutoSave,
onDrawioLoad,
resetDrawioReady,
} = useDiagram()
const router = useRouter() const router = useRouter()
const pathname = usePathname() const pathname = usePathname()
// Extract current language from pathname (e.g., "/zh/about" → "zh") // Extract current language from pathname (e.g., "/zh/about" → "zh")
@@ -29,7 +33,6 @@ export default function Home() {
const [darkMode, setDarkMode] = useState(false) const [darkMode, setDarkMode] = useState(false)
const [isLoaded, setIsLoaded] = useState(false) const [isLoaded, setIsLoaded] = useState(false)
const [isDrawioReady, setIsDrawioReady] = useState(false) const [isDrawioReady, setIsDrawioReady] = useState(false)
const [closeProtection, setCloseProtection] = useState(false)
const chatPanelRef = useRef<ImperativePanelHandle>(null) const chatPanelRef = useRef<ImperativePanelHandle>(null)
const isMobileRef = useRef(false) const isMobileRef = useRef(false)
@@ -66,13 +69,6 @@ export default function Home() {
document.documentElement.classList.toggle("dark", prefersDark) document.documentElement.classList.toggle("dark", prefersDark)
} }
const savedCloseProtection = localStorage.getItem(
STORAGE_CLOSE_PROTECTION_KEY,
)
if (savedCloseProtection === "true") {
setCloseProtection(true)
}
setIsLoaded(true) setIsLoaded(true)
}, [pathname, router]) }, [pathname, router])
@@ -146,20 +142,6 @@ export default function Home() {
return () => window.removeEventListener("keydown", handleKeyDown) 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 ( return (
<div className="h-screen bg-background relative overflow-hidden"> <div className="h-screen bg-background relative overflow-hidden">
<ResizablePanelGroup <ResizablePanelGroup
@@ -187,6 +169,8 @@ export default function Home() {
ref={drawioRef} ref={drawioRef}
onExport={handleDiagramExport} onExport={handleDiagramExport}
onLoad={handleDrawioLoad} onLoad={handleDrawioLoad}
onAutoSave={handleAutoSave}
autosave={true}
baseUrl={drawioBaseUrl} baseUrl={drawioBaseUrl}
urlParameters={{ urlParameters={{
ui: drawioUi, ui: drawioUi,
@@ -243,7 +227,6 @@ export default function Home() {
darkMode={darkMode} darkMode={darkMode}
onToggleDarkMode={handleDarkModeChange} onToggleDarkMode={handleDarkModeChange}
isMobile={isMobile} isMobile={isMobile}
onCloseProtectionChange={setCloseProtection}
/> />
</Suspense> </Suspense>
</div> </div>

View File

@@ -70,7 +70,6 @@ interface ChatPanelProps {
darkMode: boolean darkMode: boolean
onToggleDarkMode: () => void onToggleDarkMode: () => void
isMobile?: boolean isMobile?: boolean
onCloseProtectionChange?: (enabled: boolean) => void
} }
// Constants for tool states // Constants for tool states
@@ -111,7 +110,6 @@ export default function ChatPanel({
darkMode, darkMode,
onToggleDarkMode, onToggleDarkMode,
isMobile = false, isMobile = false,
onCloseProtectionChange,
}: ChatPanelProps) { }: ChatPanelProps) {
const { const {
loadDiagram: onDisplayChart, loadDiagram: onDisplayChart,
@@ -1296,7 +1294,6 @@ export default function ChatPanel({
<SettingsDialog <SettingsDialog
open={showSettingsDialog} open={showSettingsDialog}
onOpenChange={setShowSettingsDialog} onOpenChange={setShowSettingsDialog}
onCloseProtectionChange={onCloseProtectionChange}
drawioUi={drawioUi} drawioUi={drawioUi}
onToggleDrawioUi={onToggleDrawioUi} onToggleDrawioUi={onToggleDrawioUi}
darkMode={darkMode} darkMode={darkMode}

View File

@@ -61,7 +61,6 @@ const LANGUAGE_LABELS: Record<Locale, string> = {
interface SettingsDialogProps { interface SettingsDialogProps {
open: boolean open: boolean
onOpenChange: (open: boolean) => void onOpenChange: (open: boolean) => void
onCloseProtectionChange?: (enabled: boolean) => void
drawioUi: "min" | "sketch" drawioUi: "min" | "sketch"
onToggleDrawioUi: () => void onToggleDrawioUi: () => void
darkMode: boolean darkMode: boolean
@@ -71,7 +70,6 @@ interface SettingsDialogProps {
} }
export const STORAGE_ACCESS_CODE_KEY = "next-ai-draw-io-access-code" 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" const STORAGE_ACCESS_CODE_REQUIRED_KEY = "next-ai-draw-io-access-code-required"
function getStoredAccessCodeRequired(): boolean | null { function getStoredAccessCodeRequired(): boolean | null {
@@ -84,7 +82,6 @@ function getStoredAccessCodeRequired(): boolean | null {
function SettingsContent({ function SettingsContent({
open, open,
onOpenChange, onOpenChange,
onCloseProtectionChange,
drawioUi, drawioUi,
onToggleDrawioUi, onToggleDrawioUi,
darkMode, darkMode,
@@ -97,7 +94,6 @@ function SettingsContent({
const pathname = usePathname() || "/" const pathname = usePathname() || "/"
const search = useSearchParams() const search = useSearchParams()
const [accessCode, setAccessCode] = useState("") const [accessCode, setAccessCode] = useState("")
const [closeProtection, setCloseProtection] = useState(true)
const [isVerifying, setIsVerifying] = useState(false) const [isVerifying, setIsVerifying] = useState(false)
const [error, setError] = useState("") const [error, setError] = useState("")
const [accessCodeRequired, setAccessCodeRequired] = useState( const [accessCodeRequired, setAccessCodeRequired] = useState(
@@ -151,12 +147,6 @@ function SettingsContent({
localStorage.getItem(STORAGE_ACCESS_CODE_KEY) || "" localStorage.getItem(STORAGE_ACCESS_CODE_KEY) || ""
setAccessCode(storedCode) setAccessCode(storedCode)
const storedCloseProtection = localStorage.getItem(
STORAGE_CLOSE_PROTECTION_KEY,
)
// Default to true if not set
setCloseProtection(storedCloseProtection !== "false")
const storedSendShortcut = localStorage.getItem( const storedSendShortcut = localStorage.getItem(
STORAGE_KEYS.sendShortcut, STORAGE_KEYS.sendShortcut,
) )
@@ -394,25 +384,6 @@ function SettingsContent({
</Button> </Button>
</SettingItem> </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 */} {/* Diagram Style */}
<SettingItem <SettingItem
label={dict.settings.diagramStyle} label={dict.settings.diagramStyle}

View File

@@ -2,7 +2,7 @@
import type React from "react" import type React from "react"
import { createContext, useContext, useEffect, useRef, useState } from "react" import { createContext, useContext, useEffect, useRef, useState } from "react"
import type { DrawIoEmbedRef } from "react-drawio" import type { DrawIoEmbedRef, EventAutoSave } from "react-drawio"
import { toast } from "sonner" import { toast } from "sonner"
import type { ExportFormat } from "@/components/save-dialog" import type { ExportFormat } from "@/components/save-dialog"
import { getApiEndpoint } from "@/lib/base-path" import { getApiEndpoint } from "@/lib/base-path"
@@ -23,6 +23,7 @@ interface DiagramContextType {
resolverRef: React.Ref<((value: string) => void) | null> resolverRef: React.Ref<((value: string) => void) | null>
drawioRef: React.Ref<DrawIoEmbedRef | null> drawioRef: React.Ref<DrawIoEmbedRef | null>
handleDiagramExport: (data: any) => void handleDiagramExport: (data: any) => void
handleAutoSave: (data: EventAutoSave) => void
clearDiagram: () => void clearDiagram: () => void
saveDiagramToFile: ( saveDiagramToFile: (
filename: string, filename: string,
@@ -226,6 +227,13 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
} }
} }
// Handle autosave events from draw.io - keeps chartXML in sync with user modifications
const handleAutoSave = (data: EventAutoSave) => {
if (data.xml) {
setChartXML(data.xml)
}
}
const clearDiagram = () => { const clearDiagram = () => {
const emptyDiagram = `<mxfile><diagram name="Page-1" id="page-1"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/></root></mxGraphModel></diagram></mxfile>` const emptyDiagram = `<mxfile><diagram name="Page-1" id="page-1"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/></root></mxGraphModel></diagram></mxfile>`
// Skip validation for trusted internal template (loadDiagram also sets chartXML) // Skip validation for trusted internal template (loadDiagram also sets chartXML)
@@ -350,6 +358,7 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
resolverRef, resolverRef,
drawioRef, drawioRef,
handleDiagramExport, handleDiagramExport,
handleAutoSave,
clearDiagram, clearDiagram,
saveDiagramToFile, saveDiagramToFile,
getThumbnailSvg, getThumbnailSvg,

View File

@@ -62,6 +62,32 @@ const ANTHROPIC_BETA_HEADERS = {
"anthropic-beta": "fine-grained-tool-streaming-2025-05-14", "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 * Safely parse integer from environment variable with validation
*/ */
@@ -595,7 +621,11 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
case "openai": { case "openai": {
const apiKey = overrides?.apiKey || process.env.OPENAI_API_KEY const apiKey = overrides?.apiKey || process.env.OPENAI_API_KEY
const baseURL = overrides?.baseUrl || process.env.OPENAI_BASE_URL const baseURL = resolveBaseURL(
overrides?.apiKey,
overrides?.baseUrl,
process.env.OPENAI_BASE_URL,
)
if (baseURL) { if (baseURL) {
// Custom base URL = third-party proxy, use Chat Completions API // Custom base URL = third-party proxy, use Chat Completions API
// for compatibility (most proxies don't support /responses endpoint) // for compatibility (most proxies don't support /responses endpoint)
@@ -614,10 +644,12 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
case "anthropic": { case "anthropic": {
const apiKey = overrides?.apiKey || process.env.ANTHROPIC_API_KEY const apiKey = overrides?.apiKey || process.env.ANTHROPIC_API_KEY
const baseURL = const baseURL = resolveBaseURL(
overrides?.baseUrl || overrides?.apiKey,
process.env.ANTHROPIC_BASE_URL || overrides?.baseUrl,
"https://api.anthropic.com/v1" process.env.ANTHROPIC_BASE_URL,
"https://api.anthropic.com/v1",
)
const customProvider = createAnthropic({ const customProvider = createAnthropic({
apiKey, apiKey,
baseURL, baseURL,
@@ -632,7 +664,11 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
case "google": { case "google": {
const apiKey = const apiKey =
overrides?.apiKey || process.env.GOOGLE_GENERATIVE_AI_API_KEY overrides?.apiKey || process.env.GOOGLE_GENERATIVE_AI_API_KEY
const baseURL = overrides?.baseUrl || process.env.GOOGLE_BASE_URL const baseURL = resolveBaseURL(
overrides?.apiKey,
overrides?.baseUrl,
process.env.GOOGLE_BASE_URL,
)
if (baseURL || overrides?.apiKey) { if (baseURL || overrides?.apiKey) {
const customGoogle = createGoogleGenerativeAI({ const customGoogle = createGoogleGenerativeAI({
apiKey, apiKey,
@@ -647,8 +683,15 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
case "azure": { case "azure": {
const apiKey = overrides?.apiKey || process.env.AZURE_API_KEY const apiKey = overrides?.apiKey || process.env.AZURE_API_KEY
const baseURL = overrides?.baseUrl || process.env.AZURE_BASE_URL const baseURL = resolveBaseURL(
const resourceName = process.env.AZURE_RESOURCE_NAME 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
// Azure requires either baseURL or resourceName to construct the endpoint // Azure requires either baseURL or resourceName to construct the endpoint
// resourceName constructs: https://{resourceName}.openai.azure.com/openai/v1{path} // resourceName constructs: https://{resourceName}.openai.azure.com/openai/v1{path}
if (baseURL || resourceName || overrides?.apiKey) { if (baseURL || resourceName || overrides?.apiKey) {
@@ -678,8 +721,11 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
case "openrouter": { case "openrouter": {
const apiKey = overrides?.apiKey || process.env.OPENROUTER_API_KEY const apiKey = overrides?.apiKey || process.env.OPENROUTER_API_KEY
const baseURL = const baseURL = resolveBaseURL(
overrides?.baseUrl || process.env.OPENROUTER_BASE_URL overrides?.apiKey,
overrides?.baseUrl,
process.env.OPENROUTER_BASE_URL,
)
const openrouter = createOpenRouter({ const openrouter = createOpenRouter({
apiKey, apiKey,
...(baseURL && { baseURL }), ...(baseURL && { baseURL }),
@@ -690,7 +736,11 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
case "deepseek": { case "deepseek": {
const apiKey = overrides?.apiKey || process.env.DEEPSEEK_API_KEY const apiKey = overrides?.apiKey || process.env.DEEPSEEK_API_KEY
const baseURL = overrides?.baseUrl || process.env.DEEPSEEK_BASE_URL const baseURL = resolveBaseURL(
overrides?.apiKey,
overrides?.baseUrl,
process.env.DEEPSEEK_BASE_URL,
)
if (baseURL || overrides?.apiKey) { if (baseURL || overrides?.apiKey) {
const customDeepSeek = createDeepSeek({ const customDeepSeek = createDeepSeek({
apiKey, apiKey,
@@ -705,10 +755,12 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
case "siliconflow": { case "siliconflow": {
const apiKey = overrides?.apiKey || process.env.SILICONFLOW_API_KEY const apiKey = overrides?.apiKey || process.env.SILICONFLOW_API_KEY
const baseURL = const baseURL = resolveBaseURL(
overrides?.baseUrl || overrides?.apiKey,
process.env.SILICONFLOW_BASE_URL || overrides?.baseUrl,
"https://api.siliconflow.cn/v1" process.env.SILICONFLOW_BASE_URL,
"https://api.siliconflow.cn/v1",
)
const siliconflowProvider = createOpenAI({ const siliconflowProvider = createOpenAI({
apiKey, apiKey,
baseURL, baseURL,
@@ -719,11 +771,15 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
case "sglang": { case "sglang": {
const apiKey = overrides?.apiKey || process.env.SGLANG_API_KEY const apiKey = overrides?.apiKey || process.env.SGLANG_API_KEY
const baseURL = overrides?.baseUrl || process.env.SGLANG_BASE_URL const baseURL = resolveBaseURL(
overrides?.apiKey,
overrides?.baseUrl,
process.env.SGLANG_BASE_URL,
)
const sglangProvider = createOpenAI({ const sglangProvider = createOpenAI({
apiKey, apiKey,
baseURL, ...(baseURL && { baseURL }),
// Add a custom fetch wrapper to intercept and fix the stream from sglang // Add a custom fetch wrapper to intercept and fix the stream from sglang
fetch: async (url, options) => { fetch: async (url, options) => {
const response = await fetch(url, options) const response = await fetch(url, options)
@@ -828,8 +884,11 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
// Model format: "provider/model" e.g., "openai/gpt-4o", "anthropic/claude-sonnet-4-5" // Model format: "provider/model" e.g., "openai/gpt-4o", "anthropic/claude-sonnet-4-5"
// See: https://vercel.com/ai-gateway // See: https://vercel.com/ai-gateway
const apiKey = overrides?.apiKey || process.env.AI_GATEWAY_API_KEY const apiKey = overrides?.apiKey || process.env.AI_GATEWAY_API_KEY
const baseURL = const baseURL = resolveBaseURL(
overrides?.baseUrl || process.env.AI_GATEWAY_BASE_URL overrides?.apiKey,
overrides?.baseUrl,
process.env.AI_GATEWAY_BASE_URL,
)
// Only use custom configuration if explicitly set (local dev or custom Gateway) // 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 // Otherwise undefined → AI SDK uses Vercel default (https://ai-gateway.vercel.sh/v1/ai) + OIDC
if (baseURL || overrides?.apiKey) { if (baseURL || overrides?.apiKey) {
@@ -861,10 +920,12 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
case "doubao": { case "doubao": {
const apiKey = overrides?.apiKey || process.env.DOUBAO_API_KEY const apiKey = overrides?.apiKey || process.env.DOUBAO_API_KEY
const baseURL = const baseURL = resolveBaseURL(
overrides?.baseUrl || overrides?.apiKey,
process.env.DOUBAO_BASE_URL || overrides?.baseUrl,
"https://ark.cn-beijing.volces.com/api/v3" process.env.DOUBAO_BASE_URL,
"https://ark.cn-beijing.volces.com/api/v3",
)
const lowerModelId = modelId.toLowerCase() const lowerModelId = modelId.toLowerCase()
// Use DeepSeek provider for DeepSeek/Kimi models, OpenAI for others (multimodal support) // Use DeepSeek provider for DeepSeek/Kimi models, OpenAI for others (multimodal support)
if ( if (
@@ -888,10 +949,12 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
case "modelscope": { case "modelscope": {
const apiKey = overrides?.apiKey || process.env.MODELSCOPE_API_KEY const apiKey = overrides?.apiKey || process.env.MODELSCOPE_API_KEY
const baseURL = const baseURL = resolveBaseURL(
overrides?.baseUrl || overrides?.apiKey,
process.env.MODELSCOPE_BASE_URL || overrides?.baseUrl,
"https://api-inference.modelscope.cn/v1" process.env.MODELSCOPE_BASE_URL,
"https://api-inference.modelscope.cn/v1",
)
const modelscopeProvider = createOpenAI({ const modelscopeProvider = createOpenAI({
apiKey, apiKey,
baseURL, baseURL,

View File

@@ -100,8 +100,6 @@
"switchTo": "Switch to", "switchTo": "Switch to",
"minimal": "Minimal", "minimal": "Minimal",
"sketch": "Sketch", "sketch": "Sketch",
"closeProtection": "Close Protection",
"closeProtectionDescription": "Show confirmation when leaving the page.",
"diagramStyle": "Diagram Style", "diagramStyle": "Diagram Style",
"diagramStyleDescription": "Toggle between minimal and styled diagram output.", "diagramStyleDescription": "Toggle between minimal and styled diagram output.",
"sendShortcut": "Send Shortcut", "sendShortcut": "Send Shortcut",

View File

@@ -100,8 +100,6 @@
"switchTo": "切り替え", "switchTo": "切り替え",
"minimal": "ミニマル", "minimal": "ミニマル",
"sketch": "スケッチ", "sketch": "スケッチ",
"closeProtection": "ページ離脱確認",
"closeProtectionDescription": "ページを離れる際に確認を表示します。",
"diagramStyle": "ダイアグラムスタイル", "diagramStyle": "ダイアグラムスタイル",
"diagramStyleDescription": "ミニマルとスタイル付きの出力を切り替えます。", "diagramStyleDescription": "ミニマルとスタイル付きの出力を切り替えます。",
"sendShortcut": "送信ショートカット", "sendShortcut": "送信ショートカット",

View File

@@ -100,8 +100,6 @@
"switchTo": "切换到", "switchTo": "切换到",
"minimal": "简约", "minimal": "简约",
"sketch": "草图", "sketch": "草图",
"closeProtection": "关闭确认",
"closeProtectionDescription": "离开页面时显示确认。",
"diagramStyle": "图表样式", "diagramStyle": "图表样式",
"diagramStyleDescription": "切换简约与精致图表输出模式。", "diagramStyleDescription": "切换简约与精致图表输出模式。",
"sendShortcut": "发送快捷键", "sendShortcut": "发送快捷键",

View File

@@ -12,7 +12,6 @@ export const STORAGE_KEYS = {
// Settings // Settings
accessCode: "next-ai-draw-io-access-code", accessCode: "next-ai-draw-io-access-code",
closeProtection: "next-ai-draw-io-close-protection",
accessCodeRequired: "next-ai-draw-io-access-code-required", accessCodeRequired: "next-ai-draw-io-access-code-required",
aiProvider: "next-ai-draw-io-ai-provider", aiProvider: "next-ai-draw-io-ai-provider",
aiBaseUrl: "next-ai-draw-io-ai-base-url", aiBaseUrl: "next-ai-draw-io-ai-base-url",

View File

@@ -1,5 +1,141 @@
import { describe, expect, it } from "vitest" import { describe, expect, it } from "vitest"
import { supportsImageInput, supportsPromptCaching } from "@/lib/ai-providers" 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)
})
})
})
describe("supportsPromptCaching", () => { describe("supportsPromptCaching", () => {
it("returns true for Claude models", () => { it("returns true for Claude models", () => {