Files
deepseek-harness/packages/client/schema-form/src/SchemaForm.tsx
Yichen Jiang 9592c8f271 feat(schema-form): schema-driven React form renderer package
@deepseek-ai/dsh-client-schema-form rehydrates the wire's serialized
schemastery envelope (new Schema(json)) and edits a draft user section
against it: presence-in-draft marks a field overridden with a per-field
reset, inherited values render as placeholders, role('secret') slots are
write-only with configured-state placeholders from the wire's secrets
list, dict adds take a union-typed sKey as their vocabulary, and any
node the renderer cannot faithfully edit falls back to a read-only view
instead of silently disappearing. renderField(context) is the role hook
the Models page will use for the credential-ref control; validateDraft
runs the same rehydrated validator the host uses, so the browser and
host judge one schema.
2026-07-30 00:24:19 +08:00

439 lines
16 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Schema-driven form renderer. One controlled component edits a draft user
* section against a rehydrated schemastery schema: every schema-declared
* field renders a control, a field's presence in the draft marks it
* overridden (with a per-field reset back to the inherited layer), secret
* slots render write-only, and nodes the renderer cannot faithfully edit
* fall back to a read-only view instead of disappearing.
* @module @deepseek-ai/dsh-client-schema-form/SchemaForm
*/
import { useMemo } from 'react'
import type { ReactNode } from 'react'
import {
deletePath, getPath, hasPath, nodeKind, rehydrateSchema, setPath, unionChoices,
} from './model.ts'
import type { SchemaNode } from './model.ts'
import styles from './SchemaForm.module.css'
/** One secret slot reported by the wire (`SettingsSecretView` shape). */
export interface SchemaFormSecret {
/** Path from the section root to the write-only field. */
path: readonly string[]
/** Whether a value is currently stored (it never rides the wire). */
set: boolean
}
/** User-visible strings; override to localize. */
export interface SchemaFormLabels {
reset: string
add: string
remove: string
secretSet: string
secretUnset: string
inherited: string
unsupported: string
}
const DEFAULT_LABELS: SchemaFormLabels = {
reset: 'Reset',
add: 'Add',
remove: 'Remove',
secretSet: 'Configured — enter a new value to replace',
secretUnset: 'Not configured',
inherited: 'Default',
unsupported: 'This field has no form control; edit the settings document directly.',
}
/** Everything a custom field renderer learns about one leaf position. */
export interface SchemaFieldContext {
/** Path from the section root. */
path: readonly string[]
/** Live schema node at this position. */
node: SchemaNode
/** `meta.role` of the node, when declared (`secret`, `credential-ref`, …). */
role: string | undefined
/** Current draft value at the path (`undefined` while inherited). */
draftValue: unknown
/** Inherited (resolved) value shown while the draft has no override. */
fallbackValue: unknown
/** Whether the draft carries this path. */
overridden: boolean
/** Whether the whole form is disabled. */
disabled: boolean
/** Store a draft value at this path. */
setValue: (value: unknown) => void
/** Remove this path from the draft (fall back to the inherited layer). */
clearValue: () => void
}
/** Props of {@link SchemaForm}. */
export interface SchemaFormProps {
/** Serialized schemastery envelope (`SettingsNamespaceView.schema`). */
schema: unknown
/** Draft user section being edited; never mutated. */
draft: Record<string, unknown>
/** Resolved value (defaults→base→user) used for inherited display. */
fallback?: unknown
/** Secret slots from the wire; matched by path for write-only placeholders. */
secrets?: readonly SchemaFormSecret[]
/** Disable every control (read-only provider or in-flight write). */
disabled?: boolean
/** Receives the complete next draft after each edit. */
onChange: (next: Record<string, unknown>) => void
/**
* Role-aware override hook: return a node to replace the default control
* for one leaf (the credential-ref control), or `undefined` to keep it.
*/
renderField?: (context: SchemaFieldContext) => ReactNode | undefined
/** String overrides for localization. */
labels?: Partial<SchemaFormLabels>
}
function pathKey(path: readonly string[]): string {
return path.join('')
}
interface RenderEnv {
draft: Record<string, unknown>
fallback: unknown
secrets: readonly SchemaFormSecret[]
disabled: boolean
labels: SchemaFormLabels
onChange: (next: Record<string, unknown>) => void
renderField?: (context: SchemaFieldContext) => ReactNode | undefined
}
function contextFor(env: RenderEnv, node: SchemaNode, path: readonly string[]): SchemaFieldContext {
return {
path,
node,
role: typeof node.meta.role === 'string' ? node.meta.role : undefined,
draftValue: getPath(env.draft, path),
fallbackValue: getPath(env.fallback, path),
overridden: hasPath(env.draft, path),
disabled: env.disabled,
setValue: (value) => { env.onChange(setDraft(env.draft, path, value)) },
clearValue: () => { env.onChange(deletePath(env.draft, [...path])) },
}
}
// setPath forbids empty paths; the root object node never calls setValue.
function setDraft(draft: Record<string, unknown>, path: readonly string[], value: unknown): Record<string, unknown> {
return setPath(draft, [...path], value)
}
function secretStateAt(env: RenderEnv, path: readonly string[]): boolean | undefined {
const match = env.secrets.find(secret => pathKey(secret.path) === pathKey(path))
return match?.set
}
function SecretField({ context, env }: { context: SchemaFieldContext; env: RenderEnv }): ReactNode {
const stored = secretStateAt(env, context.path)
const placeholder = stored === true ? env.labels.secretSet : env.labels.secretUnset
return (
<input
className={styles['control']}
type="password"
autoComplete="off"
value={typeof context.draftValue === 'string' ? context.draftValue : ''}
placeholder={placeholder}
disabled={context.disabled}
onChange={(event) => {
const next = event.target.value
if (next === '') context.clearValue()
else context.setValue(next)
}}
/>
)
}
function StringField({ context, env }: { context: SchemaFieldContext; env: RenderEnv }): ReactNode {
const fallback = context.fallbackValue
return (
<input
className={styles['control']}
type="text"
value={typeof context.draftValue === 'string' ? context.draftValue : ''}
placeholder={typeof fallback === 'string' ? `${env.labels.inherited}: ${fallback}` : undefined}
disabled={context.disabled}
onChange={(event) => {
const next = event.target.value
if (next === '') context.clearValue()
else context.setValue(next)
}}
/>
)
}
function NumberField({ context, env }: { context: SchemaFieldContext; env: RenderEnv }): ReactNode {
const meta = context.node.meta as { min?: number; max?: number; step?: number }
const fallback = context.fallbackValue
return (
<input
className={styles['control']}
type="number"
value={typeof context.draftValue === 'number' ? context.draftValue : ''}
placeholder={typeof fallback === 'number' ? `${env.labels.inherited}: ${String(fallback)}` : undefined}
min={meta.min}
max={meta.max}
step={meta.step}
disabled={context.disabled}
onChange={(event) => {
const next = event.target.value
if (next === '') context.clearValue()
else context.setValue(Number(next))
}}
/>
)
}
function BooleanField({ context }: { context: SchemaFieldContext }): ReactNode {
const effective = context.overridden ? context.draftValue === true : context.fallbackValue === true
return (
<input
type="checkbox"
checked={effective}
disabled={context.disabled}
onChange={(event) => { context.setValue(event.target.checked) }}
/>
)
}
function UnionField({ context, env }: { context: SchemaFieldContext; env: RenderEnv }): ReactNode {
const choices = unionChoices(context.node)
const fallback = context.fallbackValue
const inheritedLabel = typeof fallback === 'string' || typeof fallback === 'number' || typeof fallback === 'boolean'
? `${env.labels.inherited}: ${String(fallback)}`
: `(${env.labels.inherited})`
return (
<select
className={styles['control']}
value={context.overridden ? String(context.draftValue) : ''}
disabled={context.disabled}
onChange={(event) => {
const next = event.target.value
if (next === '') context.clearValue()
else context.setValue(choices.find(choice => String(choice) === next))
}}
>
<option value="">{inheritedLabel}</option>
{choices.map(choice => (
<option key={String(choice)} value={String(choice)}>{String(choice)}</option>
))}
</select>
)
}
function emptyValueFor(node: SchemaNode): unknown {
switch (nodeKind(node)) {
case 'object':
case 'dict':
return {}
case 'array':
return []
case 'number':
return 0
case 'boolean':
return false
default:
return ''
}
}
function ArrayField({ context, env }: { context: SchemaFieldContext; env: RenderEnv }): ReactNode {
const inner = context.node.inner as SchemaNode | undefined
const items: readonly unknown[] = Array.isArray(context.draftValue)
? context.draftValue as unknown[]
: Array.isArray(context.fallbackValue) ? context.fallbackValue as unknown[] : []
const materialize = (): unknown[] => [...items]
return (
<div className={styles['stack']}>
{items.map((_item, index) => (
// Index keys are correct here: rows are positional slots of one draft array.
<div key={index} className={styles['row']}>
<FieldControl env={env} node={inner as SchemaNode} path={[...context.path, String(index)]} />
<button
type="button"
disabled={context.disabled}
onClick={() => {
const next = materialize()
next.splice(index, 1)
context.setValue(next)
}}
>
{env.labels.remove}
</button>
</div>
))}
<button
type="button"
disabled={context.disabled || inner === undefined}
onClick={() => {
context.setValue([...materialize(), emptyValueFor(inner as SchemaNode)])
}}
>
{env.labels.add}
</button>
</div>
)
}
function DictField({ context, env }: { context: SchemaFieldContext; env: RenderEnv }): ReactNode {
const inner = context.node.inner as SchemaNode | undefined
const draftKeys = typeof context.draftValue === 'object' && context.draftValue !== null
? Object.keys(context.draftValue)
: []
const fallbackKeys = typeof context.fallbackValue === 'object' && context.fallbackValue !== null
? Object.keys(context.fallbackValue)
: []
const keys = [...new Set([...fallbackKeys, ...draftKeys])]
const sKey = (context.node as { sKey?: SchemaNode }).sKey
const keyChoices = sKey !== undefined && nodeKind(sKey) === 'union-const'
? unionChoices(sKey).map(String).filter(choice => !keys.includes(choice))
: undefined
return (
<div className={styles['stack']}>
{keys.map(key => (
<div key={key} className={styles['row']}>
<span className={styles['dictKey']}>{key}</span>
<FieldControl env={env} node={inner as SchemaNode} path={[...context.path, key]} />
<button
type="button"
disabled={context.disabled || !hasPath(env.draft, [...context.path, key])}
onClick={() => { env.onChange(deletePath(env.draft, [...context.path, key])) }}
>
{env.labels.remove}
</button>
</div>
))}
<DictAdd context={context} env={env} keyChoices={keyChoices} />
</div>
)
}
function DictAdd({ context, env, keyChoices }: {
context: SchemaFieldContext
env: RenderEnv
keyChoices: string[] | undefined
}): ReactNode {
const inner = context.node.inner as SchemaNode | undefined
const add = (key: string): void => {
if (key === '') return
env.onChange(setDraft(env.draft, [...context.path, key], emptyValueFor(inner as SchemaNode)))
}
if (keyChoices !== undefined) {
return (
<select
className={styles['control']}
value=""
disabled={context.disabled}
aria-label={env.labels.add}
onChange={(event) => { add(event.target.value) }}
>
<option value="">{env.labels.add}…</option>
{keyChoices.map(choice => <option key={choice} value={choice}>{choice}</option>)}
</select>
)
}
return (
<input
className={styles['control']}
type="text"
disabled={context.disabled}
aria-label={env.labels.add}
placeholder={`${env.labels.add}…`}
onKeyDown={(event) => {
if (event.key !== 'Enter') return
add(event.currentTarget.value)
event.currentTarget.value = ''
}}
/>
)
}
function UnsupportedField({ context, env }: { context: SchemaFieldContext; env: RenderEnv }): ReactNode {
const shown = context.overridden ? context.draftValue : context.fallbackValue
return (
<div className={styles['unsupported']}>
<pre>{shown === undefined ? '' : JSON.stringify(shown)}</pre>
<span>{env.labels.unsupported}</span>
</div>
)
}
function FieldControl({ env, node, path }: { env: RenderEnv; node: SchemaNode; path: readonly string[] }): ReactNode {
const context = contextFor(env, node, path)
const custom = env.renderField?.(context)
if (custom !== undefined) return custom
if (context.role === 'secret') return <SecretField context={context} env={env} />
switch (nodeKind(node)) {
case 'object': return <ObjectFields env={env} node={node} path={path} />
case 'dict': return <DictField context={context} env={env} />
case 'array': return <ArrayField context={context} env={env} />
case 'string': return <StringField context={context} env={env} />
case 'number': return <NumberField context={context} env={env} />
case 'boolean': return <BooleanField context={context} />
case 'union-const': return <UnionField context={context} env={env} />
case 'unsupported': return <UnsupportedField context={context} env={env} />
}
}
function ObjectFields({ env, node, path }: { env: RenderEnv; node: SchemaNode; path: readonly string[] }): ReactNode {
const properties = Object.entries((node.dict ?? {}) as Record<string, SchemaNode>)
return (
<div className={styles['fields']}>
{properties.map(([key, child]) => {
const childPath = [...path, key]
const overridden = hasPath(env.draft, childPath)
const description = typeof child.meta.description === 'string' ? child.meta.description : undefined
const group = nodeKind(child) === 'object'
return (
<div key={key} className={`${styles['field']}${group ? ` ${styles['group']}` : ''}`}>
<div className={styles['labelRow']}>
<label className={styles['label']} title={description}>
{key}
{child.meta.required === true ? <span aria-hidden="true"> *</span> : null}
</label>
{overridden && !group
? (
<button
type="button"
className={styles['resetButton']}
disabled={env.disabled}
onClick={() => { env.onChange(deletePath(env.draft, childPath)) }}
>
{env.labels.reset}
</button>
)
: null}
</div>
{description !== undefined ? <p className={styles['description']}>{description}</p> : null}
<FieldControl env={env} node={child} path={childPath} />
</div>
)
})}
</div>
)
}
/**
* Render a settings section as an editable form.
* @param props - schema, draft, inherited layer, and edit sinks.
* @returns the form contents (no surrounding `<form>`; the page owns submit).
*/
export function SchemaForm(props: SchemaFormProps): ReactNode {
const { schema } = props
const root = useMemo(() => rehydrateSchema(schema), [schema])
const env: RenderEnv = {
draft: props.draft,
fallback: props.fallback,
secrets: props.secrets ?? [],
disabled: props.disabled === true,
labels: { ...DEFAULT_LABELS, ...props.labels },
onChange: props.onChange,
...props.renderField === undefined ? {} : { renderField: props.renderField },
}
return <FieldControl env={env} node={root} path={[]} />
}