From 58efbb2cf2e541f9f8b181b0bcbee00fa6fd8691 Mon Sep 17 00:00:00 2001 From: weekbin Date: Fri, 25 Sep 2026 00:46:45 +0800 Subject: [PATCH] fix(webui): reap the mcode acp process group and group models by provider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two increments recovered from the feat/webui-react branch, rebased onto the current main rather than merged from it. Where the branch and main overlap, main's implementation wins outright; nothing from the branch's frontend package is carried over. 1. acp.mjs: the engine's acp entry spawns its own MCP plugin tree. `stop()` signalled only the direct child, so the whole tree was orphaned on every restart. The child is now spawned `detached` on POSIX, making its pid the process-group id, and `stop()` signals the group (SIGTERM, then SIGKILL after 3s) instead of just the child. Windows has no group to signal and keeps the direct-child kill. acp-client.js: stop a non-alive singleton before overwriting the reference. A client whose `alive` is false can still own a child on its way down; overwriting the pointer leaked it the same way. 2. routes/model.js: partition /api/models by provider. The engine encodes a selection as m:::v:, so the provider falls out of the id and `groups` is always consistent with `models`. An optional models.json (MCODE_WEBUI_MODELS_CONFIG, else ./models.json) is re-read per request and overlays display metadata — group label, model label, contextLimit. It is an overlay, not a replacement: the engine's session config option still decides which models exist, so an overlay cannot invent a model the engine does not offer, and a missing or malformed file is not an error. The client renders one section per provider when more than one exists and otherwise keeps the flat list exactly as before. The branch's changes to browseWorkspace's no-path behaviour, to handleAnswer's response contract, and to the fs.js body-size limit are not carried over: the first reopens a directory enumeration oracle that lib-workspace-containment pins closed, the second is a contract regression its own test catches, and the third would drop a cap main already has. The branch's WebSocket transport is also not carried over. Its server side ports cleanly, but main's frontend consumes the state stream over SSE, and its only WebSocket client lives in the branch's webui-react package — so adding the endpoint alone would ship a transport with no consumer, while using it means replacing main's stream client. That is a transport decision rather than an absorbed increment, and it belongs in its own change. --- packages/webui/acp.mjs | 19 ++- packages/webui/server/lib/acp-client.js | 7 + packages/webui/server/routes/model.js | 115 +++++++++++++++- .../webui/test/lib/acp-process-group.test.js | 129 +++++++++++++++++ packages/webui/test/routes/model.check.mjs | 130 ++++++++++++++++++ packages/webui/webapp/components/composer.tsx | 67 +++++++-- packages/webui/webapp/lib/api.ts | 21 ++- .../webui/webapp/styles/mavis-dropdown.css | 18 +++ release/public-source.json | 1 + 9 files changed, 485 insertions(+), 22 deletions(-) create mode 100644 packages/webui/test/lib/acp-process-group.test.js diff --git a/packages/webui/acp.mjs b/packages/webui/acp.mjs index ddee8f07..af9bbab7 100644 --- a/packages/webui/acp.mjs +++ b/packages/webui/acp.mjs @@ -93,6 +93,10 @@ export class McodeAcpClient extends EventEmitter { stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true, shell: false, + // POSIX 下让子进程当进程组长 —— stop() 才能杀整组。引擎的 acp + // 入口会自己拉起 MCP 插件链 (proxy → mcp-server → …),这些孙进程收不到 + // 针对直接子进程的信号,于是每次重启都留下一棵孤儿插件树。 + detached: process.platform !== 'win32', }) // Node 24 does NOT emit 'exit' on spawn failure (ENOENT when mcode is // not installed) — only 'error' + 'close'. If pending requests were @@ -323,7 +327,20 @@ export class McodeAcpClient extends EventEmitter { stop() { if (this.child) { - try { this.child.kill() } catch {} + const pid = this.child.pid + if (process.platform !== 'win32' && pid) { + // 进程组 SIGTERM → 3s 兜底 SIGKILL (整组, 含 MCP 插件链)。 + // detached:true (见 start()) 让 pid 即进程组 id, 取负号即整组。 + try { process.kill(-pid, 'SIGTERM') } catch { try { this.child.kill('SIGTERM') } catch {} } + const c = this.child + const t = setTimeout(() => { + try { process.kill(-pid, 'SIGKILL') } catch {} + try { c.kill('SIGKILL') } catch {} + }, 3000) + if (typeof t.unref === 'function') t.unref() + } else { + try { this.child.kill() } catch {} + } this.child = null } this.started = false diff --git a/packages/webui/server/lib/acp-client.js b/packages/webui/server/lib/acp-client.js index 8e31e16a..073cef43 100644 --- a/packages/webui/server/lib/acp-client.js +++ b/packages/webui/server/lib/acp-client.js @@ -21,6 +21,13 @@ let _mcodeAcpInitPromise = null; // 防止并发 init 同一个 client export async function getMcodeAcpClient() { if (_mcodeAcpSingleton && _mcodeAcpSingleton.alive) return _mcodeAcpSingleton; if (_mcodeAcpInitPromise) return _mcodeAcpInitPromise; + // 覆盖引用前先 stop 掉旧实例: `alive` 判定为假的 client 仍可能持有子进程 + // (例如 child 已置空但 _alive 未同步, 或子进程仍在收尾), 直接覆盖引用会把它 + // 和它的 MCP 插件树一起漏掉。与下面 init 失败路径的 stop-before-null 同理。 + if (_mcodeAcpSingleton && !_mcodeAcpSingleton.alive) { + try { _mcodeAcpSingleton.stop(); } catch {} + _mcodeAcpSingleton = null; + } _mcodeAcpInitPromise = (async () => { const client = new McodeAcpClient({ debug: false }); try { diff --git a/packages/webui/server/routes/model.js b/packages/webui/server/routes/model.js index 9dd88f75..1a096849 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, @@ -20,6 +23,78 @@ function configOption(cs, id) { import { webuiModeToLabel } from "../lib/interaction/permission-presets.js"; import { readJson } from "../lib/read-json.js"; +/** + * Optional model-catalogue overlay, re-read on every request so editing the + * file takes effect without a restart. + * + * Path: `MCODE_WEBUI_MODELS_CONFIG`, else `models.json` under the server's cwd. + * Shape: `{ providers: [{ id, label, models: [{ id, label, contextLimit? }] }] }`. + * + * This is an *overlay*, not a replacement. The engine's session config option + * stays the authority on which models exist and which one is current — an + * overlay entry that the engine does not list is not offered, and an engine + * entry the overlay does not mention is still offered. The overlay only + * supplies display metadata (label, contextLimit) and lets an operator name + * the provider groups. Reading a missing or malformed file is not an error; + * it just means no overlay. + */ +function readModelsOverlay() { + const path = + process.env.MCODE_WEBUI_MODELS_CONFIG || join(process.cwd(), "models.json"); + try { + const parsed = JSON.parse(readFileSync(path, "utf8")); + if (!parsed || !Array.isArray(parsed.providers)) return null; + return parsed; + } catch { + return null; + } +} + +/** + * Provider for an engine model id. + * + * The engine encodes a selection as `m:::v:`, so the + * provider is the second colon-delimited field. Ids that are not in that + * encoding fall back to the `provider/` prefix form, and anything else reports + * an empty provider (the caller renders those as one unnamed group). + */ +function providerOf(modelId) { + if (typeof modelId !== "string") return ""; + const encoded = /^m:([^:]+):/.exec(modelId); + if (encoded) return encoded[1]; + const slash = /^([^/]+)\//.exec(modelId); + return slash ? slash[1] : ""; +} + +/** Model name within an engine id: `m:::v:` → ``. */ +function bareNameOf(modelId) { + if (typeof modelId !== "string") return ""; + const encoded = /^m:[^:]+:([^:]+):/.exec(modelId); + if (encoded) return encoded[1]; + const slash = /^[^/]+\/(.+)$/.exec(modelId); + return slash ? slash[1] : modelId; +} + +/** + * Index the overlay by provider, then by the model ids it can supply metadata + * for. A model may be written bare (`MiniMax-M3`) or fully qualified + * (`minimax_api/MiniMax-M3`); both resolve to the same entry. + */ +function indexOverlay(overlay) { + const byProvider = new Map(); + for (const p of overlay.providers) { + if (!p || typeof p.id !== "string" || !p.id) continue; + const byModel = new Map(); + for (const m of Array.isArray(p.models) ? p.models : []) { + if (!m || typeof m.id !== "string" || !m.id) continue; + byModel.set(m.id, m); + const bare = m.id.includes("/") ? m.id.slice(m.id.indexOf("/") + 1) : m.id; + byModel.set(bare, m); + } + byProvider.set(p.id, { label: typeof p.label === "string" && p.label ? p.label : p.id, byModel }); + } + return byProvider; +} // GET /api/models // The catalogue is the engine's `model` config option (the same list the TUI's @@ -34,21 +109,51 @@ import { readJson } from "../lib/read-json.js"; // 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. +// +// `groups` partitions that same catalogue by provider so a client can render +// one section per provider instead of one flat list. It is derived from the +// engine ids, so it is always consistent with `models`; the optional +// `models.json` overlay may rename a group and enrich an entry. 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 overlay = readModelsOverlay(); + const overlayIndex = overlay ? indexOverlay(overlay) : null; + + const groups = new Map(); + const models = (option && Array.isArray(option.options) ? option.options : []).map((o) => { + const provider = providerOf(o.value); + const providerEntry = overlayIndex?.get(provider); + const overlayModel = providerEntry?.byModel.get(bareNameOf(o.value)) + ?? providerEntry?.byModel.get(o.value); + const entry = { + id: o.value, + name: o.name, + // `label` is the overlay's display name when it has one, else the + // engine's. Both spellings ship so older clients reading `name` keep + // working and newer ones can prefer `label`. + label: typeof overlayModel?.label === "string" && overlayModel.label ? overlayModel.label : o.name, + provider, + }; + if (typeof overlayModel?.contextLimit === "number" && overlayModel.contextLimit > 0) { + entry.contextLimit = overlayModel.contextLimit; + } + if (!groups.has(provider)) { + groups.set(provider, { id: provider, label: providerEntry?.label ?? provider, models: [] }); + } + groups.get(provider).models.push(entry); + return entry; + }); + const current = (option && option.currentValue) || null; res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" }); return res.end( JSON.stringify({ ok: true, models, + groups: [...groups.values()], current, - source: "acp-session-config", + source: overlayIndex ? "acp-session-config+overlay" : "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" } : {}), diff --git a/packages/webui/test/lib/acp-process-group.test.js b/packages/webui/test/lib/acp-process-group.test.js new file mode 100644 index 00000000..0360e509 --- /dev/null +++ b/packages/webui/test/lib/acp-process-group.test.js @@ -0,0 +1,129 @@ +// webui/test/lib/acp-process-group.test.js +// Regression test: stopping the mcode acp child must take down the whole +// process group, not just the direct child. +// +// The bug: `stop()` called `child.kill()`, which signals only the direct child. +// The engine's acp entry spawns its own plugin chain (codex-mcp-proxy → +// codex mcp-server → …); none of those receive the signal, so every restart +// orphans a full plugin tree. This test reproduces exactly that shape — a +// direct child with one grandchild — and asserts both die. +// +// It fails on the old `child.kill()`-only implementation (the grandchild +// survives) and passes once the child is spawned `detached` and `stop()` kills +// the group, escalating to SIGKILL if the group ignores SIGTERM. +// +// POSIX-only: Windows has no process groups to signal, and `stop()` there keeps +// the direct-child kill. + +import { test, describe, before, after } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, writeFileSync, readFileSync, existsSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const posix = process.platform !== "win32"; + +/** A stand-in for the engine's acp entry: spawns one long-lived grandchild. */ +const FAKE_ACP = ` +// Spawns a grandchild that never exits on its own — the leak this test +// guards against. Both pids are published so the test can check on them. +const { spawn } = require("node:child_process"); +const { writeFileSync } = require("node:fs"); + +const grandchild = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000);"], { + stdio: "ignore", +}); +writeFileSync(process.env.PID_FILE, JSON.stringify({ child: process.pid, grandchild: grandchild.pid })); + +// Answer the initialize handshake so McodeAcpClient.start() can resolve. +let buf = ""; +process.stdin.setEncoding("utf8"); +process.stdin.on("data", (chunk) => { + buf += chunk; + let i; + while ((i = buf.indexOf("\\n")) >= 0) { + const line = buf.slice(0, i).trim(); + buf = buf.slice(i + 1); + if (!line) continue; + let msg; + try { msg = JSON.parse(line); } catch { continue; } + if (msg && msg.id !== undefined) { + process.stdout.write(JSON.stringify({ jsonrpc: "2.0", id: msg.id, result: {} }) + "\\n"); + } + } +}); +process.stdin.resume(); +`; + +function isAlive(pid) { + try { + process.kill(pid, 0); + return true; + } catch (e) { + // EPERM means the process exists but is not ours to signal. + return e.code === "EPERM"; + } +} + +async function waitFor(predicate, { timeoutMs = 5000, stepMs = 50 } = {}) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (await predicate()) return true; + await new Promise((r) => setTimeout(r, stepMs)); + } + return await predicate(); +} + +describe("McodeAcpClient.stop() — process group teardown", { skip: !posix && "POSIX-only" }, () => { + let dir; + let entry; + let McodeAcpClient; + + before(async () => { + dir = mkdtempSync(join(tmpdir(), "webui-acp-pg-")); + entry = join(dir, "fake-acp.js"); + writeFileSync(entry, FAKE_ACP); + const mod = await import("../../acp.mjs"); + McodeAcpClient = mod.McodeAcpClient; + }); + + after(() => { + if (dir) rmSync(dir, { recursive: true, force: true }); + delete process.env.MCODE_CMD; + delete process.env.PID_FILE; + }); + + test("a grandchild of the acp child does not outlive stop()", async () => { + const pidFile = join(dir, `pids-${Date.now()}.json`); + process.env.MCODE_CMD = entry; + process.env.PID_FILE = pidFile; + + const client = new McodeAcpClient(); + await client.start(); + assert.equal(client.alive, true); + + // The fake publishes its pids synchronously on spawn, but read with a + // short poll so a slow filesystem cannot flake the test. + const gotFile = await waitFor(() => existsSync(pidFile)); + assert.ok(gotFile, "fake acp never published its pids"); + const { child, grandchild } = JSON.parse(readFileSync(pidFile, "utf8")); + + // Both are running right now — the precondition for the assertion below. + assert.equal(isAlive(child), true, "direct child should be running"); + assert.equal(isAlive(grandchild), true, "grandchild should be running"); + + client.stop(); + + // The direct child dies on the SIGTERM; the grandchild only dies if the + // signal reached the whole group. Poll rather than sleep a fixed amount so + // this tracks real process teardown, not scheduling luck. + const gone = await waitFor(() => !isAlive(grandchild) && !isAlive(child), { + timeoutMs: 8000, + }); + assert.equal( + gone, + true, + `process group survived stop(): child(alive=${isAlive(child)}) grandchild(alive=${isAlive(grandchild)})`, + ); + }); +}); diff --git a/packages/webui/test/routes/model.check.mjs b/packages/webui/test/routes/model.check.mjs index ca0ee414..62819b2d 100644 --- a/packages/webui/test/routes/model.check.mjs +++ b/packages/webui/test/routes/model.check.mjs @@ -14,6 +14,9 @@ import { test, describe, before } from "node:test"; import assert from "node:assert/strict"; import { Readable } from "node:stream"; +import { mkdtempSync, writeFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { setupMocks, absPath } from "../helpers/_setup.js"; let modelRoute; @@ -102,6 +105,133 @@ describe("handleGetModels — /api/models", () => { // and it must not write that value back into the state a prompt would use assert.equal(cs.model.name, "minimax_api/MiniMax-M3"); }); + + test("partitions the catalogue by provider, parsed out of the engine id", () => { + const ctx = { + cs: fakeCs(undefined, [ + { + ...MODEL_OPTION, + options: [ + { value: "m:minimax_api:MiniMax-M3:v:default", name: "MiniMax-M3" }, + { value: "m:anthropic_api:claude-opus:v:default", name: "claude-opus" }, + { value: "m:minimax_api:MiniMax-M2.7:v:default", name: "MiniMax-M2.7" }, + ], + }, + ]), + }; + const res = fakeRes(); + modelRoute.handleGetModels(null, res, ctx); + const body = JSON.parse(res._body); + assert.deepEqual( + body.groups.map((g) => g.id), + ["minimax_api", "anthropic_api"], + ); + // Partitioning necessarily reorders an interleaved catalogue, so the + // contract is: every entry appears exactly once, and each group keeps the + // catalogue's relative order within itself. + assert.deepEqual( + [...new Set(body.groups.flatMap((g) => g.models.map((m) => m.id)))].sort(), + [...new Set(body.models.map((m) => m.id))].sort(), + ); + assert.equal( + body.groups.flatMap((g) => g.models).length, + body.models.length, + "grouping must not drop or duplicate entries", + ); + assert.deepEqual( + body.groups[0].models.map((m) => m.id), + ["m:minimax_api:MiniMax-M3:v:default", "m:minimax_api:MiniMax-M2.7:v:default"], + "a group preserves the catalogue order of its own entries", + ); + assert.equal(body.models[0].provider, "minimax_api"); + // No overlay on disk → the group label falls back to the provider id. + assert.equal(body.groups[0].label, "minimax_api"); + }); + + test("an id outside the m:: encoding still groups, by its provider/ prefix", () => { + const ctx = { + cs: fakeCs(undefined, [ + { + ...MODEL_OPTION, + options: [ + { value: "minimax_api/MiniMax-M3", name: "MiniMax-M3" }, + { value: "openai/gpt-5", name: "gpt-5" }, + ], + }, + ]), + }; + const res = fakeRes(); + modelRoute.handleGetModels(null, res, ctx); + const body = JSON.parse(res._body); + assert.deepEqual( + body.groups.map((g) => g.id), + ["minimax_api", "openai"], + ); + }); + + test("models.json overlay renames a group and enriches an entry, without inventing models", () => { + const dir = mkdtempSync(join(tmpdir(), "webui-models-")); + const cfg = join(dir, "models.json"); + writeFileSync( + cfg, + JSON.stringify({ + providers: [ + { + id: "minimax_api", + label: "MiniMax", + models: [{ id: "MiniMax-M3", label: "M3 (国内)", contextLimit: 1000000 }], + }, + ], + }), + ); + process.env.MCODE_WEBUI_MODELS_CONFIG = cfg; + try { + // The engine's own encoding, as routes/model.js documents it. The + // overlay matches on the model segment, so the `m:`/`:v:` framing is + // stripped before lookup. + const res = fakeRes(); + modelRoute.handleGetModels(null, res, { + cs: fakeCs(undefined, [ + { + ...MODEL_OPTION, + options: [ + { value: "m:minimax_api:MiniMax-M3:v:default", name: "MiniMax-M3" }, + { value: "m:minimax_api:MiniMax-M2.7:v:default", name: "MiniMax-M2.7" }, + ], + }, + ]), + }); + const body = JSON.parse(res._body); + assert.equal(body.source, "acp-session-config+overlay"); + const byId = Object.fromEntries(body.groups.flatMap((g) => g.models).map((m) => [m.id, m])); + const m3 = byId["m:minimax_api:MiniMax-M3:v:default"]; + assert.equal(m3.label, "M3 (国内)"); + assert.equal(m3.contextLimit, 1000000); + // `name` still carries the engine's own label so older clients are unaffected. + assert.equal(m3.name, "MiniMax-M3"); + // An entry the overlay does not mention is still offered, unrenamed. + assert.equal(byId["m:minimax_api:MiniMax-M2.7:v:default"].label, "MiniMax-M2.7"); + assert.equal(body.groups[0].label, "MiniMax"); + // An overlay entry the engine does not list must not be offered. + assert.equal(body.models.length, 2); + } finally { + delete process.env.MCODE_WEBUI_MODELS_CONFIG; + rmSync(dir, { recursive: true, force: true }); + } + }); + + test("a missing or malformed models.json is not an error", () => { + process.env.MCODE_WEBUI_MODELS_CONFIG = join(tmpdir(), "webui-does-not-exist.json"); + try { + const res = fakeRes(); + modelRoute.handleGetModels(null, res, { cs: fakeCs(undefined, [MODEL_OPTION]) }); + const body = JSON.parse(res._body); + assert.equal(body.source, "acp-session-config"); + assert.equal(body.models.length, 2); + } finally { + delete process.env.MCODE_WEBUI_MODELS_CONFIG; + } + }); }); describe("handleSetModel — /api/set-model", () => { diff --git a/packages/webui/webapp/components/composer.tsx b/packages/webui/webapp/components/composer.tsx index 9cfe33b7..bd56e31e 100644 --- a/packages/webui/webapp/components/composer.tsx +++ b/packages/webui/webapp/components/composer.tsx @@ -88,6 +88,9 @@ export function Composer({ t, inline = false }: { t: (key: MessageKey) => string const [error, setError] = useState(null); const [sending, setSending] = useState(false); const [models, setModels] = useState<{ id: string; label: string }[]>([]); + const [modelGroups, setModelGroups] = useState< + { id: string; label: string; models: { id: string; label: string }[] }[] + >([]); const [slashIndex, setSlashIndex] = useState(0); const editorRef = useRef(null); const fileRef = useRef(null); @@ -129,9 +132,16 @@ export function Composer({ t, inline = false }: { t: (key: MessageKey) => string useEffect(() => { void api .listModels() - .then((payload) => - setModels((payload.models ?? []).map((m) => ({ id: m.id, label: m.name || m.id }))), - ) + .then((payload) => { + setModels((payload.models ?? []).map((m) => ({ id: m.id, label: m.label || m.name || m.id }))); + setModelGroups( + (payload.groups ?? []).map((g) => ({ + id: g.id, + label: g.label || g.id, + models: (g.models ?? []).map((m) => ({ id: m.id, label: m.label || m.name || m.id })), + })), + ); + }) .catch(() => {}); }, [modelKey, sessionKey]); @@ -446,6 +456,7 @@ export function Composer({ t, inline = false }: { t: (key: MessageKey) => string void api.setModel(id)} @@ -730,18 +741,55 @@ function PermissionSelect({ function ModelSelect({ t, models, + groups, value, label, onPick, }: { t: (key: MessageKey) => string; models: { id: string; label: string }[]; + groups?: { id: string; label: string; models: { id: string; label: string }[] }[]; value?: string; label: string; onPick: (id: string) => void; }) { const [open, setOpen] = useState(false); + const pick = useCallback((id: string) => { + setOpen(false); + onPick(id); + }, [onPick]); + + // Grouping is a progressive enhancement: the server partitions the catalogue + // by provider, but a single-provider catalogue (or an older server that sends + // no `groups` at all) renders exactly as the flat list did, so adding the + // partition costs no existing user anything. + const partitioned = (groups?.length ?? 0) > 1; + const rows = partitioned + ? (groups ?? []).map((group) => ( +
+
{group.label}
+ {group.models.map((model) => ( + pick(model.id)} + /> + ))} +
+ )) + : models.map((model) => ( + pick(model.id)} + /> + )); + return ( ) : ( - models.map((model) => ( - { - setOpen(false); - onPick(model.id); - }} - /> - )) + rows )} )} diff --git a/packages/webui/webapp/lib/api.ts b/packages/webui/webapp/lib/api.ts index 6ea7def2..9718d097 100644 --- a/packages/webui/webapp/lib/api.ts +++ b/packages/webui/webapp/lib/api.ts @@ -235,10 +235,29 @@ export const getSessionTree = (refresh = false) => // --- model and permissions -------------------------------------------------- +export interface ModelEntry { + id: string; + name?: string; + /** Display name; the server's `models.json` overlay may rename an entry. */ + label?: string; + /** Provider segment parsed out of the engine's encoded model id. */ + provider?: string; + contextLimit?: number; +} + +export interface ModelGroup { + id: string; + label: string; + models: ModelEntry[]; +} + export interface ModelsPayload { ok: boolean; current: string; - models: { id: string; name?: string }[]; + models: ModelEntry[]; + /** The same catalogue partitioned by provider. Absent on older servers. */ + groups?: ModelGroup[]; + source?: string; } export const listModels = () => request("/api/models"); diff --git a/packages/webui/webapp/styles/mavis-dropdown.css b/packages/webui/webapp/styles/mavis-dropdown.css index 1ee1c912..5090e7e0 100644 --- a/packages/webui/webapp/styles/mavis-dropdown.css +++ b/packages/webui/webapp/styles/mavis-dropdown.css @@ -189,3 +189,21 @@ .mavis-dropdown.mavis-dropdown-compact .ant-dropdown-menu .ant-dropdown-menu-item { margin-bottom:0 } + +/* Provider sections in the model popover. The server partitions the catalogue + by provider; each partition gets a quiet heading above its rows. The heading + is a presentational divider only — the rows keep carrying the menu semantics, + so no extra role/aria is introduced here. */ +.mavis-dropdown-group+.mavis-dropdown-group { + margin-top:var(--spacing_4) +} + +.mavis-dropdown-group-label { + padding:var(--spacing_4) var(--spacing_8) var(--spacing_4); + color:var(--gray_500); + font-size:11px; + line-height:16px; + font-weight:500; + letter-spacing:.02em; + text-transform:uppercase +} diff --git a/release/public-source.json b/release/public-source.json index 6c1838e3..c6682e00 100644 --- a/release/public-source.json +++ b/release/public-source.json @@ -3472,6 +3472,7 @@ "packages/webui/test/integration/sse-channel.test.js", "packages/webui/test/integration/upload-limits.test.js", "packages/webui/test/lib/acp-cache.check.mjs", + "packages/webui/test/lib/acp-process-group.test.js", "packages/webui/test/lib/alerts.check.mjs", "packages/webui/test/lib/auth.test.js", "packages/webui/test/lib/authorize.check.mjs",