这里的「Schema 表单」并非一个能把任意对象渲染成 <form> 的单组件渲染器,而是设置编辑器所依赖的 schema/草稿模型层:宿主发送一个已序列化的 Schemastery 信封;浏览器把它重水合为活校验器、读取节点关系以判断存在哪些字段及其角色、校验草稿,并按路径不可变地编辑草稿。具体控件按屏幕手写,但都共享这一模型缝。
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 读取的内容。
校验机制
Schemastery 的解析器(Schema.resolve、Schema.extend)遍历节点树;每次 extend(type, resolve) 都为某个 JSON 风格的 type 字符串注册一个解析器。标量解析器强制执行的正是字段编辑器暴露的那些 meta 约束:
| 类型解析器 | 由 meta 强制执行什么 |
|---|---|
'string' | pattern 正则、min/max 长度 |
'number' | min/max 数值、step 倍数校验 |
'boolean' | typeof data === 'boolean'(非布尔拒绝) |
'array' | 每个元素走 inner、对长度做范围约束 |
'dict' | 每个值走 inner,可带 sKey |
'object' | 每个键值属性走其子 schema |
'union' | 逐分支尝试;首个命中即成功 |
'transform' | 校验 inner,再经 callback 转换 |
有两个标志决定表单如何对待缺失字段:
strict(松散):严格模式下拒绝/剔除未知对象键、缺失可选键;而松散校验的设置路径会保留一个部分合法的base/user覆盖层,而不是把整个分区判失败。validateDraft默认模式下调用根 schema,因此缺失的可选字段回退到meta.default。meta.default:当值走空/可空路径缺失时应用,且等于默认值的值会构成最小 diff——这正是让settings.mutate路径操作保持小巧的原因。
字段类别速览
设置作者声明 Config/分区 schema 时心里记的正是下面这张映射——类型构造器、它们的入/出载荷,以及各自暗示的编辑 UI:
| Schema 表达式 | type | 输入 → 输出(TypeS→TypeT) | 编辑器形态 |
|---|---|---|---|
Schema.string().min(2) | string | string | 文本框;长度约束 |
Schema.number().min(0).step(1) | number | number | 步进数字框 |
Schema.boolean() | boolean | boolean | 开关 |
Schema.union(['a','b','c']) | union | string | 单选下拉 |
Schema.array(Schema.string()) | array(inner) | Array<string> | 可重排列表 |
Schema.dict(Schema.number()) | dict(inner) | Record<string, number> | 键→值编辑器 |
Schema.object({ baseURL: z.string() }) | object(dict) | {…} | 分组字段卡片 |
Schema.const('x') | const | 字面量 | 只读 / 常量 |
编辑器读取 node.type + node.inner/node.dict/node.list 来渲染形态,用每个字段的 meta 驱动校验——而非仅依据裸值形态。
支持 .toJSON() / new Schema(serialized) 序列化,并符合 @standard-schema/spec。宿主用 z.object({…}) 构建插件/设置的 Config schema——与宿主插件中处处 import 的 z 是同一个。
浏览器模型层
@deepseek-ai/dsh-client-schema-form(packages/client/schema-form/src/model.ts)再导出一个小而聚焦的 API:
| 符号 | 职责 |
|---|---|
rehydrateSchema(serialized) | new Schema(serialized)——还原成活校验器/树 |
validateDraft(schema, draft) | 运行 schema;返回失败信息或 undefined |
nodeAtPath(root, path) | 沿对象属性 / dict/array 的 inner 走到某设置路径上的节点 |
getPath(value, path) | 读取嵌套值(数组用字符串键) |
hasPath(value, path) | 草稿是否显式携带该路径(标记用户覆盖) |
setPath / deletePath | 不可变路径写入 / 取消,按需物化容器 |
setPath 遵循「克隆容器脊柱」的纪律:绝不修改草稿,把对象/数组容器一路克隆到叶子,并把缺失的中间层物化为下一个键所需的数组或对象。这就让最小、可合并的安全编辑成为所有设置编辑器的原语。
schema 如何变成表单
在该固定版本下并没有一个不透明的 schema→DOM 渲染器;编辑器按场景手写,但都建立在两种范式上:
设置作用域(
packages/client/ui-settings/src/client/settings-scope.ts):ctx.settingsScope.bind<T>({ namespace })返回一个SettingsScope<T>,每次加载都调用宿主settings.describe,找到该命名空间的SettingsNamespaceView({ ns, schema, base, user, revision }),并用rehydrateSchema(schema)+validateDraft校验线上value。凡不是普通对象、校验失败、或其 schema 客户端无法重水合的分区,都不发布任何值——该行渲染自己的缺失态,而非一个半解码结果。写入走settings.mutate,携带{ op, path, value }操作与expectedRevisionCAS 守卫。提供商模型编辑(
packages/client/ui-settings-models/src/client/ProviderEditor.tsx):DeepSeek / pi-ai 卡片是手写的,但「自定义设置」的额外项是 schema-驱动的——它读取重水合后的命名空间 schema 上的nodeAtPath,判断哪些精选字段适用,然后通过最小settings.mutate路径操作(只改它能命名的字段,绝不重建整个子树)去编辑存储分区。DeepSeekModelsEditor同样把模型目录(id、名称、上下文窗口、推理级别)渲染成一个带自有校验的列表。
宿主 settings.describe
└─ SettingsNamespaceView { ns, schema, base, user, revision }
└─ rehydrateSchema(schema) → 活校验器 + 节点树
└─ nodeAtPath(root, settingsPath) → 存在哪些字段
└─ validateDraft(schema, value) → 把关发布
编辑:settings.mutate { ops:[{op:'set'|'unset', path, value}], expectedRevision }表单出现在哪里
| 界面 | 属主 | 模型层 |
|---|---|---|
| 设置 General 行(Language、Appearance、Permission、Agent preset) | ui-settings-general 经由 settings.general.item | SettingsScope<T> + 单字段 set/unset |
DeepSeek → 提供商编辑、baseURL、API key、模型目录 | ui-settings-models ProviderEditor | schema-form 路径助手 + credentials.set |
| pi-ai 自定义提供商路由(显示名、链路协议) | ui-settings-models | 同样的路径助手 |
插件配置卡片(settings.plugins.tab) | ui-settings-plugins | 每个暴露命名空间一个 SettingsScope |
注意 API key 是只写的凭据字段(credentials.set)——页面绝不询问环境变量名,默认推导 <ROUTE>_API_KEY。
base/user 分层与版本 CAS
SettingsScopeSnapshot 携带多个编辑器会一起读取的槽位:status(loading | ready | unavailable)、value(最后一个经 schema 解析的分区)、base(被清除字段回退到的合分层)、user(原始存储的覆盖层)、revision(按命名空间的版本计数器)、writable 与 mode(host | memory)。关键在于,一个字段是否算被覆盖取决于它在 user 里的存在性,而非值比较——等于默认值的覆盖仍是覆盖。
每次写入都是带守卫的 settings.mutate:
客户端 宿主
settings.mutate { ns, ops:[{op:'set', path, value}],
expectedRevision: rev } ────────────► 仅当
namespace.revision == rev
时应用 ops
◄── ok:经 schema 解析的值 / 新 revision ──────────── 否则:拒绝 → 重载写入被拒(!response.result.ok)或传输失败时,控制器会重读命名空间——除非已有更新的写入将其取代(代计数 readGeneration/writeGeneration)。只有最新写入的落定才能发布,因此两个编辑器在同一分区上的竞争绝不会交错出陈旧状态。由于每次写入都是单个 op(set/unset),作用域目前没有多字段事务;写后读的恢复机制正是保持单字段行一致性的办法。
schema 诞生之处:dsh-settings
编辑器要重水合的该分区 schema 来自宿主侧 @deepseek-ai/dsh-settings。功能插件用 schemastery schema 与 settingsNamespace 桶注册一个命名空间:
// 宿主侧注册(如 locale、ui-theme)
settings.register(settingsNamespace('locale'), LocaleSettingsSchema)
settings.register(settingsNamespace('ui-theme'), ThemeSettingsSchema)随后 settings.describe 返回该 schema 的序列化形态(schema.toJSON()),浏览器再重水合它——因此 SettingsNamespaceView.schema 正是宿主编排时用于校验的同一个 z.object。这种对称性就是为何浏览器无需宿主关联的表单元数据:权威字段集随值的信封一起送达。
本页涉及的包
| 包 |
|---|
@deepseek-ai/dsh-client-schema-form |
@deepseek-ai/dsh-client-ui-settings |
@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) |
延伸阅读
- 前端:Web 前端 — 设置界面在启动链中如何被挂载。
- 前端:客户端运行时与链路 — 每次作用域读/写背后的
settings.*RPC。 - 前端:UI 模块 — settings 槽位(
settings.section、settings.plugins.tab、settings.general.item)。 vendor/schemastery/src/index.ts— 校验器:类型构造器、meta、Schema.extend、解析。packages/client/schema-form/src/model.ts—rehydrateSchema、nodeAtPath、setPath。packages/client/ui-settings/src/client/settings-scope.ts—SettingsScopeController与 describe/mutate 循环。