1 line
43 KiB
JSON
1 line
43 KiB
JSON
{"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}"}}}} |