这里的「设置 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 的写入都排队在同一条尾链上,抛异常订阅者不会拖垮后续操作。
宿主 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/actualrevision 的SETTINGS_CONFLICT)或settings/rejected。- 凭据同侧挂载:
src/credentials.ts挂载credentialsRemote 命名空间(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 渲染器;编辑器按场景手写,但都建立在这些缝上:
- 设置 scope 行(
ui-settings-general、ui-theme、ui-agent-preset、ui-permission-presets…):各自经ctx.settingsScope.bind绑定命名空间,通过框架的标准 store props 读派生快照,用单字段set/unset写入。Language 与 Appearance 行是典型例子。 - 提供商模型编辑(
packages/client/ui-settings-models/src/client/ProviderEditor.tsx):DeepSeek / pi-ai 卡片是手写的,但「自定义设置」的额外项是 schema-驱动的——它读取重水合后的命名空间 schema 上的nodeAtPath,判断哪些精选字段适用,然后通过最小remote.settings.mutate路径操作(只改它能命名的字段,绝不重建整个子树)去编辑存储分区。DeepSeekModelsEditor同样把模型目录(id、名称、上下文窗口、推理级别)渲染成一个带自有校验的列表。 - 插件配置卡片(
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/credentialsRemote 命名空间。