feat(web): enhance model management, sorting, and deletion logic

- Implement model sorting in UI (default > configured > unconfigured)
- Prevent deletion of default models in the frontend
- Update backend to clear default settings when a model is deleted
- Add existence validation when setting a default model via API
- Group models in chat UI by type (API Key, OAuth, Local)
- Conditionally display model selector in chat based on configuration status
This commit is contained in:
wenjie 2026-03-05 19:39:06 +08:00
parent 7b6725e7fd
commit d1f1de2c2d
8 changed files with 1364 additions and 119 deletions

View file

@ -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

View file

@ -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
}

View file

@ -45,7 +45,7 @@ const navGroups = [
],
},
{
label: "navigation.service",
label: "navigation.services",
defaultOpen: true,
items: [
{ title: "navigation.config", url: "/config", icon: IconSettings },

View file

@ -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)
}

View file

@ -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": {

View file

@ -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": {

View file

@ -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() {
<PageHeader
title="Chat"
titleExtra={
hasConfiguredModels ? (
<Select value={defaultModelName} onValueChange={handleSetDefault}>
<SelectTrigger
size="sm"
className="text-muted-foreground hover:text-foreground h-8 max-w-[160px] bg-transparent shadow-none focus-visible:border-transparent focus-visible:ring-0 sm:max-w-[220px]"
>
<SelectValue placeholder={t("chat.noModel")} />
</SelectTrigger>
<SelectContent>
{apiKeyModels.length > 0 && (
<SelectGroup>
<SelectLabel>
{t("chat.modelGroup.apikey", "API Key")}
</SelectLabel>
{apiKeyModels.map((model) => (
<SelectItem key={model.index} value={model.model_name}>
{model.model_name}
</SelectItem>
))}
</SelectGroup>
)}
{apiKeyModels.length > 0 &&
(oauthModels.length > 0 || localModels.length > 0) && (
<SelectSeparator />
)}
{oauthModels.length > 0 && (
<SelectGroup>
<SelectLabel>
{t("chat.modelGroup.oauth", "OAuth")}
</SelectLabel>
{oauthModels.map((model) => (
<SelectItem key={model.index} value={model.model_name}>
{model.model_name}
</SelectItem>
))}
</SelectGroup>
)}
{oauthModels.length > 0 &&
(localModels.length > 0 || apiKeyModels.length > 0) && (
<SelectSeparator />
)}
{localModels.length > 0 && (
<SelectGroup>
<SelectLabel>
{t("chat.modelGroup.local", "Local")}
</SelectLabel>
{localModels.map((model) => (
<SelectItem key={model.index} value={model.model_name}>
{model.model_name}
</SelectItem>
))}
</SelectGroup>
)}
</SelectContent>
</Select>
) : (
<Button
variant="link"
<Select value={defaultModelName} onValueChange={handleSetDefault}>
<SelectTrigger
size="sm"
className="text-muted-foreground hover:text-foreground h-8 px-0 text-xs font-normal text-red-500"
onClick={() => navigate({ to: "/models" })}
className="text-muted-foreground hover:text-foreground focus-visible:border-input h-8 max-w-[160px] min-w-[80px] bg-transparent shadow-none focus-visible:ring-0 sm:max-w-[220px]"
>
{t("chat.configureModelPrompt")}
</Button>
)
<SelectValue placeholder={t("chat.noModel")} />
</SelectTrigger>
<SelectContent>
{apiKeyModels.length > 0 && (
<SelectGroup>
<SelectLabel>
{t("chat.modelGroup.apikey", "API Key")}
</SelectLabel>
{apiKeyModels.map((model) => (
<SelectItem key={model.index} value={model.model_name}>
{model.model_name}
</SelectItem>
))}
</SelectGroup>
)}
{apiKeyModels.length > 0 &&
(oauthModels.length > 0 || localModels.length > 0) && (
<SelectSeparator />
)}
{oauthModels.length > 0 && (
<SelectGroup>
<SelectLabel>
{t("chat.modelGroup.oauth", "OAuth")}
</SelectLabel>
{oauthModels.map((model) => (
<SelectItem key={model.index} value={model.model_name}>
{model.model_name}
</SelectItem>
))}
</SelectGroup>
)}
{oauthModels.length > 0 &&
(localModels.length > 0 || apiKeyModels.length > 0) && (
<SelectSeparator />
)}
{localModels.length > 0 && (
<SelectGroup>
<SelectLabel>
{t("chat.modelGroup.local", "Local")}
</SelectLabel>
{localModels.map((model) => (
<SelectItem key={model.index} value={model.model_name}>
{model.model_name}
</SelectItem>
))}
</SelectGroup>
)}
</SelectContent>
</Select>
}
>
<Button
@ -483,9 +474,9 @@ function Index() {
className="min-h-0 flex-1 overflow-y-auto px-4 py-6 md:px-8 lg:px-24 xl:px-48"
>
<div className="mx-auto flex w-full max-w-[1000px] flex-col gap-8 pb-8">
{messages.length === 0 && !isTyping && isConnected && (
{messages.length === 0 && !isTyping && (
<div className="flex flex-col items-center justify-center py-20 opacity-70">
{!hasConfiguredModels ? (
{!hasConfiguredModels || !defaultModelName ? (
<>
<div className="mb-6 flex h-16 w-16 items-center justify-center rounded-2xl bg-amber-500/10 text-amber-500">
<IconSparkles className="h-8 w-8" />
@ -496,14 +487,15 @@ function Index() {
<p className="text-muted-foreground mb-4 max-w-sm text-center text-sm">
{t("chat.setupModel.description")}
</p>
<Button
variant="outline"
size="sm"
className="gap-2"
onClick={() => navigate({ to: "/models" })}
>
{t("chat.setupModel.action")}
</Button>
</>
) : !isConnected ? (
<>
<div className="mb-6 flex h-16 w-16 items-center justify-center rounded-2xl bg-amber-500/10 text-amber-500">
<IconPlugConnectedX className="h-8 w-8" />
</div>
<p className="text-muted-foreground mb-4 max-w-sm text-center text-sm">
{t("chat.connectFirst")}
</p>
</>
) : (
<>
@ -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}

File diff suppressed because it is too large Load diff