diff --git a/docs/webui.md b/docs/webui.md index efc69d7a..3d62c7c2 100644 --- a/docs/webui.md +++ b/docs/webui.md @@ -1202,11 +1202,11 @@ missing feature. Only Worktree is genuinely not implemented. | --- | --- | --- | | Preferences | General (通用) | implemented | | Preferences | Voice | implemented, placeholder controls — the microphone dropdown is disabled with a single 「本地版不适用」 option, and both dictation rows show 未设置 (no device enumeration, no dictation input in a browser) | -| Preferences | Shortcuts | implemented, read-only — 10 desktop default bindings under a 「浏览器环境不适用」 notice; the ✕ / ↺ affordances render disabled | +| Preferences | Shortcuts | implemented — 10 desktop rows, each stating what the browser can do with it: 3 rebindable and live, 1 live on macOS only, 6 blocked with the specific reason (see **Shortcuts — what the browser can intercept**) | | Preferences | Personalization | implemented — 自定义指令 and 关于你 persist to `localStorage`; both memory switches render off and disabled with the not-applicable marker, and 管理 opens the 记忆摘要 dialog in its permanent empty state | -| Management | Usage & models | implemented; the three sources are a **view switcher** — they do not switch the model source in use | +| Management | Usage & models | implemented; since SB-1 the two engine sources are real (Token Plan / MiniMax API switch the engine's credential, the 「使用中」 badge reads the engine back, and the MiniMax API key can be saved and probed) — the third pill, Custom models, stays a VIEW onto the provider catalogue | | Management | Connection | implemented | -| Management | Account | implemented as a placeholder — 账户信息 reads 「本地模式,未登录」, 退出登录 is disabled (no account service locally) | +| Management | Account | implemented as a read — the section reads `GET /api/account` on mount and renders the account name, the current plan name, the quota overview (plan-quota state plus the 5-hour and weekly remaining figures) and the account status; sign-out stays disabled (no engine method acts on it) | | Coding | Code review | implemented — 自定义审查准则 persists to `localStorage`; 审查方式 is a disabled single-option dropdown showing 子会话 | | Coding | Worktree | **not implemented** — the tab is a one-line panel reading 「本地版暂不支持工作树管理」 | | Archived | Archived tasks | the tab renders its empty state 「暂无已归档任务」; the list and its actions need an archived-session contract that does not exist | @@ -1217,6 +1217,41 @@ workspace tabs, not a settings section; the `settings.tab.browser` dictionary key has no call site. An earlier revision of this document listed a Browser tab under Preferences. +**Shortcuts — what the browser can intercept** + +A page cannot register a global shortcut, and it cannot intercept a +combination the browser has already claimed. The Shortcuts tab therefore +does not present the desktop's bindings as a dead reference list: every +row states its own verdict, and the same registry decides both the label +and the dispatch. `webapp/lib/shortcuts.ts` holds that verdict; `app/page.tsx` +matches keydowns against it and `components/settings-extra-pages.tsx` +renders it, so a row cannot be shown as live while nothing dispatches it +(or the reverse). + +| Row | Combination | Verdict | Why | +| --- | --- | --- | --- | +| 显示或隐藏 Mini Chat | `Alt+M` | blocked | the WebUI has no Mini Chat surface | +| 全局搜索 | `Ctrl+K` | live | unclaimed in the browsers this client targets; opens the tree-column search surface | +| 搜索任务和会话 | `Ctrl+G` | blocked | the browser's find-next | +| 新建任务 | `Ctrl+N` | macOS only | a new window in Chromium and Firefox on Windows and Linux — the keydown never reaches the page there | +| 新建无项目任务 | `Ctrl+Alt+O` | live | unclaimed | +| 打开项目文件夹 | `Ctrl+O` | blocked | the browser's Open File dialog | +| 打开设置 | `Ctrl+,` | live | unclaimed | +| 按住听写 / 切换听写 | — | blocked | no speech recognition behind the rows | +| 反转跟进行为 | `Ctrl+Enter` | blocked | the key is free, but the action's semantics are undecided; binding it would promise behaviour that does not exist | + +The three **live** rows are rebindable: the box takes the next combination +the user presses, persists it under `webui-shortcut-bindings`, and the +handler picks it up on the next keydown. A combination another dispatched +row already owns is refused and the conflicting action is named — two rows +sharing one combination would be an order-dependent bug in the handler. +`Ctrl+N` is deliberately not rebindable: moving it would not make it fire +on the platforms where the browser owns it, so the row prints the limit +instead of pretending a rebind fixes it. Blocked rows keep the desktop's +printed combination for reference, render disabled, and print the reason +from the table above. `Ctrl+Shift+T`-style interception is not attempted +and cannot be: those keys never arrive. + **General (通用) — sections** The General page follows the reference's sectioned layout (ticket 48): an @@ -1230,9 +1265,9 @@ between adjacent rows: | Application | enabled | appearance picker + language switch. The reference's five desktop switches (menu-bar icon, launch-at-login, desktop notifications, early access, accelerated indexing) render **disabled**, one per row — no capability behind them | | Link destinations | disabled furniture | two rows (web links, local links) whose selects are disabled single-option dropdowns | | Files | enabled | two switches persisted in `localStorage`, see the table below | -| Session management | enabled | one switch, persisted; **records the preference only** — no surface reads it yet | +| Session management | enabled | one switch, persisted; gates the composer's context-window readout (see below) | | Agent control | disabled furniture | the 「自动打开浏览器面板」 switch renders off and disabled (no capability behind it) | -| Preference settings | enabled | follow-up behaviour radio (queue / send now), persisted; **records the preference only** — the composer does not read it yet (ticket 49 owns that surface). Watermark and data opt-in render disabled | +| Preference settings | enabled | follow-up behaviour (disabled / queue / send now); since SB-4 the composer reads it, and a send into a running turn reaches the engine's queue or steers the running turn. Watermark and data opt-in render disabled | | About | mixed | upload logs and check-for-update are disabled buttons; the local URL and LAN URL are live read-only rows from `/api/settings` | | dataDir footer | not implemented | the reference prints the app data directory at the bottom of the General page; `/api/settings` has no such field and the server routes are read-only this round, so no value exists to print | @@ -1250,8 +1285,30 @@ clients: | --- | --- | --- | | `file_open_in_new_tab` | `"true"` here (`"false"` in the reference) | **Yes.** On (default) keeps this client's standing one-tab-per-file behaviour; off replaces the **active file tab** with the newly opened file. The strip has no pinned-tab concept, so "active file tab" is the reuse target — a documented approximation of the reference's "reuse the unpinned tab" | | `file_line_wrap` | `"true"` | **Yes.** On wraps over-wide lines; off scrolls horizontally. Covers both code-file previews (ticket 48) and markdown codeblocks — chat messages, activity groups and markdown file previews (ticket 52); the language label never wraps. Applies to previews opened / messages mounted after the switch (an already-open one does not reflow); a wrapped file-preview line's gutter number aligns with its first visual row — a known trade-off | -| `webui-context-window-usage` | `"false"` | No. Recorded preference only | -| `webui-follow-up-behavior` | `"queue"` (or `"steer"`) | No. Recorded preference only | +| `webui-context-window-usage` | `"false"` | **Yes.** On draws the context-window readout in the composer's toolbar, immediately left of the model chip; off renders nothing there. The readout's own form is unchanged — the ring, the percentage, the breakdown and the plan rows all come from the session snapshot as before. Flipping the switch takes effect without a reload | +| `webui-follow-up-behavior` | `"queue"` (or `"off"`, `"steer"`) | No. Decides what a send does while a turn runs; `"off"` is webui's own third position (the reference has two) | + +**The context-window readout** + +`components/context-meter.tsx` is the only consumer of +`webui-context-window-usage`. It reads the key once at mount and then +follows `subscribeContextWindowUsage` in `webapp/lib/settings-local.ts`, +so the switch takes effect in the already-open page — the settings modal +and the composer are on screen at the same time, and a reload would be +the only other way to hear about it. The channel's shape is +`subscribe*(listener) → unsubscribe`, the same one +`webapp/lib/theme.ts#subscribeSystemTheme` uses for the appearance picker; +a listener that throws is isolated so it cannot cost the other subscribers +their update. + +The default stays `"false"`, the desktop reference's own default, so a +profile that has never touched the switch renders no readout. That is a +change from the webui's previous behaviour, where the meter drew +unconditionally and the switch did nothing: the reference hides it by +default, and the switch is what decides. The stored format is still the +bare `"true"` / `"false"` string — the key did not move onto the +`webui:ui:v1` envelope, which would have broken the reference-shared +contract. **Search and layout details (ticket 48)** @@ -1272,7 +1329,7 @@ clients: | Capability | Why | | --- | --- | -| Account page | the tab renders in the reference's form, but there is no `getAccountStatus` / `signOut`-class server contract, so the account row reads 「本地模式,未登录」 and sign-out is disabled | +| Account page | sign-out only — the readings are real (see **Account section (账户)** below); the engine exposes no sign-in/sign-out method, so the button renders in the reference's form, disabled, with that reason in its tooltip | | Archived tasks page | the tab renders its empty state; the list, its restore and its delete need the archived-session contract | | Usage & models three-source switching | the segmented tabs now match the desktop form (ticket 53), but they are a **view switcher** — they do not switch the model source in use; real Token Plan / MiniMax API / custom-model routing plus source badges still need a model-routing contract | | MiniMax API key panel | input + connectivity test + save-and-use | @@ -1295,17 +1352,54 @@ The Token Plan view is the desktop's five blocks (the tabs plus four cards): | Block | Data policy | | --- | --- | -| Plan card (ⓘ + two rows + 管理⌄) | No cloud-account source locally: the plan name and the credits figure render 「本地版不适用」, and the expiry line is omitted rather than given a fabricated date; 升级 (black primary) / 管理 ⌄ / 去充值 render in the desktop's form but disabled | +| Plan card (ⓘ + two rows + 管理⌄) | The plan NAME is real: `tokenPlan.tier` from `GET /api/account`, read when the view mounts, rendered verbatim; when the engine reports no plan, or the account surface is unreachable, the row renders 「未订阅套餐」 — never a default tier. The remaining figures have no credential path here: the credits figure renders 「云端账户域,本网页端无账户凭据」 and the expiry line is omitted rather than given a fabricated date; 升级 (black primary) / 管理 ⌄ / 去充值 render in the desktop's form but disabled, because all three act on the cloud account | | Usage card (three stacked progress bars) | The 5-hour and weekly windows are the one live source (engine over ACP, `POST /api/usage`; polled every 2 minutes, manual refresh records a forecast sample): with data they print the desktop forms "X% / 100%" / "X%" plus a relative reset caption ("resets in 43 min"); with no reading a bar shows the unavailable line, never 0%; the video window has no local source and permanently shows 「本地版不适用」 | -| Credits row (ⓘ + blue switch) | No credits system locally: the switch renders the desktop's blue on-form but greyed (checked + disabled), the hint is the reference's, and the row carries the not-applicable marker | +| Credits row (ⓘ + blue switch) | Credits are a cloud-account figure: the switch renders the desktop's blue on-form but greyed (checked + disabled), the hint is the reference's, and the row names the cloud account domain as the reason | | Invoice row | The one fully live affordance: 申请 ↗ opens the MiniMax open platform in a new tab | +The plan card is where ticket 53's A1 ruling was revised. A1 read 「无源即占位」 — a figure with no local source renders the placeholder — and applied that to the whole card, but the local server does have sources here (`POST /api/usage` for the quota windows, `GET /api/account` for the plan tier), so the ruling overstated the gap. The revision splits the card by source rather than by card: what the server can read is rendered, and what belongs to the cloud account domain renders the honest line that names that domain as the reason. Two alternatives were rejected — keeping the whole card on the placeholder (a plan name the engine has already reported is not a gap), and wiring credits / expiry / invoicing as well (this session holds no account credentials for the cloud account, so a real-looking figure there is exactly the fabrication A1 exists to prevent). + The custom-models view is the existing provider panel (API keys, protocols, model lists, connection tests, preset one-click enable); ticket 54 rebuilt the **add** flow into the desktop's dialog form (next section) and this batch folded **editing** into that same dialog (see "One dialog also edits" below) — the panel is now a list plus a delete affordance, with no second editor. +**Account section (账户)** + +One read of `GET /api/account` on mount answers the whole section. The +endpoint is not new and not duplicated: it is the same projection the user +menu's account card already reads, fetched on demand because the state +snapshot is broadcast to every SSE subscriber. + +| Row | Field | Missing-value sentence | +| --- | --- | --- | +| 账户名 | `identity.name`, trimmed | the engine answered and reported no name | +| 当前套餐 | `tokenPlan.tier`, through the Token Plan card's own `planNameOf` | 「未订阅套餐」 when the engine reported no plan; 「正在读取当前套餐…」 while a read is in flight; the unread sentence when the surface itself failed | +| 配额概况 | `tokenPlanQuotaState`, plus `quota.fiveHour` / `quota.weekly` `remainingPercent` | 「引擎未返回读数」 per window; 「不限量」 when the engine reports the window unmetered | +| 账户状态 | `status` | the unread sentence; a status token with no dictionary entry resolves to no sentence rather than leaking a raw enum | + +The unread sentence is one per failure kind and each names the endpoint: +`ok: false` renders the engine's own `reason` when it sent one, a transport +failure renders the read-failed line, and none of them asserts a fact about +the user. The two failures that are NOT the same thing — an unreachable +account surface and an answer with no account name — therefore get different +sentences, which is what the previous hardcoded 「本地模式,未登录」 row +could not express. + +Division of labour with the Token Plan card is by shape, not by topic: that +card owns the limit BARS, the plan actions, credits, expiry and invoicing; +this section owns identity and the plain-text readings. The only shared +value is the plan name, and it goes through one resolver (`planNameOf`) so +the two surfaces cannot drift. A window reported as unmetered prints +「不限量」 rather than 「剩余 0%」 — a plan with no cap must not read as an +exhausted one. + +Rejected alternatives: a second account endpoint (the projection already +exists and a second route would be a second contract to keep in sync), and +showing the quota as bars here as well (the same gauge twice, from two +different sources, on two pages). + **Add-model dialog (ticket 54, 53b)** The 「+ 添加模型」 button — centered under the empty state, at the bottom of @@ -1555,6 +1649,163 @@ since given content. The dead `if (!section)` branch inside `SettingsPanel` was removed and the `section` prop made required — every reachable tab resolves a section, so the branch could never render. +### Usage & models: the source switcher is real (SB-1) + +**What the user sees.** Opening the 用量与模型 tab reads the engine +once and settles three things that used to be local guesses: which +credential the engine is actually using, whether a MiniMax API key is +stored, and what the last connectivity probe found. The pill is still +the *view*; the 「使用中」 badge beside it is the engine's answer, and it +moves only when a write has been confirmed. Picking Token Plan or MiniMax +API in the dropdown switches the view AND writes the engine +(`PUT /api/model-source`); a refusal — the engine's `NO_API_KEY` when no +BYOK key is stored — leaves the view where the user put it, so the key +field they need is the panel that stays on screen, while the badge keeps +showing what is really in use. + +**Why the badge and the view are separate values.** They were one value +before, which is what made the old build's claim false: a `useState` +switcher could render a source as selected while the engine kept using +the other one. A badge that claims 使用中 for a source the engine never +accepted is the fake-success shape this codebase keeps refusing, so the +badge is fed exclusively by a read-back. + +**The key row.** A stored key shows as the engine's mask, never as +plaintext, and typing a new value replaces it on save. 保存并使用 is one +request, not two: the engine writes the key and switches the source in a +single transaction, so the tab never shows a saved key beside a source +that was not switched. An empty submission is the **keep** sentinel +(`changed: false`, no engine write) — the same convention +`PUT /api/providers` uses, and it exists because the read can only +return a mask while the engine rejects a mask submitted as a key. + +**What the probe does and does not test.** 检测 probes the STORED key on +the `minimax_api` provider and the response says so (`tested: +"stored_key"`). Two limits are the engine's contract, not this UI's: +`testUserModel` takes no key override, so an unsaved value cannot be +probed — the button is disabled while the field holds one, and a visible +line under the field says why, because the reason used to live only in a +`title` attribute that keyboard and touch users never see — and the +managed Token Plan credential is not a model-service key, so the Token +Plan source has nothing to probe here. A probe that ran and failed is a +completed probe, not an error: it renders the engine's status. + +**The key row is four states, not two.** The badge reads the engine's +masked projection, but it is describing a field the user can be editing +right now, so a typed-but-unsaved key is its own state (「已输入,未保存」) +that outranks both 「已保存密钥」 and 「未启用」 — the user is replacing the +stored key, or has plainly typed one, and neither badge is true. Symmetric +to that, the engine's `NO_API_KEY` refusal is a verdict on an EMPTY field: +typing one falsifies it, so the pinned 「请先填写 API Key」 is dropped on +the next keystroke. A refusal that is not about the missing key — a +transport failure, a rejected write — is still true afterwards and stays +on screen. + +**Cost.** One extra read per settings-tab open. The read boots the +engine runtime if none is up, which is the write-side contract and is +acceptable here because the user opened the tab; a future change that +moves this fetch to page level must use the non-booting host getter +instead (`server/engine/model-source.js` KNOWN DEBT 2). + +**What this did not do.** The add-model dialog's 「自动获取」 still +resolves against the built-in preset directory: v2 has no per-provider +catalogue query for an arbitrary key, so a live per-key fetch has no +engine method behind it. The Token Plan cards stay on decision A1 +(本地版不适用) — wiring them to `/api/usage` and `/api/account` is a +separate, undecided item, not a side effect of this one. + +### Usage & models: a source switch re-reads the account (P20) + +**The defect this fixes.** A UAT round trip on 2026-10-03 (板块 4) switched +Token Plan → MiniMax API → Token Plan with a key stored in between. The +Token Plan card came back reading 「未订阅套餐」 while `GET /api/account` +answered `tier: "Ultra"` throughout, and only F5 recovered it. The +endpoint was never wrong. + +**Root cause.** The plan name is read in `UsageModelsSection` +(`webapp/components/panels.tsx`), mounted only while the port's view is on +the token-plan tab, so a switch away and back remounts it. The read was +`useEffect(..., [])` — once per mount — and that mount raced the engine: +the section rendered in the same tick as `PUT /api/model-source`, and +`GET /api/account` answers an engine that is still rebinding with HTTP +**200** and `{ok: false, reason: "no_client"}`. The card read that as "no +plan", nothing re-read it, and the section's state outlived the failure. + +**The re-read.** The port owns an `accountRevision` counter and increments +it after every *successful* source write — the dropdown's `PUT +/api/model-source` and 保存并使用's `PUT /api/model-source/api-key`, which +switches the source inside the same engine transaction. The section's +`/api/account` effect lists that counter as a dependency, so a confirmed +write re-runs the read against an engine that has finished rebinding. A +refused write bumps nothing: nothing changed, and a re-read would only +spend a request to re-render the same answer. + +**A failed read may not un-know a name.** Revalidation alone is not enough, +because the re-read can also lose the race. `reconciledAccount` +(`webapp/components/usage-models-cards.tsx`, pure and unit-tested) keeps +the last `ok: true` answer standing: only an `ok: true` payload is new +information, so an unreachable account surface cannot knock a known plan +off the card. An `ok: true` answer that reports no plan *does* replace it — +the engine saying "no plan" is an answer, saying "unreachable" is not. + +**A read in flight is its own sentence.** With the name held, the only +remaining nameless state is "not read yet", and it renders 「正在读取当前 +套餐…」 rather than 「未订阅套餐」. An account surface that has not +answered has not said the user has no plan — that conflation was the +visible half of UAT4-1. + +**Cost.** One `GET /api/account` per confirmed source write, on a surface +the user has just acted on. The read is not debounced: a source switch is +a deliberate act, not a stream of them. + +### Follow-up messages: the switch is a behaviour (SB-4) + +**What the user sees.** 跟进消息行为 has three positions. While a task is +running, the composer keeps Stop where it was and — with 排队 or 立即发送 +— also offers the send arrow: 排队 hands the message to the engine's queue +so it runs after the current turn, 立即发送 steers the running turn. With +关闭 the composer is exactly what it was before: the send control is +replaced by Stop until the turn ends and the text waits in the box. +Flipping the switch takes effect in the open page; no reload, no new +send. + +**Why there is a third position.** The desktop reference's control has +two, because the desktop owns the running turn and neither of its options +can fail. webui's follow-up can be refused — the engine that owns the +running turn may be another process — so a two-valued switch would be a +behaviour change wearing a switch's clothes. 关闭 is webui's own option and +is documented as such. + +**The ownership gate, and why it is not a transport check.** Before +either action runs, the server asks the engine whether *this* process owns +the active turn (`cliService.getActiveTurn`). Under the `runtime` +transport the turn runs in the webui process, so the answer is yes and the +queue or the steering message is admitted. Under the default `acp` +transport the turn runs in an `mcode acp` subprocess, and queueing into +this host would wake its own dispatcher and start a SECOND turn for a +session that already has one — the failure `/api/send` spends four claims +and a 409 preventing. That case is refused with `turn_not_owned`, the +text comes back to the box, and the banner says why. The gate reads the +engine's own answer rather than `MCODE_WEBUI_TRANSPORT`, so it stays +correct when the chat path finishes its move to the in-process transport +and needs no edit to start working. + +**What the response reports.** The engine's own answer, never the request: +a queue answers with the item id and position the engine committed, a +steer with the turn id and delivery mode. The two refususes stay two +different sentences, because "no turn any more" and "the turn is in +another process" call for different next steps. + +**Cost.** One engine read per follow-up send, and a write that boots the +runtime if none is up — acceptable, because the user pressed send. + +**What this did not do.** The queue has no UI: a queued message is +committed with an id and a position that nothing displays, and cannot be +inspected, reordered or cancelled from the browser. That is PB-13's +scope, and until it lands a queued follow-up is invisible until the +running turn ends. A steered message reports admission, not whether the +running agent read the text before its next step. + ## Main-surface elements: user menu / project context menu / home capsules (ticket 55c) The user asked for every desktop main-surface screenshot to be copied @@ -1596,8 +1847,23 @@ it, with the A1 marker keeping the limits stated rather than implied. `titleCustom` overlays session titles. Pinned projects sort to the top and carry a persistent pin mark beside the title; the menu row toggles between 置顶项目 / 取消置顶. -- 在文件夹中显示 is a disabled placeholder: a browser cannot open the - OS file manager. +- 在文件夹中显示 is live (SB-6). It posts the project's path to + `POST /api/fs/reveal` — the endpoint has been implemented and + registered all along (`server/routes/fs.js#handleFsReveal`, + `server/app.js`); the menu row was a placeholder that claimed a + browser cannot reach the OS file manager, which is false for a + webui install (the server holds the workspace). The row is disabled + for exactly one reason: the project is bound to no local directory, + and the tooltip says so in those words rather than shrugging with + 「本地版不适用」. A reveal that succeeds is silent — the file-manager + window is the feedback, and a toast would race it. A failure reports + through the same banner as the menu's other writes, labelled with the + menu's own localized name, whether the server refused with a + structured `code` or the request threw. +- The SESSION-level 在文件夹中显示 stays a disabled placeholder. The + desktop reference disables it too, so there is no parity to chase + and no local limitation to blame — unlocking it would be a product + decision this build has not made. - 归档对话 is a disabled placeholder: the runtime db has an `archived` flag, but writing another process's database is out of scope, and until ticket 55b's archived-tasks page lands there is no un-archive @@ -2345,8 +2611,9 @@ Invariants worth keeping when touching either branch: | `webui:files-tree:` | `sessionStorage` | `webapp/components/panels.tsx` (slice 01) | slice 01 (file tree) | `{version:1, workspace, expanded[], filter, showHidden}` | | `file_open_in_new_tab` | `localStorage` | `webapp/lib/settings-local.ts` | ticket 48 (settings General page) | bare `"true"\|"false"` string; **deliberately outside the `webui:` namespace** — same key and format as the desktop reference so one browser profile shares the preference across both clients. Default `"true"` here (reference: `"false"`); read by `app/page.tsx#openFileTab` | | `file_line_wrap` | `localStorage` | `webapp/lib/settings-local.ts` | tickets 48 + 52 | bare `"true"\|"false"` string, reference-shared namespace; default `"true"`; read per mount by `components/code-view.tsx` (code-file previews) and `components/markdown-html.tsx` (markdown codeblocks: chat, activity groups, file previews) | -| `webui-context-window-usage` | `localStorage` | `webapp/lib/settings-local.ts` | ticket 48 | bare `"true"\|"false"` string, reference-shared namespace; default `"false"`; recorded preference, no reader yet | -| `webui-follow-up-behavior` | `localStorage` | `webapp/lib/settings-local.ts` | ticket 48 | bare `"queue"\|"steer"` string (anything else reads as `"queue"`), reference-shared namespace; recorded preference, no reader yet | +| `webui-context-window-usage` | `localStorage` | `webapp/lib/settings-local.ts` | ticket 48 | bare `"true"\|"false"` string, reference-shared namespace; default `"false"`; read at mount and followed live by `components/context-meter.tsx` through `subscribeContextWindowUsage` | +| `webui-follow-up-behavior` | `localStorage` | `webapp/lib/settings-local.ts` | ticket 48 / SB-4 | bare `"off"\|"queue"\|"steer"` string (anything else reads as `"queue"`), reference-shared namespace; read by `components/composer.tsx` and republished on every write | +| `webui-shortcut-bindings` | `localStorage` | `webapp/lib/shortcuts.ts` | ticket 55c (settings Shortcuts page) | `{"global-search":"Ctrl+Shift+P", …}` — rebindings of the **live** shortcut rows only, written when the user records a new combination and removed entirely when the last one is cleared. Re-validated against the registry on read: a stored id that is no longer dispatched, or a chord that no longer parses, is dropped rather than honoured, so a hand-edited entry cannot widen what the page dispatches. Read at every keydown by `app/page.tsx` (through `effectiveBindings`) and once per mount by the settings page | | `webui:project-custom:v1` | `localStorage` | `webapp/lib/project-custom.ts` | ticket 55c (project context menu) | `{version:1, titles:{:}, pinned:[]}`. **Deliberately not cid-namespaced**: a rename or a pin describes the project, not a browser session, so every tab of this browser shares it. Best-effort write, silent failure; a project's entries are cleared when its remove completed with every session deleted | Except for ticket 48's four reference-shared keys (`file_open_in_new_tab`, @@ -2728,7 +2995,7 @@ marker), not by tool name. | `POST` | `/api/fs/mkdir` | `routes/fs.js#handleFsMkdir` | `{path}`; parent in allowed roots; `403` on containment fail | | `POST` | `/api/fs/write` | `routes/fs.js#handleFsWrite` | `{path, content, expectedMtime?, expectedSize?, confirm?}` — the preview editor's save (slice 27). `200 {ok, path, size, mtime}` (fresh baseline); `400 {code:"missing-path"\|"missing-content"\|"invalid-content"\|"not-a-regular-file"}`; `403` containment / `403 {code:"credential", credentialReason}` (slice-16 shapes without `confirm:true`); `404 {code:"not-found"}` (vanished file — TOCTOU guard; a missing path normally fails the gate first, same as reads); `409 {code:"conflict", diskMtime, diskSize}` (stale baseline, nothing written); `413 {code:"too-large"}` (write cap = the read's 512 KiB). Bare `writeFileSync` on the gated path — no shell anywhere. `confirm:true` on a credential shape emits the `credential.override` audit line with `endpoint:"write"`. | | `POST` | `/api/fs/open-default` | `routes/fs.js#handleFsOpenDefault` | `{path}`; `400 {code:"missing-path"}` / `403 {code:"out-of-bounds"}` / `400 {code:"not-a-regular-file"}` / `503 {code:"no-opener"}` / `502 {code:"spawn-failed"}` | -| `POST` | `/api/fs/reveal` | `routes/fs.js#handleFsReveal` | `{path}`; same code → status map as `open-default` | +| `POST` | `/api/fs/reveal` | `routes/fs.js#handleFsReveal` | `{path}`; same code → status map as `open-default`. Consumers: the file-preview toolbar and the sidebar project menu's 在文件夹中显示 (SB-6) | | `GET` | `/api/fs/search` | `routes/fs.js#handleFsSearch` | `?root=&q=&depth=&maxNodes=&wallMs=&limit=&includeHidden=1`; `400 {code:"missing-root"\|"missing-q"\|"not-a-directory"\|"stat-failed"}`; success envelope: `{ok, root, q, matches:[{path,name,type,ancestors,credential?,credentialReason?}], scanned:{dirs,files,total}, skipped:{node_modules,n,.git,n,credential,n,huge,n,optional:{dist,build,…}}, truncated, truncatedReason: null\|"depth"\|"nodes"\|"wallClock"\|"matches", elapsedMs, budgets}`. Walker defaults: `maxDepth=8`, `maxNodes=5000`, `wallMs=1500`, `maxMatches=200`; absolute limits: `16/50_000/5_000/1_000` (`packages/webui/server/lib/fs-search.js`). `node_modules` and `.git` are non-overridable skips. | | `GET` | `/api/git/status` | `routes/git.js#handleGitStatus` | `?dir=`; `400 {error:"missing dir"}` | | `GET` | `/api/git/branches` | `routes/git.js#handleGitBranches` | `?dir=`, leading `* ` → `current` flag | @@ -2748,6 +3015,11 @@ marker), not by tool name. | `POST` | `/api/providers/test` | `routes/providers.js#handleTestProvider` | `{provider}`; structured codes → status | | `GET` | `/api/providers/presets` | `routes/providers.js#handleGetPresets` | gallery | | `POST` | `/api/providers/preset/:id/enable` | `routes/providers.js#handleEnablePreset` | one-click enable | +| `GET` | `/api/model-source` | `routes/model-source.js#handleGetModelSource` | `{ok, source, apiKey:{available,hasKey,masked,testState,lastTestedAtMs}}`; `501` when the host has no `getMiniMaxModelSource`, `503` when no runtime is booted, `502 {code:"UNKNOWN_MODEL_SOURCE"}` for a value outside the engine's own two. `available:false` is not `hasKey:false` | +| `PUT` | `/api/model-source` | `routes/model-source.js#handleSetModelSource` | `{source}`; `400 {code:"INVALID_MODEL_SOURCE"\|"BAD_FIELD_TYPE"}`, `400 {code:"NO_API_KEY"}` when the engine refuses the BYOK direction; the response carries what the engine PERSISTED | +| `PUT` | `/api/model-source/api-key` | `routes/model-source.js#handlePutModelSourceApiKey` | `{apiKey, saveAndUse?}`; an absent/empty/whitespace `apiKey` is the KEEP sentinel → `200 {changed:false}` with no engine write; `400 {code:"BAD_FIELD_TYPE"\|"INVALID_API_KEY"}`; `500 {code:"engine_error"}` never carries the thrown message | +| `POST` | `/api/model-source/test` | `routes/model-source.js#handleTestModelSource` | `{modelId?}`; always 200 for a COMPLETED probe (`{ok, success, providerId:"minimax_api", tested:"stored_key", status}`) including `success:false`; non-200 only when the probe is refused (`503`/`501`, or the engine's `400 NO_API_KEY`) | +| `POST` | `/api/follow-up` | `routes/follow-up.js#handleFollowUp` | `{behavior:"queue"\|"steer", content, attachments?, requestId?}` — the engine session id comes from the server's own conversation state, never the body; `400 {code:"invalid_follow_up_behavior"\|"follow_up_empty"\|"no_active_conversation"\|"BAD_FIELD_TYPE"}`; `409 {code:"no_active_turn"\|"turn_not_owned"}` when this process does not own the running turn, and nothing is queued; `501` when the host lacks the method, `503` when no runtime is booted; 200 `{ok, behavior, itemId, position, status}` (queue) or `{ok, behavior, turnId, mode}` (steer) — the engine's own answer | | `POST` | `/api/debug/inject` | `routes/debug.js#handleDebugInject` | `DEBUG_INJECT=1` gate | | `GET` | `/api/debug/state` | `routes/debug.js#handleDebugState` | same gate | | `POST` | `/api/protocol/set-mode` | `routes/protocol.js#handleSetMode` | mid-session mode change | @@ -2812,6 +3084,38 @@ the connection registry) is treated as "might have a listener" and keeps the old wait. The short-circuit can only ever deny — no path approves anything without a recorded decision. +**"Is anybody listening?" and "does this request have an owner?" are two +different questions, and the rule above answers only the first.** A +request without `?cid=` — a curl, a script, a caller that forgot +`withClientQuery` — is told "yes, somebody is listening" as soon as one +browser tab is open, because an empty cid is the *broadcast* target and a +connected tab really can see and answer the modal. Nobody asked for it, +so nobody answers it, and the destructive request sits there for the full +300000 ms. A gate that fires only while the connection registry is empty +therefore misses the common case, and the caller sees a hang rather than +a denial. + +The routes that serve an identified HTTP caller pass +`{requireRequester: true}` for that reason: `session.delete`, +`sessions.cleanup-orphans`, `session.export`, `session.search`. With no +owner the answer is the same fail-closed one, at once, audited as +`auth.unreachable` with `reason:"no_requester"` so an operator can tell it +apart from a closed tab. The rule is opt-in because the difference is +load-bearing in the other direction too — `startup.cleanup` asks with an +empty cid on purpose, and any tab may answer it. + +`DELETE /api/sessions/:id` applies it before anything else, ahead of the +plan, so an unattributable delete costs no store read and reaches no +engine at all. The same handler also stops asking a question it can +already answer: an id that is absent from the session store *and* is not +an `mvs_` sid has no wrapper to splice and no engine rows to remove, so +it returns the `404 {ok:false, error:"session not found"}` this branch has +always returned — the facade's own `not_mcode_sid` / `already_absent` +pair, stated at the HTTP layer instead of waited out — with no governance +round-trip. Every id that can delete something, a resolved record or an +orphan `mvs_` sid whose engine rows are about to go, still passes the +gate. + ## Blocking prompts: what each one can actually answer `components/modals.tsx` renders three blocking prompts. Two of them carry a diff --git a/docs/webui.zh-CN.md b/docs/webui.zh-CN.md index f701112b..cd8693e7 100644 --- a/docs/webui.zh-CN.md +++ b/docs/webui.zh-CN.md @@ -1001,17 +1001,45 @@ slice 22 增强: | --- | --- | --- | | 偏好 | 通用 | 已实装 | | 偏好 | 语音 | 已实装,控制件为诚实占位——麦克风下拉禁用且只有「本地版不适用」一个选项,两条听写快捷键显示「未设置」(浏览器里既没有设备枚举也没有听写输入) | -| 偏好 | 快捷键 | 已实装,只读——顶部「浏览器环境不适用」横幅下照抄桌面版 10 条默认键位,✕ / ↺ 操作件渲染但禁用 | +| 偏好 | 快捷键 | 已实装——10 行逐行说明浏览器到底能做什么:3 行可改键且真实生效,1 行仅 macOS 生效,6 行不可用并写明原因(见**快捷键:浏览器能截获什么**) | | 偏好 | 个性化 | 已实装——「自定义指令」与「关于你」真存 `localStorage`;两个记忆开关关闭且禁用、行内标注「本地版不适用」,「管理」按钮打开「记忆摘要」弹窗且恒为空态 | -| 管理 | 用量与模型 | 已实装;三来源是**视图切换器**——不切换实际使用的模型来源 | +| 管理 | 用量与模型 | 已实装;自 SB-1 起两个引擎来源是真切换(Token Plan / MiniMax API 真切引擎凭据,「使用中」徽标回读引擎真值,MiniMax API Key 可保存可检测)——第三个胶囊「自定义模型」仍是**视图**,落点是供应商目录 | | 管理 | 连接 | 已实装 | -| 管理 | 账户 | 诚实占位——「账户信息」显示「本地模式,未登录」,「退出登录」禁用(本地版未接入账户服务) | +| 管理 | 账户 | 以读取实现——该分区挂载时读一次 `GET /api/account`,渲染账户名、当前套餐名、配额概况(套餐配额状态 + 5 小时/周窗口剩余读数)与账户状态;「退出登录」维持禁用(引擎没有可调用的登录登出方法) | | 编码 | 代码审查 | 已实装——「自定义审查准则」真存 `localStorage`;「审查方式」是禁用单选下拉,显示「子会话」 | | 编码 | 工作树 | **未实装**——页签是一行文案「本地版暂不支持工作树管理」 | | 归档 | 已归档任务 | 页签渲染空态「暂无已归档任务」;列表与其操作需要目前不存在的归档会话契约 | **设置页没有「浏览器」页签。** 浏览器能力是工作区的一列标签页(`workspaceTabs.tab.browser`),在工作区标签里挂载 `BrowserPanel`,不是设置分区;`settings.tab.browser` 这个文案键没有任何调用点。本文早期版本曾在「偏好」组下列出「浏览器」页签,那是错的。 +**快捷键:浏览器能截获什么** + +网页注册不了全局快捷键,也截获不了浏览器已经占用的组合。因此快捷键页 +不再把桌面版键位摆成一份死的对照清单:每一行都写明自己的判定,而判定 +与分发读同一份注册表——`webapp/lib/shortcuts.ts` 存判定,`app/page.tsx` +按它匹配键盘事件,`components/settings-extra-pages.tsx` 渲染它,所以一行 +不可能「显示为已生效却无人分发」,反之亦然。 + +| 行 | 组合 | 判定 | 原因 | +| --- | --- | --- | --- | +| 显示或隐藏 Mini Chat | `Alt+M` | 不可用 | WebUI 没有 Mini Chat 这个功能面 | +| 全局搜索 | `Ctrl+K` | 已生效 | 本客户端面向的浏览器都未占用;打开工作区树列的搜索面板 | +| 搜索任务和会话 | `Ctrl+G` | 不可用 | 浏览器用它查找下一个 | +| 新建任务 | `Ctrl+N` | 仅 macOS | Chromium 与 Firefox 在 Windows / Linux 上用它开新窗口,那里按键根本到不了网页 | +| 新建无项目任务 | `Ctrl+Alt+O` | 已生效 | 未被占用 | +| 打开项目文件夹 | `Ctrl+O` | 不可用 | 浏览器用它打开文件对话框 | +| 打开设置 | `Ctrl+,` | 已生效 | 未被占用 | +| 按住听写 / 切换听写 | — | 不可用 | 没有语音识别支撑 | +| 反转跟进行为 | `Ctrl+Enter` | 不可用 | 键位空闲,但操作语义尚未定;绑上等于承诺还不存在的行为 | + +三行**已生效**可改键:点击输入框后按下新组合,存入 +`webui-shortcut-bindings`,下一次键盘事件即按新键分发。若新组合已被另一 +个在用行占用,改键被拒绝并点名冲突的是哪一项——两行共用一个组合会让分发 +结果依赖注册表顺序。`Ctrl+N` 有意不可改:换键并不能让它在浏览器占用的平台 +上生效,所以该行直接写明限制,而不是让用户以为改键能解决。六行不可用的行 +保留桌面版印出的组合以供对照,控件禁用,并按上表打印原因。`Ctrl+Shift+T` +这类组合不做尝试,也做不到:按键不会到达网页。 + **通用页有哪些分区** 通用页自上而下(工单 48 起按桌面参照分区,每区有小标题、卡片、行间分隔线;设置行为横排两栏——左标题加说明、右控件): @@ -1022,9 +1050,9 @@ slice 22 增强: | 应用 | 可用 | 外观三选一卡片、语言切换。桌面版此处另有 5 个开关(菜单栏图标、开机自启、桌面通知、提前灰度、加速索引),本服务端无对应能力,**照常渲染但禁用** | | 链接打开位置 | 禁用摆设 | 两行(网页链接、本地链接),下拉均为禁用的单选 | | 文件 | 可用 | 两个开关,读写浏览器本地存储,见下表 | -| 会话管理 | 可用 | 一个开关,读写本地存储,**目前仅记录偏好**,尚无界面读取它 | +| 会话管理 | 可用 | 一个开关,读写本地存储,控制输入区上下文用量计量的显示(见下) | | Agent 控制权限 | 禁用摆设 | 「自动打开浏览器面板」开关渲染为关闭且禁用(无对应能力) | -| 偏好设置 | 可用 | 「跟进消息行为」单选(排队 / 立即发送),读写本地存储,**目前仅记录偏好**,尚未影响实际发送行为(编写器归工单 49);水印与数据授权两行渲染为禁用 | +| 偏好设置 | 可用 | 「跟进消息行为」三段(关闭 / 排队 / 立即发送),读写本地存储;自 SB-4 起编写器真的读它——回合运行中发送的消息会进引擎队列或转向当前回合。水印与数据授权两行渲染为禁用 | | 关于 | 混合 | 上传日志与检查更新是禁用按钮;本机地址与局域网地址是从 `/api/settings` 取值的真实只读行 | | 页底数据目录(dataDir) | 未实现 | 桌面版在通用页底部显示应用数据目录;`/api/settings` 契约没有该字段且本轮服务端只读,无法取到真值,如实留空不做 | @@ -1038,8 +1066,24 @@ slice 22 增强: | --- | --- | --- | | `file_open_in_new_tab` | `true`(本客户端默认开;桌面参照默认关) | **是**。开启时保持本客户端一贯的「每文件一个预览标签页」;关闭后打开新文件会**替换当前激活的文件标签页**。本客户端的标签条没有「固定」概念,故以「当前激活的文件标签页」为复用目标,与桌面「复用未固定标签页」语义近似但不相同 | | `file_line_wrap` | `true` | **是**。开启时超宽行自动折行;关闭时横向滚动。覆盖两类表面:代码文件预览(工单 48)与 markdown 代码块——聊天消息、活动组、markdown 文件预览(工单 52);语言标签不随代码行折行。对之后打开的预览/之后挂载的消息生效(已打开的不重排);文件预览折行后行号与第二视觉行不对齐,是已知取舍 | -| `webui-context-window-usage` | `false` | 否。仅记录偏好,尚无界面读取 | -| `webui-follow-up-behavior` | `queue`(可选 `steer`) | 否。仅记录偏好,尚未影响实际发送行为 | +| `webui-context-window-usage` | `false` | **是**。开启时在输入区工具栏(紧挨模型选择器左侧)绘制上下文用量计量;关闭时该处不渲染任何内容。计量本身的形态不变——圆环、百分比、分类明细、套餐各行仍照旧来自会话快照。拨动开关无需刷新页面即生效 | +| `webui-follow-up-behavior` | `queue`(可选 `off`、`steer`) | 否。决定回合运行中发送的去向;`off` 是 webui 自有的第三态(参照只有两态) | + +**上下文用量计量** + +`webui-context-window-usage` 的消费方只有 +`components/context-meter.tsx` 一个:它在挂载时读一次该键,此后通过 +`webapp/lib/settings-local.ts` 的 `subscribeContextWindowUsage` 订阅, +所以开关在已打开的页面上即刻生效——设置弹窗与输入区往往同屏, +否则只能靠刷新页面感知。订阅通道的形态是 +`subscribe*(listener) → 取消订阅函数`,与外观三选一用的 +`webapp/lib/theme.ts#subscribeSystemTheme` 一致;某个订阅者抛错会被 +隔离,不会连累其他订阅者丢掉这次更新。 + +默认值仍是 `"false"`(桌面参照的默认值),因此从未碰过该开关的配置 +不绘制计量。这相对 webui 此前「无条件绘制、开关无效果」的行为是变化: +参照默认隐藏,由开关决定。存储格式仍是裸 `"true"` / `"false"` 字符串 +——该键没有搬进 `webui:ui:v1` 信封,那会破坏与桌面参照共享的契约。 **搜索与排版细节(工单 48)** @@ -1054,10 +1098,9 @@ slice 22 增强: | 能力 | 说明 | | --- | --- | -| 账户页 | 页签按桌面形态渲染,但本地没有 `getAccountStatus` / `signOut` 类后端契约,账户行显示「本地模式,未登录」、退出登录禁用 | +| 账户页的退出登录 | 读取是真数据(见下节**账户分区**);引擎没有登录登出方法,因此按钮按参照形态渲染但禁用,tooltip 写明该原因 | | 已归档任务页 | 页签渲染空态;列表、恢复与删除需归档会话契约 | -| 用量与模型的三来源切换 | 分段页签已按桌面形态落地(工单 53),但它是**视图切换器**——不切换实际使用的模型来源;真实的 Token Plan / MiniMax API / 自定义模型来源切换与来源徽标仍需模型路由契约 | -| MiniMax API Key 面板 | 输入 + 测试连通性 + 保存并使用 | +| 添加模型弹窗的「自动获取」live per-key 模型目录 | v2 无按任意密钥查询供应商目录的方法(`cli-service.ts#listModels` 回答的是"本应用已配置了哪些模型",是另一个问题);维持内置 preset 目录方案 | | 自定义模型拖拽排序、逐模型启停、预设选择器 | 需 provider 契约扩展;增删改已全部收敛到同一弹窗(本批),列表只负责展示与删除 | | 搜索关键词高亮 | 参照自己也没接线(定义了组件与动画但无调用点) | | 通用页 dataDir 底注 | 见上表 | @@ -1070,13 +1113,76 @@ Token Plan 视图是桌面的五区块(页签 + 四卡): | 区块 | 数据策略 | | --- | --- | -| 当前套餐卡(ⓘ + 两行 + 管理⌄) | 本地无云端账户数据源:套餐名与积分数值显示「本地版不适用」,到期行不渲染(不造假日期);「升级」(黑底主按钮)/「管理 ⌄」/「去充值」渲染桌面同款形态但禁用 | +| 当前套餐卡(ⓘ + 两行 + 管理⌄) | 套餐名是真数据:取 `GET /api/account` 的 `tokenPlan.tier`,进视图时读一次并原样渲染;引擎没报套餐、或账户接口不可达时,该行显示「未订阅套餐」——绝不回退到某个默认档位。余下数值本地无凭据通道:积分数值显示「云端账户域,本网页端无账户凭据」,到期行不渲染(不造假日期);「升级」(黑底主按钮)/「管理 ⌄」/「去充值」渲染桌面同款形态但禁用——三者动作都落在云端账户上 | | 用量卡(三条进度条纵排) | 5 小时限额与周限额是唯一真实数据源(引擎经 ACP 上报,`POST /api/usage`;页面每 2 分钟自动读一次,手动刷新计入预测采样),有数据时印桌面格式「X% / 100%」/「X%」+ 相对时间重置文案(如「43分后重置」);引擎未上报时该条显示「暂无用量数据」而非 0%;视频限额本地无数据源,固定显示「本地版不适用」 | -| 积分行(ⓘ + 蓝色开关) | 本地无积分体系:开关渲染桌面同款蓝色 iOS 形态但置灰(checked + disabled),文案照桌面,行内标注「本地版不适用」 | +| 积分行(ⓘ + 蓝色开关) | 积分属云端账户域:开关渲染桌面同款蓝色 iOS 形态但置灰(checked + disabled),文案照桌面,行内标注的理由改为「云端账户域,本网页端无账户凭据」 | | 发票行 | 唯一完全真实的外链:「申请 ↗」新标签打开 MiniMax 开放平台 | +当前套餐卡正是工单 53 的 A1 拍板被修订之处。A1 写的是「无源即占位」,并把这条整卡套用;但本地服务端其实有数据源(配额窗口走 `POST /api/usage`,套餐档位走 `GET /api/account`),这条拍板夸大了缺口。修订的做法是按数据源拆卡,而不是按卡拆:服务端读得到的就渲染,属于云端账户域的数值则填上点名该域的诚实文案。被否的备选有两个——一是整卡维持占位(引擎已经报出档位时那不是缺口);二是把积分 / 到期 / 发票也一并接真(自托管的浏览器会话拿不到该账户的凭据,在那里放一个看起来真实的数值,正是 A1 要防的造假)。 + 自定义模型视图是原有供应商面板(API Key、协议、模型清单、连接测试、预设一键启用);工单 54 把**添加**流程重做成桌面同款弹窗(见下节),本批把**编辑**也并入同一弹窗(见「弹窗同时承载编辑」一节)——面板现在只剩列表与删除,没有第二个编辑面。 +**三来源切换与 MiniMax API Key(SB-1)** + +**用户能看到什么。** 打开「用量与模型」页会读一次引擎,落定三件原本靠本地 state 猜的事:引擎当前用的是哪个凭据、有没有存 MiniMax API Key、上一次连通检测的结果。胶囊仍然是**视图**;旁边的「使用中」徽标是引擎的回答,且只在写入被确认之后才移动。在下拉里点 Token Plan 或 MiniMax API,会同时切视图并写引擎(`PUT /api/model-source`);被拒绝时——引擎在没有存 BYOK 密钥时返回 `NO_API_KEY`——视图停在用户放的位置,于是"需要填的密钥框"正好留在屏幕上,而徽标继续显示真正在用的那一个。 + +**为什么徽标与视图是两个值。** 此前它们是同一个 `useState`,这正是旧实现那句声明为假的原因:切换器可以把一个来源渲染成"已选",而引擎仍在用另一个。为引擎从未接受的来源打上「使用中」,正是本仓库一贯拒绝的假成功形态,因此徽标只由回读喂数据。 + +**密钥行。** 已存的密钥显示为引擎的掩码,绝不显示明文;输入新值即在保存时替换。「保存并使用」是一次请求而不是两次:引擎在一个事务里既写密钥又切来源,因此界面不会出现"密钥已存、来源没切"的中间态。提交空值即**保留**哨兵(`changed: false`,不调用任何引擎写)——与 `PUT /api/providers` 同一约定;它存在的原因是读接口只能返回掩码,而引擎拒绝把掩码当密钥提交。 + +**检测测的是什么、不测什么。** 检测针对 `minimax_api` 供应商上的**已存密钥**,并在响应里说明这一点(`tested: "stored_key"`)。两条限制来自引擎契约而非本界面:`testUserModel` 不接受密钥覆写,因此未保存的值无法被检测——输入框里有未保存内容时按钮禁用,且输入框下方常驻一行**可见**说明(该理由原本只写在 `title` 属性里,键盘与触屏用户根本看不到),而托管的 Token Plan 凭据不是模型服务的密钥,Token Plan 来源在这里没有可检测对象。跑完但失败的检测是一次"跑完的检测"而不是错误,它渲染引擎给出的状态。 + +**密钥行是四态,不是两态。** 徽标读的是引擎的掩码投影,可它描述的是一个用户此刻可能正在编辑的输入框,因此"已填未存"是它自己的一态(「已输入,未保存」),优先级高于「已保存密钥」与「未启用」——用户要么正在替换已存密钥,要么明摆着填了内容,两个徽标此时都不成立。与之对称,引擎的 `NO_API_KEY` 拒绝是针对**空**输入框的判决:打一个字就把它证伪,因此那句钉住的「请先填写 API Key」在下一个字符处消失。与缺密钥无关的拒绝(传输失败、写入被驳回)此后依然成立,继续留在屏幕上。 + +**代价。** 每次打开该设置页多一次读;这次读在没有运行时时会启动引擎,属于写侧契约,在这里可以接受(是用户打开了页签)。若将来把这次读取挪到页面级,必须改用不启动运行时的宿主 getter(见 `server/engine/model-source.js` KNOWN DEBT 2)。 + +**本批没有做的事。** 添加模型弹窗的「自动获取」仍走内置 preset 目录(v2 没有按任意密钥查询供应商目录的方法);Token Plan 四张卡维持 A1 决策的「本地版不适用」——把它们接到 `/api/usage` 与 `/api/account` 是另一个尚未拍板的独立项,不是本批的副作用。 + +**切源后重拉账户(UAT4-1)** + +**缺陷。** 2026-10-03 的 UAT 第四板块(板块 4)做了一次三来源往返:Token Plan → MiniMax API(存了 Key)→ Token Plan。回来后 Token Plan 卡显示「未订阅套餐」,而 `GET /api/account` 全程答 `tier: "Ultra"`,只有 F5 能恢复。接口自始至终没有说谎。 + +**根因。** 套餐名在 `UsageModelsSection`(`webapp/components/panels.tsx`)里读,而该组件只在端口视图停在 token-plan 页签时挂载,所以切走再切回会重新挂载。那次读是 `useEffect(..., [])`——每个挂载只读一次——而这次挂载与引擎赛跑:组件与 `PUT /api/model-source` 在同一 tick 渲染,此刻仍在重绑定的引擎会让 `GET /api/account` 以 HTTP **200** 加 `{ok: false, reason: "no_client"}` 应答。卡片把它读成"没有套餐",此后无人重读,组件状态比失败活得更久。 + +**重拉机制。** 端口持有一个 `accountRevision` 计数器,在**每一次成功的来源写入之后**递增——下拉的 `PUT /api/model-source`,以及「保存并使用」的 `PUT /api/model-source/api-key`(它在同一个引擎事务里切来源)。`UsageModelsSection` 的 `/api/account` effect 把该计数器列入依赖,于是被确认的写入会对已完成重绑定的引擎重跑这次读。被拒绝的写入不递增:什么都没变,重读只会白花一次请求去渲染同一个答案。 + +**失败的读不能"忘掉"已知的名字。** 只做重拉还不够,因为重拉自己也会输掉赛跑。`reconciledAccount`(`webapp/components/usage-models-cards.tsx`,纯函数、有单测)会让上一个 `ok: true` 的答案继续站住:只有 `ok: true` 的载荷才算新信息,因此账户面不可达无法把已知套餐名从卡上打掉。而 `ok: true` 且明确报"没有套餐"的答案**会**替换它——引擎说"没有套餐"是回答,说"读不到"不是。 + +**读取在途是它自己的一句话。** 名字被保住之后,唯一剩下的无名状态就是"还没读到",它渲染「正在读取当前套餐…」而不是「未订阅套餐」。账户面没应答不等于用户没订阅——把两者混成一句正是 UAT4-1 的可见那一半。 + +**代价。** 每一次被确认的来源写入多一次 `GET /api/account`,发生在用户刚刚操作过的界面上。不做防抖:切源是一次明确动作,不是一串动作。 + +**账户分区(SB-5)** + +整个分区在挂载时读一次 `GET /api/account`。端点既不新建也不重复:它就是用户菜单账户卡已在读的同一份投影,按需读取的原因也相同——状态快照会广播给每一个 SSE 订阅者,账户数据不进去。 + +| 行 | 字段 | 缺失时的文案 | +| --- | --- | --- | +| 账户名 | `identity.name`(去空白) | 引擎应答了但没给名字 | +| 当前套餐 | `tokenPlan.tier`,走 Token Plan 卡自己的 `planNameOf` | 引擎没报套餐时是「未订阅套餐」;读取在途时是「正在读取当前套餐…」;账户面本身不可达时是未读到的那句 | +| 配额概况 | `tokenPlanQuotaState`,以及 `quota.fiveHour` / `quota.weekly` 的 `remainingPercent` | 每个窗口各自显示「引擎未返回读数」;窗口被判为不限量时显示「不限量」 | +| 账户状态 | `status` | 未读到的那句;字典里没有句子的状态枚举归为空,而不是把原始枚举漏给用户 | + +未读到的那句按失败形态分句,且每句都点名来源:`ok: false` 优先显示引擎自己的 `reason`,传输失败显示读取失败句,两者都不替用户下判断。由此「账户面不可达」与「应答了但没有账户名」得到两句不同的话——这正是此前那句硬编码「本地模式,未登录」表达不了的区分。 + +与 Token Plan 卡按**形态**分工,不按主题分工:限额条、套餐操作、积分、到期与发票归那张卡;身份与纯文本读数归本分区。两者共享的只有套餐名,且走同一个解析器(`planNameOf`),两个面不会各自漂移。窗口被判为不限量时显示「不限量」而不是「剩余 0%」——没有上限的套餐不能读成已用尽。 + +被否的备选:再建一个账户端点(投影已经存在,第二个路由只是多一份要同步的契约);以及在本分区也把配额画成进度条(同一把量尺在两个页面、用两个数据源各画一次)。 + +**跟进消息:开关真的改变行为(SB-4)** + +**用户能看到什么。** 「跟进消息行为」有三个位置。任务运行中,输入区仍然保留停止按钮,同时——在「排队」或「立即发送」下——也提供发送按钮:「排队」把消息交给引擎队列,等本回合结束后执行;「立即发送」把它作为转向消息投进正在跑的回合。选「关闭」时输入区与本批之前完全一致:发送按钮被停止按钮顶替,文本留在框里等回合结束。切换即时生效,不需要刷新,也不需要重发一次。 + +**为什么有第三个位置。** 桌面参照只有两个选项,因为桌面自己持有正在跑的回合,两个选项都不可能失败。webui 的跟进消息可能被拒绝——持有该回合的引擎可能是另一个进程——因此一个两值开关其实是"换了行为却顶着开关的外衣"。「关闭」是 webui 自己的选项,文档如实标注。 + +**归属闸门,以及为什么它不判传输方式。** 两个动作执行前,服务端先问引擎"这个回合是不是本进程持有"(`cliService.getActiveTurn`)。`runtime` 传输下回合就跑在 webui 进程内,答案是肯定的,队列或转向消息被受理;默认的 `acp` 传输下回合跑在 `mcode acp` 子进程里,此时把消息排进本宿主会唤醒它自己的调度器,为一个已经在跑回合的会话**再开一个**回合——正是 `/api/send` 用四道 claim 和一个 409 防住的事。这种情况以 `turn_not_owned` 拒绝,原文回到输入框,横幅说明原因。闸门读的是引擎自己的回答而不是 `MCODE_WEBUI_TRANSPORT`,因此等聊天链路彻底迁到进程内传输时,它无需任何改动就会开始工作。 + +**响应报的是什么。** 引擎自己的回答,绝不回显请求:排队返回引擎提交的条目 id 与位置,转向返回回合 id 与投递模式。两种拒绝保持两句不同的话——"已经没有回合了"与"回合在另一个进程里"——因为用户该做的下一步不同。 + +**代价。** 每次跟进发送多一次引擎读;这是写路径,没有运行时时会启动引擎,可以接受(是用户按了发送)。 + +**本批没有做的事。** 队列没有界面:排进去的消息带着 id 和位置,却没有任何地方展示,也无法在浏览器里查看、重排或撤销。那是 PB-13 的范围;在它落地之前,排队的跟进消息在本回合结束前不可见。转向消息返回的是受理结果,不是"运行中的 agent 是否在下一步之前读到了这段文字"。 + **添加模型弹窗(工单 54,53b)** 未配置任何供应商时,页签中央显示「暂未添加自定义模型」与「+ 添加模型」按钮;有供应商后按钮移到列表下方。模型选择器的「新增供应商」深链也落在同一弹窗。弹窗内: @@ -1179,7 +1285,8 @@ Token Plan 视图是桌面的五区块(页签 + 四卡): **项目右键菜单**(侧栏项目行右键,参照 ref-26)五项对齐桌面:重命名项目 / 置顶项目 / 在文件夹中显示 / 归档对话 / 移除(红)。 - 重命名与置顶是真做的。项目名与置顶状态存在浏览器本地(`webui:project-custom:v1`,见持久化键一节)——mcode 的运行时数据库里项目不是实体、没有可写入口,所以覆盖层放在唯一消费者所在处,与会话标题 `titleCustom` 的思路一致。置顶的项目排到列表最上,项目名旁常驻图钉标记。 -- 在文件夹中显示是占位禁用:浏览器打不开操作系统的文件管理器。 +- 在文件夹中显示是真做的(SB-6)。点击把项目路径发给 `POST /api/fs/reveal`——该端点一直都已实现并注册(`server/routes/fs.js#handleFsReveal`、`server/app.js`),而菜单这一行是占位,声称浏览器打不开操作系统的文件管理器;对持有工作区的 webui 部署而言这句话不成立。这一行只有一个诚实的禁用理由:项目未关联任何本地目录,此时提示语直说这件事,而不是拿「本地版不适用」敷衍。成功时静默——文件管理器窗口本身就是反馈,toast 只会跟它抢时序;失败时与菜单其余写操作走同一块错误横幅,标签用菜单自身已本地化的名称,无论服务端以结构化 `code` 拒绝还是请求抛出。 +- 会话级的在文件夹中显示仍是禁用占位:桌面参照同位也禁用,既没有对齐目标可追,也没有本地限制可归咎;解禁是一项本版尚未作出的产品决定。 - 归档对话是占位禁用:mcode 数据库虽有 `archived` 字段,但写别的进程的数据库不在本片范围,且已归档任务页(工单 55b)未落地前没有取消归档的入口——归档会变成不可逆的数据消失。 - 移除是真做的红色危险项:确认弹窗写明真实删除总数(主会话与子代理会话全量,不是侧栏角标的主会话数——不可逆确认不得少报)与不可恢复,并预告删除将逐个进行、期间会出现 N 次授权确认(服务端对每个单会话删除分别走 `authorize("session.delete")`,没有批量授权契约);确认后弹窗内实时显示「正在删除 i/N」,逐个走既有的单会话删除端点,失败即停并报告。仅当全部删除成功时才清掉该项目的重命名/置顶记录(部分失败时存活项目保留其自定义),清理经组件状态与 localStorage 同步进行。 @@ -1817,8 +1924,9 @@ loading-states 相同:让 SSR 渲染测试可以脱离 `chat.tsx` 的 `@/` 别 | `webui:files-tree:` | `sessionStorage` | `webapp/components/panels.tsx`(slice 01) | slice 01(文件树) | `{version:1, workspace, expanded[], filter, showHidden}` | | `file_open_in_new_tab` | `localStorage` | `webapp/lib/settings-local.ts` | 工单 48(设置通用页) | 纯 `"true"\|"false"` 字符串;**有意不带 `webui:` 前缀**——与桌面参照同名同格式,同一浏览器配置在两个客户端共享该偏好。本客户端默认 `"true"`(参照为 `"false"`);读取方 `app/page.tsx#openFileTab` | | `file_line_wrap` | `localStorage` | `webapp/lib/settings-local.ts` | 工单 48 + 52 | 纯 `"true"\|"false"` 字符串,参照共享命名;默认 `"true"`;每次挂载读取方为 `components/code-view.tsx`(代码文件预览)与 `components/markdown-html.tsx`(markdown 代码块:聊天、活动组、文件预览) | -| `webui-context-window-usage` | `localStorage` | `webapp/lib/settings-local.ts` | 工单 48 | 纯 `"true"\|"false"` 字符串,参照共享命名;默认 `"false"`;仅记录偏好,尚无读取方 | -| `webui-follow-up-behavior` | `localStorage` | `webapp/lib/settings-local.ts` | 工单 48 | 纯 `"queue"\|"steer"` 字符串(其他值读取为 `"queue"`),参照共享命名;仅记录偏好,尚无读取方 | +| `webui-context-window-usage` | `localStorage` | `webapp/lib/settings-local.ts` | 工单 48 | 纯 `"true"\|"false"` 字符串,参照共享命名;默认 `"false"`;`components/context-meter.tsx` 挂载时读取一次,并通过 `subscribeContextWindowUsage` 实时跟随 | +| `webui-follow-up-behavior` | `localStorage` | `webapp/lib/settings-local.ts` | 工单 48 / SB-4 | 纯 `"off"\|"queue"\|"steer"` 字符串(其他值读取为 `"queue"`),参照共享命名;由 `components/composer.tsx` 读取,每次写入都会重新发布 | +| `webui-shortcut-bindings` | `localStorage` | `webapp/lib/shortcuts.ts` | 工单 55c(设置快捷键页) | `{"global-search":"Ctrl+Shift+P", …}`——**已生效**行的改键记录,用户录入新组合时写入,清掉最后一条时整个键删除。读取时按注册表重新校验:已不再分发的行 id、或已无法解析的组合一律丢弃,手工改过的存储项无法借此扩大页面的分发面。`app/page.tsx` 每次键盘事件经 `effectiveBindings` 读取,设置页每次挂载读取一次 | | `webui:project-custom:v1` | `localStorage` | `webapp/lib/project-custom.ts` | 工单 55c(项目右键菜单) | `{version:1, titles:{<项目key>:<自定义名>}, pinned:[<项目key>]}`。**不按 cid 命名空间**(有意):重命名与置顶描述的是项目本身而非某个浏览器会话,同一浏览器的所有标签页共享。写入尽力而为,失败静默;项目被完整移除(全部会话删除成功)时同步清除其条目 | 除工单 48 的四个参照共享键(`file_open_in_new_tab` / `file_line_wrap` / @@ -2136,7 +2244,7 @@ createdAtMs, updatedAtMs}`)下发,按 `toolCallId` 幂等、上限 32 条、 | `POST` | `/api/fs/mkdir` | `routes/fs.js#handleFsMkdir` | `{path}`;父目录必须在允许根内;containment 失败 → `403` | | `POST` | `/api/fs/write` | `routes/fs.js#handleFsWrite` | `{path, content, expectedMtime?, expectedSize?, confirm?}`——预览编辑器的保存端点(slice 27)。`200 {ok, path, size, mtime}`(返回新基线);`400 {code:"missing-path"\|"missing-content"\|"invalid-content"\|"not-a-regular-file"}`;containment → `403`;凭据形路径未确认 → `403 {code:"credential", credentialReason}`;文件消失 → `404 {code:"not-found"}`(TOCTOU 兜底——缺失路径通常先被共享闸门拦下,与读取行为一致);基线过期 → `409 {code:"conflict", diskMtime, diskSize}`(不写盘);超上限 → `413 {code:"too-large"}`(写入上限与读取同为 512 KiB)。实现是对围栏内路径的裸 `writeFileSync`——全程无 shell。凭据形路径带 `confirm:true` 时输出 `endpoint:"write"` 的 `credential.override` 审计行。 | | `POST` | `/api/fs/open-default` | `routes/fs.js#handleFsOpenDefault` | `{path}`;`400 {code:"missing-path"}` / `403 {code:"out-of-bounds"}` / `400 {code:"not-a-regular-file"}` / `503 {code:"no-opener"}` / `502 {code:"spawn-failed"}` | -| `POST` | `/api/fs/reveal` | `routes/fs.js#handleFsReveal` | `{path}`;`code` → status 映射与 `open-default` 相同 | +| `POST` | `/api/fs/reveal` | `routes/fs.js#handleFsReveal` | `{path}`;`code` → status 映射与 `open-default` 相同。消费方:文件预览工具条,以及侧栏项目右键的「在文件夹中显示」(SB-6) | | `GET` | `/api/fs/search` | `routes/fs.js#handleFsSearch` | `?root=&q=&depth=&maxNodes=&wallMs=&limit=&includeHidden=1`;`400 {code:"missing-root"\|"missing-q"\|"not-a-directory"\|"stat-failed"}`;成功时返回 `{ok, root, q, matches:[{path,name,type,ancestors,credential?,credentialReason?}], scanned:{dirs,files,total}, skipped:{node_modules,n,.git,n,credential,n,huge,n,optional:{dist,build,…}}, truncated, truncatedReason: null\|"depth"\|"nodes"\|"wallClock"\|"matches", elapsedMs, budgets}`。默认预算 `maxDepth=8 / maxNodes=5000 / wallMs=1500 / maxMatches=200`;绝对上限 `16 / 50_000 / 5_000 / 1_000`(`packages/webui/server/lib/fs-search.js`);`node_modules` 与 `.git` 不可被覆盖。 | | `GET` | `/api/git/status` | `routes/git.js#handleGitStatus` | `?dir=`;`400 {error:"missing dir"}` | | `GET` | `/api/git/branches` | `routes/git.js#handleGitBranches` | `?dir=`;前导 `* ` → `current` 标志 | @@ -2156,6 +2264,11 @@ createdAtMs, updatedAtMs}`)下发,按 `toolCallId` 幂等、上限 32 条、 | `POST` | `/api/providers/test` | `routes/providers.js#handleTestProvider` | `{provider}`;结构化 code → status | | `GET` | `/api/providers/presets` | `routes/providers.js#handleGetPresets` | 画廊 | | `POST` | `/api/providers/preset/:id/enable` | `routes/providers.js#handleEnablePreset` | 一键启用 | +| `GET` | `/api/model-source` | `routes/model-source.js#handleGetModelSource` | `{ok, source, apiKey:{available,hasKey,masked,testState,lastTestedAtMs}}`;宿主没有 `getMiniMaxModelSource` → `501`;没有运行时 → `503`;引擎报出其两值之外的值 → `502 {code:"UNKNOWN_MODEL_SOURCE"}`。`available:false` 不等于 `hasKey:false` | +| `PUT` | `/api/model-source` | `routes/model-source.js#handleSetModelSource` | `{source}`;`400 {code:"INVALID_MODEL_SOURCE"\|"BAD_FIELD_TYPE"}`;引擎因无密钥拒绝 BYOK 方向 → `400 {code:"NO_API_KEY"}`;响应带的是引擎**已持久化**的值 | +| `PUT` | `/api/model-source/api-key` | `routes/model-source.js#handlePutModelSourceApiKey` | `{apiKey, saveAndUse?}`;`apiKey` 缺失/空/仅空白即**保留**哨兵 → `200 {changed:false}` 且不写引擎;`400 {code:"BAD_FIELD_TYPE"\|"INVALID_API_KEY"}`;`500 {code:"engine_error"}` 绝不携带抛出的异常消息 | +| `POST` | `/api/model-source/test` | `routes/model-source.js#handleTestModelSource` | `{modelId?}`;**跑完**的检测恒 200(`{ok, success, providerId:"minimax_api", tested:"stored_key", status}`),含 `success:false`;非 200 只出现在拒绝去试时(`503`/`501`,或引擎的 `400 NO_API_KEY`) | +| `POST` | `/api/follow-up` | `routes/follow-up.js#handleFollowUp` | `{behavior:"queue"\|"steer", content, attachments?, requestId?}`——引擎会话 id 取自服务端自己的会话状态,**不从请求体取**;`400 {code:"invalid_follow_up_behavior"\|"follow_up_empty"\|"no_active_conversation"\|"BAD_FIELD_TYPE"}`;本进程不持有正在跑的回合时 `409 {code:"no_active_turn"\|"turn_not_owned"}`,且**不排任何队**;宿主缺该方法 `501`、没有运行时 `503`;200 `{ok, behavior, itemId, position, status}`(排队)或 `{ok, behavior, turnId, mode}`(转向)——都是引擎自己的回答 | | `POST` | `/api/debug/inject` | `routes/debug.js#handleDebugInject` | `DEBUG_INJECT=1` 守门 | | `GET` | `/api/debug/state` | `routes/debug.js#handleDebugState` | 同上 | | `POST` | `/api/protocol/set-mode` | `routes/protocol.js#handleSetMode` | 会话中途切换 mode | @@ -2210,6 +2323,28 @@ decidedBy:"timeout", decidedAt}` —— 并记 `auth.unreachable` 审计(带 若总线无法回答这个问题(不建模连接注册表的测试替身),按"可能有人"处理,保持原 有的等待语义。这条短路只可能拒绝,不存在任何"未经记录裁决即放行"的路径。 +**"有没有人在听"和"这个请求有没有主人"是两个问题,上面这条规则只回答了第一个。** +不带 `?cid=` 的请求 —— curl、脚本、忘了 `withClientQuery` 的调用方 —— 只要有一个 +标签页开着,就会被判成"有人在听":空 cid 是**广播**目标,连上的标签页确实能看到 +并回答那个弹窗。可没有人问过,于是也没有人回答,这个破坏性请求就那么挂满 300000 +毫秒。只在连接注册表为空时才触发的短路因此漏掉了最常见的情形,调用方看到的是挂起, +不是拒绝。 + +所以服务"有主 HTTP 调用方"的路由都传了 `{requireRequester: true}`: +`session.delete`、`sessions.cleanup-orphans`、`session.export`、`session.search`。 +没有主人时,给出的仍是同一个失败即关闭的答案,且立刻给出,记 `auth.unreachable` +审计(带 `reason:"no_requester"`),让运维能把它与"标签页已关闭"区分开。这条规则是 +选择性的,因为差异在另一个方向上同样是承重的 —— `startup.cleanup` 就是**故意**用空 +cid 发起询问的,启动时没有请求人,任意标签页都可以裁决。 + +`DELETE /api/sessions/:id` 把这条规则放在最前面、早于 plan:无归属的删除不读一次 +会话存储,也完全不触达引擎。同一个 handler 还不再问一个它已经能回答的问题 —— 既不在 +会话存储里、又不是 `mvs_` 形态的 id,没有包装条目可摘,也没有引擎行可删,于是直接返回 +这条分支一直以来的 `404 {ok:false, error:"session not found"}`(门面自己的 +`not_mcode_sid` / `already_absent` 语义在 HTTP 层的直说,而不是等出来),不发起任何 +裁决往返。凡是**能**删掉东西的 id —— 解析出的记录,或引擎行即将消失的 `mvs_` 孤儿会话 —— +仍然照常过门。 + ## 阻断式弹窗:各自到底能应答什么 `components/modals.tsx` 渲染三个阻断式弹窗。其中两个的决定引擎收得到,另一个收不到 —— 那个不装样子,而是直说。这个区分是契约,不是界面偏好:**一个把决定发往引擎从不读取之处的按钮,会让点击"成功"而提问一直挂着**,比干脆不显示该按钮更糟。 diff --git a/packages/webui/docs/API.md b/packages/webui/docs/API.md index 5dd62045..1dd1a320 100644 --- a/packages/webui/docs/API.md +++ b/packages/webui/docs/API.md @@ -2477,6 +2477,157 @@ it through the same form the custom-providers UI uses. --- +## Model source (SB-1) + +Four endpoints behind the 「用量与模型」 tab's source switcher, the +「使用中」 badge and the MiniMax API key row. Each one is a thin window +over an engine method that already existed +(`packages/local-runtime-v2/src/local/cli-service.ts`: +`getMiniMaxModelSource`, `setMiniMaxModelSource`, +`upsertMiniMaxApiKey`, `testUserModel`) and previously had no route. + +**These are NOT the provider catalogue.** `/api/providers*` is webui's +own store of custom BYOK endpoints. This family is the ENGINE's MiniMax +credential state — `minimaxModelSource` and `minimax_api.apiKey` in the +engine's own `config.yaml`. The two sit on adjacent tabs, share a masking +convention, and share no storage and no validation. + +**Gate.** The four methods hang off the v2 cli-service's own +`modelProviders` requirement, which is not one of the 14 declared +capability keys, so this family is gated on the LIVE member rather than +on a declaration: `503 engine_host_unavailable` when no runtime is +booted, `501 engine_member_unavailable` when the booted host does not +carry the method. See `server/engine/model-source.js`. + +**Masking.** `apiKey` is masked in every response. The mask is the +engine's own (`service/model-system/secret.js`), forwarded unchanged, and +no path in the route can unmask it. + +### `GET /api/model-source` + +The read the settings tab opens on: the active source, plus the stored +key's masked projection. + +**Response 200** +```json +{ + "ok": true, + "source": "token_plan", + "apiKey": { + "available": true, + "hasKey": false, + "masked": null, + "testState": null, + "lastTestedAtMs": null + } +} +``` + +`source` is `token_plan` (the managed Token Plan credential) or +`minimax_api_key` (the user's own BYOK key). `apiKey.available: false` +is NOT `hasKey: false`: the first means the host could not report the +key half at all, the second means it reported that nothing is stored. A +UI that collapsed the two would tell a user with a saved key that they +have none. + +- `501 engine_member_unavailable` — the host has no + `getMiniMaxModelSource`. +- `503 engine_host_unavailable` — no runtime booted. +- `502 UNKNOWN_MODEL_SOURCE` — the engine reported a value outside the + two its own type allows. Refused rather than rendered, because the UI + has no way back out of a source it cannot name. + +### `PUT /api/model-source` + +Switch the active source. The response reports what the engine +PERSISTED, not what was requested, so the badge can never disagree with +the config. + +**Request** `{ "source": "token_plan" | "minimax_api_key" }` + +**Response 200** `{ "ok": true, "source": "minimax_api_key" }` + +- `400 INVALID_MODEL_SOURCE` — missing or unknown `source`. Checked + before the engine is reached, so a typo costs no runtime round trip. +- `400 BAD_FIELD_TYPE` — `source` was not a string. +- `400 NO_API_KEY` — the engine refused the BYOK direction because no + key is stored. This code is the UI's cue to point the user at the key + field rather than to show a failure. + +### `PUT /api/model-source/api-key` + +Upsert the BYOK key, optionally switching to it in the same call. + +**Request** `{ "apiKey": "", "saveAndUse": true }` + +**The keep-key sentinel.** An absent, empty or whitespace-only `apiKey` +keeps the stored key, calls no engine write, and answers `200` with +`changed: false` plus the current masked status. It exists because the +GET can only return a MASK and the engine rejects a mask submitted as a +key (`INVALID_API_KEY`); a UI that round-tripped its own masked state +would turn every save into a failure. Same convention and same +empty-string spelling as `PUT /api/providers`. + +`saveAndUse` is the engine's own flag: it writes the key AND switches +the source to `minimax_api_key` in one transaction, so the tab never +shows a saved key beside a source that was not switched. + +**Response 200** +```json +{ + "ok": true, + "source": "minimax_api_key", + "apiKey": { "available": true, "hasKey": true, "masked": "sk-a*******6789" }, + "changed": true, + "saveAndUse": true +} +``` + +- `400 BAD_FIELD_TYPE` — `apiKey` was not a string, or `saveAndUse` was + not a boolean. A non-string key is refused rather than treated as the + keep sentinel, which would turn a client's bug into a successful no-op. +- `400 INVALID_API_KEY` — the engine's own refusal (empty or masked). +- `500 engine_error` — a throw with no engine status. Its message is + REPLACED, not forwarded: an exception string from an unrecognised + thrower is the one place a credential could still be echoed. + +### `POST /api/model-source/test` + +Connectivity probe. **Request** `{ "modelId"?: "MiniMax-M3" }`; the +engine falls back to the first configured MiniMax model when it is +absent, and an unknown id is the engine's own 404. + +The probe always runs against the STORED key on the `minimax_api` +provider, and says so in `tested: "stored_key"`. Two limits are the +engine's contract, not this route's: v2's `testUserModel` takes no key +override, so an unsaved key cannot be probed; and the managed Token Plan +credential is not a model-service key, so the Token Plan source has +nothing here to probe with. + +**Response 200** — for a completed probe, successful or not: +```json +{ + "ok": true, + "success": true, + "providerId": "minimax_api", + "modelId": null, + "tested": "stored_key", + "status": { + "state": "available", + "lastTestedAt": 1700000000000, + "lastErrorCode": null, + "lastErrorMessage": null + } +} +``` + +`success: false` is a COMPLETED probe of a model that did not answer, so +it stays 200 — the same split `POST /api/providers/test` makes. Only a +refusal to try is a non-200: `503` / `501` from the gate, or the +engine's `400 NO_API_KEY` when nothing is stored to test. + +--- + ## Usage ### `POST /api/usage` and `POST /api/usage-trigger` diff --git a/packages/webui/docs/API.zh-CN.md b/packages/webui/docs/API.zh-CN.md index 1a518588..28e05eba 100644 --- a/packages/webui/docs/API.zh-CN.md +++ b/packages/webui/docs/API.zh-CN.md @@ -2263,6 +2263,139 @@ SSE 事件,让每个已连接客户端刷新目录。下一次 `/api/models` --- +## 模型来源(SB-1) + +「用量与模型」页的三来源切换头、「使用中」徽标与 MiniMax API Key +一行的四个端点。每一个都是引擎既有方法 +(`packages/local-runtime-v2/src/local/cli-service.ts` 的 +`getMiniMaxModelSource`、`setMiniMaxModelSource`、 +`upsertMiniMaxApiKey`、`testUserModel`)的薄窗口,此前没有 HTTP 出口。 + +**这一族不是供应商目录。** `/api/providers*` 是 webui 自有存储里的 +自定义 BYOK 端点;本族是**引擎**侧的 MiniMax 凭据状态,即引擎自己的 +`config.yaml` 里的 `minimaxModelSource` 与 `minimax_api.apiKey`。两者 +在界面上相邻、共用掩码约定,但存储与校验完全不共享。 + +**门控。** 四个方法挂在 v2 cli-service 自己的 `modelProviders` 要求上, +而它不是 14 个已声明能力键之一,因此本族按**活成员**门控而非按声明门控: +没有运行时启动时 `503 engine_host_unavailable`,已启动的宿主不带该方法时 +`501 engine_member_unavailable`。见 `server/engine/model-source.js`。 + +**掩码。** `apiKey` 在任何响应里都是掩码,且是引擎自己的掩码 +(`service/model-system/secret.js`)原样透传;本路由没有任何代码路径 +能把它还原。 + +### `GET /api/model-source` + +设置页打开时读的那一次:当前来源,加上已存密钥的掩码投影。 + +**响应 200** +```json +{ + "ok": true, + "source": "token_plan", + "apiKey": { + "available": true, + "hasKey": false, + "masked": null, + "testState": null, + "lastTestedAtMs": null + } +} +``` + +`source` 取 `token_plan`(托管的 Token Plan 凭据)或 +`minimax_api_key`(用户自带的 BYOK 密钥)。`apiKey.available: false` +**不等于** `hasKey: false`:前者是宿主根本读不到密钥半边,后者是读到了 +"没有存密钥"。把两者合并渲染,会告诉一个已经存了密钥的用户"你没有密钥"。 + +- `501 engine_member_unavailable` —— 宿主没有 `getMiniMaxModelSource`。 +- `503 engine_host_unavailable` —— 没有已启动的运行时。 +- `502 UNKNOWN_MODEL_SOURCE` —— 引擎报出了其自身类型之外的值。拒绝而 + 不是渲染,因为界面无法从一个叫不出名字的来源里退出来。 + +### `PUT /api/model-source` + +切换当前来源。响应报的是引擎**已持久化**的值而不是请求值,因此徽标 +不可能与配置不一致。 + +**请求** `{ "source": "token_plan" | "minimax_api_key" }` + +**响应 200** `{ "ok": true, "source": "minimax_api_key" }` + +- `400 INVALID_MODEL_SOURCE` —— 缺失或未知的 `source`。在触达引擎之前 + 就判定,因此一次笔误不会付出一次运行时往返。 +- `400 BAD_FIELD_TYPE` —— `source` 不是字符串。 +- `400 NO_API_KEY` —— 引擎因为没有存密钥而拒绝了 BYOK 方向。这个码是 + 界面把用户指向密钥输入框的信号,而不是弹一个失败提示。 + +### `PUT /api/model-source/api-key` + +写入(upsert)BYOK 密钥,可选在同一调用里切到它。 + +**请求** `{ "apiKey": "<原始密钥>", "saveAndUse": true }` + +**保留密钥哨兵。** `apiKey` 缺失、为空或只有空白时保留已存密钥, +不调用任何引擎写,并返回 `200` + `changed: false` + 当前掩码状态。 +它存在的原因是:`GET` 只能返回掩码,而引擎把掩码当密钥提交会拒绝 +(`INVALID_API_KEY`)——一个把自己读到的掩码回写的前端会让每次保存都 +失败。约定与拼写都跟 `PUT /api/providers` 一致(空串即保留)。 + +`saveAndUse` 是引擎自带的标志:它在一个事务里既写密钥又把来源切成 +`minimax_api_key`,因此界面不会出现"密钥已存、来源没切"的中间态。 + +**响应 200** +```json +{ + "ok": true, + "source": "minimax_api_key", + "apiKey": { "available": true, "hasKey": true, "masked": "sk-a*******6789" }, + "changed": true, + "saveAndUse": true +} +``` + +- `400 BAD_FIELD_TYPE` —— `apiKey` 不是字符串,或 `saveAndUse` 不是 + 布尔值。非字符串密钥被拒绝而不是当作"保留",否则客户端的 bug 会变成 + 一次"成功但什么都没做"的调用。 +- `400 INVALID_API_KEY` —— 引擎自身的拒绝(空值或掩码形态)。 +- `500 engine_error` —— 没有引擎状态的抛出。异常消息被**替换**而非 + 透传:来自不可识别抛出者的异常字符串是唯一仍可能回显凭据的地方。 + +### `POST /api/model-source/test` + +连通性检测。**请求** `{ "modelId"?: "MiniMax-M3" }`;缺省时引擎回落到 +第一个已配置的 MiniMax 模型,未知 id 则是引擎自身的 404。 + +检测始终针对**已保存的密钥**、在 `minimax_api` 供应商上进行,并在 +`tested: "stored_key"` 里说明这一点。两条限制来自引擎契约而非本路由的 +选择:v2 的 `testUserModel` 不接受密钥覆写,因此未保存的密钥无法被检测; +托管的 Token Plan 凭据也不是模型服务的密钥,Token Plan 来源在这里没有 +可检测的对象。 + +**响应 200** —— 检测跑完即 200,无论成功与否: +```json +{ + "ok": true, + "success": true, + "providerId": "minimax_api", + "modelId": null, + "tested": "stored_key", + "status": { + "state": "available", + "lastTestedAt": 1700000000000, + "lastErrorCode": null, + "lastErrorMessage": null + } +} +``` + +`success: false` 是一次**跑完**的检测且被测模型没有应答,因此仍是 200 +——与 `POST /api/providers/test` 的切分一致。只有"拒绝去试"才是非 200: +门控给出的 `503` / `501`,或没有可测密钥时引擎的 `400 NO_API_KEY`。 + +--- + ## 用量 ### `POST /api/usage` 与 `POST /api/usage-trigger` diff --git a/packages/webui/server/app.js b/packages/webui/server/app.js index 2cfe75ed..ebdfa25b 100644 --- a/packages/webui/server/app.js +++ b/packages/webui/server/app.js @@ -66,6 +66,8 @@ import * as modelRoute from "./routes/model.js"; import * as debugRoute from "./routes/debug.js"; import * as protocolRoute from "./routes/protocol.js"; import * as providersRoute from "./routes/providers.js"; +import * as modelSourceRoute from "./routes/model-source.js"; +import * as followUpRoute from "./routes/follow-up.js"; import * as gitRoute from "./routes/git.js"; import * as pluginsRoute from "./routes/plugins.js"; import * as turnDiffRoute from "./routes/turn-diff.js"; @@ -129,6 +131,16 @@ export const OWNED_ROUTES = new Set([ "POST /api/send", "POST /api/stop", "POST /api/cmd", + // SB-4 — the follow-up message family. One window over the two engine + // methods that already existed with nothing in front of them + // (`cli-service.ts`: enqueueMessage / steer), which the composer's + // 跟进消息行为 switch was writing to localStorage and never reading. + // Unlike `/api/send` this is NOT fire-and-forget: the response carries + // the engine's own answer (a queue item id and position, or the turn a + // message was steered into), because a message that was not delivered + // has to come back to the input box. See `engine/follow-up.js` for the + // ownership gate and the KNOWN DEBT list. + "POST /api/follow-up", // Usage / quota. "POST /api/usage", "POST /api/usage-trigger", @@ -213,6 +225,20 @@ export const OWNED_ROUTES = new Set([ // Preset providers (ticket 02): gallery + one-click enable. "GET /api/providers/presets", "POST /api/providers/preset/:id/enable", + // SB-1 — the 「用量与模型」 tab's model-source family. Four windows + // over engine methods that existed all along + // (`cli-service.ts`: getMiniMaxModelSource / setMiniMaxModelSource / + // upsertMiniMaxApiKey / testUserModel) and had no route: the source + // switcher was `useState` plus a comment claiming the backend did not + // exist, and the two key buttons were permanently disabled. The key + // write sits on its own sub-resource so its handler can carry the + // keep-key sentinel (an absent key keeps the stored one) without a + // body flag that would read as "clear my key" by accident. See + // `engine/model-source.js` for the gate and the KNOWN DEBT list. + "GET /api/model-source", + "PUT /api/model-source", + "PUT /api/model-source/api-key", + "POST /api/model-source/test", // Debug injection (gated by DEBUG_INJECT=1). "POST /api/debug/inject", "GET /api/debug/state", @@ -539,6 +565,13 @@ export function createHonoApp() { app.post("/api/cmd", (c) => invokeHandler(c, c.get(CAPTURE_KEY), chatRoute.handleCmd), ); + // ----- SB-4: follow-up messages ----- + // Adjacent to the chat routes because it is a chat action, and + // registered after them so the OWNED_ROUTES order and the registration + // order stay the same list. + app.post("/api/follow-up", (c) => + invokeHandler(c, c.get(CAPTURE_KEY), followUpRoute.handleFollowUp), + ); // ----- Usage / quota ----- // /api/usage and /api/usage-trigger share one handler in the legacy table; @@ -754,6 +787,25 @@ export function createHonoApp() { ), ); + // ----- SB-1: model source + MiniMax API key ----- + // Registered after the provider family and in the same order as + // `OWNED_ROUTES`. The three literal paths share the `/api/model-source` + // prefix, so the order between them does not shadow anything — but the + // sub-resource (`/api/model-source/api-key`) is a distinct path, not a + // suffix match, and the router resolves it on its own literal. + app.get("/api/model-source", (c) => + invokeHandler(c, c.get(CAPTURE_KEY), modelSourceRoute.handleGetModelSource), + ); + app.put("/api/model-source", (c) => + invokeHandler(c, c.get(CAPTURE_KEY), modelSourceRoute.handleSetModelSource), + ); + app.put("/api/model-source/api-key", (c) => + invokeHandler(c, c.get(CAPTURE_KEY), modelSourceRoute.handlePutModelSourceApiKey), + ); + app.post("/api/model-source/test", (c) => + invokeHandler(c, c.get(CAPTURE_KEY), modelSourceRoute.handleTestModelSource), + ); + // ----- Debug injection (gated by DEBUG_INJECT=1) ----- app.post("/api/debug/inject", (c) => invokeHandler(c, c.get(CAPTURE_KEY), debugRoute.handleDebugInject), diff --git a/packages/webui/server/engine/follow-up.js b/packages/webui/server/engine/follow-up.js new file mode 100644 index 00000000..f1155345 --- /dev/null +++ b/packages/webui/server/engine/follow-up.js @@ -0,0 +1,476 @@ +// webui/server/engine/follow-up.js +// +// Settings batch SB-4 (plan `doc/settings-batch-plan.md` §5 row 4): the +// follow-up message family — the one HTTP window over the two engine +// methods that already existed with nothing in front of them +// (`packages/local-runtime-v2/src/local/cli-service.ts`): +// +// POST /api/follow-up behavior=queue → cliService.enqueueMessage +// behavior=steer → cliService.steer +// +// WHY IT IS ONE ENDPOINT AND NOT TWO. The two actions are the two values +// of ONE setting (`webui-follow-up-behavior`), and the difference between +// them is a routing decision the composer has already made by the time it +// calls. Two endpoints would move that decision to the wire and leave the +// client with two calls to keep in step with a segmented control. +// +// THE OWNERSHIP GATE, which is the only interesting thing in this file. +// `enqueueMessage` and `steer` act on the session's ACTIVE turn, and +// webui's turns do not always live in this process: +// +// - Under the `runtime` transport the turn runs in the webui process on +// the SAME CliService the catalogue host exposes (`lib/runtime-host.js` +// — "it shares the underlying CliService with the catalogue host"), so +// a queue item is picked up by the dispatcher that is already running. +// - Under the default `acp` transport the turn runs in an `mcode acp` +// SUBPROCESS. This host's turn service would see an idle session, and +// `submit({allowQueue:true})` on an idle session COMMITS THE QUEUE ITEM +// AND WAKES THE DISPATCHER — a second live turn for a session that is +// already running one. That is the failure `/api/send` spends four +// claims and a 409 preventing (see `routes/chat.js#handleSend`: +// "ten concurrent sends produced ten live engine processes"). +// +// So the gate is `cliService.getActiveTurn(sessionId)`, which reports the +// session's active turn together with `locallyOwned` — the turn system's +// own word for "this process is the one executing it" +// (`turn-system/initialize.ts#inspection.activeTurn`). Three outcomes, and +// each is a DIFFERENT fact the client has to be told: +// +// undefined → 409 no_active_turn (the browser's running flag +// and the engine disagree; the text is not lost, +// it comes back to the box) +// locallyOwned === false → 409 turn_not_owned (a turn is running, in +// another process; queueing would be a second turn) +// locallyOwned === true → the engine owns the turn here, proceed +// +// The gate is deliberately NOT a transport check. Reading +// `MCODE_WEBUI_TRANSPORT` would restate a fact the engine already reports, +// and it would be wrong the moment a provider or a route disagrees with the +// env var. See KNOWN DEBT 1 for what this costs today. +// +// READ-vs-WRITE. This is a write in the strict sense of `host.js` — it +// hands work to the engine — and it is reached by a user pressing a +// button, so it boots through `getEngineCatalogueHost()`. Nothing reads +// this family at page load, so the boot-on-read hazard `model-source.js` +// records (KNOWN DEBT 2) does not arise here. + +import { projectSendAttachments } from "./streaming-send.js"; + +/** + * The two engine actions, as the wire spells them. `off` is NOT here: it + * is a client-side decision (render no send control), and a request that + * carries it is a client that ignored the setting — a 400, not a silent + * downgrade to "queue". + * + * @type {Readonly>} + */ +export const FOLLOW_UP_BEHAVIORS = Object.freeze({ queue: true, steer: true }); + +/** + * The engine method each action needs. `member` is the host path both + * rows share, so a future third action is a table edit. + * + * `getActiveTurn` is deliberately NOT a row: it is the gate, it runs for + * BOTH actions, and a host that lacks it cannot answer the question the + * gate asks — which is a 501 rather than an unverified guess. + * + * @type {Readonly>>} + */ +export const FOLLOW_UP_ACTIONS = Object.freeze({ + queue: Object.freeze({ member: "cliService", method: "enqueueMessage" }), + steer: Object.freeze({ member: "cliService", method: "steer" }), +}); + +/** The action keys, in table order. */ +export const FOLLOW_UP_ACTION_KEYS = Object.freeze(Object.keys(FOLLOW_UP_ACTIONS)); + +/** The member the ownership gate reads. */ +export const FOLLOW_UP_GATE_METHOD = "getActiveTurn"; + +/** + * The producer id the engine itself uses for a message typed by a person + * into a turn that is already running + * (`turn-system/agent-host/runner/contracts.ts#USER_STEERING_PRODUCERS`). + * It is not a label webui invents: a user-steering producer survives Turn + * teardown, so the message is requeued as a fresh query instead of being + * dropped with the turn that was closing. Using any other id would send + * the same text down the machine-injection path and lose it at the exit + * boundary. + * + * @type {string} + */ +export const FOLLOW_UP_STEER_PRODUCER_ID = "composer-steer"; + +/** + * The `source` the engine records for a message that came from an API + * caller rather than from a channel, a cron or a teammate. + * + * @type {string} + */ +export const FOLLOW_UP_STEER_SOURCE = "api"; + +/** + * The two refusals this family answers with, shared with the client so + * both sides spell them identically. They are codes, not statuses: a 409 + * from `/api/send` means "this conversation is busy" and its `reason` is + * the composer's banner vocabulary, while these describe what is wrong + * with the ENGINE's turn record, which the composer renders with its own + * two sentences (`webapp/lib/follow-up.ts#followUpFailureKey`). + * + * @type {Readonly>} + */ +export const FOLLOW_UP_CODES = Object.freeze({ + no_active_turn: true, + turn_not_owned: true, +}); + +/** + * @typedef {{ok: true, member: Function, host: object}} ResolvedFollowUpMember + * @typedef {{ok: false, code: string, status: number, error: string}} FollowUpFailure + */ + +/** + * Read one `cliService` method off the booted catalogue host. + * + * `getHost` is the same flat injection seam `engine/model-source.js` uses: + * production falls through to the process-wide getter, a test hands in a + * fake host, and the route forwards its own optional fourth argument with + * one spread. + * + * @param {object} options + * @param {string} options.label Endpoint/method label for the failure text. + * @param {string} options.method Method name on `host.cliService`. + * @param {Function} [options.getHost] + * @returns {Promise} + */ +export async function resolveFollowUpMember(options) { + const { label, method } = options; + const getHost = options.getHost || (await import("./host.js")).getEngineCatalogueHost; + let host; + try { + host = await getHost(); + } catch (e) { + return { + ok: false, + code: "engine_host_unavailable", + status: 503, + error: e && e.message ? e.message : String(e), + }; + } + if (!host) { + return { + ok: false, + code: "engine_host_unavailable", + status: 503, + error: `${label}: the engine catalogue host is not available`, + }; + } + if (typeof host.cliService?.[method] !== "function") { + return { + ok: false, + code: "engine_member_unavailable", + status: 501, + error: `${label}: host.cliService.${method} is not a function`, + }; + } + return { ok: true, member: host.cliService[method].bind(host.cliService), host }; +} + +/** + * Map a thrown engine error onto the HTTP surface. + * + * The engine's own statuses are forwarded (a 404 for a session it does + * not hold, a 400 for a message it refuses). A `ConversationTurnRejectedError` + * carries no status but does carry a stable `code` and a `reason` — that is + * a refusal, so it becomes a 409 with both. Everything else becomes a 500 + * whose body carries no engine text: an exception string from an unknown + * thrower is the one place a session id or a message could be echoed. + * + * @param {unknown} error + * @param {string} label + * @returns {FollowUpFailure} + */ +export function mapFollowUpError(error, label) { + const status = error && typeof error.status === "number" ? error.status : 0; + if (status >= 400 && status <= 599) { + return { + ok: false, + code: typeof error.code === "string" ? error.code : "ENGINE_REFUSED", + error: typeof error.message === "string" ? error.message : `${label} failed`, + status, + }; + } + if (error && error.code === "CONVERSATION_TURN_REJECTED") { + return { + ok: false, + code: "CONVERSATION_TURN_REJECTED", + error: + typeof error.reason === "string" && error.reason + ? `${label}: the engine rejected the steering message (${error.reason})` + : `${label}: the engine rejected the steering message`, + status: 409, + }; + } + return { ok: false, code: "engine_error", error: `${label}: the engine call failed`, status: 500 }; +} + +/** + * Project webui's resolved attachment records onto the queue's input + * shape (`AttachmentInput` in `@mavis/protocol/local`). + * + * Delegated to `engine/streaming-send.js#projectSendAttachments` rather + * than rewritten: the mime type it sends (`application/octet-stream`) and + * the reason it sends that instead of a real one are a known limitation of + * webui's upload pipeline, and a second projection would be a second place + * for them to drift. + * + * @param {object[]} attachments Resolved attachments (`{path, name, size}`). + * @param {object} attachmentsLib The `lib/attachments.js` namespace, for + * the per-turn cap. Injected by the caller, like the send path's. + * @returns {object[]} + */ +export function projectFollowUpAttachments(attachments, attachmentsLib) { + return projectSendAttachments(attachments, attachmentsLib); +} + +/** + * The steer-side projection. `ConversationAttachment` is a flat + * `{type, filePath, fileName, mimeType}` record rather than the queue's + * nested `{meta, local}` one, so the two actions cannot share a mapping. + * The mime caveat above applies here too. + * + * @param {object[]} attachments Resolved attachments (`{path, name, size}`). + * @returns {object[]} + */ +export function projectSteerAttachments(attachments) { + return attachments.map((a) => ({ + type: "file", + ...(a && a.path ? { filePath: a.path } : {}), + ...(a && a.name ? { fileName: a.name } : {}), + mimeType: "application/octet-stream", + })); +} + +/** + * Bound on the per-send identity the client sends. The engine stores the + * queue item's `clientRequestId` and steers on the message's + * `idempotencyKey`, so an unbounded string from a browser would be stored + * verbatim. 128 characters covers `clientId()` plus a counter and nothing + * else; the value is only compared, never parsed. + */ +export const FOLLOW_UP_REQUEST_ID_MAX = 128; + +/** + * Accept a per-send identity, or `undefined`. + * + * The charset is deliberately narrow (letters, digits, dash, underscore, + * colon, dot) because the value reaches an engine-side idempotency record + * that several producers share, and an arbitrary string in that key space + * is a record-shape hazard, not a feature. An over-long or otherwise + * shaped value is DROPPED rather than rejected: the identity is an + * optimisation against a double submit, not a contract the message + * depends on, and refusing a message over it would be a worse lie than + * sending it once. + * + * @param {unknown} value + * @returns {string|undefined} + */ +export function normalizeFollowUpRequestId(value) { + if (typeof value !== "string") return undefined; + const trimmed = value.trim(); + if (trimmed.length === 0 || trimmed.length > FOLLOW_UP_REQUEST_ID_MAX) return undefined; + return /^[A-Za-z0-9_.:-]+$/.test(trimmed) ? trimmed : undefined; +} + +/** + * The ownership gate, as a total function over what the engine reported. + * + * Split out because the three outcomes must stay distinct all the way to + * the client, and a test can pin them without a host. + * + * @param {unknown} active `getActiveTurn`'s answer: `{turnId, busyReason, + * locallyOwned}` or `undefined`. + * @returns {FollowUpFailure|null} `null` means "this process owns the + * turn, proceed". + */ +export function classifyActiveTurn(active) { + if (!active || typeof active !== "object") { + return { + ok: false, + code: "no_active_turn", + status: 409, + error: + "POST /api/follow-up: the engine reports no running turn for this session, so the message was not queued", + }; + } + if (active.locallyOwned !== true) { + return { + ok: false, + code: "turn_not_owned", + status: 409, + error: + "POST /api/follow-up: the running turn belongs to another engine process, which holds this session's queue", + }; + } + return null; +} + +/** + * Hand one follow-up message to the engine. + * + * The order of the checks is the order of the costs: the cheap + * validations that need no runtime run first, then the gate (one engine + * read), then the action itself. Nothing here is reachable while no turn + * is running, and the client only calls it in that case. + * + * @param {object} options + * @param {unknown} options.behavior `"queue"` or `"steer"`. + * @param {unknown} options.sessionId The ENGINE session id (`mvs_…`), never + * a webui session id. + * @param {unknown} options.content Message text. + * @param {object[]} [options.attachments] Resolved attachments. + * @param {object} [options.attachmentsLib] The `lib/attachments.js` + * namespace, forwarded to the queue's attachment projection. + * @param {unknown} [options.requestId] Per-send identity. + * @param {Function} [options.getHost] + * @returns {Promise<{ok: true, payload: object}|FollowUpFailure>} + */ +export async function sendEngineFollowUp(options = {}) { + const endpoint = "POST /api/follow-up"; + const behavior = typeof options.behavior === "string" ? options.behavior : ""; + if (!FOLLOW_UP_BEHAVIORS[behavior]) { + return { + ok: false, + code: "invalid_follow_up_behavior", + status: 400, + error: `${endpoint}: behavior must be one of ${FOLLOW_UP_ACTION_KEYS.join(", ")}`, + }; + } + const sessionId = typeof options.sessionId === "string" ? options.sessionId.trim() : ""; + if (!sessionId) { + return { + ok: false, + code: "session_required", + status: 400, + error: `${endpoint}: sessionId is required`, + }; + } + const content = typeof options.content === "string" ? options.content.trim() : ""; + const attachments = Array.isArray(options.attachments) ? options.attachments : []; + if (!content && attachments.length === 0) { + return { + ok: false, + code: "follow_up_empty", + status: 400, + error: `${endpoint}: content or an attachment is required`, + }; + } + const requestId = normalizeFollowUpRequestId(options.requestId); + + const gate = await resolveFollowUpMember({ + label: endpoint, + method: FOLLOW_UP_GATE_METHOD, + ...(options.getHost ? { getHost: options.getHost } : {}), + }); + if (!gate.ok) return gate; + let active; + try { + active = await gate.member(sessionId); + } catch (error) { + return mapFollowUpError(error, `${endpoint} (${FOLLOW_UP_GATE_METHOD})`); + } + const refused = classifyActiveTurn(active); + if (refused) return refused; + + const action = FOLLOW_UP_ACTIONS[behavior]; + const resolved = await resolveFollowUpMember({ + label: endpoint, + method: action.method, + ...(options.getHost ? { getHost: options.getHost } : {}), + }); + if (!resolved.ok) return resolved; + + if (behavior === "queue") { + let queued; + try { + queued = await resolved.member({ + id: sessionId, + content, + ...(attachments.length > 0 + ? { attachments: projectFollowUpAttachments(attachments, options.attachmentsLib) } + : {}), + ...(requestId ? { clientRequestId: requestId } : {}), + }); + } catch (error) { + return mapFollowUpError(error, `${endpoint} (${action.method})`); + } + // The response reports the ENGINE's commit, not the request: an item + // id and a position the engine never recorded would send the user + // looking for a queue entry that does not exist. + const record = queued && typeof queued === "object" ? queued : {}; + return { + ok: true, + payload: { + ok: true, + behavior: "queue", + itemId: typeof record.itemId === "string" ? record.itemId : null, + position: typeof record.position === "number" ? record.position : null, + status: typeof record.status === "string" ? record.status : null, + }, + }; + } + + let steered; + try { + steered = await resolved.member({ + sessionId, + source: FOLLOW_UP_STEER_SOURCE, + message: { + content, + ...(attachments.length > 0 ? { attachments: projectSteerAttachments(attachments) } : {}), + }, + producerId: FOLLOW_UP_STEER_PRODUCER_ID, + idempotencyKey: requestId ?? `${sessionId}:follow-up`, + }); + } catch (error) { + return mapFollowUpError(error, `${endpoint} (${action.method})`); + } + const outcome = steered && typeof steered === "object" ? steered : {}; + return { + ok: true, + payload: { + ok: true, + behavior: "steer", + turnId: typeof outcome.turnId === "string" ? outcome.turnId : null, + mode: typeof outcome.mode === "string" ? outcome.mode : null, + }, + }; +} + +// --------------------------------------------------------------------------- +// KNOWN DEBT +// --------------------------------------------------------------------------- +// +// 1. The default `acp` transport cannot take a follow-up, and refuses +// honestly. The running turn lives in an `mcode acp` subprocess, so +// `getActiveTurn` reports it with `locallyOwned: false` and this +// family answers 409 `turn_not_owned`; the composer restores the text +// and says why. The alternative — queueing into an engine that is not +// running the turn — was measured, not assumed: `submitQueued` commits +// the item and then wakes the dispatcher, which would start a SECOND +// turn for a session that already has one. When the chat path finishes +// its move to the in-process `runtime` transport, this gate starts +// passing on its own and nothing here has to change; no transport +// check was added, precisely so that moment needs no edit. +// +// 2. The queue's UI does not exist yet. A queued follow-up is committed +// with an item id and a position that nothing displays, and the queue +// cannot be inspected, reordered or cancelled from the browser. That is +// PB-13's scope, and until it lands the honest statement is that a +// queued message is invisible until the running turn ends and the +// dispatcher picks it up. +// +// 3. Steer reports no position and no receipt. `ConversationSteerResult` +// is an admission ACK (`turnId` + `mode`); whether the running agent +// actually read the text before its next step is the engine's +// internal, and the response does not claim otherwise. diff --git a/packages/webui/server/engine/model-source.js b/packages/webui/server/engine/model-source.js new file mode 100644 index 00000000..a034142d --- /dev/null +++ b/packages/webui/server/engine/model-source.js @@ -0,0 +1,593 @@ +// webui/server/engine/model-source.js +// +// Settings batch SB-1 (plan `doc/settings-batch-plan.md` §5 row 1, the +// PB-4 item): the MiniMax model SOURCE family — +// +// GET /api/model-source → cliService.getMiniMaxModelSource +// PUT /api/model-source → cliService.setMiniMaxModelSource +// PUT /api/model-source/api-key → cliService.upsertMiniMaxApiKey +// POST /api/model-source/test → cliService.testUserModel +// +// What this batch unlocks, and what it deliberately does not. +// +// UNLOCKED (3 of 4 tabs' worth): the 「用量与模型」 tab's source +// switcher, its 「使用中」 badge and the MiniMax API key row. All four +// engine methods existed the whole time +// (`packages/local-runtime-v2/src/local/cli-service.ts` — +// `getMiniMaxApiKeyStatus`, `getMiniMaxModelSource`, +// `setMiniMaxModelSource`, `upsertMiniMaxApiKey`, `testUserModel`) +// and had NO HTTP window: `settings-modal-port.tsx` carried +// `useState` for the switcher and two permanently `disabled` buttons, +// under a comment that said this repo has no +// `setMiniMaxModelSource` backend. That comment was true about the +// ROUTE and false about the CAPABILITY, which is the same distinction +// PB-1 drew for the session right-click actions. +// +// STILL A PLACEHOLDER (1), recorded at its own site rather than +// re-derived: the 「自动获取」 live per-key model catalogue in the +// add-model dialog. v2 exposes no per-provider catalogue query over an +// arbitrary key — `cli-service.ts#listModels` lists the models the +// application is ALREADY configured with, which is a different +// question — so the built-in preset directory stays the only honest +// implementation. See KNOWN DEBT 1. +// +// The gate, and why it is a PRESENCE gate rather than a declaration one. +// +// `requireCapability("modelProviders", …)` in the v2 cli-service names a +// capability that is NOT one of the 14 keys in `capabilities.js` +// (`ENGINE_CAPABILITY_KEYS` is a fixed audited matrix). Adding a 15th +// key for one batch would restate every provider declaration and the +// capability snapshot audit, and would claim a level nobody has +// re-audited. PB-1 met the identical situation for `pinSession` and +// resolved it the same way: gate on the LIVE member. So +// `MODEL_SOURCE_ENDPOINTS` is a table of `cliService` method names, and +// `resolveModelSourceMember` answers 501 when the booted host does not +// carry one. The three outcomes stay distinct: +// +// null host → 503 engine_host_unavailable +// host without the method → 501 engine_member_unavailable +// method that throws → mapped from the engine's own status/code +// +// Engine error mapping. The v2 model service throws +// `LocalModelProviderError(status, message, code)` (see +// `service/model-system/contracts.ts:396`). That status is the honest +// HTTP status for the refusal it describes — a 400 for an empty or +// masked key, a 404 for a model that is not configured — so it is +// forwarded, together with the machine-readable `code` the frontend +// branches on. Anything without a numeric engine status is OUR failure +// and becomes a 500 whose body carries no engine text: an exception +// message from an unknown thrower is the one place a credential could +// still be echoed, and the route has no reason to print it. +// +// Read-vs-write boot. All four rows go through `getEngineCatalogueHost()`, +// which BOOTS the runtime on first call. That is correct for a user +// action (the user opened the settings tab and pressed a button) and +// wrong for a first-paint read: `host.js` documents the asymmetry — +// "a write may boot what it needs; a read may only use what is already +// there" — and the settings tab is not on the boot path of `/api/state` +// or `/api/session-tree`, so this family is read on demand by the tab +// itself and never by a page-level fetch. KNOWN DEBT 2 records the +// residual risk if that ever changes. + +import { DEFAULT_ENGINE_PROVIDER_ID, getEngineProvider } from "./index.js"; + +/** + * The engine method each endpoint of this family needs. `member` is the + * host path every row shares, so a fifth row is a table edit and not a + * new branch in the resolver. + * + * `getMiniMaxApiKeyStatus` is deliberately NOT a row: it is a SECOND, + * OPTIONAL member read behind the GET (the source plus the key's masked + * projection). `readEngineModelSource` degrades that half instead of + * failing the whole read, because a host that can report which source + * is in use but not the key's masked form still answers the question + * the source switcher asks. See `readEngineModelSource`. + * + * @type {Readonly>>} + */ +export const MODEL_SOURCE_ENDPOINTS = Object.freeze({ + "GET /api/model-source": Object.freeze({ member: "cliService", method: "getMiniMaxModelSource" }), + "PUT /api/model-source": Object.freeze({ member: "cliService", method: "setMiniMaxModelSource" }), + "PUT /api/model-source/api-key": Object.freeze({ + member: "cliService", + method: "upsertMiniMaxApiKey", + }), + "POST /api/model-source/test": Object.freeze({ member: "cliService", method: "testUserModel" }), +}); + +/** The endpoint keys of this family, in `OWNED_ROUTES` order. */ +export const MODEL_SOURCE_ROUTES = Object.freeze(Object.keys(MODEL_SOURCE_ENDPOINTS)); + +/** The capability label the v2 cli-service declares these under. */ +export const MODEL_SOURCE_CAPABILITY = "modelProviders"; + +/** + * The two model sources the engine accepts. Mirrors + * `minimaxModelSource` in the v2 config (`packages/config`): `token_plan` + * is the managed Token Plan credential, `minimax_api_key` is the user's + * own BYOK key. The value is what the engine persists and what this + * family validates against, so an unknown value is a 400 here rather + * than a 400 three layers down. + * + * @type {Readonly>} + */ +export const MODEL_SOURCE_VALUES = Object.freeze({ token_plan: true, minimax_api_key: true }); + +/** The default the engine itself applies when nothing is persisted. */ +export const DEFAULT_MODEL_SOURCE = "token_plan"; + +/** + * The provider id the connectivity test runs against. The BYOK MiniMax + * provider is the only one of the two sources with a testable credential + * in the model service: `resolveTestTarget` routes `minimax_api` to + * `resolveMinimaxTestTarget` (needs `minimax_api.apiKey`), while the + * managed `minimax` id falls through to the CUSTOM-provider branch and + * 404s, because the Token Plan credential is not a model-service key at + * all. So the test endpoint tests the stored API key, whatever the + * active source is, and says so in its response — see KNOWN DEBT 3. + * + * @type {string} + */ +export const MINIMAX_API_PROVIDER_ID = "minimax_api"; + +function providerByTransport() { + // Built per call, never frozen at module scope: `engine/index.js` + // re-exports this module, so a module-level table would read + // `DEFAULT_ENGINE_PROVIDER_ID` while that binding is still in its + // temporal dead zone on a cold `import("./engine/index.js")`. Same + // reason, same wording as `session-context-actions.js`. + return Object.freeze({ runtime: DEFAULT_ENGINE_PROVIDER_ID }); +} + +/** + * Resolve the provider that answers the model-source family on + * `transport`, or `null` when none is registered yet. + * + * `null` means "no provider claims this transport", which is NOT a + * capability refusal: under the default `acp` transport the work still + * runs on the process-local `local-runtime-v2` host reached through + * `getEngineCatalogueHost()`. The refusal is the live member read. + * + * @param {string} transport + * @returns {{id: string, transport: string, capabilities: object}|null} + */ +export function resolveModelSourceProvider(transport) { + const providerId = providerByTransport()[transport]; + if (!providerId) return null; + return getEngineProvider(providerId); +} + +/** + * Report the gate for one endpoint of this family. It never throws for + * an engine limitation — the enforcement is the member read, which + * happens at dispatch time against the live host, so this function only + * records which declared provider (if any) would be behind it. + * + * A caller passing an endpoint key outside the table gets a plain + * Error: that is webui's own bug, and the HTTP layer must never turn it + * into a 501 that reads like an engine limitation. + * + * @param {string} endpoint A key of `MODEL_SOURCE_ENDPOINTS`. + * @param {string} transport The active transport. + * @returns {{endpoint: string, gate: string, provider: string|null, capability: string, subItem: string}} + */ +export function assertModelSourceCapability(endpoint, transport) { + const need = MODEL_SOURCE_ENDPOINTS[endpoint]; + if (need === undefined) { + const err = new Error( + `assertModelSourceCapability: "${endpoint}" is not part of the model source family ` + + `(known: ${MODEL_SOURCE_ROUTES.join(", ")})`, + ); + err.code = "unknown_model_source_endpoint"; + throw err; + } + const provider = resolveModelSourceProvider(transport); + return { + endpoint, + // `member-presence`, never `capability`: the four methods hang off + // the v2 cli-service's own `modelProviders` requirement, which is + // not one of the 14 declared keys. See the module header. + gate: "member-presence", + provider: provider ? provider.id : null, + capability: MODEL_SOURCE_CAPABILITY, + subItem: need.method, + }; +} + +/** + * @typedef {{ok: true, member: Function, host: object}} ResolvedModelSourceMember + * @typedef {{ok: false, code: "engine_host_unavailable"|"engine_member_unavailable", status: 503|501, error: string}} ResolvedModelSourceFailure + */ + +/** + * Read one `cliService` method off the booted catalogue host. + * + * `getHost` is an injection seam carried on the option bag ITSELF, not + * nested under a `deps` key: the tests hand in a fake host, and + * production falls through to the process-wide getter. `peek` picks the + * non-booting getter for callers that must not start a runtime — this + * family does not use it today (see the read-vs-write note in the module + * header) and the seam exists so a future caller does not have to + * re-open the resolver to reach it. Keeping the seam flat is what lets a + * route forward its own optional fourth argument with a single spread. + * + * @param {object} options + * @param {string} options.endpoint Endpoint key, for the failure text. + * @param {string} [options.method] Method name; defaults to the endpoint's own. + * @param {Function} [options.getHost] Host getter override. + * @param {boolean} [options.peek] Use the non-booting getter. + * @returns {Promise} + */ +export async function resolveModelSourceMember(options) { + const { endpoint } = options; + const declared = MODEL_SOURCE_ENDPOINTS[endpoint]; + const method = options.method || (declared ? declared.method : options.method); + let getHost; + if (options.getHost) { + getHost = options.getHost; + } else if (options.peek) { + getHost = (await import("./host.js")).peekEngineCatalogueHost; + } else { + getHost = (await import("./host.js")).getEngineCatalogueHost; + } + let host; + try { + host = await getHost(); + } catch (e) { + // A throwing host getter is caught here (and nowhere else in the + // facade) so the route can answer 503 with a body instead of an + // unhandled rejection. + return { + ok: false, + code: "engine_host_unavailable", + status: 503, + error: e && e.message ? e.message : String(e), + }; + } + if (!host) { + return { + ok: false, + code: "engine_host_unavailable", + status: 503, + error: `${endpoint}: the engine catalogue host is not available`, + }; + } + if (typeof host.cliService?.[method] !== "function") { + return { + ok: false, + code: "engine_member_unavailable", + status: 501, + error: `${endpoint}: host.cliService.${method} is not a function`, + }; + } + return { ok: true, member: host.cliService[method].bind(host.cliService), host }; +} + +/** + * Map a thrown engine error onto the HTTP surface. + * + * The v2 model service throws `LocalModelProviderError(status, message, + * code)`, and that status is the honest answer for the refusal it + * describes. Only errors that carry a numeric status in the 4xx/5xx + * range are forwarded; everything else becomes a 500 with a fixed + * message, because an exception string from an unknown thrower is the + * one place a credential could still be echoed onto the wire. + * + * @param {unknown} error + * @param {string} endpoint + * @returns {{ok: false, code: string, error: string, status: number}} + */ +export function mapModelSourceError(error, endpoint) { + const status = error && typeof error.status === "number" ? error.status : 0; + if (status >= 400 && status <= 599) { + return { + ok: false, + code: typeof error.code === "string" ? error.code : "ENGINE_REFUSED", + error: typeof error.message === "string" ? error.message : `${endpoint} failed`, + status, + }; + } + return { + ok: false, + code: "engine_error", + error: `${endpoint}: the engine call failed`, + status: 500, + }; +} + +/** + * The masked projection of the stored key, normalised to the shape the + * frontend branches on. `maskedApiKey` is what the engine returns (a + * mask, never plaintext — `getMinimaxApiKeyStatus` masks through + * `service/model-system/secret.js`); it is passed through untouched so + * webui invents no second masking rule that could drift from the + * engine's. + * + * @param {object} status The engine's `getMiniMaxApiKeyStatus` answer. + * @returns {{available: true, hasKey: boolean, masked: string|null, testState: string|null, lastTestedAtMs: number|null}} + */ +export function publicApiKeyStatus(status) { + const record = status && typeof status === "object" ? status : {}; + const cached = record.cachedStatus && typeof record.cachedStatus === "object" ? record.cachedStatus : {}; + const lastTested = typeof cached.lastTestedAt === "number" ? cached.lastTestedAt : null; + return { + available: true, + hasKey: record.hasApiKey === true, + masked: typeof record.maskedApiKey === "string" ? record.maskedApiKey : null, + testState: typeof cached.state === "string" ? cached.state : null, + lastTestedAtMs: lastTested, + }; +} + +/** The shape returned when the optional key-status member is absent. */ +export function unavailableApiKeyStatus() { + return { available: false, hasKey: false, masked: null, testState: null, lastTestedAtMs: null }; +} + +/** + * GET /api/model-source — the active source plus the key's masked status. + * + * The two halves have different failure semantics, which is why they are + * two reads: the SOURCE is required (a caller that cannot learn which + * source is in use cannot render the switcher at all, so it 501s), and + * the KEY STATUS is optional (a host that can answer the source but not + * the masked form still renders a truthful switcher with a key block + * that says it is unavailable, rather than failing the whole read). + * + * @param {object} [options] + * @param {Function} [options.getHost] Passed to `resolveModelSourceMember`. + * @returns {Promise<{ok: true, payload: object}|{ok: false, code: string, error: string, status: number}>} + */ +export async function readEngineModelSource(options = {}) { + const source = await resolveModelSourceMember({ + endpoint: "GET /api/model-source", + ...options, + }); + if (!source.ok) return source; + const keyStatus = await resolveModelSourceMember({ + endpoint: "GET /api/model-source", + method: "getMiniMaxApiKeyStatus", + ...options, + }); + let sourceValue; + try { + sourceValue = await source.member(); + } catch (error) { + return mapModelSourceError(error, "GET /api/model-source"); + } + if (typeof sourceValue !== "string" || !MODEL_SOURCE_VALUES[sourceValue]) { + // The engine's own type says two values. A third one is a contract + // drift, and rendering it as a selected source would put the UI in + // a state it has no way back out of. + return { + ok: false, + code: "UNKNOWN_MODEL_SOURCE", + error: `GET /api/model-source: the engine reported an unknown model source`, + status: 502, + }; + } + // A key-status read that THROWS lands in the same degraded block as a + // missing one, and the two are resolved BEFORE normalisation — + // `publicApiKeyStatus(null)` is a valid record that says `hasKey: + // false`, which is exactly the claim this branch must not make: it + // would tell a user with a stored key that they have none. + const keyRecord = keyStatus.ok ? await callKeyStatus(keyStatus.member) : null; + return { + ok: true, + payload: { + ok: true, + source: sourceValue, + apiKey: keyRecord === null ? unavailableApiKeyStatus() : publicApiKeyStatus(keyRecord), + }, + }; +} + +/** + * Read the optional key status, mapping ITS throw into the degraded + * block rather than into the response. A key-status read that throws is + * a partial answer, not a failed read — hence `null` ("could not tell + * you") rather than an empty record ("there is no key"). + * + * @param {Function} member + * @returns {Promise} + */ +async function callKeyStatus(member) { + try { + return await member(); + } catch { + return null; + } +} + +/** + * PUT /api/model-source — switch the active source. + * + * The engine refuses the `minimax_api_key` direction when no key is + * configured (`resolveMinimaxTestTarget` throws `NO_API_KEY`), and that + * refusal is forwarded with its own status and code so the UI can say + * "save a key first" instead of a generic failure. + * + * @param {object} options + * @param {unknown} options.source The requested source, validated here. + * @param {Function} [options.getHost] Host getter override. + * @returns {Promise<{ok: true, payload: object}|{ok: false, code: string, error: string, status: number}>} + */ +export async function applyEngineModelSource(options = {}) { + const endpoint = "PUT /api/model-source"; + const source = typeof options.source === "string" ? options.source.trim() : ""; + if (!MODEL_SOURCE_VALUES[source]) { + return { + ok: false, + code: "INVALID_MODEL_SOURCE", + error: `PUT /api/model-source: source must be one of ${Object.keys(MODEL_SOURCE_VALUES).join(", ")}`, + status: 400, + }; + } + const resolved = await resolveModelSourceMember({ endpoint, ...options }); + if (!resolved.ok) return resolved; + let written; + try { + written = await resolved.member({ source }); + } catch (error) { + return mapModelSourceError(error, endpoint); + } + // The engine echoes what it persisted. The response reports the + // engine's answer, not the request, so a future engine that + // normalises the value cannot leave this route claiming a source the + // config does not carry. + return { + ok: true, + payload: { + ok: true, + source: typeof written === "string" && MODEL_SOURCE_VALUES[written] ? written : source, + }, + }; +} + +/** + * PUT /api/model-source/api-key — upsert the BYOK key, optionally + * switching to it in the same call (`saveAndUse`, the engine's own + * flag). + * + * The KEEP-KEY sentinel, and why this family needs one at all. The + * masked projection is the only key shape the GET can return, so a UI + * that round-trips its own state would post the mask back and the + * engine would refuse it (`assertValidRawApiKey` rejects anything + * carrying the mask marker — `INVALID_API_KEY`). The provider family + * solved the same problem with an empty-string sentinel + * (`lib/providers-config.js#applyKeepKeyConvention`, ticket 03) and this + * endpoint adopts that convention rather than inventing a second one: an + * absent or empty `apiKey` means "do not change the stored key". + * + * A keep is answered 200 with `changed: false` and the CURRENT masked + * status, having called no engine write. The engine is never asked to + * re-assert a value it already holds, so a keep is free and cannot + * fail — and the response still tells the UI what is stored, so the + * badge does not have to be guessed from local state. + * + * @param {object} options + * @param {unknown} options.apiKey Raw key, or absent/empty for keep. + * @param {unknown} [options.saveAndUse] + * @param {Function} [options.getHost] Host getter override. + * @returns {Promise<{ok: true, payload: object}|{ok: false, code: string, error: string, status: number}>} + */ +export async function saveEngineModelSourceApiKey(options = {}) { + const endpoint = "PUT /api/model-source/api-key"; + const rawKey = typeof options.apiKey === "string" ? options.apiKey.trim() : ""; + if (!rawKey) { + const current = await readEngineModelSource(options); + if (!current.ok) return current; + return { + ok: true, + payload: { ...current.payload, changed: false, saveAndUse: false }, + }; + } + const resolved = await resolveModelSourceMember({ endpoint, ...options }); + if (!resolved.ok) return resolved; + try { + await resolved.member({ + apiKey: rawKey, + ...(options.saveAndUse === true ? { saveAndUse: true } : {}), + }); + } catch (error) { + return mapModelSourceError(error, endpoint); + } + // Re-read through the engine rather than echoing the write. The write + // returns a full provider view, but the switcher's contract is the + // source plus the key's masked projection, and reading it back is + // what proves the two agree — a response assembled from the request + // would say "saved" even if the engine had stored something else. + const after = await readEngineModelSource(options); + if (!after.ok) return after; + return { ok: true, payload: { ...after.payload, changed: true, saveAndUse: options.saveAndUse === true } }; +} + +/** + * POST /api/model-source/test — connectivity probe for the STORED key. + * + * Two things this endpoint deliberately cannot do, both recorded as + * KNOWN DEBT 3 rather than papered over: + * + * 1. It tests the stored key, not the one in the input. The v2 + * `testUserModel` takes no key override (only + * `discoverCandidate` / `saveCandidate` carry one), so a probe of + * an unsaved key has no contract. The UI disables the button + * while the input holds an unsaved value. + * 2. It always probes the `minimax_api` provider. The managed + * Token Plan source is not a model-service credential, so there is + * nothing on this surface to probe it with. + * + * `modelId` is optional and the engine falls back to the first + * configured MiniMax model when it is absent; passing an unknown id is + * the engine's 404 and is forwarded as one. + * + * @param {object} options + * @param {unknown} [options.modelId] + * @param {Function} [options.getHost] Host getter override. + * @returns {Promise<{ok: true, payload: object}|{ok: false, code: string, error: string, status: number}>} + */ +export async function testEngineModelSourceModel(options = {}) { + const endpoint = "POST /api/model-source/test"; + const modelId = typeof options.modelId === "string" ? options.modelId.trim() : ""; + const resolved = await resolveModelSourceMember({ endpoint, ...options }); + if (!resolved.ok) return resolved; + let outcome; + try { + outcome = await resolved.member({ + providerId: MINIMAX_API_PROVIDER_ID, + ...(modelId ? { modelId } : {}), + }); + } catch (error) { + return mapModelSourceError(error, endpoint); + } + // The engine answers `{success, status}` (the application layer's + // framing of `ModelProviderTestOutcome`). A probe that came back + // `success: false` is a successful REQUEST whose subject failed, so + // it stays 200 and the UI renders the status — the same split + // `POST /api/providers/test` makes between a 400 body and a 502 one. + const record = outcome && typeof outcome === "object" ? outcome : {}; + const status = record.status && typeof record.status === "object" ? record.status : {}; + return { + ok: true, + payload: { + ok: true, + success: record.success === true, + providerId: MINIMAX_API_PROVIDER_ID, + modelId: modelId || null, + tested: "stored_key", + status: { + state: typeof status.state === "string" ? status.state : null, + lastTestedAt: typeof status.lastTestedAt === "number" ? status.lastTestedAt : null, + lastErrorCode: typeof status.lastErrorCode === "string" ? status.lastErrorCode : null, + lastErrorMessage: typeof status.lastErrorMessage === "string" ? status.lastErrorMessage : null, + }, + }, + }; +} + +// --------------------------------------------------------------------------- +// KNOWN DEBT +// --------------------------------------------------------------------------- +// +// 1. Live per-key model catalogue. The add-model dialog's 「自动获取」 +// still resolves against the built-in preset directory because v2 has +// no per-provider catalogue query for an arbitrary key +// (`cli-service.ts#listModels` answers a different question — the +// models this application is already configured with). This batch did +// not invent a route for it, and the dialog's preset answer is the +// honest one until an engine method exists. +// +// 2. Boot-on-read. All four rows reach the engine through +// `getEngineCatalogueHost()`, which boots the runtime on first call. +// The settings tab fetches on demand (the user opened the tab), which +// is the write-side contract `host.js` documents. If a future change +// moves this fetch onto a page-level or boot-time path, it must move +// to `peekEngineCatalogueHost()` — the `peek` seam on +// `resolveModelSourceMember` is already there for that. +// +// 3. Test scope. `POST /api/model-source/test` probes the STORED key on +// the `minimax_api` provider. It cannot probe an unsaved key (no +// engine override) and cannot probe the Token Plan source (not a +// model-service credential). Both are engine-contract limits, not +// route choices, and the response says which credential it used +// (`tested: "stored_key"`) so the UI never implies otherwise. diff --git a/packages/webui/server/lib/authorize.js b/packages/webui/server/lib/authorize.js index 6478a871..81ede063 100644 --- a/packages/webui/server/lib/authorize.js +++ b/packages/webui/server/lib/authorize.js @@ -18,6 +18,24 @@ // destructive HTTP request open for the full budget. See // `state-bus.js#hasDecisionListener`. // +// P19 extends the same reasoning one step further, to the OTHER way a +// decision can be unreachable: a request the router could not attribute +// to any client at all. `?cid=` is absent — a curl, a script, a caller +// that forgot `withClientQuery` — and `hasDecisionListener("")` then +// answers "somebody is listening", because an empty cid is the +// BROADCAST target and a connected browser really can see (and answer) +// the modal. The request then waits the full five minutes for a human +// who has no idea a modal is open, and a destructive endpoint looks +// exactly like a hang: open socket, no status, no body. +// +// So "listening" and "answerable by the requester" are two different +// questions, and only the first one is what the broadcast rule was +// ever about. `hasDecidableRequester` asks the second. It is opt-in +// (`opts.requireRequester`) because the difference is load-bearing in +// the other direction too: `startup.cleanup` asks with an empty cid ON +// PURPOSE — no HTTP requester exists at boot, any tab may decide — and +// must keep broadcasting. +// // Audit: every approve / reject / timeout writes one NDJSON event via // the static import of `server/lib/events.js`. The DECISION-OUTCOME // audit write (auth.approve/reject/timeout/cancelled) is @@ -127,15 +145,43 @@ function _tryWriteEvent(evt) { // ---------- core API ---------- +/** + * Whether a request carries a client id its human decision can be + * attributed to. + * + * The server keys per-client session and state on `?cid=` (see + * `state-bus.js#getCidFromReq`), and the browser client puts it on + * EVERY api call (`webapp/lib/cid.ts#withClientQuery`). An empty cid + * is therefore not "the anonymous user" — it is a request no tab + * claimed: a curl, a script, a caller that forgot the query. No modal + * can be shown to anybody who asked for it, so nobody can say they + * meant it. + * + * Exported because the DELETE contract names it: P13's "unreachable + * channel ends at once" promise was written against `hasDecisionListener` + * and covers the offline-tab case only. This is the other half. + * + * @param {unknown} cid + * @returns {boolean} + */ +export function hasDecidableRequester(cid) { + return typeof cid === "string" && cid.trim() !== ""; +} + // authorize(action, ctx, opts) → Promise<{approved, decidedBy, decidedAt}> // action: one of AUTHORIZE_ACTIONS (throws on invalid) // ctx: { cid: string, [any extra context] } — cid is optional; // empty cid = broadcast to all SSE clients -// opts: { timeoutMs?: number, metadata?: object, bypass?: boolean } +// opts: { timeoutMs?: number, metadata?: object, bypass?: boolean, +// requireRequester?: boolean } // bypass=true skips the user gate (only for trusted internal // callers — e.g. LAN token rotation triggered by the // settings card modal that already presented its own // confirmation UI). +// requireRequester=true additionally declares this request +// MUST be attributable to a client id, and turns "no cid" into +// the same at-once fail-closed answer an empty decision channel +// gets. See `hasDecidableRequester` above. // // There is deliberately NO test-mode auto-approve. A prior branch // inspected Node's runtime flag vector for --test / @@ -180,6 +226,46 @@ export function authorize(action, ctx = {}, opts = {}) { const expiresAt = requestedAt + timeoutMs; const safeCtx = ctx && typeof ctx === "object" ? ctx : {}; + // P19: the caller declares this request must belong to a client, and + // it does not. Same answer, same shape, same audit frame as the + // empty-channel case below — the decision is unavailable, so the + // fail-closed answer is already determined and waiting for it only + // turns a 403 into a five-minute hang. Checked BEFORE the channel + // question, because a broadcast channel says nothing about whether + // this request has an owner. + if ( + opts.requireRequester === true && + !hasDecidableRequester(cid) && + !_inProcessDeciderAttached + ) { + const decidedAt = Date.now(); + _tryWriteEvent({ + kind: "auth.unreachable", + target: action, + cid: null, + data: { + requestId, + requestedAt, + expiresAt, + timeoutMs, + reason: "no_requester", + metadata: opts.metadata || null, + }, + }); + // Same frame the timeout path emits. A request with no owner has no + // modal to close anywhere, so this is belt-and-braces for a tab that + // replayed a pending request onto a reconnect. + try { + pushAuthDecision({ requestId, approved: false, decidedBy: "timeout" }); + } catch {} + return Promise.resolve({ + approved: false, + decidedBy: "timeout", + decidedAt, + reason: "no_requester", + }); + } + return new Promise((resolve) => { // Can this request be decided at all? The gate is a push to a live // SSE response, so "no connected client" is not a slow answer, it is diff --git a/packages/webui/server/routes/export.js b/packages/webui/server/routes/export.js index fb5baf4c..19e9bd19 100644 --- a/packages/webui/server/routes/export.js +++ b/packages/webui/server/routes/export.js @@ -314,14 +314,23 @@ export async function handleExport(req, res, ctx) { // B03: per-request authorize. Export is non-destructive but exposes // conversation history — same gate class as session.delete per the // C06 spec. Tests drive the decision via test/_setup.js#withDecisions. + // + // P19: `requireRequester` for the same reason DELETE carries it — a + // curl with no `?cid=` has no owner to show the modal to, and without + // this the gate broadcasts to whoever happens to be connected and then + // waits out the full 300000ms budget. let authResult = null; try { - authResult = await authorize("session.export", { - cid, - targetSessionId: id, - format, - download, - }); + authResult = await authorize( + "session.export", + { + cid, + targetSessionId: id, + format, + download, + }, + { requireRequester: true }, + ); } catch (e) { return _jsonError(res, 500, "authorize error", { detail: e.message }); } diff --git a/packages/webui/server/routes/follow-up.js b/packages/webui/server/routes/follow-up.js new file mode 100644 index 00000000..641c7afd --- /dev/null +++ b/packages/webui/server/routes/follow-up.js @@ -0,0 +1,155 @@ +// webui/server/routes/follow-up.js +// +// The follow-up message family (settings batch SB-4, plan +// `doc/settings-batch-plan.md` §5 row 4): +// +// POST /api/follow-up — hand a message to the engine while a turn runs +// +// This file is the HTTP shape and nothing else. The behaviour whitelist, +// the ownership gate, the two engine projections and the error mapping +// live in `../engine/follow-up.js`, which is where every other family +// keeps them. What stays here is what only an HTTP layer can own: the +// request body read, the attachment resolution (client paths are +// untrusted, exactly as in `routes/chat.js#handleSend`), the status line +// and the JSON body. +// +// WHY THE SESSION ID IS NOT TAKEN FROM THE BODY. `/api/send` reads the +// engine session id from the per-cid conversation state +// (`ctx.cs.mcodeSessionId`) and the client sends only text. A follow-up is +// the same message to the same conversation, so it takes the same source +// for the same two reasons: a client that names its own session could aim +// a message at a conversation the tab is not showing, and the id in the +// body would be one more unvalidated engine identifier on the wire. +// +// The consequence is honest rather than convenient: a browser that has no +// live conversation for this cid has nothing to follow up on, and gets a +// 400 naming that, instead of a queued message in a session nobody is +// looking at. + +import { readJson } from "../lib/read-json.js"; +import * as attachmentsLib from "../lib/attachments.js"; +import { sendEngineFollowUp } from "../engine/follow-up.js"; + +/** + * Write one answer, whether it succeeded or not. The engine facade + * already decided the status and the code; this only serialises it and + * never invents a field the facade did not produce. + * + * @param {object} res + * @param {{ok: boolean, status?: number, payload?: object, code?: string, error?: string}} result + * @returns {number} The status written. + */ +function writeResult(res, result) { + if (result.ok) { + res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" }); + res.end(JSON.stringify(result.payload)); + return 200; + } + const status = Number.isInteger(result.status) ? result.status : 500; + res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" }); + res.end( + JSON.stringify({ + ok: false, + code: result.code || "engine_call_failed", + error: result.error || "the engine call failed", + }), + ); + return status; +} + +/** + * Reject an optional field that is present with the wrong type. + * + * `lib/read-json.js` already normalises an absent body to `{}`, so the + * only thing that CAN arrive wrong is a field's type — and silently + * treating `{"behavior": 7}` as "no behaviour given" would turn a client's + * bug into a 400 that names the wrong problem. + * + * @param {object} res + * @param {unknown} value + * @param {string} field + * @param {string} expected `"string"`, `"boolean"` or `"array"`. + * @returns {number|null} The status written, or `null` when acceptable. + */ +function rejectWrongType(res, value, field, expected) { + if (value === undefined || value === null) return null; + const actual = Array.isArray(value) ? "array" : typeof value; + if (actual === expected) return null; + res.writeHead(400, { "Content-Type": "application/json; charset=utf-8" }); + res.end( + JSON.stringify({ + ok: false, + code: "BAD_FIELD_TYPE", + error: `${field} must be ${expected === "array" ? "an array" : `a ${expected}`}`, + }), + ); + return 400; +} + +/** + * POST /api/follow-up — queue or steer one message into the running turn. + * + * Body: `{ "behavior": "queue" | "steer", "content": string, + * "attachments"?: string[], "requestId"?: string }`. + * + * 200 `{ok:true, behavior, itemId, position, status}` for a queue, or + * `{ok:true, behavior, turnId, mode}` for a steer — the engine's own + * answer, never an echo of the request. + * + * 400 `invalid_follow_up_behavior` for anything but the two engine + * actions. The OFF position never reaches here: it is the composer + * rendering no send control, and a request that carries it is a client + * that ignored the setting, not a user choice to honour here. + * + * 409 `no_active_turn` / `turn_not_owned` — the two facts the ownership + * gate can find (see `../engine/follow-up.js`). Both are refusals: the + * message was NOT delivered, and the composer puts the text back. + * + * Every handler takes an optional FOURTH argument forwarded to the engine + * facade's option bag (`{getHost, attachmentsLib}` — nothing in + * production, a fake host in `test/routes/follow-up.test.js`). + * `app.js#invokeHandler` passes three arguments, so the seam costs + * production nothing and keeps the suite hermetic: no runtime boot, no + * network, no tmpdir. + */ +export async function handleFollowUp(req, res, ctx, deps = {}) { + const parsed = await readJson(req); + const wrongBehavior = rejectWrongType(res, parsed.behavior, "behavior", "string"); + if (wrongBehavior !== null) return wrongBehavior; + const wrongContent = rejectWrongType(res, parsed.content, "content", "string"); + if (wrongContent !== null) return wrongContent; + const wrongAttachments = rejectWrongType(res, parsed.attachments, "attachments", "array"); + if (wrongAttachments !== null) return wrongAttachments; + const wrongRequestId = rejectWrongType(res, parsed.requestId, "requestId", "string"); + if (wrongRequestId !== null) return wrongRequestId; + + // Same validation the send route runs, same reason: a path from the + // browser is untrusted, and a silently dropped chip is the bug this + // replaced. A rejected or dropped path is still a delivery, so the + // response says how many — the message is not refused for a bad chip. + const { attachments } = attachmentsLib.resolveAttachments(parsed.attachments); + const sessionId = (ctx && ctx.cs && ctx.cs.mcodeSessionId) || ""; + if (!sessionId) { + res.writeHead(400, { "Content-Type": "application/json; charset=utf-8" }); + return res.end( + JSON.stringify({ + ok: false, + code: "no_active_conversation", + error: + "POST /api/follow-up: this tab has no live conversation with an engine session, so there is nothing to follow up on", + }), + ); + } + return writeResult( + res, + await sendEngineFollowUp({ + ...deps, + behavior: parsed.behavior, + sessionId, + content: parsed.content, + attachments, + requestId: parsed.requestId, + attachmentsLib, + }), + ); +} diff --git a/packages/webui/server/routes/model-source.js b/packages/webui/server/routes/model-source.js new file mode 100644 index 00000000..7a543290 --- /dev/null +++ b/packages/webui/server/routes/model-source.js @@ -0,0 +1,251 @@ +// webui/server/routes/model-source.js +// +// The 「用量与模型」 tab's model-source family (settings batch SB-1, plan +// `doc/settings-batch-plan.md` §5 row 1): +// +// GET /api/model-source — active source + masked key status +// PUT /api/model-source — switch the source +// PUT /api/model-source/api-key — upsert the key (empty = keep) +// POST /api/model-source/test — connectivity probe, stored key +// +// This file is the HTTP shape and nothing else: every gate, every engine +// call, the source whitelist, the keep-key convention and the error +// mapping live in `../engine/model-source.js`, which is where the rest of +// the families keep them. What stays here is what only an HTTP layer can +// own — the request body read, the status line, the JSON body, and the +// response's own invariant: an apiKey never appears in cleartext on any +// path, because the engine hands this route a mask and the route has no +// code path that could unmask it. +// +// REST shape, and why it is REST rather than the `/api/settings` patch +// this tab's other rows use. The source and the key are engine-owned +// state with their own engine methods and their own validation +// (`minimaxModelSource` in the engine's `config.yaml`, validated by +// `setMinimaxModelSource`), not webui settings the `POST /api/settings` +// handler mirrors to disk. A GET/PUT pair on the resource, with the key +// write on a sub-resource, keeps "the source" and "the key" as the two +// things they are — and the key's sub-path is what lets its handler +// carry the keep-key sentinel without a body flag that would read as +// "clear my key" by accident. +// +// Every handler takes an optional FOURTH argument, forwarded to the +// engine facade's option bag (`{getHost}` — nothing in production, a fake +// host in `test/routes/model-source.test.js`). `app.js#invokeHandler` +// passes three arguments, so the seam costs production nothing and keeps +// the suite hermetic: no runtime boot, no network, no tmpdir. + +import { readJson } from "../lib/read-json.js"; +import { + MODEL_SOURCE_ENDPOINTS, + MODEL_SOURCE_ROUTES, + assertModelSourceCapability, + applyEngineModelSource, + readEngineModelSource, + saveEngineModelSourceApiKey, + testEngineModelSourceModel, +} from "../engine/model-source.js"; + +/** + * The active transport, read through a function so a test can move it + * between two calls and the module-scope import cost stays zero — the + * same rule every other gated route follows. + * + * @returns {string} + */ +function activeTransport() { + return process.env.MCODE_WEBUI_TRANSPORT || "acp"; +} + +/** + * Write one answer, whether it succeeded or not. The engine facade + * already decided the status and the code; this only serialises it, and + * it never invents a field the facade did not produce. + * + * @param {object} res + * @param {{ok: boolean, status?: number, payload?: object, code?: string, error?: string}} result + * @returns {number} The status written. + */ +function writeResult(res, result) { + if (result.ok) { + res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" }); + res.end(JSON.stringify(result.payload)); + return 200; + } + const status = Number.isInteger(result.status) ? result.status : 500; + res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" }); + res.end( + JSON.stringify({ + ok: false, + code: result.code || "engine_call_failed", + error: result.error || "the engine call failed", + }), + ); + return status; +} + +/** + * Reject an optional field that is present with the wrong type. + * + * Note what is NOT here: a "body must be a JSON object" guard. The + * shared reader (`lib/read-json.js`) already normalises an empty body, + * an array and a primitive to `{}`, so such a guard could never fire — + * it would be a branch that reads like a contract and enforces nothing. + * What CAN arrive is a field of the wrong type (`{"apiKey": 123}`), and + * silently treating that as "no key given" would turn a client's bug + * into a successful keep. So the type is what gets checked. + * + * @param {object} res + * @param {unknown} value The field as parsed. + * @param {string} field Field name, for the error text. + * @param {"string"|"boolean"} expected The type the field must have. + * @returns {number|null} The status written, or `null` when acceptable. + */ +function rejectWrongType(res, value, field, expected) { + if (value === undefined || value === null) return null; + if (typeof value === expected) return null; + res.writeHead(400, { "Content-Type": "application/json; charset=utf-8" }); + res.end( + JSON.stringify({ + ok: false, + code: "BAD_FIELD_TYPE", + error: `${field} must be ${expected === "string" ? "a string" : "a boolean"}`, + }), + ); + return 400; +} + +/** + * GET /api/model-source — the read the tab opens on. + * + * 200: + * { + * ok: true, + * source: "token_plan" | "minimax_api_key", + * apiKey: { available, hasKey, masked, testState, lastTestedAtMs } + * } + * + * `apiKey.masked` is the engine's own mask (`service/model-system/ + * secret.js`), passed through unchanged. `available: false` means the + * host could not report the key half at all, which the UI renders as an + * unavailable block rather than as "no key stored" — those are + * different facts and collapsing them would show a user with a saved key + * that it has none. + * + * 501 when the booted host has no `getMiniMaxModelSource`, 503 when + * there is no host at all. + */ +export async function handleGetModelSource(_req, res, _ctx, deps = {}) { + const endpoint = "GET /api/model-source"; + assertModelSourceCapability(endpoint, activeTransport()); + return writeResult(res, await readEngineModelSource(deps)); +} + +/** + * PUT /api/model-source — switch the active source. + * + * Body: `{ "source": "token_plan" | "minimax_api_key" }`. + * + * 400 `INVALID_MODEL_SOURCE` for a missing or unknown `source` — the + * facade checks the whitelist, so a typo costs no runtime round trip and + * the engine re-checks the same two values before it persists them. + * 400 `NO_API_KEY` when the engine refuses the BYOK direction because no + * key is stored; that code is the UI's cue to send the user to the key + * field rather than to an error toast. + */ +export async function handleSetModelSource(req, res, _ctx, deps = {}) { + const endpoint = "PUT /api/model-source"; + const parsed = await readJson(req); + const wrongType = rejectWrongType(res, parsed.source, "source", "string"); + if (wrongType !== null) return wrongType; + assertModelSourceCapability(endpoint, activeTransport()); + return writeResult(res, await applyEngineModelSource({ ...deps, source: parsed.source })); +} + +/** + * PUT /api/model-source/api-key — upsert the BYOK key. + * + * Body: `{ "apiKey": "", "saveAndUse": true|false }`. + * + * The keep-key sentinel: an absent or empty `apiKey` keeps the stored + * key and answers 200 `{changed: false}` with the current masked status, + * having called no engine write. It exists because the GET can only ever + * return a MASK, and the engine rejects a mask as a key + * (`INVALID_API_KEY`) — a UI that round-tripped its own masked state + * would turn every save into a failure. Same convention, same reason and + * the same empty-string spelling as `PUT /api/providers`. + * + * `saveAndUse` is the engine's own flag: it writes the key AND switches + * the source to `minimax_api_key` in one transaction, so the UI never + * has to make that a two-request dance it could get half-applied. + * + * 400 `BAD_FIELD_TYPE` for a non-string `apiKey` or `saveAndUse`; the + * key's own validity (empty, masked) is the engine's call and its status + * is forwarded. + */ +export async function handlePutModelSourceApiKey(req, res, _ctx, deps = {}) { + const endpoint = "PUT /api/model-source/api-key"; + const parsed = await readJson(req); + const wrongKey = rejectWrongType(res, parsed.apiKey, "apiKey", "string"); + if (wrongKey !== null) return wrongKey; + const wrongFlag = rejectWrongType(res, parsed.saveAndUse, "saveAndUse", "boolean"); + if (wrongFlag !== null) return wrongFlag; + assertModelSourceCapability(endpoint, activeTransport()); + return writeResult( + res, + await saveEngineModelSourceApiKey({ + ...deps, + apiKey: parsed.apiKey, + saveAndUse: parsed.saveAndUse, + }), + ); +} + +/** + * POST /api/model-source/test — connectivity probe for the STORED key. + * + * Body: `{ "modelId"?: "MiniMax-M3" }` — the engine falls back to the + * first configured MiniMax model when it is absent. + * + * 200 in BOTH cases, deliberately. `success: false` is a successful + * request about a failed subject (the same split + * `POST /api/providers/test` makes), and the UI renders the engine's + * status rather than an error toast. What comes back as a non-200 is a + * refusal to even try: no host (503), no such method (501), or the + * engine's own 400 `NO_API_KEY` when nothing is stored to test. + * + * The probe always runs against the STORED key — v2's `testUserModel` + * takes no key override — and the response says so + * (`tested: "stored_key"`), so no UI can imply it probed an unsaved + * key. See `engine/model-source.js` KNOWN DEBT 3. + * + * The body is optional (the probe has no required field, and refusing an + * absent body would make a plain click need a fabricated `{}`); + * `modelId` must be a string when it is present. + */ +export async function handleTestModelSource(req, res, _ctx, deps = {}) { + const endpoint = "POST /api/model-source/test"; + const parsed = await readJson(req); + const wrongType = rejectWrongType(res, parsed.modelId, "modelId", "string"); + if (wrongType !== null) return wrongType; + assertModelSourceCapability(endpoint, activeTransport()); + return writeResult(res, await testEngineModelSourceModel({ ...deps, modelId: parsed.modelId })); +} + +// Test-only helpers. The family has no tmp-dir or network footprint, so +// the surface a test needs is the body shape a synthetic request must +// carry and the table it dispatches through. + +/** + * @returns {string[]} The endpoint keys, in `OWNED_ROUTES` order. + */ +export function _modelSourceRoutes() { + return [...MODEL_SOURCE_ROUTES]; +} + +/** + * @param {string} endpoint + * @returns {{member: string, method: string}|undefined} + */ +export function _modelSourceDeclaration(endpoint) { + return MODEL_SOURCE_ENDPOINTS[endpoint]; +} diff --git a/packages/webui/server/routes/sessions.js b/packages/webui/server/routes/sessions.js index 6c054690..41402159 100644 --- a/packages/webui/server/routes/sessions.js +++ b/packages/webui/server/routes/sessions.js @@ -119,7 +119,7 @@ import { readEnginePinnedSessionOrder, readEngineSessionForkOptions, } from "../engine/session-context-actions.js"; -import { authorize } from "../lib/authorize.js"; +import { authorize, hasDecidableRequester } from "../lib/authorize.js"; import { pushAlert } from "../lib/alerts.js"; import { append as _eventsAppend } from "../lib/events.js"; // Session-create workspace gate: body.workspace is user input and used to be @@ -401,13 +401,21 @@ export async function handleRenameSession(req, res, ctx) { // // planEngineSessionDelete resolves the id and runs the gate. No // mutation, so it is safe to run BEFORE -// the user is asked anything. +// the user is asked anything — and its +// verdict is what lets the handler skip +// the question entirely when the request +// provably deletes nothing. // authorize() + intent audit unchanged, and still strictly between // the plan and the commit. The write-ahead // intent line has to be durably recorded // before any row is removed, and it // records the match kind and chat length -// the plan produced. +// the plan produced. P19: the gate is +// also the FIRST thing that touches the +// request's ownership — an unattributable +// request (no `?cid=`) and an id that +// resolves to nothing both end here, +// fast, and neither reaches the engine. // commit*EngineSessionDelete splices the store, drops the tree cache, // mirrors the delete into the engine's // `local_runtime_*` tables and fans the @@ -440,19 +448,102 @@ export async function handleDeleteSession(req, res, ctx) { console.log( `[delete] cid=${cid} incoming id=${id.substring(0, 12)}… isMcodeSid=${isMcodeSessionId(id)} dryRun=${dryRun}`, ); + // P19, first half — the OWNERSHIP gate, and the first thing this + // handler decides. + // + // A request the router could not attribute to a client (`?cid=` + // absent: a curl, a script, a caller that forgot `withClientQuery`) + // has no owner to show a modal to. Left to `authorize` alone it falls + // into the BROADCAST branch — an empty cid is the broadcast target, + // so "somebody is connected" answers a question nobody asked and the + // destructive request then waits out the full 300000ms budget against + // a modal no human knows exists. That is the P19 hang: an open socket, + // no status, no body. + // + // So the question is asked BEFORE the plan, deliberately: this gate + // needs nothing the plan produces, and putting it first means the + // answer costs no store read, no capability declaration and — the + // point of the whole exercise — no engine delivery. The decline body + // is built by `authorize` itself, so this path and the timeout path + // downstream are byte-identical to what the client has always seen. + // + // `requireRequester` is what makes that call a short-circuit instead + // of a modal. It is asked here ONLY when the requester is + // unattributable, so the reduced `ctx` can never reach a human: the + // attributable path below asks the full question, with the match kind + // and chat length the plan produced, exactly as before. + if (!dryRun && !hasDecidableRequester(cid)) { + const owned = await authorize( + "session.delete", + { cid, targetSessionId: id, isMcodeSid: isMcodeSessionId(id) }, + { requireRequester: true }, + ); + console.log( + `[delete] cid=${cid} DECLINED id=${id.substring(0, 12)}… reason=no_requester`, + ); + res.writeHead(403, { "Content-Type": "application/json; charset=utf-8" }); + return res.end(JSON.stringify({ + ok: false, + error: "authorize declined", + decidedBy: owned.decidedBy, + decidedAt: owned.decidedAt, + })); + } const plan = await planEngineSessionDelete({ id }); + // P19, second half: an id that resolves to NOTHING is not a + // destructive request, and asking the user to authorize one is how a + // 404 became a five-minute hold — the modal is live, the answer is + // already "there is nothing here", and the only way out is the + // fail-closed timeout. + // + // `isOrphan && !isMcodeSessionId(id)` is exactly that provable no-op: + // the id is absent from the webui store, so there is no wrapper to + // splice, and it is not an `mvs_` sid, so there are no engine rows to + // delete either. The response is the 404 this branch has always + // returned — same status, same body, see docs/API.md — reached WITHOUT + // a governance round-trip and WITHOUT touching the engine. The + // semantic is the engine facade's own `not_mcode_sid` / + // `already_absent` pair (engine/session-delete.js), stated at the HTTP + // layer instead of waited out. + // + // The gate below still runs for every request that CAN delete + // something: a resolved record, or an orphan `mvs_` sid whose engine + // rows are about to go. This branch narrows the gate, never bypasses + // it, and runs before the write-ahead intent line — a no-op delete + // must leave no audit trail of a destructive act. + // + // `?dryRun=true` stays on the old path (preview answers for any id, + // gated by nothing, mutating nothing) so the preview contract is + // unchanged. + if (!dryRun && plan.isOrphan && !isMcodeSessionId(id)) { + console.log( + `[delete] cid=${cid} 404 id=${id.substring(0, 12)}… not found reason=not_mcode_sid`, + ); + res.writeHead(404, { "Content-Type": "application/json" }); + return res.end(JSON.stringify({ ok: false, error: "session not found" })); + } // B03: real-delete path must pass per-request authorize() before // mutating db / saveSessions / the caches. // dryRun=true bypasses (preview only — no side effects to gate). + // + // `requireRequester` stays on this call as the second line of the + // same defence: the ownership gate above already returned for an + // unattributable request, and this keeps the property attached to the + // GATE rather than to one call site, so a future refactor that drops + // the early check does not silently reopen the hang. if (!dryRun) { - const authResult = await authorize("session.delete", { - cid, - targetSessionId: id, - matchKind: plan.matchKind || (plan.isOrphan ? "unknown" : "webuiId"), - isMcodeSid: isMcodeSessionId(id), - isOrphan: plan.isOrphan, - chatLen: plan.chatLen, - }); + const authResult = await authorize( + "session.delete", + { + cid, + targetSessionId: id, + matchKind: plan.matchKind || (plan.isOrphan ? "unknown" : "webuiId"), + isMcodeSid: isMcodeSessionId(id), + isOrphan: plan.isOrphan, + chatLen: plan.chatLen, + }, + { requireRequester: true }, + ); if (!authResult.approved) { console.log( `[delete] cid=${cid} DECLINED id=${id.substring(0, 12)}… reason=${authResult.decidedBy}`, @@ -766,12 +857,20 @@ export async function handleSearchSessions(req, res, ctx) { // the user is not currently in. Gate the same way session.delete // / session.export are gated. Tests drive the real decision path // via test/_setup.js#withDecisions. - const authResult = await authorize("session.search", { - cid, - q, - workspace: workspaceParam, - limit, - }); + // P19: `requireRequester` — same rule as DELETE. A search with no + // `?cid=` has no owner to show the modal to; broadcasting it to + // whichever tab is connected and then holding the request for the + // full 300000ms budget is the hang P19 was filed about. + const authResult = await authorize( + "session.search", + { + cid, + q, + workspace: workspaceParam, + limit, + }, + { requireRequester: true }, + ); if (!authResult.approved) { res.writeHead(403, { "Content-Type": "application/json; charset=utf-8" }); return res.end(JSON.stringify({ @@ -912,11 +1011,18 @@ export async function handleCleanupOrphans(req, res, ctx) { res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" }); return res.end(JSON.stringify({ ok: true, dryRun: false, deleted: 0, ids: [] })); } - const authResult = await authorize("sessions.cleanup-orphans", { - cid, - orphanCount: targetIds.length, - orphanIds: targetIds.slice(0, 32), // truncated for log hygiene - }); + // P19: `requireRequester` — a sweep is the most destructive gate in + // this file, so an unattributable caller (no `?cid=`) must not be able + // to park it on somebody else's browser tab for five minutes. + const authResult = await authorize( + "sessions.cleanup-orphans", + { + cid, + orphanCount: targetIds.length, + orphanIds: targetIds.slice(0, 32), // truncated for log hygiene + }, + { requireRequester: true }, + ); if (!authResult.approved) { console.log( `[cleanup-orphans] cid=${cid} DECLINED count=${targetIds.length} reason=${authResult.decidedBy}`, diff --git a/packages/webui/test/integration/event-chain.test.js b/packages/webui/test/integration/event-chain.test.js index a94e3d98..f1a56c39 100644 --- a/packages/webui/test/integration/event-chain.test.js +++ b/packages/webui/test/integration/event-chain.test.js @@ -587,14 +587,48 @@ describe("event-chain: gate-blocking (decline / timeout / approve)", () => { return res.json.session.id; } + // A bare SSE subscriber that records what it was pushed. Used by the + // P19 case below to prove that a request with no owner is NOT + // broadcast: absence of a frame is the assertion, so this must not + // decide anything, only listen. + function openEventStream(port, cid) { + let body = ""; + const req = http.request( + { + method: "GET", + host: "127.0.0.1", + port, + path: "/api/events?cid=" + encodeURIComponent(cid), + }, + (res) => { + res.setEncoding("utf8"); + res.on("data", (chunk) => { body += chunk; }); + res.on("error", () => {}); + }, + ); + req.on("error", () => {}); + req.end(); + return { text: () => body, close: () => { try { req.destroy(); } catch {} } }; + } + // Fire a gated DELETE and drive the decision through the real wire // path. Subscribes the decider SSE first, then fires the request. + // P19: the request carries the decider's own `?cid=`, exactly as the + // browser client does (`webapp/lib/cid.ts#withClientQuery`). It used + // to be sent bare and resolved through the gate's BROADCAST branch; + // a gated request with no owner is no longer broadcast at all (it is + // denied at once instead — see case (d)), so the bare form cannot + // reach a decision any more. A real client is always attributed. async function deleteWithDecision(port, id, approve) { const decisionPromise = decideNextAuthorization({ port, approve, cid: "cid-decider" }); // Give the decider's SSE connection a moment to register before - // the gate broadcast fires (broadcasts are not replayed). + // the gate pushes the modal (pushes are not replayed). await new Promise((r) => setTimeout(r, 150)); - const reqPromise = requestJson({ method: "DELETE", port, path: `/api/sessions/${id}` }); + const reqPromise = requestJson({ + method: "DELETE", + port, + path: `/api/sessions/${id}?cid=cid-decider`, + }); const { decision } = await decisionPromise; const res = await reqPromise; return { res, decision }; @@ -703,4 +737,52 @@ describe("event-chain: gate-blocking (decline / timeout / approve)", () => { assert.equal(v.ok, true, `verify() failed: ${JSON.stringify(v)}`); assert.ok(v.count >= 4, `expected >=4 events (pending/approve/intent/outcome), got ${v.count}`); }); + + // P19 at the wire, which is the only layer where the hang was ever + // visible: `curl -X DELETE /api/sessions/` with no `?cid=` while + // the owner's browser tab is connected. The gate used to answer + // "somebody is listening" (an empty cid is the broadcast target), + // push a modal no tab had asked for, and hold the socket for the + // full 300000ms budget — a request with no status and no body. + test("(d) DELETE with no ?cid= is denied at once and broadcasts nothing", async () => { + const id = await createSession(server.port); + // A real tab is connected, so the broadcast branch WOULD have + // looked answerable. This is the exact P19 condition. + const deciderSse = await openEventStream(server.port, "cid-decider-2"); + try { + const startedAt = Date.now(); + const res = await requestJson({ + method: "DELETE", + port: server.port, + path: `/api/sessions/${id}`, + }); + const elapsed = Date.now() - startedAt; + assert.ok(elapsed < 3000, `unattributed delete must not wait: ${elapsed}ms`); + assert.equal(res.status, 403, `expected 403, got ${res.status}: ${res.body}`); + assert.equal(res.json && res.json.error, "authorize declined"); + assert.equal(res.json.decidedBy, "timeout", "fail-closed, same shape as a timeout"); + // No modal was pushed to the bystander tab. + assert.ok( + !deciderSse.text().includes("needs_authorization"), + "an unowned destructive request must not be broadcast to a bystander tab", + ); + // Nothing was deleted, and no destructive intent was audited. + const ids = await listSessionIds(server.port); + assert.ok(ids.includes(id), "denied delete must leave the session on disk"); + const events = readEvents(server.eventsPath); + assert.equal( + events.filter((e) => e.kind === "session.delete.intent").length, + 0, + "a denied delete writes no destructive-intent line", + ); + assert.ok( + events.some( + (e) => e.kind === "auth.unreachable" && e.data && e.data.reason === "no_requester", + ), + "audited as auth.unreachable/no_requester so an operator can tell it from a closed tab", + ); + } finally { + deciderSse.close(); + } + }); }); \ No newline at end of file diff --git a/packages/webui/test/integration/router-boot.test.js b/packages/webui/test/integration/router-boot.test.js index bf409593..33a23b8f 100644 --- a/packages/webui/test/integration/router-boot.test.js +++ b/packages/webui/test/integration/router-boot.test.js @@ -355,7 +355,11 @@ test("router-boot: GET /api/sessions//export?format=json returns 404 for unk await new Promise((r) => setTimeout(r, 150)); const resPromise = httpRequest({ port: server.port, - path: "/api/sessions/nonexistent-session-id-xyz/export?format=json", + // P19: carry the decider's own `?cid=`, as the browser client + // does. A gated request with no owner is denied at once now + // instead of being broadcast to whoever is connected, so the + // bare form can no longer reach a decision. + path: "/api/sessions/nonexistent-session-id-xyz/export?format=json&cid=cid-router-boot", }); const { decision } = await decisionPromise; assert.ok(decision, "auth decision must have been posted"); diff --git a/packages/webui/test/lib/authorize.check.mjs b/packages/webui/test/lib/authorize.check.mjs index 76a95109..c2bd0f38 100644 --- a/packages/webui/test/lib/authorize.check.mjs +++ b/packages/webui/test/lib/authorize.check.mjs @@ -503,6 +503,97 @@ describe("authorize — 不可达的裁决通道(不等满预算就失败即 }); }); +// ============================================================ +// Who asked? (P19) +// +// The block above answers "is anybody there?". The case P19 was filed +// about is the other question: the request has no OWNER to be shown +// anything. `?cid=` is absent — a curl, a script — and an empty cid is +// this module's BROADCAST target, so with one browser tab open the +// probe above answers TRUE and the destructive request then waits out +// the full 300000ms budget against a modal no human knows about. +// +// So `requireRequester` is opt-in and checked before the channel +// question: the two are different questions, and only the routes that +// serve an identified HTTP caller may claim one. `startup.cleanup` +// asks with an empty cid on purpose and must keep broadcasting. +// ============================================================ +describe("authorize — requireRequester(没有主人就没有可裁决的请求)", () => { + test("空 cid + 有在线客户端 ⇒ 毫秒级失败即关闭,不等满预算", async () => { + // The P19 condition exactly: the channel answer is "yes, somebody + // is listening", and it must not matter. + _listenerProbe = () => true; + const startedAt = Date.now(); + const r = await authorize("session.delete", { cid: "" }, { requireRequester: true }); + assert.equal(r.approved, false, "无归属的破坏性请求绝不批准"); + assert.equal(r.decidedBy, "timeout", "与超时同解,形状不变"); + assert.equal(r.reason, "no_requester"); + assert.ok(Date.now() - startedAt < 1000, "毫秒级返回,而不是 300000ms"); + assert.equal(getPendingCount(), 0, "不进挂起表:没有可被裁决的东西"); + assert.equal( + _sseFrames.filter((f) => f.event === "needs_authorization").length, + 0, + "不向不相干的标签页广播破坏性请求", + ); + }); + + test("cid 缺失 / 空白与 cid:\"\" 同解", async () => { + _listenerProbe = () => true; + for (const cid of [undefined, null, " "]) { + const r = await authorize("session.delete", { cid }, { requireRequester: true }); + assert.equal(r.approved, false, `cid=${JSON.stringify(cid)}`); + assert.equal(r.reason, "no_requester"); + } + }); + + test("反向半边:有 cid 时恢复完整人工往返,绝不自动批准", async () => { + _listenerProbe = () => true; + const p = authorize("session.delete", { cid: "tab-live" }, { requireRequester: true }); + assert.equal(getPendingCount(), 1, "有主人 ⇒ 照常挂起等人"); + const [rid] = getPendingRequestIds(); + assert.equal( + _sseFrames.filter((f) => f.event === "needs_authorization").length, + 1, + ); + const res = fakeRes(); + await handleAuthDecision(fakeReq({ requestId: rid, approve: true }), res); + assert.equal(res._status, 200); + const result = await p; + assert.equal(result.approved, true); + assert.equal(result.decidedBy, "user"); + }); + + test("不声明 requireRequester 时空 cid 仍广播 —— startup.cleanup 不受影响", async () => { + // The blast radius of the rule is exactly the routes that opted in. + // `cleanup.js` calls authorize("startup.cleanup", {cid: ""}) with no + // options, on purpose: at boot there is no requester and any tab may + // decide. If this case ever starts short-circuiting, the boot-time + // orphan sweep silently stops running. + _listenerProbe = () => true; + const p = authorize("startup.cleanup", { cid: "" }, {}); + assert.equal(getPendingCount(), 1, "启动清扫仍走广播挂起"); + const [rid] = getPendingRequestIds(); + _decideForTests(rid, false); + const r = await p; + assert.equal(r.decidedBy, "user"); + assert.equal(r.approved, false); + }); + + test("无归属时写 auth.unreachable 审计,reason=no_requester", async () => { + _listenerProbe = () => true; + await authorize("session.delete", { cid: "" }, { requireRequester: true }); + const events = readFileSync(join(_tmpAuditDir, "events.ndjson"), "utf8") + .trim().split("\n").filter(Boolean).map((l) => JSON.parse(l)); + const last = events[events.length - 1]; + assert.equal(last.kind, "auth.unreachable"); + assert.equal(last.data.reason, "no_requester"); + assert.ok(!last.cid, `没有主人 ⇒ 审计行也不挂到任何客户端名下(cid=${last.cid})`); + const eventsMod = await import(absPath("lib/events.js")); + const v = eventsMod.verify({ path: join(_tmpAuditDir, "events.ndjson") }); + assert.equal(v.ok, true, `chain must verify: ${JSON.stringify(v)}`); + }); +}); + // ============================================================ // SSE emission contract // ============================================================ diff --git a/packages/webui/test/routes/follow-up.test.js b/packages/webui/test/routes/follow-up.test.js new file mode 100644 index 00000000..d84c5328 --- /dev/null +++ b/packages/webui/test/routes/follow-up.test.js @@ -0,0 +1,467 @@ +// webui/test/routes/follow-up.test.js +// The `/api/follow-up` family (settings batch SB-4) — the composer's +// 跟进消息行为 switch reaching `cliService.enqueueMessage` / `steer`. +// +// Hermetic by construction: every handler takes an optional fourth +// argument that reaches the engine facade's `getHost` seam, so these +// tests drive a fake `host.cliService` — no runtime boot, no network, no +// temporary directory, no spawned server. +// +// FIVE invariants, in the order they matter: +// +// 1. THE OWNERSHIP GATE. A follow-up is only legal while THIS PROCESS +// owns the running turn. Under the default `acp` transport the turn +// lives in an `mcode acp` subprocess, and `submit({allowQueue:true})` +// on an idle session commits the queue item and wakes the dispatcher +// — a SECOND live turn for a session that already has one. So the +// gate reads `getActiveTurn` and refuses both "no turn" and "a turn +// this process does not own", with two DIFFERENT codes, because they +// are two different facts and the composer words them differently. +// 2. NOTHING IS SENT WHEN THE GATE FAILS. The engine write must not be +// reached at all — a refusal that still queued the message would be +// the exact fake-success shape this gate exists to prevent. +// 3. THE RESPONSE IS ENGINE TRUTH. A queue answers with the engine's own +// item id and position, a steer with the engine's own turn id; the +// request is never echoed back as if it were a receipt. +// 4. THE THREE ENGINE FAILURES STAY THREE FAILURES. No host → 503, a +// host without the method → 501, an engine refusal → its own status +// and code. +// 5. THE OFF POSITION CANNOT REACH THE WIRE AS A DOWNGRADE. `off` is +// the composer rendering no send control; a request that carries it +// is a client that ignored the setting, and it is a 400 rather than a +// silent queue. + +import { test, describe } from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { Readable } from "node:stream"; +import { pathToFileURL } from "node:url"; +import { join } from "node:path"; + +const absPath = (rel) => + pathToFileURL(join(import.meta.dirname, "..", "..", "server", rel)).href; +const absFile = (rel) => join(import.meta.dirname, "..", "..", rel); +const followUpRoute = await import(absPath("routes/follow-up.js")); +const followUpEngine = await import(absPath("engine/follow-up.js")); +const attachmentsLib = await import(absPath("lib/attachments.js")); +const { ownsRequest } = await import(absPath("app.js")); + +const SESSION = "mvs_session_for_follow_up"; + +/** A stand-in for the Node ServerResponse, mirroring model-source.test.js. */ +function fakeRes() { + let resolveDone; + const done = new Promise((r) => (resolveDone = r)); + return { + status: 0, + body: "", + headers: {}, + writeHead(status, headers) { + this.status = status; + if (headers) this.headers = headers; + }, + end(chunk) { + if (chunk !== undefined) this.body += chunk; + resolveDone(); + }, + done, + }; +} + +function bodyReq(payload) { + const stream = Readable.from([ + Buffer.from(payload === undefined ? "" : JSON.stringify(payload), "utf8"), + ]); + stream.url = "/api/follow-up"; + return stream; +} + +async function readBody(res) { + await res.done; + return JSON.parse(res.body || "{}"); +} + +/** The context `/api/follow-up` reads: the per-cid conversation's + * ENGINE session id, exactly as `/api/send` gets it. */ +function ctx(sessionId = SESSION) { + return { cs: { mcodeSessionId: sessionId } }; +} + +/** + * A fake catalogue host. `active` is what `getActiveTurn` reports, and it + * is the knob the gate is driven with: `{locallyOwned:true}` is a turn + * this process runs, `{locallyOwned:false}` is a turn in an `mcode acp` + * subprocess, `undefined` is no turn at all. + */ +function fakeHost(overrides = {}) { + const calls = []; + const state = { + active: + "active" in overrides ? overrides.active : { turnId: "turn_1", locallyOwned: true }, + ...(overrides.store || {}), + }; + const cliService = { + async getActiveTurn(sessionId) { + calls.push(["getActiveTurn", sessionId]); + return state.active; + }, + async enqueueMessage(input) { + calls.push(["enqueueMessage", input]); + return { itemId: `qi_${calls.length}`, status: "queued", position: 2 }; + }, + async steer(input) { + calls.push(["steer", input]); + return { turnId: "turn_1", mode: "steered" }; + }, + }; + for (const [name, value] of Object.entries(overrides.methods || {})) { + if (value === null) delete cliService[name]; + else cliService[name] = value; + } + const host = { cliService }; + return { host, cliService, calls, state, getHost: async () => host }; +} + +/** Post a follow-up through the route with a fake host. */ +async function post(fake, payload, context = ctx()) { + const res = fakeRes(); + await followUpRoute.handleFollowUp(bodyReq(payload), res, context, { + getHost: fake.getHost, + attachmentsLib, + }); + return { res, body: await readBody(res) }; +} + +const QUEUE_BODY = { behavior: "queue", content: "also check the migration" }; +const STEER_BODY = { behavior: "steer", content: "stop, use the other table" }; + +// --- 1. the two actions reach the engine ----------------------------------- + +describe("POST /api/follow-up reaches the engine", () => { + test("queue calls enqueueMessage with the engine session id and answers the engine's commit", async () => { + const fake = fakeHost(); + const { res, body } = await post(fake, QUEUE_BODY); + assert.equal(res.status, 200); + assert.equal(body.ok, true); + assert.equal(body.behavior, "queue"); + // The engine's own ids, not the request: an item id and a position the + // engine never recorded would send the user looking for a queue entry + // that does not exist. + assert.equal(body.itemId, "qi_2"); + assert.equal(body.position, 2); + const call = fake.calls.find(([name]) => name === "enqueueMessage"); + assert.ok(call, "the engine's queue method must be called"); + assert.equal(call[1].id, SESSION, "the engine session id, not the webui one"); + assert.equal(call[1].content, QUEUE_BODY.content); + }); + + test("steer calls steer with the engine's own steering vocabulary", async () => { + const fake = fakeHost(); + const { res, body } = await post(fake, STEER_BODY); + assert.equal(res.status, 200); + assert.equal(body.behavior, "steer"); + assert.equal(body.turnId, "turn_1"); + assert.equal(body.mode, "steered"); + const call = fake.calls.find(([name]) => name === "steer"); + assert.ok(call, "the engine's steering method must be called"); + // `composer-steer` is the engine's own user-steering producer + // (turn-system/agent-host/runner/contracts.ts#USER_STEERING_PRODUCERS): + // a message under any other id is dropped at turn teardown instead of + // being requeued. + assert.equal(call[1].producerId, "composer-steer"); + assert.equal(call[1].sessionId, SESSION); + assert.equal(call[1].source, "api"); + assert.equal(call[1].message.content, STEER_BODY.content); + }); + + test("the per-send identity is forwarded as the engine's dedupe key", async () => { + const fake = fakeHost(); + await post(fake, { ...QUEUE_BODY, requestId: "cid.1.2" }); + const queued = fake.calls.find(([name]) => name === "enqueueMessage")[1]; + assert.equal(queued.clientRequestId, "cid.1.2"); + const fake2 = fakeHost(); + await post(fake2, { ...STEER_BODY, requestId: "cid.1.2" }); + const steered = fake2.calls.find(([name]) => name === "steer")[1]; + assert.equal(steered.idempotencyKey, "cid.1.2"); + }); + + test("an identity the server cannot accept is dropped, not rejected", async () => { + // The identity guards against a double submit; refusing the MESSAGE + // over it would be a worse lie than sending it once. + const fake = fakeHost(); + const { res } = await post(fake, { ...QUEUE_BODY, requestId: "not a valid id!" }); + assert.equal(res.status, 200); + const queued = fake.calls.find(([name]) => name === "enqueueMessage")[1]; + assert.equal(queued.clientRequestId, undefined); + }); +}); + +// --- 2. the ownership gate -------------------------------------------------- + +describe("the ownership gate", () => { + test("a turn this process does not own is refused, and NOTHING is queued", async () => { + // The acp transport's situation: a turn is running, in another + // process. Queueing into this host would wake its dispatcher and start + // a second turn for a session that already has one. + const fake = fakeHost({ active: { turnId: "turn_1", locallyOwned: false } }); + const { res, body } = await post(fake, QUEUE_BODY); + assert.equal(res.status, 409); + assert.equal(body.code, "turn_not_owned"); + assert.equal( + fake.calls.some(([name]) => name === "enqueueMessage"), + false, + "a refused follow-up must not reach the engine write", + ); + }); + + test("no running turn is a DIFFERENT refusal, and also queues nothing", async () => { + const fake = fakeHost({ active: undefined }); + const { res, body } = await post(fake, QUEUE_BODY); + assert.equal(res.status, 409); + assert.equal(body.code, "no_active_turn"); + assert.equal(fake.calls.some(([name]) => name === "enqueueMessage"), false); + }); + + test("the gate is asked BEFORE the action, for both actions", async () => { + for (const payload of [QUEUE_BODY, STEER_BODY]) { + const fake = fakeHost(); + await post(fake, payload); + const names = fake.calls.map(([name]) => name); + assert.equal(names[0], "getActiveTurn", "the gate must run first"); + assert.ok( + names.indexOf("getActiveTurn") < names.indexOf(payload.behavior === "queue" ? "enqueueMessage" : "steer"), + "the action must not precede the gate", + ); + } + }); + + test("a host without the gate member is 501, not an unverified guess", async () => { + const fake = fakeHost({ methods: { getActiveTurn: null } }); + const { res, body } = await post(fake, QUEUE_BODY); + assert.equal(res.status, 501); + assert.equal(body.code, "engine_member_unavailable"); + assert.equal(fake.calls.some(([name]) => name === "enqueueMessage"), false); + }); + + test("a gate read that throws is a 500 with no engine text", async () => { + const fake = fakeHost(); + fake.cliService.getActiveTurn = async () => { + throw new Error("turn table at /home/somebody/.mavis/turns.sqlite is corrupt"); + }; + const { res, body } = await post(fake, QUEUE_BODY); + assert.equal(res.status, 500); + assert.equal(body.code, "engine_error"); + assert.ok( + !res.body.includes(".mavis"), + "an exception string from an unknown thrower must not reach the wire", + ); + }); +}); + +// --- 3. the three engine failures stay three ------------------------------- + +describe("the engine failures", () => { + test("no host is 503", async () => { + const res = fakeRes(); + await followUpRoute.handleFollowUp(bodyReq(QUEUE_BODY), res, ctx(), { + getHost: async () => null, + attachmentsLib, + }); + const body = await readBody(res); + assert.equal(res.status, 503); + assert.equal(body.code, "engine_host_unavailable"); + }); + + test("a host getter that throws is 503 with a body, not an unhandled rejection", async () => { + const res = fakeRes(); + await followUpRoute.handleFollowUp(bodyReq(QUEUE_BODY), res, ctx(), { + getHost: async () => { + throw new Error("the runtime failed to boot"); + }, + attachmentsLib, + }); + const body = await readBody(res); + assert.equal(res.status, 503); + assert.equal(body.code, "engine_host_unavailable"); + }); + + test("a host without enqueueMessage is 501", async () => { + const fake = fakeHost({ methods: { enqueueMessage: null } }); + const { res, body } = await post(fake, QUEUE_BODY); + assert.equal(res.status, 501); + assert.equal(body.code, "engine_member_unavailable"); + }); + + test("an engine refusal keeps its own status and code", async () => { + const fake = fakeHost(); + fake.cliService.enqueueMessage = async () => { + const err = new Error("Session not found: mvs_x"); + err.status = 404; + err.code = "local_session_not_found"; + throw err; + }; + const { res, body } = await post(fake, QUEUE_BODY); + assert.equal(res.status, 404); + assert.equal(body.code, "local_session_not_found"); + }); + + test("a rejected steering message is a 409 with the engine's reason", async () => { + // `ConversationTurnRejectedError` carries no status but does carry a + // stable code and a reason; a refusal must not be flattened into a + // 500 that reads like a broken route. + const fake = fakeHost(); + fake.cliService.steer = async () => { + const err = new Error("Conversation Turn was rejected"); + err.code = "CONVERSATION_TURN_REJECTED"; + err.reason = "delivery-closed"; + throw err; + }; + const { res, body } = await post(fake, STEER_BODY); + assert.equal(res.status, 409); + assert.equal(body.code, "CONVERSATION_TURN_REJECTED"); + assert.ok(body.error.includes("delivery-closed")); + }); +}); + +// --- 4. input validation --------------------------------------------------- + +describe("what the route refuses before it reaches the engine", () => { + test("an unknown behaviour is a 400 naming the two that exist", async () => { + for (const behavior of ["off", "", "QUEUE", "cancel"]) { + const fake = fakeHost(); + const { res, body } = await post(fake, { behavior, content: "x" }); + assert.equal(res.status, 400, behavior); + assert.equal(body.code, "invalid_follow_up_behavior"); + assert.ok(body.error.includes("queue") && body.error.includes("steer")); + assert.equal(fake.calls.length, 0, "a refused request must not boot or call the engine"); + } + }); + + test("a field of the wrong type is a 400 naming the field", async () => { + const cases = [ + [{ behavior: 7, content: "x" }, "behavior"], + [{ behavior: "queue", content: [] }, "content"], + [{ behavior: "queue", content: "x", attachments: "@/tmp/a" }, "attachments"], + [{ behavior: "queue", content: "x", requestId: 12 }, "requestId"], + ]; + for (const [payload, field] of cases) { + const fake = fakeHost(); + const { res, body } = await post(fake, payload); + assert.equal(res.status, 400, field); + assert.equal(body.code, "BAD_FIELD_TYPE"); + assert.ok(body.error.includes(field)); + } + }); + + test("an empty message is a 400, and reaches no engine", async () => { + const fake = fakeHost(); + const { res, body } = await post(fake, { behavior: "queue", content: " " }); + assert.equal(res.status, 400); + assert.equal(body.code, "follow_up_empty"); + assert.equal(fake.calls.length, 0); + }); + + test("a tab with no live conversation is a 400, not a queue with no owner", async () => { + // The session id comes from the server's own conversation state, so a + // client cannot aim a message at a conversation it is not showing. + const fake = fakeHost(); + const { res, body } = await post(fake, QUEUE_BODY, ctx("")); + assert.equal(res.status, 400); + assert.equal(body.code, "no_active_conversation"); + assert.equal(fake.calls.length, 0); + }); + + test("an untrusted attachment path is dropped and the message still goes", async () => { + // Same rule as `/api/send`: a chip that cannot be resolved must not + // become a silent success ("the model saw the file"). With no + // attachments left, the text still carries the request. + const fake = fakeHost(); + const { res } = await post(fake, { ...QUEUE_BODY, attachments: ["@/etc/passwd"] }); + assert.equal(res.status, 200); + const queued = fake.calls.find(([name]) => name === "enqueueMessage")[1]; + assert.equal(queued.attachments, undefined); + }); +}); + +// --- 5. the route and the engine facade agree on the surface --------------- + +describe("the declared surface", () => { + test("the app's OWNED_ROUTES contains the family exactly once", () => { + assert.equal(ownsRequest("POST", "/api/follow-up"), true); + }); + + test("the engine table names the two methods that exist upstream", () => { + // A drift here would 501 every follow-up on a host that has the + // method under another name; pin it against the cli-service source + // rather than against a comment. + const cliServiceSource = readFileSync( + join(import.meta.dirname, "..", "..", "..", "local-runtime-v2", "src", "local", "cli-service.ts"), + "utf8", + ); + for (const action of Object.values(followUpEngine.FOLLOW_UP_ACTIONS)) { + assert.ok( + new RegExp(`\\n\\s{2}${action.method}\\(`).test(cliServiceSource), + `cli-service.ts must declare ${action.method}`, + ); + } + assert.ok( + new RegExp(`\\n\\s{2}${followUpEngine.FOLLOW_UP_GATE_METHOD}\\(`).test(cliServiceSource), + "cli-service.ts must declare the gate method", + ); + }); + + test("the route reaches the engine only through getEngineCatalogueHost", () => { + // The SB-1 discipline: no second host getter, no direct runtime + // import, so the "one host per process" rule cannot be bypassed here. + const engineSource = readFileSync(absFile("server/engine/follow-up.js"), "utf8"); + assert.ok( + engineSource.includes("getEngineCatalogueHost"), + "the engine facade must boot through getEngineCatalogueHost", + ); + assert.ok( + !/getCatalogueHost\(/.test(engineSource), + "the facade must not call the un-namespaced getter directly", + ); + assert.ok( + !engineSource.includes("peekEngineCatalogueHost"), + "a write may boot what it needs; this is a write", + ); + }); + + test("the producer id and the refusal codes are the engine's own vocabulary", () => { + const runnerSource = readFileSync( + join( + import.meta.dirname, + "..", + "..", + "..", + "local-runtime-v2", + "src", + "service", + "turn-system", + "agent-host", + "runner", + "contracts.ts", + ), + "utf8", + ); + assert.ok( + runnerSource.includes(`'${followUpEngine.FOLLOW_UP_STEER_PRODUCER_ID}'`), + "the steering producer must be one the engine treats as user steering", + ); + const clientSource = readFileSync(absFile("webapp/lib/follow-up.ts"), "utf8"); + for (const code of Object.keys(followUpEngine.FOLLOW_UP_CODES)) { + assert.ok(clientSource.includes(code), `the client must know the ${code} code`); + } + }); + + test("the client and the server spell the behaviour whitelist the same way", async () => { + const clientSource = readFileSync(absFile("webapp/lib/follow-up.ts"), "utf8"); + for (const action of followUpEngine.FOLLOW_UP_ACTION_KEYS) { + assert.ok( + clientSource.includes(`"${action}"`), + `the client must be able to produce the ${action} action`, + ); + } + }); +}); diff --git a/packages/webui/test/routes/model-source.test.js b/packages/webui/test/routes/model-source.test.js new file mode 100644 index 00000000..110b7ae1 --- /dev/null +++ b/packages/webui/test/routes/model-source.test.js @@ -0,0 +1,730 @@ +// webui/test/routes/model-source.test.js +// The `/api/model-source*` family (settings batch SB-1). +// +// Hermetic by construction: every handler takes an optional fourth +// argument that reaches the engine facade's `getHost` seam, so these +// tests drive a fake `host.cliService` — no runtime boot, no network, no +// temporary directory, no spawned server. +// +// The suite is organised around FOUR invariants, because everything else +// is bookkeeping next to them: +// +// 1. The KEEP-KEY sentinel. An absent or empty `apiKey` must keep the +// stored key and must NOT call the engine's write. The GET can only +// return a MASK (the engine masks through `secret.js`) and the +// engine REJECTS a mask submitted as a key +// (`assertValidRawApiKey` → `INVALID_API_KEY`), so a UI that +// round-tripped its own masked state would turn every save into a +// failure. A keep is the only way out, and it has to be free. +// 2. The badge is engine truth, never the request. Every write's +// response is assembled from a READ BACK through the engine, so a +// response can never report a source or a key status the engine +// does not hold. +// 3. The three engine failures stay three failures. No host → 503, a +// host without the method → 501, an engine refusal (`LocalModelProviderError`) +// → ITS status and code. Collapsing any two of them is the +// fake-success shape #110 fixed. +// 4. No secret on any path. The route has no code path that could +// unmask a key, and the fake asserts the raw key it was handed is +// never echoed in a response body. + +import { test, describe } from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { Readable } from "node:stream"; +import { pathToFileURL } from "node:url"; +import { join } from "node:path"; + +const absPath = (rel) => + pathToFileURL(join(import.meta.dirname, "..", "..", "server", rel)).href; +const absFile = (rel) => join(import.meta.dirname, "..", "..", rel); +const modelSourceRoute = await import(absPath("routes/model-source.js")); +const { ownsRequest } = await import(absPath("app.js")); + +const RAW_KEY = "sk-ant-secret-value-0123456789"; +const MASKED_KEY = "sk-a*******6789"; + +/** A stand-in for the Node ServerResponse, mirroring turn-diff.test.js. */ +function fakeRes() { + let resolveDone; + const done = new Promise((r) => (resolveDone = r)); + return { + status: 0, + body: "", + headers: {}, + writeHead(status, headers) { + this.status = status; + if (headers) this.headers = headers; + }, + end(chunk) { + if (chunk !== undefined) this.body += chunk; + resolveDone(); + }, + done, + }; +} + +function bodyReq(payload) { + const stream = Readable.from([ + Buffer.from(payload === undefined ? "" : JSON.stringify(payload), "utf8"), + ]); + stream.url = "/api/model-source"; + return stream; +} + +async function readBody(res) { + await res.done; + return JSON.parse(res.body || "{}"); +} + +/** + * A fake catalogue host whose `cliService` carries the four methods this + * family calls, plus the call log. `store` is the engine's own state, so + * a write in one request is visible to the read-back of the next — which + * is what makes invariant 2 testable rather than tautological. + */ +function fakeHost(overrides = {}) { + const store = { + source: "token_plan", + apiKey: null, + ...(overrides.store || {}), + }; + const calls = []; + const cliService = { + async getMiniMaxModelSource() { + calls.push(["getMiniMaxModelSource"]); + return store.source; + }, + async getMiniMaxApiKeyStatus() { + calls.push(["getMiniMaxApiKeyStatus"]); + return store.apiKey === null + ? { hasApiKey: false } + : { hasApiKey: true, maskedApiKey: MASKED_KEY, cachedStatus: { state: "available" } }; + }, + async setMiniMaxModelSource(input) { + calls.push(["setMiniMaxModelSource", input]); + if (input.source === "minimax_api_key" && store.apiKey === null) { + // The engine's own refusal (LocalModelProviderError 400 NO_API_KEY). + const err = new Error("MiniMax API key is not configured"); + err.name = "LocalModelProviderError"; + err.status = 400; + err.code = "NO_API_KEY"; + throw err; + } + store.source = input.source; + return store.source; + }, + async upsertMiniMaxApiKey(input) { + calls.push(["upsertMiniMaxApiKey", input]); + store.apiKey = input.apiKey; + if (input.saveAndUse) store.source = "minimax_api_key"; + return { id: "minimax_api" }; + }, + async testUserModel(input) { + calls.push(["testUserModel", input]); + return { success: true, status: { state: "available", lastTestedAt: 1_700_000_000_000 } }; + }, + }; + for (const [name, value] of Object.entries(overrides.methods || {})) { + if (value === null) delete cliService[name]; + else cliService[name] = value; + } + return { host: { cliService }, calls, store, getHost: async () => ({ cliService }) }; +} + +// --- 1. the read ----------------------------------------------------------- + +describe("GET /api/model-source reports engine truth", () => { + test("answers the source and the masked key status", async () => { + const fake = fakeHost(); + const res = fakeRes(); + await modelSourceRoute.handleGetModelSource({}, res, {}, { getHost: fake.getHost }); + const body = await readBody(res); + assert.equal(res.status, 200); + assert.equal(body.source, "token_plan"); + assert.deepEqual(body.apiKey, { + available: true, + hasKey: false, + masked: null, + testState: null, + lastTestedAtMs: null, + }); + }); + + test("passes the engine's mask through and never invents one", async () => { + const fake = fakeHost({ store: { apiKey: RAW_KEY, source: "minimax_api_key" } }); + const res = fakeRes(); + await modelSourceRoute.handleGetModelSource({}, res, {}, { getHost: fake.getHost }); + const body = await readBody(res); + assert.equal(body.source, "minimax_api_key"); + assert.equal(body.apiKey.masked, MASKED_KEY); + assert.equal(body.apiKey.hasKey, true); + assert.equal(body.apiKey.testState, "available"); + assert.ok(!res.body.includes(RAW_KEY), "the raw key must never reach a response"); + }); + + test("a host without the OPTIONAL key method still answers the source", async () => { + // Degradation, not failure: the switcher can be truthful about the + // source even when it cannot report the key half, and + // `available: false` is what keeps that from reading as "no key". + const fake = fakeHost({ methods: { getMiniMaxApiKeyStatus: null } }); + const res = fakeRes(); + await modelSourceRoute.handleGetModelSource({}, res, {}, { getHost: fake.getHost }); + const body = await readBody(res); + assert.equal(res.status, 200); + assert.equal(body.source, "token_plan"); + assert.equal(body.apiKey.available, false); + assert.equal(body.apiKey.hasKey, false); + }); + + test("a key-status read that THROWS degrades, and never claims 'no key'", async () => { + // The difference matters to a user with a stored key: `hasKey:false` + // would tell them they have none. + const fake = fakeHost({ store: { apiKey: RAW_KEY } }); + fake.host.cliService.getMiniMaxApiKeyStatus = async () => { + throw new Error("status cache unreadable"); + }; + const res = fakeRes(); + await modelSourceRoute.handleGetModelSource({}, res, {}, { getHost: fake.getHost }); + const body = await readBody(res); + assert.equal(res.status, 200); + assert.equal(body.apiKey.available, false); + assert.equal(body.apiKey.hasKey, false); + }); + + test("a source the engine's own type does not allow is refused, not rendered", async () => { + const fake = fakeHost({ methods: { getMiniMaxModelSource: async () => "somewhere_else" } }); + const res = fakeRes(); + await modelSourceRoute.handleGetModelSource({}, res, {}, { getHost: fake.getHost }); + const body = await readBody(res); + assert.equal(res.status, 502); + assert.equal(body.code, "UNKNOWN_MODEL_SOURCE"); + }); +}); + +// --- 2. the switch --------------------------------------------------------- + +describe("PUT /api/model-source switches and reports the persisted value", () => { + test("writes the source and answers what the engine persisted", async () => { + const fake = fakeHost({ store: { apiKey: RAW_KEY } }); + const res = fakeRes(); + await modelSourceRoute.handleSetModelSource( + bodyReq({ source: "minimax_api_key" }), + res, + {}, + { getHost: fake.getHost }, + ); + const body = await readBody(res); + assert.equal(res.status, 200); + assert.equal(body.source, "minimax_api_key"); + assert.equal(fake.store.source, "minimax_api_key", "the engine's own state must have moved"); + }); + + test("survives a reopen: the switch is read back, not cached", async () => { + const fake = fakeHost({ store: { apiKey: RAW_KEY } }); + await modelSourceRoute.handleSetModelSource( + bodyReq({ source: "minimax_api_key" }), + fakeRes(), + {}, + { getHost: fake.getHost }, + ); + // A FRESH request against the same engine state — this is the + // persistence proof: the badge after a reopen comes from the engine, + // not from the state the first response left in the browser. + const res = fakeRes(); + await modelSourceRoute.handleGetModelSource({}, res, {}, { getHost: fake.getHost }); + const body = await readBody(res); + assert.equal(body.source, "minimax_api_key"); + }); + + test("the response reports what the engine PERSISTED, not what was requested", async () => { + // The engine echoes the value today, so a route that simply echoed + // the REQUEST would pass every other test here. This is the pin that + // makes the difference observable: a fake that answers with a + // different value (a future engine that normalises, or one whose + // write was coerced) must move the badge to what the engine said, or + // the UI would claim a source the config does not carry. + const fake = fakeHost({ + methods: { + setMiniMaxModelSource: async () => "token_plan", + }, + }); + const res = fakeRes(); + await modelSourceRoute.handleSetModelSource( + bodyReq({ source: "minimax_api_key" }), + res, + {}, + { getHost: fake.getHost }, + ); + const body = await readBody(res); + assert.equal(body.source, "token_plan", "the engine's persisted value wins over the request"); + }); + + test("an unknown source is a 400 and the engine is never asked", async () => { + const fake = fakeHost(); + const res = fakeRes(); + await modelSourceRoute.handleSetModelSource( + bodyReq({ source: "openai" }), + res, + {}, + { getHost: fake.getHost }, + ); + const body = await readBody(res); + assert.equal(res.status, 400); + assert.equal(body.code, "INVALID_MODEL_SOURCE"); + assert.equal(fake.calls.length, 0, "a typo must not cost a runtime round trip"); + }); + + test("a missing source is the same 400", async () => { + const fake = fakeHost(); + const res = fakeRes(); + await modelSourceRoute.handleSetModelSource(bodyReq({}), res, {}, { getHost: fake.getHost }); + const body = await readBody(res); + assert.equal(res.status, 400); + assert.equal(body.code, "INVALID_MODEL_SOURCE"); + }); + + test("the engine's refusal is forwarded with its own status and code", async () => { + // NO_API_KEY is the UI's cue to send the user to the key field, not a + // generic failure — so the code has to survive the trip. + const fake = fakeHost(); + const res = fakeRes(); + await modelSourceRoute.handleSetModelSource( + bodyReq({ source: "minimax_api_key" }), + res, + {}, + { getHost: fake.getHost }, + ); + const body = await readBody(res); + assert.equal(res.status, 400); + assert.equal(body.code, "NO_API_KEY"); + assert.equal(fake.store.source, "token_plan", "a refused switch must not move the source"); + }); + + test("a non-string source is a 400 before the engine is reached", async () => { + const fake = fakeHost(); + const res = fakeRes(); + await modelSourceRoute.handleSetModelSource( + bodyReq({ source: 1 }), + res, + {}, + { getHost: fake.getHost }, + ); + const body = await readBody(res); + assert.equal(res.status, 400); + assert.equal(body.code, "BAD_FIELD_TYPE"); + assert.equal(fake.calls.length, 0); + }); +}); + +// --- 3. the key write + the keep sentinel ----------------------------------- + +describe("PUT /api/model-source/api-key upserts, and an empty key keeps", () => { + test("a raw key is written and read back as a mask", async () => { + const fake = fakeHost(); + const res = fakeRes(); + await modelSourceRoute.handlePutModelSourceApiKey( + bodyReq({ apiKey: RAW_KEY, saveAndUse: true }), + res, + {}, + { getHost: fake.getHost }, + ); + const body = await readBody(res); + assert.equal(res.status, 200); + assert.equal(body.changed, true); + assert.equal(body.source, "minimax_api_key", "saveAndUse must switch in the same call"); + assert.equal(body.apiKey.masked, MASKED_KEY); + assert.ok(!res.body.includes(RAW_KEY), "the raw key must never reach a response"); + }); + + test("saveAndUse omitted stores the key WITHOUT switching the source", async () => { + const fake = fakeHost(); + const res = fakeRes(); + await modelSourceRoute.handlePutModelSourceApiKey( + bodyReq({ apiKey: RAW_KEY }), + res, + {}, + { getHost: fake.getHost }, + ); + const body = await readBody(res); + assert.equal(body.changed, true); + assert.equal(body.saveAndUse, false); + assert.equal(body.source, "token_plan"); + assert.equal(fake.store.apiKey, RAW_KEY); + }); + + test("an empty apiKey KEEPS the stored key and calls no write", async () => { + const fake = fakeHost({ store: { apiKey: RAW_KEY } }); + const res = fakeRes(); + await modelSourceRoute.handlePutModelSourceApiKey( + bodyReq({ apiKey: "" }), + res, + {}, + { getHost: fake.getHost }, + ); + const body = await readBody(res); + assert.equal(res.status, 200); + assert.equal(body.changed, false); + assert.equal(body.apiKey.masked, MASKED_KEY, "a keep must still report what is stored"); + assert.equal( + fake.calls.filter(([name]) => name === "upsertMiniMaxApiKey").length, + 0, + "a keep must not ask the engine to re-assert a value it already holds", + ); + }); + + test("an absent apiKey is the same keep", async () => { + const fake = fakeHost({ store: { apiKey: RAW_KEY } }); + const res = fakeRes(); + await modelSourceRoute.handlePutModelSourceApiKey(bodyReq({}), res, {}, { getHost: fake.getHost }); + const body = await readBody(res); + assert.equal(body.changed, false); + assert.equal(fake.store.apiKey, RAW_KEY); + }); + + test("a whitespace-only key is a keep, not a write of blanks", async () => { + // The engine trims, so " " would reach it as empty and be refused + // as INVALID_API_KEY; treating it as the keep sentinel here is what + // the sentinel is FOR. + const fake = fakeHost({ store: { apiKey: RAW_KEY } }); + const res = fakeRes(); + await modelSourceRoute.handlePutModelSourceApiKey( + bodyReq({ apiKey: " " }), + res, + {}, + { getHost: fake.getHost }, + ); + const body = await readBody(res); + assert.equal(body.changed, false); + assert.equal(fake.calls.filter(([name]) => name === "upsertMiniMaxApiKey").length, 0); + }); + + test("a non-string apiKey is a 400, never a silent keep", async () => { + const fake = fakeHost({ store: { apiKey: RAW_KEY } }); + const res = fakeRes(); + await modelSourceRoute.handlePutModelSourceApiKey( + bodyReq({ apiKey: 42 }), + res, + {}, + { getHost: fake.getHost }, + ); + const body = await readBody(res); + assert.equal(res.status, 400); + assert.equal(body.code, "BAD_FIELD_TYPE"); + assert.equal(fake.calls.length, 0); + }); + + test("a non-boolean saveAndUse is a 400", async () => { + const fake = fakeHost(); + const res = fakeRes(); + await modelSourceRoute.handlePutModelSourceApiKey( + bodyReq({ apiKey: RAW_KEY, saveAndUse: "yes" }), + res, + {}, + { getHost: fake.getHost }, + ); + const body = await readBody(res); + assert.equal(res.status, 400); + assert.equal(body.code, "BAD_FIELD_TYPE"); + assert.equal(fake.calls.filter(([name]) => name === "upsertMiniMaxApiKey").length, 0); + }); + + test("the engine's own key refusal keeps its status and code", async () => { + const fake = fakeHost({ + methods: { + upsertMiniMaxApiKey: async () => { + const err = new Error("API key looks masked; provide the raw key"); + err.name = "LocalModelProviderError"; + err.status = 400; + err.code = "INVALID_API_KEY"; + throw err; + }, + }, + }); + const res = fakeRes(); + await modelSourceRoute.handlePutModelSourceApiKey( + bodyReq({ apiKey: MASKED_KEY }), + res, + {}, + { getHost: fake.getHost }, + ); + const body = await readBody(res); + assert.equal(res.status, 400); + assert.equal(body.code, "INVALID_API_KEY"); + }); + + test("an unknown thrower becomes a 500 that carries no engine text", async () => { + // The one place a credential could still be echoed is an exception + // message from something we do not recognise, so that path is + // replaced wholesale rather than forwarded. + const fake = fakeHost({ + methods: { + upsertMiniMaxApiKey: async () => { + throw new Error(`upstream rejected ${RAW_KEY}`); + }, + }, + }); + const res = fakeRes(); + await modelSourceRoute.handlePutModelSourceApiKey( + bodyReq({ apiKey: RAW_KEY }), + res, + {}, + { getHost: fake.getHost }, + ); + const body = await readBody(res); + assert.equal(res.status, 500); + assert.equal(body.code, "engine_error"); + assert.ok(!res.body.includes(RAW_KEY), "an unknown thrower must not print its message"); + }); +}); + +// --- 4. the probe ---------------------------------------------------------- + +describe("POST /api/model-source/test probes the stored key", () => { + test("always probes the BYOK provider, and says which credential it used", async () => { + const fake = fakeHost({ store: { apiKey: RAW_KEY } }); + const res = fakeRes(); + await modelSourceRoute.handleTestModelSource(bodyReq({}), res, {}, { getHost: fake.getHost }); + const body = await readBody(res); + assert.equal(res.status, 200); + assert.equal(body.success, true); + assert.equal(body.tested, "stored_key"); + assert.equal(body.providerId, "minimax_api"); + const call = fake.calls.find(([name]) => name === "testUserModel"); + assert.deepEqual(call[1], { providerId: "minimax_api" }); + }); + + test("a named model is forwarded", async () => { + const fake = fakeHost({ store: { apiKey: RAW_KEY } }); + const res = fakeRes(); + await modelSourceRoute.handleTestModelSource( + bodyReq({ modelId: "MiniMax-M3" }), + res, + {}, + { getHost: fake.getHost }, + ); + const body = await readBody(res); + assert.equal(body.modelId, "MiniMax-M3"); + const call = fake.calls.find(([name]) => name === "testUserModel"); + assert.equal(call[1].modelId, "MiniMax-M3"); + }); + + test("a failed probe is a COMPLETED probe: 200 with success false", async () => { + const fake = fakeHost({ + methods: { + testUserModel: async () => ({ + success: false, + status: { state: "failed", lastErrorCode: "unauthorized", lastErrorMessage: "401" }, + }), + }, + }); + const res = fakeRes(); + await modelSourceRoute.handleTestModelSource(bodyReq({}), res, {}, { getHost: fake.getHost }); + const body = await readBody(res); + assert.equal(res.status, 200); + assert.equal(body.success, false); + assert.equal(body.status.state, "failed"); + assert.equal(body.status.lastErrorCode, "unauthorized"); + }); + + test("no stored key is the engine's 400 NO_API_KEY, forwarded", async () => { + const fake = fakeHost({ + methods: { + testUserModel: async () => { + const err = new Error("MiniMax API key is not configured"); + err.name = "LocalModelProviderError"; + err.status = 400; + err.code = "NO_API_KEY"; + throw err; + }, + }, + }); + const res = fakeRes(); + await modelSourceRoute.handleTestModelSource(bodyReq({}), res, {}, { getHost: fake.getHost }); + const body = await readBody(res); + assert.equal(res.status, 400); + assert.equal(body.code, "NO_API_KEY"); + }); + + test("a non-string modelId is a 400 before the engine is reached", async () => { + const fake = fakeHost({ store: { apiKey: RAW_KEY } }); + const res = fakeRes(); + await modelSourceRoute.handleTestModelSource( + bodyReq({ modelId: 7 }), + res, + {}, + { getHost: fake.getHost }, + ); + const body = await readBody(res); + assert.equal(res.status, 400); + assert.equal(body.code, "BAD_FIELD_TYPE"); + assert.equal(fake.calls.length, 0); + }); +}); + +// --- 5. the three engine failures stay three ------------------------------- + +describe("engine availability is reported, never faked", () => { + const cases = [ + ["no host at all", async () => null, 503, "engine_host_unavailable"], + [ + "a host without the method", + async () => ({ cliService: {} }), + 501, + "engine_member_unavailable", + ], + [ + "a host getter that throws", + async () => { + throw new Error("runtime boot failed"); + }, + 503, + "engine_host_unavailable", + ], + ]; + + for (const [name, getHost, status, code] of cases) { + test(`GET with ${name} answers ${status} ${code}`, async () => { + const res = fakeRes(); + await modelSourceRoute.handleGetModelSource({}, res, {}, { getHost }); + const body = await readBody(res); + assert.equal(res.status, status); + assert.equal(body.code, code); + }); + + test(`PUT with ${name} answers ${status} ${code}`, async () => { + const res = fakeRes(); + await modelSourceRoute.handleSetModelSource( + bodyReq({ source: "token_plan" }), + res, + {}, + { getHost }, + ); + const body = await readBody(res); + assert.equal(res.status, status); + assert.equal(body.code, code); + }); + } + + test("a failed key write does not fake a saved key", async () => { + const res = fakeRes(); + await modelSourceRoute.handlePutModelSourceApiKey( + bodyReq({ apiKey: RAW_KEY }), + res, + {}, + { getHost: async () => null }, + ); + const body = await readBody(res); + assert.equal(res.status, 503); + assert.equal(body.ok, false); + assert.ok(!("apiKey" in body), "a failed write must not carry a key block"); + }); +}); + +// --- 6. the wiring --------------------------------------------------------- + +describe("the family is registered end to end", () => { + test("all four endpoints are in OWNED_ROUTES", () => { + for (const [method, path] of [ + ["GET", "/api/model-source"], + ["PUT", "/api/model-source"], + ["PUT", "/api/model-source/api-key"], + ["POST", "/api/model-source/test"], + ]) { + assert.equal(ownsRequest(method, path), true, `${method} ${path} must be owned by Hono`); + } + }); + + test("the route table and OWNED_ROUTES cover the same four endpoints", () => { + assert.deepEqual( + [...modelSourceRoute._modelSourceRoutes()].sort(), + [ + "GET /api/model-source", + "POST /api/model-source/test", + "PUT /api/model-source", + "PUT /api/model-source/api-key", + ], + ); + for (const endpoint of modelSourceRoute._modelSourceRoutes()) { + const declaration = modelSourceRoute._modelSourceDeclaration(endpoint); + assert.equal(declaration.member, "cliService", `${endpoint} must resolve on cliService`); + assert.match(declaration.method, /^(get|set|upsert|test)MiniMax|^testUserModel/); + } + }); + + test("app.js mounts the four handlers, in the OWNED_ROUTES order", () => { + const app = readFileSync(absFile("server/app.js"), "utf8"); + for (const handler of [ + "handleGetModelSource", + "handleSetModelSource", + "handlePutModelSourceApiKey", + "handleTestModelSource", + ]) { + assert.ok(app.includes(handler), `app.js must mount ${handler}`); + } + // A route mounted in a different order than the ledger reads is a + // ledger that no longer describes the app; the pin is cheap and the + // drift is invisible otherwise. + const order = [ + '"GET /api/model-source"', + '"PUT /api/model-source"', + '"PUT /api/model-source/api-key"', + '"POST /api/model-source/test"', + ].map((needle) => app.indexOf(needle)); + for (const index of order) assert.notEqual(index, -1); + assert.deepEqual([...order].sort((a, b) => a - b), order, "OWNED_ROUTES order must be stable"); + }); + + test("the settings tab reads the badge from the ENGINE value, not the view", () => { + // A source-level tripwire, and the reason is structural: the tab + // component imports `panels.tsx`, which pulls the session store and + // the api graph, so there is no render harness for it (the same + // constraint `usage-models-cards.test.ts` documents for the cards). + // What must not regress is the fake-success shape: a badge fed by + // the local `sourceTab` would claim 使用中 for a source the engine + // never accepted. + const port = readFileSync(absFile("webapp/components/settings-modal-port.tsx"), "utf8"); + assert.ok( + port.includes("setActiveSource(written.source)"), + "the switch must take the badge from what the engine persisted", + ); + assert.ok( + port.includes("=== activeSource ?"), + "the in-use badge must be gated on the engine value read back from the server", + ); + assert.ok( + !/settings-usage-source-in-use[\s\S]{0,400}sourceTab ===/.test(port), + "the in-use badge must not be derived from the local view tab", + ); + }); + + test("the two key controls are gated on real state, not permanently disabled", () => { + const port = readFileSync(absFile("webapp/components/settings-modal-port.tsx"), "utf8"); + // The probe reads the STORED key (no engine override exists), so an + // unsaved value must disable it rather than probe the wrong thing. + assert.ok( + port.includes("disabled={!hasStoredKey || hasUnsavedKey || busy !== null}"), + "the connectivity probe must be disabled while the field holds an unsaved value", + ); + assert.ok( + port.includes("disabled={!hasUnsavedKey || busy !== null}"), + "save-and-use must be disabled on an empty field (an empty submit is the keep sentinel)", + ); + assert.ok( + !/usageModels\.minimax\.unavailable"\s*\n\s*disabled/.test(port), + "neither control may be hard-disabled with the degraded-state title any more", + ); + }); + + test("the frontend client calls the four paths this route serves", () => { + const api = readFileSync(absFile("webapp/lib/api.ts"), "utf8"); + for (const call of [ + 'getModelSource = () => request("/api/model-source")', + 'setModelSource = (source: ModelSource) =>\n request<{ ok: true; source: ModelSource }>("/api/model-source"', + 'putModelSourceApiKey = (payload: { apiKey: string; saveAndUse?: boolean }) =>\n request("/api/model-source/api-key"', + 'testModelSourceModel = (payload: { modelId?: string } = {}) =>\n request("/api/model-source/test"', + ]) { + assert.ok(api.includes(call), `api.ts must declare: ${call.split("\n")[0]}`); + } + }); +}); diff --git a/packages/webui/test/routes/sessions-search.check.mjs b/packages/webui/test/routes/sessions-search.check.mjs index 8c387c5d..ca1ecf27 100644 --- a/packages/webui/test/routes/sessions-search.check.mjs +++ b/packages/webui/test/routes/sessions-search.check.mjs @@ -454,6 +454,11 @@ describe("handleSearchSessions — B03 authorize gate", () => { // touch it don't crash. The handler doesn't read it, but // import side-effects might. AUTHORIZE_ACTIONS: Object.freeze(["session.search"]), + // P19: the DELETE handler's ownership gate calls this + // predicate directly, so a stub module that omits it turns + // `!hasDecidableRequester(cid)` into a TypeError at the top + // of the handler. Keep the shape the real module has. + hasDecidableRequester: (cid) => typeof cid === "string" && cid.trim() !== "", DEFAULT_TIMEOUT_MS: 5 * 60 * 1000, handleAuthDecision: async () => {}, getPendingCount: () => 0, diff --git a/packages/webui/test/routes/sessions.check.mjs b/packages/webui/test/routes/sessions.check.mjs index 66cfe8b5..72290afd 100644 --- a/packages/webui/test/routes/sessions.check.mjs +++ b/packages/webui/test/routes/sessions.check.mjs @@ -906,6 +906,172 @@ describe("handleDeleteSession — v1.0 anti-resurrection", () => { "webui 包装条目同样被摘掉", ); }); + + // ------------------------------------------------------------------- + // P19: the wait that was still there, behind the P13 fix. + // + // P13 closed one hole — "no connected client ends the gate at once". + // It did not close the other one. A request the router cannot + // attribute to a client carries an EMPTY cid, and an empty cid is + // `authorize`'s BROADCAST target: with one browser tab open, + // `hasDecisionListener("")` answers TRUE, the gate pushes a modal to + // a tab that never asked for it, and the request then waits the full + // 300000ms budget. A `curl -X DELETE /api/sessions/no-such-cid` on + // an instance whose owner happens to have the UI open therefore hung + // with no status and no body — which is what P19 was filed about. + // + // Two invariants, one per half of the fix: + // + // 1. No owner ⇒ 403 at once. Asserted with a LIVE SSE connection + // registered under a different cid, because that is the exact + // condition that made the P13 fix insufficient. + // 2. Nothing to delete ⇒ no question. An id that is absent from the + // store AND is not an `mvs_` sid provably mutates nothing, so it + // answers 404 without a governance round-trip — and without + // reaching the engine. + // + // The reverse halves are pinned too, because each of these cases is + // only half a fix: a gate that refuses every request is as broken as + // one that waits for every request. + // ------------------------------------------------------------------- + test("P19: 无 cid 的 DELETE 在有标签页在线时立即 403,不等裁决预算,也不触引擎", async () => { + const calls = trackAntiResurrection(); + const sb = await import(absPath("lib/state-bus.js")); + // A real SSE response under ANOTHER cid. This is what makes the + // broadcast answer "somebody is listening" — the P19 condition. + const live = { writableEnded: false, destroyed: false, write() { return true; } }; + sb.setSseClient("cid-other-tab", live); + try { + const cs = makeClientState(); + cs.workspace = { dir: WS_A, branch: null, tree: null }; + const res = fakeRes(); + // cid === "" — exactly what `getCidFromReq` returns for a curl + // with no `?cid=`. + const ctx = { cs, cid: "", pathname: "/api/sessions/webui-A" }; + const startedAt = Date.now(); + await handleDeleteSession(fakeReq({}), res, ctx); + const elapsed = Date.now() - startedAt; + + assert.ok( + elapsed < 2000, + `挂起复现:不可归属的请求等了 ${elapsed}ms(预算上限 300000ms)`, + ); + assert.equal(res._status, 403); + const body = JSON.parse(res._body); + assert.deepEqual( + Object.keys(body), + ["ok", "error", "decidedBy", "decidedAt"], + "拒绝体逐键不变", + ); + assert.equal(body.error, "authorize declined"); + assert.equal(body.decidedBy, "timeout", "失败即关闭:无人可裁决就绝不放行"); + assert.equal( + getSessionsStore().some((s) => s.id === "webui-A"), + true, + "无归属的请求不得删除任何一行", + ); + assert.equal(calls.shutdown, 0, "不得牵动 ACP 子进程"); + assert.deepEqual(calls.drop, [], "不得动引擎会话缓存"); + } finally { + sb.endSseClient("cid-other-tab", live); + } + }); + + test("P19: id 解析不出任何东西时直接 404,不发起裁决也不触引擎", async () => { + const calls = trackAntiResurrection(); + const cs = makeClientState(); + cs.workspace = { dir: WS_A, branch: null, tree: null }; + const cid = "cid-live"; + clients.set(cid, cs); + const res = fakeRes(); + const ctx = { cs, cid, pathname: "/api/sessions/no-such-cid" }; + // NOT wrapped in withDecisions: if a governance round-trip is + // started at all, this test hangs on the real 300000ms budget + // instead of passing — which is the point. + const startedAt = Date.now(); + await handleDeleteSession(fakeReq({}), res, ctx); + const elapsed = Date.now() - startedAt; + + assert.ok(elapsed < 2000, `无谓的裁决往返:等了 ${elapsed}ms`); + assert.equal(res._status, 404, "状态码与既有 404 契约一致"); + assert.deepEqual(JSON.parse(res._body), { + ok: false, + error: "session not found", + }); + assert.equal(calls.shutdown, 0, "不会删除的东西不得牵动 ACP 子进程"); + assert.deepEqual(calls.drop, [], "不会删除的东西不得动引擎会话缓存"); + }); + + test("P19: 引擎侧 id 形态(cid 有、mvs_ 但无 webui 记录)仍过门 —— 引擎行真的要删", async () => { + const calls = trackAntiResurrection(); + const cs = makeClientState(); + cs.workspace = { dir: WS_A, branch: null, tree: null }; + const cid = "cid-live"; + clients.set(cid, cs); + + // (a) 用户拒绝 ⇒ 403,且引擎一行都没碰。快路径绝不能顺手把 + // "没有 webui 包装记录"等同于"没有东西可删":mvs_ 形态的 + // 孤儿会话在引擎侧是有行的。 + const declined = fakeRes(); + await withDecisions( + () => handleDeleteSession(fakeReq({}), declined, { + cs, + cid, + pathname: "/api/sessions/" + MVS_SID, + }), + { approve: false }, + ); + assert.equal(declined._status, 403, "未获裁决的引擎行删除必须被拒"); + assert.equal(calls.shutdown, 0, "被拒的请求不得先杀子进程"); + assert.deepEqual(calls.drop, [], "被拒的请求不得动引擎缓存"); + + // (b) 用户批准 ⇒ 进入引擎交付(子进程与缓存都被触达)。这里不 + // 钉状态码:本机没有运行时 db 时引擎侧本来就答 500,那与 + // "门有没有被越过"无关。 + const approved = fakeRes(); + await withDecisions( + () => handleDeleteSession(fakeReq({}), approved, { + cs, + cid, + pathname: "/api/sessions/" + MVS_SID, + }), + ); + assert.equal(calls.shutdown, 1, "孤儿 mcode 会话必须先杀掉会写回注册表的子进程"); + assert.deepEqual(calls.drop, [MVS_SID]); + }); + + test("P19: 真实可删的会话在无 cid 时仍然拒绝(门在 plan 之前,不因 id 存在而放行)", async () => { + const calls = trackAntiResurrection(); + registerSessionsStore({ + initial: [ + ...initialSessions, + { + id: "webui-owned", + mcodeSessionId: MVS_SID, + title: "deletable but unowned", + workspace: WS_A, + createdAt: 9, + updatedAt: 9, + chat: [], + }, + ], + }); + const cs = makeClientState(); + cs.workspace = { dir: WS_A, branch: null, tree: null }; + const res = fakeRes(); + const ctx = { cs, cid: "", pathname: "/api/sessions/webui-owned" }; + const startedAt = Date.now(); + await handleDeleteSession(fakeReq({}), res, ctx); + + assert.ok(Date.now() - startedAt < 2000, "仍然必须快速失败"); + assert.equal(res._status, 403, "id 真实存在不构成放行理由:没有主人就没有授权"); + assert.equal( + getSessionsStore().some((s) => s.id === "webui-owned"), + true, + ); + assert.equal(calls.shutdown, 0); + assert.deepEqual(calls.drop, []); + }); }); // ============================================================ diff --git a/packages/webui/test/server/app-hono.test.js b/packages/webui/test/server/app-hono.test.js index 3e7484c8..9ef8156a 100644 --- a/packages/webui/test/server/app-hono.test.js +++ b/packages/webui/test/server/app-hono.test.js @@ -81,6 +81,11 @@ describe("app.js — migration ledger", () => { "POST /api/send", "POST /api/stop", "POST /api/cmd", + // SB-4: the follow-up family, registered next to the chat routes it + // belongs to. It is a chat action (a message sent INTO a running + // turn), and unlike /api/send its response carries the engine's + // answer rather than an acknowledgement. + "POST /api/follow-up", "POST /api/usage", "POST /api/usage-trigger", "GET /api/usage-real", @@ -138,6 +143,13 @@ describe("app.js — migration ledger", () => { "POST /api/providers/test", "GET /api/providers/presets", "POST /api/providers/preset/:id/enable", + // SB-1: the model-source family. Four windows over engine methods + // that existed all along; the key write sits on a sub-resource so + // its handler can carry the keep-key sentinel. + "GET /api/model-source", + "PUT /api/model-source", + "PUT /api/model-source/api-key", + "POST /api/model-source/test", "POST /api/debug/inject", "GET /api/debug/state", "POST /api/protocol/set-mode", diff --git a/packages/webui/webapp/app/page.tsx b/packages/webui/webapp/app/page.tsx index 07db87b1..326c2037 100644 --- a/packages/webui/webapp/app/page.tsx +++ b/packages/webui/webapp/app/page.tsx @@ -39,6 +39,7 @@ import { } from "@/lib/url-restore"; import { openFileInWeb, closeOpenFile } from "@/lib/open-file"; import { readFileOpenInNewTab } from "@/lib/settings-local"; +import { effectiveBindings, matchShortcut } from "@/lib/shortcuts"; import { closeTab, openTab, @@ -446,29 +447,44 @@ function App() { setBrowserPath(null); }, [workspaceDir]); - // Ctrl+N mirrors the sidebar's "新建任务" shortcut. Ctrl+K - // used to open a legacy "search" panel kind that slice 17 - // removed — the search surface now lives in the tree column - // and is reached through the sidebar's 搜索 nav entry (which - // dispatches `openSurfaceTab("search")` from shell.tsx). - // Ctrl+, (55c) opens settings — the same binding the desktop - // prints on its user-menu Settings row, so the kbd badge the - // webui menu now carries is a real binding, not decoration. + // Global shortcut dispatch. The verdict per row — which combinations a + // browser hands to a page at all, and which are taken by the browser + // itself — lives in `lib/shortcuts.ts`; so does the effective binding + // per row, which the settings page writes. This handler reads the same + // registry, so the Shortcuts page cannot show a row as dead while this + // dispatch fires it. + // + // Per action: + // - newTask / newTaskNoProject: the sidebar's 新建任务 action + // (Ctrl+N fires only where the browser leaves it free — see the + // `partial` verdict in the registry; Ctrl+Alt+O fires everywhere). + // - globalSearch: the search surface lives in the tree column since + // slice 17 removed the legacy "search" panel kind, reached through + // the sidebar's 搜索 nav entry, which dispatches the same + // `openSurfaceTab("search")` from shell.tsx. + // - openSettings: Ctrl+, — the binding the desktop prints on its + // user-menu Settings row, so the kbd badge the webui menu carries + // is a real binding, not decoration. useEffect(() => { if (!state) return; + const bindings = effectiveBindings(); const onKey = (event: KeyboardEvent) => { - if (!(event.ctrlKey || event.metaKey)) return; - if (event.key.toLowerCase() === "n") { - event.preventDefault(); - void runAction(t("topbar.newSession"), api.newSession()); - } else if (event.key === ",") { - event.preventDefault(); + const action = matchShortcut(event, bindings); + if (!action) return; + event.preventDefault(); + if (action === "openSettings") { openSettings(); + return; + } + if (action === "globalSearch") { + openSurfaceTab("search"); + return; } + void runAction(t("topbar.newSession"), api.newSession()); }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); - }, [state, openPanel, openSettings, t]); + }, [state, openSettings, openSurfaceTab, t]); // URL ↔ session reconcile. const [urlRestored, setUrlRestored] = useState(false); diff --git a/packages/webui/webapp/components/composer.tsx b/packages/webui/webapp/components/composer.tsx index b55d88f2..3bf13b21 100644 --- a/packages/webui/webapp/components/composer.tsx +++ b/packages/webui/webapp/components/composer.tsx @@ -50,6 +50,16 @@ import { startComposerSent, } from "@/lib/composer-sent"; import { getActiveSessionId, useSessionContext } from "@/lib/store"; +import { + readFollowUpBehavior, + subscribeFollowUpBehavior, + type FollowUpBehavior, +} from "@/lib/settings-local"; +import { + followUpFailureKey, + resolveFollowUpAction, + type FollowUpAction, +} from "@/lib/follow-up"; import { completeSlashWord, flattenAvailableCommands, @@ -223,6 +233,10 @@ export function Composer({ >([]); const [slashIndex, setSlashIndex] = useState(0); const editorRef = useRef(null); + // Per-send counter for the follow-up request identity. A ref, not state: + // nothing renders it, and a send must not be able to re-render the + // composer before its request is built. + const followUpSeqRef = useRef(0); const fileRef = useRef(null); // The live session id used to live on a per-instance ref here. // That was correct for chat→chat switching (the instance survives) @@ -254,6 +268,18 @@ export function Composer({ const readOnly = state?.readOnly ?? false; const running = state?.running.active ?? false; + // SB-4 — what a send does while a turn is running. The behaviour comes + // from the settings page's 跟进消息行为 row + // (`webui-follow-up-behavior`), read once per mount and then followed + // through the module's live channel, the same shape + // `components/context-meter.tsx` uses for the context-window switch: the + // settings page and the composer are on screen together, so the switch + // has to take effect without a reload. + const [followUp, setFollowUp] = useState(() => readFollowUpBehavior()); + useEffect(() => subscribeFollowUpBehavior(setFollowUp), []); + // `wait` is the OFF position and means "render no send control", which + // is the pre-SB-4 composer exactly. See lib/follow-up.ts. + const followUpAction: FollowUpAction = resolveFollowUpAction(followUp, running); // The server stores the permission mode as its *label* (`webuiModeToLabel(id)` // in routes/model.js) while this selector is keyed by id, so comparing the two // directly never matched and the chip always fell back to 完全访问 — you could @@ -452,6 +478,17 @@ export function Composer({ const submit = useCallback(async () => { const content = value.trim(); if ((!content && attachments.length === 0) || readOnly || sending) return; + // SB-4 — freeze the action at dispatch. The switch can be flipped + // while the request is in flight, and a message that was QUEUED must + // not be reported (or retried) as one that was steered: the two reach + // different engine methods and mean different things to the user. + const action = followUpAction; + // `wait` is unreachable through the UI (the send control is not + // rendered while a turn runs), and this guard is what keeps it + // unreachable through the KEYBOARD as well: the textarea's Enter + // handler calls `submit` directly, so the button's absence is not + // enough. + if (action === "wait") return; // Capture the dispatch context — what session this send was FOR. // The outbox record stores these, so a later failure can identify // its owner. They are NOT the values the catch branch compares @@ -488,6 +525,28 @@ export function Composer({ }); setComposerDraft(dispatchDraftKey, { value: "", attachments: [] }); try { + // SB-4 — a send into a RUNNING turn is not a slash command and not + // a new turn: it goes to the one endpoint the follow-up family + // owns, carrying the action the switch selected. `requestId` is + // the per-send identity the engine dedupes on (the queue item's + // `clientRequestId` / the steering message's `idempotencyKey`), so + // a retried click cannot produce two messages. + if (action === "queue" || action === "steer") { + // A per-send identity, unique for this TAB and this send: the + // engine dedupes a queue item on `clientRequestId` and a steering + // message on `idempotencyKey`, so two sends that happened to carry + // the same text must not collapse into one. Bounded and charset- + // safe by construction (the server drops anything else). + followUpSeqRef.current += 1; + await api.submitFollowUp({ + behavior: action, + content, + attachments, + requestId: `${dispatchCid}.${Date.now().toString(36)}.${followUpSeqRef.current}`, + }); + completeComposerSent(); + return; + } // A slash input is a message OR a command, and only the eight // webui button commands belong to /api/cmd — routing on the // leading slash alone sent `/goal ` and `/compact` to an @@ -521,14 +580,26 @@ export function Composer({ // resend, the engine is running it" wording would be the exact // opposite of the truth. const busy = !unconfirmed && isConversationBusy(cause); + // SB-4 — the follow-up family's two refusals. Both are "not + // delivered" for a reason the user can act on, so they get their + // own banner (a finished sentence, not a server string) and they + // are NOT routed through the unconfirmed probe: the probe asks + // whether a MESSAGE became a turn, and a follow-up is by + // definition not a turn. + const followUpRefusal = + !unconfirmed && (action === "queue" || action === "steer") + ? followUpFailureKey(cause) + : null; const outcome: SendProbeOutcome | null = unconfirmed ? await probeSend(content) : null; const errorMessage = unconfirmed ? "" - : cause instanceof Error - ? cause.message - : String(cause); + : followUpRefusal + ? t(followUpRefusal) + : cause instanceof Error + ? cause.message + : String(cause); // Read the LIVE context at catch time. The dispatch-side // closure has the session id from when the user pressed // Enter; if the user has since switched sessions (e.g. via the @@ -585,13 +656,19 @@ export function Composer({ } setComposerDraft(dispatchDraftKey, { error: errorMessage, - errorKind: unconfirmed ? "unconfirmed" : busy ? "busy" : "rejected", + errorKind: unconfirmed + ? "unconfirmed" + : followUpRefusal + ? "followUp" + : busy + ? "busy" + : "rejected", unconfirmed: outcome, }); } finally { setSending(false); } - }, [value, attachments, readOnly, sending, state?.sessionId]); + }, [value, attachments, readOnly, sending, state?.sessionId, followUpAction, t]); const onPickFiles = useCallback(async (files: FileList | null) => { if (!files?.length) return; @@ -949,7 +1026,12 @@ export function Composer({ {running ? ( /* Upstream's stop control is a 30px circle in the quaternary icon colour with a 12px square inside, not a rounded square in the - danger colour. Classes are the upstream ones verbatim. */ + danger colour. Classes are the upstream ones verbatim. + SB-4: it is no longer the ONLY control while a turn runs — + the send button below comes back when the follow-up + behaviour is queue or steer, because stopping the turn and + answering it are two different things a user may want in the + same second. */ - ) : ( - /* Voice input is not implemented in this frontend (the server - has no ASR contract), so the mic is present but permanently - disabled rather than hidden. The send button is the up arrow - and is always rendered, disabled until there is something to - send. */ + ) : null} + {/* Voice input is not implemented in this frontend (the server + has no ASR contract), so the mic is present but permanently + disabled rather than hidden. The send button is the up arrow + and is always rendered, disabled until there is something to + send. + SB-4: the group is rendered whenever the send is possible — + which, while a turn runs, depends on the follow-up + behaviour. `wait` (the OFF position) renders neither, and that + is the pre-SB-4 composer exactly. */} + {running && followUpAction === "wait" ? null : ( <> + + + + ); +} diff --git a/packages/webui/webapp/components/settings-extra-pages.tsx b/packages/webui/webapp/components/settings-extra-pages.tsx index c28e18ec..a1aba8ba 100644 --- a/packages/webui/webapp/components/settings-extra-pages.tsx +++ b/packages/webui/webapp/components/settings-extra-pages.tsx @@ -64,6 +64,19 @@ import { readCodeReviewGuidelines, readCustomInstructions, } from "../lib/settings-local"; +import { + applyBinding, + chordFromStroke, + clearBinding, + formatChord, + readCustomBindings, + resolveBindings, + shortcutSpec, + writeCustomBindings, + type BlockedReason, + type ShortcutId, + type ShortcutStatus, +} from "../lib/shortcuts"; import { Icon } from "./icons"; // --- structural copies of the panels.tsx primitives ------------------------- @@ -268,128 +281,175 @@ function PersistedTextBlock({ } // --- 快捷键 (Shortcuts) ------------------------------------------------------- +// +// The Shortcuts page is the one settings page whose honesty problem was +// not "no capability" but "a capability hidden behind a false notice": +// `app/page.tsx` dispatched Ctrl+N and Ctrl+, while this page printed all +// ten desktop rows disabled behind 「浏览器环境不适用」. Both halves now +// read `lib/shortcuts.ts` — the registry that says which combinations a +// browser hands to a page at all, and what each dispatched row is bound +// to — so a row cannot be shown dead while the handler fires it. +// +// Three states render differently: +// +// - `live` the handler dispatches it on every platform, and the box +// records a new combination. ✕ drops the customisation and +// restores the desktop default. +// - `partial` the handler dispatches it where the browser leaves the +// combination free (Ctrl+N is a new window in Chromium and +// Firefox on Windows and Linux, so only macOS delivers it). +// Live, therefore shown as such, but not editable: a +// rebinding would not make it work everywhere. +// - `blocked` no honest binding exists, and the row says WHICH of the +// three reasons applies — a combination the browser owns +// (Ctrl+T-style interception is impossible from a page), a +// surface the WebUI does not have, dictation with no speech +// recognition behind it, or an action whose semantics are +// still undecided. The row keeps the desktop's printed +// combination for reference; the box stays disabled. +// +// Overrides persist under `webui-shortcut-bindings` (see the lib) and a +// combination already dispatched by another row is refused with the +// conflicting action named, rather than stored into an order-dependent +// tie. -/** One shortcut row's static description. `binding` is the desktop default - * verbatim (key combos are locale-independent, hence not in i18n); `null` - * is the desktop's unset state (未设置), which renders no ✕ — matching - * `ref-09`. `reset` marks the one row the reference gives an external ↺ - * affordance (Mini Chat). */ -interface ShortcutRowDef { - id: string; +/** The Shortcuts page's group layout. Group membership is the desktop's + * (ref-09): Mini Chat on its own, everything else under 常用. Which rows + * exist, what they are bound to and whether they are live comes from + * `SHORTCUT_SPECS` — this table only says which card a row sits in. */ +const SHORTCUT_GROUPS: { titleKey: MessageKey; - hintKey: MessageKey; - binding: string | null; - reset?: boolean; -} - -const SHORTCUT_GROUPS: { titleKey: MessageKey; testId: string; rows: ShortcutRowDef[] }[] = [ + testId: string; + ids: readonly ShortcutId[]; +}[] = [ { titleKey: "settings.shortcuts.group.miniChat", testId: "settings-shortcuts-group-minichat", - rows: [ - { - id: "mini-chat", - titleKey: "settings.shortcuts.item.miniChat", - hintKey: "settings.shortcuts.item.miniChatHint", - binding: "Alt+M", - reset: true, - }, - ], + ids: ["mini-chat"], }, { titleKey: "settings.shortcuts.group.common", testId: "settings-shortcuts-group-common", - rows: [ - { - id: "global-search", - titleKey: "settings.shortcuts.item.globalSearch", - hintKey: "settings.shortcuts.item.globalSearchHint", - binding: "Ctrl+K", - }, - { - id: "search-tasks", - titleKey: "settings.shortcuts.item.searchTasks", - hintKey: "settings.shortcuts.item.searchTasksHint", - binding: "Ctrl+G", - }, - { - id: "new-task", - titleKey: "settings.shortcuts.item.newTask", - hintKey: "settings.shortcuts.item.newTaskHint", - binding: "Ctrl+N", - }, - { - id: "new-task-no-project", - titleKey: "settings.shortcuts.item.newTaskNoProject", - hintKey: "settings.shortcuts.item.newTaskNoProjectHint", - binding: "Ctrl+Alt+O", - }, - { - id: "open-folder", - titleKey: "settings.shortcuts.item.openFolder", - hintKey: "settings.shortcuts.item.openFolderHint", - binding: "Ctrl+O", - }, - { - id: "open-settings", - titleKey: "settings.shortcuts.item.openSettings", - hintKey: "settings.shortcuts.item.openSettingsHint", - binding: "Ctrl+,", - }, - { - id: "hold-dictation", - titleKey: "settings.shortcuts.item.holdDictation", - hintKey: "settings.shortcuts.item.holdDictationHint", - binding: null, - }, - { - id: "toggle-dictation", - titleKey: "settings.shortcuts.item.toggleDictation", - hintKey: "settings.shortcuts.item.toggleDictationHint", - binding: null, - }, - { - id: "invert-follow-up", - titleKey: "settings.shortcuts.item.invertFollowUp", - hintKey: "settings.shortcuts.item.invertFollowUpHint", - binding: "Ctrl+Enter", - }, + ids: [ + "global-search", + "search-tasks", + "new-task", + "new-task-no-project", + "open-folder", + "open-settings", + "hold-dictation", + "toggle-dictation", + "invert-follow-up", ], }, ]; -/** The shortcut binding cell — the desktop's keycapture input in its - * disabled form: a 150px bordered box holding the binding text (or the - * unset placeholder), with the ✕ clear affordance inside-right for set - * rows. Nothing is editable: a browser page cannot rebind global - * shortcuts, so the input is readOnly + disabled and the ✕ is disabled. */ +/** The row id → i18n key suffix mapping, derived rather than hand-listed: + * the Shortcuts page's item keys are `settings.shortcuts.item.` plus the + * id in camelCase (`new-task-no-project` → `newTaskNoProject`). A hand + * table would drift from the registry the moment a row is added. */ +function itemKey(id: ShortcutId): MessageKey { + const camel = id.replace(/-([a-z])/g, (_, letter: string) => letter.toUpperCase()); + return `settings.shortcuts.item.${camel}` as MessageKey; +} + +/** The i18n key for a blocked row's reason. */ +const REASON_KEYS: Readonly> = { + browserReserved: "settings.shortcuts.reason.browserReserved", + noSurface: "settings.shortcuts.reason.noSurface", + noDictation: "settings.shortcuts.reason.noDictation", + pending: "settings.shortcuts.reason.pending", +}; + +/** The i18n key for a dispatched row's status badge. */ +const STATUS_KEYS: Readonly> = { + live: "settings.shortcuts.status.live", + partial: "settings.shortcuts.status.partial", +}; + +/** The binding cell. Two forms, one component: the desktop's keycapture + * input. + * + * - `status` set (the Shortcuts page): a `live` box records the next + * combination the user presses and reports it upward; a `partial` box + * shows the same text but takes no input, because rebinding it would + * not make it fire on the platforms where the browser owns the + * combination; a `blocked` box is the disabled reference form the + * desktop shows, and the reason is rendered by the caller as the + * row's caption. + * - `status` omitted (the Voice page's dictation rows): the old + * readOnly+disabled unset placeholder, unchanged. + */ function BindingBox({ binding, unsetLabel, testId, t, + status, + customized, + disabled = false, + onCapture, + onClear, + onCancel, }: { binding: string | null; unsetLabel: string; testId: string; t: (key: MessageKey) => string; + status?: ShortcutStatus; + /** The effective combination differs from the desktop default. */ + customized?: boolean; + disabled?: boolean; + onCapture?: (chord: string) => void; + onClear?: () => void; + onCancel?: () => void; }) { const unset = binding === null; + // Only a `live` row is editable. A row with no `status` (the Voice + // page's dictation rows) is the desktop's dead reference form. + const editable = status === "live" && onCapture !== undefined; + const isDisabled = disabled || status !== "live"; + const onKeyDown = (event: React.KeyboardEvent) => { + if (!editable) return; + // Escape abandons a capture in progress without touching storage. + if (event.key === "Escape") { + event.preventDefault(); + event.currentTarget.blur(); + onCancel?.(); + return; + } + const chord = chordFromStroke(event); + if (!chord) return; + // preventDefault first: a captured combination must not also run its + // own action (recording Ctrl+, must not open the settings page). + event.preventDefault(); + onCapture(formatChord(chord)); + }; return (
{unset ? null : ( - ) : null} - - - - ))} + {group.ids.map((id, index) => { + const spec = shortcutSpec(id); + const binding = bindings[id]; + const customized = custom[id] !== undefined; + const blocked = spec.status === "blocked"; + const conflictLine = + conflict && conflict.id === id + ? `${t("settings.shortcuts.conflict")} ${t(itemKey(conflict.with))}` + : null; + return ( + + {index > 0 ? : null} + +
+
+ {spec.reset ? ( + /* The reference's external ↺ affordance on the Mini + * Chat row. That row is blocked, so the control + * stays dead — the shape is kept for parity. */ + + ) : null} + commit(id, chord)} + onClear={() => restore(id)} + onCancel={() => setConflict(null)} + /> + {blocked ? null : ( + + {customized ? t("settings.shortcuts.status.customized") : t(STATUS_KEYS[spec.status as "live" | "partial"])} + + )} +
+ {conflictLine ? ( + + {conflictLine} + + ) : null} +
+
+
+ ); + })} ))}
diff --git a/packages/webui/webapp/components/settings-modal-port.tsx b/packages/webui/webapp/components/settings-modal-port.tsx index 18764009..ee9f3bfb 100644 --- a/packages/webui/webapp/components/settings-modal-port.tsx +++ b/packages/webui/webapp/components/settings-modal-port.tsx @@ -17,9 +17,13 @@ * localStorage key);usage → 三来源切换头,token-plan 落点复用 53 号 * 的 `UsageModelsSection`(headless,只出四张卡)、custom 落点复用 54 * 号 `ProviderManagementPanel`;connection → 本仓既有连接面板。 - * - minimax-api 来源:本仓 base 无 MiniMax API Key 后端 capability, - * 面板按参照形态渲染、操作件禁用(capability honesty,参照对缺 - * capability 的语义同样是不绑不定)。 + * - minimax-api 来源:SB-1 起接真后端(`GET/PUT /api/model-source`、 + * `PUT /api/model-source/api-key`、`POST /api/model-source/test`, + * 服务端 `server/engine/model-source.js` 消费引擎的 + * `getMiniMaxModelSource` / `setMiniMaxModelSource` / + * `upsertMiniMaxApiKey` / `testUserModel`)。三来源切换是真动作, + * 「使用中」徽标渲染引擎回读的真值;检测读的是已保存的密钥,引擎 + * 的 `testUserModel` 不接受临时 key,输入未保存时按钮禁用。 * - voice/shortcuts/custom-instructions/coding/worktree 五个 Tab 照抄 * 参照的空面板形态;其真实内容由 deploy-55 分支的 55a 四子页提供, * 合并后在对应分支接线。 @@ -49,6 +53,8 @@ import { applyAppearance, currentAppearance } from "@/lib/theme"; import type { Locale, MessageKey } from "@/lib/i18n"; import { ProviderManagementPanel } from "./provider-management"; import { SettingsPanel, UsageModelsSection } from "./panels"; +// SB-5:账户 Tab 的真读取分区(`GET /api/account`),见该文件头的分工说明。 +import { AccountSection } from "./settings-account-section"; // 55a 四子页(工单 58 线 D 接线):与移植壳同目录的纯前端组件,无 // store/api 依赖;面板自带 localStorage 持久化(lib/settings-local.ts)。 import { @@ -477,19 +483,10 @@ export function SettingsModalPort({ ) : null} {active === "account" ? (
- - {/* 本地版无账户会话:按参照形态渲染账户信息行(空值)与 - * 退出登录(无 capability,禁用)。 */} -
-
- {t("settings.account.info")} - {t("settings.account.localLoggedOut")} -
-
- - {t("settings.account.signOut")} - -
+ {/* SB-5:账户分区接 `GET /api/account`(账户名 / 当前套餐 / 配额读数 / + * 账户状态)。SB-5 之前这里是恒空态的硬编码行。退出登录维持禁用——引擎 + * 没有登录登出方法(`doc/settings-batch-plan.md` §1.2 拍板)。 */} +
) : null} {active === "archived" ? ( @@ -799,8 +796,22 @@ function ModeCard({ // --- 用量与模型(参照 UsageModelSettings 的三来源切换头)------------------- +/** + * The view tab — which panel the user is LOOKING at. Distinct from + * `activeSource`, which is what the ENGINE is using. The reference draws + * the same two things: the pill is the view, the 「使用中」 badge is the + * truth. Collapsing them is what let the old build pick a source in the + * dropdown and show it as selected while the engine kept using the other + * one. + */ type UsageSourceTab = "token-plan" | "minimax-api" | "custom"; +/** The switcher tab ↔ the engine source it selects. */ +const TAB_TO_ENGINE_SOURCE = { + "token-plan": "token_plan", + "minimax-api": "minimax_api_key", +} as const; + function UsageModelSettingsPort({ t, autoAddProvider, @@ -814,14 +825,175 @@ function UsageModelSettingsPort({ const [sourceMenuOpen, setSourceMenuOpen] = useState(false); const [apiKey, setApiKey] = useState(""); + // SB-1: the engine's truth for this tab. `null` while the read is in + // flight and after a failed one — the badge renders nothing in both + // cases rather than guessing, because a badge that claims 「使用中」 for + // a source the engine never accepted is the fake-success shape this + // repository keeps refusing. + const [activeSource, setActiveSource] = useState(null); + const [keyStatus, setKeyStatus] = useState(null); + const [loadState, setLoadState] = useState<"loading" | "ready" | "error">("loading"); + const [busy, setBusy] = useState<"source" | "key" | "test" | null>(null); + const [notice, setNotice] = useState<{ + tone: "ok" | "error"; + text: string; + /** What the engine refused. `keyRequired` is the one verdict a later + * keystroke invalidates (UAT4-2): once a key is typed, 「请先填写 + * API Key」 is stale and contradicted the input box next to it. */ + kind?: "keyRequired"; + } | null>(null); + + // P20 (UAT4-1): a source write changes which account the engine + // reports, so the Token Plan card's `GET /api/account` read has to + // follow it. Bumped on SUCCESS only — a refused switch changed + // nothing, and re-reading on failure would just spend a request to + // re-render the same answer. + const [accountRevision, setAccountRevision] = useState(0); + const revalidateAccount = useCallback( + () => setAccountRevision((revision) => revision + 1), + [], + ); + + // Read the engine's source when the tab mounts. This is deliberately + // NOT a page-level fetch: the server boots the engine runtime to + // answer it, and a boot belongs to a user action (opening the tab) — + // see `server/engine/model-source.js` KNOWN DEBT 2. + useEffect(() => { + let cancelled = false; + void (async () => { + try { + const snapshot = await api.getModelSource(); + if (cancelled) return; + setActiveSource(snapshot.source); + setKeyStatus(snapshot.apiKey); + setLoadState("ready"); + } catch { + if (cancelled) return; + setActiveSource(null); + setKeyStatus(null); + setLoadState("error"); + } + })(); + return () => { + cancelled = true; + }; + }, []); + const sourceLabel = sourceTab === "token-plan" ? "Token Plan" : "MiniMax API"; + const keyAvailable = keyStatus !== null && keyStatus.available; + const hasStoredKey = keyStatus?.hasKey === true; + const hasUnsavedKey = apiKey.trim().length > 0; + + /** + * Select a source: move the view AND switch the engine in one action. + * + * The order matters. The view moves first (it is instant and reversible + * by clicking again), then the engine call; a refusal leaves the + * VIEW where the user put it — so a failed switch to MiniMax API shows + * the key field the user has to fill, rather than snapping back to a + * panel that does not explain the failure — while the badge keeps + * showing the source that is actually in use. + */ + const chooseSource = useCallback( + async (tab: Exclude) => { + setSourceMenuOpen(false); + setSourceTab(tab); + const wanted = TAB_TO_ENGINE_SOURCE[tab]; + if (wanted === activeSource) return; + setBusy("source"); + setNotice(null); + try { + const written = await api.setModelSource(wanted); + // The response carries what the engine PERSISTED, not what was + // requested, so a re-read of the badge can never disagree with + // the config. + setActiveSource(written.source); + // P20: the engine has now rebound to the new source, so the Token + // Plan card's account read is stale by construction. + revalidateAccount(); + } catch (cause) { + // Only the NO_API_KEY refusal is invalidated by typing; a generic + // switch failure is still true after the next keystroke, so it + // carries no `kind` and survives the edit. + const keyRefused = + cause instanceof api.ApiHttpError && api.hasApiErrorCode(cause, "NO_API_KEY"); + setNotice({ + tone: "error", + kind: keyRefused ? "keyRequired" : undefined, + text: keyRefused + ? t("usageModels.minimax.keyRequired") + : t("usageModels.source.switchFailed"), + }); + } finally { + setBusy(null); + } + }, + [activeSource, revalidateAccount, t], + ); + + const saveKeyAndUse = useCallback(async () => { + const raw = apiKey.trim(); + if (!raw) { + setNotice({ tone: "error", kind: "keyRequired", text: t("usageModels.minimax.keyRequired") }); + return; + } + setBusy("key"); + setNotice(null); + try { + // `saveAndUse` writes the key and switches the source in ONE + // engine transaction, so the tab never shows a saved key beside a + // source that was not switched. + const result = await api.putModelSourceApiKey({ apiKey: raw, saveAndUse: true }); + setKeyStatus(result.apiKey); + setActiveSource(result.source); + setApiKey(""); + setSourceTab("minimax-api"); + setNotice({ tone: "ok", text: t("usageModels.minimax.saved") }); + // P20: `saveAndUse` switched the engine's source too, so the Token + // Plan card's account read is stale by the same construction. + revalidateAccount(); + } catch (cause) { + setNotice({ + tone: "error", + text: cause instanceof Error ? cause.message : t("usageModels.minimax.keyRequired"), + }); + } finally { + setBusy(null); + } + }, [apiKey, revalidateAccount, t]); + + const testKey = useCallback(async () => { + setBusy("test"); + setNotice(null); + try { + const result = await api.testModelSourceModel(); + setKeyStatus((previous) => + previous === null + ? previous + : { ...previous, testState: result.status.state ?? previous.testState }, + ); + setNotice({ + tone: result.success ? "ok" : "error", + text: result.success + ? t("usageModels.minimax.testOk") + : result.status.lastErrorMessage || t("usageModels.minimax.testFailed"), + }); + } catch (cause) { + setNotice({ + tone: "error", + text: cause instanceof Error ? cause.message : t("usageModels.minimax.testFailed"), + }); + } finally { + setBusy(null); + } + }, [t]); return (
{/* 参照的三来源切换头:来源 pill(下拉切 Token Plan / MiniMax API) - * + 细分隔线 + 自定义模型按钮。来源切换只改视图——本仓 base 无 - * setMiniMaxModelSource 后端,参照的“使用中”徽标与持久化切换属 - * 后续轮次。 */} + * + 细分隔线 + 自定义模型按钮。SB-1 起切换是真动作:下拉点选同时 + * 改视图并写引擎(PUT /api/model-source),「使用中」徽标渲染的是 + * 引擎回读的真值而非本地 state。 */}
) : null}
+ {/* 徽标只在读到了真值、且当前视图就是那一来源时出现:视图是 + * token-plan 时一个「MiniMax API 使用中」的徽标会挂在错的行上。 */} + {activeSource !== null && + TAB_TO_ENGINE_SOURCE[sourceTab as "token-plan" | "minimax-api"] === activeSource ? ( + + {t("usageModels.source.inUse")} + + ) : null}
+ {notice ? ( +

+ {notice.text} +

+ ) : null} + {sourceTab === "token-plan" ? (
- {/* 53 号四张卡(headless:内部切换头由本组件的三来源头取代)。 */} - + {/* 53 号四张卡(headless:内部切换头由本组件的三来源头取代)。 + P20:accountRevision 随切源成功递增,卡片的 /api/account + 读随之重跑(UAT4-1)。 */} +
) : null} @@ -898,9 +1096,25 @@ function UsageModelSettingsPort({
- {/* 无后端 capability:恒“未启用”,参照的 valid 徽标分支不触发。 */} - - {t("usageModels.minimax.notEnabled")} + {/* 徽标读的是引擎回传的掩码投影:available=false(服务读不到 + * 密钥半边)与 hasKey=false(确实没存)是两件事,分开渲染。 */} + + {loadState === "loading" + ? t("usageModels.minimax.loading") + : !keyAvailable + ? t("usageModels.minimax.unavailable") + : // UAT4-2: a typed-but-unsaved key outranks both stored + // and absent — 「已保存密钥」 beside a field the user + // is actively replacing is stale, and 「未启用」 is + // false once something is typed. + hasUnsavedKey + ? t("usageModels.minimax.pendingSave") + : hasStoredKey + ? t("usageModels.minimax.configured") + : t("usageModels.minimax.notEnabled")}
@@ -908,27 +1122,67 @@ function UsageModelSettingsPort({ aria-label="API Key" type="password" value={apiKey} - onChange={(event) => setApiKey(event.target.value)} - placeholder={t("usageModels.minimax.apiKeyPlaceholder")} + onChange={(event) => { + setApiKey(event.target.value); + // UAT4-2: 「请先填写 API Key」 is the engine's verdict on + // an EMPTY field. Typing falsifies it, and leaving it up + // beside a filled field is the contradiction the UAT + // caught. Every other verdict stands. + setNotice((previous) => + previous && previous.kind === "keyRequired" ? null : previous, + ); + }} + placeholder={ + hasStoredKey + ? t("usageModels.minimax.storedPlaceholder") + : t("usageModels.minimax.apiKeyPlaceholder") + } className="min-w-0 flex-[1_0_0] rounded-[8px] border border-border_default bg-bg_default_primary px-3 py-2 text-[14px]" /> + {/* 检测读的是「已保存的密钥」——引擎的 testUserModel 不接受 + * 临时 key(v2 无 override 通道),所以输入框里有未保存的值时 + * 按钮禁用。UAT4-2:这个理由原本只在 title 里,键盘与触屏用户 + * 看不到,等于没有解释;门控开启期间下方常驻一行说明。 */}
+ {!hasStoredKey || hasUnsavedKey ? ( +

+ {t("usageModels.minimax.probeGate")} +

+ ) : null}
) : null} diff --git a/packages/webui/webapp/components/usage-models-cards.tsx b/packages/webui/webapp/components/usage-models-cards.tsx index 4da91246..2d9c6588 100644 --- a/packages/webui/webapp/components/usage-models-cards.tsx +++ b/packages/webui/webapp/components/usage-models-cards.tsx @@ -19,10 +19,12 @@ import { Switch } from "antd"; * loading-states.tsx is tested — panels.tsx itself pulls the session * store and the api graph and stays unimportable in a test process. * - * The data policy lives in the panels.tsx section wrapper (decision A1/B1, - * ticket 53): every figure region the local server has no source for - * renders the 「本地版不适用」 placeholder, and controls keep the desktop - * reference's form but disabled. + * The data policy lives in the panels.tsx section wrapper (decision A1/B1 + * from ticket 53, revised by SB-7): a figure the local server has a source + * for is rendered from it — the plan name comes in as a prop — and the + * figures whose source is the cloud account domain render the honest + * placeholder that names that domain as the reason, while controls keep the + * desktop reference's form but disabled. */ /** @@ -31,13 +33,31 @@ import { Switch } from "antd"; * the black 「升级」 primary button plus 「管理 ⌄」 on top, 「去充值」 plus * 「管理 ⌄」 below. * - * The local edition has no cloud-account source for any of the figures, so - * by decision A1 the data regions render the 「本地版不适用」 placeholder, - * the expiry line is omitted rather than given a fabricated date, and every - * action renders in the desktop's form but disabled — there is nothing - * local for 升级 / 管理 / 去充值 to act on. + * SB-7 (the A1 revision): the plan NAME is a real figure, read from + * `tokenPlan.tier` on /api/account by the container. The remaining + * figures stay honest placeholders, and the reason is now the accurate + * one — credits, expiry and invoicing live in the cloud account domain, + * which a self-hosted browser session has no credentials for. The expiry + * line is omitted rather than given a fabricated date, and every action + * renders in the desktop's form but disabled: 升级 / 管理 / 去充值 all act + * on the cloud account, not on the local server. */ -export function PlanCard({ t }: { t: (key: MessageKey) => string }) { +export function PlanCard({ + t, + planName, + planPending = false, +}: { + t: (key: MessageKey) => string; + /** `tokenPlan.tier` from /api/account, or null when the engine reported + * no plan / no answer. A blank or absent name renders the honest + * 「未订阅套餐」 line, never a fallback tier. */ + planName?: string | null; + /** A read is in flight and no name is known yet. Distinct from "no + * plan": an account surface that has not answered has not said the + * user has no plan, and printing 「未订阅套餐」 during that window is + * the same lie UAT4-1 reported after a source switch. */ + planPending?: boolean; +}) { const manageButton = (testId: string) => (
- - {t("usage.notLocal")} - + {planName ? ( + + {planName} + + ) : planPending ? ( + + {t("usage.plan.loading")} + + ) : ( + + {t("usage.plan.noPlan")} + + )}
- {/* The reference's black primary button, disabled (no local - * plan to upgrade). */} + {/* The reference's black primary button, disabled (the upgrade + * path belongs to the cloud account). */}