Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions docs/webui.md
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,7 @@ Default provider is `local-runtime-v2` (the only registered host provider until
### Migration state and constraints

- **M1 done in this batch**: host construction (`createCatalogueHost`) moved verbatim into `server/engine/providers/local-runtime-v2.js`; `runtime-host.js` re-exports it, so every existing importer is untouched. No existing route's behaviour changed; `GET /api/engine-capabilities` is a new, additive endpoint.
- **M2 done (declaration-vs-implementation snapshot)**: `packages/webui/test/lib/engine/capability-snapshot.test.js` boots a REAL catalogue host on an isolated tmp data dir (`MINIMAX_DATA_DIR` + every `MCODE_WEBUI_*` path pinned before the provider import) and audits every `full`/`partial` key of both providers — `full` requires every tracked method to exist on the declared surface (`adapter` / `cliService` / `applications.session.diff`), `partial` requires the present half to exist, the method-named `missing` items to be genuinely absent, and kebab-case sub-capabilities (`file-write`, `git-diff`) to have no covering method; `none` is not method-checked. The tracked-method table was reflected off the live surfaces (91 adapter / 94 CliService methods), not copied from the design matrix; mutation tests in the same file pin that flipping a level, deleting a method, or growing a sub-capability each goes red. A registry-driven guard (`engine/index.js#listEngineProviderIds`) rejects any provider declaration carrying keys outside the 14-key contract, so a typo cannot pass silently.
- **Capability probing (design §2.3 step 2) is deliberately not in this batch**: no route consumes a probe result yet, and wiring one would touch the catalogue host lifecycle that M1 leaves alone. It lands with the first A-batch route that needs it.
- **New-provider admission rules** (enforced by the snapshot tests in `packages/webui/test/lib/engine/capabilities.test.js`): all 14 keys declared; `partial` enumerates `missing` + `reason`; declaration levels are pinned — a level flip without re-auditing the surface goes red in CI; calling an undeclared capability answers the structured 501, never an empty implementation.

Expand Down Expand Up @@ -2039,6 +2040,17 @@ failure mode we care about is the `app/global-error.tsx` crash, not a quota
error here. Per-session scroll keys are deliberate: a refresh restores
the user's place in each conversation independently.

One timing invariant guards all of it (webui-parity 106): the page root
never reads these keys during render. The prerendered server HTML and the
client's first (hydration) render must be identical, and a render-phase
storage read breaks that equality the moment the `state === null` skeleton
changes shape. `app/page.tsx` renders its first frame from the shared
DEFAULT constants and applies the stored payload in one post-mount effect;
the three write-back mirrors are gated on that restore having run, so the
defaults-seeded first render cannot overwrite the stored payload. What the
user sees is unchanged: the skeleton is still up while the restore lands,
and by the time the first snapshot arrives the saved layout is in place.

## Slash commands: which endpoint answers them (webui-parity ticket 65)

A `/`-prefixed line in the composer is not automatically a command. Two
Expand Down Expand Up @@ -2251,6 +2263,23 @@ losing what the user typed is the worse defect, and the banner carries the
"check the history first" instruction that makes the restore safe. The
banner is also styled as secondary text rather than as an error.

The banner's *display* semantics are the three answers above; its *dismissal*
is separate (webui-parity 106). While `running.active` is up, the warning is
doing its job. When the flag falls — the turn it warned about is over — the
grey banner goes with it (`unconfirmedPatchOnTurnEnd` in
`webapp/lib/composer-draft.ts`, applied by a composer effect that watches the
running-flag fall): after `sleep 35` finished, the banner used to sit under
the input until the next send or a reload. A real `rejected` refusal keeps
its dismiss paths; no display rule changed.

The banner is also addressed, not broadcast. The draft store is keyed by
session, and the catch branch writes the banner into the key of the session
the send was dispatched FROM — so a failure recorded in session A while the
user has already switched to session B never paints B red; the user finds
the banner when they return to A. The previous behaviour (a module-scope
shared box, then #141's clear-on-switch) either bled the banner across
sessions or destroyed the returning session's own unread one.

A client-generated idempotency key on `POST /api/send` would make the
duplicate structurally impossible rather than merely unlikely. It is not
implemented: it is a request-contract change, and it needs a
Expand All @@ -2262,6 +2291,38 @@ an engine turn, and leave the tab open: the output is still there ten
seconds later, and it is still there after a reload. Force an
acknowledgement timeout against a server that is running the turn: the
banner says the engine is running the message, and the composer is empty.
Wait for the turn to finish: the grey banner disappears on its own.

## The composer's state is per-session (webui-parity 106)

Everything the user has parked in the composer — typed text, attachment
chips, the send-error banner — is stored under the active session's key
(`webapp/lib/composer-draft.ts`, a `Map` keyed by `state.sessionId`; `""` is
the no-session home-screen bucket). Switching sessions swaps the whole box:
session B never shows session A's draft or banner, and both survive the
round trip. The smoke run's s28 capture was the shared-bucket version of
this store: session 2's view showing session 1's draft, 409 banner and
model chip at the same time.

Per-session storage, not clear-on-switch, is the deliberate choice: a
clear-on-switch effect (the #141 interim fix) also fires when the user
comes BACK, destroying the very draft and unread banner they returned for.
Keyed storage keeps the good half of the old global behaviour (nothing is
lost when hopping between sessions) while removing the bleed. Drafts are
not persisted to `localStorage` — they are working state for the current
page visit; the persisted surface stays `lib/persist.ts`'s contract.

The model picker's chip VALUE always read the server snapshot and needs no
isolation; its local UI state (open cascade, previewed row, per-model draft
mirror) resets when the session key changes, so no menu state from session A
visually persists into session B's view. Whether a model pick made in one
session's view can land in another session's engine config is a
server-side `applyConfigOptionUpdate` question and out of this ticket's
frontend scope.

**How you would tell it works.** Type a draft in session A, switch to
session B: B's composer is empty and the chip follows B's server model.
Switch back: A's draft and any unread failure banner are exactly as left.

## Endpoint catalog (against current source)

Expand Down
51 changes: 50 additions & 1 deletion docs/webui.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,7 @@ GET /api/engine-capabilities[?provider=<id>]
### 迁移状态与边界

- **本批只做迁移第一步 M1**:host 构造(`createCatalogueHost`)原样移入 `engine/providers/local-runtime-v2.js`,`runtime-host.js` 转发导出,既有引用方零改动;没有任何现有路由行为变化,`GET /api/engine-capabilities` 是纯新增端点。
- **M2 已做(声明与实现的快照校验)**:`packages/webui/test/lib/engine/capability-snapshot.test.js` 在隔离的临时数据目录上起**真实** catalogue host(`MINIMAX_DATA_DIR` 与全部 `MCODE_WEBUI_*` 路径在 provider import 前钉死),审计两个 provider 的每个 `full`/`partial` 键——`full` 要求跟踪的方法在声明的 surface(`adapter` / `cliService` / `applications.session.diff`)上全部存在;`partial` 要求存在的部分在、方法名形态的 `missing` 项真的不存在、kebab-case 子能力(`file-write`、`git-diff`)没有覆盖方法;`none` 不做方法校验。方法跟踪表是对真实 surface 的反射取证(adapter 91 个 / CliService 94 个方法),不是抄设计矩阵;同文件的变异测试钉住改档位、删方法、子能力长出方法各自必然红。注册表驱动的守卫(`engine/index.js#listEngineProviderIds`)拒绝任何携带 14 键契约之外键的 provider 声明,拼错无法静默通过。
- **启动只读探测(设计稿 §2.3 第 2 步)本批刻意不做**:尚无路由消费探测结果,而接探测要动 M1 明确不动的 catalogue host 生命周期;随第一个需要它的 A 批路由一起落。
- **新 provider 准入规则**(由 `packages/webui/test/lib/engine/capabilities.test.js` 快照测试钉住):14 键全声明;`partial` 必须枚举 `missing` 与 `reason`;声明档位被测试钉死——不经重新审计改档位,CI 直接红;调未声明能力一律答结构化 501,绝不给空实现。

Expand Down Expand Up @@ -1480,6 +1481,14 @@ loading-states 相同:让 SSR 渲染测试可以脱离 `chat.tsx` 的 `@/` 别
错误。会话内每个 sessionId 单独存储滚动位置 —— 按会话恢复滚动位置
是有意为之的契约。

一条时序不变式守着这一切(webui-parity 106):页面根组件**绝不在渲染期读
这些键**。预渲染的服务端 HTML 与客户端首次(hydration)渲染必须逐字节
一致,渲染期读存储会在 `state === null` 骨架屏第一次改形时炸出不一致。
`app/page.tsx` 首帧用共享的 DEFAULT 常量渲染,挂载后的一个 effect 统一
套用存储值;三处写回镜像都加闸在该恢复之后,默认值首帧不可能覆盖存储
payload。用户看到的东西不变:恢复落地时骨架屏仍亮着,第一份快照到达时
保存过的布局已经就位。

## 斜杠命令走哪个端点(webui-parity ticket 65)

输入框里以 `/` 开头的一行**不等于**命令。两个端点都能消费斜杠输入,
Expand Down Expand Up @@ -1658,13 +1667,53 @@ composer 实际调用的那个函数。
回填草稿——让用户输入的内容消失是更严重的缺陷,而文案里带着「先查历史」
这句指引,回填才是安全的。该错误条同时改用次要文字色,不再是错误红。

上面三种答案定的是错误条**何时显示**;**何时消失**是另一件事
(webui-parity 106)。`running.active` 亮着时,灰条在履行职责;这个标志
落下——它警告的那个回合结束了——灰条随之消失
(`webapp/lib/composer-draft.ts#unconfirmedPatchOnTurnEnd`,composer 里
一个盯 running 下降沿的 effect 负责套用):此前 `sleep 35` 跑完后,灰条
会一直挂在输入框下直到下次发送或刷新。真正的 `rejected` 拒绝保持原有的
消失路径;显示判定一字未动。

错误条还是**有归属**的,不是广播。草稿存储按会话分键,catch 分支把红条
写进**发起发送的那个会话**的键下——用户在会话 A 发送失败后已经切到
会话 B,B 的输入框永远不会因此变红;回到 A 时才看到这条失败。旧行为
(模块级共享桶,再到 #141 的切换即清)要么把红条串到别的会话,要么把
用户正要回去看的那个会话自己的红条销毁掉。

给 `POST /api/send` 加一个客户端生成的幂等键,可以让重复执行从「不太可能」
变成「结构上不可能」。本次没做:那是请求契约变更,还需要服务端带明确时间窗
的去重存储。留作独立一单,不塞进这次修复。

**怎么验证它真的好了。** 在一个已经有引擎回合的会话里发 `/help`,把标签页
放着:十秒后输出还在,刷新之后还在。对着一个「回合正在跑」的服务器制造一次
确认超时:错误条会说引擎正在执行这条消息,且输入框是空的。
确认超时:错误条会说引擎正在执行这条消息,且输入框是空的。等这个回合跑完:
灰色错误条自己消失。

## 输入区的状态按会话隔离(webui-parity 106)

用户停在输入区的一切——正在打的文字、附件 chips、发送失败红条——都存在
当前会话的键下(`webapp/lib/composer-draft.ts`,以 `state.sessionId` 为键
的 `Map`;`""` 是首页无会话的桶)。切换会话就是换一个盒子:会话 B 永远
不会显示会话 A 的草稿或红条,来回切换两边的状态都不丢。质检 s28 截图
拍到的正是这个存储的共享桶版本:会话 2 的视图同时挂着会话 1 的草稿、
409 红条和模型 chip。

按会话存储、而不是「切换时清空」,是权衡后的决定:清空 effect(#141 的
过渡修法)在用户**切回来**时同样触发,恰恰毁掉他们回来要看的那份草稿和
没读完的红条。按会话分键保住了旧全局行为里好的那一半(来回跳会话什么都不
丢),又去掉了串扰。草稿不落 `localStorage`——它们是本次页面访问的工作
状态;持久化面仍归 `lib/persist.ts` 的契约管。

模型选择器 chip 的**值**一直读服务端快照,本就不需要隔离;它的本地 UI
状态(打开的级联、预览中的行、按模型记的草稿镜像)在会话键变化时重置,
会话 A 的菜单状态不会在会话 B 的视图里残留。至于在一个会话视图里做的
模型选择会不会落进另一个会话的引擎配置,那是服务端
`applyConfigOptionUpdate` 的事,不在本单前端范围内。

**怎么验证它真的好了。** 在会话 A 打一段草稿,切到会话 B:B 的输入框是
空的,chip 跟着 B 的服务端模型走。切回 A:草稿和没读完的红条原样都在。


## 端点清单(依据当前源码)

Expand Down
Loading
Loading