feat(ui-models): schema-driven provider configuration page
The Models settings section joins llm.providers (the configurable directory with live state), settings.describe (schemas, layered redacted values, secret slots), and credentials.describe (value-free badges) into provider rows with one editor card at a time. The editor renders the provider's profile subtree through dsh-client-schema-form; the credential-ref role mounts a control that shows configured/source state and stores keys write-only through credentials.set. Apply without removals merges a minimal patch (stored secrets outside it survive); apply after a reset — and row deletion — replace the user section so removals land. The client runtime bridges the three new host frames to typed ctx events (settings/credentials/models changed), the page refetches on any of them once loaded, and ui-model's per-session picker directories reload on models/changed so a settings-born route appears in open pickers without a reopen.
This commit is contained in:
@@ -11,6 +11,6 @@ export type {
|
||||
SchemaFieldContext, SchemaFormLabels, SchemaFormProps, SchemaFormSecret,
|
||||
} from './SchemaForm.tsx'
|
||||
export {
|
||||
deletePath, getPath, hasPath, nodeKind, rehydrateSchema, setPath, unionChoices, validateDraft,
|
||||
deletePath, getPath, hasPath, nodeAtPath, nodeKind, rehydrateSchema, setPath, unionChoices, validateDraft,
|
||||
} from './model.ts'
|
||||
export type { NodeKind, SchemaNode } from './model.ts'
|
||||
|
||||
@@ -78,6 +78,26 @@ export function unionChoices(node: SchemaNode): unknown[] {
|
||||
return (node.list ?? []).map(branch => (branch as { value?: unknown }).value)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the schema node at a settings path (the configurable-provider
|
||||
* directory's `settingsPath` vocabulary): object properties by name, dict
|
||||
* entries through `inner`. An unresolvable segment returns `undefined` so
|
||||
* the caller falls back instead of rendering a wrong subtree.
|
||||
* @param root - rehydrated section root node.
|
||||
* @param path - key path from the section root.
|
||||
* @returns the node describing that position, or `undefined`.
|
||||
*/
|
||||
export function nodeAtPath(root: SchemaNode, path: readonly string[]): SchemaNode | undefined {
|
||||
let node: SchemaNode | undefined = root
|
||||
for (const key of path) {
|
||||
if (node === undefined) return undefined
|
||||
if (node.type === 'object') node = (node.dict as Record<string, SchemaNode> | undefined)?.[key]
|
||||
else if (node.type === 'dict' || node.type === 'array') node = node.inner as SchemaNode | undefined
|
||||
else return undefined
|
||||
}
|
||||
return node
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a nested value by path.
|
||||
* @param value - root value (draft or fallback layer).
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import Schema from 'schemastery'
|
||||
import {
|
||||
deletePath, getPath, hasPath, nodeKind, rehydrateSchema, setPath, unionChoices, validateDraft,
|
||||
deletePath, getPath, hasPath, nodeAtPath, nodeKind, rehydrateSchema, setPath, unionChoices, validateDraft,
|
||||
} from '../src/model.ts'
|
||||
|
||||
const Wire = (schema: Schema): unknown => JSON.parse(JSON.stringify(schema.toJSON()))
|
||||
@@ -101,3 +101,27 @@ describe('path helpers', () => {
|
||||
expect(draft.models[0]).toEqual({ id: 'a', contextWindow: 1 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('nodeAtPath', () => {
|
||||
const Root = Schema.object({
|
||||
providers: Schema.dict(Schema.object({ baseURL: Schema.string() })),
|
||||
models: Schema.array(Schema.object({ id: Schema.string() })),
|
||||
leaf: Schema.string(),
|
||||
})
|
||||
|
||||
it('resolves object, dict, and array positions', () => {
|
||||
const root = rehydrateSchema(Wire(Root))
|
||||
expect(nodeAtPath(root, [])).toBe(root)
|
||||
expect(nodeAtPath(root, ['providers', 'openai'])?.type).toBe('object')
|
||||
expect(nodeAtPath(root, ['providers', 'openai', 'baseURL'])?.type).toBe('string')
|
||||
expect(nodeAtPath(root, ['models', '0', 'id'])?.type).toBe('string')
|
||||
expect(nodeAtPath(root, ['missing'])).toBeUndefined()
|
||||
expect(nodeAtPath(root, ['missing', 'deeper'])).toBeUndefined()
|
||||
expect(nodeAtPath(root, ['leaf', 'below'])).toBeUndefined()
|
||||
})
|
||||
|
||||
it('tolerates structural nodes missing their relation maps', () => {
|
||||
expect(nodeAtPath({ type: 'object' } as never, ['x'])).toBeUndefined()
|
||||
expect(nodeAtPath({ type: 'dict' } as never, ['x'])).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user