Skip to content

"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:

ConstructorProducesTypical 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 literalsselect / segmented
Schema.from(value) / Schema.const(value)fixed value or inferred typestatic / 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 objectgrouped field card
Schema.any() / Schema.never()anything / nothingpassthrough
Schema.transform(inner, fn)validated→convertedcustom post-processing
Schema.lazy(builder)deferred/recursivedeferred 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 resolverWhat 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 valid base/user overlay instead of failing the whole section. validateDraft calls the root schema in default mode, so a missing optional field falls back to meta.default.
  • meta.default: applied when the value equals/nullable path misses, and equal-to-default values build a minimal diff — which is what keeps settings.mutate path 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 expressiontypeInput → Output (TypeSTypeT)Editor affordance
Schema.string().min(2)stringstringtext input; length rail
Schema.number().min(0).step(1)numbernumberstepped numeric input
Schema.boolean()booleanbooleantoggle
Schema.union(['a','b','c'])unionstringsingle-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')constliteralread-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:

ts
// 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:

SymbolJob
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 / deletePathimmutable 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:

  1. Settings scope (packages/client/ui-settings/src/client/settings-scope.ts): ctx.settingsScope.bind<T>({ namespace }) returns a SettingsScope<T> that on every reload calls the Host settings.describe, finds the namespace's SettingsNamespaceView ({ ns, schema, base, user, revision }), and validates the wire value with rehydrateSchema(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 through settings.mutate with one { op, path, value } ops and an expectedRevision CAS guard.

  2. 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 reads nodeAtPath off the rehydrated namespace schema to decide which curated fields apply, then edits the stored section via minimal settings.mutate path ops (only the fields it can name, never a rebuilt subtree). DeepSeekModelsEditor similarly renders the model catalog (id, name, context window, reasoning levels) as a list with its own validation.

txt
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

SurfaceOwnerModel layer
Settings General rows (Language, Appearance, Permission, Agent preset)ui-settings-general via settings.general.itemSettingsScope<T> + one-field set/unset
DeepSeek → provider editor, baseURL, API key, model catalogui-settings-models ProviderEditorschema-form path helpers + credentials.set
pi-ai custom provider route (display name, wire protocol)ui-settings-modelssame path helpers
Plugin configuration cards (settings.plugins.tab)ui-settings-pluginsSettingsScope 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:

txt
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 → reload

On 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.tsrehydrateSchema, nodeAtPath, setPath.
  • packages/client/ui-settings/src/client/settings-scope.tsSettingsScopeController and the describe/mutate cycle.