# Conflicts: # .agents/notes/implemented/architecture/2026-07-30-web-config-plane.i18n.yaml # .agents/notes/implemented/feature/2026-07-24-web-session-model-selector.i18n.yaml # packages/client/ui-model/README.i18n.yaml # packages/client/ui-model/README.zh.md # packages/client/ui-models/README.i18n.yaml # packages/client/ui-models/README.zh.md # packages/host/apiproxy/README.i18n.yaml # packages/llm/llm-deepseek/README.i18n.yaml
11 KiB
Agent Note: web 配置平面
Status: implemented
English | 中文
范围:请求级 LLM 配置 note 中延后的 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 节点,用于校验、路径解析与不可变草稿编辑——但不做通用渲染:第一版交付了完整的 schema 驱动表单渲染器,得到的却是一个未加样式、把 schema 原样倾倒出来的页面(每个进阶字段都平铺到卡片上、原始字段名直接充当标签、retryPolicy 的「不支持」回退落在主流程里)。用户没有再加一套提示/分组系统,而是选择了手写方向,第二轮又把引用输入框整个移除:卡片的主字段是一个 API 密钥输入框,未配置密钥的整分节提供方会以其设置卡片的形式打开,收起的「自定义设置」折叠区承载按家族精选的额外字段(两个家族都有 baseURL,deepseek 有 reasoningEffort/pi-ai 有 reasoning,另有直接 DeepSeek 模型行的 id、name 和 contextWindow)。现有模型字段中不在可见集合内的部分会在数组编辑后保留;重试策略、超时及其他字段仍归 settings.yaml 所有。校验仍会在写入前运行还原出的 schema,适配器特有的检查则会拒绝序列化 schema 无法表达的目录不变量。卡片的颜色经 --dsw-alias-* 设计 token 解析;它此前引用的 --border/--surface/--text-* 在本应用中无人定义,于是渲染出的是它们的亮色模式回退值,在暗色主题下依旧保持亮色。模型目录采用 pi-ai 提供方表单引入的行形态:每个模型一个带边框的条目,ID 与显示名称落在行上,容量则收在该行自己的折叠区里,使两个编辑器呈现为同一套设计,而不是在双方都落地后各自分岔。每个字段都保留那个为其命名的带序号 aria-label。两项容量都是文本输入框,读取十进制的 K/M 后缀(1M 即 1000K,与容量的通行标注方式一致)并存储纯数值:字段持有焦点期间保留键入的文本,因为若每次按键都从解析出的数值重新推导该文本,1000 会在尚未输完时就被改写成 1K;无法解析的文本也会留在屏幕上,因此保存时的拒绝点名的是用户仍能看见的那一行。共用的类名承载的是本文件的 token 写法,而非那个分支的:--dsw-alias-border-subtle、--dsw-alias-text-tertiary 和 --dsw-alias-text-primary 均未声明,于是它们解析为各自回退槽位中的亮色模式字面值——正是本节此前迁离的那个缺陷。现在有一个样式测试会拒绝 token 表未声明的任何 --dsw-* 名称,因此下一个写出这类名称的编辑者会当场失败,而不是交付一个只有亮色的界面。
**Models 页是一次三领域联接,应用语义与 seam 同形。**每一行是一个已配置的提供方;「新增」卡片的选择框是可配置提供方目录中剩余的休眠条目。路由存活状态仍用于就绪判定,并会使该联接失效,但页面不将其渲染为提供方状态,因为配置存在与运行时可用性是两个不同概念。密钥通道保持引用形态,却从不展示任何引用:键入的密钥经 credentials.set 只写存入 profile 的 apiKeyEnv 之下,引用不存在时便派生 <ROUTE>_API_KEY(pi-ai profile 会记录该派生),因此 settings.yaml 从不携带密钥值。profile 的编辑和删除会针对脱敏后的用户分节,以按路径寻址的最小 settings.mutate 操作落地,绝不会点名页面未收到的机密。删除用户层提供方时,会先打开本地化的模型提供方确认对话框;取消操作、关闭按钮和遮罩均不会改动 profile,而破坏性确认会提交唯一一条 unset,并在其完成前阻止重复提交。DeepSeek 的模型列表是数组替换配置:继承而来的生效模型行会一直显示,直到第一次编辑将完整列表具化到用户层;重置则会取消设置该列表覆盖。
曾考虑的替代方案
- 在 wire 上改发 JSON Schema——schemastery 的
toJSON()信封能往返保留role()/meta,并还原成客户端为草稿校验本就自带的那个校验器;转换成 JSON Schema 丢掉的恰恰是凭据控件与 secret 脱敏所依赖的角色注解。 - 通用的 schema 驱动表单渲染器——先实现、后被替换:如实呈现字段却缺失视觉层级,产出的卡片丑陋且不可用;要把它做好,就意味着构建一套提示词汇(主要/进阶分组、逐字段描述、数组项卡片),成本堪比手写编辑器,却仍无法与任何设计稿完全吻合。今天存在两份 schema(deepseek 的
Config与共享的 pi-ai profile),手写因此就是两套以 namespace 为键的薄布局;漂移风险由保存时的 schema 校验以及未知字段在文档中的原样保留共同约束。 - 逐字段脱敏机密并在
replace时回填哨兵值——PR1 的决策(机密是引用)已经为产品默认形态删掉了「存储字面量」这种情况;结构化脱敏加上只写的凭据通道足以处理残余情形,无需让每个写入方都学会一套哨兵协议。 - 把键入的密钥存成字面
apiKey设置——v1「单个 API 密钥输入框」的需求本可以把字面量直接写进 profile,但 UI 的每条删除路径都会从脱敏后的各层重建用户分节,任何重置或整行删除都会静默丢掉已存储的兄弟密钥;派生引用让输入保持单字段,同时让settings.yaml不含机密、每一次 replace 都安全。 - 由
models桥接插件持有提供方配置——与 PR1 相同的否决理由:按插件划分的 namespace 加上四字段的目录声明已经给了 UI 需要的一切;桥接层的统一字典会把适配器映射那层间接重新引进来。 - 页面侧轮询而非推送帧——mux 已经承载
host/commands-changed;再加三个帧各自只多一个形状的成本,就让第二个标签页、外部的settings.yaml编辑和由设置催生的路由都以事件速度收敛。
后果
整条闭环以无密钥方式固定在浏览器测试通道(apps/web/tests/models-settings.e2e.ts):「新增」卡片提供休眠的 pi-ai catalog,携键入的密钥添加 minimax-cn 会把只含引用的 profile 写入 settings.yaml、把密钥值存入 harness 家目录 .env 中派生的 MINIMAX_CN_API_KEY 之下、路由随拓扑帧注册为存活,「自定义设置」折叠区则把 reasoning 合并到引用旁边——全程零模型调用,「新增」卡片态、已配置态与删除确认态各有 ARIA golden,另有脚手架式的 harnessHome,测试绝不触碰真实的 ~/.dsh(受测提供方是派生引用不可能与开发者已导出密钥相撞的那一个)。删除场景证明:取消后 profile 保持原样,确认后会将其删除,而刻意保留的凭据依然存在。DeepSeek 首次使用 fixture 会把默认目录编辑为用户自有列表、持久化任意模型的 ID/名称/上下文窗口、移除活动模型行,并观察模型选择器的空选择回退。这次重命名在一次提交中触及 239 个文件(fixture(测试前置数据)、golden、文档、python),未保留兼容别名。替换渲染器只花了一次提交,且没有任何 wire 变更:应用语义、脱敏与目录联接从一开始就与渲染器无关。延后事项:每行的模型预览(选择器已能列出模型)、为从未声明可配置性的存活路由提供页面地址,以及显式删除提供方所保留的凭据。