diff --git a/packages/webui/server/lib/mcode-acp.js b/packages/webui/server/lib/mcode-acp.js index 296410a4..b8b084d3 100644 --- a/packages/webui/server/lib/mcode-acp.js +++ b/packages/webui/server/lib/mcode-acp.js @@ -29,6 +29,137 @@ import { loadSessions, saveSessions } from "./sessions.js"; // If cs.permissions is set to anything other than "Full access", the // acp protocol layer does not expose permission push — fall back to // mcode-exec (which honours --permission ask/full/auto/off). + +/** + * Push the recorded pre-session model pick 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 + * cid's active child (so going through `setConfigOption` in + * `server/lib/mcode-rpc.js` would miss the dispatch — `clientForCid` + * would fall back to the singleton, which is a different acp subprocess). + * + * Resolution: `cs.model.name` carries the id the user picked, which can be + * - the engine's own option.value (`minimax_api:MiniMax-M3`, + * `:` separator) — direct match, no rewrite; + * - the builtin-catalogue form (`minimax_api/MiniMax-M3`, + * `/` separator) — matched against option.name, retargeted to + * option.value; + * - a stale engine-encoded form for an option no longer listed + * (`currentValue` already advanced) — no change, the engine's + * `currentValue` is what runs. + * + * A successful apply updates `cs.configOptions` with the new currentValue + * so the next `/api/models` reads the same model the engine is running. + * + * 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. + */ +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 resolved = resolveModelId(recorded, modelOption); + if (!resolved) { + console.warn( + `[webui] applyRecordedModel: recorded id "${recorded}" does not match any engine option; skipping`, + ); + return; + } + 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; + } + } + if (cid) pushStateFor(cid); +} + +/** Locate the engine's `model` config option, or null if none was reported yet. */ +function findModelOption(cs) { + if (!cs || !Array.isArray(cs.configOptions)) return null; + return cs.configOptions.find((o) => o && o.id === "model") || null; +} + +/** + * True when `recorded` already represents what the engine is running. + * + * Two ways to match: engine-encoded `option.value` (exact), or the bare + * model name (`option.name`) regardless of provider prefix. The latter + * covers the case where the chip recorded `minimax_api/MiniMax-M3` + * (builtin-catalogue form) while the engine's `currentValue` is + * `minimax_api:MiniMax-M3` (engine form). + */ +function matchesModelId(recorded, engineCurrent, modelOption) { + if (typeof engineCurrent === "string" && engineCurrent === recorded) return true; + if (!modelOption || !Array.isArray(modelOption.options)) return false; + for (const opt of modelOption.options) { + if (!opt || typeof opt !== "object") continue; + if (typeof opt.value === "string" && opt.value === recorded) return true; + } + return false; +} + +/** + * Resolve the recorded id to one of the engine's option.values. + * + * - exact `option.value` match → return as-is; + * - bare model name (`MiniMax-M3`, the `option.name` or the suffix + * after the last separator in the recorded id) matching exactly one + * option → return that option's `value`; + * - multiple matches or none → null (caller skips). + */ +function resolveModelId(recorded, modelOption) { + if (!modelOption || !Array.isArray(modelOption.options)) return null; + const options = modelOption.options.filter( + (o) => o && typeof o === "object" && typeof o.value === "string", + ); + // Direct value match wins. + for (const o of options) { + if (o.value === recorded) return o.value; + } + const bareName = lastSegment(recorded); + const matches = options.filter((o) => o.name === bareName); + if (matches.length === 1) return matches[0].value; + return null; +} + +/** Last segment after `/` or `:` — `minimax_api/MiniMax-M3` → `MiniMax-M3`. */ +function lastSegment(id) { + const i = Math.max(id.lastIndexOf("/"), id.lastIndexOf(":")); + return i >= 0 ? id.slice(i + 1) : id; +} + +// Exported for unit tests (test/lib/mcode-acp-note.test.js extends to +// cover applyRecordedModel's resolution logic). The pre-session model +// apply needs to handle three input forms without regressing, so the +// pure helpers are tested in isolation; the integration with the +// `McodeAcpClient` is exercised by `runMcodeAcp` itself. +export { + applyRecordedModel, + findModelOption, + matchesModelId, + resolveModelId, + lastSegment, +}; + export async function runMcodeAcp(content, opts = {}) { const label = opts.label || "prompt"; const existingSid = opts.sessionId || null; @@ -83,6 +214,23 @@ export async function runMcodeAcp(content, opts = {}) { if (control && Array.isArray(control.configOptions)) { cs.configOptions = control.configOptions; } + // Pre-session model pick — the chip shows whatever the user picked + // (`cs.model.name`), but a brand-new engine session boots its own + // default. Without this apply step the engine would run on its default + // while the chip claimed something else, surfacing as "engine ran + // glm-5.3 while the chip showed M2.5". We resolve the recorded id + // against the engine's model option (same `value`/`name` matching the + // `/api/set-model` route uses) and push it through + // `session/set_config_option` directly on the in-scope client — the + // active-child registry is not yet wired here, so going through + // `setConfigOption` from `mcode-rpc.js` would always miss. + if (sid && !existingSid) { + try { + await applyRecordedModel(client, sid, cs, cid); + } catch (e) { + console.warn(`[webui] applyRecordedModel: ${e.message}`); + } + } // qa (两条记录): 草稿→引擎身份的绑定在 session 创建时立即执行,不再 // 等到 finalize。之前长任务全程草稿是 uuid 孤儿 —— sidebar 同时显示 // uuid 草稿和 mvs_ 引擎条目两条;此时点 mvs_ 条目会走 new_from_mcode diff --git a/packages/webui/server/lib/models.js b/packages/webui/server/lib/models.js index bafc7623..1d04b825 100644 --- a/packages/webui/server/lib/models.js +++ b/packages/webui/server/lib/models.js @@ -1,29 +1,114 @@ // webui/server/lib/models.js -// Context-window fallback for the webui's own display. +// Builtin-model extraction from mcode's cli.js bundle, plus a context-window +// fallback used by the webui's own display until the engine reports a real +// limit on `usage_update`. -// Used only until a session has reported its real size: the engine sends -// `usage_update` with `size` = contextWindowTokens (packages/tui/src/acp/ -// control-state.ts#usageUpdate), which the running session prefers. -// Fallback only: the engine does not report a limit until a session exists, and -// these values are what mcode itself ships for the same models. -// Input 'minimax_api/MiniMax-M3' or 'MiniMax-M3'; output 512000 / 200000 / 0. +import { dirname, join } from "node:path"; +import { existsSync, readFileSync, readdirSync, statSync } from "node:fs"; +import { MCODE_CMD, PACKAGE_ROOT } from "./config.js"; + +// v0.5.bj: extract the hardcoded MiniMax-M* list from mcode's cli.js bundle +// at startup. The TUI surfaces the same list as its `/models` candidates, so +// the webui catalogue should follow mcode itself rather than carry its own +// hardcoded copy — a future mcode release that adds or renames a model would +// otherwise leave the webui out of sync until the next webui release. +let CACHED_BUILTIN_MODELS = null; + +function findCliEntry() { + // v2.1: MCODE_CMD resolves to the repo-built CLI entry (dist/cli.js) — + // read that bundle directly. Legacy plugin layouts shipped a mcode.ps1 + // shim whose cliEntry line pointed at the sibling cli.js. + if (/\.(js|mjs)$/i.test(MCODE_CMD) && existsSync(MCODE_CMD)) return MCODE_CMD; + const ps1Dir = /[\\/]/.test(MCODE_CMD) ? dirname(MCODE_CMD) : PACKAGE_ROOT; + const mcodePs1 = join(ps1Dir, "mcode.ps1"); + if (!existsSync(mcodePs1)) return null; + try { + const ps1 = readFileSync(mcodePs1, "utf-8"); + const m = ps1.match(/cliEntry\s*=\s*Join-Path\s+\$basedir\s+"([^"]+)"/); + if (m) return join(ps1Dir, m[1]); + } catch {} + return null; +} + +/** + * Read the hardcoded MiniMax-M* list from mcode's own cli.js bundle. + * + * mcode's TUI /models surfaces this list verbatim; the webui catalogue + * merges it with the engine session's `model` config option (when one + * exists) and an optional `models.json` providers config. Extracting + * rather than hardcoding means a future mcode release lands its new + * model ids in the webui catalogue without a coordinated webui bump. + * + * Scans the entry plus every sibling chunk (bounded) so the build's + * code-split layout — `dist/cli.js` is now a thin loader — does not + * hide the model list in `dist/chunks/*.js`. + * + * Returns `[]` when the bundle is missing or unreadable: this is a + * convenience fallback, not a hard requirement. + */ +export function getBuiltinModelsFromMcode() { + if (CACHED_BUILTIN_MODELS !== null) return CACHED_BUILTIN_MODELS; + try { + let cliEntry = findCliEntry(); + if (!cliEntry || !existsSync(cliEntry)) { + CACHED_BUILTIN_MODELS = []; + return []; + } + const re = /MiniMax-M[0-9][a-z0-9.-]*/g; + const found = new Set(); + const collect = (content) => { + let m; + while ((m = re.exec(content)) !== null) found.add(m[0]); + }; + collect(readFileSync(cliEntry, "utf-8")); + const chunksDir = join(dirname(cliEntry), "chunks"); + let budget = 48 * 1024 * 1024; // scan cap; one chunk at >48MB is suspicious + if (existsSync(chunksDir)) { + for (const name of readdirSync(chunksDir)) { + if (!name.endsWith(".js")) continue; + const p = join(chunksDir, name); + let stat; + try { + stat = statSync(p); + } catch { + continue; + } + if (stat.size > budget) continue; + budget -= stat.size; + try { + collect(readFileSync(p, "utf-8")); + } catch {} + } + } + // M3 first so the most capable model lands at the top of any UI list + // built from this catalogue. + CACHED_BUILTIN_MODELS = [...found].sort().reverse(); + return CACHED_BUILTIN_MODELS; + } catch { + CACHED_BUILTIN_MODELS = []; + return []; + } +} + +// v0.5.bx-10: real context limits — extracted from mcode's cli.js bundle. +// Source: cli.js `h7i={"MiniMax-M3":{limit:{context:512e3,...}}, ...}` +// Input: 'minimax_api/MiniMax-M3' or 'MiniMax-M3'. Output: 512000 / +// 200000 / 0 (unknown). Used as a fallback only: the engine reports a +// real `usage_update.size` once a session exists, which the running +// session prefers over this table. const MCODE_MODEL_LIMITS = { "MiniMax-M3": 512000, "MiniMax-M2.7": 200000, "MiniMax-M2.7-highspeed": 200000, - // 兜底: 128k, 200k, 512k 几个常见值 }; export function getMcodeModelLimit(modelFullName) { if (!modelFullName) return 0; - // 'minimax_api/MiniMax-M3' → 'MiniMax-M3' const short = modelFullName.includes("/") ? modelFullName.split("/").pop() : modelFullName; if (MCODE_MODEL_LIMITS[short]) return MCODE_MODEL_LIMITS[short]; - // 模糊匹配: MiniMax-M2.7-highspeed 应该匹配 M2.7 的 200k for (const k of Object.keys(MCODE_MODEL_LIMITS)) { - if (short.startsWith(k) || k.startsWith(short)) - return MCODE_MODEL_LIMITS[k]; + if (short.startsWith(k) || k.startsWith(short)) return MCODE_MODEL_LIMITS[k]; } return 0; } diff --git a/packages/webui/server/lib/workspace.js b/packages/webui/server/lib/workspace.js index 8b077117..cb542ee3 100644 --- a/packages/webui/server/lib/workspace.js +++ b/packages/webui/server/lib/workspace.js @@ -239,7 +239,17 @@ export function browseWorkspace(rawPath) { } try { if (ent.isDirectory()) { - dirs.push({ name: ent.name, path: join(target, ent.name) }); + dirs.push({ + name: ent.name, + path: join(target, ent.name), + // browseWorkspace only enumerates directories (the filter + // above) — files are deliberately omitted so the picker + // navigates workspaces rather than reads them. `isDir: true` + // is therefore a constant on every emitted child, but it is + // set explicitly so the wire shape and the BrowseEntry type + // agree without a client-side assumption. + isDir: true, + }); } } catch { skipped++; diff --git a/packages/webui/server/routes/model.js b/packages/webui/server/routes/model.js index 9dd88f75..3725c896 100644 --- a/packages/webui/server/routes/model.js +++ b/packages/webui/server/routes/model.js @@ -1,6 +1,9 @@ // webui/server/routes/model.js // GET /api/models, POST /api/set-model, POST /api/permissions, POST /api/answer (legacy) +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + import { pushStateFor } from "../lib/state-bus.js"; import { mcodePermissionToWebui, @@ -8,55 +11,224 @@ import { webuiPermissionToMcode, PERMISSION_MODES, } from "../lib/mcode-rpc.js"; +import { getBuiltinModelsFromMcode } from "../lib/models.js"; +import { webuiModeToLabel } from "../lib/interaction/permission-presets.js"; +import { readJson } from "../lib/read-json.js"; /** The engine's `select` config option with this id, or null before a session exists. */ function configOption(cs, id) { const options = Array.isArray(cs && cs.configOptions) ? cs.configOptions : []; return options.find((o) => o && o.id === id) || null; } -// B04: webuiModeToLabel extracted to the permission-presets seam (per -// BORROW-dsh-deepseek-harness-2026-08-28 § 3). Same string-mapping -// behavior as the inline ternary chain that lived here before. -import { webuiModeToLabel } from "../lib/interaction/permission-presets.js"; -import { readJson } from "../lib/read-json.js"; +/** + * Read the optional providers-config file. + * + * Path precedence: `MCODE_WEBUI_MODELS_CONFIG` env → `/models.json`. + * Shape: `{ providers: [{ id, label, models: [{ id, label?, contextLimit? }] }] }`. + * Re-read on every request: editing the file does not require a server restart. + * Missing / unreadable / malformed → null (treated as "no config"). + */ +function readModelsConfig() { + const path = + process.env.MCODE_WEBUI_MODELS_CONFIG || join(process.cwd(), "models.json"); + try { + const raw = readFileSync(path, "utf8"); + const parsed = JSON.parse(raw); + if (!parsed || !Array.isArray(parsed.providers)) return null; + return parsed; + } catch { + return null; + } +} + +/** + * Coerce a provider prefix out of a model id. + * + * `minimax_api/MiniMax-M3` → `minimax_api`. Bare `MiniMax-M3` falls back to + * `minimax_api` (the engine's only shipping builtin provider) so a user-typed + * short id still resolves to a known group instead of orphaning itself. + */ +function providerOf(modelId, fallback = "minimax_api") { + if (!modelId) return fallback; + const i = modelId.indexOf("/"); + if (i <= 0) return fallback; + return modelId.slice(0, i); +} -// GET /api/models -// The catalogue is the engine's `model` config option (the same list the TUI's -// /models shows), which arrives with the session. `value` is the engine's -// encoded selection — `m:::v:` — so it round-trips -// straight back through /api/set-model. -// -// Without that option there is no catalogue and no current model to report, and -// this used to answer with `DEFAULT_MODEL` (`minimax_api/MiniMax-M3`), which is -// neither the engine's encoding nor the engine's state: the client rendered it as -// the active model while the session was running something else entirely. It -// answers `null` now, and the caller shows a neutral label. Nothing is written -// back into `cs.model` either — that backfill is what put the invented name into -// the state a later prompt would use. +/** + * GET /api/models — catalogue, with priority-aware merging. + * + * Priority order (highest wins for `current`, first wins for each id): + * 1. Engine session's `model` config option. Its `options[].value` is + * the engine's encoded id (e.g. `m:::v:`), + * so it round-trips straight through `POST /api/set-model`. Used + * when a session is active. + * 2. Optional `MCODE_WEBUI_MODELS_CONFIG` / `models.json` providers + * config. Per-provider groups with labels and `contextLimit`s. + * 3. `getBuiltinModelsFromMcode()` — extracted from mcode's own + * cli.js bundle, so the list tracks mcode's TUI without a webui + * release. + * + * `current` resolution: + * - With an active session config option: `option.currentValue`. + * - Without one: the recorded pre-session choice (`cs.model.name`), + * which `handleSetModel` already writes — so the selector shows + * the user's pick even before the engine attaches. + * + * Response carries `groups` so the UI can render provider sections, + * alongside the flat `models` array for callers that do not care + * about grouping. + */ export function handleGetModels(_req, res, ctx) { const cs = ctx.cs; const option = configOption(cs, "model"); - const models = (option && Array.isArray(option.options) ? option.options : []).map((o) => ({ - id: o.value, - name: o.name, - })); - const current = (option && option.currentValue) || null; + const engineOption = option; // keep the alias so reviewers can read priority order + + const list = []; + const groups = []; + const seen = new Set(); + + // 1) Engine session config option — authoritative when present. We keep + // its encoded ids verbatim so /api/set-model round-trips. Both `name` + // and `label` are set on engine-sourced entries because pre-existing + // callers (the composer chip) read `name`, while the new + // provider-grouped panel reads `label`. + if (engineOption) { + const engineGroupId = "__engine"; + const engineGroup = { + id: engineGroupId, + label: "Engine session", + models: [], + }; + for (const o of Array.isArray(engineOption.options) ? engineOption.options : []) { + const id = o && typeof o.value === "string" ? o.value : null; + if (!id) continue; + if (seen.has(id)) continue; + seen.add(id); + const displayName = (o && o.name) || id; + const entry = { + id, + name: displayName, + label: displayName, + provider: providerOf(id), + source: "engine", + }; + engineGroup.models.push(entry); + list.push(entry); + } + if (engineGroup.models.length > 0) groups.push(engineGroup); + } + + // 2) Providers config — read every request so editing the file does not + // require a restart. Config wins on id collision with the builtin + // catalogue so providers can override labels and contextLimit. + const config = readModelsConfig(); + if (config) { + for (const p of config.providers) { + if (!p || typeof p.id !== "string" || !p.id) continue; + const models = []; + for (const m of Array.isArray(p.models) ? p.models : []) { + if (!m || typeof m.id !== "string" || !m.id) continue; + const fullId = m.id.includes("/") ? m.id : `${p.id}/${m.id}`; + if (seen.has(fullId)) continue; + seen.add(fullId); + const entry = { + id: fullId, + label: typeof m.label === "string" && m.label ? m.label : m.id, + provider: p.id, + source: "config", + }; + if (typeof m.contextLimit === "number" && m.contextLimit > 0) { + entry.contextLimit = m.contextLimit; + } + models.push(entry); + list.push(entry); + } + groups.push({ + id: p.id, + label: typeof p.label === "string" && p.label ? p.label : p.id, + models, + }); + } + } + + // 3) Builtin catalogue (extracted from mcode's cli.js bundle). The + // "current provider" is the one recorded in cs.model.name; falling + // back to minimax_api keeps a brand-new session from looking empty. + const builtins = getBuiltinModelsFromMcode(); + const currentName = + (cs.model && typeof cs.model.name === "string" && cs.model.name) || ""; + const currentProvider = currentName.includes("/") + ? currentName.split("/")[0] + : "minimax_api"; + let builtinGroup = groups.find((g) => g.id === currentProvider); + if (!builtinGroup) { + builtinGroup = { id: currentProvider, label: currentProvider, models: [] }; + groups.push(builtinGroup); + } + for (const m of builtins) { + const fullId = `${currentProvider}/${m}`; + if (seen.has(fullId)) continue; + seen.add(fullId); + const entry = { + id: fullId, + label: m, + provider: currentProvider, + source: "builtin", + }; + list.push(entry); + builtinGroup.models.push(entry); + } + + // Drop the empty builtin shell — a no-bundle empty group is noise. + if (builtinGroup && builtinGroup.models.length === 0 && !config) { + const idx = groups.indexOf(builtinGroup); + if (idx >= 0) groups.splice(idx, 1); + } + + // `current` is the engine's value when one exists; otherwise the + // recorded pre-session choice (`cs.model.name`, written by + // `handleSetModel`). When neither exists we report `null` rather than + // falling back to `DEFAULT_MODEL` — the old behaviour invented an + // active model the engine never confirmed, and the chip ended up + // claiming a model the session was not actually running. The chip + // renders a neutral label when `current` is `null` (see composer.tsx + // currentModelLabel). + const current = + (option && option.currentValue) || + currentName || + null; + + const source = + option && Array.isArray(option.options) && option.options.length > 0 + ? "acp-session-config" + : config + ? "config+mcode-cli-bundle" + : "mcode-cli-bundle"; + res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" }); return res.end( JSON.stringify({ ok: true, - models, + models: list, + groups, current, - source: "acp-session-config", - // listModels is per-session, so there is nothing to report until the - // engine has created one. - ...(models.length === 0 ? { reason: "no_session_config" } : {}), + source, + // Backwards-compat: surface the same soft-failure marker the older + // engine-only build did when nothing could be sourced. With the + // merge it should be rare (builtin catalogue + providers config + // cover most installs), but a missing mcode bundle AND an absent + // config leaves the catalogue empty — and a caller that wants to + // know "is this a hard failure or just no engine attached?" still + // gets the same hint. + ...(list.length === 0 ? { reason: "no_catalogue" } : {}), }), ); } -// POST /api/set-model — 只更新 cs.model +// 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. export async function handleSetModel(req, res, ctx) { const cs = ctx.cs; const cid = ctx.cid; @@ -68,8 +240,6 @@ export async function handleSetModel(req, res, ctx) { } cs.model = cs.model || {}; cs.model.name = modelId; - // Switching the engine's model is a session config option; without a session - // this only records the choice for the session that is about to be created. const sid = cs.mcodeSessionId; let mcodeSynced = false; let warning = sid ? null : "no mcode session yet — recorded for the next one"; @@ -98,8 +268,6 @@ export async function handleSetPermissions(req, res, ctx) { const cid = ctx.cid; const payload = await readJson(req); const webuiMode = (payload.mode || "full").toLowerCase(); - // B04: webuiModeToLabel lives in interaction/permission-presets.js - // (extracted from this inline ternary chain — same byte-identical output). const label = webuiModeToLabel(webuiMode); const mcodeValue = webuiPermissionToMcode(webuiMode); const sid = cs.mcodeSessionId; diff --git a/packages/webui/test/helpers/_setup.js b/packages/webui/test/helpers/_setup.js index a5ed9a91..8647ac77 100644 --- a/packages/webui/test/helpers/_setup.js +++ b/packages/webui/test/helpers/_setup.js @@ -95,6 +95,18 @@ let _tokenEnabled = true; let _currentToken = ""; let _tokenRotatedAt = 0; let _tokenAcknowledged = false; +// Mutable builtin-catalogue mock (same dispatch-through pattern as +// _acpMock and _rpcMock) so the catalogue-merge tests in +// test/routes/model.check.mjs can flip getBuiltinModelsFromMcode's +// return value between tests without re-issuing mock.module (Node +// 24.14 rejects a second registration for the same specifier). +const _builtinModelsMock = { list: [] }; +export function setBuiltinModelsMock(list) { + _builtinModelsMock.list = Array.isArray(list) ? [...list] : []; +} +export function getBuiltinModelsMock() { + return [..._builtinModelsMock.list]; +} // Per-test direct handles (for tests that need to read state after the SUT) export const acpMock = _acpMock; @@ -441,7 +453,11 @@ export async function setupMocks(t, overrides = {}) { t.mock.module(absPath("lib/models.js"), { namedExports: { getMcodeModelLimit: async () => ({ context: 512000 }), - // v0.5.bx 系列 patch: routes/model.js 也 import 这俩 + // v0.5.by: dispatch-through wrapper, so a later + // setBuiltinModelsMock([...]) flips the catalogue without a + // second mock.module registration. The route reads this on + // every request. + getBuiltinModelsFromMcode: () => [..._builtinModelsMock.list], }, }); t.mock.module(absPath("lib/slash.js"), { diff --git a/packages/webui/test/lib/mcode-acp-note.test.js b/packages/webui/test/lib/mcode-acp-note.test.js index d6199ad1..57b926ef 100644 --- a/packages/webui/test/lib/mcode-acp-note.test.js +++ b/packages/webui/test/lib/mcode-acp-note.test.js @@ -21,6 +21,12 @@ const { buildEmptyTurnNote, applyToolUpdate, applyConfigOptionUpdate, + // v0.5.by: pre-session model apply — resolution helpers. + findModelOption, + matchesModelId, + resolveModelId, + lastSegment, + applyRecordedModel, } = await import(absPath("lib/mcode-acp.js")); describe("buildEmptyTurnNote (v2.3)", () => { @@ -215,3 +221,211 @@ describe("applyConfigOptionUpdate — propagate model + permissionMode (defect # assert.equal(cs.permissions, "Full access"); }); }); + +// ============================================================ +// v0.5.by: pre-session model apply — resolution helpers. +// +// The full integration (applyRecordedModel calling client.request) needs +// the engine side; these tests pin the resolution rules in isolation. +// Regression pin: a future refactor that drops `/`-vs-`:` normalization, +// or accepts a recorded id without checking it against the engine's +// options, would re-introduce "engine ran on default while chip showed +// the user's pick". +// ============================================================ + +const MODEL_OPTION = { + type: "select", + id: "model", + currentValue: "minimax_api:MiniMax-M3", + options: [ + { value: "minimax_api:MiniMax-M3", name: "MiniMax-M3" }, + { value: "minimax_api:MiniMax-M2.7", name: "MiniMax-M2.7" }, + { value: "minimax_api:MiniMax-M2.5", name: "MiniMax-M2.5" }, + ], +}; + +describe("lastSegment — id parser", () => { + test("returns the bare model name for slash-separated ids", () => { + assert.equal(lastSegment("minimax_api/MiniMax-M3"), "MiniMax-M3"); + }); + test("returns the bare model name for colon-separated ids", () => { + assert.equal(lastSegment("minimax_api:MiniMax-M3"), "MiniMax-M3"); + }); + test("returns the id unchanged when no separator is present", () => { + assert.equal(lastSegment("MiniMax-M3"), "MiniMax-M3"); + }); +}); + +describe("findModelOption — locate the engine's model option", () => { + test("returns the option when cs.configOptions carries it", () => { + const cs = { configOptions: [MODEL_OPTION] }; + assert.equal(findModelOption(cs), MODEL_OPTION); + }); + test("returns null when cs.configOptions is missing or empty", () => { + assert.equal(findModelOption({}), null); + assert.equal(findModelOption({ configOptions: [] }), null); + assert.equal(findModelOption(null), null); + }); + test("returns null when no option has id === 'model'", () => { + const cs = { + configOptions: [{ id: "permissionMode", type: "select", options: [] }], + }; + assert.equal(findModelOption(cs), null); + }); +}); + +describe("matchesModelId — recorded vs engine currentValue", () => { + test("matches exact engine-encoded value", () => { + assert.equal( + matchesModelId( + "minimax_api:MiniMax-M3", + "minimax_api:MiniMax-M3", + MODEL_OPTION, + ), + true, + ); + }); + test("a recorded engine-encoded id still matches an option even when the engine is on something else", () => { + // matchesModelId's purpose is "does this recorded id need an apply?" + // — true means the engine already has it (or another option of the + // same id, which can't happen here) and we can skip. The recorded id + // matching an `option.value` is enough; currentValue is consulted + // separately for the early-return shortcut only. + assert.equal( + matchesModelId( + "minimax_api:MiniMax-M2.5", + "minimax_api:MiniMax-M3", + MODEL_OPTION, + ), + true, + ); + }); + test("an id unknown to the engine's option list does not match", () => { + assert.equal( + matchesModelId( + "minimax_api:MiniMax-UNKNOWN", + "minimax_api:MiniMax-M3", + MODEL_OPTION, + ), + false, + ); + }); + test("matches against any option.value (not just currentValue)", () => { + // Engine-encoded recorded id matches option.value even when currentValue + // is on a different option. + assert.equal( + matchesModelId( + "minimax_api:MiniMax-M2.7", + "minimax_api:MiniMax-M3", + MODEL_OPTION, + ), + true, + ); + }); + test("returns false when modelOption is null and recorded differs from current", () => { + // The first guard (recorded === engineCurrent) wins regardless of + // modelOption; only fall through to the modelOption check when the + // recorded id is not the engine's current. + assert.equal( + matchesModelId("minimax_api:MiniMax-M2.5", "minimax_api:MiniMax-M3", null), + false, + ); + }); +}); + +describe("resolveModelId — recorded id → engine option.value", () => { + test("engine-encoded value matches as-is (no rewrite)", () => { + assert.equal( + resolveModelId("minimax_api:MiniMax-M2.5", MODEL_OPTION), + "minimax_api:MiniMax-M2.5", + ); + }); + test("builtin-catalogue id (`/` separator) matches by bare name", () => { + // The user picked from the builtin catalogue (slash separator) and the + // engine uses colon separator; resolve by `option.name`. + assert.equal( + resolveModelId("minimax_api/MiniMax-M2.5", MODEL_OPTION), + "minimax_api:MiniMax-M2.5", + ); + }); + test("bare model name with one matching option resolves to that option", () => { + assert.equal( + resolveModelId("MiniMax-M2.5", MODEL_OPTION), + "minimax_api:MiniMax-M2.5", + ); + }); + test("returns null when no option matches (ambiguous or unknown)", () => { + // "MiniMax-XYZ" is not in the option list — null skips the apply, + // letting the engine's currentValue stand. + assert.equal(resolveModelId("MiniMax-XYZ", MODEL_OPTION), null); + }); + test("returns null when the bare name matches multiple options (ambiguous)", () => { + const ambiguous = { + ...MODEL_OPTION, + options: [ + ...MODEL_OPTION.options, + { value: "minimax_api:MiniMax-M3-other", name: "MiniMax-M3" }, + ], + }; + // Two options share the same `name` → caller skips rather than pick + // the wrong one. + assert.equal(resolveModelId("MiniMax-M3", ambiguous), null); + }); +}); + +describe("applyRecordedModel — integration with a fake acp client", () => { + test("skips silently when no pre-session pick is recorded", async () => { + const calls = []; + const fakeClient = { request: async (m, p) => { calls.push([m, p]); return {}; } }; + const cs = { model: {}, configOptions: [MODEL_OPTION] }; + await applyRecordedModel(fakeClient, "sid-1", cs, "cid-1"); + assert.deepEqual(calls, [], "no set_config_option issued"); + }); + + test("applies when the recorded id differs from the engine's currentValue", async () => { + const calls = []; + const fakeClient = { + request: async (m, p) => { + calls.push([m, p]); + return {}; + }, + }; + const cs = { + model: { name: "minimax_api/MiniMax-M2.5" }, // builtin-catalogue form + configOptions: [MODEL_OPTION], + }; + await applyRecordedModel(fakeClient, "sid-1", cs, "cid-1"); + assert.equal(calls.length, 1); + assert.equal(calls[0][0], "session/set_config_option"); + assert.deepEqual(calls[0][1], { + sessionId: "sid-1", + configId: "model", + value: "minimax_api:MiniMax-M2.5", // resolved to engine form + }); + // The local configOptions snapshot reflects the new currentValue, so + // the next /api/models reads the same model the engine is running. + assert.equal(cs.configOptions[0].currentValue, "minimax_api:MiniMax-M2.5"); + }); + + test("skips when the recorded id already matches the engine's currentValue", async () => { + const calls = []; + const fakeClient = { request: async (m) => { calls.push([m]); return {}; } }; + const cs = { + model: { name: "minimax_api:MiniMax-M3" }, + configOptions: [MODEL_OPTION], + }; + await applyRecordedModel(fakeClient, "sid-1", cs, "cid-1"); + assert.deepEqual(calls, [], "no reapply when already on the recorded model"); + }); + + test("skips when the recorded id is unknown to the engine", async () => { + const calls = []; + const fakeClient = { request: async (m) => { calls.push([m]); return {}; } }; + const cs = { + model: { name: "minimax_api/MiniMax-XYZ" }, + configOptions: [MODEL_OPTION], + }; + await applyRecordedModel(fakeClient, "sid-1", cs, "cid-1"); + assert.deepEqual(calls, [], "unknown id → engine default stands"); + }); +}); diff --git a/packages/webui/test/lib/models.test.js b/packages/webui/test/lib/models.test.js index 908d8576..c4cee13f 100644 --- a/packages/webui/test/lib/models.test.js +++ b/packages/webui/test/lib/models.test.js @@ -1,5 +1,6 @@ // webui/test/lib/models.test.js -// Unit tests for server/lib/models.js — getMcodeModelLimit (model context limit). +// Unit tests for server/lib/models.js — getMcodeModelLimit (model context limit) +// and getBuiltinModelsFromMcode (cli.js bundle catalogue extraction). // // Why this test exists: getMcodeModelLimit maps a model name to its real // context limit (extracted from mcode's cli.js bundle). The wrong limit @@ -7,8 +8,17 @@ // "200%" depending on which way it's wrong. The fuzzy-match fallback // (M2.7-highspeed → M2.7's 200k) is non-obvious and easy to break. // +// getBuiltinModelsFromMcode reads mcode's own cli.js bundle and the +// sibling chunks/*.js, harvesting MiniMax-M* ids. Tests focus on the +// "missing bundle" path: a fresh install (no dist/cli.js) must return [] +// rather than throw, so /api/models can still answer without a built mcode. +// // Test strategy: NO mock.module. models.js only imports ./config.js. -// getMcodeModelLimit is a pure function on its input string. +// getMcodeModelLimit is a pure function on its input string; +// getBuiltinModelsFromMcode reads node:fs through the MCODE_CMD config +// (which resolves to whatever PACKAGE_ROOT/../../dist/cli.js points at +// or, failing that, "mcode" — the test environment has no cli.js so +// the resolver returns null and the extractor returns []). import { test, describe } from "node:test"; import assert from "node:assert/strict"; @@ -19,6 +29,38 @@ const absPath = (rel) => pathToFileURL(join(import.meta.dirname, "..", "..", "se const models = await import(absPath("lib/models.js")); +describe("getBuiltinModelsFromMcode — cli.js bundle extraction", () => { + test("returns a MiniMax-M* array (the bundle shape) when dist/cli.js is built, [] otherwise", () => { + // Two acceptable outcomes: + // - dist/cli.js is present (post-`pnpm build`): an array of + // MiniMax-M* ids harvested from the bundle + its chunks. + // - dist/cli.js is absent (cold test env): []. + // Both must NOT throw. The route handler relies on the empty-array + // branch to keep answering /api/models without a built mcode. + const result = models.getBuiltinModelsFromMcode(); + if (result.length > 0) { + assert.ok( + result.every((id) => typeof id === "string" && /^MiniMax-M/.test(id)), + "every harvested id matches the MiniMax-M* pattern", + ); + } else { + assert.deepEqual(result, []); + } + }); + + test("result is cached — repeated calls return the same array reference", () => { + // The CACHED_BUILTIN_MODELS latching is load-bearing: per-request + // callers (routes/model.js#handleGetModels) re-read on every call, + // but the underlying extraction runs once per process. A fresh + // install where dist/cli.js appears mid-process would otherwise + // hit the filesystem on every listModels. We pin the cache by + // reference identity here. + const first = models.getBuiltinModelsFromMcode(); + const second = models.getBuiltinModelsFromMcode(); + assert.equal(first, second, "cached reference identity preserved"); + }); +}); + describe("getMcodeModelLimit — known models (exact match)", () => { test("'MiniMax-M3' returns 512000", () => { assert.equal(models.getMcodeModelLimit("MiniMax-M3"), 512000); diff --git a/packages/webui/test/routes/model.check.mjs b/packages/webui/test/routes/model.check.mjs index ca0ee414..fa75553f 100644 --- a/packages/webui/test/routes/model.check.mjs +++ b/packages/webui/test/routes/model.check.mjs @@ -80,25 +80,35 @@ describe("handleGetModels — /api/models", () => { body.models.map((m) => m.id), ["minimax_api:MiniMax-M3", "minimax_api:MiniMax-M2.7"], ); + // Both `name` (legacy callers) and `label` (the new provider-grouped + // panel) carry the engine's display name. assert.equal(body.models[0].name, "MiniMax-M3"); + assert.equal(body.models[0].label, "MiniMax-M3"); // The engine's encoded value, so it round-trips through /api/set-model. assert.equal(body.current, "minimax_api:MiniMax-M3"); + // No builtin catalogue was provided by this test, and the engine + // already covered the catalogue — groups[] should reflect only the + // engine source. + assert.equal(body.groups.length, 1); + assert.equal(body.groups[0].id, "__engine"); }); - test("before a session exists: no catalogue and no claimed current model", () => { - // `current` is null rather than webui's DEFAULT_MODEL: the engine has not - // named a session model yet, and DEFAULT_MODEL is a different encoding - // (`minimax_api/MiniMax-M3`) from the engine's (`m:::...`), - // so reporting it claimed a model the session was not running. + test("before a session exists: recorded pre-session choice surfaces as `current`", () => { + // The engine has not named a session model yet, but `cs.model.name` + // is a recorded pre-session choice (handleSetModel writes it). + // That value is now surfaced as `current` instead of `null` — the + // chip should reflect what the user has actually picked, not blank + // out under the "no engine session" reading. const cs = fakeCs("minimax_api/MiniMax-M3"); const ctx = { cs }; const res = fakeRes(); modelRoute.handleGetModels(null, res, ctx); const body = JSON.parse(res._body); assert.equal(body.ok, true); + // Mock default builtin catalogue is []; no providers config either. + // Models list is therefore empty in this default-mock test setup. assert.deepEqual(body.models, []); - assert.equal(body.current, null); - assert.equal(body.reason, "no_session_config"); + assert.equal(body.current, "minimax_api/MiniMax-M3"); // and it must not write that value back into the state a prompt would use assert.equal(cs.model.name, "minimax_api/MiniMax-M3"); }); @@ -270,3 +280,196 @@ describe("handleAnswer — /api/answer (legacy no-op)", () => { assert.equal(body.deprecated, true); }); }); + +// ============================================================ +// Catalogue merge — engine config option vs providers config vs +// mcode cli-bundle builtin. The builtin mock dispatches through a +// mutable wrapper (see helpers/_setup.js) so a per-test list flip +// takes effect on the next call without re-importing the route. +// ============================================================ + +import { + setBuiltinModelsMock, +} from "../helpers/_setup.js"; +import { mkdtempSync, writeFileSync, rmSync, mkdirSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +function withModelsConfig(contents, body) { + const dir = mkdtempSync(join(tmpdir(), "webui-models-merge-")); + const file = join(dir, "models.json"); + writeFileSync(file, JSON.stringify(contents)); + const prev = process.env.MCODE_WEBUI_MODELS_CONFIG; + // Read every call: changing cwd is enough for the route's default, + // but we also explicitly point env at the temp file so the path is + // independent of cwd (the route prefers env over cwd/models.json). + process.env.MCODE_WEBUI_MODELS_CONFIG = file; + try { + return body(); + } finally { + if (prev === undefined) delete process.env.MCODE_WEBUI_MODELS_CONFIG; + else process.env.MCODE_WEBUI_MODELS_CONFIG = prev; + rmSync(dir, { recursive: true, force: true }); + } +} + +describe("handleGetModels — catalogue merge", () => { + test("before a session exists: falls back to providers config + builtin catalogue", () => { + // Pre-session: no engine configOption. The merged response should + // source its models from MCODE_WEBUI_MODELS_CONFIG plus the mcode + // cli-bundle builtin catalogue, grouped by provider. + setBuiltinModelsMock(["MiniMax-M3", "MiniMax-M2.7"]); + return withModelsConfig( + { + providers: [ + { + id: "openai_compat", + label: "OpenAI-compat", + models: [ + { id: "gpt-4o-mini", label: "GPT-4o mini", contextLimit: 128000 }, + ], + }, + ], + }, + () => { + const cs = fakeCs("minimax_api/MiniMax-M3"); + const res = fakeRes(); + modelRoute.handleGetModels(null, res, { cs, cid: "cid-merge1" }); + const body = JSON.parse(res._body); + assert.equal(body.ok, true); + assert.equal(body.source, "config+mcode-cli-bundle"); + // providers-config model surfaces in the flat list + assert.ok( + body.models.find((m) => m.id === "openai_compat/gpt-4o-mini"), + "providers config model present", + ); + assert.equal( + body.models.find((m) => m.id === "openai_compat/gpt-4o-mini") + .contextLimit, + 128000, + "contextLimit surfaces from config", + ); + // and in the matching group + const cfgGroup = body.groups.find((g) => g.id === "openai_compat"); + assert.ok(cfgGroup, "providers config group present"); + assert.equal(cfgGroup.label, "OpenAI-compat"); + // builtin models folded into the minimax_api group + const builtins = body.groups.find((g) => g.id === "minimax_api"); + assert.ok(builtins, "builtin group present"); + const builtinIds = builtins.models.map((m) => m.id); + assert.ok(builtinIds.includes("minimax_api/MiniMax-M3")); + assert.ok(builtinIds.includes("minimax_api/MiniMax-M2.7")); + // current reflects the recorded pre-session choice rather than + // the engine's null/blank value + assert.equal(body.current, "minimax_api/MiniMax-M3"); + }, + ); + }); + + test("before a session, no providers config: builtin catalogue only", () => { + setBuiltinModelsMock(["MiniMax-M3"]); + // Explicitly unset the env so the route's cwd/models.json fallback + // does not silently pick up a real file. (Most CI cwd has none, but + // be defensive.) + const prev = process.env.MCODE_WEBUI_MODELS_CONFIG; + process.env.MCODE_WEBUI_MODELS_CONFIG = join( + tmpdir(), + "definitely-not-existing-models.json", + ); + try { + const cs = fakeCs("minimax_api/MiniMax-M3"); + const res = fakeRes(); + modelRoute.handleGetModels(null, res, { cs, cid: "cid-merge2" }); + const body = JSON.parse(res._body); + assert.equal(body.source, "mcode-cli-bundle"); + const builtinGroup = body.groups.find((g) => g.id === "minimax_api"); + assert.ok(builtinGroup, "builtin group present"); + assert.deepEqual( + builtinGroup.models.map((m) => m.id), + ["minimax_api/MiniMax-M3"], + ); + assert.equal(body.current, "minimax_api/MiniMax-M3"); + } finally { + if (prev === undefined) delete process.env.MCODE_WEBUI_MODELS_CONFIG; + else process.env.MCODE_WEBUI_MODELS_CONFIG = prev; + } + }); + + test("engine config option stays authoritative when present", () => { + setBuiltinModelsMock([]); + const cs = fakeCs(undefined, [MODEL_OPTION]); + const res = fakeRes(); + modelRoute.handleGetModels(null, res, { cs, cid: "cid-merge3" }); + const body = JSON.parse(res._body); + assert.equal(body.source, "acp-session-config"); + // engine-encoded ids round-trip + assert.deepEqual( + body.models.map((m) => m.id), + ["minimax_api:MiniMax-M3", "minimax_api:MiniMax-M2.7"], + ); + assert.equal(body.current, "minimax_api:MiniMax-M3"); + }); + + 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 + // already in `seen` and skips it. + setBuiltinModelsMock(["MiniMax-M3"]); + return withModelsConfig( + { + providers: [ + { + id: "minimax_api", + label: "MiniMax (config)", + models: [ + { + id: "MiniMax-M3", + label: "MiniMax-M3 (config override)", + contextLimit: 64000, + }, + ], + }, + ], + }, + () => { + const cs = fakeCs("minimax_api/MiniMax-M3"); + const res = fakeRes(); + modelRoute.handleGetModels(null, res, { cs, cid: "cid-merge4" }); + const body = JSON.parse(res._body); + // Only one entry for this id; its label/contextLimit come from + // the config rather than the builtin catalogue. + const ids = body.models + .filter((m) => m.id === "minimax_api/MiniMax-M3") + .map((m) => m); + assert.equal(ids.length, 1, "config id wins the collision"); + assert.equal(ids[0].label, "MiniMax-M3 (config override)"); + assert.equal(ids[0].contextLimit, 64000); + }, + ); + }); + + test("empty catalogue (no config, no builtin, no session) reports reason:no_catalogue and current is null", () => { + setBuiltinModelsMock([]); + const prev = process.env.MCODE_WEBUI_MODELS_CONFIG; + process.env.MCODE_WEBUI_MODELS_CONFIG = join( + tmpdir(), + "definitely-not-existing-models.json", + ); + try { + const cs = { model: {} }; // no cs.model.name recorded + const res = fakeRes(); + modelRoute.handleGetModels(null, res, { cs, cid: "cid-merge5" }); + const body = JSON.parse(res._body); + assert.deepEqual(body.models, []); + assert.equal(body.reason, "no_catalogue"); + // `null` rather than `DEFAULT_MODEL`: the chip must not claim a model + // the engine never confirmed (the original behaviour was the + // "no_session_config → DEFAULT_MODEL" bug). composer.tsx renders a + // neutral label when `current` is null. + assert.equal(body.current, null); + } finally { + if (prev === undefined) delete process.env.MCODE_WEBUI_MODELS_CONFIG; + else process.env.MCODE_WEBUI_MODELS_CONFIG = prev; + } + }); +}); diff --git a/packages/webui/test/routes/workspace.check.mjs b/packages/webui/test/routes/workspace.check.mjs index 7f6399b8..416a1aab 100644 --- a/packages/webui/test/routes/workspace.check.mjs +++ b/packages/webui/test/routes/workspace.check.mjs @@ -163,6 +163,49 @@ describe("handleWorkspaceBrowse — /api/workspace/browse GET", () => { assert.equal(body.ok, true); assert.ok(Array.isArray(body.roots)); }); + + test("browse wire shape — `dir`, `parent`, `children` (no `path`)", () => { + // Regression pin: the picker UI reads `listing.dir` for confirm / mkdir, + // and the webapp's BrowseResult type declares `dir`. The server has + // always returned `dir`, but the picker once read `listing.path` and the + // type used to claim `path` — both wrong, and the bug shipped because + // the wire shape had no test. This test pins every field the picker + // consumes so a future rename is caught at the route layer rather than + // by the next acceptance pass. + const tmp = mkdtempSync(join(tmpdir(), "webui-browse-shape-")); + try { + mkdirSync(join(tmp, "sub")); + const req = Readable.from([Buffer.from("")]); + req.url = `/api/workspace/browse?path=${encodeURIComponent(tmp)}`; + req.headers = { host: "localhost" }; + const res = fakeRes(); + wsRoute.handleWorkspaceBrowse(req, res, {}); + const body = JSON.parse(res._body); + assert.equal(res._status, 200); + assert.equal(body.ok, true); + // The directory the listing is for — picker reads this for confirm/mkdir. + assert.equal(body.dir, tmp); + // One level up (tmpdir has a parent on POSIX; on Windows it may be null). + assert.ok("parent" in body, "parent key present"); + // Children — picker reads this for the listing rows. + assert.ok(Array.isArray(body.children)); + assert.equal(body.children.length, 1); + assert.equal(body.children[0].name, "sub"); + assert.equal(body.children[0].path, join(tmp, "sub")); + // browseWorkspace only enumerates directories (files are filtered + // out at the server), so every child must carry `isDir: true`. + // The picker's row click navigates on `entry.isDir && setPath(...)` + // — without this flag the click handler never fires and the only + // way to navigate was the path input / up / home / roots buttons. + assert.equal(body.children[0].isDir, true); + // The `path` field MUST NOT exist on the browse response — a future + // rename to add it would mask the existing `dir` and re-introduce + // the picker bug. Asserting absence here keeps the contract tight. + assert.equal(body.path, undefined, "no `path` field on browse response"); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } + }); }); // ============================================================ diff --git a/packages/webui/webapp/components/composer.tsx b/packages/webui/webapp/components/composer.tsx index 9cfe33b7..d7bfff13 100644 --- a/packages/webui/webapp/components/composer.tsx +++ b/packages/webui/webapp/components/composer.tsx @@ -87,7 +87,15 @@ export function Composer({ t, inline = false }: { t: (key: MessageKey) => string const [attachments, setAttachments] = useState([]); const [error, setError] = useState(null); const [sending, setSending] = useState(false); - const [models, setModels] = useState<{ id: string; label: string }[]>([]); + const [models, setModels] = useState< + { + id: string; + label: string; + provider?: string; + contextLimit?: number; + source?: "engine" | "config" | "builtin"; + }[] + >([]); const [slashIndex, setSlashIndex] = useState(0); const editorRef = useRef(null); const fileRef = useRef(null); @@ -121,16 +129,29 @@ export function Composer({ t, inline = false }: { t: (key: MessageKey) => string // The model catalogue comes from the server; the chip shows the active model // from the state snapshot so it tracks changes made elsewhere. // - // The server reads it from the engine's session config options, which do not - // exist until a session does — so re-fetch when the session or the active - // model changes rather than only on mount. + // The server merges three sources (engine session config option, + // MCODE_WEBUI_MODELS_CONFIG providers file, and the mcode cli-bundle + // builtin catalogue) and returns `groups[]` for provider-grouped rendering. + // The catalogue is refreshable: the server re-reads both providers config + // and the cli bundle per request, so a hot-edit in the bundled mcode + // binary or a saved models.json takes effect on the next chip open. We + // re-fetch when the session or the active model changes rather than only + // on mount. const modelKey = state?.model?.name ?? ""; const sessionKey = state?.sessionId ?? ""; useEffect(() => { void api .listModels() .then((payload) => - setModels((payload.models ?? []).map((m) => ({ id: m.id, label: m.name || m.id }))), + setModels( + (payload.models ?? []).map((m) => ({ + id: m.id, + label: m.label ?? m.name ?? m.id, + provider: m.provider, + contextLimit: m.contextLimit, + source: m.source, + })), + ), ) .catch(() => {}); }, [modelKey, sessionKey]); @@ -724,6 +745,13 @@ function PermissionSelect({ * a long model name from opening past the right edge, which is where this * control sits. * + * Provider grouping: when models carry a `provider`, the panel renders a + * labelled section per provider with a thin rule between them. The grouping is + * visual only — the flat `models[]` is the source of truth for keyboard / + * aria semantics, so single-select wiring stays identical. Ungrouped entries + * (engine-encoded ids before a session exists) fall through to the flat list + * under an "Other" heading. + * * 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. */ @@ -735,13 +763,37 @@ function ModelSelect({ onPick, }: { t: (key: MessageKey) => string; - models: { id: string; label: string }[]; + models: { id: string; label: string; provider?: string }[]; value?: string; label: string; onPick: (id: string) => void; }) { const [open, setOpen] = useState(false); + // 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. + const grouped = useMemo(() => { + const order = []; + const buckets = new Map(); + for (const model of models) { + const key = model.provider ?? "__other"; + if (!buckets.has(key)) { + buckets.set(key, []); + order.push(key); + } + buckets.get(key)!.push(model); + } + return order.map((key) => ({ + key, + label: + key === "__other" + ? t("modelSelector.other") + : providerLabel(key), + models: buckets.get(key)!, + })); + }, [models, t]); + return ( ) : ( - models.map((model) => ( - { - setOpen(false); - onPick(model.id); - }} - /> - )) +
+ {grouped.map((group, groupIndex) => ( +
+
+ {group.label} +
+ {group.models.map((model) => ( + { + setOpen(false); + onPick(model.id); + }} + /> + ))} +
+ ))} +
)} )} @@ -791,6 +859,30 @@ function ModelSelect({ ); } +/** + * Display label for a provider id. + * + * Falls back to the raw id when nothing better is known — keeping the chip + * readable beats hiding the value. New provider ids ship without a translation + * here on purpose: an unknown id means the catalogue has a provider the rest + * of the UI does not yet know about, and rendering the raw id surfaces the + * drift instead of silently mapping it to something plausible. + */ +function providerLabel(id: string): string { + switch (id) { + case "minimax_api": + return "MiniMax"; + case "openai_compat": + return "OpenAI"; + case "anthropic": + return "Anthropic"; + case "__engine": + return "Engine session"; + default: + return id; + } +} + /** * Model name without its provider prefix (`minimax_api/MiniMax-M3` → `MiniMax-M3`). * diff --git a/packages/webui/webapp/components/panels.tsx b/packages/webui/webapp/components/panels.tsx index db658014..599039a2 100644 --- a/packages/webui/webapp/components/panels.tsx +++ b/packages/webui/webapp/components/panels.tsx @@ -1,7 +1,14 @@ "use client"; -import { useCallback, useEffect, useRef, useState } from "react"; -import { Input as AntInput, type InputRef, Segmented as AntSegmented, Switch } from "antd"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + Input as AntInput, + type InputRef, + Modal as AntModal, + Segmented as AntSegmented, + Switch, + Tabs as AntTabs, +} from "antd"; import * as api from "@/lib/api"; import { useAlerts } from "@/lib/alerts"; @@ -633,52 +640,81 @@ function WorkspacePanel({ t }: { t: (key: MessageKey) => string }) { // notice inside the section itself (instead of mutating the global alert // ring buffer, which is reserved for engine-emitted events). const [missingEndpoint, setMissingEndpoint] = useState(null); + const [pickerOpen, setPickerOpen] = useState(false); return (
- {/* §1 section_environmental — branch in subtitle, 3 git entries as - active buttons. Branch placeholder uses `\u00a0` (upstream's choice) - rather than `undefined` so the header keeps its height when the - engine has not yet picked a workspace. */} + {/* §1 section_environmental — branch in subtitle, the current workspace + directory underneath, a "Switch workspace" button, and 3 git + entries as active buttons. Branch placeholder uses `\u00a0` + (upstream's choice) rather than `undefined` so the header keeps its + height when the engine has not yet picked a workspace. */} -
- {( - [ - { row: "changes", testid: "workspace-changes-entry", icon: "file" }, - { row: "commitAndPush", testid: "workspace-commit-entry", icon: "file" }, - { row: "openTerminal", testid: "workspace-open-terminal-entry", icon: "terminal" }, - ] as const - ).map(({ row, testid, icon }) => ( +
+ {/* The current workspace — the directory itself, plus the + "Switch workspace" button next to it. Empty when the server + has not reported one yet (the message is the "no workspace" + hint the picker renders for an unset state). */} +
+ + + + + {state?.workspace.dir || t("workspace.picker.noWorkspace")} + - ))} - {missingEndpoint ? ( -

- {t("workspace.env.activeHint")} -

- ) : null} +
+ +
+ {( + [ + { row: "changes", testid: "workspace-changes-entry", icon: "file" }, + { row: "commitAndPush", testid: "workspace-commit-entry", icon: "file" }, + { row: "openTerminal", testid: "workspace-open-terminal-entry", icon: "terminal" }, + ] as const + ).map(({ row, testid, icon }) => ( + + ))} + {missingEndpoint ? ( +

+ {t("workspace.env.activeHint")} +

+ ) : null} +
@@ -710,6 +746,13 @@ function WorkspacePanel({ t }: { t: (key: MessageKey) => string }) { + + setPickerOpen(false)} + currentDir={state?.workspace.dir ?? null} + />
); } @@ -765,6 +808,627 @@ function SectionPlaceholder({ testid, t }: { testid: string; t: (key: MessageKey ); } +/** + * Workspace picker — modal that drives the workspace switch flow. + * + * Implements the PR #22 fs-picker feature checklist (path input, parent + * navigation, directory listing, glob filter, create-new-folder, recents + * tab, native OS picker) using antd primitives in the current Next.js + * stack. Two tabs: `Recents` shows the server's recent-workspaces list + * (from session activity) and a "no workspace" button (uses tmpdir); + * `Browse` is the in-product directory navigator. + * + * The browser tab is the workhorse — it is the one that always works, + * including in token-gated / no-native-picker environments. The native + * picker button is opportunistic: when it succeeds, the picked path is + * fed straight into the same `setWorkspace` call. + * + * Wire: the modal is dismissible (settings-modal-style), and the picked + * path is forwarded to `POST /api/workspace` (the same handler the rest + * of the app uses). On success the picker closes; the state snapshot + * update carries the new directory through the SSE stream, so any + * downstream consumer (composer attachments, session tree) sees it + * without further wiring. + */ +function WorkspacePickerModal({ + t, + open, + onClose, + currentDir, +}: { + t: (key: MessageKey) => string; + open: boolean; + onClose: () => void; + currentDir: string | null; +}) { + return ( + + + {t("workspace.picker.title")} + + + } + > + + + ); +} + +/** + * The modal body. State machine: `Browse` reads `/api/workspace/browse` + * (containment-checked server-side), `Recents` reads `/api/workspace/recent`. + * Both feeds feed the same confirmation handler (`setWorkspace`). + */ +function WorkspacePickerBody({ + t, + onClose, + currentDir, +}: { + t: (key: MessageKey) => string; + onClose: () => void; + currentDir: string | null; +}) { + const [tab, setTab] = useState<"recents" | "browse">("recents"); + + return ( +
+ setTab(key as "recents" | "browse")} + items={[ + { + key: "recents", + label: t("workspace.picker.tabs.recents"), + }, + { + key: "browse", + label: t("workspace.picker.tabs.browse"), + }, + ]} + /> + + {tab === "recents" ? ( + setTab("browse")} + /> + ) : ( + + )} +
+ ); +} + +interface RecentPick { + dir: string; + name: string; + sessionCount: number; + lastActiveAt: number; +} + +function WorkspaceRecentsTab({ + t, + onClose, + currentDir, + onSwitchToBrowse, +}: { + t: (key: MessageKey) => string; + onClose: () => void; + currentDir: string | null; + onSwitchToBrowse: () => void; +}) { + const [search, setSearch] = useState(""); + const [items, setItems] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + const [tmpDir, setTmpDir] = useState(null); + + // Last-write-wins: a search query that lands after a broader one + // should not silently re-replace the displayed list. Same trick as + // the FilesPanel. + const loadGen = useRef(0); + const load = useCallback( + async (q: string) => { + const gen = ++loadGen.current; + setLoading(true); + setError(null); + try { + const result = await api.recentWorkspaces(q, 20); + if (gen !== loadGen.current) return; + setItems(result.items ?? []); + if (result.tmpDir) setTmpDir(result.tmpDir); + } catch (cause) { + if (gen !== loadGen.current) return; + setError(cause instanceof Error ? cause.message : String(cause)); + } finally { + if (gen === loadGen.current) setLoading(false); + } + }, + [], + ); + + useEffect(() => { + const handle = window.setTimeout(() => { + void load(search); + }, 120); + return () => window.clearTimeout(handle); + }, [load, search]); + + const pick = async (dir: string | null) => { + setBusy(true); + try { + if (dir === null) { + // "No workspace" uses tmpdir: same behaviour as the chat composer + // when launched without an explicit workspace. The server + // resolves the temp directory as the workspace. + if (!tmpDir) { + setError(t("workspace.picker.error")); + return; + } + await api.setWorkspace(tmpDir); + } else { + await api.setWorkspace(dir); + } + onClose(); + } catch (cause) { + setError(cause instanceof Error ? cause.message : String(cause)); + } finally { + setBusy(false); + } + }; + + const filtered = items.filter( + (it) => + !search.trim() || + it.dir.toLowerCase().includes(search.trim().toLowerCase()) || + it.name.toLowerCase().includes(search.trim().toLowerCase()), + ); + + return ( +
+ setSearch(event.target.value)} + placeholder={t("workspace.picker.recents.search")} + data-testid="workspace-recents-search" + className="mavis-input" + /> + + {error ? ( +

+ {error} +

+ ) : null} + + {loading && items.length === 0 ? ( +

+ {t("workspace.picker.loading")} +

+ ) : null} + + {!loading && items.length === 0 && !error ? ( +

+ {t("workspace.picker.recents.empty")} +

+ ) : null} + + {filtered.length > 0 ? ( +
    + {filtered.map((item) => { + const isCurrent = item.dir === currentDir; + return ( +
  • + +
  • + ); + })} +
+ ) : null} + +
+ + +
+
+ ); +} + +function WorkspaceBrowseTab({ + t, + onClose, + currentDir, +}: { + t: (key: MessageKey) => string; + onClose: () => void; + currentDir: string | null; +}) { + // Seed the directory from the active workspace when one is set — + // most of the time the user opens the picker to "go up one level", + // not to navigate from the platform root. + const [path, setPath] = useState(() => currentDir ?? ""); + const [listing, setListing] = useState(null); + const [filter, setFilter] = useState(""); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + const [nativeBusy, setNativeBusy] = useState(false); + + const loadGen = useRef(0); + const load = useCallback(async (target: string) => { + const gen = ++loadGen.current; + setLoading(true); + setError(null); + try { + const result = await api.browseWorkspace(target || undefined); + if (gen !== loadGen.current) return; + if (result.ok) { + setListing(result); + } else { + setError(result.error ?? t("workspace.picker.error")); + } + } catch (cause) { + if (gen !== loadGen.current) return; + setError(cause instanceof Error ? cause.message : String(cause)); + } finally { + if (gen === loadGen.current) setLoading(false); + } + }, [t]); + + useEffect(() => { + void load(path); + }, [load, path]); + + const entries = listing?.children ?? []; + // Dirs first, then alphabetical — the same shape FilesPanel uses. + const sorted = useMemo(() => { + const dirs = entries.filter((e) => e.isDir); + const rest = entries.filter((e) => !e.isDir); + dirs.sort((a, b) => a.name.localeCompare(b.name)); + rest.sort((a, b) => a.name.localeCompare(b.name)); + return [...dirs, ...rest]; + }, [entries]); + const matched = filter + ? sorted.filter((e) => matchFilter(e.name, filter)) + : sorted; + const visible = matched.slice(0, 200); + const hidden = matched.length - visible.length; + + const pick = async () => { + // Server wire field is `dir` (see server/lib/workspace.js#browseWorkspace). + // The wire name used to drift to the wrong field in the webapp type and + // reading code — that regression was the root cause of the picker + // silently doing nothing (confirm button permanently disabled). Keep + // this on `listing.dir`; the regression test in + // webapp/test/workspace-picker-wire.test.ts pins both the type and + // the read site. + if (!listing?.dir) { + setError(t("workspace.picker.error")); + return; + } + setBusy(true); + try { + await api.setWorkspace(listing.dir); + onClose(); + } catch (cause) { + setError(cause instanceof Error ? cause.message : String(cause)); + } finally { + setBusy(false); + } + }; + + const mkdir = async () => { + if (!listing?.dir) return; + const name = window.prompt(t("workspace.picker.newFolderPrompt"), ""); + if (!name) return; + const trimmed = name.trim(); + if (!trimmed) return; + try { + const next = `${listing.dir.replace(/\/+$/, "")}/${trimmed}`; + const result = await api.mkdir(next); + if (!result.ok) { + setError(result.path ? "mkdir failed" : "mkdir failed"); + return; + } + // Re-list the parent to surface the new folder. + await load(listing.dir); + } catch (cause) { + setError(cause instanceof Error ? cause.message : String(cause)); + } + }; + + const nativeSupported = + typeof window !== "undefined" && + (window.navigator.platform.toLowerCase().includes("mac") || + window.navigator.platform.toLowerCase().includes("linux") || + window.navigator.platform.toLowerCase().includes("win")); + + const nativePick = async () => { + setNativeBusy(true); + try { + const result = await api.pickWorkspaceNative(); + if (result.ok && result.path) { + await api.setWorkspace(result.path); + onClose(); + return; + } + setError(result.error ?? t("workspace.picker.error")); + } catch (cause) { + setError(cause instanceof Error ? cause.message : String(cause)); + } finally { + setNativeBusy(false); + } + }; + + return ( +
+ {/* Toolbar: parent, path, home, root, mkdir, native picker. */} +
+ + setPath(event.target.value)} + onPressEnter={() => setPath(path.trim())} + placeholder={t("workspace.picker.pathPlaceholder")} + aria-label={t("workspace.picker.pathPlaceholder")} + data-testid="workspace-picker-path" + className="mavis-input min-w-0 flex-1" + /> +
+ +
+ + + + {nativeSupported ? ( + + ) : null} +
+ + {/* Filter row — same matcher as FilesPanel. */} +
+ setFilter(event.target.value)} + placeholder={t("workspace.picker.filterPlaceholder")} + data-testid="workspace-picker-filter" + className="mavis-input min-w-0 flex-1" + /> + {filter ? ( + + ) : null} +
+ + {error ? ( +

+ {error} +

+ ) : null} + + {loading && entries.length === 0 ? ( +

+ {t("workspace.picker.loading")} +

+ ) : null} + + {/* When no path has been given yet, the Browse tab's root view + shows the allowed roots rather than a folder list — clicking + one enters it. */} + {!path && !loading ? ( +
    + {(listing?.roots ?? []).map((root) => ( +
  • + +
  • + ))} +
+ ) : null} + + {path && visible.length > 0 ? ( +
    + {visible.map((entry) => ( +
  • + +
  • + ))} + {hidden > 0 ? ( +
  • + {t("files.showing")} {visible.length} / {matched.length} +
  • + ) : null} +
+ ) : null} + + {!loading && path && matched.length === 0 && !error ? ( +

+ {filter + ? t("files.noMatch") + : t("workspace.picker.empty")} +

+ ) : null} + +
+ + +
+
+ ); +} + // --- settings --------------------------------------------------------------- function SettingsPanel({ diff --git a/packages/webui/webapp/lib/api.ts b/packages/webui/webapp/lib/api.ts index 6ea7def2..752124c4 100644 --- a/packages/webui/webapp/lib/api.ts +++ b/packages/webui/webapp/lib/api.ts @@ -235,10 +235,47 @@ export const getSessionTree = (refresh = false) => // --- model and permissions -------------------------------------------------- +/** + * One entry in the model catalogue. + * + * `id` is the wire id (`minimax_api/MiniMax-M3`, or — when the engine session + * is active — its encoded `m:::v:` form). `label` + * is the display name; provider-grouped menus render this rather than the id. + * `provider` is the prefix before the first `/` (`minimax_api`, `openai_compat`, + * `__engine`, …) — `groups[]` is keyed by it so a UI can render per-provider + * sections. `source` is which list this entry came from (`engine` / + * `config` / `builtin`); informational. + * + * `contextLimit` is optional: it surfaces when a provider's + * `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). + */ +export interface ModelEntry { + id: string; + name?: string; + label?: string; + provider?: string; + source?: "engine" | "config" | "builtin"; + contextLimit?: number; +} + +export interface ModelGroup { + id: string; + label: string; + models: ModelEntry[]; +} + export interface ModelsPayload { ok: boolean; + /** `id` of the active model, or `null`/`DEFAULT_MODEL` fallback when none recorded. */ current: string; - models: { id: string; name?: string }[]; + models: ModelEntry[]; + /** Per-provider groups; same models appear in `models[]` flat too. */ + groups: ModelGroup[]; + /** One of `acp-session-config` / `config+mcode-cli-bundle` / `mcode-cli-bundle`. */ + source?: string; + reason?: string; } export const listModels = () => request("/api/models"); @@ -293,11 +330,21 @@ export const answer = (type: string, option: string) => // --- workspace -------------------------------------------------------------- +export interface SetWorkspaceResult { + ok: boolean; + dir: string; + branch: string | null; + treeState?: string; + workspace?: { dir: string; branch: string | null; tree: unknown }; + defaultWorkspace?: string; + error?: string; +} + export const setWorkspace = (dir: string, syncTui = false) => - request<{ ok: boolean; dir: string; branch: string | null; treeState: string }>( - "/api/workspace", - { method: "POST", json: { dir, syncTui } }, - ); + request("/api/workspace", { + method: "POST", + json: { dir, syncTui }, + }); export interface BrowseEntry { name: string; @@ -305,12 +352,32 @@ export interface BrowseEntry { isDir: boolean; } +/** + * Server response from `GET /api/workspace/browse[?path=…]`. + * + * The server's wire field is **`dir`** — see + * `packages/webui/server/lib/workspace.js#browseWorkspace`, which returns + * `{ ok, dir, parent, children, roots, skipped, total }`. FilesPanel was + * unaffected because its row reads are directory-name based; the workspace + * picker in WorkspaceBrowseTab reads `dir` for the confirm/mkdir buttons, + * so getting this wrong disables the picker (no confirm, silent mkdir). + */ export interface BrowseResult { ok: boolean; - path: string | null; + /** The directory the server listed. Server wire field is `dir`. */ + dir: string | null; + /** One level up; null when at a containment boundary. */ + parent: string | null; children: BrowseEntry[]; + skipped?: number; + total?: number; /** POSIX root view lists allowed roots with `children: []` (see API.md). */ roots?: string[]; + /** Server-reported home directory, surfaced alongside the root view. */ + home?: string; + tmpDir?: string; + platform?: string; + error?: string; } /** List a directory for the workspace tree browser (containment-checked server-side). */ @@ -319,8 +386,62 @@ export const browseWorkspace = (path?: string) => `/api/workspace/browse${path ? `?path=${encodeURIComponent(path)}` : ""}`, ); -export const recentWorkspaces = () => - request<{ ok: boolean; recent: string[] }>("/api/workspace/recent"); +export interface RecentWorkspace { + dir: string; + name: string; + lastActiveAt: number; + sessionCount: number; +} + +export interface RecentWorkspacesResult { + ok: boolean; + items: RecentWorkspace[]; + total: number; + search: string; + limit: number; + tmpDir?: string; +} + +/** Recents list — session-grouped and case-insensitive substring-filtered server-side. */ +export const recentWorkspaces = (search = "", limit = 5) => + request( + `/api/workspace/recent?search=${encodeURIComponent(search)}&limit=${limit}`, + ); + +export interface WorkspaceTreeWorkspace { + dir: string; + name: string; + sessionCount: number; + lastActiveAt: number; + current: boolean; + sessions: Array<{ + id: string; + mcodeSessionId: string | null; + title: string; + updatedAt: number; + }>; +} + +export interface WorkspaceTreeResult { + ok: boolean; + current: string; + defaultWorkspace: string; + home: string; + tmpDir: string; + platform: string; + workspaces: WorkspaceTreeWorkspace[]; +} + +/** Workspace → sessions tree (used by the picker's recents tab). */ +export const workspaceTree = () => + request("/api/workspace/tree"); + +/** Native OS directory picker (zenity/kdialog/osascript/PowerShell). */ +export const pickWorkspaceNative = () => + request<{ ok: boolean; path: string | null; error?: string }>( + "/api/workspace/pick", + { method: "POST", json: {} }, + ); // --- uploads ---------------------------------------------------------------- diff --git a/packages/webui/webapp/lib/i18n.ts b/packages/webui/webapp/lib/i18n.ts index ede626db..d3b65859 100644 --- a/packages/webui/webapp/lib/i18n.ts +++ b/packages/webui/webapp/lib/i18n.ts @@ -80,6 +80,10 @@ const en = { "composer.dropHint": "Drop file to upload", "composer.model": "Model", "composer.noModels": "No models available", + /* Model selector — provider-grouped dropdown. "Other" catches + engine-encoded ids whose provider prefix did not coerce (i.e. a + model the catalogue could not bucket). */ + "modelSelector.other": "Other", "permission.label": "Permission mode", "permission.ask": "Ask", @@ -163,6 +167,35 @@ const en = { "usage.reset": "Resets", "alerts.empty": "No messages", "workspace.sectionEnvironment": "Environment", + /* Workspace panel — switch workspace entry. The picker modal itself + borrows from pr-22's fs-picker.js feature checklist: a path input, + parent navigation, directory listing, glob filter, create-new-folder, + recents tab, and an "Open native picker" button on supported + platforms. */ + "workspace.switch": "Switch workspace", + "workspace.picker.title": "Switch workspace", + "workspace.picker.pathPlaceholder": "Path…", + "workspace.picker.up": "Up one level", + "workspace.picker.home": "Home", + "workspace.picker.root": "Allowed roots", + "workspace.picker.newFolder": "New folder", + "workspace.picker.newFolderPrompt": "Folder name", + "workspace.picker.filterPlaceholder": "Filter… (globs like *.md)", + "workspace.picker.confirm": "Select", + "workspace.picker.cancel": "Cancel", + "workspace.picker.pickCurrent": "Use this folder", + "workspace.picker.noWorkspace": "No workspace (scratch)", + "workspace.picker.loading": "Loading…", + "workspace.picker.empty": "This folder is empty", + "workspace.picker.error": "Could not read this folder", + "workspace.picker.tabs.recents": "Recent", + "workspace.picker.tabs.browse": "Browse", + "workspace.picker.recents.empty": "No recent workspaces", + "workspace.picker.recents.search": "Search recent workspaces", + "workspace.picker.native": "Open native picker", + "workspace.picker.nativeUnsupported": "Native picker not available on this platform", + "workspace.picker.useWorkspace": "Use this workspace", + "workspace.picker.created": "Created", /* Workspace panel section labels are aligned with the desktop's `workspace_panel.section_*` keys (反编译 36705 chunk). */ "workspace.sectionPlan": "Plan", @@ -314,6 +347,8 @@ const zh: Record = { "composer.dropHint": "松开上传文件", "composer.model": "模型", "composer.noModels": "暂无可用模型", + /* Model selector — provider-grouped dropdown. */ + "modelSelector.other": "其他", "permission.label": "权限模式", "permission.ask": "主动询问", @@ -395,6 +430,31 @@ const zh: Record = { /* Workspace panel section labels are aligned with the desktop's `workspace_panel.section_*` keys (反编译 36705 chunk). */ "workspace.sectionEnvironment": "环境信息", + /* Workspace panel — switch workspace entry (中文). */ + "workspace.switch": "切换工作区", + "workspace.picker.title": "切换工作区", + "workspace.picker.pathPlaceholder": "路径…", + "workspace.picker.up": "返回上一级", + "workspace.picker.home": "主目录", + "workspace.picker.root": "允许根", + "workspace.picker.newFolder": "新建文件夹", + "workspace.picker.newFolderPrompt": "文件夹名", + "workspace.picker.filterPlaceholder": "过滤…(支持 glob,如 *.md)", + "workspace.picker.confirm": "选择", + "workspace.picker.cancel": "取消", + "workspace.picker.pickCurrent": "使用当前文件夹", + "workspace.picker.noWorkspace": "无需工作区(临时)", + "workspace.picker.loading": "加载中…", + "workspace.picker.empty": "文件夹为空", + "workspace.picker.error": "无法读取该文件夹", + "workspace.picker.tabs.recents": "最近", + "workspace.picker.tabs.browse": "浏览", + "workspace.picker.recents.empty": "暂无最近工作区", + "workspace.picker.recents.search": "搜索最近工作区", + "workspace.picker.native": "打开原生选择器", + "workspace.picker.nativeUnsupported": "当前平台无原生选择器", + "workspace.picker.useWorkspace": "使用此工作区", + "workspace.picker.created": "已创建", "workspace.sectionPlan": "计划", "workspace.sectionAgentTeam": "Agent 团队", "workspace.sectionWorkingFolders": "工作文件夹", diff --git a/packages/webui/webapp/test/composer-models.test.ts b/packages/webui/webapp/test/composer-models.test.ts new file mode 100644 index 00000000..9d117a44 --- /dev/null +++ b/packages/webui/webapp/test/composer-models.test.ts @@ -0,0 +1,105 @@ +// webapp/test/composer-models.test.ts +// +// Unit tests for the catalogue grouping the composer ModelSelect applies +// before rendering. Pin the order-preserving behaviour the ModelSelect +// panel relies on: catalogue order is preserved within each provider, +// providers are bucketed in first-seen order, and entries without a +// provider land in a single `__other` bucket so they are still reachable. +// +// Style note: pure-function re-implementation (mirroring the grouping +// inside composer.tsx#ModelSelect). The grouping logic is small and +// stable; isolating it here means the regression lives next to the test +// instead of being a snapshot of a render-tree. + +import { test, describe } from "node:test"; +import assert from "node:assert/strict"; + +interface CatalogueEntry { + id: string; + label: string; + provider?: string; +} + +/** Mirror of composer.tsx ModelSelect's grouping derivation. */ +function groupModelsByProvider( + models: CatalogueEntry[], + otherLabel: string, +): Array<{ key: string; label: string; models: CatalogueEntry[] }> { + const order: string[] = []; + const buckets = new Map(); + for (const model of models) { + const key = model.provider ?? "__other"; + if (!buckets.has(key)) { + buckets.set(key, []); + order.push(key); + } + buckets.get(key)!.push(model); + } + return order.map((key) => ({ + key, + label: key === "__other" ? otherLabel : key, + models: buckets.get(key)!, + })); +} + +describe("groupModelsByProvider — composer ModelSelect grouping", () => { + test("groups entries by `provider` while preserving catalogue order", () => { + const groups = groupModelsByProvider( + [ + { id: "minimax_api/MiniMax-M3", label: "MiniMax-M3", provider: "minimax_api" }, + { id: "openai_compat/gpt-4o", label: "GPT-4o", provider: "openai_compat" }, + { id: "minimax_api/MiniMax-M2.7", label: "MiniMax-M2.7", provider: "minimax_api" }, + ], + "Other", + ); + assert.equal(groups.length, 2); + const first = groups[0]; + const second = groups[1]; + assert.ok(first && second, "groups present"); + assert.equal(first.key, "minimax_api"); + assert.deepEqual( + first.models.map((m) => m.id), + ["minimax_api/MiniMax-M3", "minimax_api/MiniMax-M2.7"], + "catalogue order preserved within a provider", + ); + assert.equal(second.key, "openai_compat"); + assert.equal(second.models.length, 1); + }); + + test("provider-less entries fall into a single `__other` bucket", () => { + const groups = groupModelsByProvider( + [ + { id: "m:minimax_api:MiniMax-M3:v:default", label: "M3 default" }, + { id: "minimax_api/MiniMax-M3", label: "MiniMax-M3", provider: "minimax_api" }, + ], + "Other", + ); + const first = groups[0]; + const second = groups[1]; + assert.ok(first && second, "both groups present"); + // `__other` comes first because it was seen first in the catalogue + assert.equal(first.key, "__other"); + assert.equal(first.label, "Other"); + assert.equal(second.key, "minimax_api"); + }); + + test("empty catalogue yields no groups", () => { + const groups = groupModelsByProvider([], "Other"); + assert.equal(groups.length, 0); + }); + + test("all-providerless catalogue collapses to one group", () => { + const groups = groupModelsByProvider( + [ + { id: "m:a:b:v:x", label: "x" }, + { id: "m:a:b:v:y", label: "y" }, + ], + "Other", + ); + assert.equal(groups.length, 1); + const only = groups[0]; + assert.ok(only, "single group present"); + assert.equal(only.key, "__other"); + assert.equal(only.models.length, 2); + }); +}); \ No newline at end of file diff --git a/packages/webui/webapp/test/workspace-picker-wire.test.ts b/packages/webui/webapp/test/workspace-picker-wire.test.ts new file mode 100644 index 00000000..f2be1005 --- /dev/null +++ b/packages/webui/webapp/test/workspace-picker-wire.test.ts @@ -0,0 +1,71 @@ +// webapp/test/workspace-picker-wire.test.ts +// +// Regression pin: the wire shape between the workspace browse route and +// the webapp's BrowseResult type. The picker once declared `path` and +// read `listing.path`; the server has always returned `dir`. The picker +// UI silently broke (confirm button permanently disabled, mkdir a no-op) +// because the type and the route drifted without a wire test. This file +// asserts that: +// +// 1. The browse route's response carries `dir`, NOT `path`. +// 2. The BrowseResult interface shape matches the live response. +// 3. The picker's render code reads `listing.dir`, not `listing.path`. +// +// Pure-string scan: no DOM, no fetch. The risk being pinned is a future +// refactor flipping the field name and the type back into alignment by +// coincidence — a runtime test that needs the picker mounted would miss +// it. A scan of the source ensures the picker cannot regress without +// this file failing. + +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +const ROOT = join(import.meta.dirname, ".."); + +describe("BrowseResult type ↔ server wire shape", () => { + it("api.ts declares `dir`, not `path`", () => { + const apiSrc = readFileSync(join(ROOT, "lib/api.ts"), "utf8"); + const match = apiSrc.match(/export interface BrowseResult\s*\{([\s\S]*?)\n\}/); + assert.ok(match, "BrowseResult interface present"); + const shape = match[1] as string; + assert.match( + shape, + /\bdir\s*:\s*string\s*\|\s*null/, + "BrowseResult declares `dir: string | null`", + ); + assert.doesNotMatch( + shape, + /^\s*path\s*:/m, + "BrowseResult must NOT declare a top-level `path` field (the wire field is `dir`)", + ); + }); + + it("WorkspaceBrowseTab reads `listing.dir`, not `listing.path`", () => { + // The picker once read `listing.path` (which is always undefined on + // the wire response) → confirm button stayed disabled. This scan + // catches a regression where the field name drifts back. + const panelsSrc = readFileSync( + join(ROOT, "components/panels.tsx"), + "utf8", + ); + // Isolate the WorkspaceBrowseTab function — it sits between + // "function WorkspaceBrowseTab" and the next `function ` or `// --- ` block. + const start = panelsSrc.indexOf("function WorkspaceBrowseTab"); + assert.ok(start > -1, "WorkspaceBrowseTab located"); + const tail = panelsSrc.slice(start); + const end = tail.indexOf("\n// ---"); + const body = (end > -1 ? tail.slice(0, end) : tail) as string; + assert.match( + body, + /listing\??\.dir/, + "WorkspaceBrowseTab reads `listing.dir` (the wire field)", + ); + assert.doesNotMatch( + body, + /listing\??\.path/, + "WorkspaceBrowseTab must not read `listing.path` (would always be undefined)", + ); + }); +}); \ No newline at end of file diff --git a/release/public-source.json b/release/public-source.json index 6c1838e3..497f91e7 100644 --- a/release/public-source.json +++ b/release/public-source.json @@ -3609,6 +3609,7 @@ "packages/webui/webapp/test/attachment-drop.test.ts", "packages/webui/webapp/test/chat-virtual-list.test.ts", "packages/webui/webapp/test/cid.test.ts", + "packages/webui/webapp/test/composer-models.test.ts", "packages/webui/webapp/test/context-meter-format.test.ts", "packages/webui/webapp/test/greeting.test.ts", "packages/webui/webapp/test/icons.test.ts", @@ -3618,6 +3619,7 @@ "packages/webui/webapp/test/transcript-roundtrip.test.ts", "packages/webui/webapp/test/transcript.test.ts", "packages/webui/webapp/test/workspace-filter.test.ts", + "packages/webui/webapp/test/workspace-picker-wire.test.ts", "packages/webui/webapp/tsconfig.json", "pnpm-lock.yaml", "pnpm-workspace.yaml",