refactor(client): move schema handling into ui-settings

This commit is contained in:
imccyu
2026-08-18 01:34:20 +08:00
parent 027bd013bc
commit 56dff07c4e
38 changed files with 312 additions and 568 deletions
@@ -4,7 +4,7 @@
import { Context } from '@deepseek-ai/cordis'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client'
import { SettingsScopeBinder } from '@deepseek-ai/dsh-client-ui-settings/client'
import { SettingsSchemaService, SettingsScopeBinder } from '@deepseek-ai/dsh-client-ui-settings/client'
import { TestRemote } from '@deepseek-ai/dsh-client-test-runtime'
import {
apply, inject, SETTINGS_NS,
@@ -47,7 +47,7 @@ async function bench() {
ctx.provide('connection', { api: { settings: { describe, mutate } }, isLoopback: true } as never)
// The settings transport and the forwarded-event port the plugin injects.
new TestRemote(ctx)
await ctx.plugin(SettingsScopeBinder).await()
await ctx.plugin(SettingsScopeBinder, new SettingsSchemaService(ctx)).await()
return {
ctx, slots: ctx.get('slots') as SlotRegistry, describe, mutate,
setHostPreference: (next: string | undefined) => { preference = next; revision += 1 },
@@ -1,6 +0,0 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/schema-form/README.md
README.md: ef1d2f9d8ce936fe60d38849f975dc8c0a08ded4
README.zh.md: 315e508ab1a2837acf4d159795cdcc93dedd72fa
-23
View File
@@ -1,23 +0,0 @@
# @deepseek-ai/dsh-client-schema-form
English | [中文](README.zh.md)
Schema/draft model layer for settings editors. The wire's `settings.describe` carries each namespace's serialized schemastery schema (`schema.toJSON()` ref envelope); `rehydrateSchema` turns it back into a live validator with `new Schema(json)` — the same schema object that validates a section on the host validates drafts in the browser, so client-side validation never drifts from the Service Definition's. Editors render their own controls (the Models page hand-writes its card around the fields it probes here); this package owns no React and no rendering.
## Contract
The unit of editing is a **draft user section**: a plain object edited immutably (`setPath` materializes intermediates, `deletePath` is the per-field reset — dropping the key falls the resolved value back to the composition base and schema defaults). A field's presence in the draft marks it **overridden** (`hasPath`) — presence semantics, not value comparison, exactly mirroring the settings seam's layering. `nodeAtPath` resolves the schema node addressed by a configurable-provider directory `settingsPath` (object properties by name, dict entries through `inner`), so an editor can probe which fields a provider's profile carries (and their `meta.role`) before deciding what to render; an unresolvable path returns `undefined` so the caller degrades loudly instead of rendering a wrong subtree. `validateDraft(schema, draft)` runs the rehydrated validator and returns its failure message, letting pages reject an invalid draft before writing.
## Model Experience
None, as this package backs browser configuration editors; nothing here reaches a model request.
#### KV Cache effect
None; this package neither assembles nor sends a provider request.
## Known Limitations and Deferred Work
- **Rehydration executes the served envelope** — `rehydrateSchema` reconstructs a live schemastery validator, and schemastery revives serialized callbacks through `new Function`, so the schema envelope is executable content rather than inert data. This is safe only for an envelope from the same trusted host that serves the page; the protocol provides no inert cross-trust representation.
- **Validation is draft-level, not per-field** — `validateDraft` reports schemastery's first failure message, including its `$.path`; it does not map errors onto individual controls.
- **No generic renderer** — consumers build feature-specific forms over these helpers. The [Web config-plane Agent Note](../../../.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md) records that trade-off.
-23
View File
@@ -1,23 +0,0 @@
# @deepseek-ai/dsh-client-schema-form
[English](README.md) | 中文
面向 settings 编辑器的 schema/草稿模型层。wire 侧的 `settings.describe` 携带每个 namespace 的序列化 schemastery schema`schema.toJSON()` 的 ref 封装);`rehydrateSchema``new Schema(json)` 将其还原(rehydrate)为活的校验器——在宿主上校验分节的那份 schema 对象,就是在浏览器里校验草稿的那份对象,因此客户端校验绝不会偏离 Service Definition 的校验。编辑器各自渲染自己的控件(Models 页围绕它在此探测到的字段手写自己的卡片);该包不含任何 React,也不做任何渲染。
## 约定
编辑的单元是**用户分节草稿**:一个以不可变方式编辑的普通对象(`setPath` 会物化中间对象,`deletePath` 即逐字段重置——去掉该键,解析值便回退到组合 base 与 schema 默认值)。字段只要出现在草稿中就被标记为**已覆盖**(`hasPath`)——判定采用存在性语义而非值比较,与 settings seam 的分层方式严格对应。`nodeAtPath` 解析可配置提供方目录 `settingsPath` 所寻址的 schema 节点(object 属性按名称解析,dict 条目经由 `inner`),编辑器因此可以在决定渲染什么之前,先探测某提供方的 profile 携带哪些字段(及其 `meta.role`);无法解析的路径返回 `undefined`,调用方因此会明确进入降级路径,而不是渲染出错误的子树。`validateDraft(schema, draft)` 运行还原出的校验器并返回其失败消息,页面因此可以在写入前拒绝无效草稿。
## 模型体验
无。该包支撑的是浏览器配置编辑器;这里没有任何内容进入模型请求。
#### KV Cache 影响
无;该包既不组装也不发送提供方请求。
## 已知限制与暂缓事项
- **重建 schema 会执行所收到的封装**——`rehydrateSchema` 会重建一个活的 schemastery 校验器,而 schemastery 通过 `new Function` 复活序列化过的回调函数,因此 schema 信封是可执行内容,而不是不可执行数据。只有该封装来自提供该页面的同一受信任宿主时才安全;该协议没有跨信任边界使用的不可执行表示。
- **校验是草稿级的,而非逐字段**——`validateDraft` 报告 schemastery 的第一条失败消息及其 `$.path`;它不会把错误映射到各个控件。
- **没有通用渲染器**——消费方在这些辅助函数上构建功能专用表单。[Web 配置面 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md) 记录该权衡。
-45
View File
@@ -1,45 +0,0 @@
{
"name": "@deepseek-ai/dsh-client-schema-form",
"description": "Schema/draft model layer for settings editors: rehydrates a serialized schemastery schema, validates drafts, and edits them immutably by path",
"version": "0.1.0-rc.7",
"publishConfig": {
"access": "public"
},
"repository": {
"type": "git",
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
"directory": "packages/client/schema-form"
},
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./invariant": {
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"license": "MIT",
"dependencies": {
"@deepseek-ai/schemastery": "workspace:^"
},
"peerDependencies": {
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/cordis": "workspace:^"
},
"devDependencies": {
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/cordis": "workspace:^"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/types/**/*.d.ts"
]
}
-12
View File
@@ -1,12 +0,0 @@
/**
* Schema/draft model layer for settings editors: rehydrate the wire's
* serialized schemastery envelope, resolve nodes by settings path, validate
* drafts, and edit them immutably by path. Editors render their own controls
* (the Models page hand-writes its layout) on top of these helpers.
* @module @deepseek-ai/dsh-client-schema-form
*/
export {
deletePath, getPath, hasPath, nodeAtPath, rehydrateSchema, setPath, validateDraft,
} from './model.ts'
export type { SchemaNode } from './model.ts'
@@ -1,32 +0,0 @@
/**
* Package-owned invariant companion for `@deepseek-ai/dsh-client-schema-form`.
* @module @deepseek-ai/dsh-client-schema-form/invariant
*/
/* jscpd:ignore-start */
import type { Context } from '@deepseek-ai/cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-client-schema-form'
/** Cordis companion plugin name. */
export const name = 'client-schema-form-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/**
* No runtime invariant: a pure schema/draft helper library — it emits no
* cordis events and owns no cross-plugin mutable relation; draft
* immutability, schema rehydration, and path-edit round trips are asserted
* directly by this package's model specs.
*/
const install: InvariantInstaller = () => {}
/**
* Register this package's invariant companion.
* @param ctx - Cordis context carrying the invariant service.
* @returns the installed registration's disposer after setup succeeds.
*/
export const apply = (ctx: Context): Promise<() => void> =>
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
/* jscpd:ignore-end */
-151
View File
@@ -1,151 +0,0 @@
/**
* Schema introspection and draft-editing helpers behind settings editors.
* The serialized schemastery envelope (`schema.toJSON()`) rehydrates into a
* live validator whose node relations (`dict`/`inner`) editors probe for
* field presence and roles; drafts are edited immutably by path.
* @module @deepseek-ai/dsh-client-schema-form/model
*/
import Schema from '@deepseek-ai/schemastery'
/** Live schemastery node; the renderer reads only its structural relations. */
export type SchemaNode = Schema
/**
* Rehydrate a serialized schema envelope into a live validator/node tree.
* @param serialized - `schema.toJSON()` output received over the wire.
* @returns the root schema node.
*/
export function rehydrateSchema(serialized: unknown): SchemaNode {
return new Schema(serialized as Schema)
}
/**
* Validate a draft against a rehydrated schema.
* @param schema - rehydrated root node.
* @param draft - candidate value.
* @returns the validation failure message, or `undefined` when the draft passes.
*/
export function validateDraft(schema: SchemaNode, draft: unknown): string | undefined {
try {
;(schema as unknown as (value: unknown) => unknown)(draft)
return undefined
} catch (error) {
return error instanceof Error ? error.message : String(error)
}
}
/**
* 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).
* @param path - key path from the root; array indexes as strings.
* @returns the value at the path, or `undefined` along a missing branch.
*/
export function getPath(value: unknown, path: readonly string[]): unknown {
let current: unknown = value
for (const key of path) {
if (Array.isArray(current)) {
current = current[Number(key)]
continue
}
if (typeof current !== 'object' || current === null) return undefined
current = (current as Record<string, unknown>)[key]
}
return current
}
/**
* Whether a draft explicitly carries the path (its presence marks a user
* override, independent of the value stored there).
* @param value - root value (draft or fallback layer).
* @param path - key path from the root; array indexes as strings.
* @returns whether the path's final key exists on its parent.
*/
export function hasPath(value: unknown, path: readonly string[]): boolean {
if (path.length === 0) return value !== undefined
const parent = getPath(value, path.slice(0, -1))
const key = path[path.length - 1] as string
if (Array.isArray(parent)) return Number(key) < parent.length
if (typeof parent !== 'object' || parent === null) return false
return key in parent
}
function cloneContainer(container: unknown, key: string): Record<string, unknown> | unknown[] {
if (Array.isArray(container)) return [...container as unknown[]]
if (typeof container === 'object' && container !== null) return { ...container as Record<string, unknown> }
// A missing intermediate materializes as the container the next key needs.
return /^\d+$/.test(key) ? [] : {}
}
/** Clone the container spine down to the leaf's parent, materializing missing intermediates. */
function cloneSpine(root: Record<string, unknown>, path: readonly string[]): {
result: Record<string, unknown>
parent: Record<string, unknown> | unknown[]
leaf: string
} {
const result = { ...root }
let target: Record<string, unknown> | unknown[] = result
for (let i = 0; i < path.length - 1; i++) {
const key = path[i] as string
const child = cloneContainer(
Array.isArray(target) ? target[Number(key)] : (target)[key],
path[i + 1] as string,
)
if (Array.isArray(target)) target[Number(key)] = child
else (target)[key] = child
target = child
}
return { result, parent: target, leaf: path[path.length - 1] as string }
}
/**
* Immutably set a nested value, materializing missing intermediate containers.
* @param root - draft root (never mutated).
* @param path - non-empty key path.
* @param value - value to store at the path.
* @returns the new draft root.
*/
export function setPath(root: Record<string, unknown>, path: readonly string[], value: unknown): Record<string, unknown> {
if (path.length === 0) throw new Error('schema-form: setPath needs a non-empty path')
const { result, parent, leaf } = cloneSpine(root, path)
if (Array.isArray(parent)) parent[Number(leaf)] = value
else parent[leaf] = value
return result
}
/**
* Immutably remove a nested key (the per-field reset: the resolved value
* falls back to the composition base and schema defaults). Removing along a
* missing branch returns the root unchanged.
* @param root - draft root (never mutated).
* @param path - non-empty key path.
* @returns the new draft root.
*/
export function deletePath(root: Record<string, unknown>, path: readonly string[]): Record<string, unknown> {
if (path.length === 0) throw new Error('schema-form: deletePath needs a non-empty path')
if (!hasPath(root, path)) return root
const { result, parent, leaf } = cloneSpine(root, path)
if (Array.isArray(parent)) parent.splice(Number(leaf), 1)
else Reflect.deleteProperty(parent, leaf)
return result
}
@@ -1,12 +0,0 @@
import { describe, expect, it } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import * as SchemaFormInvariant from '@deepseek-ai/dsh-client-schema-form/invariant'
import InvariantRegistry from '@deepseek-ai/dsh-invariants'
describe('invariant companion', () => {
it('registers under the package name with an empty installer', async () => {
const ctx = new Context()
await ctx.plugin(InvariantRegistry, { enabled: true })
await expect(ctx.plugin(SchemaFormInvariant).await()).resolves.toBeDefined()
})
})
@@ -1,100 +0,0 @@
import { describe, expect, it } from 'vitest'
import Schema from '@deepseek-ai/schemastery'
import {
deletePath, getPath, hasPath, nodeAtPath, rehydrateSchema, setPath, validateDraft,
} from '../src/model.ts'
const Wire = (schema: Schema): unknown => JSON.parse(JSON.stringify(schema.toJSON()))
describe('rehydration and validation', () => {
it('rehydrates a serialized envelope into a working validator', () => {
const root = rehydrateSchema(Wire(Schema.object({ name: Schema.string().required() })))
expect(validateDraft(root, { name: 'ok' })).toBeUndefined()
expect(validateDraft(root, { name: 42 })).toContain('name')
})
it('stringifies non-Error validation throws', () => {
const hostile = (() => {
throw 'plain-string failure'
}) as unknown as Parameters<typeof validateDraft>[0]
expect(validateDraft(hostile, {})).toBe('plain-string failure')
})
})
describe('path helpers', () => {
const root = { providers: { openai: { baseURL: 'https://x' } }, models: [{ id: 'a' }] }
it('reads nested object and array paths', () => {
expect(getPath(root, [])).toBe(root)
expect(getPath(root, ['providers', 'openai', 'baseURL'])).toBe('https://x')
expect(getPath(root, ['models', '0', 'id'])).toBe('a')
expect(getPath(root, ['providers', 'missing', 'x'])).toBeUndefined()
expect(getPath(root, ['providers', 'openai', 'baseURL', 'deep'])).toBeUndefined()
})
it('reports draft presence by key existence, not value truthiness', () => {
expect(hasPath({ flag: false }, ['flag'])).toBe(true)
expect(hasPath({ nested: { key: undefined } }, ['nested', 'key'])).toBe(true)
expect(hasPath({}, ['missing'])).toBe(false)
expect(hasPath({ leaf: 'x' }, ['leaf', 'deeper'])).toBe(false)
expect(hasPath({ models: ['a'] }, ['models', '0'])).toBe(true)
expect(hasPath({ models: ['a'] }, ['models', '1'])).toBe(false)
expect(hasPath({ root: true }, [])).toBe(true)
expect(hasPath(undefined, [])).toBe(false)
})
it('sets nested paths immutably, materializing containers by key shape', () => {
const draft = {}
const next = setPath(draft, ['providers', 'openai', 'baseURL'], 'https://y')
expect(draft).toEqual({})
expect(next).toEqual({ providers: { openai: { baseURL: 'https://y' } } })
const withArray = setPath(next, ['models', '0'], { id: 'a' })
expect(withArray).toEqual({ providers: { openai: { baseURL: 'https://y' } }, models: [{ id: 'a' }] })
const replaced = setPath(withArray, ['models', '0', 'id'], 'b')
expect(replaced.models).toEqual([{ id: 'b' }])
expect((withArray as { models: unknown[] }).models).toEqual([{ id: 'a' }])
expect(() => setPath({}, [], 'x')).toThrow(/non-empty path/)
})
it('deletes nested paths immutably and splices array indexes', () => {
const draft = { providers: { openai: { baseURL: 'https://x', apiKey: 'k' } }, models: ['a', 'b'] }
const withoutKey = deletePath(draft, ['providers', 'openai', 'apiKey'])
expect(withoutKey).toEqual({ providers: { openai: { baseURL: 'https://x' } }, models: ['a', 'b'] })
expect(draft.providers.openai.apiKey).toBe('k')
const withoutModel = deletePath(withoutKey, ['models', '0'])
expect(withoutModel.models).toEqual(['b'])
expect(deletePath(draft, ['providers', 'missing', 'x'])).toBe(draft)
expect(() => deletePath({}, [])).toThrow(/non-empty path/)
})
it('deletes keys through array intermediates immutably', () => {
const draft = { models: [{ id: 'a', contextWindow: 1 }] }
const next = deletePath(draft, ['models', '0', 'contextWindow'])
expect(next).toEqual({ models: [{ id: 'a' }] })
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()
})
})
-18
View File
@@ -1,18 +0,0 @@
{
"extends": "../../../tsconfig.base.client.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": [
"src"
],
"references": [
{
"path": "../../../vendor/schemastery"
},
{
"path": "../../runtime-diagnostics/invariants"
}
]
}
@@ -1,6 +0,0 @@
import { clientLibrary } from '../tsdown.client.ts'
export default clientLibrary(
'@deepseek-ai/dsh-client-schema-form',
['lib/types/index.js', 'lib/types/invariant.js'],
)
@@ -53,15 +53,11 @@
"@deepseek-ai/dsh-client-connection": "workspace:^",
"@deepseek-ai/dsh-client-locale": "workspace:^",
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-schema-form": "workspace:^",
"@deepseek-ai/dsh-client-ui-commands": "workspace:^",
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
"@deepseek-ai/dsh-client-ui-settings": "workspace:^",
"@deepseek-ai/dsh-client-ui-input-trigger": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-permission-presets": "workspace:^",
"react": "^18.2.0"
"@deepseek-ai/dsh-permission-presets": "workspace:^"
},
"devDependencies": {
"@deepseek-ai/cordis": "workspace:^",
@@ -69,7 +65,6 @@
"@deepseek-ai/dsh-client-connection": "workspace:^",
"@deepseek-ai/dsh-client-locale": "workspace:^",
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-schema-form": "workspace:^",
"@deepseek-ai/dsh-client-test-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-commands": "workspace:^",
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
@@ -43,7 +43,7 @@ export type {
} from './settings-store.ts'
/** Required services (cordis fiber inject). */
export const inject = ['commandUi', 'sessions', 'slots', 'locale', 'connection', 'remote']
export const inject = ['commandUi', 'sessions', 'slots', 'locale', 'connection', 'remote', 'settingsSchema']
const ACCESS_NS = 'permission.access'
@@ -113,7 +113,7 @@ export function apply(ctx: ClientContext): void {
ctx.effect(() => ctx.locale.register('settings.permission', { zh, en }), 'ui-permission: settings row dictionaries')
const connection = ctx.get('connection') as ConnectionHandle
const controller = new PermissionPresetSettingsController(connection.api)
const controller = new PermissionPresetSettingsController(connection.api, ctx.settingsSchema)
const load = (): Promise<void> => controller.load()
const select = (preset: string): Promise<void> => controller.select(preset)
const injected = (): PermissionRowInjected => ({
@@ -10,9 +10,7 @@ import type {
import {
createSnapshotStore, type SnapshotStore,
} from '@deepseek-ai/dsh-client-runtime/client'
import {
nodeAtPath, rehydrateSchema, type SchemaNode,
} from '@deepseek-ai/dsh-client-schema-form'
import type { SchemaNode, SettingsSchemaService } from '@deepseek-ai/dsh-client-ui-settings/client'
import { displayPermissionPreset } from './presentation.ts'
/** Permission's settings namespace on the host wire. */
@@ -47,13 +45,13 @@ interface ConstChoice {
* @param view - permission namespace descriptor.
* @returns current value and selectable options.
*/
export function permissionDefaultOf(view: SettingsNamespaceView): {
export function permissionDefaultOf(view: SettingsNamespaceView, schema: SettingsSchemaService): {
currentValue: string
options: PermissionDefaultOption[]
} {
const value = (view.value as { defaultPreset?: unknown } | null)?.defaultPreset
if (typeof value !== 'string') throw new Error('permission settings has no defaultPreset value')
const node = nodeAtPath(rehydrateSchema(view.schema), ['defaultPreset'])
const node = schema.nodeAtPath(schema.rehydrate(view.schema), ['defaultPreset'])
if (node === undefined) throw new Error('permission settings schema has no defaultPreset field')
const rawChoices = node.type === 'union'
? (node.list as SchemaNode[] | undefined) ?? []
@@ -91,7 +89,10 @@ export class PermissionPresetSettingsController {
private view: SettingsNamespaceView | undefined
/** @param api - Settings wire face. */
constructor(private readonly api: Pick<IApiClient, 'settings'>) {}
constructor(
private readonly api: Pick<IApiClient, 'settings'>,
private readonly schema: SettingsSchemaService,
) {}
/**
* Refresh the permission descriptor. Latest request wins.
@@ -161,7 +162,7 @@ export class PermissionPresetSettingsController {
}
private accept(view: SettingsNamespaceView, writable: boolean): void {
const resolved = permissionDefaultOf(view)
const resolved = permissionDefaultOf(view, this.schema)
this.view = view
this.store.update((state) => {
state.status = 'ready'
@@ -1,8 +1,10 @@
// @vitest-environment jsdom
import { Context } from '@deepseek-ai/cordis'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
import type { SettingsNamespaceView } from '@deepseek-ai/dsh-api-remotes/client'
import { SettingsSchemaService } from '@deepseek-ai/dsh-client-ui-settings/client'
import { PermissionRow, type PermissionRowProps } from '../src/client/PermissionRow.tsx'
import { en } from '../src/client/locales.ts'
import { PermissionPresetSettingsController } from '../src/client/settings-store.ts'
@@ -20,6 +22,12 @@ const SCHEMA = {
},
}
const schema = new SettingsSchemaService(new Context())
function createController(api: ConstructorParameters<typeof PermissionPresetSettingsController>[0]) {
return new PermissionPresetSettingsController(api, schema)
}
function view(defaultPreset: string, revision = 0): SettingsNamespaceView {
return {
ns: 'permission',
@@ -58,7 +66,7 @@ function mount(controller: PermissionPresetSettingsController) {
describe('PermissionRow', () => {
it('loads the descriptor, opens the menu, and selects a new default', async () => {
const mutate = vi.fn(() => Promise.resolve(ok(view('workspace-write', 1))))
const controller = new PermissionPresetSettingsController({
const controller = createController({
settings: {
describe: () => Promise.resolve(ok({ writable: true, hasDocument: false, namespaces: [view('read-only')] })),
mutate,
@@ -85,7 +93,7 @@ describe('PermissionRow', () => {
it('requires explicit acknowledgement before saving Full access', async () => {
const mutate = vi.fn(() => Promise.resolve(ok(view('danger-full-access', 1))))
const controller = new PermissionPresetSettingsController({
const controller = createController({
settings: {
describe: () => Promise.resolve(ok({ writable: true, hasDocument: false, namespaces: [view('read-only')] })),
mutate,
@@ -109,7 +117,7 @@ describe('PermissionRow', () => {
})
it('hides an unavailable namespace and disables a read-only provider', async () => {
const absent = new PermissionPresetSettingsController({
const absent = createController({
settings: {
describe: () => Promise.resolve(ok({ writable: true, hasDocument: false, namespaces: [] })),
mutate: vi.fn(),
@@ -119,7 +127,7 @@ describe('PermissionRow', () => {
await waitFor(() => { expect(rendered.container.textContent).toBe('') })
rendered.unmount()
const readonly = new PermissionPresetSettingsController({
const readonly = createController({
settings: {
describe: () => Promise.resolve(ok({ writable: false, hasDocument: false, namespaces: [view('read-only')] })),
mutate: vi.fn(),
@@ -134,7 +142,7 @@ describe('PermissionRow', () => {
writable: boolean
namespaces: SettingsNamespaceView[]
}>>>()
const controller = new PermissionPresetSettingsController({
const controller = createController({
settings: {
describe: () => describe.promise,
mutate: () => Promise.resolve({
@@ -1,5 +1,7 @@
import { Context } from '@deepseek-ai/cordis'
import { describe, expect, it, vi } from 'vitest'
import type { SettingsNamespaceView } from '@deepseek-ai/dsh-api-remotes/client'
import { SettingsSchemaService } from '@deepseek-ai/dsh-client-ui-settings/client'
import {
PermissionPresetSettingsController, permissionDefaultOf, refreshPermissionIfLoaded,
} from '../src/client/settings-store.ts'
@@ -14,6 +16,16 @@ const SCHEMA = {
},
}
const schema = new SettingsSchemaService(new Context())
function resolveDefault(view: SettingsNamespaceView) {
return permissionDefaultOf(view, schema)
}
function createController(api: ConstructorParameters<typeof PermissionPresetSettingsController>[0]) {
return new PermissionPresetSettingsController(api, schema)
}
function view(defaultPreset: string, revision = 0, schema: SettingsNamespaceView['schema'] = SCHEMA): SettingsNamespaceView {
return {
ns: 'permission',
@@ -32,7 +44,7 @@ function ok<T>(value: T) {
describe('permission settings store', () => {
it('derives dynamic options and host labels from the descriptor schema', () => {
expect(permissionDefaultOf(view('read-only'))).toEqual({
expect(resolveDefault(view('read-only'))).toEqual({
currentValue: 'read-only',
options: [
{ id: 'read-only', label: 'Read Only' },
@@ -46,7 +58,7 @@ describe('permission settings store', () => {
2: { type: 'object', dict: { defaultPreset: 1 } },
},
}
expect(permissionDefaultOf(view('read-only', 0, single))).toEqual({
expect(resolveDefault(view('read-only', 0, single))).toEqual({
currentValue: 'read-only',
options: [{ id: 'read-only', label: 'Read Only' }],
})
@@ -57,23 +69,23 @@ describe('permission settings store', () => {
2: { type: 'object', dict: { defaultPreset: 1 } },
},
}
expect(permissionDefaultOf(view('read-only', 0, undescribed)).options)
expect(resolveDefault(view('read-only', 0, undescribed)).options)
.toEqual([{ id: 'read-only', label: 'Read Only' }])
})
it('rejects malformed values and dynamic enums at the wire boundary', () => {
expect(() => permissionDefaultOf({ ...view('read-only'), value: {} })).toThrow(/no defaultPreset value/)
expect(() => permissionDefaultOf(view('read-only', 0, {
expect(() => resolveDefault({ ...view('read-only'), value: {} })).toThrow(/no defaultPreset value/)
expect(() => resolveDefault(view('read-only', 0, {
uid: 1, refs: { 1: { type: 'object', dict: {} } },
}))).toThrow(/no defaultPreset field/)
expect(() => permissionDefaultOf(view('read-only', 0, {
expect(() => resolveDefault(view('read-only', 0, {
uid: 2,
refs: {
1: { type: 'union' },
2: { type: 'object', dict: { defaultPreset: 1 } },
},
}))).toThrow(/does not advertise/)
expect(() => permissionDefaultOf(view('read-only', 0, {
expect(() => resolveDefault(view('read-only', 0, {
uid: 4,
refs: {
1: { type: 'string' },
@@ -82,7 +94,7 @@ describe('permission settings store', () => {
4: { type: 'object', dict: { defaultPreset: 3 } },
},
}))).toThrow(/does not advertise/)
expect(() => permissionDefaultOf(view('missing'))).toThrow(/does not advertise/)
expect(() => resolveDefault(view('missing'))).toThrow(/does not advertise/)
})
it('loads and writes defaultPreset with optimistic concurrency', async () => {
@@ -92,7 +104,7 @@ describe('permission settings store', () => {
namespaces: [view('read-only', 4)],
})))
const mutate = vi.fn(() => Promise.resolve(ok(view('workspace-write', 5))))
const controller = new PermissionPresetSettingsController({
const controller = createController({
settings: { describe, mutate } as never,
})
await controller.load()
@@ -117,13 +129,13 @@ describe('permission settings store', () => {
it('hides the row when the namespace is absent and contains write failures', async () => {
const describe = vi.fn(() => Promise.resolve(ok({ writable: true, hasDocument: false, namespaces: [] })))
const controller = new PermissionPresetSettingsController({
const controller = createController({
settings: { describe, mutate: vi.fn() } as never,
})
await controller.load()
expect(controller.store.getSnapshot().status).toBe('unavailable')
const failing = new PermissionPresetSettingsController({
const failing = createController({
settings: {
describe: () => Promise.resolve(ok({ writable: true, hasDocument: false, namespaces: [view('read-only')] })),
mutate: () => Promise.resolve({
@@ -149,7 +161,7 @@ describe('permission settings store', () => {
.mockImplementationOnce(() => first.promise)
.mockResolvedValueOnce(ok({ writable: false, hasDocument: false, namespaces: [view('read-only', 2)] }))
const mutate = vi.fn()
const controller = new PermissionPresetSettingsController({
const controller = createController({
settings: { describe, mutate } as never,
})
const stale = controller.load()
@@ -164,7 +176,7 @@ describe('permission settings store', () => {
await controller.select('workspace-write')
expect(mutate).not.toHaveBeenCalled()
const rejected = new PermissionPresetSettingsController({
const rejected = createController({
settings: {
describe: () => Promise.resolve({
rpcId: 'test',
@@ -177,7 +189,7 @@ describe('permission settings store', () => {
await rejected.load()
expect(rejected.store.getSnapshot()).toMatchObject({ status: 'error', error: 'offline' })
const thrown = new PermissionPresetSettingsController({
const thrown = createController({
settings: {
// Promise consumers must contain unknown rejection values from a
// transport implementation, including non-Error legacy clients.
@@ -196,7 +208,7 @@ describe('permission settings store', () => {
namespaces: SettingsNamespaceView[]
}>>>()
const describe = vi.fn(() => read.promise)
const idle = new PermissionPresetSettingsController({ settings: { describe, mutate: vi.fn() } as never })
const idle = createController({ settings: { describe, mutate: vi.fn() } as never })
refreshPermissionIfLoaded(idle)
expect(describe).not.toHaveBeenCalled()
const loading = idle.load()
@@ -209,7 +221,7 @@ describe('permission settings store', () => {
writable: boolean
namespaces: SettingsNamespaceView[]
}>>>()
const disposedRead = new PermissionPresetSettingsController({
const disposedRead = createController({
settings: { describe: () => rejectedRead.promise, mutate: vi.fn() } as never,
})
const reading = disposedRead.load()
@@ -224,7 +236,7 @@ describe('permission settings store', () => {
hasDocument: false,
namespaces: [view('read-only')],
})))
const active = new PermissionPresetSettingsController({
const active = createController({
settings: {
describe: activeDescribe,
mutate: () => mutation.promise,
@@ -240,7 +252,7 @@ describe('permission settings store', () => {
expect(active.store.getSnapshot().status).toBe('saving')
const rejectedMutation = Promise.withResolvers<ReturnType<typeof ok<SettingsNamespaceView>>>()
const disposedWrite = new PermissionPresetSettingsController({
const disposedWrite = createController({
settings: {
describe: () => Promise.resolve(ok({ writable: true, hasDocument: false, namespaces: [view('read-only')] })),
mutate: () => rejectedMutation.promise,
@@ -17,9 +17,6 @@
{
"path": "../runtime"
},
{
"path": "../schema-form"
},
{
"path": "../ui-commands"
},
@@ -50,19 +50,15 @@
"@deepseek-ai/dsh-api-remotes": "workspace:^",
"@deepseek-ai/dsh-client-connection": "workspace:^",
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-schema-form": "workspace:^",
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-client-web-react": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"react": "^18.2.0"
"@deepseek-ai/dsh-client-locale": "workspace:^",
"@deepseek-ai/dsh-client-ui-settings": "workspace:^"
},
"devDependencies": {
"@deepseek-ai/dsh-api-remotes": "workspace:^",
"@deepseek-ai/dsh-client-connection": "workspace:^",
"@deepseek-ai/dsh-client-locale": "workspace:^",
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-schema-form": "workspace:^",
"@deepseek-ai/dsh-client-test-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
"@deepseek-ai/dsh-client-ui-settings": "workspace:^",
@@ -101,6 +101,7 @@ export function DeepSeekOnboardingDialog(props: DeepSeekOnboardingDialogProps):
provider={row.entry.provider}
displayName={row.entry.displayName}
namespace={namespace}
schema={controller.schema}
settingsPath={row.entry.settingsPath}
api={api}
t={t}
@@ -63,7 +63,7 @@ interface EditorTarget extends ProviderIdentity {
/** Values that vary around the shared provider-editor rendering. */
interface ProviderEditorRenderProps extends Pick<
ProviderEditorProps,
'namespace' | 'api' | 't' | 'readOnly' | 'onClose'
'namespace' | 'schema' | 'api' | 't' | 'readOnly' | 'onClose'
> {
target: EditorTarget
}
@@ -269,7 +269,7 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode {
// Hand-declared routes live in the pi-ai namespace, which is also the only
// one whose schema names the protocols one may speak; without it mounted
// there is nothing to declare and the entry point stays disabled.
const protocols = protocolChoices(state.namespaces.get('llm-pi-ai'))
const protocols = protocolChoices(state.namespaces.get('llm-pi-ai'), controller.schema)
return (
<div className={styles['section']}>
@@ -297,6 +297,7 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode {
{renderProviderEditor({
target,
namespace,
schema: controller.schema,
api,
t,
readOnly: !state.writable,
@@ -381,6 +382,7 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode {
? renderProviderEditor({
target,
namespace,
schema: controller.schema,
api,
t,
readOnly: !state.writable,
@@ -419,6 +421,7 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode {
displayName={addTarget.displayName}
hideTitle
namespace={addNamespace}
schema={controller.schema}
settingsPath={addTarget.settingsPath}
api={api}
t={t}
@@ -24,9 +24,7 @@
import { useEffect, useMemo, useState } from 'react'
import type { ReactNode } from 'react'
import type { CredentialView, IApiClient, SettingsNamespaceView, SettingsPathOpView } from '@deepseek-ai/dsh-api-remotes/client'
import {
deletePath, getPath, hasPath, nodeAtPath, rehydrateSchema, setPath, validateDraft,
} from '@deepseek-ai/dsh-client-schema-form'
import type { SettingsSchemaService } from '@deepseek-ai/dsh-client-ui-settings/client'
import {
DeepSeekModelsEditor, modelDrafts, validateDeepSeekModels,
} from './DeepSeekModelsEditor.tsx'
@@ -61,6 +59,8 @@ export interface ProviderEditorProps {
declared?: boolean
/** The owning namespace view (schema, layers, secrets). */
namespace: SettingsNamespaceView
/** Settings-owned synchronous schema and immutable path operations. */
schema: SettingsSchemaService
/** Path from the section root to this provider's profile. */
settingsPath: readonly string[]
/** Wire faces for writes and for interrogating a provider endpoint. */
@@ -86,8 +86,12 @@ export interface ProviderEditorProps {
}
/** A user-section subtree as a plain draft object (absent → empty). */
function draftAt(namespace: SettingsNamespaceView, path: readonly string[]): Record<string, unknown> {
const subtree = getPath(namespace.user, path)
function draftAt(
schema: SettingsSchemaService,
namespace: SettingsNamespaceView,
path: readonly string[],
): Record<string, unknown> {
const subtree = schema.getPath(namespace.user, path)
if (typeof subtree !== 'object' || subtree === null || Array.isArray(subtree)) return {}
return structuredClone(subtree) as Record<string, unknown>
}
@@ -129,8 +133,13 @@ function layoutOf(ns: string): EditorLayout {
}
/** The credential reference this profile resolves keys through. */
function refFor(namespace: SettingsNamespaceView, path: readonly string[], provider: string): string {
const profile = getPath(namespace.value, path)
function refFor(
schema: SettingsSchemaService,
namespace: SettingsNamespaceView,
path: readonly string[],
provider: string,
): string {
const profile = schema.getPath(namespace.value, path)
const named = typeof profile === 'object' && profile !== null
? (profile as { apiKeyEnv?: unknown }).apiKeyEnv
: undefined
@@ -143,8 +152,8 @@ function refFor(namespace: SettingsNamespaceView, path: readonly string[], provi
* @returns the editor card.
*/
export function ProviderEditor(props: ProviderEditorProps): ReactNode {
const { namespace, settingsPath, api, t } = props
const [draft, setDraft] = useState<Record<string, unknown>>(() => draftAt(namespace, settingsPath))
const { namespace, schema, settingsPath, api, t } = props
const [draft, setDraft] = useState<Record<string, unknown>>(() => draftAt(schema, namespace, settingsPath))
const [keyDraft, setKeyDraft] = useState('')
const [keyState, setKeyState] = useState<CredentialView | undefined>(undefined)
const [busy, setBusy] = useState(false)
@@ -153,22 +162,22 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
// derived fields in the draft prevents a pushed namespace refresh from
// turning them into deletions when the following credential write is retried.
const [committedOriginal, setCommittedOriginal] = useState<unknown>(
() => getPath(namespace.user, settingsPath),
() => schema.getPath(namespace.user, settingsPath),
)
const [expectedRevision, setExpectedRevision] = useState(() => namespace.revision)
const root = useMemo(() => rehydrateSchema(namespace.schema), [namespace.schema])
const node = useMemo(() => nodeAtPath(root, settingsPath), [root, settingsPath])
const fallback = getPath(namespace.value, settingsPath)
const root = useMemo(() => schema.rehydrate(namespace.schema), [namespace.schema, schema])
const node = useMemo(() => schema.nodeAtPath(root, settingsPath), [root, schema, settingsPath])
const fallback = schema.getPath(namespace.value, settingsPath)
const disabled = props.readOnly || busy
const layout = layoutOf(namespace.ns)
const keyRef = refFor(namespace, settingsPath, props.provider)
const keyRef = refFor(schema, namespace, settingsPath, props.provider)
// The same schema read the create card makes, so the choices offered here
// and there cannot drift apart: both come from the adapter's own `Config`.
// Only the pi-ai layout has a per-route protocol for the read to find, and
// it rehydrates the whole section schema, so the other layouts skip it.
const protocols = useMemo(
() => layout === 'pi-ai' ? protocolChoices(namespace) : [],
[layout, namespace],
() => layout === 'pi-ai' ? protocolChoices(namespace, schema) : [],
[layout, namespace, schema],
)
useEffect(() => {
@@ -189,7 +198,7 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
}, [api.credentials, keyRef])
const stringAt = (source: unknown, key: string): string | undefined => {
const value = getPath(source, [key])
const value = schema.getPath(source, [key])
return typeof value === 'string' && value.trim().length > 0 ? value : undefined
}
const setField = (key: string, next: string | undefined): void => {
@@ -198,12 +207,14 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
// while the draft still carried the spaces into `settings.yaml`, where
// both adapters would accept that non-empty string as a real value.
const value = next === undefined || next.trim().length === 0 ? undefined : next
setDraft(current => value === undefined ? deletePath(current, [key]) : setPath(current, [key], value))
setDraft(current => value === undefined
? schema.deletePath(current, [key])
: schema.setPath(current, [key], value))
}
// The model list is validated by the same per-row checker for both families,
// so a bad row is named by its position rather than by a blanket message.
const modelFailure = validateDeepSeekModels(getPath(draft, ['models']))
const modelFailure = validateDeepSeekModels(schema.getPath(draft, ['models']))
const keyFailure = apiKeyFailure(keyDraft)
// What a probe or a write must carry: the typed key with paste whitespace
// removed. A blank field yields an empty string, which both call sites read
@@ -240,14 +251,14 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
// about to store a key. Otherwise the provider keeps its native auth path.
const next = layout === 'pi-ai' && stringAt(draft, 'apiKeyEnv') === undefined
&& stringAt(fallback, 'apiKeyEnv') === undefined && keyValue.length > 0
? setPath(draft, ['apiKeyEnv'], keyRef)
? schema.setPath(draft, ['apiKeyEnv'], keyRef)
: draft
if (props.credentialOnly !== true) {
// The same checker gates the submit button, so a card cannot reach this
// with a bad row; it stays because the schema check below would refuse
// the write with a message naming a path instead of the row, and because
// nothing but this function decides what is written.
const failure = validateDeepSeekModels(getPath(next, ['models']))
const failure = validateDeepSeekModels(schema.getPath(next, ['models']))
/* v8 ignore next 3 -- unreachable from the card: the same failure disables submit */
if (failure !== undefined) {
return `${t('model')} ${String(failure.index + 1)}: ${t(failure.key)}`
@@ -255,7 +266,7 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
}
/* v8 ignore next -- apply is only reachable from the rendered card, which required a resolved node */
if (props.credentialOnly !== true && node !== undefined && settingsPath.length === 0) {
const sectionError = validateDraft(node, next)
const sectionError = schema.validate(node, next)
if (sectionError !== undefined) return sectionError
}
const materializesNativeProfile = layout === 'pi-ai'
@@ -274,7 +285,7 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
? t('conflict')
: response.result.error.message
}
setCommittedOriginal(getPath(response.result.value.user, settingsPath))
setCommittedOriginal(schema.getPath(response.result.value.user, settingsPath))
setExpectedRevision(response.result.value.revision)
setDraft(next)
}
@@ -322,8 +333,8 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
* moment reset drops it, leaving the rows unchanged until a reload.
*/
const inheritedModels = (): unknown => {
const pinned = getPath(namespace.base, [...settingsPath, 'models'])
return pinned ?? nodeAtPath(root, [...settingsPath, 'models'])?.meta.default
const pinned = schema.getPath(namespace.base, [...settingsPath, 'models'])
return pinned ?? schema.nodeAtPath(root, [...settingsPath, 'models'])?.meta.default
}
/**
@@ -336,11 +347,11 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
// A whole-section `llm-deepseek` profile is a composition fact with no
// per-route identity for its schema to carry, hence the family test.
const ownsIdentity = family === 'pi-ai' && props.declared === true
const customModels = getPath(draft, ['models'])
const modelsOverridden = hasPath(draft, ['models'])
const customModels = schema.getPath(draft, ['models'])
const modelsOverridden = schema.hasPath(draft, ['models'])
const models = modelDrafts(modelsOverridden ? customModels : inheritedModels())
const defaultContextWindow = getPath(fallback, ['defaultContextWindow'])
const defaultMaxTokens = getPath(fallback, ['maxTokens'])
const defaultContextWindow = schema.getPath(fallback, ['defaultContextWindow'])
const defaultMaxTokens = schema.getPath(fallback, ['maxTokens'])
const keyPlaceholder = keyLocked
? t('keyEnvLocked')
: keyState?.configured === true && props.credentialRequired !== true
@@ -353,9 +364,9 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
t,
disabled,
onChange: (next: Record<string, unknown>[]) => {
setDraft(current => setPath(current, ['models'], next))
setDraft(current => schema.setPath(current, ['models'], next))
},
onReset: () => { setDraft(current => deletePath(current, ['models'])) },
onReset: () => { setDraft(current => schema.deletePath(current, ['models'])) },
}
return (
<>
@@ -397,7 +408,7 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
// the answer the route id. Reading the effective value
// instead would echo the stored override back as the
// thing clearing restores.
placeholder={stringAt(getPath(namespace.base, settingsPath), 'displayName')
placeholder={stringAt(schema.getPath(namespace.base, settingsPath), 'displayName')
?? props.provider}
aria-label={t('customDisplayName')}
disabled={disabled}
@@ -56,7 +56,7 @@ export function refreshIfLoaded(controller: ModelsSettingsStore): void {
* ui-settings' apply, whose activation order relative to this one is NOT
* constrained; registration depends on each slot through `slots.inject()`.
*/
export const inject = ['slots', 'locale', 'connection', 'remote']
export const inject = ['slots', 'locale', 'connection', 'remote', 'settingsSchema']
/**
* Register the Models section once the `settings.section` declaration is on
@@ -68,7 +68,7 @@ export function apply(ctx: ClientContext): void {
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-settings-models: copy dictionaries')
const connection = ctx.get('connection') as ConnectionHandle
const controller = new ModelsSettingsStore(connection.api)
const controller = new ModelsSettingsStore(connection.api, ctx.settingsSchema)
const useSnapshot = bindSnapshotSelector(controller.store)
// Registration-time text (the nav label thunk) and the inject faces share
// one bound translate; copy freshness rides the locale revision.
@@ -11,7 +11,7 @@ import type {
} from '@deepseek-ai/dsh-api-remotes/client'
import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import { getPath, hasPath, nodeAtPath, rehydrateSchema } from '@deepseek-ai/dsh-client-schema-form'
import type { SettingsSchemaService } from '@deepseek-ai/dsh-client-ui-settings/client'
/**
* Any route key walks a dict schema to the same profile node, so the lookup
@@ -78,18 +78,25 @@ export function deriveKeyRef(provider: string): string {
* @param namespace - the namespace view whose schema declares the profile shape.
* @returns the protocol identifiers, or an empty list when the schema has none.
*/
export function protocolChoices(namespace: SettingsNamespaceView | undefined): string[] {
export function protocolChoices(
namespace: SettingsNamespaceView | undefined,
schema: SettingsSchemaService,
): string[] {
if (namespace === undefined) return []
const node = nodeAtPath(rehydrateSchema(namespace.schema), ['providers', PROBE_ROUTE, 'api'])
const node = schema.nodeAtPath(schema.rehydrate(namespace.schema), ['providers', PROBE_ROUTE, 'api'])
const list = (node as { type?: string; list?: readonly { value?: unknown }[] } | undefined)
if (list?.type !== 'union' || list.list === undefined) return []
return list.list.map(entry => entry.value).filter((value): value is string => typeof value === 'string')
}
/** The credential reference a resolved profile names (its `apiKeyEnv` field). */
function apiKeyEnvOf(namespace: SettingsNamespaceView | undefined, path: readonly string[]): string | undefined {
function apiKeyEnvOf(
namespace: SettingsNamespaceView | undefined,
path: readonly string[],
schema: SettingsSchemaService,
): string | undefined {
if (namespace === undefined) return undefined
const profile = getPath(namespace.value, path)
const profile = schema.getPath(namespace.value, path)
if (typeof profile !== 'object' || profile === null) return undefined
const ref = (profile as { apiKeyEnv?: unknown }).apiKeyEnv
return typeof ref === 'string' && ref.length > 0 ? ref : undefined
@@ -108,7 +115,10 @@ export class ModelsSettingsStore {
/**
* @param api - the wire face (settings/credentials/llm domains).
*/
constructor(private readonly api: Pick<IApiClient, 'settings' | 'credentials' | 'llm'>) {}
constructor(
private readonly api: Pick<IApiClient, 'settings' | 'credentials' | 'llm'>,
readonly schema: SettingsSchemaService,
) {}
/**
* Refresh the whole page snapshot: directory and namespaces in parallel,
@@ -144,16 +154,16 @@ export class ModelsSettingsStore {
const rows: ProviderRow[] = providers.map((entry) => {
const namespace = namespaces.get(entry.settingsNs)
const configured = namespace !== undefined
&& (entry.settingsPath.length === 0 || getPath(namespace.value, entry.settingsPath) !== undefined)
&& (entry.settingsPath.length === 0 || this.schema.getPath(namespace.value, entry.settingsPath) !== undefined)
const removable = namespace !== undefined
&& entry.settingsPath.length > 0
&& hasPath(namespace.user, entry.settingsPath)
&& !hasPath(namespace.base, entry.settingsPath)
&& this.schema.hasPath(namespace.user, entry.settingsPath)
&& !this.schema.hasPath(namespace.base, entry.settingsPath)
return {
entry,
configured,
removable,
apiKeyEnv: apiKeyEnvOf(namespace, entry.settingsPath),
apiKeyEnv: apiKeyEnvOf(namespace, entry.settingsPath, this.schema),
credential: undefined,
}
})
@@ -17,6 +17,7 @@ import { apiKeyFailure } from '../src/client/apiKey.ts'
import { deriveKeyRef, ModelsSettingsStore } from '../src/client/store.ts'
import type { ProviderRow } from '../src/client/store.ts'
import { en } from '../src/client/locales.ts'
import { settingsSchema } from './settings-schema.client.ts'
afterEach(cleanup)
@@ -185,7 +186,7 @@ type WireFace = ConstructorParameters<typeof ModelsSettingsStore>[0]
async function mountFace(scripted: ReturnType<typeof scriptedFace>) {
const { face, update, replace, mutate, set, unset } = scripted
const controller = new ModelsSettingsStore(face as unknown as WireFace)
const controller = new ModelsSettingsStore(face as unknown as WireFace, settingsSchema)
await controller.load()
const injected: ModelsSectionInjected = {
controller,
@@ -265,7 +266,7 @@ describe('ModelsSection', () => {
face.credentials.describe.mockImplementation((payload: { refs: string[] }) => Promise.resolve(ok({
credentials: Object.fromEntries(payload.refs.map(ref => [ref, { configured: false, writable: true }])),
})))
const controller = new ModelsSettingsStore(face as unknown as WireFace)
const controller = new ModelsSettingsStore(face as unknown as WireFace, settingsSchema)
await controller.load()
render(<ModelsSection
controller={controller}
@@ -287,7 +288,7 @@ describe('ModelsSection', () => {
face.credentials.describe.mockImplementation((payload: { refs: string[] }) => Promise.resolve(ok({
credentials: Object.fromEntries(payload.refs.map(ref => [ref, { configured: true, writable: true }])),
})))
const controller = new ModelsSettingsStore(face as unknown as WireFace)
const controller = new ModelsSettingsStore(face as unknown as WireFace, settingsSchema)
await controller.load()
cleanup()
render(<ModelsSection
@@ -369,6 +370,7 @@ describe('ModelsSection', () => {
displayName="DeepSeek"
hideTitle
namespace={wireNamespaces()[0]!}
schema={settingsSchema}
settingsPath={[]}
api={face as never}
t={t}
@@ -621,6 +623,7 @@ describe('ModelsSection', () => {
provider="deepseek-official"
displayName="DeepSeek"
namespace={overridden}
schema={settingsSchema}
settingsPath={[]}
api={face as never}
t={t}
@@ -850,6 +853,7 @@ describe('ModelsSection', () => {
provider="deepseek-official"
displayName="DeepSeek"
namespace={bare}
schema={settingsSchema}
settingsPath={[]}
api={face as never}
t={t}
@@ -1007,7 +1011,7 @@ describe('ModelsSection', () => {
const unhandled = vi.fn()
process.on('unhandledRejection', unhandled)
try {
const controller = new ModelsSettingsStore(face as unknown as WireFace)
const controller = new ModelsSettingsStore(face as unknown as WireFace, settingsSchema)
await controller.load()
render(<ModelsSection
controller={controller}
@@ -1144,7 +1148,7 @@ describe('ModelsSection', () => {
it('renders the load failure with a retry control', async () => {
const face = scriptedFace()
face.face.llm.providers = vi.fn(() => Promise.resolve(fail('directory down', 'internal'))) as never
const controller = new ModelsSettingsStore(face.face as unknown as WireFace)
const controller = new ModelsSettingsStore(face.face as unknown as WireFace, settingsSchema)
await controller.load()
render(<ModelsSection
controller={controller}
@@ -1164,7 +1168,7 @@ describe('ModelsSection', () => {
hasDocument: false,
namespaces: wireNamespaces(),
})))
const controller = new ModelsSettingsStore(face as unknown as WireFace)
const controller = new ModelsSettingsStore(face as unknown as WireFace, settingsSchema)
await controller.load()
cleanup()
render(<ModelsSection
@@ -1226,7 +1230,7 @@ describe('ModelsSection', () => {
it('loads on first render of an idle controller', async () => {
const { face } = scriptedFace()
const controller = new ModelsSettingsStore(face as unknown as WireFace)
const controller = new ModelsSettingsStore(face as unknown as WireFace, settingsSchema)
render(<ModelsSection
controller={controller}
useSnapshot={bindSnapshotSelector(controller.store)}
@@ -9,6 +9,7 @@ import { DeepSeekOnboardingDialog } from '../src/client/DeepSeekOnboardingDialog
import type { DeepSeekOnboardingDialogProps } from '../src/client/DeepSeekOnboardingDialog.tsx'
import { ModelsSettingsStore } from '../src/client/store.ts'
import { en } from '../src/client/locales.ts'
import { settingsSchema } from './settings-schema.client.ts'
afterEach(() => {
cleanup()
@@ -124,7 +125,7 @@ function harness(options: {
set,
},
}
const controller = new ModelsSettingsStore(face as never)
const controller = new ModelsSettingsStore(face as never, settingsSchema)
const openSection = vi.fn()
const complete = vi.fn()
const unusedHook = (() => { throw new Error('unused standard hook') }) as never
@@ -11,6 +11,7 @@ import { CustomProviderCard } from '../src/client/CustomProviderCard.tsx'
import { formatCapacity, parseCapacity } from '../src/client/DeepSeekModelsEditor.tsx'
import { ModelsSettingsStore, deriveKeyRef, protocolChoices } from '../src/client/store.ts'
import { en } from '../src/client/locales.ts'
import { settingsSchema } from './settings-schema.client.ts'
afterEach(cleanup)
@@ -139,7 +140,7 @@ function firstMutate(mutate: ReturnType<typeof vi.fn>): MutateCall {
async function mountSection(options: Parameters<typeof scriptedFace>[0] = {}) {
const scripted = scriptedFace(options)
const controller = new ModelsSettingsStore(scripted.face as unknown as WireFace)
const controller = new ModelsSettingsStore(scripted.face as unknown as WireFace, settingsSchema)
await controller.load()
const injected: ModelsSectionInjected = {
controller,
@@ -183,10 +184,10 @@ function within_(scope: HTMLElement, label: string): HTMLElement {
describe('protocolChoices', () => {
it('reads the protocols out of the namespace schema and nothing else', async () => {
const { namespace } = scriptedFace()
expect(protocolChoices(namespace)).toEqual(PROTOCOLS)
expect(protocolChoices(undefined)).toEqual([])
expect(protocolChoices(namespace, settingsSchema)).toEqual(PROTOCOLS)
expect(protocolChoices(undefined, settingsSchema)).toEqual([])
const plain = { ...namespace, schema: JSON.parse(JSON.stringify(Schema.object({}).toJSON())) as unknown }
expect(protocolChoices(plain)).toEqual([])
expect(protocolChoices(plain, settingsSchema)).toEqual([])
await Promise.resolve()
})
})
@@ -637,7 +638,7 @@ describe('provider rows', () => {
active: true,
}],
}))) as never
const controller = new ModelsSettingsStore(scripted.face as unknown as WireFace)
const controller = new ModelsSettingsStore(scripted.face as unknown as WireFace, settingsSchema)
await controller.load()
render(<ModelsSection
controller={controller}
@@ -0,0 +1,5 @@
import { Context } from '@deepseek-ai/cordis'
import { SettingsSchemaService } from '@deepseek-ai/dsh-client-ui-settings/client'
/** Stateless schema operations used by settings-model component fixtures. */
export const settingsSchema = new SettingsSchemaService(new Context())
@@ -2,6 +2,7 @@
import { describe, expect, it } from 'vitest'
import type { RpcResponse } from '@deepseek-ai/dsh-api-remotes/client'
import { messageOf, ModelsSettingsStore } from '../src/client/store.ts'
import { settingsSchema } from './settings-schema.client.ts'
let nextRpc = 0
function ok<T>(value: T): RpcResponse<T> {
@@ -72,7 +73,7 @@ function api(overrides: {
describe('ModelsSettingsStore', () => {
it('joins rows with configured, removable, and credential state', async () => {
const { face, seenRefs } = api()
const store = new ModelsSettingsStore(face)
const store = new ModelsSettingsStore(face, settingsSchema)
await store.load()
const state = store.store.getSnapshot()
expect(state.status).toBe('ready')
@@ -100,7 +101,7 @@ describe('ModelsSettingsStore', () => {
it('degrades the credential badge, not the page, when the credential domain fails', async () => {
const { face } = api({ describeCredentials: () => Promise.resolve(fail('no provider')) })
const store = new ModelsSettingsStore(face)
const store = new ModelsSettingsStore(face, settingsSchema)
await store.load()
const state = store.store.getSnapshot()
expect(state.status).toBe('ready')
@@ -112,7 +113,7 @@ describe('ModelsSettingsStore', () => {
const { face } = api({
describeCredentials: () => Promise.reject(new Error('credential transport down')),
})
const store = new ModelsSettingsStore(face)
const store = new ModelsSettingsStore(face, settingsSchema)
await expect(store.load()).resolves.toBeUndefined()
expect(store.store.getSnapshot()).toMatchObject({
status: 'ready',
@@ -125,18 +126,18 @@ describe('ModelsSettingsStore', () => {
// oxlint-disable-next-line typescript/prefer-promise-reject-errors -- the non-Error rejection is the scenario
describeCredentials: () => Promise.reject('credential transport refusal'),
})
const store = new ModelsSettingsStore(face)
const store = new ModelsSettingsStore(face, settingsSchema)
await expect(store.load()).resolves.toBeUndefined()
expect(store.store.getSnapshot().credentialError).toBe('credential transport refusal')
})
it('surfaces a directory failure and keeps the last good rows', async () => {
const { face } = api()
const store = new ModelsSettingsStore(face)
const store = new ModelsSettingsStore(face, settingsSchema)
await store.load()
expect(store.store.getSnapshot().rows).toHaveLength(4)
const broken = api({ providers: () => Promise.resolve(fail('directory down')) })
const failing = new ModelsSettingsStore(broken.face)
const failing = new ModelsSettingsStore(broken.face, settingsSchema)
await failing.load()
expect(failing.store.getSnapshot()).toMatchObject({ status: 'error', error: 'directory down' })
// The first store's snapshot is untouched by the second's failure.
@@ -157,7 +158,7 @@ describe('ModelsSettingsStore', () => {
return ok({ providers: DIRECTORY })
},
})
const store = new ModelsSettingsStore(face)
const store = new ModelsSettingsStore(face, settingsSchema)
const first = store.load()
const second = store.load()
release?.()
@@ -187,7 +188,7 @@ describe('edge joins', () => {
] as never,
})),
})
const store = new ModelsSettingsStore(face)
const store = new ModelsSettingsStore(face, settingsSchema)
await store.load()
const state = store.store.getSnapshot()
expect(state.rows[0]).toMatchObject({ configured: true, removable: false })
@@ -207,7 +208,7 @@ describe('edge joins', () => {
] as never,
})),
})
const store = new ModelsSettingsStore(face)
const store = new ModelsSettingsStore(face, settingsSchema)
await store.load()
expect(seenRefs).toEqual([])
expect(store.store.getSnapshot().status).toBe('ready')
@@ -215,7 +216,7 @@ describe('edge joins', () => {
it('surfaces a settings describe failure', async () => {
const { face } = api({ describeSettings: () => Promise.resolve(fail('settings down')) })
const store = new ModelsSettingsStore(face)
const store = new ModelsSettingsStore(face, settingsSchema)
await store.load()
expect(store.store.getSnapshot()).toMatchObject({ status: 'error', error: 'settings down' })
})
@@ -224,7 +225,7 @@ describe('edge joins', () => {
// The wire can surface non-Error throwables; the store must stringify them.
// oxlint-disable-next-line typescript/prefer-promise-reject-errors -- the non-Error rejection is the scenario
const { face } = api({ providers: () => Promise.reject('plain refusal') })
const store = new ModelsSettingsStore(face)
const store = new ModelsSettingsStore(face, settingsSchema)
await store.load()
expect(store.store.getSnapshot()).toMatchObject({ status: 'error', error: 'plain refusal' })
})
@@ -243,7 +244,7 @@ describe('edge joins', () => {
return ok({ providers: DIRECTORY })
},
})
const store = new ModelsSettingsStore(face)
const store = new ModelsSettingsStore(face, settingsSchema)
const first = store.load()
const second = store.load()
await second
@@ -17,9 +17,6 @@
{
"path": "../runtime"
},
{
"path": "../schema-form"
},
{
"path": "../ui-primitives"
},
@@ -6,7 +6,7 @@ import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots'
import { SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client'
import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
import { TestRemote, usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime'
import { SettingsScopeBinder } from '@deepseek-ai/dsh-client-ui-settings/client'
import { SettingsSchemaService, SettingsScopeBinder } from '@deepseek-ai/dsh-client-ui-settings/client'
import { apply, inject } from '@deepseek-ai/dsh-client-ui-settings-plugins/client'
import type {
ConfigurablePluginsTabFace, PluginsSettingsSectionInjected,
@@ -52,7 +52,7 @@ async function bench(served?: string[]) {
credentials: { describe: describeCredentials },
},
} as never)
await ctx.plugin(SettingsScopeBinder).await()
await ctx.plugin(SettingsScopeBinder, new SettingsSchemaService(ctx)).await()
return { ctx, slots: ctx.get('slots') as SlotRegistry, describeCredentials, describeSettings }
}
+6 -6
View File
@@ -44,28 +44,28 @@
"watch": "tsdown --watch"
},
"license": "MIT",
"dependencies": {
"@deepseek-ai/schemastery": "workspace:^"
},
"peerDependencies": {
"@deepseek-ai/cordis": "workspace:^",
"@deepseek-ai/dsh-api-remotes": "workspace:^",
"@deepseek-ai/dsh-client-connection": "workspace:^",
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-schema-form": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-settings": "workspace:^",
"react": "^18.2.0"
"@deepseek-ai/dsh-settings": "workspace:^"
},
"devDependencies": {
"@deepseek-ai/cordis": "workspace:^",
"@deepseek-ai/dsh-api-remotes": "workspace:^",
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-schema-form": "workspace:^",
"@deepseek-ai/dsh-client-test-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-settings": "workspace:^",
"@types/react": "~18.3.1",
"react": "^18.2.0"
"react": "^18.2.0",
"@deepseek-ai/dsh-client-connection": "workspace:^"
},
"files": [
"lib/index.js",
@@ -9,6 +9,7 @@
* through ui-layout and ui-theme. Export discipline: packages/client/AGENTS.md.
*/
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
import { SettingsSchemaService } from './schema.ts'
import { SettingsScopeBinder } from './settings-scope.ts'
export type {
@@ -16,6 +17,8 @@ export type {
SettingsPluginsTabOwnerProps, SettingsSectionOwnerProps, SettingsTriggerOwnerProps,
} from './contract/slots.ts'
export { SettingsScopeController, SettingsScopeBinder } from './settings-scope.ts'
export { SettingsSchemaService } from './schema.ts'
export type { SchemaNode } from './schema.ts'
/**
* Required services: none. The transport is resolved per caller through
@@ -31,5 +34,6 @@ export const inject = []
* @param ctx - client root context.
*/
export function apply(ctx: ClientContext): void {
new SettingsScopeBinder(ctx)
const schema = new SettingsSchemaService(ctx)
new SettingsScopeBinder(ctx, schema)
}
@@ -0,0 +1,121 @@
/** Synchronous schema introspection and immutable settings-draft edits. */
import { Service } from '@deepseek-ai/cordis'
import type { Context } from '@deepseek-ai/cordis'
import Schema from '@deepseek-ai/schemastery'
/** Live schemastery node used for settings introspection and validation. */
export type SchemaNode = Schema
function cloneContainer(container: unknown, key: string): Record<string, unknown> | unknown[] {
if (Array.isArray(container)) return [...container as unknown[]]
if (typeof container === 'object' && container !== null) return { ...container as Record<string, unknown> }
return /^\d+$/.test(key) ? [] : {}
}
function cloneSpine(root: Record<string, unknown>, path: readonly string[]): {
result: Record<string, unknown>
parent: Record<string, unknown> | unknown[]
leaf: string
} {
const result = { ...root }
let target: Record<string, unknown> | unknown[] = result
for (let index = 0; index < path.length - 1; index++) {
const key = path[index] as string
const child = cloneContainer(
Array.isArray(target) ? target[Number(key)] : target[key],
path[index + 1] as string,
)
if (Array.isArray(target)) target[Number(key)] = child
else target[key] = child
target = child
}
return { result, parent: target, leaf: path[path.length - 1] as string }
}
/**
* Settings-owned synchronous schema service. Dynamic client plugins receive
* this Cordis entity instead of importing executable helpers from one another.
*/
export class SettingsSchemaService extends Service {
/** @param ctx - providing ui-settings context. */
constructor(ctx: Context) {
super(ctx, 'settingsSchema')
}
/** Rehydrate one serialized `schema.toJSON()` envelope. */
rehydrate(serialized: unknown): SchemaNode {
return new Schema(serialized as Schema)
}
/** Return a validation failure message, or `undefined` for a valid draft. */
validate(schema: SchemaNode, draft: unknown): string | undefined {
try {
;(schema as unknown as (value: unknown) => unknown)(draft)
return undefined
} catch (error) {
return error instanceof Error ? error.message : String(error)
}
}
/** Resolve an object, dict, or array schema node at a settings path. */
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 a string-key or array-index path. */
getPath(value: unknown, path: readonly string[]): unknown {
let current: unknown = value
for (const key of path) {
if (Array.isArray(current)) {
current = current[Number(key)]
continue
}
if (typeof current !== 'object' || current === null) return undefined
current = (current as Record<string, unknown>)[key]
}
return current
}
/** Report whether the final path key exists independently of its value. */
hasPath(value: unknown, path: readonly string[]): boolean {
if (path.length === 0) return value !== undefined
const parent = this.getPath(value, path.slice(0, -1))
const key = path[path.length - 1] as string
if (Array.isArray(parent)) return Number(key) < parent.length
if (typeof parent !== 'object' || parent === null) return false
return key in parent
}
/** Immutably set a nested value, materializing missing containers. */
setPath(root: Record<string, unknown>, path: readonly string[], value: unknown): Record<string, unknown> {
if (path.length === 0) throw new Error('ui-settings: setPath needs a non-empty path')
const { result, parent, leaf } = cloneSpine(root, path)
if (Array.isArray(parent)) parent[Number(leaf)] = value
else parent[leaf] = value
return result
}
/** Immutably remove a nested key, preserving an unchanged missing root. */
deletePath(root: Record<string, unknown>, path: readonly string[]): Record<string, unknown> {
if (path.length === 0) throw new Error('ui-settings: deletePath needs a non-empty path')
if (!this.hasPath(root, path)) return root
const { result, parent, leaf } = cloneSpine(root, path)
if (Array.isArray(parent)) parent.splice(Number(leaf), 1)
else Reflect.deleteProperty(parent, leaf)
return result
}
}
declare module '@deepseek-ai/cordis' {
interface Context {
/** Settings-owned synchronous schema and immutable path operations. */
settingsSchema: SettingsSchemaService
}
}
@@ -10,7 +10,6 @@ import type { Context } from '@deepseek-ai/cordis'
import type {
ConnectionHandle, IApiClient, SettingsNamespaceView, SettingsPathOpView,
} from '@deepseek-ai/dsh-api-remotes/client'
import { rehydrateSchema, validateDraft } from '@deepseek-ai/dsh-client-schema-form'
import {
createSnapshotStore, type SettingsScope, type SettingsScopeSnapshot,
type SettingsScopeSpec, type SnapshotStore,
@@ -31,6 +30,7 @@ import type {} from '@deepseek-ai/dsh-api-remotes/types'
// never — the owning package's client-safe, type-only subpath supplies the
// cordis `Events` entry (and with it the branded `SettingsNamespace`).
import type {} from '@deepseek-ai/dsh-settings/types'
import type { SettingsSchemaService } from './schema.ts'
type SettingsFace = Pick<IApiClient, 'settings'>
/**
@@ -55,6 +55,7 @@ export class SettingsScopeController<T> implements SettingsScope<T> {
private readonly api: SettingsFace,
private readonly spec: SettingsScopeSpec<T>,
private readonly persistence: 'host' | 'memory' = 'host',
private readonly schema?: SettingsSchemaService,
) {
this.store = createSnapshotStore<SettingsScopeSnapshot<T>>({
status: persistence === 'host' ? 'loading' : 'unavailable',
@@ -201,7 +202,8 @@ export class SettingsScopeController<T> implements SettingsScope<T> {
if (typeof view.value !== 'object' || view.value === null || Array.isArray(view.value)) return undefined
let failure: string | undefined
try {
failure = validateDraft(rehydrateSchema(view.schema), view.value)
if (this.schema === undefined) throw new Error('ui-settings: schema service unavailable')
failure = this.schema.validate(this.schema.rehydrate(view.schema), view.value)
} catch (_malformedSchemaEnvelope) {
// A schema envelope this client cannot rehydrate vouches for no section;
// the value is treated exactly like a schema-invalid one.
@@ -228,7 +230,7 @@ export class SettingsScopeBinder extends Service {
/**
* @param ctx - the providing plugin's context.
*/
constructor(ctx: Context) {
constructor(ctx: Context, private readonly schema: SettingsSchemaService) {
super(ctx, 'settingsScope')
}
@@ -249,6 +251,7 @@ export class SettingsScopeBinder extends Service {
connection.api,
spec,
connection.isLoopback ? 'host' : 'memory',
this.schema,
)
ctx.effect(() => {
const refresh = (namespace?: string): void => {
@@ -4,6 +4,7 @@ import { describe, expect, it, vi } from 'vitest'
import type { RpcResponse, SettingsNamespaceView } from '@deepseek-ai/dsh-api-remotes/client'
import { TestRemote } from '@deepseek-ai/dsh-client-test-runtime'
import type { SettingsScope } from '@deepseek-ai/dsh-client-runtime/client'
import { SettingsSchemaService } from '../src/client/schema.ts'
import { SettingsScopeController, SettingsScopeBinder } from '../src/client/settings-scope.ts'
interface UiTestSettings {
@@ -379,7 +380,7 @@ describe('SettingsScopeBinder.bind', () => {
} as never)
let scope!: SettingsScope<UiTestSettings>
new TestRemote(ctx)
await ctx.plugin(SettingsScopeBinder).await()
await ctx.plugin(SettingsScopeBinder, new SettingsSchemaService(ctx)).await()
const fiber = ctx.plugin({
inject: ['connection', 'remote', 'settingsScope'],
apply: (plugin: Context) => {
@@ -411,7 +412,7 @@ describe('SettingsScopeBinder.bind', () => {
} as never)
let scope!: SettingsScope<UiTestSettings>
new TestRemote(ctx)
await ctx.plugin(SettingsScopeBinder).await()
await ctx.plugin(SettingsScopeBinder, new SettingsSchemaService(ctx)).await()
const fiber = ctx.plugin({
inject: ['connection', 'remote', 'settingsScope'],
apply: (plugin: Context) => {
+1 -1
View File
@@ -18,7 +18,7 @@
"path": "../runtime"
},
{
"path": "../schema-form"
"path": "../../../vendor/schemastery"
},
{
"path": "../../api/remotes/tsconfig.client.json"
@@ -6,7 +6,7 @@ import { describe, expect, it, vi } from 'vitest'
import { SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client'
import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
import { TestRemote, usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime'
import { SettingsScopeBinder } from '@deepseek-ai/dsh-client-ui-settings/client'
import { SettingsSchemaService, SettingsScopeBinder } from '@deepseek-ai/dsh-client-ui-settings/client'
import { apply, inject, SETTINGS_NS } from '@deepseek-ai/dsh-client-ui-theme/client'
import type { AppearanceRowInjected, ThemeRuntime } from '@deepseek-ai/dsh-client-ui-theme/client'
import { THEME_SETTINGS_NAMESPACE, ThemeSettingsSchema } from '../src/theme-settings.ts'
@@ -56,7 +56,7 @@ async function bench(isLoopback = true) {
ctx.provide('connection', { api: { settings: { describe, mutate } }, isLoopback } as never)
// The settings transport and the forwarded-event port the plugin injects.
new TestRemote(ctx)
await ctx.plugin(SettingsScopeBinder).await()
await ctx.plugin(SettingsScopeBinder, new SettingsSchemaService(ctx)).await()
return {
ctx, slots: ctx.get('slots') as SlotRegistry, locale, describe, mutate,
setHostPreference: (next: string) => { preference = next },