From 4fd813abe504aeb92d1d5af8978786caf8e67782 Mon Sep 17 00:00:00 2001 From: liuhailong <857688528@qq.com> Date: Sat, 26 Sep 2026 17:24:00 +0800 Subject: [PATCH] feat(webui): thinking-effort picker + protocol-aware model selector MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ticket 04 — model selector upgrade (final slice of the provider-configuration feature). Built on the schema (01), presets (02), and management UI (03) already merged on `main`. Server (engine ↔ webui contract): - GET /api/models now surfaces `currentThinking` from the engine's `thinkingEffort` configOption (falling back to `cs.model.thinking`, then `null`) and routes the per-model `thinkingLevels` / `modalities` through the response — the new selector reads them directly without a second round-trip. - POST /api/set-model accepts `{model?, thinking?}` independently: a thinking-only update leaves the model alone, a model-only update carries the recorded effort with it, and `thinking: ""` clears the recorded override. When a session exists, both pushes go through `session/set_config_option{configId: "thinkingEffort"}` — the engine's `thinkingEffort` configId (see packages/tui/src/acp/ control-state.ts#ACP_CONFIG_THINKING_EFFORT) maps 1:1 to the webui level; the engine validates the level against the selected model's `effortOptions` and answers invalidParams on a mismatch, which the route surfaces as `thinkingSynced: false` + `warning`. Pre-session the recorded effort also flows through the same path on the next `applyRecordedModel`, which now pushes model first then effort (engine contract: "Select a Session model before changing thinking effort.", agent.ts#1003). - applyConfigOptionUpdate propagates the engine's `thinkingEffort.currentValue` into `cs.model.thinking` so a TUI change propagates back through the SSE snapshot. - state-bus default for `model.thinking` switched from the cosmetic "On" to "" (the runtime "no override" sentinel); the snapshot is built through the existing `...cs` spread, so the new value rides on every push without further wiring. Frontend (composer ModelSelect + ThinkingEffortSelect): - ModelSelect now consumes the `groups[]` shape with `auth.{hasKey, type}` and a per-model `modalities[]`. Groups with `hasKey === false` render greyed with a "configure in Settings" hint, models carry modality badges (text/image/audio/video/file → i18n). The engine session group (`__engine`) has no `auth` and stays usable. - ThinkingEffortSelect is mounted next to the model selector only when the active model declares a non-empty `thinkingLevels`; the menu enumerates exactly the model's levels plus an "engine default" option (sends `thinking: ""`). Disabled while a run is active — the documented "running → next turn" semantic. - Chip label appends `· ` when `cs.model.thinking` is set. - composer.tsx wires both selectors through the new `api.setModel({model, thinking})` payload, threading the recorded effort across mid-session model changes. Tests: - model.check.mjs: handleGetModels surfaces `currentThinking` (engine / pre-session / null paths); handleSetModel persists, pushes, and surfaces `thinkingSynced` for the 6 new branches (both, model-only, thinking-only, clear, empty-payload 400, engine rejection, in-place configOptions mirror). - mcode-acp-note.test.js: applyRecordedModel for the 6 new effort paths (both / effort-only / model-only / empty / no modelOption / engine-rejected-effort) plus applyConfigOptionUpdate for the 3 new propagation paths. - composer-models.test.ts: 11 new tests covering `isGroupDisabled`, `modalityBadgeKey`, `thinkingLevelKey`, and the setModel payload shape the composer sends (model+effort together, model-only, effort-only, clear). Gates: webapp typecheck 0 errs, webapp tests 264/264 pass (+11), unit tests 825/825 pass (no regressions), `pnpm build` green, `pnpm check:source` 4585 files clean. Live self-check (isolated 18108/18109, own data dir, fresh providers.json with one byok + two no-key providers): open model selector → no-key groups greyed with "请在设置中配置 API Key" hint, modality badges visible on every model row, pick `openai_compat/ gpt-4o` (thinkingLevels [low, medium, high]) → effort picker mounts, pick "高" → chip becomes "GPT-4o · 高", reload → both selections round-trip via `currentThinking` (engine-side pre-session record). Screenshots under /tmp/dev-ms/shot-ms{1,2,3}-*.png. --- packages/webui/server/lib/mcode-acp.js | 129 ++++- packages/webui/server/lib/state-bus.js | 2 +- packages/webui/server/routes/model.js | 96 +++- .../webui/test/lib/mcode-acp-note.test.js | 166 ++++++ packages/webui/test/routes/model.check.mjs | 214 ++++++++ packages/webui/webapp/components/composer.tsx | 519 +++++++++++++++--- packages/webui/webapp/lib/api.ts | 66 ++- packages/webui/webapp/lib/i18n.ts | 34 ++ packages/webui/webapp/lib/types.ts | 5 + .../webui/webapp/test/composer-models.test.ts | 175 ++++++ 10 files changed, 1284 insertions(+), 122 deletions(-) diff --git a/packages/webui/server/lib/mcode-acp.js b/packages/webui/server/lib/mcode-acp.js index b8b027a9..5ba400ed 100644 --- a/packages/webui/server/lib/mcode-acp.js +++ b/packages/webui/server/lib/mcode-acp.js @@ -40,7 +40,8 @@ import { loadSessions, saveSessions } from "./sessions.js"; // mcode-exec (which honours --permission ask/full/auto/off). /** - * Push the recorded pre-session model pick to a brand-new engine session. + * Push the recorded pre-session model pick (and, if recorded, the + * matching thinking-effort level) to a brand-new engine session. * * Called from `runMcodeAcp` immediately after `session/new` returns, while * the new `McodeAcpClient` is still in scope but not yet registered as the @@ -61,6 +62,14 @@ import { loadSessions, saveSessions } from "./sessions.js"; * A successful apply updates `cs.configOptions` with the new currentValue * so the next `/api/models` reads the same model the engine is running. * + * Engine contract: the `thinkingEffort` config option is rejected when + * no model is selected (`Select a Session model before changing + * thinking effort.`, agent.ts#1003). We push the model first, then the + * effort, in that order — and only when a level was recorded + * (`cs.model.thinking` non-empty). The level is otherwise accepted + * as-is: the engine validates it against the selected model's + * effortOptions and answers invalidParams on a mismatch. + * * Errors are swallowed: a fresh session with the engine's default is * better than a failed session start; the user can re-pick on the chip. */ @@ -68,37 +77,82 @@ async function applyRecordedModel(client, sid, cs, cid) { const recorded = cs && cs.model && typeof cs.model.name === "string" ? cs.model.name.trim() : ""; - if (!recorded) return; - const modelOption = findModelOption(cs); - if (!modelOption) return; // engine hasn't reported its model option yet - const engineCurrent = modelOption.currentValue; - if (matchesModelId(recorded, engineCurrent, modelOption)) return; + const recordedThinking = cs && cs.model && typeof cs.model.thinking === "string" + ? cs.model.thinking.trim() + : ""; - const resolved = resolveModelId(recorded, modelOption); - if (!resolved) { - console.warn( - `[webui] applyRecordedModel: recorded id "${recorded}" does not match any engine option; skipping`, - ); - return; + let modelApplied = false; + if (recorded) { + const modelOption = findModelOption(cs); + if (!modelOption) { + // Engine hasn't reported its model option yet — neither apply + // can fire (the engine rejects effort before a model is selected). + // Bail; both will get re-attempted on the next session event that + // carries a fresh configOptions list. + return; + } + const engineCurrent = modelOption.currentValue; + if (!matchesModelId(recorded, engineCurrent, modelOption)) { + const resolved = resolveModelId(recorded, modelOption); + if (!resolved) { + console.warn( + `[webui] applyRecordedModel: recorded id "${recorded}" does not match any engine option; skipping`, + ); + } else { + await client.request("session/set_config_option", { + sessionId: sid, + configId: "model", + value: resolved, + }); + // Reflect the apply on the local config-options snapshot so a + // follow-up /api/models reads the engine's new currentValue + // instead of the session-boot default. The engine pushes a + // `config_option_update` notification when it processes the + // apply; this local update is the synchronous mirror that keeps + // the chip and the engine in lockstep before the next SSE flush + // lands. + const opts = Array.isArray(cs.configOptions) ? cs.configOptions : []; + for (const o of opts) { + if (o && o.id === "model" && typeof o === "object") { + o.currentValue = resolved; + } + } + modelApplied = true; + } + } } - await client.request("session/set_config_option", { - sessionId: sid, - configId: "model", - value: resolved, - }); - // Reflect the apply on the local config-options snapshot so a follow-up - // /api/models reads the engine's new currentValue instead of the - // session-boot default. The engine pushes a `config_option_update` - // notification when it processes the apply; this local update is the - // synchronous mirror that keeps the chip and the engine in lockstep - // before the next SSE flush lands. - const opts = Array.isArray(cs.configOptions) ? cs.configOptions : []; - for (const o of opts) { - if (o && o.id === "model" && typeof o === "object") { - o.currentValue = resolved; + + // Thinking-effort apply (ticket 04): only when a level was recorded + // AND a model is selected (recorded or just applied). The engine + // validates the level against the selected model's effortOptions; a + // rejection is logged and otherwise ignored — the engine's default + // stands, and the next /api/models reflects that. + if (recordedThinking) { + if (!recorded && !modelApplied) { + // No recorded model and the engine's current model is unknown to + // us; we have no anchor for the effort. Skip. + return; + } + try { + await client.request("session/set_config_option", { + sessionId: sid, + configId: "thinkingEffort", + value: recordedThinking, + }); + const opts = Array.isArray(cs.configOptions) ? cs.configOptions : []; + for (const o of opts) { + if (o && o.id === "thinkingEffort" && typeof o === "object") { + o.currentValue = recordedThinking; + } + } + } catch (e) { + console.warn( + `[webui] applyRecordedModel: thinking effort "${recordedThinking}" rejected: ${e.message}`, + ); } } - if (cid) pushStateFor(cid); + + if ((modelApplied || recordedThinking) && cid) pushStateFor(cid); } /** Locate the engine's `model` config option, or null if none was reported yet. */ @@ -346,8 +400,9 @@ export function buildEmptyTurnNote(stopReason, answer) { // applyConfigOptionUpdate — handle the engine's `config_option_update` // session event. Replaces `cs.configOptions` wholesale (the engine sends // the whole list), propagates `permissionMode.currentValue` through -// `mcodePermissionToWebui`, and propagates `model.currentValue` into -// `cs.model.name`. The model field is read with the same +// `mcodePermissionToWebui`, propagates `model.currentValue` into +// `cs.model.name`, and propagates `thinkingEffort.currentValue` into +// `cs.model.thinking`. The model field is read with the same // `option.currentValue` contract that `routes/model.js#handleGetModels` // uses, so the two cannot disagree about which holds the encoded id. // When the model option is absent or its currentValue is empty, @@ -366,6 +421,20 @@ export function applyConfigOptionUpdate(cs, update) { if (model && model.currentValue) { cs.model = { ...(cs.model || {}), name: model.currentValue }; } + const thinking = opts.find((o) => o && o.id === "thinkingEffort"); + if (thinking) { + // currentValue can legitimately be empty (engine's default or no + // override); reflect that exactly so the picker shows "off" rather + // than a stale level. The field is dropped when the option is + // missing altogether (model without an effort dimension). + if (typeof thinking.currentValue === "string" && thinking.currentValue) { + cs.model = { ...(cs.model || {}), thinking: thinking.currentValue }; + } else if (cs.model && "thinking" in cs.model) { + const { thinking: _drop, ...rest } = cs.model; + void _drop; + cs.model = rest; + } + } } // applyToolUpdate — handle a `tool_update` (a.k.a. `tool_call_update`) diff --git a/packages/webui/server/lib/state-bus.js b/packages/webui/server/lib/state-bus.js index 7e7ff270..e446d7bc 100644 --- a/packages/webui/server/lib/state-bus.js +++ b/packages/webui/server/lib/state-bus.js @@ -36,7 +36,7 @@ export function makeClientState() { return { version: "1.0", // 顶栏显示 "v" + version workspace: { dir: DEFAULT_WORKSPACE, branch: null, tree: null }, - model: { name: DEFAULT_MODEL, thinking: "On", ctx: "512k" }, + model: { name: DEFAULT_MODEL, thinking: "", ctx: "512k" }, sessionId: null, // webui 侧边栏 session id (randomUUID) mcodeSessionId: null, // mcode acp/exec 自己的 session id (mvs_xxx) sessionTitle: "Untitled", diff --git a/packages/webui/server/routes/model.js b/packages/webui/server/routes/model.js index c26d98f9..b4ab5040 100644 --- a/packages/webui/server/routes/model.js +++ b/packages/webui/server/routes/model.js @@ -256,6 +256,22 @@ export function handleGetModels(_req, res, ctx) { currentName || null; + // Current thinking-effort level: read the engine's `thinkingEffort` + // option when present; otherwise fall back to `cs.model.thinking`, + // which `handleSetModel` writes (pre-session record) and which the + // engine's `config_option_update` notification refreshes via + // `applyConfigOptionUpdate` (see lib/mcode-acp.js). The selector + // reads this to highlight the active level and to skip the picker + // when the active model has no `thinkingLevels`. + const thinkingEffortOption = + Array.isArray(cs && cs.configOptions) ? cs.configOptions.find((o) => o && o.id === "thinkingEffort") : null; + const currentThinking = + (thinkingEffortOption && typeof thinkingEffortOption.currentValue === "string" + ? thinkingEffortOption.currentValue + : null) || + (cs && cs.model && typeof cs.model.thinking === "string" && cs.model.thinking) || + null; + const source = option && Array.isArray(option.options) && option.options.length > 0 ? "acp-session-config" @@ -270,6 +286,7 @@ export function handleGetModels(_req, res, ctx) { models: list, groups, current, + currentThinking, source, // Backwards-compat: surface the same soft-failure marker the older // engine-only build did when nothing could be sourced. With the @@ -285,32 +302,97 @@ export function handleGetModels(_req, res, ctx) { // POST /api/set-model — only updates cs.model; with a session the same value // is also pushed to the engine via session/set_config_option. +// +// Body: `{ model: string, thinking?: string }`. `thinking` is the +// reasoning-effort level the engine accepts on its `thinkingEffort` +// config option (`low` / `medium` / `high`, plus `off` / `none` for +// models that disable reasoning — see the engine's control-state.ts +// `thinkingEffortOption`). Per the engine's contract, the +// `thinkingEffort` set is rejected when no model is selected +// (`Select a Session model before changing thinking effort.`, +// agent.ts#1003), so a thinking-only update routes the same way the +// set_config_option engine path expects: model first, then effort. +// +// `thinking` is OPTIONAL: a model-only update leaves the recorded +// effort intact (it gets re-applied on the next session boot via +// `applyRecordedModel`); an effort-only update leaves the model alone. +// An empty string clears the recorded effort, signalling "no override +// — let the engine's default stand". export async function handleSetModel(req, res, ctx) { const cs = ctx.cs; const cid = ctx.cid; const payload = await readJson(req); - const modelId = (payload.model || "").trim(); - if (!modelId) { + const modelId = typeof payload.model === "string" ? payload.model.trim() : ""; + const rawThinking = + typeof payload.thinking === "string" ? payload.thinking.trim() : undefined; + // "no field" → keep the existing cs.model.thinking; "empty string" → + // clear it (no override). Both arrive as falsy here, but the + // distinction is encoded by `thinkingWasProvided`. + const thinkingWasProvided = Object.prototype.hasOwnProperty.call(payload, "thinking"); + const thinking = thinkingWasProvided ? (rawThinking || "") : undefined; + if (!modelId && !thinkingWasProvided) { res.writeHead(400, { "Content-Type": "application/json" }); return res.end(JSON.stringify({ ok: false, error: "model required" })); } cs.model = cs.model || {}; - cs.model.name = modelId; + if (modelId) cs.model.name = modelId; + if (thinkingWasProvided) { + cs.model.thinking = thinking; + } const sid = cs.mcodeSessionId; let mcodeSynced = false; + let thinkingSynced = false; let warning = sid ? null : "no mcode session yet — recorded for the next one"; + // Engine contract: model first, then thinkingEffort (the engine + // rejects a thinkingEffort set when no model is selected). Only push + // when BOTH the recorded model and the new (or unchanged) thinking + // are concrete — the engine will validate the level against the + // selected model's effortOptions and reject unknown values. if (sid) { - const r = await setConfigOption(sid, "model", modelId, ctx.cid); - mcodeSynced = r.ok; - if (!r.ok) warning = r.error; + if (modelId) { + const r = await setConfigOption(sid, "model", modelId, ctx.cid); + mcodeSynced = r.ok; + if (!r.ok) warning = r.error; + } + if (thinkingWasProvided && thinking) { + const r = await setConfigOption(sid, "thinkingEffort", thinking, ctx.cid); + thinkingSynced = r.ok; + if (!r.ok && (!warning || warning === null || warning === "no mcode session yet — recorded for the next one")) { + warning = r.error; + } + if (r.ok) { + // Mirror the apply on the local configOptions snapshot so a + // follow-up /api/models reads the engine's new currentValue + // before the SSE flush lands (same reason as + // applyRecordedModel's cs.configOptions write). + const opts = Array.isArray(cs.configOptions) ? cs.configOptions : []; + for (const o of opts) { + if (o && o.id === "thinkingEffort") { + o.currentValue = thinking; + } + } + } + } else if (thinkingWasProvided && !thinking && modelId) { + // Model changed AND effort cleared. The engine picks its own + // default for the new model; we drop the local mirror so a + // subsequent /api/models doesn't keep showing the cleared value. + const opts = Array.isArray(cs.configOptions) ? cs.configOptions : []; + for (const o of opts) { + if (o && o.id === "thinkingEffort") { + delete o.currentValue; + } + } + } } pushStateFor(cid); res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" }); return res.end( JSON.stringify({ ok: true, - model: modelId, + ...(modelId ? { model: modelId } : {}), + ...(thinkingWasProvided ? { thinking } : {}), mcodeSynced, + thinkingSynced, ...(warning ? { warning } : {}), }), ); diff --git a/packages/webui/test/lib/mcode-acp-note.test.js b/packages/webui/test/lib/mcode-acp-note.test.js index 2785a9f9..fda6d0a4 100644 --- a/packages/webui/test/lib/mcode-acp-note.test.js +++ b/packages/webui/test/lib/mcode-acp-note.test.js @@ -251,6 +251,48 @@ describe("applyConfigOptionUpdate — propagate model + permissionMode (defect # }); }); +// ============================================================ +// Ticket 04 — applyConfigOptionUpdate also propagates +// `thinkingEffort.currentValue` into `cs.model.thinking`. The field is +// dropped when the engine clears it (empty currentValue) so the picker +// shows "off" rather than a stale level. +// ============================================================ + +describe("applyConfigOptionUpdate — propagate thinkingEffort (ticket 04)", () => { + function optsWithThinking(thinking) { + return [ + { id: "model", type: "select", currentValue: "minimax_api:MiniMax-M3", options: [] }, + { id: "thinkingEffort", type: "select", currentValue: thinking, options: [] }, + ]; + } + + test("propagates a thinkingEffort change into cs.model.thinking", () => { + const cs = { model: { name: "minimax_api:MiniMax-M3", thinking: "low" }, permissions: "Full access" }; + applyConfigOptionUpdate(cs, { configOptions: optsWithThinking("high") }); + assert.equal(cs.model.thinking, "high"); + assert.equal(cs.model.name, "minimax_api:MiniMax-M3"); + }); + + test("clears cs.model.thinking when the engine clears its currentValue", () => { + const cs = { model: { name: "minimax_api:MiniMax-M3", thinking: "high" }, permissions: "Full access" }; + applyConfigOptionUpdate(cs, { configOptions: optsWithThinking("") }); + assert.equal( + Object.prototype.hasOwnProperty.call(cs.model, "thinking"), + false, + "thinking field dropped — picker shows no override", + ); + assert.equal(cs.model.name, "minimax_api:MiniMax-M3"); + }); + + test("leaves cs.model alone when no thinkingEffort option is in the update", () => { + const cs = { model: { name: "minimax_api:MiniMax-M3", thinking: "low" }, permissions: "Full access" }; + applyConfigOptionUpdate(cs, { + configOptions: [{ id: "model", type: "select", currentValue: "minimax_api:MiniMax-M3", options: [] }], + }); + assert.equal(cs.model.thinking, "low", "untouched when option absent"); + }); +}); + // ============================================================ // v0.5.by: pre-session model apply — resolution helpers. // @@ -458,3 +500,127 @@ describe("applyRecordedModel — integration with a fake acp client", () => { assert.deepEqual(calls, [], "unknown id → engine default stands"); }); }); + +// ============================================================ +// Ticket 04 — pre-session apply of the recorded thinking-effort +// level. The engine contract requires a model to be selected before +// `thinkingEffort` is accepted; the helper pushes model first (when +// recorded) and effort second (when recorded). +// ============================================================ + +describe("applyRecordedModel — thinkingEffort pre-session apply (ticket 04)", () => { + function clientRecorder() { + const calls = []; + return { + calls, + client: { request: async (m, p) => { calls.push([m, p]); return {}; } }, + }; + } + // Deep-clone the shared option constants per cs so the in-place + // mutations `applyRecordedModel` performs on `currentValue` do not + // bleed across tests in this file (the test that asserts the local + // mirror stays at "low" after a failed effort apply would otherwise + // see "medium" left behind by an earlier passing test). + function csWithOptions(model, thinking) { + return { + model: { name: model, thinking }, + configOptions: [ + JSON.parse(JSON.stringify(MODEL_OPTION)), + { + type: "select", + id: "thinkingEffort", + currentValue: "low", + options: [ + { value: "low", name: "Low" }, + { value: "medium", name: "Medium" }, + { value: "high", name: "High" }, + ], + }, + ], + }; + } + + test("applies recorded thinkingEffort after the model when both are recorded", async () => { + const { calls, client } = clientRecorder(); + const cs = csWithOptions("minimax_api/MiniMax-M2.5", "high"); + await applyRecordedModel(client, "sid-1", cs, "cid-1"); + assert.equal(calls.length, 2, "model then effort"); + assert.equal(calls[0][0], "session/set_config_option"); + assert.equal(calls[0][1].configId, "model"); + assert.equal(calls[1][1].configId, "thinkingEffort"); + assert.equal(calls[1][1].value, "high"); + // Local config-options mirror reflects both applies. + assert.equal(cs.configOptions[0].currentValue, "minimax_api:MiniMax-M2.5"); + assert.equal(cs.configOptions[1].currentValue, "high"); + }); + + test("applies only the effort when the recorded model already matches the engine", async () => { + const { calls, client } = clientRecorder(); + const cs = csWithOptions("minimax_api:MiniMax-M3", "medium"); + await applyRecordedModel(client, "sid-1", cs, "cid-1"); + assert.equal(calls.length, 1); + assert.equal(calls[0][1].configId, "thinkingEffort"); + assert.equal(calls[0][1].value, "medium"); + }); + + test("applies only the model when no thinking level is recorded", async () => { + const { calls, client } = clientRecorder(); + const cs = csWithOptions("minimax_api/MiniMax-M2.5", ""); + await applyRecordedModel(client, "sid-1", cs, "cid-1"); + assert.equal(calls.length, 1); + assert.equal(calls[0][1].configId, "model"); + }); + + test("skips entirely when nothing is recorded", async () => { + const { calls, client } = clientRecorder(); + const cs = { model: { name: "", thinking: "" }, configOptions: [ + JSON.parse(JSON.stringify(MODEL_OPTION)), + { type: "select", id: "thinkingEffort", currentValue: "low", options: [] }, + ] }; + await applyRecordedModel(client, "sid-1", cs, "cid-1"); + assert.deepEqual(calls, []); + }); + + test("skips entirely when no modelOption has been reported (engine still booting)", async () => { + const { calls, client } = clientRecorder(); + // No MODEL_OPTION in configOptions → engine hasn't reported its + // model option yet. The effort apply would be rejected by the + // engine contract anyway. + const cs = { + model: { name: "minimax_api/MiniMax-M3", thinking: "high" }, + configOptions: [{ id: "permissionMode", type: "select", options: [] }], + }; + await applyRecordedModel(client, "sid-1", cs, "cid-1"); + assert.deepEqual(calls, []); + }); + + test("logs a warning when the engine rejects the effort level (unknown effort)", async () => { + const calls = []; + const client = { + request: async (m, p) => { + calls.push([m, p]); + if (p && p.configId === "thinkingEffort") { + throw new Error("Thinking effort is not advertised for the selected model: turbo"); + } + return {}; + }, + }; + const warnings = []; + const origWarn = console.warn; + console.warn = (msg) => warnings.push(msg); + let cs; + try { + cs = csWithOptions("minimax_api/MiniMax-M2.5", "turbo"); + await applyRecordedModel(client, "sid-1", cs, "cid-1"); + } finally { + console.warn = origWarn; + } + // Model still went through; effort was rejected and logged. + assert.equal(calls.length, 2); + assert.equal(calls[0][1].configId, "model"); + assert.equal(calls[1][1].configId, "thinkingEffort"); + assert.ok(warnings.some((w) => /turbo/.test(w))); + // Local mirror not updated on the failed effort. + assert.equal(cs.configOptions[1].currentValue, "low"); + }); +}); diff --git a/packages/webui/test/routes/model.check.mjs b/packages/webui/test/routes/model.check.mjs index fa75553f..a7a4e276 100644 --- a/packages/webui/test/routes/model.check.mjs +++ b/packages/webui/test/routes/model.check.mjs @@ -157,6 +157,177 @@ describe("handleSetModel — /api/set-model", () => { }); }); +// ============================================================ +// Ticket 04 — thinking-effort payload on /api/set-model. +// +// Body shape: { model: string, thinking?: string }. +// * thinking absent → keep cs.model.thinking untouched (model-only update). +// * thinking === "" → clear cs.model.thinking (no override). +// * thinking === "low"/"medium"/"high"/"off" → persist + push. +// The engine contract is "model first, then thinkingEffort"; with no +// session, both writes are local + carry the warning string. +// ============================================================ + +import { registerRpcMock } from "../helpers/_setup.js"; + +describe("handleSetModel — /api/set-model thinking payload", () => { + test("persists thinkingEffort alongside the model and pushes to the engine", async () => { + const calls = []; + registerRpcMock({ + setConfigOption: async (_sid, configId, value, _cid) => { + calls.push({ configId, value }); + return { ok: true, data: {} }; + }, + }); + const cs = fakeCs("minimax_api/MiniMax-M3"); + cs.mcodeSessionId = "mvs_test"; + const ctx = { cs, cid: "cid-1" }; + const res = fakeRes(); + await modelRoute.handleSetModel( + fakeReq({ model: "minimax_api/MiniMax-M2.7", thinking: "high" }), + res, + ctx, + ); + assert.equal(res._status, 200); + const body = JSON.parse(res._body); + assert.equal(body.ok, true); + assert.equal(body.model, "minimax_api/MiniMax-M2.7"); + assert.equal(body.thinking, "high"); + assert.equal(body.mcodeSynced, true); + assert.equal(body.thinkingSynced, true); + assert.equal(cs.model.name, "minimax_api/MiniMax-M2.7"); + assert.equal(cs.model.thinking, "high"); + // Engine contract: model before effort. + assert.equal(calls.length, 2); + assert.deepEqual(calls[0], { configId: "model", value: "minimax_api/MiniMax-M2.7" }); + assert.deepEqual(calls[1], { configId: "thinkingEffort", value: "high" }); + }); + + test("thinking-only update (no model in payload) leaves cs.model.name alone", async () => { + const calls = []; + registerRpcMock({ + setConfigOption: async (_sid, configId, value, _cid) => { + calls.push({ configId, value }); + return { ok: true, data: {} }; + }, + }); + const cs = fakeCs("minimax_api/MiniMax-M3"); + cs.mcodeSessionId = "mvs_test"; + const ctx = { cs, cid: "cid-1" }; + const res = fakeRes(); + await modelRoute.handleSetModel(fakeReq({ thinking: "medium" }), res, ctx); + const body = JSON.parse(res._body); + assert.equal(body.ok, true); + assert.equal(body.model, undefined, "no model field echoed"); + assert.equal(body.thinking, "medium"); + assert.equal(cs.model.name, "minimax_api/MiniMax-M3", "model untouched"); + assert.equal(cs.model.thinking, "medium"); + // Only one engine call — no model push, just the effort push. + assert.equal(calls.length, 1); + assert.deepEqual(calls[0], { configId: "thinkingEffort", value: "medium" }); + }); + + test("thinking:'' clears the recorded effort", async () => { + const calls = []; + registerRpcMock({ + setConfigOption: async (_sid, configId, value, _cid) => { + calls.push({ configId, value }); + return { ok: true, data: {} }; + }, + }); + const cs = fakeCs("minimax_api/MiniMax-M3"); + cs.model.thinking = "high"; + cs.mcodeSessionId = "mvs_test"; + const ctx = { cs, cid: "cid-1" }; + const res = fakeRes(); + await modelRoute.handleSetModel(fakeReq({ model: "minimax_api/MiniMax-M3", thinking: "" }), res, ctx); + assert.equal(cs.model.thinking, ""); + // Empty effort → no engine call (the engine's default stands). + assert.equal(calls.length, 1, "no effort push on clear"); + assert.equal(calls[0].configId, "model", "model still pushed"); + }); + + test("missing model with no thinking still 400s (payload was empty)", async () => { + const cs = fakeCs(); + const ctx = { cs, cid: "cid-1" }; + const res = fakeRes(); + await modelRoute.handleSetModel(fakeReq({}), res, ctx); + assert.equal(res._status, 400); + }); + + test("without a session, thinking persists locally and the warning is set", async () => { + registerRpcMock({ + setConfigOption: async () => { + throw new Error("should not be called without a session"); + }, + }); + const cs = fakeCs("minimax_api/MiniMax-M3"); + // mcodeSessionId intentionally absent. + const ctx = { cs, cid: "cid-1" }; + const res = fakeRes(); + await modelRoute.handleSetModel( + fakeReq({ model: "minimax_api/MiniMax-M2.7", thinking: "low" }), + res, + ctx, + ); + assert.equal(cs.model.name, "minimax_api/MiniMax-M2.7"); + assert.equal(cs.model.thinking, "low"); + const body = JSON.parse(res._body); + assert.equal(body.mcodeSynced, false); + assert.equal(body.thinkingSynced, false); + assert.match(body.warning, /no mcode session/); + }); + + test("engine rejection of the effort surfaces in the response without dropping the model apply", async () => { + const calls = []; + registerRpcMock({ + setConfigOption: async (_sid, configId, value, _cid) => { + calls.push({ configId, value }); + if (configId === "thinkingEffort") { + return { ok: false, error: "Thinking effort is not advertised for the selected model: turbo" }; + } + return { ok: true, data: {} }; + }, + }); + const cs = fakeCs("minimax_api/MiniMax-M3"); + cs.mcodeSessionId = "mvs_test"; + const ctx = { cs, cid: "cid-1" }; + const res = fakeRes(); + await modelRoute.handleSetModel( + fakeReq({ model: "minimax_api/MiniMax-M2.7", thinking: "turbo" }), + res, + ctx, + ); + assert.equal(cs.model.name, "minimax_api/MiniMax-M2.7", "model still applied"); + assert.equal(cs.model.thinking, "turbo", "local record preserved for next session boot"); + const body = JSON.parse(res._body); + assert.equal(body.mcodeSynced, true); + assert.equal(body.thinkingSynced, false); + assert.match(body.warning, /turbo/); + }); + + test("engine acceptance updates cs.configOptions in lockstep (synchronous mirror)", async () => { + registerRpcMock({ + setConfigOption: async () => ({ ok: true, data: {} }), + }); + const cs = fakeCs("minimax_api/MiniMax-M3"); + cs.mcodeSessionId = "mvs_test"; + cs.configOptions = [ + { id: "model", type: "select", currentValue: "minimax_api/MiniMax-M3" }, + { id: "thinkingEffort", type: "select", currentValue: "low" }, + ]; + const ctx = { cs, cid: "cid-1" }; + const res = fakeRes(); + await modelRoute.handleSetModel( + fakeReq({ model: "minimax_api/MiniMax-M3", thinking: "high" }), + res, + ctx, + ); + assert.equal(cs.configOptions[0].currentValue, "minimax_api/MiniMax-M3"); + assert.equal(cs.configOptions[1].currentValue, "high"); + }); +}); + describe("handleSetPermissions — /api/permissions (5 mode mappings)", () => { test("'ask' maps to 'Ask' label", async () => { const cs = fakeCs(); @@ -410,6 +581,49 @@ describe("handleGetModels — catalogue merge", () => { assert.equal(body.current, "minimax_api:MiniMax-M3"); }); + test("surfaces the engine's thinkingEffort currentValue as `currentThinking`", () => { + setBuiltinModelsMock([]); + const cs = fakeCs(undefined, [ + MODEL_OPTION, + { + id: "thinkingEffort", + type: "select", + currentValue: "high", + options: [ + { value: "low", name: "Low" }, + { value: "medium", name: "Medium" }, + { value: "high", name: "High" }, + ], + }, + ]); + const res = fakeRes(); + modelRoute.handleGetModels(null, res, { cs, cid: "cid-thinking1" }); + const body = JSON.parse(res._body); + assert.equal(body.currentThinking, "high"); + }); + + test("falls back to cs.model.thinking when the engine has no thinkingEffort option yet", () => { + // Pre-session record: cs.model.thinking is set by handleSetModel, + // the engine hasn't pushed its configOption list yet. + setBuiltinModelsMock(["MiniMax-M3"]); + const cs = fakeCs("minimax_api/MiniMax-M3"); + cs.model.thinking = "low"; + const res = fakeRes(); + modelRoute.handleGetModels(null, res, { cs, cid: "cid-thinking2" }); + const body = JSON.parse(res._body); + assert.equal(body.currentThinking, "low"); + }); + + test("currentThinking is null when neither the engine nor cs.model.thinking has a value", () => { + setBuiltinModelsMock(["MiniMax-M3"]); + const cs = fakeCs("minimax_api/MiniMax-M3"); + cs.model.thinking = ""; // explicit "no override" — the runtime default + const res = fakeRes(); + modelRoute.handleGetModels(null, res, { cs, cid: "cid-thinking3" }); + const body = JSON.parse(res._body); + assert.equal(body.currentThinking, null); + }); + test("providers config id wins over builtin id collision", () => { // Same provider prefix + same model id from both sources: the // config entry is added first, so the builtin pass sees the id diff --git a/packages/webui/webapp/components/composer.tsx b/packages/webui/webapp/components/composer.tsx index eb0c7493..9d165820 100644 --- a/packages/webui/webapp/components/composer.tsx +++ b/packages/webui/webapp/components/composer.tsx @@ -116,6 +116,17 @@ export function Composer({ t, inline = false }: { t: (key: MessageKey) => string provider?: string; contextLimit?: number; source?: "engine" | "config" | "builtin"; + protocol?: "openai" | "anthropic" | "gemini"; + thinkingLevels?: string[]; + modalities?: string[]; + }[] + >([]); + const [groups, setGroups] = useState< + { + id: string; + label: string; + auth?: { hasKey: boolean; type: "byok" | "coding-plan" }; + protocol?: "openai" | "anthropic" | "gemini"; }[] >([]); const [slashIndex, setSlashIndex] = useState(0); @@ -164,7 +175,7 @@ export function Composer({ t, inline = false }: { t: (key: MessageKey) => string useEffect(() => { void api .listModels() - .then((payload) => + .then((payload) => { setModels( (payload.models ?? []).map((m) => ({ id: m.id, @@ -172,9 +183,20 @@ export function Composer({ t, inline = false }: { t: (key: MessageKey) => string provider: m.provider, contextLimit: m.contextLimit, source: m.source, + protocol: m.protocol, + thinkingLevels: m.thinkingLevels, + modalities: m.modalities, + })), + ); + setGroups( + (payload.groups ?? []).map((g) => ({ + id: g.id, + label: g.label, + auth: g.auth, + protocol: g.protocol, })), - ), - ) + ); + }) .catch(() => {}); }, [modelKey, sessionKey, providersRevision]); @@ -220,6 +242,10 @@ export function Composer({ t, inline = false }: { t: (key: MessageKey) => string * names for one model. Resolve through the catalogue so both surfaces name * the same thing, and fall back to the value only when the engine lists no * entry for it. + * + * When a thinking-effort level is recorded (`state.model.thinking`), + * append a short tag like "· High" so the user can see what they're + * about to send without opening the picker. */ const currentModelLabel = useMemo(() => { const value = state?.model?.name ?? ""; @@ -227,13 +253,38 @@ export function Composer({ t, inline = false }: { t: (key: MessageKey) => string // is nothing to claim. Rendering the state's default here is how the chip // came to say `MiniMax-M3` while the session ran something else — the // default is webui's own constant, in an encoding the engine does not use. - if (models.length === 0) return t("composer.model"); + let baseLabel: string; + if (models.length === 0) baseLabel = t("composer.model"); + else { + const known = models.find((model) => model.id === value); + if (known) baseLabel = modelDisplayName(known.label); + // A catalogue without this value: show the engine's own string rather + // than inventing a label for it. + else baseLabel = value || t("composer.model"); + } + const thinking = state?.model?.thinking; + if (!thinking) return baseLabel; + const level = thinkingLevelKey(thinking); + if (!level) return baseLabel; + return `${baseLabel} · ${t(level)}`; + }, [models, state?.model?.name, state?.model?.thinking, t]); + + /** + * The thinking-effort levels the active model supports. + * + * The picker is mounted only when this list is non-empty; a model + * that does not advertise reasoning controls never shows a no-op + * control. The active model's id is matched against the catalogue + * the same way `currentModelLabel` does; missing match → empty + * picker (e.g. mid-fetch, or the engine encoded an id the + * catalogue doesn't carry). + */ + const thinkingLevelsForActive = useMemo(() => { + const value = state?.model?.name ?? ""; + if (!value) return []; const known = models.find((model) => model.id === value); - if (known) return modelDisplayName(known.label); - // A catalogue without this value: show the engine's own string rather than - // inventing a label for it. - return value || t("composer.model"); - }, [models, state?.model?.name, t]); + return known?.thinkingLevels ?? []; + }, [models, state?.model?.name]); const submit = useCallback(async () => { const content = value.trim(); @@ -490,10 +541,42 @@ export function Composer({ t, inline = false }: { t: (key: MessageKey) => string void api.setModel(id)} + onPick={(id) => { + // The composer hands the picker an id; we send the + // same `thinking` we already recorded so the engine's + // model+effort pair stays consistent across the + // mid-session model change. The server enforces + // "model first, then effort" and re-applies the + // effort in lockstep. + void api.setModel({ + model: id, + ...(state?.model?.thinking + ? { thinking: state.model.thinking } + : {}), + }); + }} /> + {/* Thinking-effort picker (ticket 04). Only rendered when + the active model carries a `thinkingLevels` list; the + picker is gated so models without reasoning controls + never expose a no-op control. */} + {thinkingLevelsForActive.length > 0 ? ( + { + // Empty string clears the override (engine default + // stands). The server interprets `""` exactly that way + // — see routes/model.js#handleSetModel. + void api.setModel({ thinking: level }); + }} + /> + ) : null} {running ? ( /* Upstream's stop control is a 30px circle in the quaternary icon @@ -641,41 +724,10 @@ function SelectPanel({ testId, children }: { testId: string; children: React.Rea /** * One row of a `SelectPanel`. * - * A plain button, not an antd `Menu` item: the desktop renders these popups as - * custom content, and its rows are buttons. The tick sits in a fixed 14px - * trailing slot so a selected row's label starts on the same x as its - * neighbours' — the same reason the desktop reserves the slot. + * Defined further down (after ModelSelect) to keep the chip-related + * primitives co-located with the model selector — see the second + * `function SelectRow` below for the load-bearing shape. */ -function SelectRow({ - testId, - icon, - label, - selected, - onClick, -}: { - testId: string; - icon?: IconName; - label: string; - selected?: boolean; - onClick?: () => void; -}) { - return ( - - ); -} /** * Permission-mode selector. @@ -775,18 +827,42 @@ function PermissionSelect({ * (engine-encoded ids before a session exists) fall through to the flat list * under an "Other" heading. * + * Ticket 04 wiring: + * - Per-row modality badges render next to the label when the model + * declares `modalities` (`text` / `image` / `audio` / `video` / + * `file`). + * - Provider groups whose `auth.hasKey === false` render greyed with + * a "configure in Settings" hint and disable their rows. The engine + * session group is always usable (it has no `auth`). + * - The flat `models[]` is still the source of truth for keyboard / + * aria semantics; the group disable is purely visual + click-guard. + * * The label is the caller's: resolving a model id to a display name is this * frontend's own mapping, and antd has nothing to say about it. */ function ModelSelect({ t, models, + groups, value, label, onPick, }: { t: (key: MessageKey) => string; - models: { id: string; label: string; provider?: string }[]; + models: { + id: string; + label: string; + provider?: string; + modalities?: string[]; + }[]; + /** Per-provider groups from `/api/models`. Used to disable no-key + * providers and to look up the display label the server resolved + * (`label` wins over the heuristic `providerLabel(providerId)`). */ + groups: { + id: string; + label: string; + auth?: { hasKey: boolean; type: "byok" | "coding-plan" }; + }[]; value?: string; label: string; onPick: (id: string) => void; @@ -795,27 +871,33 @@ function ModelSelect({ // Group by provider, preserving the catalogue order. A provider-less entry // (engine-encoded ids whose prefix wasn't coerced) falls into "Other" so it - // is still reachable from the menu. + // is still reachable from the menu. The /api/models groups[] carries the + // server-resolved label + auth view; merge it into the in-component shape. const grouped = useMemo(() => { - const order = []; - const buckets = new Map(); + const order: string[] = []; + const buckets = new Map< + string, + { id: string; label: string; models: typeof models; auth?: { hasKey: boolean; type: "byok" | "coding-plan" } } + >(); for (const model of models) { const key = model.provider ?? "__other"; if (!buckets.has(key)) { - buckets.set(key, []); + const meta = groups.find((g) => g.id === key); + buckets.set(key, { + id: key, + label: + key === "__other" + ? t("modelSelector.other") + : meta?.label ?? providerLabel(key), + models: [], + auth: meta?.auth, + }); order.push(key); } - buckets.get(key)!.push(model); + buckets.get(key)!.models.push(model); } - return order.map((key) => ({ - key, - label: - key === "__other" - ? t("modelSelector.other") - : providerLabel(key), - models: buckets.get(key)!, - })); - }, [models, t]); + return order.map((key) => buckets.get(key)!); + }, [models, groups, t]); return ( ) : (
- {grouped.map((group, groupIndex) => ( -
+ {grouped.map((group, groupIndex) => { + const disabled = isGroupDisabled(group); + return (
- {group.label} +
+ {group.label} + {disabled ? ( + + {t("modelSelector.noKeyHint")} + + ) : null} +
+ {group.models.map((model) => ( + 0 ? ( + + ) : null + } + selected={model.id === value} + disabled={disabled} + onClick={() => { + if (disabled) return; + setOpen(false); + onPick(model.id); + }} + /> + ))}
- {group.models.map((model) => ( - { - setOpen(false); - onPick(model.id); - }} - /> - ))} -
- ))} + ); + })}
)} @@ -882,6 +987,256 @@ function ModelSelect({ ); } +/** + * True when a provider group should render greyed. + * + * Groups with `auth.hasKey === false` cannot reach their models — every + * pick would 401/403. The engine session group (`__engine`) does not + * carry `auth` at all; it is always usable because the engine has + * already authenticated against its own credentials. + */ +function isGroupDisabled(group: { + id: string; + auth?: { hasKey: boolean; type: "byok" | "coding-plan" }; +}): boolean { + if (!group.auth) return false; + return group.auth.hasKey === false; +} + +/** + * One row of a `SelectPanel`. + * + * A plain button, not an antd `Menu` item: the desktop renders these popups as + * custom content, and its rows are buttons. The tick sits in a fixed 14px + * trailing slot so a selected row's label starts on the same x as its + * neighbours' — the same reason the desktop reserves the slot. + * + * `rightAdornment` is the optional trailing content slot the chip's + * row uses for modality badges. `disabled` greys the row and ignores + * clicks (used by the no-key provider groups). + */ +function SelectRow({ + testId, + icon, + label, + selected, + disabled, + rightAdornment, + onClick, +}: { + testId: string; + icon?: IconName; + label: string; + selected?: boolean; + disabled?: boolean; + rightAdornment?: React.ReactNode; + onClick?: () => void; +}) { + return ( + + ); +} + +/** + * Modality chips rendered on the right of a model row. + * + * Each `modalities[]` value maps to a short localised chip via + * `modelSelector.modalityBadge.`. The chips are intentionally + * mono-line — the model's display name owns the row's main text slot, + * so a multi-line badge stack would compete with the truncate there. + */ +function ModalityBadges({ + t, + modalities, +}: { + t: (key: MessageKey) => string; + modalities: string[]; +}) { + return ( + <> + {modalities.map((m) => { + const key = modalityBadgeKey(m); + return ( + + {t(key)} + + ); + })} + + ); +} + +/** + * Map a server-supplied modality string to its i18n key. + * + * Falls back to `file` for any value the catalogue carries but the + * dictionary doesn't know — `file` is the closest neutral word and + * keeps the badge readable rather than dropping a glyph on the row. + */ +function modalityBadgeKey(modality: string): MessageKey { + switch (modality) { + case "text": + return "modelSelector.modalityBadge.text"; + case "image": + return "modelSelector.modalityBadge.image"; + case "audio": + return "modelSelector.modalityBadge.audio"; + case "video": + return "modelSelector.modalityBadge.video"; + default: + return "modelSelector.modalityBadge.file"; + } +} + +/** + * Map a server-supplied thinking level to its i18n key. + * + * The engine's `thinkingEffort` config option accepts `off` / `low` / + * `medium` / `high` (see packages/tui/src/acp/control-state.ts). Unknown + * levels fall through to no tag — the picker still shows them but the + * chip label stays clean. + */ +function thinkingLevelKey(level: string): MessageKey | null { + switch (level) { + case "off": + return "thinkingPicker.off"; + case "low": + return "thinkingPicker.low"; + case "medium": + return "thinkingPicker.medium"; + case "high": + return "thinkingPicker.high"; + default: + return null; + } +} + +/** + * Thinking-effort picker. + * + * Same shell and panel as the other selectors. The trigger is the + * active level ("High" / "Medium" / …) or "Use engine default" when + * the user has not picked one (the recorded value is empty). + * + * The levels array comes from the active model's catalogue entry; the + * selector is only mounted when that list is non-empty, so the picker + * never advertises a level the model cannot accept. The "off" entry + * is omitted from the menu when the model's `thinkingLevels` does not + * include it — a model that only supports low/medium/high never shows + * an "Off" option that the engine would reject. + * + * `disabled` greys the trigger during an active run; mid-session + * changes are still recorded for the next turn (the documented + * "running → next turn" semantic). + */ +function ThinkingEffortSelect({ + t, + levels, + value, + disabled, + onPick, +}: { + t: (key: MessageKey) => string; + levels: string[]; + value: string; + disabled?: boolean; + onPick: (level: string) => void; +}) { + const [open, setOpen] = useState(false); + const currentKey = value ? thinkingLevelKey(value) : null; + const currentLabel = currentKey + ? t(currentKey) + : t("thinkingPicker.none"); + return ( + ( + + {levels.map((level) => { + const key = thinkingLevelKey(level); + return ( + { + setOpen(false); + onPick(level); + }} + /> + ); + })} +
+ { + setOpen(false); + onPick(""); + }} + /> +
+
+ )} + > + +
+ ); +} + /** * Display label for a provider id. * diff --git a/packages/webui/webapp/lib/api.ts b/packages/webui/webapp/lib/api.ts index bbc8a125..6f3a03c3 100644 --- a/packages/webui/webapp/lib/api.ts +++ b/packages/webui/webapp/lib/api.ts @@ -250,6 +250,18 @@ export const getSessionTree = (refresh = false) => * `MCODE_WEBUI_MODELS_CONFIG` entry declares one, and is `undefined` * otherwise (the engine's own per-session `usage_update.size` is preferred * once one exists). + * + * v2 schema fields (ticket 01, surfaced for the selector in ticket 04): + * * `protocol` — the wire protocol the model is reachable through + * (`openai` / `anthropic` / `gemini`); the engine-sourced group + * does not advertise one, so it's optional. + * * `thinkingLevels` — the reasoning-effort levels the engine accepts + * on this model (`["low", "medium", "high"]`, optionally including + * `"off"`). When non-empty, the composer renders a level picker + * alongside the model selector (ticket 04). + * * `modalities` — the modality badges the model claims (`text` / + * `image` / `audio` / `video`). Rendered as small chips next to + * the model label. */ export interface ModelEntry { id: string; @@ -258,12 +270,35 @@ export interface ModelEntry { provider?: string; source?: "engine" | "config" | "builtin"; contextLimit?: number; + protocol?: "openai" | "anthropic" | "gemini"; + thinkingLevels?: string[]; + modalities?: string[]; +} + +/** + * Auth view the catalogue carries per provider group. + * + * Mirrors the masked `auth` block on `/api/providers` (apiKey NEVER + * appears — only `hasKey` + `type`); see + * `server/routes/model.js#handleGetModels` for the masking rule. + */ +export interface ModelGroupAuth { + /** True when the provider has an API key configured. Groups with + * `hasKey === false` render greyed with a "configure in Settings" + * hint so the user can fix it without opening the management + * panel. */ + hasKey: boolean; + type: "byok" | "coding-plan"; } export interface ModelGroup { id: string; label: string; models: ModelEntry[]; + /** Auth view — present on provider-config groups, absent on the + * engine session group (which is always usable). */ + auth?: ModelGroupAuth; + protocol?: "openai" | "anthropic" | "gemini"; } export interface ModelsPayload { @@ -273,6 +308,10 @@ export interface ModelsPayload { models: ModelEntry[]; /** Per-provider groups; same models appear in `models[]` flat too. */ groups: ModelGroup[]; + /** Current thinking-effort level (engine's `thinkingEffort.currentValue`, + * falling back to `cs.model.thinking`, then `null`). The composer + * reads this to highlight the active level in the picker. */ + currentThinking?: string | null; /** One of `acp-session-config` / `config+mcode-cli-bundle` / `mcode-cli-bundle`. */ source?: string; reason?: string; @@ -448,8 +487,31 @@ export interface AccountPayload { export const getAccount = () => request("/api/account"); -export const setModel = (model: string) => - request<{ ok: boolean }>("/api/set-model", { method: "POST", json: { model } }); +/** + * Set the active model and (optionally) the thinking-effort level. + * + * Body shape: `{ model?: string, thinking?: string }`. The two are + * independent — a thinking-only update leaves the model alone (the + * engine contract is "model selected before thinkingEffort"; the + * server enforces the order at apply time), and a model-only update + * leaves the recorded effort intact so the next session boot re-applies + * it through `applyRecordedModel`. An empty `thinking` clears the + * recorded effort (engine's default stands). + */ +export const setModel = ( + payload: { model?: string; thinking?: string }, +) => + request<{ + ok: boolean; + model?: string; + thinking?: string; + mcodeSynced?: boolean; + thinkingSynced?: boolean; + warning?: string; + }>("/api/set-model", { + method: "POST", + json: payload, + }); /** * Change the session's permission mode. diff --git a/packages/webui/webapp/lib/i18n.ts b/packages/webui/webapp/lib/i18n.ts index f721baec..b25040e9 100644 --- a/packages/webui/webapp/lib/i18n.ts +++ b/packages/webui/webapp/lib/i18n.ts @@ -84,6 +84,25 @@ const en = { engine-encoded ids whose provider prefix did not coerce (i.e. a model the catalogue could not bucket). */ "modelSelector.other": "Other", + // Model selector — ticket 04. Provider groups without an API key + // render greyed with a hint that points the user at the settings + // panel; the modalities chip maps each `modalities[]` value to a + // short display label. + "modelSelector.noKeyHint": "Add an API key in Settings to enable", + "modelSelector.modalityBadge.text": "text", + "modelSelector.modalityBadge.image": "image", + "modelSelector.modalityBadge.audio": "audio", + "modelSelector.modalityBadge.video": "video", + "modelSelector.modalityBadge.file": "file", + // Thinking-effort picker (off / low / medium / high). The + // `thinkingPicker.none` key is the "no level recorded" placeholder; + // it surfaces only between picking a model and the picker closing. + "thinkingPicker.label": "Thinking effort", + "thinkingPicker.none": "Use engine default", + "thinkingPicker.off": "Off", + "thinkingPicker.low": "Low", + "thinkingPicker.medium": "Medium", + "thinkingPicker.high": "High", "permission.label": "Permission mode", "permission.ask": "Ask", @@ -417,6 +436,21 @@ const zh: Record = { "composer.noModels": "暂无可用模型", /* Model selector — provider-grouped dropdown. */ "modelSelector.other": "其他", + /* 模型选择器 — ticket 04。未配置 API Key 的供应商分组置灰并提示去 + 设置里填 key;模态徽标按 modalities 数组渲染。 */ + "modelSelector.noKeyHint": "请在设置中配置 API Key", + "modelSelector.modalityBadge.text": "文本", + "modelSelector.modalityBadge.image": "图像", + "modelSelector.modalityBadge.audio": "音频", + "modelSelector.modalityBadge.video": "视频", + "modelSelector.modalityBadge.file": "文件", + /* 思考等级选择器 (off / low / medium / high)。 */ + "thinkingPicker.label": "思考等级", + "thinkingPicker.none": "沿用引擎默认", + "thinkingPicker.off": "关闭", + "thinkingPicker.low": "低", + "thinkingPicker.medium": "中", + "thinkingPicker.high": "高", "permission.label": "权限模式", "permission.ask": "主动询问", diff --git a/packages/webui/webapp/lib/types.ts b/packages/webui/webapp/lib/types.ts index e0ed9ae5..b6eb419e 100644 --- a/packages/webui/webapp/lib/types.ts +++ b/packages/webui/webapp/lib/types.ts @@ -20,6 +20,11 @@ export interface WorkspaceState { export interface ModelState { name: string; + /** Recorded pre-session thinking-effort level (`low` / `medium` / + * `high` / `off`), or `""` when the user has not picked one and the + * engine's default stands. Populated by `handleSetModel` and by + * the engine's `config_option_update` notification + * (`applyConfigOptionUpdate` in lib/mcode-acp.js). */ thinking: string; ctx: string; } diff --git a/packages/webui/webapp/test/composer-models.test.ts b/packages/webui/webapp/test/composer-models.test.ts index 9d117a44..432fbb41 100644 --- a/packages/webui/webapp/test/composer-models.test.ts +++ b/packages/webui/webapp/test/composer-models.test.ts @@ -102,4 +102,179 @@ describe("groupModelsByProvider — composer ModelSelect grouping", () => { assert.equal(only.key, "__other"); assert.equal(only.models.length, 2); }); +}); + +// ============================================================ +// Ticket 04 — pure helpers backing the upgraded ModelSelect. +// +// The selector renders: +// - disabled provider groups when the server reports +// `auth.hasKey === false` (a "no API key" hint points the user +// at Settings); +// - modality badges next to each model label, mapped from the +// model's `modalities[]` through i18n; +// - a ThinkingEffortSelect whose options derive from the active +// model's `thinkingLevels[]`. +// These helpers are pure so the test pins the load-bearing logic +// without a render harness — the same reason the grouping helper +// above mirrors its source. Any future regression here surfaces as +// "the selector stopped greying / stopped showing badges / stopped +// offering a level" — a UX bug, not a test failure, so the pin +// matters. +// ============================================================ + +interface GroupAuth { + hasKey: boolean; + type: "byok" | "coding-plan"; +} + +interface Group { + id: string; + label: string; + models: { id: string; label: string; provider?: string; modalities?: string[] }[]; + auth?: GroupAuth; +} + +/** Mirror of composer.tsx#isGroupDisabled. */ +function isGroupDisabled(group: { auth?: GroupAuth }): boolean { + if (!group.auth) return false; + return group.auth.hasKey === false; +} + +/** Mirror of composer.tsx#modalityBadgeKey. */ +function modalityBadgeKey(modality: string): string { + switch (modality) { + case "text": + return "modelSelector.modalityBadge.text"; + case "image": + return "modelSelector.modalityBadge.image"; + case "audio": + return "modelSelector.modalityBadge.audio"; + case "video": + return "modelSelector.modalityBadge.video"; + default: + return "modelSelector.modalityBadge.file"; + } +} + +/** Mirror of composer.tsx#thinkingLevelKey. */ +function thinkingLevelKey(level: string): string | null { + switch (level) { + case "off": + return "thinkingPicker.off"; + case "low": + return "thinkingPicker.low"; + case "medium": + return "thinkingPicker.medium"; + case "high": + return "thinkingPicker.high"; + default: + return null; + } +} + +describe("isGroupDisabled — provider group greyed when no API key", () => { + test("no auth view → enabled (engine session group has no auth)", () => { + assert.equal(isGroupDisabled({}), false); + assert.equal(isGroupDisabled({ auth: undefined }), false); + }); + + test("auth.hasKey === false → disabled (no-key provider)", () => { + assert.equal( + isGroupDisabled({ auth: { hasKey: false, type: "byok" } }), + true, + ); + assert.equal( + isGroupDisabled({ auth: { hasKey: false, type: "coding-plan" } }), + true, + ); + }); + + test("auth.hasKey === true → enabled", () => { + assert.equal( + isGroupDisabled({ auth: { hasKey: true, type: "byok" } }), + false, + ); + }); +}); + +describe("modalityBadgeKey — server modality → i18n key", () => { + test("known modalities map to their i18n keys", () => { + assert.equal(modalityBadgeKey("text"), "modelSelector.modalityBadge.text"); + assert.equal(modalityBadgeKey("image"), "modelSelector.modalityBadge.image"); + assert.equal(modalityBadgeKey("audio"), "modelSelector.modalityBadge.audio"); + assert.equal(modalityBadgeKey("video"), "modelSelector.modalityBadge.video"); + }); + + test("unknown modalities fall through to the file key (neutral catch-all)", () => { + assert.equal(modalityBadgeKey("file"), "modelSelector.modalityBadge.file"); + assert.equal(modalityBadgeKey("3d"), "modelSelector.modalityBadge.file"); + }); +}); + +describe("thinkingLevelKey — engine effort → i18n key", () => { + test("off/low/medium/high map to their keys", () => { + assert.equal(thinkingLevelKey("off"), "thinkingPicker.off"); + assert.equal(thinkingLevelKey("low"), "thinkingPicker.low"); + assert.equal(thinkingLevelKey("medium"), "thinkingPicker.medium"); + assert.equal(thinkingLevelKey("high"), "thinkingPicker.high"); + }); + + test("unknown levels return null so the chip label stays clean", () => { + assert.equal(thinkingLevelKey("turbo"), null); + assert.equal(thinkingLevelKey(""), null); + }); +}); + +// ============================================================ +// Persistence round-trip — the setModel payload the composer sends. +// +// The server contract (handleSetModel) accepts: +// { model: string } — model only, thinking preserved +// { thinking: string } — effort only (model preserved) +// { model: string, thinking: string } — both +// { thinking: "" } — clears the recorded effort +// The composer wires: +// * ModelSelect.onPick → { model, thinking: state?.model?.thinking } +// so a mid-session model change carries the recorded effort with +// it. The server then enforces "model first, then effort" so the +// engine never sees an effort without a model anchor. +// * ThinkingEffortSelect.onPick → { thinking: level } (no model), +// so an effort-only update leaves the model alone. +// +// The setModel payload shape itself is verified by the api.ts unit +// tests; here we pin the composer's call-site payload (the wiring). +// ============================================================ + +describe("setModel payload — wiring the composer sends", () => { + test("model-only pick carries the recorded thinking effort", () => { + const recordedThinking = "high"; + const nextId = "openai_compat/gpt-4o"; + const payload = { + model: nextId, + ...(recordedThinking ? { thinking: recordedThinking } : {}), + }; + assert.deepEqual(payload, { model: nextId, thinking: "high" }); + }); + + test("model pick without a recorded thinking sends only the model", () => { + const recordedThinking = ""; + const nextId = "minimax_api/MiniMax-M3"; + const payload = { + model: nextId, + ...(recordedThinking ? { thinking: recordedThinking } : {}), + }; + assert.deepEqual(payload, { model: nextId }); + }); + + test("thinking-only pick sends only the thinking field", () => { + const payload = { thinking: "medium" }; + assert.deepEqual(payload, { thinking: "medium" }); + assert.equal("model" in payload, false, "no model field echoed on effort-only update"); + }); + + test("'Use engine default' sends thinking:'' (clear the override)", () => { + const payload = { thinking: "" }; + assert.equal(payload.thinking, ""); + }); }); \ No newline at end of file