Skip to content

这里的「设置 Schema」并非一个能把任意对象渲染成 <form> 的单组件渲染器,而是设置编辑器所依赖的 schema/草稿模型层:宿主发送一个已序列化的 Schemastery 信封;浏览器把它重水合为活校验器、读取节点关系以判断存在哪些字段及其角色、校验草稿,并按路径不可变地编辑草稿。具体控件按屏幕手写,但都共享这一模型缝。

曾拥有这一层的包 @deepseek-ai/dsh-client-schema-form(packages/client/schema-form)已被删除。模型活了下来并迁移进设置域基础插件:现在是 packages/client/ui-settings 里的 ctx.settingsSchema 服务,而它过去经 settings.* RPC 发出的读写,如今都走 settings-controller Remote 命名空间(ctx.remote.settings),并带 revision CAS。

schema 库:vendor/schemastery ​

@deepseek-ai/schemastery(vendored)是一款类型驱动的 schema 校验器——单文件实现(src/index.ts,约 900 行),在 harness 里被广泛用于插件 Config schema 与线上信封。核心类型构造器:

构造器产物常见表单控件
Schema.string()Schema<string>文本框
Schema.number()Schema<number>数字/步进框
Schema.boolean()Schema<boolean>开关 / 复选
Schema.union([…])在若干字面量间选择下拉 / 分段
Schema.from(value) / Schema.const(value)固定值或推导类型静态 / 常量
Schema.array(inner)Schema<T[]>列表 / 可重排行
Schema.dict(inner, sKey?)Schema<Dict<string,T>>键→值映射编辑器
Schema.object({…})定键普通对象分组字段卡片
Schema.any() / Schema.never()任意 / 无直通
Schema.transform(inner, fn)校验→转换自定义后处理
Schema.lazy(builder)延迟/递归延迟子树

每个节点还携带 meta 块(default、required、description、min/max/step、pattern、用于 deprecated/experimental 的 badges),供表单渲染与校验。Schema.prototype 深谙表单要探测的结构关系——node.type、node.inner(用于 dict/array)、node.dict(用于 object)、node.list(用于元组)——这正是 nodeAtPath 读取的内容。序列化走 .toJSON() / new Schema(serialized),并符合 @standard-schema/spec;宿主用 z.object({…}) 构建插件/设置的 Config schema——与宿主插件中处处 import 的 z 是同一个。

浏览器模型层:ctx.settingsSchema ​

packages/client/ui-settings/src/client/schema.ts 定义了 SettingsSchemaService——一个 Cordis Service(super(ctx, 'settingsSchema')),掌管同步的 schema 内省与不可变的草稿编辑。动态客户端插件经由该服务取得这些实体,而非彼此导入可执行助手(客户端 bundle 纯净门)。

符号职责
rehydrate(serialized)new Schema(serialized)——还原成活校验器/树(SchemaNode)
validate(schema, draft)运行 schema;返回失败信息或 undefined
nodeAtPath(root, path)沿对象属性 / dict/array 的 inner 走到某设置路径上的节点
getPath(value, path)读取嵌套值(数组用字符串键)
hasPath(value, path)草稿是否显式携带该路径(标记用户覆盖)
setPath / deletePath不可变路径写入 / 取消,按需物化容器

setPath 遵循「克隆容器脊柱」的纪律(同文件里的 cloneSpine):绝不修改草稿,把对象/数组容器一路克隆到叶子,并把缺失的中间层物化为下一个键所需的数组或对象。这就让最小、可合并的安全编辑成为所有设置编辑器的原语。由于校验走的是编辑器内省所用的同一个重水合节点,字段按控件暴露出的完全相同的约束集被校验——不存在第二份手工维护的约束。

唯一共享的 describe 镜像 ​

读操作被刻意集中。packages/client/ui-settings/src/client/settings-mirror.ts 定义了 SettingsDescribeMirror——浏览器里唯一的 settings.describe 读者:一切消费者都从它的快照派生,因此启动成本与新鲜度是这个类的属性,而非取决于有多少功能拥有偏好。

  • 宿主始终是事实源:镜像在其属主插件订阅的失效信号上刷新——ctx.remote.$on('settings/document-updated', …) 与 ctx.on('connection/reset', …)——外加首次使用时的 ensure()。并发的 load() 调用会折叠进在途读取再加一次重跑,因此读中到达的失效既不会丢失也不会重复。
  • 快照是 { status: 'idle' | 'loading' | 'ready' | 'unavailable', view, error };视图即整个 describe() 答复——{ namespaces: SettingsNamespaceView[], writable, hasDocument }。unavailable 是非环回状态的终点;ready 在后续刷新失败后依旧保持(持有的视图继续伺服)。
  • 链路读取只有一次调用:ctx.remote.settings.describe()——宿主侧以 settings.describe({ redactSecrets: true }) 作答(见下文控制器),因此 role('secret') 字段绝不可能随响应传出。
  • 写入答复无需第二次链路读取即折叠回来:acceptView(view) 用该命名空间的新行替换持有视图中的旧行,并使任何在途读取失效(防止陈旧的文档读取发布写入前快照)。
  • 持久化由客户端选择:const persistence = ctx.remote.$host.isLoopback ? 'host' : 'memory'(packages/client/ui-settings/src/client/index.ts),因为设置文件持久化仅限环回;非环回页面从一开始就是 mode: 'memory'。

镜像经 ctx.settingsScope.describe() 以 SettingsDescribeFace 形态暴露给跨命名空间界面,而按命名空间的 scope 都从它派生。

按命名空间的 scope 与写入 ​

packages/client/ui-settings/src/client/settings-scope.ts 保留了 SettingsScope 缝(settings-contract.ts),如今由基于共享镜像加序列化写入路径的 SettingsScopeController<T> 实现:

  • 读取在这里绝不碰链路。 ctx.settingsScope.bind<T>({ namespace, decode? }) 返回的 SettingsScope<T> 是镜像快照上的选择器——derive() 找到该命名空间的 SettingsNamespaceView,用 settingsSchema.validate(settingsSchema.rehydrate(view.schema), value) 校验其 value,并发布 { status, value, base, user, revision, writable, mode }。凡不是普通对象、校验失败、或其 schema 客户端无法重水合的分区,都不发布任何值——该行渲染自己的缺失态,而非一个半解码结果。
  • 写入经由 ctx.remote.settings 并带 revision CAS。 mutate(ops, expectedRevision?) 取 expectedRevision ?? pendingRevision ?? snapshot.revision,调用 ctx.remote.settings.mutate(ns, ops, revision),成功后把答复里的命名空间视图经 mirror.acceptView 折叠回来。set(field, value) / unset(field) 是单操作的 mutate 便捷形式。
  • 陈旧写入恢复: 写入失败(冲突或传输)会重载镜像——除非已有更新的写入将其取代(writeGeneration 计数器;被取代者把其答复的 revision 记为下一次栅栏)。只有最新写入的落定才能发布,因此两个编辑器在同一分区上的竞争绝不会交错出陈旧状态。每个 scope 的写入都排队在同一条尾链上,抛异常订阅者不会拖垮后续操作。
txt
宿主 settings-controller Remote(packages/api/settings-controller)
   └─ describe() → { writable, hasDocument, namespaces }        (始终 redactSecrets)
        └─ SettingsDescribeMirror(浏览器里唯一快照存储)
             └─ SettingsScopeController.derive()  → 按命名空间的 SettingsScope
             └─ SettingsScopeController.mutate(ns, ops, expectedRevision)
                  └─ ctx.remote.settings.mutate → { op:'set'|'unset', path, value } + CAS
                       └─ acceptView(namespaceView)  ← 折叠写入答复
   └─ update(ns, patch, expectedRevision) / replace(ns, section, expectedRevision)

宿主还提供编辑器可选的另外两种写入模式:settings.update(把补丁深合并进用户分区)与 settings.replace(整体重置分区)——两者都接受同样的可选 expectedRevision。

宿主控制器:settings-controller ​

该 Remote 命名空间由 packages/api/settings-controller(@deepseek-ai/dsh-api-settings-controller)属主——即支持生成出的 ctx.remote.settings 的宿主服务:

  • SettingsController extends TypertRemoteService——describe()、update(ns, patch, expectedRevision)、replace(ns, section, expectedRevision)、mutate(ns, ops, expectedRevision),外加 canOpenAgentPresetDirectory() / openSettingsDocument() / openAgentPresetDirectory()(原生打开文档/预设目录)。每一次远端读取都使用 redactSecrets: true;写入路径在提交后重新红act读取该命名空间,并把每种拒绝归类为 settings/conflict(携带 expected/actual revision 的 SETTINGS_CONFLICT)或 settings/rejected。
  • 凭据同侧挂载:src/credentials.ts 挂载 credentials Remote 命名空间(ctx.remote.credentials)——describe(refs)(批量,≤ 64 个引用)、set(ref, value)、unset(ref)。秘密值只单向过线:没有任何方法返回它,因此配置页面永远无法回显已存密钥。
  • 两个命名空间在未挂载 provider 时也保持注册,调用会返回配置 API 的可行动缺失-provider 诊断,而非死端点。

类型化命名空间 ​

线上类型来自设置缝,并由客户端组装包再导出:

  • SettingsNamespaceView({ ns, schema: JsonValue, value, base?, user?, applies: 'live' | 'restart', secrets, revision })、SettingsPathOpView、SettingsDescribeValue、SettingsSecretView 声明于 packages/settings/settings/src/types.ts,并从 @deepseek-ai/dsh-api-remotes/client 导出——这是浏览器包点名的类型门。
  • 命名空间身份如今是品牌化字符串:SettingsNamespace = Branded<'SettingsNamespace'>。旧的 settingsNamespace(...) 运行时助手已删除——字面量小写连字符字符串由缝的类型打上品牌,installSection 在运行时校验文法(非 [a-z0-9-] id 抛 TypeError)。
  • 载体自身的门是 @deepseek-ai/dsh-client-connection/client(RpcId、RpcRequest、RpcResponse、RpcResult、ConnectionHandle…)。浏览器 bundle 声明 inject: ['remote', 'remote.settings'](外加事件白名单类型),绝不导入宿主包根。

schema 如何变成表单 ​

在该固定版本下并没有一个不透明的 schema→DOM 渲染器;编辑器按场景手写,但都建立在这些缝上:

  1. 设置 scope 行(ui-settings-general、ui-theme、ui-agent-preset、ui-permission-presets…):各自经 ctx.settingsScope.bind 绑定命名空间,通过框架的标准 store props 读派生快照,用单字段 set/unset 写入。Language 与 Appearance 行是典型例子。
  2. 提供商模型编辑(packages/client/ui-settings-models/src/client/ProviderEditor.tsx):DeepSeek / pi-ai 卡片是手写的,但「自定义设置」的额外项是 schema-驱动的——它读取重水合后的命名空间 schema 上的 nodeAtPath,判断哪些精选字段适用,然后通过最小 remote.settings.mutate 路径操作(只改它能命名的字段,绝不重建整个子树)去编辑存储分区。DeepSeekModelsEditor 同样把模型目录(id、名称、上下文窗口、推理级别)渲染成一个带自有校验的列表。
  3. 插件配置卡片(ui-settings-plugins):每个暴露命名空间一个 SettingsScope,从同一镜像渲染。

注意 API key 是只写的凭据字段(credentials.set/unset)——页面绝不询问环境变量名,默认推导 <ROUTE>_API_KEY,镜像也绝不会给它展示已存值。

纯服务协作模型的又一推论:dsh-client-ui-approval(浏览器审批界面)声明零运行时依赖——仅一个 cordis peer——因为它需要的每个跨插件事实都由注入服务送达;设置模型层里没有任何东西被烘焙进它。

磁盘上的分层仍在缝里 ​

base/user 分层、revision 计数器、「等于默认值的覆盖仍是覆盖」这些规则都在 packages/settings/settings——域的侧面见设置系统;本页讲的是同一份文档的浏览器传输。

本页涉及的包 ​

包
@deepseek-ai/dsh-client-ui-settings(模型层:settingsSchema、镜像、scope)
@deepseek-ai/dsh-api-settings-controller(宿主 Remote:settings + credentials)
@deepseek-ai/dsh-client-ui-settings-general
@deepseek-ai/dsh-client-ui-settings-models
@deepseek-ai/dsh-client-ui-settings-plugins
@deepseek-ai/schemastery(vendored)
@deepseek-ai/dsh-client-ui-approval(零运行时依赖)

延伸阅读 ​

  • 前端:Web 前端 — 设置界面在启动链中如何被挂载。
  • 前端:客户端运行时与链路 — 每次 scope 读/写背后的 Connection RPC 信封与 /api 传输。
  • 前端:UI 模块 — settings 槽位(settings.section、settings.plugins.tab、settings.general.item)。
  • LLM 平台:设置系统 — 域的侧面:base/user 分层、installSection、SettingsConflictError。
  • packages/client/ui-settings/src/client/schema.ts — SettingsSchemaService(rehydrate、validate、nodeAtPath、setPath)。
  • packages/client/ui-settings/src/client/settings-mirror.ts — SettingsDescribeMirror,唯一的 describe 读者。
  • packages/client/ui-settings/src/client/settings-scope.ts — SettingsScopeController 与 mutate/CAS 循环。
  • packages/api/settings-controller/src/index.ts + src/credentials.ts — settings/credentials Remote 命名空间。