diff --git a/CHANGELOG.md b/CHANGELOG.md index d73d684f..2438bdf8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- **Small model picker in the web/desktop settings.** Settings → Providers gains a "Model roles" section: pick `small_model` from the enabled-model catalog (grouped by provider) or clear it back to "follow main model". Changes persist to config and apply immediately — the `"small"` alias, automations, and session titles pick up the new value without a restart. New `POST /api/small-model` endpoint, `small_model` exposed in `GET /api/config`; a configured ref whose model was since disabled/removed still renders (marked unavailable) so it can be cleared. Localized in all five UI languages. - **Built-in color themes, unified across terminal and web.** A new single source of truth (`internal/theme`) defines 7 themes — 4 dark (jcode Dark, Midnight, Dracula, Nord) and 3 light (jcode Light, GitHub Light, Solarized Light) — as a typed semantic palette. `go generate` emits the web CSS (`[data-theme]` blocks) and the picker registry from that one Go file, so the two renderers can never drift. - **`/theme` command** in the TUI opens a live-preview selector: arrow keys repaint the whole UI, Enter applies and persists to `config.theme`, Esc reverts. When no theme is persisted, the startup default is auto-selected from the terminal background. New `theme` config field. - **Appearance settings tab** in the web UI: a System (follow-OS) option plus dark/light swatch grids that render a true mini-preview of each theme. Themes apply via `html[data-theme]`; the legacy light/dark/system localStorage values migrate automatically. diff --git a/internal-doc/small-model-settings-ui-design.md b/internal-doc/small-model-settings-ui-design.md new file mode 100644 index 00000000..517bedd5 --- /dev/null +++ b/internal-doc/small-model-settings-ui-design.md @@ -0,0 +1,94 @@ +# small_model 设置 UI(Web/Desktop)设计 + +2026-07-15。[small-model-design.md](small-model-design.md) 的 v2 遗留项: +"Web/TUI 设置 UI 尚未露出 small_model(config-file only)"。Desktop 是 Tauri 壳, +UI 由内嵌 Go sidecar 提供的 web 前端渲染,所以做完 web 即覆盖 desktop。 +本文记录 API 形状、UI 位置与生效语义的调研结论和拍板建议,实现前先过评审。 + +## 现状(调研结论) + +- 后端 small_model 功能完整(subagent/workflow/team/automation 的 `"small"` 别名、 + LLM 会话标题、doctor 探针),但唯一入口是手编 `~/.jcode/config.json`。 +- web 后端没有任何读写 small_model 的端点:`GET /api/config`(internal/web/models.go) + 只返回 provider/model/max_iterations;`POST /api/model` 只切当前任务主模型且不落盘 config。 +- 前端 SettingsDialog(web/src/components/SettingsDialog.tsx)没有模型角色概念; + 模型相关 UI 全在 Providers tab(目录、自定义模型、启用开关)。 + +### 配置热更新的两种既有模式(决定生效语义的关键) + +web 模式下存在两条不等价的配置发布路径: + +1. **原地写共享指针**(browser.go `handleBrowserConfig`):`s.cfg.Browser = &req` + + `SaveConfig(s.cfg)`,持 `cfgMu`+`mu`。`s.cfg` 与 command/web.go `buildWebTask` + 闭包捕获的 `cfg` 是**同一个启动指针**,原地写对新旧 engine 全部可见 + (ModelFactory.SmallModelRef 是 call-time 读 `f.cfg.SmallModel`,factory.go:47)。 +2. **重载后重指**(providers.go:424 `s.cfg = cfg`):fresh LoadConfig 的新对象只发布到 + server 字段,**buildWebTask 闭包里的旧指针不会更新**——若 small_model 走这条路, + 新建任务的 subagent "small" 别名仍读旧值,直到重启。 + +small_model 必须走模式 1,否则"保存了但不生效"。 + +各消费方的生效语义(走模式 1 后): + +| 消费方 | 读取时机 | 保存后生效 | +|---|---|---| +| 会话标题 refiner | fire 时 LoadConfig 读盘(command/title.go:34) | 立即,天然生效 | +| subagent/workflow/team 别名 | factory call-time 读 `f.cfg.SmallModel` | 立即(共享指针) | +| automation 别名 | `s.cfg.SmallModel`(automation_run.go:88) | 立即 | + +## 拍板建议 + +### 1. API:专用端点,不做通用 config PATCH + +``` +GET /api/config → 增加 "small_model": "provider/model"(空串=未设置) +POST /api/small-model → body {"provider":"...","model":"..."};两者皆空 = 清除 +``` + +- body 形状对齐 `POST /api/model` / model-state 系端点({provider, model}), + 空值清除对齐 `SetEffortOverride` 的语义。不引入 `PATCH /api/config` + (白名单/校验面失控,现阶段只有一个键,scope 不值)。 +- 写路径:持 `cfgMu`(+`mu`,照抄 browser.go 的锁序)→ 原地 `s.cfg.SmallModel = ref` + → `SaveConfig(s.cfg)` → 返回。失败不改内存值。 +- 校验:非空时 provider 必须存在于 `cfg.Providers`(400),model 非空即可 + (自定义端点的模型 id 任意;运行期畸形值本就优雅降级)。不做在线探活 + (doctor 已覆盖;"测试连接"按钮列为可选后续)。 +- 并发竞态说明:factory 无锁读共享 cfg 字段,与既有 browser 站点权限读取同一模式, + 竞态窗口不新增;如后续要根治应统一 config 读写锁,不在本期。 + +### 2. UI:Providers tab 顶部 "模型角色" 区块 + +- 位置:ProvidersTab 最上方、provider 卡片列表之前,新增 "Model roles / 模型角色" + 小节。理由:模型数据(`api.models()` 的 providers+enabled 状态)该 tab 已加载, + 语义归属也对;起名 "roles" 给将来的 memory.model 等留扩展位。 +- 控件:一行 "Small model" + 按 provider 分组的下拉(只列 enabled 模型), + 首项 "未设置(跟随主模型)"。选择即保存(与目录启用开关的即时保存一致), + 失败回滚选项并 console.error。 +- 帮助文案(i18n,中英):说明两个用途——subagent 等场景的 `"small"` 别名路由、 + LLM 会话标题;未设置时别名降级主模型、标题用截断。 +- 前端改动:api.ts 加 `getConfig()` 补 small_model 字段 + `setSmallModel()`; + modelSlice 加 smallModel 状态;SettingsDialog 加区块;locales 补 key。 + +### 3. 范围外(记录不做) + +- TUI 设置入口(`/model` picker 加 small 角色页)——另行立项。 +- extension 内旧 Vue 设置界面(若仍在维护)——React 版是 desktop 的实际 UI。 +- memory.model 露出、"测试连接" 按钮、自动推荐 small 模型——观察使用后再议。 + +## 测试方案 + +- 后端 handler 单测:设置/清除/provider 不存在/畸形 body;断言 SaveConfig 落盘 + 与 `s.cfg` 内存值同步更新;GET 回读一致。 +- 生效语义:设置后以 `s.cfg` 构建的 ModelFactory `ResolveRef("small")` 返回新值 + (路由正确性已有 subagent_model_routing_test e2e 锁定,不重复)。 +- 前端:dev server 手动全流程——设置→回读→config.json 落盘→新会话标题走小模型; + 暗色/亮色主题下布局检查。 + +## 实现位置清单 + +- internal/web/models.go:`handleGetConfig` 补字段;新增 `handleSetSmallModel` +- internal/web/server.go:注册 `POST /api/small-model` +- web/src/lib/api.ts、web/src/app/store(modelSlice) +- web/src/components/SettingsDialog.tsx(ProvidersTab 顶部区块) +- web/src/i18n/locales/*(新 key,中英) +- site/docs/overview/models.md:补一句 "可在设置 → Providers 中配置" diff --git a/internal/web/models.go b/internal/web/models.go index 7fc23f79..4b472f02 100644 --- a/internal/web/models.go +++ b/internal/web/models.go @@ -208,10 +208,69 @@ func (s *Server) handleGetConfig(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]any{ "provider": providerName, "model": modelName, + "small_model": cfg.SmallModel, "max_iterations": cfg.MaxIterations, }) } +// handleSetSmallModel sets or clears config.small_model (both fields empty = +// clear). It mutates the live shared config IN PLACE (the pointer captured by +// buildWebTask's closure and read by ModelFactory/automation at call time) — +// the s.cfg = fresh reassign pattern would leave those readers on the old +// value until restart. Same discipline as handleBrowserConfig. +func (s *Server) handleSetSmallModel(w http.ResponseWriter, r *http.Request) { + var req struct { + Provider string `json:"provider"` + Model string `json:"model"` + } + if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request"}) + return + } + if (req.Provider == "") != (req.Model == "") { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "provider and model must both be set, or both empty to clear"}) + return + } + + ref := "" + if req.Provider != "" { + ref = req.Provider + "/" + req.Model + } + + s.cfgMu.Lock() + s.mu.Lock() + if s.cfg == nil { + s.mu.Unlock() + s.cfgMu.Unlock() + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "config unavailable"}) + return + } + if ref != "" { + if _, ok := s.cfg.GetProviders()[req.Provider]; !ok { + s.mu.Unlock() + s.cfgMu.Unlock() + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "unknown provider: " + req.Provider}) + return + } + } + prev := s.cfg.SmallModel + s.cfg.SmallModel = ref + err := config.SaveConfig(s.cfg) + if err != nil { + // Keep memory consistent with disk: a failed save must not leave the + // live config advertising a value that won't survive a restart. + s.cfg.SmallModel = prev + } + s.mu.Unlock() + s.cfgMu.Unlock() + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to save config: " + err.Error()}) + return + } + + writeJSON(w, http.StatusOK, map[string]string{"status": "ok", "small_model": ref}) +} + // handleGetModelState returns the recent, favorite, and visibility settings. func (s *Server) handleGetModelState(w http.ResponseWriter, r *http.Request) { state, err := config.LoadModelState() diff --git a/internal/web/server.go b/internal/web/server.go index 32df3303..934dd65e 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -327,6 +327,7 @@ func (s *Server) Start(ctx context.Context) error { mux.HandleFunc("GET /api/tasks/{id}/stats", s.handleTaskStats) mux.HandleFunc("GET /api/models", s.handleListModels) mux.HandleFunc("POST /api/model", s.handleSwitchModel) + mux.HandleFunc("POST /api/small-model", s.handleSetSmallModel) mux.HandleFunc("POST /api/mode", s.handleSwitchMode) mux.HandleFunc("POST /api/exec", s.handleExec) mux.HandleFunc("GET /api/diff", s.handleDiff) diff --git a/internal/web/small_model_test.go b/internal/web/small_model_test.go new file mode 100644 index 00000000..dded4421 --- /dev/null +++ b/internal/web/small_model_test.go @@ -0,0 +1,111 @@ +package web + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/cnjack/jcode/internal/config" +) + +func newSmallModelTestServer() *Server { + return &Server{ + cfg: &config.Config{ + Model: "zai/glm-5.2", + Providers: map[string]*config.ProviderConfig{ + "zai": {APIKey: "k"}, + }, + }, + } +} + +func postSmallModel(t *testing.T, s *Server, body string) *httptest.ResponseRecorder { + t.Helper() + rec := httptest.NewRecorder() + s.handleSetSmallModel(rec, httptest.NewRequest(http.MethodPost, "/api/small-model", strings.NewReader(body))) + return rec +} + +func TestSetSmallModelPersistsAndMutatesLiveConfig(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + s := newSmallModelTestServer() + live := s.cfg // the pointer task builders capture; must be updated in place + + rec := postSmallModel(t, s, `{"provider":"zai","model":"glm-4.6v"}`) + if rec.Code != 200 { + t.Fatalf("code=%d body=%q", rec.Code, rec.Body.String()) + } + if live.SmallModel != "zai/glm-4.6v" { + t.Errorf("live config not mutated in place: SmallModel=%q", live.SmallModel) + } + onDisk, err := config.LoadConfig() + if err != nil { + t.Fatalf("LoadConfig: %v", err) + } + if onDisk.SmallModel != "zai/glm-4.6v" { + t.Errorf("not persisted: disk SmallModel=%q", onDisk.SmallModel) + } + + // GET /api/config surfaces the saved value. + get := httptest.NewRecorder() + s.handleGetConfig(get, httptest.NewRequest(http.MethodGet, "/api/config", nil)) + var cfgResp struct { + SmallModel string `json:"small_model"` + } + if err := json.Unmarshal(get.Body.Bytes(), &cfgResp); err != nil { + t.Fatalf("config response: %v", err) + } + if cfgResp.SmallModel != "zai/glm-4.6v" { + t.Errorf("GET /api/config small_model=%q", cfgResp.SmallModel) + } + + // Clear with both fields empty. + rec = postSmallModel(t, s, `{"provider":"","model":""}`) + if rec.Code != 200 { + t.Fatalf("clear: code=%d body=%q", rec.Code, rec.Body.String()) + } + if live.SmallModel != "" { + t.Errorf("clear: live SmallModel=%q", live.SmallModel) + } + onDisk, _ = config.LoadConfig() + if onDisk.SmallModel != "" { + t.Errorf("clear not persisted: disk SmallModel=%q", onDisk.SmallModel) + } +} + +func TestSetSmallModelValidation(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + + cases := []struct { + name string + body string + }{ + {"unknown provider", `{"provider":"nope","model":"m"}`}, + {"provider without model", `{"provider":"zai","model":""}`}, + {"model without provider", `{"provider":"","model":"glm-4.6v"}`}, + {"garbage body", `{"provider":`}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + s := newSmallModelTestServer() + s.cfg.SmallModel = "zai/glm-4.6v" + rec := postSmallModel(t, s, c.body) + if rec.Code != 400 { + t.Fatalf("code=%d body=%q, want 400", rec.Code, rec.Body.String()) + } + if s.cfg.SmallModel != "zai/glm-4.6v" { + t.Errorf("rejected request mutated config: SmallModel=%q", s.cfg.SmallModel) + } + }) + } +} + +func TestSetSmallModelNilConfig(t *testing.T) { + s := &Server{} + rec := postSmallModel(t, s, `{"provider":"zai","model":"glm-4.6v"}`) + if rec.Code != 500 { + t.Fatalf("code=%d body=%q, want 500", rec.Code, rec.Body.String()) + } +} diff --git a/site/docs/overview/models.md b/site/docs/overview/models.md index 69f14be5..2c41189e 100644 --- a/site/docs/overview/models.md +++ b/site/docs/overview/models.md @@ -76,6 +76,9 @@ jcode supports two model roles: } ``` +In the web UI and desktop app, set the small model from **Settings → Providers → +Model roles** — changes apply immediately, no restart needed. + When `small_model` is set, it powers: - **Subagent delegation** — the `subagent` tool accepts `"small"` as its diff --git a/web/src/app/store.ts b/web/src/app/store.ts index f64bd270..dcd116ae 100644 --- a/web/src/app/store.ts +++ b/web/src/app/store.ts @@ -443,6 +443,8 @@ const sessionSlice = createSlice({ interface ModelState { providerName: string modelName: string + // config.small_model as "provider/model"; '' = unset (follow the main model). + smallModel: string mode: AgentMode providers: ProviderInfo[] favoriteModels: string[] @@ -457,6 +459,7 @@ interface ModelState { const initialModel: ModelState = { providerName: '', modelName: '', + smallModel: '', mode: 'approval', providers: [], favoriteModels: [], @@ -478,6 +481,9 @@ const modelSlice = createSlice({ setModel(s, a: { payload: string }) { s.modelName = a.payload }, + setSmallModel(s, a: { payload: string }) { + s.smallModel = a.payload + }, setMode(s, a: { payload: AgentMode }) { s.mode = a.payload }, @@ -842,6 +848,7 @@ export const loadBLEState = createAsyncThunk('ui/loadBLEState', async (_, { disp export const loadConfig = createAsyncThunk('model/loadConfig', async (_, { dispatch }) => { const cfg = await api.config() dispatch(modelActions.setMaxIterations(cfg.max_iterations)) + dispatch(modelActions.setSmallModel(cfg.small_model ?? '')) }) export const loadStatus = createAsyncThunk('app/loadStatus', async (_, { dispatch }) => { diff --git a/web/src/components/SettingsDialog.tsx b/web/src/components/SettingsDialog.tsx index 5350cea1..82abd65e 100644 --- a/web/src/components/SettingsDialog.tsx +++ b/web/src/components/SettingsDialog.tsx @@ -45,7 +45,7 @@ import { } from '@heroicons/react/24/outline' import { useTranslation } from 'react-i18next' import { useAppDispatch, useAppSelector } from '../app/hooks' -import { uiActions, modelActions } from '../app/store' +import { uiActions, modelActions, loadConfig } from '../app/store' import { ProviderIcon } from './ProviderIcon' import { api } from '../lib/api' import { openRemoteConnect } from '../lib/remote' @@ -397,6 +397,14 @@ function ProvidersTab() { const [editing, setEditing] = useState(null) const [modelForm, setModelForm] = useState<{ providerId: string; target: CustomModelDetail | null } | null>(null) + // Model roles: config.small_model ("provider/model", '' = unset). Options + // come from the chat-picker payload in redux (enabled models only). + const smallModel = useAppSelector((s) => s.model.smallModel) + const pickerProviders = useAppSelector((s) => s.model.providers) + const [smallSaving, setSmallSaving] = useState(false) + const [smallError, setSmallError] = useState('') + const [smallSaved, setSmallSaved] = useState(false) + // Refresh the chat model picker after provider/model mutations. async function refreshModels() { try { @@ -436,9 +444,46 @@ function ProvidersTab() { void load() // Fetch the registry list for the add-provider picker. api.setupProviders().then(setSetupList).catch(() => {}) + // Fresh enabled-model list + small_model value for the roles section (the + // dialog can open long after boot; another client may have changed both). + void refreshModels() + void dispatch(loadConfig()) // eslint-disable-next-line react-hooks/exhaustive-deps }, []) + // Set or clear config.small_model. The select is controlled by redux state, + // so a failed save simply never moves it — no manual revert needed. + async function changeSmallModel(value: string) { + setSmallError('') + setSmallSaved(false) + let provider = '' + let model = '' + if (value) { + // Provider ids never contain '/'; model ids may (custom endpoints). + const i = value.indexOf('/') + if (i >= 0) { + provider = value.slice(0, i) + model = value.slice(i + 1) + } else { + // Malformed ref without a separator (hand-edited config surfaced via + // the "unavailable" option): pass it as model-only so the API rejects + // it intact instead of us mangling the provider name. + model = value + } + } + setSmallSaving(true) + try { + await api.setSmallModel(provider, model) + dispatch(modelActions.setSmallModel(value)) + setSmallSaved(true) + window.setTimeout(() => setSmallSaved(false), 2000) + } catch (err) { + setSmallError(err instanceof Error ? err.message : String(err)) + } finally { + setSmallSaving(false) + } + } + async function refreshCatalog(providerId: string) { setCatalogLoading(providerId) try { @@ -591,8 +636,70 @@ function ProvidersTab() { ) } + // Enabled models grouped by provider for the small-model picker; providers + // with nothing enabled disappear entirely. + const roleOptions = pickerProviders + .map((p) => ({ ...p, models: p.models.filter((m) => m.enabled) })) + .filter((p) => p.models.length > 0) + // A configured ref whose model was since disabled/removed still renders (as + // its raw ref, marked unavailable) so it can be seen and cleared. + const smallModelListed = + smallModel === '' || roleOptions.some((p) => p.models.some((m) => `${p.id}/${m.id}` === smallModel)) + return (
+
+

{t('settings.providers.roles.title')}

+
+
+ +
+
+
+ {t('settings.providers.roles.smallName')} +
+
+ {roleOptions.length === 0 + ? t('settings.providers.roles.smallNoProviders') + : t('settings.providers.roles.smallDesc')} +
+ {smallError && ( +
+ {t('settings.providers.roles.smallSaveFailed', { reason: smallError })} +
+ )} + {smallSaved && ( +
+ {t('settings.providers.roles.smallSaved')} +
+ )} +
+ +
+
+

{t('settings.providers.title')}

diff --git a/web/src/i18n/locales/en.ts b/web/src/i18n/locales/en.ts index e990b922..6afbcca0 100644 --- a/web/src/i18n/locales/en.ts +++ b/web/src/i18n/locales/en.ts @@ -384,6 +384,16 @@ export default { }, providers: { title: 'Providers', + roles: { + title: 'Model roles', + smallName: 'Small model', + smallDesc: 'Routes the "small" alias (subagents, workflows, automations) and generates session titles. When unset, the alias falls back to the main model and titles use truncated text.', + smallNoProviders: 'Add a provider and enable a model to pick a small model.', + smallUnset: 'Not set (follow main model)', + smallSaved: 'Saved — takes effect immediately', + smallSaveFailed: 'Save failed: {reason}', + smallUnavailable: 'unavailable', + }, add: '+ Add provider', selectProvider: 'Select Provider', selectModel: 'Select Model', diff --git a/web/src/i18n/locales/ja.ts b/web/src/i18n/locales/ja.ts index f22f0c35..c59cbe8b 100644 --- a/web/src/i18n/locales/ja.ts +++ b/web/src/i18n/locales/ja.ts @@ -367,6 +367,16 @@ export default { }, providers: { title: 'プロバイダー', + roles: { + title: 'モデルロール', + smallName: 'Small model', + smallDesc: 'subagent / workflow / automation の "small" エイリアスのルーティングとセッションタイトル生成に使用します。未設定の場合、エイリアスはメインモデルにフォールバックし、タイトルは切り詰めテキストになります。', + smallNoProviders: 'small model を選ぶには、先にプロバイダーを追加してモデルを有効にしてください。', + smallUnset: '未設定(メインモデルに従う)', + smallSaved: '保存しました。即時反映されます', + smallSaveFailed: '保存に失敗しました:{reason}', + smallUnavailable: '利用不可', + }, add: '+ プロバイダーを追加', selectProvider: 'プロバイダーを選択', selectModel: 'モデルを選択', diff --git a/web/src/i18n/locales/ko.ts b/web/src/i18n/locales/ko.ts index e1cd543d..a7c9cc22 100644 --- a/web/src/i18n/locales/ko.ts +++ b/web/src/i18n/locales/ko.ts @@ -367,6 +367,16 @@ export default { }, providers: { title: '프로바이더', + roles: { + title: '모델 역할', + smallName: 'Small model', + smallDesc: 'subagent / workflow / automation의 "small" 별칭 라우팅과 세션 제목 생성에 사용됩니다. 설정하지 않으면 별칭은 메인 모델로 대체되고 제목은 잘린 텍스트를 사용합니다.', + smallNoProviders: 'small model을 선택하려면 먼저 프로바이더를 추가하고 모델을 활성화하세요.', + smallUnset: '설정 안 함(메인 모델 따름)', + smallSaved: '저장됨 — 즉시 적용됩니다', + smallSaveFailed: '저장 실패: {reason}', + smallUnavailable: '사용 불가', + }, add: '+ 프로바이더 추가', selectProvider: '프로바이더 선택', selectModel: '모델 선택', diff --git a/web/src/i18n/locales/zh-Hans.ts b/web/src/i18n/locales/zh-Hans.ts index b01d2927..621a944c 100644 --- a/web/src/i18n/locales/zh-Hans.ts +++ b/web/src/i18n/locales/zh-Hans.ts @@ -367,6 +367,16 @@ export default { }, providers: { title: '服务商', + roles: { + title: '模型角色', + smallName: 'Small model', + smallDesc: '用于 subagent / workflow / automation 的 "small" 别名路由与会话标题生成。未设置时别名回落主模型,标题使用截断文本。', + smallNoProviders: '先添加服务商并启用模型,才能选择 small model。', + smallUnset: '未设置(跟随主模型)', + smallSaved: '已保存,立即生效', + smallSaveFailed: '保存失败:{reason}', + smallUnavailable: '不可用', + }, add: '+ 添加服务商', selectProvider: '选择服务商', selectModel: '选择模型', diff --git a/web/src/i18n/locales/zh-Hant.ts b/web/src/i18n/locales/zh-Hant.ts index 215e9078..b1ae07c5 100644 --- a/web/src/i18n/locales/zh-Hant.ts +++ b/web/src/i18n/locales/zh-Hant.ts @@ -368,6 +368,16 @@ export default { }, providers: { title: '服務商', + roles: { + title: '模型角色', + smallName: 'Small model', + smallDesc: '用於 subagent / workflow / automation 的 "small" 別名路由與工作階段標題生成。未設定時別名回落主模型,標題使用截斷文字。', + smallNoProviders: '先新增服務商並啟用模型,才能選擇 small model。', + smallUnset: '未設定(跟隨主模型)', + smallSaved: '已儲存,立即生效', + smallSaveFailed: '儲存失敗:{reason}', + smallUnavailable: '不可用', + }, add: '+ 新增服務商', selectProvider: '選擇服務商', selectModel: '選擇模型', diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index f4bd9882..039289be 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -65,7 +65,7 @@ export const api = { mode: string token?: TokenUpdateData }>('/api/status'), - config: () => request<{ provider: string; model: string; max_iterations: number }>('/api/config'), + config: () => request<{ provider: string; model: string; small_model: string; max_iterations: number }>('/api/config'), usageStats: (days = 30) => request(`/api/usage/stats?days=${days}`), taskStats: (id: string) => request(`/api/tasks/${encodeURIComponent(id)}/stats`), todos: () => request('/api/todos'), @@ -119,6 +119,13 @@ export const api = { method: 'POST', body: JSON.stringify({ provider, model }), }), + // Set or clear config.small_model (both empty = clear). Persisted server-side; + // takes effect immediately (subagent "small" alias, session titles). + setSmallModel: (provider: string, model: string) => + request<{ status: string; small_model: string }>('/api/small-model', { + method: 'POST', + body: JSON.stringify({ provider, model }), + }), switchMode: (mode: string) => request<{ status: string; mode: string }>('/api/mode', { method: 'POST',