"Schema form" here is not a one-component renderer that turns any object into a <form>. It is the schema/draft model layer that settings editors build on: the host sends a serialized Schemastery envelope; the browser rehydrates it into a live validator, reads node relations to decide what fields exist and their roles, validates drafts, and edits drafts immutably by path. The concrete controls are hand-written per screen, but they all share this model seam.
The schema library: vendor/schemastery
@deepseek-ai/schemastery (vendored) is a type-driven schema validator — a single-file (src/index.ts, ~900 lines) port used across the harness for plugin Config schemas and wire envelopes. Core type constructors:
| Constructor | Produces | Typical form control |
|---|---|---|
Schema.string() | Schema<string> | text input |
Schema.number() | Schema<number> | number/step input |
Schema.boolean() | Schema<boolean> | toggle / checkbox |
Schema.union([…]) | choice among literals | select / segmented |
Schema.from(value) / Schema.const(value) | fixed value or inferred type | static / constant |
Schema.array(inner) | Schema<T[]> | list / reorderable rows |
Schema.dict(inner, sKey?) | Schema<Dict<string,T>> | key→value map editor |
Schema.object({…}) | fixed-key plain object | grouped field card |
Schema.any() / Schema.never() | anything / nothing | passthrough |
Schema.transform(inner, fn) | validated→converted | custom post-processing |
Schema.lazy(builder) | deferred/recursive | deferred subtree |
Each node also carries a meta block (default, required, description, min/max/step, pattern, badges for deprecated/experimental) that forms render and validate against. Schema.prototype understands the structural relations forms probe — node.type, node.inner (for dict/array), node.dict (for object), and node.list for tuples — which is exactly what nodeAtPath reads.
Validation mechanics
Schemastery's resolver (Schema.resolve, Schema.extend) walks the node tree; each extend(type, resolve) registers a resolver for a JSON-ish type string. The scalar resolvers enforce the exact meta constraints a field editor exposes:
| Type resolver | What it enforces from meta |
|---|---|
'string' | pattern regex, min/max length |
'number' | min/max value, step multiple-of check |
'boolean' | typeof data === 'boolean' (non-booleans rejected) |
'array' | inner per element, range on length |
'dict' | inner per value, optional sKey |
'object' | each keyed property against its child schema |
'union' | try each branch; first success wins |
'transform' | validate inner, convert via callback |
Two flags shape how forms treat absent fields:
strict(loose): in strict mode unknown object keys and missing optional keys are rejected/elided; the loosely-validated setting path keeps a partially validbase/useroverlay instead of failing the whole section.validateDraftcalls the root schema in default mode, so a missing optional field falls back tometa.default.meta.default: applied when the value equals/nullable path misses, and equal-to-default values build a minimal diff — which is what keepssettings.mutatepath ops small.
Serializable via .toJSON() / new Schema(serialized), and it confirms to @standard-schema/spec. The Host builds plugin/settings Config schemas with z.object({…}) — the same z imported everywhere in host plugins.
Field kinds at a glance
The mapping below is what a settings author keeps in mind when declaring a Config/section schema — the type constructors, their input/output payloads, and the editing UI each implies:
| Schema expression | type | Input → Output (TypeS→TypeT) | Editor affordance |
|---|---|---|---|
Schema.string().min(2) | string | string | text input; length rail |
Schema.number().min(0).step(1) | number | number | stepped numeric input |
Schema.boolean() | boolean | boolean | toggle |
Schema.union(['a','b','c']) | union | string | single-choice select |
Schema.array(Schema.string()) | array (inner) | Array<string> | reorderable list |
Schema.dict(Schema.number()) | dict (inner) | Record<string, number> | key→value editor |
Schema.object({ baseURL: z.string() }) | object (dict) | {…} | grouped field card |
Schema.const('x') | const | literal | read-only / constant |
The editor reads node.type + node.inner/node.dict/node.list to render the affordance and per-field meta to drive validation — never the raw value shape alone.
Where schemas are born: dsh-settings
The section schema an editor rehydrates comes from @deepseek-ai/dsh-settings on the host. A feature plugin registers a namespace with a schemastery schema and a settingsNamespace bucket:
// host-side registration (e.g. locale, ui-theme)
settings.register(settingsNamespace('locale'), LocaleSettingsSchema)
settings.register(settingsNamespace('ui-theme'), ThemeSettingsSchema)settings.describe then returns that schema serialized (schema.toJSON()), and the browser rehydrates it — so a SettingsNamespaceView.schema is the same z.object the host validates against. This symmetry is why the browser needs no host-coupled form metadata: the authoritative field set travels with the value envelope itself.
The browser model layer
@deepseek-ai/dsh-client-schema-form (packages/client/schema-form/src/model.ts) re-exports a small, focused API:
| Symbol | Job |
|---|---|
rehydrateSchema(serialized) | new Schema(serialized) — back into a live validator/tree |
validateDraft(schema, draft) | run the schema; return failure message or undefined |
nodeAtPath(root, path) | walk object props / dict/array inner to the node at a settings path |
getPath(value, path) | read a nested value (arrays via string keys) |
hasPath(value, path) | whether a draft explicitly carries the path (marks a user override) |
setPath / deletePath | immutable path-write / unset, materializing containers as needed |
setPath follows a clone-the-container-spine discipline: it never mutates the draft, cloning object/array containers down to the leaf and materializing a missing intermediate as the array/object the next key needs. This makes minimal, merge-safe edits the primitive for all settings editors.
How a schema becomes a form
There is no opaque schema→DOM renderer at the shipped revision; editors are per-scenario but built on two patterns:
Settings scope (
packages/client/ui-settings/src/client/settings-scope.ts):ctx.settingsScope.bind<T>({ namespace })returns aSettingsScope<T>that on every reload calls the Hostsettings.describe, finds the namespace'sSettingsNamespaceView({ ns, schema, base, user, revision }), and validates the wirevaluewithrehydrateSchema(schema)+validateDraft. A section that is not a plain object, fails validation, or carries a schema the client cannot rehydrate publishes no value — the row renders its own absent state instead of a half-decoded one. Writes go throughsettings.mutatewith one{ op, path, value }ops and anexpectedRevisionCAS guard.Provider model editor (
packages/client/ui-settings-models/src/client/ProviderEditor.tsx): the DeepSeek / pi-ai card is hand-written but schema-driven for the "自定义设置" extras — it readsnodeAtPathoff the rehydrated namespace schema to decide which curated fields apply, then edits the stored section via minimalsettings.mutatepath ops (only the fields it can name, never a rebuilt subtree).DeepSeekModelsEditorsimilarly renders the model catalog (id, name, context window, reasoning levels) as a list with its own validation.
Host settings.describe
└─ SettingsNamespaceView { ns, schema, base, user, revision }
└─ rehydrateSchema(schema) → live validator + node tree
└─ nodeAtPath(root, settingsPath) → which fields exist
└─ validateDraft(schema, value) → gate publication
edits: settings.mutate { ops:[{op:'set'|'unset', path, value}], expectedRevision }Where forms appear
| Surface | Owner | Model layer |
|---|---|---|
| Settings General rows (Language, Appearance, Permission, Agent preset) | ui-settings-general via settings.general.item | SettingsScope<T> + one-field set/unset |
DeepSeek → provider editor, baseURL, API key, model catalog | ui-settings-models ProviderEditor | schema-form path helpers + credentials.set |
| pi-ai custom provider route (display name, wire protocol) | ui-settings-models | same path helpers |
Plugin configuration cards (settings.plugins.tab) | ui-settings-plugins | SettingsScope per exposed namespace |
Note the API key is a write-only credential field (credentials.set) — the page never asks for the env-var name, deriving <ROUTE>_API_KEY by default.
Base/user layering and the revision CAS
The SettingsScopeSnapshot carries several slots an editor reads together: status (loading | ready | unavailable), value (the last schema-resolved section), base (the composition layer a cleared field reverts to), user (the raw stored override layer), revision (per-namespace version counter), writable, and mode (host | memory). Crucially, a field counts as overridden by its presence in user, not by value comparison — an override equal to the default is still an override.
Every write is a guarded settings.mutate:
client host
settings.mutate { ns, ops:[{op:'set', path, value}],
expectedRevision: rev } ────────────► applies ops only if
namespace.revision == rev
◄── ok: schema-resolved value / new revision ───────── else: rejected → reloadOn a rejected write (!response.result.ok) or a failed transport, the controller re-reads the namespace — unless a newer write already superseded it (generation counters readGeneration/writeGeneration). Only the latest write's settlement may publish, so two editors racing on the same section never interleave stale state. Because each write is one op (set/unset), the scope has no multi-field transaction today; the read-after-write recovery is what keeps single-field rows consistent.
Packages in this section
| Package |
|---|
@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) |
Further reading
- Frontend: The web frontend — where settings screens are mounted in the boot chain.
- Frontend: Client runtime and wire — the
settings.*RPCs behind every scope read/write. - Frontend: UI modules — the settings slots (
settings.section,settings.plugins.tab,settings.general.item). vendor/schemastery/src/index.ts— the validator: type constructors,meta,Schema.extend, resolution.packages/client/schema-form/src/model.ts—rehydrateSchema,nodeAtPath,setPath.packages/client/ui-settings/src/client/settings-scope.ts—SettingsScopeControllerand the describe/mutate cycle.