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:
parent
7b6725e7fd
commit
d1f1de2c2d
8 changed files with 1364 additions and 119 deletions
|
|
@ -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,6 +29,14 @@ type modelResponse struct {
|
|||
APIKey string `json:"api_key"`
|
||||
Proxy string `json:"proxy,omitempty"`
|
||||
AuthMethod string `json:"auth_method,omitempty"`
|
||||
// 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"`
|
||||
}
|
||||
|
|
@ -54,6 +63,12 @@ func (h *Handler) handleListModels(w http.ResponseWriter, r *http.Request) {
|
|||
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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@ const navGroups = [
|
|||
],
|
||||
},
|
||||
{
|
||||
label: "navigation.service",
|
||||
label: "navigation.services",
|
||||
defaultOpen: true,
|
||||
items: [
|
||||
{ title: "navigation.config", url: "/config", icon: IconSettings },
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
if (data.models.some((m) => m.model_name === data.default_model)) {
|
||||
setDefaultModelName(data.default_model)
|
||||
}
|
||||
} catch {
|
||||
// silently fail
|
||||
}
|
||||
|
|
@ -332,11 +334,10 @@ 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]"
|
||||
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]"
|
||||
>
|
||||
<SelectValue placeholder={t("chat.noModel")} />
|
||||
</SelectTrigger>
|
||||
|
|
@ -389,16 +390,6 @@ function Index() {
|
|||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<Button
|
||||
variant="link"
|
||||
size="sm"
|
||||
className="text-muted-foreground hover:text-foreground h-8 px-0 text-xs font-normal text-red-500"
|
||||
onClick={() => navigate({ to: "/models" })}
|
||||
>
|
||||
{t("chat.configureModelPrompt")}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
>
|
||||
<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
Loading…
Add table
Reference in a new issue