diff --git a/web/backend/api/models.go b/web/backend/api/models.go index 6c8c8a8fc..cb57d6f2e 100644 --- a/web/backend/api/models.go +++ b/web/backend/api/models.go @@ -20,6 +20,7 @@ func (h *Handler) registerModelRoutes(mux *http.ServeMux) { } // modelResponse is the JSON structure returned for each model in the list. +// All ModelConfig fields are included so the frontend can display and edit them. type modelResponse struct { Index int `json:"index"` ModelName string `json:"model_name"` @@ -28,8 +29,16 @@ type modelResponse struct { APIKey string `json:"api_key"` Proxy string `json:"proxy,omitempty"` AuthMethod string `json:"auth_method,omitempty"` - Configured bool `json:"configured"` - IsDefault bool `json:"is_default"` + // Advanced fields + ConnectMode string `json:"connect_mode,omitempty"` + Workspace string `json:"workspace,omitempty"` + RPM int `json:"rpm,omitempty"` + MaxTokensField string `json:"max_tokens_field,omitempty"` + RequestTimeout int `json:"request_timeout,omitempty"` + ThinkingLevel string `json:"thinking_level,omitempty"` + // Meta + Configured bool `json:"configured"` + IsDefault bool `json:"is_default"` } // handleListModels returns all model_list entries with masked API keys. @@ -47,15 +56,21 @@ func (h *Handler) handleListModels(w http.ResponseWriter, r *http.Request) { models := make([]modelResponse, 0, len(cfg.ModelList)) for i, m := range cfg.ModelList { models = append(models, modelResponse{ - Index: i, - ModelName: m.ModelName, - Model: m.Model, - APIBase: m.APIBase, - APIKey: maskAPIKey(m.APIKey), - Proxy: m.Proxy, - AuthMethod: m.AuthMethod, - Configured: m.APIKey != "" || m.AuthMethod != "", - IsDefault: m.ModelName == defaultModel, + Index: i, + ModelName: m.ModelName, + Model: m.Model, + APIBase: m.APIBase, + APIKey: maskAPIKey(m.APIKey), + Proxy: m.Proxy, + AuthMethod: m.AuthMethod, + ConnectMode: m.ConnectMode, + Workspace: m.Workspace, + RPM: m.RPM, + MaxTokensField: m.MaxTokensField, + RequestTimeout: m.RequestTimeout, + ThinkingLevel: m.ThinkingLevel, + Configured: m.APIKey != "" || m.AuthMethod != "", + IsDefault: m.ModelName == defaultModel, }) } @@ -110,6 +125,9 @@ func (h *Handler) handleAddModel(w http.ResponseWriter, r *http.Request) { } // handleUpdateModel replaces a model configuration entry at the given index. +// If the request body omits api_key (or sends an empty string), the existing +// stored key is preserved so callers can update only api_base / proxy without +// exposing or clearing the secret. // // PUT /api/models/{index} func (h *Handler) handleUpdateModel(w http.ResponseWriter, r *http.Request) { @@ -148,6 +166,12 @@ func (h *Handler) handleUpdateModel(w http.ResponseWriter, r *http.Request) { return } + // Preserve the existing API key when the caller omits it (empty string). + // This lets the UI update api_base / proxy without clearing the stored secret. + if mc.APIKey == "" { + mc.APIKey = cfg.ModelList[idx].APIKey + } + cfg.ModelList[idx] = mc if err := config.SaveConfig(h.configPath, cfg); err != nil { @@ -180,8 +204,18 @@ func (h *Handler) handleDeleteModel(w http.ResponseWriter, r *http.Request) { return } + deletedModelName := cfg.ModelList[idx].ModelName + cfg.ModelList = append(cfg.ModelList[:idx], cfg.ModelList[idx+1:]...) + // If the deleted model was the default, clear it. + if cfg.Agents.Defaults.ModelName == deletedModelName { + cfg.Agents.Defaults.ModelName = "" + } + if cfg.Agents.Defaults.Model == deletedModelName { + cfg.Agents.Defaults.Model = "" + } + if err := config.SaveConfig(h.configPath, cfg); err != nil { http.Error(w, fmt.Sprintf("Failed to save config: %v", err), http.StatusInternalServerError) return diff --git a/web/frontend/src/api/models.ts b/web/frontend/src/api/models.ts index 0766be265..e83b0b255 100644 --- a/web/frontend/src/api/models.ts +++ b/web/frontend/src/api/models.ts @@ -8,6 +8,14 @@ export interface ModelInfo { api_key: string proxy?: string auth_method?: string + // Advanced fields + connect_mode?: string + workspace?: string + rpm?: number + max_tokens_field?: string + request_timeout?: number + thinking_level?: string + // Meta configured: boolean is_default: boolean } diff --git a/web/frontend/src/components/app-sidebar.tsx b/web/frontend/src/components/app-sidebar.tsx index ce94f9d3b..66c3318e2 100644 --- a/web/frontend/src/components/app-sidebar.tsx +++ b/web/frontend/src/components/app-sidebar.tsx @@ -45,7 +45,7 @@ const navGroups = [ ], }, { - label: "navigation.service", + label: "navigation.services", defaultOpen: true, items: [ { title: "navigation.config", url: "/config", icon: IconSettings }, diff --git a/web/frontend/src/hooks/use-gateway.ts b/web/frontend/src/hooks/use-gateway.ts index 203a91039..ca305f077 100644 --- a/web/frontend/src/hooks/use-gateway.ts +++ b/web/frontend/src/hooks/use-gateway.ts @@ -63,6 +63,7 @@ export function useGateway() { setGateway((prev) => ({ ...prev, status: "starting" })) } catch (err) { console.error("Failed to start gateway:", err) + setGateway((prev) => ({ ...prev, status: "unknown" })) } finally { setLoading(false) } diff --git a/web/frontend/src/i18n/locales/en.json b/web/frontend/src/i18n/locales/en.json index 175a1172f..cee3493c8 100644 --- a/web/frontend/src/i18n/locales/en.json +++ b/web/frontend/src/i18n/locales/en.json @@ -37,11 +37,9 @@ "noHistory": "No chat history yet", "messagesCount": "{{count}} messages", "noModel": "Select model", - "configureModelPrompt": "Configure models to use", "setupModel": { "title": "No Model Configured", - "description": "You need to configure at least one AI model with an API key before you can start chatting.", - "action": "Configure Models" + "description": "You need to configure at least one AI model with an API key before you can start chatting." }, "modelGroup": { "apikey": "API Key", @@ -66,7 +64,79 @@ } }, "common": { - "cancel": "Cancel" + "cancel": "Cancel", + "save": "Save" + }, + "models": { + "description": "Configure API keys for each AI provider. Only configured models are available for chat.", + "loadError": "Failed to load models", + "header": { + "configured": "configured" + }, + "currentDefault": "Default model:", + "status": { + "configured": "Configured", + "unconfigured": "Not configured" + }, + "badge": { + "default": "Default" + }, + "action": { + "edit": "Edit API key", + "setDefault": "Set as default", + "delete": "Delete model" + }, + "add": { + "button": "Add Model", + "title": "Add Custom Model", + "description": "Add an OpenAI-compatible or native model endpoint.", + "modelName": "Model Alias", + "modelNamePlaceholder": "e.g. my-gpt4", + "modelNameHint": "A short name used to identify this model in conversations.", + "modelId": "Model Identifier", + "modelIdPlaceholder": "e.g. openai/gpt-4o", + "modelIdHint": "Format: protocol/model-id. Supported: openai, anthropic, gemini, groq, …", + "errorRequired": "This field is required.", + "saveError": "Failed to add model", + "confirm": "Add Model" + }, + "delete": { + "title": "Delete Model?", + "description": "\"{{name}}\" will be permanently removed from your model list. This cannot be undone.", + "confirm": "Delete", + "errorDefault": "Cannot delete the default model. Please set another model as default first." + }, + "advanced": { + "toggle": "Advanced options" + }, + "field": { + "apiBase": "API Base URL", + "apiKey": "API Key", + "apiKeyPlaceholder": "Enter your API key", + "apiKeyPlaceholderSet": "Leave blank to keep existing key", + "proxy": "HTTP Proxy", + "proxyHint": "Optional. e.g. http://127.0.0.1:7890", + "authMethod": "Auth Method", + "authMethodHint": "Authentication method: oauth, token. Leave blank for API key auth.", + "connectMode": "Connect Mode", + "connectModeHint": "Connection mode for CLI-based providers: stdio or grpc.", + "workspace": "Workspace Path", + "workspaceHint": "Working directory for CLI-based providers (e.g. GitHub Copilot).", + "requestTimeout": "Request Timeout (s)", + "requestTimeoutHint": "Maximum seconds to wait for a response. 0 = use default.", + "rpm": "Rate Limit (RPM)", + "rpmHint": "Maximum requests per minute. 0 = no limit.", + "thinkingLevel": "Thinking Level", + "thinkingLevelHint": "Extended thinking budget: off, low, medium, high, xhigh, adaptive.", + "maxTokensField": "Max Tokens Field", + "maxTokensFieldHint": "Override the request field name for max tokens, e.g. max_completion_tokens." + }, + "edit": { + "title": "Configure {{name}}", + "apiKeyHint": "A key is already set. Leave blank to keep it unchanged.", + "oauthNote": "This provider uses OAuth — no API key required.", + "saveError": "Failed to save" + } }, "pages": { "providers": { diff --git a/web/frontend/src/i18n/locales/zh.json b/web/frontend/src/i18n/locales/zh.json index 692139474..bb1994cb2 100644 --- a/web/frontend/src/i18n/locales/zh.json +++ b/web/frontend/src/i18n/locales/zh.json @@ -37,11 +37,9 @@ "noHistory": "暂无对话历史", "messagesCount": "{{count}} 条消息", "noModel": "选择模型", - "configureModelPrompt": "配置模型后使用", "setupModel": { "title": "尚未配置模型", - "description": "请先配置至少一个带有 API Key 的 AI 模型,才能开始对话。", - "action": "配置模型" + "description": "请先配置至少一个带有 API Key 的 AI 模型,才能开始对话。" }, "modelGroup": { "apikey": "API Key", @@ -66,7 +64,79 @@ } }, "common": { - "cancel": "取消" + "cancel": "取消", + "save": "保存" + }, + "models": { + "description": "为每个 AI 服务商配置 API Key。只有已配置的模型可用于对话。", + "loadError": "加载模型列表失败", + "header": { + "configured": "已配置" + }, + "currentDefault": "默认模型:", + "status": { + "configured": "已配置", + "unconfigured": "未配置" + }, + "badge": { + "default": "默认" + }, + "action": { + "edit": "编辑 API Key", + "setDefault": "设为默认", + "delete": "删除模型" + }, + "add": { + "button": "添加模型", + "title": "添加自定义模型", + "description": "添加兼容 OpenAI 或原生协议的模型端点。", + "modelName": "模型别名", + "modelNamePlaceholder": "例如 my-gpt4", + "modelNameHint": "用于在对话中识别此模型的简短名称。", + "modelId": "模型标识符", + "modelIdPlaceholder": "例如 openai/gpt-4o", + "modelIdHint": "格式:协议/模型ID。支持:openai、anthropic、gemini、groq 等。", + "errorRequired": "此字段为必填项。", + "saveError": "添加模型失败", + "confirm": "添加模型" + }, + "delete": { + "title": "确认删除模型?", + "description": "「{{name}}」将从模型列表中永久移除,此操作不可撤销。", + "confirm": "删除", + "errorDefault": "无法删除默认模型。请先将其他模型设为默认。" + }, + "advanced": { + "toggle": "高级选项" + }, + "field": { + "apiBase": "API Base URL", + "apiKey": "API Key", + "apiKeyPlaceholder": "请输入 API Key", + "apiKeyPlaceholderSet": "留空保持原有 Key 不变", + "proxy": "HTTP 代理", + "proxyHint": "可选。例如 http://127.0.0.1:7890", + "authMethod": "认证方式", + "authMethodHint": "认证方式:oauth、token。留空表示使用 API Key 认证。", + "connectMode": "连接模式", + "connectModeHint": "CLI 型服务商的连接模式:stdio 或 grpc。", + "workspace": "工作目录", + "workspaceHint": "CLI 型服务商的工作目录路径(如 GitHub Copilot)。", + "requestTimeout": "请求超时(秒)", + "requestTimeoutHint": "等待响应的最大秒数,0 表示使用默认值。", + "rpm": "速率限制(RPM)", + "rpmHint": "每分钟最大请求数,0 表示不限制。", + "thinkingLevel": "思考级别", + "thinkingLevelHint": "扩展思考预算:off、low、medium、high、xhigh、adaptive。", + "maxTokensField": "Max Tokens 字段名", + "maxTokensFieldHint": "覆盖请求中 max_tokens 的字段名,例如 max_completion_tokens。" + }, + "edit": { + "title": "配置 {{name}}", + "apiKeyHint": "已设置 API Key,留空表示不修改。", + "oauthNote": "该服务商使用 OAuth 认证,无需 API Key。", + "saveError": "保存失败" + } }, "pages": { "providers": { diff --git a/web/frontend/src/routes/index.tsx b/web/frontend/src/routes/index.tsx index d0eb01879..8252dae43 100644 --- a/web/frontend/src/routes/index.tsx +++ b/web/frontend/src/routes/index.tsx @@ -5,11 +5,12 @@ import { IconHistory, IconMicrophone, IconPaperclip, + IconPlugConnectedX, IconPlus, IconSparkles, IconTrash, } from "@tabler/icons-react" -import { createFileRoute, useNavigate } from "@tanstack/react-router" +import { createFileRoute } from "@tanstack/react-router" import dayjs from "dayjs" import { useCallback, useEffect, useRef, useState } from "react" import { useTranslation } from "react-i18next" @@ -185,9 +186,8 @@ function Index() { newChat, } = usePicoChat() - const { state: gwState, isInitialized } = useGateway() + const { state: gwState } = useGateway() const isConnected = gwState === "running" - const navigate = useNavigate() const hasConfiguredModels = modelList.some((m) => m.configured) const oauthModels = modelList.filter( @@ -210,7 +210,9 @@ function Index() { try { const data = await getModels() setModelList(data.models) - setDefaultModelName(data.default_model) + if (data.models.some((m) => m.model_name === data.default_model)) { + setDefaultModelName(data.default_model) + } } catch { // silently fail } @@ -332,73 +334,62 @@ function Index() { - - - - - {apiKeyModels.length > 0 && ( - - - {t("chat.modelGroup.apikey", "API Key")} - - {apiKeyModels.map((model) => ( - - {model.model_name} - - ))} - - )} - {apiKeyModels.length > 0 && - (oauthModels.length > 0 || localModels.length > 0) && ( - - )} - - {oauthModels.length > 0 && ( - - - {t("chat.modelGroup.oauth", "OAuth")} - - {oauthModels.map((model) => ( - - {model.model_name} - - ))} - - )} - {oauthModels.length > 0 && - (localModels.length > 0 || apiKeyModels.length > 0) && ( - - )} - - {localModels.length > 0 && ( - - - {t("chat.modelGroup.local", "Local")} - - {localModels.map((model) => ( - - {model.model_name} - - ))} - - )} - - - ) : ( - - ) + + + + {apiKeyModels.length > 0 && ( + + + {t("chat.modelGroup.apikey", "API Key")} + + {apiKeyModels.map((model) => ( + + {model.model_name} + + ))} + + )} + {apiKeyModels.length > 0 && + (oauthModels.length > 0 || localModels.length > 0) && ( + + )} + + {oauthModels.length > 0 && ( + + + {t("chat.modelGroup.oauth", "OAuth")} + + {oauthModels.map((model) => ( + + {model.model_name} + + ))} + + )} + {oauthModels.length > 0 && + (localModels.length > 0 || apiKeyModels.length > 0) && ( + + )} + + {localModels.length > 0 && ( + + + {t("chat.modelGroup.local", "Local")} + + {localModels.map((model) => ( + + {model.model_name} + + ))} + + )} + + } > + + ) : !isConnected ? ( + <> +
+ +
+

+ {t("chat.connectFirst")} +

) : ( <> @@ -545,14 +537,8 @@ function Index() { value={input} onChange={(e) => setInput(e.target.value)} onKeyDown={handleKeyDown} - placeholder={ - !isInitialized - ? t("chat.connecting") - : isConnected - ? t("chat.placeholder") - : t("chat.connectFirst") - } - disabled={!isConnected} + placeholder={t("chat.placeholder")} + disabled={!isConnected || !defaultModelName} className="max-h-[200px] min-h-[60px] resize-none border-0 bg-transparent px-2 py-1 text-[15px] shadow-none focus-visible:ring-0 focus-visible:outline-none dark:bg-transparent" minRows={1} maxRows={8} diff --git a/web/frontend/src/routes/models.tsx b/web/frontend/src/routes/models.tsx index b0147fe3d..43982f816 100644 --- a/web/frontend/src/routes/models.tsx +++ b/web/frontend/src/routes/models.tsx @@ -1,27 +1,1103 @@ +import { + IconChevronDown, + IconEdit, + IconEye, + IconEyeOff, + IconKey, + IconLoader2, + IconPlus, + IconStar, + IconStarFilled, + IconTrash, +} from "@tabler/icons-react" import { createFileRoute } from "@tanstack/react-router" +import { useCallback, useEffect, useState } from "react" import { useTranslation } from "react-i18next" +import { + type ModelInfo, + addModel, + deleteModel, + getModels, + setDefaultModel, + updateModel, +} from "@/api/models" import { PageHeader } from "@/components/page-header" +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { + Sheet, + SheetContent, + SheetDescription, + SheetFooter, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet" export const Route = createFileRoute("/models")({ component: ModelsPage, }) -function ModelsPage() { - const { t } = useTranslation() +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function getProviderLabel(model: string): string { + const prefix = model.split("/")[0] + const labels: Record = { + openai: "OpenAI", + anthropic: "Anthropic", + gemini: "Google Gemini", + deepseek: "DeepSeek", + qwen: "Qwen (阿里云)", + moonshot: "Moonshot (月之暗面)", + groq: "Groq", + openrouter: "OpenRouter", + nvidia: "NVIDIA", + cerebras: "Cerebras", + volcengine: "Volcengine (火山引擎)", + shengsuanyun: "ShengsuanYun (神算云)", + antigravity: "Google Code Assist", + "github-copilot": "GitHub Copilot", + ollama: "Ollama (local)", + mistral: "Mistral AI", + avian: "Avian", + vllm: "VLLM (local)", + zhipu: "Zhipu AI (智谱)", + } + return labels[prefix] ?? prefix +} + +// --------------------------------------------------------------------------- +// Shared form field +// --------------------------------------------------------------------------- + +interface FieldProps { + label: string + hint?: string + children: React.ReactNode +} + +function Field({ label, hint, children }: FieldProps) { return ( -
- -
-
-

- {t("navigation.models", "Models")} -

-

- {t("pages.models.description", "Manage AI models here.")} -

+
+ + {children} + {hint &&

{hint}

} +
+ ) +} + +// --------------------------------------------------------------------------- +// API key input with show/hide toggle +// --------------------------------------------------------------------------- + +interface KeyInputProps { + value: string + onChange: (v: string) => void + placeholder?: string +} + +function KeyInput({ value, onChange, placeholder }: KeyInputProps) { + const [show, setShow] = useState(false) + return ( +
+ onChange(e.target.value)} + placeholder={placeholder} + className="pr-10" + /> + +
+ ) +} + +// --------------------------------------------------------------------------- +// Advanced options toggle +// --------------------------------------------------------------------------- + +interface AdvancedSectionProps { + children: React.ReactNode +} + +function AdvancedSection({ children }: AdvancedSectionProps) { + const { t } = useTranslation() + const [open, setOpen] = useState(false) + + return ( +
+ + {open && ( +
+ {children}
+ )} +
+ ) +} + +// --------------------------------------------------------------------------- +// Edit sheet +// --------------------------------------------------------------------------- + +interface EditForm { + apiKey: string + apiBase: string + proxy: string + authMethod: string + connectMode: string + workspace: string + rpm: string + maxTokensField: string + requestTimeout: string + thinkingLevel: string +} + +interface EditSheetProps { + model: ModelInfo | null + open: boolean + onClose: () => void + onSaved: () => void +} + +function EditSheet({ model, open, onClose, onSaved }: EditSheetProps) { + const { t } = useTranslation() + const [form, setForm] = useState({ + apiKey: "", + apiBase: "", + proxy: "", + authMethod: "", + connectMode: "", + workspace: "", + rpm: "", + maxTokensField: "", + requestTimeout: "", + thinkingLevel: "", + }) + const [saving, setSaving] = useState(false) + const [error, setError] = useState("") + + useEffect(() => { + if (model) { + setForm({ + apiKey: "", + apiBase: model.api_base ?? "", + proxy: model.proxy ?? "", + authMethod: model.auth_method ?? "", + connectMode: model.connect_mode ?? "", + workspace: model.workspace ?? "", + rpm: model.rpm ? String(model.rpm) : "", + maxTokensField: model.max_tokens_field ?? "", + requestTimeout: model.request_timeout + ? String(model.request_timeout) + : "", + thinkingLevel: model.thinking_level ?? "", + }) + setError("") + } + }, [model]) + + const setF = + (key: keyof EditForm) => (e: React.ChangeEvent) => + setForm((f) => ({ ...f, [key]: e.target.value })) + + const handleSave = async () => { + if (!model) return + setSaving(true) + setError("") + try { + await updateModel(model.index, { + model_name: model.model_name, + model: model.model, + api_base: form.apiBase || undefined, + api_key: form.apiKey || undefined, + proxy: form.proxy || undefined, + auth_method: form.authMethod || undefined, + connect_mode: form.connectMode || undefined, + workspace: form.workspace || undefined, + rpm: form.rpm ? Number(form.rpm) : undefined, + max_tokens_field: form.maxTokensField || undefined, + request_timeout: form.requestTimeout + ? Number(form.requestTimeout) + : undefined, + thinking_level: form.thinkingLevel || undefined, + }) + onSaved() + onClose() + } catch (e) { + setError(e instanceof Error ? e.message : t("models.edit.saveError")) + } finally { + setSaving(false) + } + } + + const isOAuth = model?.auth_method === "oauth" + + return ( + !v && onClose()}> + + + + {t("models.edit.title", { name: model?.model_name })} + + + {model?.model} + + + +
+
+ {/* ── Basic fields ── */} + + + {isOAuth && ( +

+ {t("models.edit.oauthNote")} +

+ )} +
+ + {!isOAuth && ( + + setForm((f) => ({ ...f, apiKey: v }))} + placeholder={ + model?.configured + ? t("models.field.apiKeyPlaceholderSet") + : t("models.field.apiKeyPlaceholder") + } + /> + + )} + + {/* ── Advanced options ── */} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {error && ( +

+ {error} +

+ )} +
+
+ + + + + +
+
+ ) +} + +// --------------------------------------------------------------------------- +// Add model sheet +// --------------------------------------------------------------------------- + +interface AddForm { + modelName: string + model: string + apiBase: string + apiKey: string + // advanced + proxy: string + authMethod: string + connectMode: string + workspace: string + rpm: string + maxTokensField: string + requestTimeout: string + thinkingLevel: string +} + +const EMPTY_ADD_FORM: AddForm = { + modelName: "", + model: "", + apiBase: "", + apiKey: "", + proxy: "", + authMethod: "", + connectMode: "", + workspace: "", + rpm: "", + maxTokensField: "", + requestTimeout: "", + thinkingLevel: "", +} + +interface AddSheetProps { + open: boolean + onClose: () => void + onSaved: () => void +} + +function AddSheet({ open, onClose, onSaved }: AddSheetProps) { + const { t } = useTranslation() + const [form, setForm] = useState(EMPTY_ADD_FORM) + const [saving, setSaving] = useState(false) + const [fieldErrors, setFieldErrors] = useState< + Partial> + >({}) + const [serverError, setServerError] = useState("") + + useEffect(() => { + if (open) { + setForm(EMPTY_ADD_FORM) + setFieldErrors({}) + setServerError("") + } + }, [open]) + + const validate = (): boolean => { + const e: Partial> = {} + if (!form.modelName.trim()) e.modelName = t("models.add.errorRequired") + if (!form.model.trim()) e.model = t("models.add.errorRequired") + setFieldErrors(e) + return Object.keys(e).length === 0 + } + + const setField = + (key: keyof AddForm) => (e: React.ChangeEvent) => { + setForm((f) => ({ ...f, [key]: e.target.value })) + if (fieldErrors[key]) + setFieldErrors((prev) => ({ ...prev, [key]: undefined })) + } + + const handleSave = async () => { + if (!validate()) return + setSaving(true) + setServerError("") + try { + await addModel({ + model_name: form.modelName.trim(), + model: form.model.trim(), + api_base: form.apiBase.trim() || undefined, + api_key: form.apiKey.trim() || undefined, + proxy: form.proxy.trim() || undefined, + auth_method: form.authMethod.trim() || undefined, + connect_mode: form.connectMode.trim() || undefined, + workspace: form.workspace.trim() || undefined, + rpm: form.rpm ? Number(form.rpm) : undefined, + max_tokens_field: form.maxTokensField.trim() || undefined, + request_timeout: form.requestTimeout + ? Number(form.requestTimeout) + : undefined, + thinking_level: form.thinkingLevel.trim() || undefined, + }) + onSaved() + onClose() + } catch (e) { + setServerError(e instanceof Error ? e.message : t("models.add.saveError")) + } finally { + setSaving(false) + } + } + + return ( + !v && onClose()}> + + + {t("models.add.title")} + + {t("models.add.description")} + + + +
+
+ {/* ── Required basic fields ── */} + + + {fieldErrors.modelName && ( +

+ {fieldErrors.modelName} +

+ )} +

+ {t("models.add.modelNameHint")} +

+
+ + + + {fieldErrors.model && ( +

{fieldErrors.model}

+ )} +

+ {t("models.add.modelIdHint")} +

+
+ + + + + + + setForm((f) => ({ ...f, apiKey: v }))} + placeholder={t("models.field.apiKeyPlaceholder")} + /> + + + {/* ── Advanced options ── */} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {serverError && ( +

+ {serverError} +

+ )} +
+
+ + + + + +
+
+ ) +} + +// --------------------------------------------------------------------------- +// Delete confirmation dialog +// --------------------------------------------------------------------------- + +interface DeleteDialogProps { + model: ModelInfo | null + onClose: () => void + onDeleted: () => void +} + +function DeleteDialog({ model, onClose, onDeleted }: DeleteDialogProps) { + const { t } = useTranslation() + const [deleting, setDeleting] = useState(false) + + const handleConfirm = async () => { + if (!model) return + if (model.is_default) { + onClose() + return + } + setDeleting(true) + try { + await deleteModel(model.index) + onDeleted() + } catch { + // ignore — list will still show; user can retry + } finally { + setDeleting(false) + onClose() + } + } + + return ( + !v && onClose()}> + + + {t("models.delete.title")} + + {t("models.delete.description", { name: model?.model_name })} + + + + + {t("common.cancel")} + + + {deleting && } + {t("models.delete.confirm")} + + + + + ) +} + +// --------------------------------------------------------------------------- +// Model card +// --------------------------------------------------------------------------- + +interface ModelCardProps { + model: ModelInfo + onEdit: (m: ModelInfo) => void + onSetDefault: (m: ModelInfo) => void + onDelete: (m: ModelInfo) => void + settingDefault: boolean +} + +function ModelCard({ + model, + onEdit, + onSetDefault, + onDelete, + settingDefault, +}: ModelCardProps) { + const { t } = useTranslation() + const isOAuth = model.auth_method === "oauth" + + return ( +
+ {/* Top row: status dot + name + default badge */} +
+
+ {/* Configured indicator */} + + + {model.model_name} + + {model.is_default && ( + + {t("models.badge.default")} + + )} +
+ + {/* Action buttons — always visible on card */} +
+ {model.is_default ? ( + + + + ) : ( + + )} + + +
+
+ + {/* Model identifier */} +

+ {model.model} +

+ + {/* Footer row: masked key or auth badge */} +
+ {isOAuth ? ( + + OAuth + + ) : model.configured && model.api_key ? ( + + + {model.api_key} + + ) : ( + + {t("models.status.unconfigured")} + + )}
) } + +// --------------------------------------------------------------------------- +// Provider section +// --------------------------------------------------------------------------- + +interface ProviderSectionProps { + provider: string + models: ModelInfo[] + onEdit: (m: ModelInfo) => void + onSetDefault: (m: ModelInfo) => void + onDelete: (m: ModelInfo) => void + settingDefaultIndex: number | null +} + +function ProviderSection({ + provider, + models, + onEdit, + onSetDefault, + onDelete, + settingDefaultIndex, +}: ProviderSectionProps) { + const configuredCount = models.filter((m) => m.configured).length + + return ( +
+ {/* Section label */} +
+ + {provider} + + + {configuredCount}/{models.length} + +
+
+ + {/* Card grid */} +
+ {models.map((m) => ( + + ))} +
+
+ ) +} + +// --------------------------------------------------------------------------- +// Page +// --------------------------------------------------------------------------- + +function ModelsPage() { + const { t } = useTranslation() + const [models, setModels] = useState([]) + const [loading, setLoading] = useState(true) + const [fetchError, setFetchError] = useState("") + + const [editingModel, setEditingModel] = useState(null) + const [deletingModel, setDeletingModel] = useState(null) + const [addOpen, setAddOpen] = useState(false) + const [settingDefaultIndex, setSettingDefaultIndex] = useState( + null, + ) + + const fetchModels = useCallback(async () => { + try { + const data = await getModels() + // Sort: default first, then configured, then by name + const sorted = [...data.models].sort((a, b) => { + if (a.is_default && !b.is_default) return -1 + if (!a.is_default && b.is_default) return 1 + if (a.configured && !b.configured) return -1 + if (!a.configured && b.configured) return 1 + return a.model_name.localeCompare(b.model_name) + }) + setModels(sorted) + setFetchError("") + } catch (e) { + setFetchError(e instanceof Error ? e.message : t("models.loadError")) + } finally { + setLoading(false) + } + }, [t]) + + useEffect(() => { + fetchModels() + }, [fetchModels]) + + const handleSetDefault = async (model: ModelInfo) => { + setSettingDefaultIndex(model.index) + try { + await setDefaultModel(model.model_name) + await fetchModels() + } catch { + // ignore + } finally { + setSettingDefaultIndex(null) + } + } + + // Group by provider, preserving insertion order + const grouped: Record = {} + for (const m of models) { + const p = getProviderLabel(m.model) + if (!grouped[p]) grouped[p] = [] + grouped[p].push(m) + } + + const defaultModel = models.find((m) => m.is_default) + + return ( +
+ +
+ +
+
+ + {/* ── scrollable body ── */} + {/* overflow-y-auto on this div, NOT a ScrollArea wrapper with flex-1 */} +
+
+ {defaultModel && ( +
+ + {t("models.currentDefault")}{" "} + + {defaultModel.model_name} + +
+ )} +

+ {t("models.description")} +

+
+ + {loading && ( +
+ +
+ )} + + {fetchError && ( +
+ {fetchError} +
+ )} + + {!loading && !fetchError && ( +
+ {Object.entries(grouped).map(([provider, providerModels]) => ( + + ))} +
+ )} +
+ + setEditingModel(null)} + onSaved={fetchModels} + /> + + setAddOpen(false)} + onSaved={fetchModels} + /> + + setDeletingModel(null)} + onDeleted={fetchModels} + /> +
+ ) +}