Skip to content

这里的「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 块(defaultrequireddescriptionmin/max/steppattern、用于 deprecated/experimentalbadges),供表单渲染与校验。Schema.prototype 深谙表单要探测的结构关系——node.typenode.inner(用于 dict/array)、node.dict(用于 object)、node.list(用于元组)——这正是 nodeAtPath 读取的内容。

校验机制

Schemastery 的解析器(Schema.resolveSchema.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输入 → 输出(TypeSTypeT编辑器形态
Schema.string().min(2)stringstring文本框;长度约束
Schema.number().min(0).step(1)numbernumber步进数字框
Schema.boolean()booleanboolean开关
Schema.union(['a','b','c'])unionstring单选下拉
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-formpackages/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 渲染器;编辑器按场景手写,但都建立在两种范式上:

  1. 设置作用域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 } 操作与 expectedRevision CAS 守卫。

  2. 提供商模型编辑packages/client/ui-settings-models/src/client/ProviderEditor.tsx):DeepSeek / pi-ai 卡片是手写的,但「自定义设置」的额外项是 schema-驱动的——它读取重水合后的命名空间 schema 上的 nodeAtPath,判断哪些精选字段适用,然后通过最小 settings.mutate 路径操作(只改它能命名的字段,绝不重建整个子树)去编辑存储分区。DeepSeekModelsEditor 同样把模型目录(id、名称、上下文窗口、推理级别)渲染成一个带自有校验的列表。

txt
宿主 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.itemSettingsScope<T> + 单字段 set/unset
DeepSeek → 提供商编辑、baseURL、API key、模型目录ui-settings-models ProviderEditorschema-form 路径助手 + credentials.set
pi-ai 自定义提供商路由(显示名、链路协议)ui-settings-models同样的路径助手
插件配置卡片(settings.plugins.tabui-settings-plugins每个暴露命名空间一个 SettingsScope

注意 API key 是只写的凭据字段(credentials.set)——页面绝不询问环境变量名,默认推导 <ROUTE>_API_KEY

base/user 分层与版本 CAS

SettingsScopeSnapshot 携带多个编辑器会一起读取的槽位:statusloading | ready | unavailable)、value(最后一个经 schema 解析的分区)、base(被清除字段回退到的合分层)、user(原始存储的覆盖层)、revision(按命名空间的版本计数器)、writablemodehost | memory)。关键在于,一个字段是否算被覆盖取决于它在 user 里的存在性,而非值比较——等于默认值的覆盖仍是覆盖。

每次写入都是带守卫的 settings.mutate

txt
客户端                                                   宿主
  settings.mutate { ns, ops:[{op:'set', path, value}],
                    expectedRevision: rev } ────────────►  仅当
                                                            namespace.revision == rev
                                                            时应用 ops
  ◄── ok:经 schema 解析的值 / 新 revision ────────────   否则:拒绝 → 重载

写入被拒(!response.result.ok)或传输失败时,控制器会重读命名空间——除非已有更新的写入将其取代(代计数 readGeneration/writeGeneration)。只有最新写入的落定才能发布,因此两个编辑器在同一分区上的竞争绝不会交错出陈旧状态。由于每次写入都是单个 opset/unset),作用域目前没有多字段事务;写后读的恢复机制正是保持单字段行一致性的办法。

schema 诞生之处:dsh-settings

编辑器要重水合的该分区 schema 来自宿主侧 @deepseek-ai/dsh-settings。功能插件用 schemastery schema 与 settingsNamespace 桶注册一个命名空间:

ts
// 宿主侧注册(如 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.sectionsettings.plugins.tabsettings.general.item)。
  • vendor/schemastery/src/index.ts — 校验器:类型构造器、metaSchema.extend、解析。
  • packages/client/schema-form/src/model.tsrehydrateSchemanodeAtPathsetPath
  • packages/client/ui-settings/src/client/settings-scope.tsSettingsScopeController 与 describe/mutate 循环。