docs: bilingual config-plane documentation, regenerated catalogs, and the web-config-plane Agent Note

This commit is contained in:
Yichen Jiang
2026-07-30 10:53:39 +08:00
parent 353f5c0a39
commit 51415debe5
50 changed files with 396 additions and 84 deletions

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-30-web-config-plane.md
2026-07-30-web-config-plane.md: 0f4368b9cac3a36d491ce0290562225b147b97e7
2026-07-30-web-config-plane.zh.md: 17e940baf6840654aa759e8558b71cdf049c8fcc

View File

@@ -0,0 +1,34 @@
# Agent Note: the web configuration plane
Status: implemented
English | [中文](2026-07-30-web-config-plane.zh.md)
> Scope: the wire face and web UI deferred from the [request-level LLM configuration note](2026-07-29-request-level-llm-config-credentials.md) — the `settings.*`/`credentials.*`/`llm.*` RPC domains with pushed invalidations, layered+redacted `describe()`, the llm configurable-provider directory and topology event, the standalone `dsh-client-schema-form` renderer, and the Models settings page. The `deepseek` → `deepseek-official` provider-route rename rides along as the enabling breaking change.
## Problem
PR1 made LLM adapter configuration restart-free at the seam, but the only writer was a text editor on `settings.yaml`: the web client had no wire access to settings, credentials, or provider topology, so "store a key, prompt again" still meant leaving the product. Three gaps blocked a config page rather than one: `describe()` returned only the merged effective value (a form cannot tell a user override from a composition default, and serializing it would have shipped `role('secret')` values to every browser), nothing enumerated the providers an adapter *could* run (a bare-mounted `llm-pi-ai` was invisible until configured), and the two adapters both wanted a `deepseek` route key, so the directory could not attribute routes to owning namespaces unambiguously. Hand-maintaining a form per provider was rejected outright — the schemas already exist as schemastery `Config` values, and a second source of field truth drifts.
## Decision
**Wire domains on the compiled RPC map, rejections as codes, invalidations as frames.** `settings.describe/update/replace`, `credentials.describe/set/unset`, `llm.providers`, and `llm.models` (claiming the reserved `host.listModels` surface) join `RpcMethodMap`, so the seven compiler-locked wiring sites keep contract, schema, handler, and client in lockstep. Seam rejections fold into `settings-rejected {ns}` / `credential-rejected {ref}` business errors (HTTP stays a carrier), and three `HostFrame`s — `host/settings-changed {ns}`, `host/credentials-changed {ref}`, `host/models-changed` — follow the `host/commands-changed` shape so every client converges without polling. Writes join `pickDirectory`/`openPath` in the connection guard's privileged set: loopback + same-origin or 403, because a LAN-exposed dsh web must not accept config mutation from another origin.
**`describe()` grows layers and structural secret redaction.** `SettingsDescriptor` carries `base`/`user` beside the effective value, so the form marks "overridden" by presence in the user layer, not value inequality (an override *equal* to the base is still an override). `describe({ redactSecrets: true })` — mandatory at every wire face — strips `role('secret')` subtrees from all three layers via a pure structural walk of the schema (object/dict/array containers; a secret-role subtree is one opaque leaf) and enumerates the stripped slots as `{path, set}`, so a page can render write-only inputs without ever receiving a value.
**The llm seam declares configurability and announces topology.** `registerConfigurableProviders()` is an all-or-nothing, fiber-scoped directory of `{provider, displayName, settingsNs, settingsPath}` — the addressing a config page needs to open the right settings subtree for a route that may not exist yet; `listConfigurableProviders()` merges with live routes in the wire handler so undeclared live routes still report active. The zero-payload `'llm/adapters-updated'` event fires from all four registration/unregistration commit points with contained listener dispatch (INVARIANT rethrow), following the settings/commands precedent. `llm-deepseek`'s route renamed to `deepseek-official` because the pi-ai catalog legitimately owns `deepseek` as an aggregator entry; pre-release stance, no alias.
**A standalone schema-driven form renderer.** `dsh-client-schema-form` rehydrates the wire's `toJSON()` envelope into live schemastery nodes and renders by structural classification: objects/dicts/arrays recurse, all-literal unions become selects (an absent value shows `Default: X` from the fallback layer), dict key-unions feed the add-entry vocabulary, and anything it cannot faithfully edit renders as read-only JSON — visible, never dropped. Presence-in-draft drives the override badge and per-field Reset; a `renderField` hook lets consumers mount role-specific controls without the renderer knowing any role.
**The Models page is a three-domain join with seam-shaped apply semantics.** Rows are configured providers; the add vocabulary is the dormant directory remainder; badges come from route liveness and the credential reference's value-free `configured` state. The `credential-ref` role mounts the credential control: reference name in settings, key value **write-only** through `credentials.set`. An edit without removals lands as a minimal `settings.update` merge patch (stored secrets outside the patch survive); a field reset or row deletion replaces the whole user section via `settings.replace`, because merge semantics cannot express removal.
## Alternatives considered
- **Serving JSON Schema over the wire** — schemastery's `toJSON()` envelope round-trips `role()`/meta and rehydrates into the validator the client already ships for drafts; converting to JSON Schema loses exactly the role annotations the credential control and secret redaction key on.
- **Masking secrets per-field with sentinel backfill on `replace`** — the PR1 decision (secrets are references) already deleted the stored-literal case for the product default; structural redaction plus a write-only credential path handles the residue without teaching every writer a sentinel protocol.
- **A `models` bridge plugin owning provider configuration** — same rejection as PR1: per-plugin namespaces plus a four-field directory declaration give the UI everything it needs; the bridge's unified dict re-imports the adapter-mapping indirection.
- **Page-side polling instead of pushed frames** — the mux already carries `host/commands-changed`; three more frames cost one shape each and make a second tab, an external `settings.yaml` edit, and a settings-born route converge at event speed.
## Consequences
The whole loop is pinned keyless in the browser lane (`apps/web/tests/models-settings.e2e.ts`): the dormant pi-ai catalog renders as add vocabulary, adding `anthropic` writes `settings.yaml` and the route registers live on the topology frame, the key stores write-only into the harness home's `.env`, and the badge converges from the credentials frame — zero model calls, ARIA goldens for the empty and configured states, plus a scaffold `harnessHome` so tests never touch a real `~/.dsh`. The rename touched 239 files (fixtures, goldens, docs, python) in one commit with no compatibility alias. Deferred: a per-row models preview (the picker already lists models), a page address for live routes that never declared configurability, and the documented reset edge — a `settings.replace` cannot re-supply a stored *literal* secret in the replaced subtree, which the reference-based default makes unreachable.

View File

@@ -0,0 +1,34 @@
# Agent Noteweb 配置平面
Status: implemented
[English](2026-07-30-web-config-plane.md) | 中文
> 范围:[请求级 LLM 配置 note](2026-07-29-request-level-llm-config-credentials.md) 中延后的 wire 面与 web UI——带推送式失效的 `settings.*`/`credentials.*`/`llm.*` RPC 领域、分层且脱敏的 `describe()`、llm 可配置提供方目录与拓扑事件、独立的 `dsh-client-schema-form` 渲染器,以及 Models 设置页。`deepseek` → `deepseek-official` 提供方路由重命名作为解锁前提的破坏性变更一并搭车合入。
## 问题
PR1 让 LLM大语言模型适配器配置在 seam 层面免重启,但唯一的写入方还是直接编辑 `settings.yaml` 的文本编辑器web 客户端没有触达设置、凭据或提供方拓扑的任何 wire 通道,「存入密钥、再次发起提示」于是仍意味着离开产品本身。挡住配置页的缺口不是一个,而是三个:`describe()` 只返回合并后的生效值(表单分不清用户覆盖与组合默认值,而且照原样序列化会把 `role('secret')` 的值发到每一个浏览器);没有任何东西枚举适配器*可以*运行的提供方(裸挂载的 `llm-pi-ai` 在配置之前完全不可见);两个适配器又都想要 `deepseek` 这个路由键,目录因此无法无歧义地把路由归到拥有它的 namespace 名下。为每个提供方手工维护一份表单被直接否决——schema 已经以 schemastery `Config` 值的形式存在,第二份字段真源注定漂移。
## 决策
**wire 领域挂上编译期 RPC 映射,拒绝落为错误码,失效落为帧。**`settings.describe/update/replace``credentials.describe/set/unset``llm.providers``llm.models`(认领预留的 `host.listModels` 面)一同加入 `RpcMethodMap`七处由编译器锁定的接线位点因此让契约、schema、处理器与客户端保持步调一致。seam 侧的拒绝折叠为 `settings-rejected {ns}`/`credential-rejected {ref}` 业务错误HTTP 仍只是载体),三个 `HostFrame`——`host/settings-changed {ns}``host/credentials-changed {ref}``host/models-changed`——沿用 `host/commands-changed` 的形状,因此每个客户端都无需轮询即可收敛。写入与 `pickDirectory`/`openPath` 一起进入连接守卫的特权集合:回环 + 同源,否则 403因为暴露在局域网上的 dsh web 绝不能接受来自其他源的配置修改。
**`describe()` 增加分层与结构化 secret 脱敏。**`SettingsDescriptor` 在生效值之外携带 `base`/`user`,表单据此按「字段是否出现在用户层」来标记「已覆盖」,而非按值是否不等(与 base *相等*的覆盖仍然是覆盖)。`describe({ redactSecrets: true })`——在每个 wire 面都强制启用——经由对 schema 的纯结构遍历object/dict/array 容器secret 角色子树整体是一个不透明叶节点)从全部三层剥除 `role('secret')` 子树,并把剥除的槽位枚举为 `{path, set}`,页面因此不必收到任何值就能渲染只写输入框。
**llm seam 声明可配置性并公布拓扑。**`registerConfigurableProviders()` 是一个全有或全无、以 fiber 为作用域的目录,条目为 `{provider, displayName, settingsNs, settingsPath}`——这正是配置页要为一条可能尚不存在的路由打开正确设置子树时所需要的寻址;`listConfigurableProviders()` 在 wire 处理器里与存活路由合并,未声明的存活路由因此仍报告为激活。零负载的 `'llm/adapters-updated'` 事件从全部四个注册注销提交点触发listener 派发带异常隔离INVARIANT 重抛),沿用 settings/commands 的先例。`llm-deepseek` 的路由重命名为 `deepseek-official`,因为 pi-ai catalog 名正言顺地拥有 `deepseek` 这个聚合器条目;依预发布立场,不设别名。
**独立的 schema 驱动表单渲染器。**`dsh-client-schema-form` 把 wire 的 `toJSON()` 信封还原rehydrate为活的 schemastery 节点并按结构分类渲染object/dict/array 递归展开,全字面量联合成为下拉框(值缺失时显示取自回退层的 `Default: X`dict 的键联合供给「新增条目」的词汇,凡是无法忠实编辑的一律渲染为只读 JSON——保持可见绝不丢弃。「是否出现在草稿中」驱动覆盖徽标与逐字段 Reset`renderField` 钩子让消费方挂载角色专属控件,渲染器自身不必认识任何角色。
**Models 页是一次三领域联接,应用语义与 seam 同形。**每一行是一个已配置的提供方;「新增」词汇是可配置提供方目录中剩余的休眠条目;徽标来自路由存活状态与凭据引用不含值的 `configured` 状态。`credential-ref` 角色挂载凭据控件:引用名进设置,密钥值经 `credentials.set` **只写**存入。不含删除的编辑以一次最小的 `settings.update` 合并 patch 落地patch 之外已存储的机密得以保留);字段重置或整行删除则经 `settings.replace` 替换整个用户分节,因为合并语义表达不了删除。
## 曾考虑的替代方案
- **在 wire 上改发 JSON Schema**——schemastery 的 `toJSON()` 信封能往返保留 `role()`/meta并还原成客户端为草稿校验本就自带的那个校验器转换成 JSON Schema 丢掉的恰恰是凭据控件与 secret 脱敏所依赖的角色注解。
- **逐字段脱敏机密并在 `replace` 时回填哨兵值**——PR1 的决策(机密是引用)已经为产品默认形态删掉了「存储字面量」这种情况;结构化脱敏加上只写的凭据通道足以处理残余情形,无需让每个写入方都学会一套哨兵协议。
- **由 `models` 桥接插件持有提供方配置**——与 PR1 相同的否决理由:按插件划分的 namespace 加上四字段的目录声明已经给了 UI 需要的一切;桥接层的统一字典会把适配器映射那层间接重新引进来。
- **页面侧轮询而非推送帧**——mux 已经承载 `host/commands-changed`;再加三个帧各自只多一个形状的成本,就让第二个标签页、外部的 `settings.yaml` 编辑和由设置催生的路由都以事件速度收敛。
## 后果
整条闭环以无密钥方式固定在浏览器测试通道(`apps/web/tests/models-settings.e2e.ts`):休眠的 pi-ai catalog 渲染为「新增」词汇,添加 `anthropic` 会写入 `settings.yaml`、路由随拓扑帧注册为存活,密钥只写存入 harness 家目录的 `.env`,徽标随凭据帧收敛——全程零模型调用,空态与已配置态各有 ARIA golden另有脚手架式的 `harnessHome`,测试绝不触碰真实的 `~/.dsh`。这次重命名在一次提交中触及 239 个文件fixture测试前置数据、golden、文档、python未保留兼容别名。延后事项每行的模型预览选择器已能列出模型、为从未声明可配置性的存活路由提供页面地址以及已记录在案的重置边界情形——`settings.replace` 无法在被替换的子树里重新补上已存储的*字面量*机密,而基于引用的默认形态让这种情况根本无从出现。