Files

1 line
43 KiB
JSON
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{"repo": "1047664386/minicode", "files": {".env": {"d261bce8903bdb77846ca6eac4cf7a064f13323c": {"t": 1785674418.592089, "n": 595, "h": "70c2e995fb5cb9bf", "c": "# miniCodeIde 云端 .env 示例 (开发用, prod 请通过 secret manager 注入)\n#\n# 复制成 .env: cp .env.example .env\n# 然后 cd infra && docker compose up -d\n# 再 pnpm --filter @mini/storage prisma migrate dev --name init\n\nDATABASE_URL=\"postgresql://mci:mci@localhost:5433/mci?schema=public\"\n\n# 切换存储后端: jsonl (默认, 桌面) / postgres (云端)\nSTORAGE_KIND=postgres\n\n# Cloud server\nPORT=4000\nJWT_SECRET=please-change-me-in-prod\nJWT_TTL_SECONDS=2592000 # 30 天\n\n# LLM keys (M5 之前先用平台 key)\nOPENAI_API_KEY=\nANTHROPIC_API_KEY=\n\n# 路线 A 新增\nLLM_BASE_URL=https://api1.deepseek.com/v1\nLLM_MODEL=deepseek-chat\n\n# 路线 D / M3 沙箱\nSANDBOX_KIND=mem"}}, "apps/server-cloud/src/llm/factory.ts": {"d261bce8903bdb77846ca6eac4cf7a064f13323c": {"t": 1785746037.6107733, "n": 2200, "h": "2401f9b34ec72a6c", "c": "\n/**\n * 网页版 LLM Provider 工厂\n *\n * 策略(与 Roadmap Q3 决策一致):\n * 1. 用户配了 BYOK(User.llmKeyEnc)→ 优先用,无 quota 限制\n * 2. 否则用平台 key(OPENAI_API_KEY / ANTHROPIC_API_KEY)→ 受 freeQuota 约束\n *\n * 当前 M2/A 阶段:单 provider(OpenAI 兼容),后续可扩展 routing。\n * 这里有意不抽象成 LLMRouter(apps/server 那个)—— 网页端用例更简单:\n * - 桌面端:用户在 settings 里配多个 profile,自动 fallback\n * - 网页端:平台兜底,用户只配自己一个 key(DeepSeek / OpenAI 二选一)\n */\nimport { OpenAICompatProvider, AnthropicProvider, isAnthropicEndpoint } from '@mini/core';\nimport type { LLMProvider } from '@mini/core';\nimport { decryptApiKey } from './crypto.js';\n\nexport interface UserLLMConfig {\n llmKeyEnc?: string | null;\n llmBaseUrl?: string | null;\n llmModel?: string | null;\n llmKind?: string | null;\n}\n\nexport interface ProviderResult {\n provider: LLMProvider;\n model: string;\n /** 是否用了用户自带 key(不计 quota) */\n byok: boolean;\n}\n\n/**\n * 选 provider:用户 BYOK 优先,否则平台 key\n */\nexport function pickProvider(user: UserLLMConfig): ProviderResult {\n // 1. 用户 BYOK\n if (user.llmKeyEnc) {\n const apiKey = decryptApiKey(user.llmKeyEnc);\n const baseURL = user.llmBaseUrl || 'https://api.deepseek.com/v1';\n const model = user.llmModel || 'deepseek-chat';\n const kind = user.llmKind ?? (isAnthropicEndpoint(baseURL) ? 'anthropic' : 'openai');\n return {\n provider: kind === 'anthropic'\n ? new AnthropicProvider({ baseURL, apiKey })\n : new OpenAICompatProvider({ baseURL, apiKey }),\n model,\n byok: true,\n };\n }\n\n // 2. 平台 key\n const platformKey = process.env.OPENAI_API_KEY || process.env.ANTHROPIC_API_KEY;\n if (!platformKey) {\n throw new Error(\n 'No LLM key configured. Set OPENAI_API_KEY/ANTHROPIC_API_KEY in env, ' +\n 'or have user configure BYOK via PATCH /api/me/api-key',\n );\n }\n const baseURL = process.env.LLM_BASE_URL || 'https://api.deepseek.com/v1';\n const model = process.env.LLM_MODEL || 'deepseek-chat';\n const kind = process.env.LLM_KIND ?? (isAnthropicEndpoint(baseURL) ? 'anthropic' : 'openai');\n return {\n provider: kind === 'anthropic'\n ? new AnthropicProvider({ baseURL, apiKey: platformKey })\n : new OpenAICompatProvider({ baseURL, apiKey: platformKey }),\n model,\n byok: false,\n };\n}"}, "860eef8738af012cfd48d227a3b008c26baa2365": {"t": 1785759096.1039166, "n": 2200, "h": "2401f9b34ec72a6c", "c": "\n/**\n * 网页版 LLM Provider 工厂\n *\n * 策略(与 Roadmap Q3 决策一致):\n * 1. 用户配了 BYOK(User.llmKeyEnc)→ 优先用,无 quota 限制\n * 2. 否则用平台 key(OPENAI_API_KEY / ANTHROPIC_API_KEY)→ 受 freeQuota 约束\n *\n * 当前 M2/A 阶段:单 provider(OpenAI 兼容),后续可扩展 routing。\n * 这里有意不抽象成 LLMRouter(apps/server 那个)—— 网页端用例更简单:\n * - 桌面端:用户在 settings 里配多个 profile,自动 fallback\n * - 网页端:平台兜底,用户只配自己一个 key(DeepSeek / OpenAI 二选一)\n */\nimport { OpenAICompatProvider, AnthropicProvider, isAnthropicEndpoint } from '@mini/core';\nimport type { LLMProvider } from '@mini/core';\nimport { decryptApiKey } from './crypto.js';\n\nexport interface UserLLMConfig {\n llmKeyEnc?: string | null;\n llmBaseUrl?: string | null;\n llmModel?: string | null;\n llmKind?: string | null;\n}\n\nexport interface ProviderResult {\n provider: LLMProvider;\n model: string;\n /** 是否用了用户自带 key(不计 quota) */\n byok: boolean;\n}\n\n/**\n * 选 provider:用户 BYOK 优先,否则平台 key\n */\nexport function pickProvider(user: UserLLMConfig): ProviderResult {\n // 1. 用户 BYOK\n if (user.llmKeyEnc) {\n const apiKey = decryptApiKey(user.llmKeyEnc);\n const baseURL = user.llmBaseUrl || 'https://api.deepseek.com/v1';\n const model = user.llmModel || 'deepseek-chat';\n const kind = user.llmKind ?? (isAnthropicEndpoint(baseURL) ? 'anthropic' : 'openai');\n return {\n provider: kind === 'anthropic'\n ? new AnthropicProvider({ baseURL, apiKey })\n : new OpenAICompatProvider({ baseURL, apiKey }),\n model,\n byok: true,\n };\n }\n\n // 2. 平台 key\n const platformKey = process.env.OPENAI_API_KEY || process.env.ANTHROPIC_API_KEY;\n if (!platformKey) {\n throw new Error(\n 'No LLM key configured. Set OPENAI_API_KEY/ANTHROPIC_API_KEY in env, ' +\n 'or have user configure BYOK via PATCH /api/me/api-key',\n );\n }\n const baseURL = process.env.LLM_BASE_URL || 'https://api.deepseek.com/v1';\n const model = process.env.LLM_MODEL || 'deepseek-chat';\n const kind = process.env.LLM_KIND ?? (isAnthropicEndpoint(baseURL) ? 'anthropic' : 'openai');\n return {\n provider: kind === 'anthropic'\n ? new AnthropicProvider({ baseURL, apiKey: platformKey })\n : new OpenAICompatProvider({ baseURL, apiKey: platformKey }),\n model,\n byok: false,\n };\n}"}}, "apps/desktop/src/components/SettingsPanel.tsx": {"d261bce8903bdb77846ca6eac4cf7a064f13323c": {"t": 1785809844.9868605, "n": 34794, "h": "cca92fdb27f1742e", "c": "\nimport { useEffect, useMemo, useState } from 'react';\nimport { useStore } from '../store';\n\ntype Kind = 'openai' | 'anthropic' | undefined;\n\ninterface ProfileView {\n id: string;\n name: string;\n kind?: Kind;\n baseUrl: string;\n apiKey: string; // masked\n model?: string;\n embedModel?: string;\n embedDim?: number;\n hash?: boolean;\n}\ninterface Cfg {\n profiles: ProfileView[];\n active: { chat?: string; complete?: string; embed?: string; fast?: string };\n fallbacks?: {\n chat?: string[];\n complete?: string[];\n embed?: string[];\n fast?: string[];\n };\n}\n\ninterface DraftState {\n id?: string;\n name: string;\n kind: 'auto' | 'openai' | 'anthropic';\n baseUrl: string;\n apiKey: string;\n model: string;\n embedModel: string;\n}\n\nconst EMPTY_DRAFT: DraftState = {\n name: '',\n kind: 'auto',\n baseUrl: '',\n apiKey: '',\n model: '',\n embedModel: '',\n};\n\n/**\n * Provider 预设大全。每个预设带:\n * - 默认 baseUrl / 默认 chat model\n * - 常用 chat model 列表(前端做 datalist 下拉补全)\n * - 是否提供 embedding(无则 UI 显示提示)\n */\ninterface Preset {\n id: string;\n label: string;\n vendor: string;\n icon: string;\n kind: 'openai' | 'anthropic';\n baseUrl: string;\n defaultModel: string;\n models: string[];\n defaultEmbedModel?: string;\n embedModels?: string[];\n signupUrl?: string;\n notes?: string;\n}\n\nconst PRESETS: Preset[] = [\n {\n id: 'deepseek',\n label: 'DeepSeek',\n vendor: 'DeepSeek',\n icon: '🔮',\n kind: 'openai',\n baseUrl: 'https://api.deepseek.com/v1',\n defaultModel: 'deepseek-chat',\n models: ['deepseek-chat', 'deepseek-reasoner', 'deepseek-coder'],\n signupUrl: 'https://platform.deepseek.com/api_keys',\n notes: '便宜 + 国内速度好;无 embedding,请用 OpenAI 或 hash fallback',\n },\n {\n id: 'tencent-lkeap',\n label: '腾讯云 LKEAP (DeepSeek)',\n vendor: 'Tencent Cloud',\n icon: '🐧',\n kind: 'openai',\n baseUrl: 'https://api.lkeap.cloud.tencent.com/v1',\n defaultModel: 'deepseek-v4',\n models: [\n 'deepseek-v4',\n 'deepseek-v4-pro',\n 'deepseek-v3.2-exp',\n 'deepseek-v3.1',\n 'deepseek-v3-0324',\n 'deepseek-v3',\n 'deepseek-r1',\n 'deepseek-r1-0528',\n 'deepseek-r1-distill-qwen-32b',\n ],\n signupUrl: 'https://console.cloud.tencent.com/lkeap/api',\n notes: '腾讯云知识引擎原子能力(LKEAP),托管满血 DeepSeek V3/R1;新用户每模型送 100w token,OpenAI 兼容协议',\n },\n {\n id: 'hunyuan',\n label: '腾讯混元 (Hunyuan)',\n vendor: 'Tencent Cloud',\n icon: '🐧',\n kind: 'openai',\n baseUrl: 'https://api.hunyuan.cloud.tencent.com/v1',\n defaultModel: 'hunyuan-turbos-latest',\n models: [\n 'hunyuan-turbos-latest',\n 'hunyuan-turbo',\n 'hunyuan-large',\n 'hunyuan-standard',\n 'hunyuan-standard-256K',\n 'hunyuan-lite',\n 'hunyuan-code',\n 'hunyuan-t1-latest',\n ],\n defaultEmbedModel: 'hunyuan-embedding',\n embedModels: ['hunyuan-embedding'],\n signupUrl: 'https://console.cloud.tencent.com/hunyuan/api-key',\n notes: '腾讯自研混元大模型,OpenAI 兼容;hunyuan-lite 永久免费',\n },\n {\n id: 'kimi',\n label: 'Kimi (Moonshot)',\n vendor: 'Moonshot',\n icon: '🌙',\n kind: 'openai',\n baseUrl: 'https://api.moonshot.cn/v1',\n defaultModel: 'moonshot-v1-32k',\n models: [\n 'moonshot-v1-8k',\n 'moonshot-v1-32k',\n 'moonshot-v1-128k',\n 'moonshot-v1-auto',\n 'kimi-k2-0711-preview',\n ],\n signupUrl: 'https://platform.moonshot.cn/console/api-keys',\n notes: '长上下文友好(128k);OpenAI 兼容协议',\n },\n {\n id: 'glm',\n label: 'GLM (智谱)',\n vendor: 'Zhipu',\n icon: '🐉',\n kind: 'openai',\n baseUrl: 'https://open.bigmodel.cn/api/paas/v4',\n defaultModel: 'glm-4-plus',\n models: [\n 'glm-4.6',\n 'glm-4-plus',\n 'glm-4-0520',\n 'glm-4-long',\n 'glm-4-air',\n 'glm-4-flash',\n 'glm-4v-plus',\n ],\n defaultEmbedModel: 'embedding-3',\n embedModels: ['embedding-2', 'embedding-3'],\n signupUrl: 'https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys',\n notes: '智谱 GLM,原生支持 OpenAI /v1 协议,自带 embedding-3',\n },\n {\n id: 'minimax',\n label: 'MiniMax',\n vendor: 'MiniMax',\n icon: '🧬',\n kind: 'openai',\n baseUrl: 'https://api.minimaxi.com/v1',\n defaultModel: 'MiniMax-Text-01',\n models: ['MiniMax-Text-01', 'abab6.5s-chat', 'abab6.5g-chat', 'abab6.5t-chat'],\n defaultEmbedModel: 'embo-01',\n embedModels: ['embo-01'],\n signupUrl: 'https://platform.minimaxi.com/user-center/basic-information/interface-key',\n notes: '海螺 AI 官方接口(注意:海外版用 minimax.io,国内版 minimaxi.com)',\n },\n {\n id: 'qwen',\n label: '通义千问 (Qwen)',\n vendor: 'Alibaba',\n icon: '☁️',\n kind: 'openai',\n baseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1',\n defaultModel: 'qwen-max',\n models: [\n 'qwen-max',\n 'qwen-max-latest',\n 'qwen-plus',\n 'qwen-turbo',\n 'qwen-long',\n 'qwen2.5-72b-instruct',\n 'qwen2.5-coder-32b-instruct',\n 'qwen3-235b-a22b',\n ],\n defaultEmbedModel: 'text-embedding-v3',\n embedModels: ['text-embedding-v1', 'text-embedding-v2', 'text-embedding-v3'],\n signupUrl: 'https://bailian.console.aliyun.com/?apiKey=1',\n notes: '阿里云灵积;使用 OpenAI 兼容端点(compatible-mode)',\n },\n {\n id: 'doubao',\n label: '豆包 (Doubao)',\n vendor: 'ByteDance Volc',\n icon: '🫘',\n kind: 'openai',\n baseUrl: 'https://ark.cn-beijing.volces.com/api/v3',\n defaultModel: '',\n models: [\n 'doubao-1-5-pro-32k-250115',\n 'doubao-1-5-pro-256k-250115',\n 'doubao-1-5-lite-32k-250115',\n 'doubao-pro-32k',\n 'doubao-pro-128k',\n 'doubao-1-5-thinking-pro-250415',\n ],\n signupUrl: 'https://console.volcengine.com/ark/region:ark+cn-beijing/apiKey',\n notes: '字节火山引擎;model 字段填的是「endpoint_id」(ep-xxx)或预置模型 ID',\n },\n {\n id: 'siliconflow',\n label: '硅基流动 (SiliconFlow)',\n vendor: 'SiliconFlow',\n icon: '🪐',\n kind: 'openai',\n baseUrl: 'https://api.siliconflow.cn/v1',\n defaultModel: 'Qwen/Qwen2.5-72B-Instruct',\n models: [\n 'Qwen/Qwen2.5-72B-Instruct',\n 'Qwen/Qwen2.5-Coder-32B-Instruct',\n 'deepseek-ai/DeepSeek-V3',\n 'deepseek-ai/DeepSeek-R1',\n 'meta-llama/Meta-Llama-3.1-405B-Instruct',\n 'THUDM/glm-4-9b-chat',\n ],\n defaultEmbedModel: 'BAAI/bge-large-zh-v1.5',\n embedModels: ['BAAI/bge-large-zh-v1.5', 'BAAI/bge-m3', 'netease-youdao/bce-embedding-base_v1'],\n signupUrl: 'https://cloud.siliconflow.cn/account/ak',\n notes: '聚合托管多家开源模型 + 嵌入;OpenAI 兼容;新用户送额度',\n },\n {\n id: 'openai',\n label: 'OpenAI',\n vendor: 'OpenAI',\n icon: '⚪',\n kind: 'openai',\n baseUrl: 'https://api.openai.com/v1',\n defaultModel: 'gpt-4o-mini',\n models: [\n 'gpt-4o',\n 'gpt-4o-mini',\n 'gpt-4-turbo',\n 'gpt-4.1',\n 'gpt-4.1-mini',\n 'o1',\n 'o1-mini',\n 'o3-mini',\n 'gpt-3.5-turbo',\n ],\n defaultEmbedModel: 'text-embedding-3-small',\n embedModels: ['text-embedding-3-small', 'text-embedding-3-large', 'text-embedding-ada-002'],\n signupUrl: 'https://platform.openai.com/api-keys',\n },\n {\n id: 'claude',\n label: 'Claude (Anthropic)',\n vendor: 'Anthropic',\n icon: '🟧',\n kind: 'anthropic',\n baseUrl: 'https://api.anthropic.com',\n defaultModel: 'claude-3-5-sonnet-20241022',\n models: [\n 'claude-3-5-sonnet-20241022',\n 'claude-3-5-sonnet-20240620',\n 'claude-3-5-haiku-20241022',\n 'claude-3-opus-20240229',\n 'claude-3-sonnet-20240229',\n 'claude-3-haiku-20240307',\n 'claude-sonnet-4-5',\n 'claude-opus-4-7',\n ],\n signupUrl: 'https://console.anthropic.com/settings/keys',\n notes: '走原生 /v1/messages;不提供 embedding',\n },\n {\n id: 'openrouter',\n label: 'OpenRouter',\n vendor: 'OpenRouter',\n icon: '🌐',\n kind: 'openai',\n baseUrl: 'https://openrouter.ai/api/v1',\n defaultModel: 'anthropic/claude-3.5-sonnet',\n models: [\n 'anthropic/claude-3.5-sonnet',\n 'anthropic/claude-3-opus',\n 'openai/gpt-4o',\n 'google/gemini-pro-1.5',\n 'meta-llama/llama-3.1-405b-instruct',\n 'deepseek/deepseek-chat',\n 'qwen/qwen-2.5-72b-instruct',\n ],\n signupUrl: 'https://openrouter.ai/keys',\n notes: '聚合 200+ 模型,可以一个 key 用所有',\n },\n {\n id: 'groq',\n label: 'Groq',\n vendor: 'Groq',\n icon: '⚡',\n kind: 'openai',\n baseUrl: 'https://api.groq.com/openai/v1',\n defaultModel: 'llama-3.3-70b-versatile',\n models: [\n 'llama-3.3-70b-versatile',\n 'llama-3.1-70b-versatile',\n 'llama-3.1-8b-instant',\n 'mixtral-8x7b-32768',\n 'gemma2-9b-it',\n ],\n signupUrl: 'https://console.groq.com/keys',\n notes: '主打超低延迟推理;OpenAI 兼容',\n },\n {\n id: 'mistral',\n label: 'Mistral',\n vendor: 'Mistral AI',\n icon: '🇫🇷',\n kind: 'openai',\n baseUrl: 'https://api.mistral.ai/v1',\n defaultModel: 'mistral-large-latest',\n models: [\n 'mistral-large-latest',\n 'mistral-medium-latest',\n 'mistral-small-latest',\n 'codestral-latest',\n 'open-mistral-nemo',\n ],\n defaultEmbedModel: 'mistral-embed',\n embedModels: ['mistral-embed'],\n signupUrl: 'https://console.mistral.ai/api-keys/',\n },\n {\n id: 'ollama',\n label: 'Ollama (本地)',\n vendor: 'Local',\n icon: '🦙',\n kind: 'openai',\n baseUrl: 'http://localhost:11434/v1',\n defaultModel: 'qwen2.5-coder:7b',\n models: [\n 'llama3.3',\n 'llama3.2',\n 'qwen2.5:7b',\n 'qwen2.5:14b',\n 'qwen2.5-coder:7b',\n 'qwen2.5-coder:32b',\n 'deepseek-r1:7b',\n 'deepseek-r1:32b',\n 'gemma2:9b',\n 'mistral-nemo',\n ],\n defaultEmbedModel: 'nomic-embed-text',\n embedModels: ['nomic-embed-text', 'mxbai-embed-large', 'bge-m3'],\n notes: '本地模型;apiKey 随便填一个非空字符串即可(Ollama 不校验)',\n },\n {\n id: 'custom',\n label: '自定义 (Custom)',\n vendor: 'Any',\n icon: '🛠',\n kind: 'openai',\n baseUrl: '',\n defaultModel: '',\n models: [],\n notes: '任何 OpenAI / Anthropic 兼容的端点都可手动填',\n },\n];\n\ntype Role = 'chat' | 'complete' | 'embed' | 'fast';\nconst ROLES: Role[] = ['chat', 'complete', 'embed', 'fast'];\nconst ROLE_DESC: Record<Role, string> = {\n chat: '对话 / Agent 主模型',\n complete: '补全(inline completion)',\n embed: 'Embedding(构建向量索引)',\n fast: '快速任务(commit msg / 路由器分流)',\n};\n\nexport function SettingsPanel({ onClose }: { onClose: () => void }) {\n const [cfg, setCfg] = useState<Cfg | null>(null);\n const [draft, setDraft] = useState<DraftState>(EMPTY_DRAFT);\n const [editingId, setEditingId] = useState<string | null>(null);\n const [selectedPreset, setSelectedPreset] = useState<string | null>(null);\n const [busy, setBusy] = useState(false);\n const [testResult, setTestResult] = useState<Record<string, string>>({});\n const [tab, setTab] = useState<'providers' | 'routing' | 'advanced'>('providers');\n const advancedSettings = useStore((s) => s.advancedSettings);\n const setAdvancedSettings = useStore((s) => s.setAdvancedSettings);\n\n const refresh = async () => {\n try {\n const r = await fetch('/api/providers');\n if (r.ok) {\n setCfg(await r.json());\n } else {\n // cloud server 不支持 /api/providers → 仅显示高级设置 tab\n setCfg({ profiles: [], active: {} });\n }\n } catch {\n setCfg({ profiles: [], active: {} });\n }\n };\n\n useEffect(() => {\n refresh();\n }, []);\n\n // 监听其他组件的 provider 变更\n useEffect(() => {\n const handler = () => refresh();\n window.addEventListener('providers-changed', handler);\n return () => window.removeEventListener('providers-changed', handler);\n }, []);\n\n // Esc 关闭\n useEffect(() => {\n const onKey = (e: KeyboardEvent) => {\n if (e.key === 'Escape') onClose();\n };\n window.addEventListener('keydown', onKey);\n return () => window.removeEventListener('keydown', onKey);\n }, [onClose]);\n\n const save = async () => {\n if (!draft.name || !draft.baseUrl) return;\n setBusy(true);\n try {\n await fetch('/api/providers', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({\n ...(editingId ? { id: editingId } : {}),\n name: draft.name,\n kind: draft.kind === 'auto' ? undefined : draft.kind,\n baseUrl: draft.baseUrl,\n apiKey: draft.apiKey || undefined,\n model: draft.model || undefined,\n embedModel: draft.embedModel || undefined,\n }),\n });\n setDraft(EMPTY_DRAFT);\n setEditingId(null);\n setSelectedPreset(null);\n await refresh();\n window.dispatchEvent(new Event('providers-changed'));\n } finally {\n setBusy(false);\n }\n };\n\n const remove = async (id: string) => {\n if (!confirm('Delete provider profile?')) return;\n setBusy(true);\n try {\n await fetch(`/api/providers/${id}`, { method: 'DELETE' });\n await refresh();\n window.dispatchEvent(new Event('providers-changed'));\n } finally {\n setBusy(false);\n }\n };\n\n const setActive = async (role: Role, id: string) => {\n setBusy(true);\n try {\n await fetch('/api/providers/active', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ role, id: id || null }),\n });\n await refresh();\n window.dispatchEvent(new Event('providers-changed'));\n } finally {\n setBusy(false);\n }\n };\n\n const setFallbacks = async (role: Role, ids: string[]) => {\n setBusy(true);\n try {\n await fetch('/api/providers/fallbacks', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ role, ids }),\n });\n await refresh();\n window.dispatchEvent(new Event('providers-changed'));\n } finally {\n setBusy(false);\n }\n };\n\n const startEdit = (p: ProfileView) => {\n setEditingId(p.id);\n // 找一下这条 profile 跟哪个预设最像(matching by baseUrl),把它的 model list 也带上\n const preset = PRESETS.find((x) => x.baseUrl && p.baseUrl && x.baseUrl === p.baseUrl);\n setSelectedPreset(preset?.id ?? 'custom');\n setDraft({\n id: p.id,\n name: p.name,\n kind: p.kind ?? 'auto',\n baseUrl: p.baseUrl,\n apiKey: '', // masked; user must re-enter to change\n model: p.model ?? '',\n embedModel: p.embedModel ?? '',\n });\n setTab('providers');\n };\n\n const applyPreset = (preset: Preset) => {\n setEditingId(null);\n setSelectedPreset(preset.id);\n setDraft({\n ...EMPTY_DRAFT,\n name: preset.label.replace(/\\s*\\(.*\\)$/, ''),\n kind: preset.kind,\n baseUrl: preset.baseUrl,\n model: preset.defaultModel,\n embedModel: preset.defaultEmbedModel ?? '',\n });\n };\n\n const cancelEdit = () => {\n setEditingId(null);\n setSelectedPreset(null);\n setDraft(EMPTY_DRAFT);\n };\n\n const testProfile = async (id: string) => {\n setTestResult((r) => ({ ...r, [id]: 'testing...' }));\n try {\n const resp = await fetch('/api/providers/test', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ id }),\n });\n const j = await resp.json();\n if (j.ok) {\n setTestResult((r) => ({\n ...r,\n [id]: `✓ ${j.firstTokenMs}ms first / ${j.totalMs}ms total`,\n }));\n } else {\n setTestResult((r) => ({ ...r, [id]: `✗ ${j.error?.slice(0, 80) ?? 'failed'}` }));\n }\n } catch (e: any) {\n setTestResult((r) => ({ ...r, [id]: `✗ ${e?.message ?? 'error'}` }));\n }\n };\n\n const detectedKind = useMemo(() => {\n if (draft.kind !== 'auto') return draft.kind;\n return /\\banthropic\\.com\\b/.test(draft.baseUrl) ? 'anthropic' : 'openai';\n }, [draft.kind, draft.baseUrl]);\n\n const currentPreset = useMemo(\n () => PRESETS.find((p) => p.id === selectedPreset),\n [selectedPreset],\n );\n\n return (\n <div className=\"settings-mask\" onClick={onClose}>\n <div className=\"settings-dialog\" onClick={(e) => e.stopPropagation()}>\n <header className=\"settings-head\">\n <div className=\"settings-title\">\n <span className=\"settings-icon\">⚙️</span>\n <h3>Settings</h3>\n <span className=\"settings-sub\">· LLM Providers</span>\n </div>\n <div className=\"settings-tabs\">\n <button\n className={tab === 'providers' ? 'active' : ''}\n onClick={() => setTab('providers')}\n >\n Providers\n </button>\n <button\n className={tab === 'routing' ? 'active' : ''}\n onClick={() => setTab('routing')}\n >\n Routing\n </button>\n <button\n className={tab === 'advanced' ? 'active' : ''}\n onClick={() => setTab('advanced')}\n >\n 高级\n </button>\n </div>\n <button className=\"settings-close\" onClick={onClose} title=\"Close (Esc)\">\n ×\n </button>\n </header>\n\n <div className=\"settings-body\">\n {tab === 'providers' ? (\n <>\n {/* 左:已有 profile + 添加面板,右:预设卡片 */}\n <div className=\"settings-pane settings-pane-left\">\n <section className=\"settings-section\">\n <div className=\"settings-section-head\">\n <h4>已配置的 Providers</h4>\n <span className=\"settings-count\">{cfg?.profiles.length ?? 0}</span>\n </div>\n <div className=\"profile-list\">\n {(!cfg || cfg.profiles.length === 0) && (\n <div className=\"settings-empty\">还没有配置任何 Provider,从右侧选一个预设开始 →</div>\n )}\n {cfg?.profiles.map((p) => {\n const kindLabel = p.hash\n ? 'fallback'\n : p.kind ??\n (/\\banthropic\\.com\\b/.test(p.baseUrl)\n ? 'anthropic (auto)'\n : 'openai (auto)');\n const isActive = Object.values(cfg.active).includes(p.id);\n return (\n <div\n key={p.id}\n className={`profile-card ${editingId === p.id ? 'editing' : ''} ${\n isActive ? 'is-active' : ''\n }`}\n >\n <div className=\"profile-card-head\">\n <div className=\"profile-card-title\">\n <span className=\"profile-card-name\">{p.name}</span>\n <span className={`badge badge-${p.hash ? 'fallback' : 'kind'}`}>\n {kindLabel}\n </span>\n {isActive && <span className=\"badge badge-active\">ACTIVE</span>}\n </div>\n {!p.hash && (\n <div className=\"profile-card-actions\">\n <button onClick={() => testProfile(p.id)} title=\"Test connectivity\">\n Test\n </button>\n <button onClick={() => startEdit(p)} title=\"Edit\">\n Edit\n </button>\n <button\n onClick={() => remove(p.id)}\n className=\"danger\"\n title=\"Delete\"\n >\n Del\n </button>\n </div>\n )}\n </div>\n <div className=\"profile-card-detail\">\n <span className=\"kv\">\n <span className=\"k\">URL</span>\n <span className=\"v\">{p.baseUrl || '(no url)'}</span>\n </span>\n <span className=\"kv\">\n <span className=\"k\">KEY</span>\n <span className=\"v\">{p.apiKey || '(none)'}</span>\n </span>\n <span className=\"kv\">\n <span className=\"k\">CHAT</span>\n <span className=\"v\">{p.model || '-'}</span>\n </span>\n <span className=\"kv\">\n <span className=\"k\">EMBED</span>\n <span className=\"v\">{p.embedModel || '-'}</span>\n </span>\n </div>\n {testResult[p.id] && (\n <div\n className={`profile-test ${\n testResult[p.id].startsWith('✓')\n ? 'ok'\n : testResult[p.id].startsWith('✗')\n ? 'err'\n : ''\n }`}\n >\n {testResult[p.id]}\n </div>\n )}\n </div>\n );\n })}\n </div>\n </section>\n\n <section className=\"settings-section\">\n <div className=\"settings-section-head\">\n <h4>{editingId ? `编辑 · ${draft.name}` : '添加 / 编辑 Provider'}</h4>\n {currentPreset && (\n <span className=\"settings-current-preset\">\n {currentPreset.icon} {currentPreset.label}\n </span>\n )}\n </div>\n\n {currentPreset?.notes && (\n <div className=\"settings-tip\">💡 {currentPreset.notes}</div>\n )}\n\n <div className=\"draft-form\">\n <Field label=\"Name\">\n <input\n value={draft.name}\n placeholder=\"e.g. DeepSeek Personal\"\n onChange={(e) => setDraft({ ...draft, name: e.target.value })}\n />\n </Field>\n\n <Field label=\"Protocol\">\n <select\n value={draft.kind}\n onChange={(e) =>\n setDraft({ ...draft, kind: e.target.value as DraftState['kind'] })\n }\n >\n <option value=\"auto\">Auto-detect (by baseUrl)</option>\n <option value=\"openai\">OpenAI-compatible (/v1/chat/completions)</option>\n <option value=\"anthropic\">Anthropic Messages (/v1/messages)</option>\n </select>\n <small className=\"hint\">→ resolved: {detectedKind}</small>\n </Field>\n\n <Field label=\"Base URL\">\n <input\n value={draft.baseUrl}\n placeholder=\"https://api.openai.com/v1\"\n onChange={(e) => setDraft({ ...draft, baseUrl: e.target.value })}\n />\n </Field>\n\n <Field label=\"API Key\">\n <input\n type=\"password\"\n value={draft.apiKey}\n placeholder={editingId ? '(unchanged unless you type)' : 'sk-...'}\n onChange={(e) => setDraft({ ...draft, apiKey: e.target.value })}\n />\n {currentPreset?.signupUrl && (\n <small className=\"hint\">\n 没 key?{' '}\n <a\n href={currentPreset.signupUrl}\n target=\"_blank\"\n rel=\"noreferrer\"\n className=\"link\"\n >\n 申请 →\n </a>\n </small>\n )}\n </Field>\n\n <Field label=\"Chat Model\">\n <input\n list={`models-${selectedPreset ?? 'none'}`}\n value={draft.model}\n placeholder=\"deepseek-chat / gpt-4o-mini / ...\"\n onChange={(e) => setDraft({ ...draft, model: e.target.value })}\n />\n {currentPreset && currentPreset.models.length > 0 && (\n <datalist id={`models-${selectedPreset}`}>\n {currentPreset.models.map((m) => (\n <option key={m} value={m} />\n ))}\n </datalist>\n )}\n {currentPreset && currentPreset.models.length > 0 && (\n <div className=\"chip-row\">\n {currentPreset.models.slice(0, 6).map((m) => (\n <button\n key={m}\n className={`chip ${draft.model === m ? 'active' : ''}`}\n onClick={() => setDraft({ ...draft, model: m })}\n >\n {m}\n </button>\n ))}\n </div>\n )}\n </Field>\n\n <Field label=\"Embed Model\">\n <input\n list={`embed-models-${selectedPreset ?? 'none'}`}\n value={draft.embedModel}\n placeholder=\"(可选;该 Provider 无 embedding 留空即可)\"\n onChange={(e) => setDraft({ ...draft, embedModel: e.target.value })}\n />\n {currentPreset?.embedModels && currentPreset.embedModels.length > 0 && (\n <datalist id={`embed-models-${selectedPreset}`}>\n {currentPreset.embedModels.map((m) => (\n <option key={m} value={m} />\n ))}\n </datalist>\n )}\n </Field>\n\n <div className=\"draft-actions\">\n <button\n className=\"primary\"\n onClick={save}\n disabled={busy || !draft.name || !draft.baseUrl}\n >\n {editingId ? '💾 保存修改' : '+ 添加 Provider'}\n </button>\n <button onClick={cancelEdit}>取消</button>\n </div>\n </div>\n </section>\n </div>\n\n <div className=\"settings-pane settings-pane-right\">\n <section className=\"settings-section\">\n <div className=\"settings-section-head\">\n <h4>选一个预设</h4>\n <span className=\"settings-hint-inline\">点击 → 自动填表单</span>\n </div>\n <div className=\"preset-grid\">\n {PRESETS.map((p) => (\n <button\n key={p.id}\n className={`preset-card ${selectedPreset === p.id ? 'selected' : ''}`}\n onClick={() => applyPreset(p)}\n >\n <div className=\"preset-icon\">{p.icon}</div>\n <div className=\"preset-label\">{p.label}</div>\n <div className=\"preset-vendor\">{p.vendor}</div>\n </button>\n ))}\n </div>\n </section>\n </div>\n </>\n ) : tab === 'routing' ? (\n <div className=\"settings-pane settings-pane-full\">\n <section className=\"settings-section\">\n <div className=\"settings-section-head\">\n <h4>角色路由 (Active + Fallback)</h4>\n </div>\n <p className=\"settings-hint\">\n 每个 role 指定一个主 Provider + 一组 fallback。当主 Provider 返回 429 / 5xx / 网络错误且\n 尚未流出任何 token 时,自动按顺序切到 fallback。\n </p>\n <div className=\"role-grid\">\n {ROLES.map((role) => (\n <RoleRow\n key={role}\n role={role}\n desc={ROLE_DESC[role]}\n cfg={cfg}\n busy={busy}\n onActive={(id) => setActive(role, id)}\n onFallbacks={(ids) => setFallbacks(role, ids)}\n />\n ))}\n </div>\n </section>\n </div>\n ) : (\n <div className=\"settings-pane settings-pane-full\">\n <section className=\"settings-section\">\n <div className=\"settings-section-head\">\n <h4>LLM 超时设置</h4>\n </div>\n <p className=\"settings-hint\">\n 控制 Agent 与 LLM API 之间的超时行为。超时触发后会自动走 backoff / fallback 机制。\n 值越大越不容易误杀慢请求,但检测断连也越慢。\n </p>\n <div className=\"advanced-form\">\n <label className=\"advanced-toggle\">\n <input\n type=\"checkbox\"\n checked={advancedSettings.noTimeout}\n onChange={(e) => setAdvancedSettings({ noTimeout: e.target.checked })}\n />\n <span className=\"advanced-toggle-label\">\n 不限制超时\n <small className=\"hint\">适合本地模型 / 特殊慢场景 / 批量预处理</small>\n </span>\n </label>\n\n <div className={`advanced-row ${advancedSettings.noTimeout ? 'disabled' : ''}`}>\n <span className=\"advanced-label\">首帧等待超时</span>\n <div className=\"advanced-input-group\">\n <input\n type=\"number\"\n min={10}\n max={1800}\n step={10}\n value={advancedSettings.fetchTimeoutSec}\n disabled={advancedSettings.noTimeout}\n onChange={(e) =>\n setAdvancedSettings({ fetchTimeoutSec: Math.max(0, Number(e.target.value) || 300) })\n }\n />\n <span className=\"advanced-unit\">秒</span>\n </div>\n <small className=\"hint\">API 多久没响应视为失败(默认 300)</small>\n </div>\n\n <div className={`advanced-row ${advancedSettings.noTimeout ? 'disabled' : ''}`}>\n <span className=\"advanced-label\">流中途空闲超时</span>\n <div className=\"advanced-input-group\">\n <input\n type=\"number\"\n min={10}\n max={600}\n step={10}\n value={advancedSettings.streamIdleTimeoutSec}\n disabled={advancedSettings.noTimeout}\n onChange={(e) =>\n setAdvancedSettings({ streamIdleTimeoutSec: Math.max(0, Number(e.target.value) || 120) })\n }\n />\n <span className=\"advanced-unit\">秒</span>\n </div>\n <small className=\"hint\">两个数据包之间最大等待(默认 120)</small>\n </div>\n </div>\n </section>\n </div>\n )}\n </div>\n </div>\n </div>\n );\n}\n\nfunction Field({ label, children }: { label: string; children: React.ReactNode }) {\n return (\n <label className=\"form-field\">\n <span className=\"form-field-label\">{label}</span>\n <div className=\"form-field-control\">{children}</div>\n </label>\n );\n}\n\n/** 单个 role 的「Primary + Fallback chain」编辑行 */\nfunction RoleRow({\n role,\n desc,\n cfg,\n busy,\n onActive,\n onFallbacks,\n}: {\n role: Role;\n desc: string;\n cfg: Cfg | null;\n busy: boolean;\n onActive: (id: string) => void;\n onFallbacks: (ids: string[]) => void;\n}) {\n const profiles = cfg?.profiles ?? [];\n const active = cfg?.active[role] ?? '';\n const fallbacks = cfg?.fallbacks?.[role] ?? [];\n const available = profiles.filter((p) => p.id !== active && !fallbacks.includes(p.id));\n\n return (\n <div className=\"role-card\">\n <div className=\"role-card-head\">\n <div className=\"role-name\">{role.toUpperCase()}</div>\n <div className=\"role-desc\">{desc}</div>\n </div>\n <div className=\"role-card-body\">\n <div className=\"role-row\">\n <span className=\"role-label\">primary</span>\n <select value={active} onChange={(e) => onActive(e.target.value)} disabled={busy}>\n <option value=\"\">(none)</option>\n {profiles.map((p) => (\n <option key={p.id} value={p.id}>\n {p.name}\n </option>\n ))}\n </select>\n </div>\n {fallbacks.length > 0 && (\n <div className=\"role-row\">\n <span className=\"role-label\">fallbacks</span>\n <div className=\"chip-row\">\n {fallbacks.map((id, idx) => {\n const p = profiles.find((x) => x.id === id);\n if (!p) return null;\n return (\n <span key={id} className=\"chip chip-fallback\">\n {idx + 1}. {p.name}\n <button\n className=\"chip-x\"\n onClick={() => onFallbacks(fallbacks.filter((x) => x !== id))}\n title=\"Remove\"\n >\n ×\n </button>\n </span>\n );\n })}\n </div>\n </div>\n )}\n {available.length > 0 && (\n <div className=\"role-row\">\n <span className=\"role-label\">+ add</span>\n <select\n value=\"\"\n onChange={(e) => {\n if (e.target.value) onFallbacks([...fallbacks, e.target.value]);\n }}\n disabled={busy}\n >\n <option value=\"\">(pick fallback…)</option>\n {available.map((p) => (\n <option key={p.id} value={p.id}>\n {p.name}\n </option>\n ))}\n </select>\n </div>\n )}\n </div>\n </div>\n );\n}"}}}}