From 56dff07c4e0bc769eba9e02954c9958459f20332 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sun, 16 Aug 2026 23:47:32 +0800 Subject: [PATCH] refactor(client): move schema handling into ui-settings --- .../client/locale/tests/apply.client.spec.ts | 4 +- packages/client/schema-form/README.i18n.yaml | 6 - packages/client/schema-form/README.md | 23 --- packages/client/schema-form/README.zh.md | 23 --- packages/client/schema-form/package.json | 45 ------ packages/client/schema-form/src/index.ts | 12 -- packages/client/schema-form/src/invariant.ts | 32 ---- packages/client/schema-form/src/model.ts | 151 ------------------ .../tests/invariant.client.spec.ts | 12 -- .../schema-form/tests/model.client.spec.ts | 100 ------------ packages/client/schema-form/tsconfig.json | 18 --- packages/client/schema-form/tsdown.config.ts | 6 - .../client/ui-permission-presets/package.json | 7 +- .../ui-permission-presets/src/client/index.ts | 4 +- .../src/client/settings-store.ts | 15 +- .../permission-presets-row.client.spec.tsx | 18 ++- .../tests/settings-store.client.spec.ts | 48 +++--- .../ui-permission-presets/tsconfig.json | 3 - .../client/ui-settings-models/package.json | 8 +- .../src/client/DeepSeekOnboardingDialog.tsx | 1 + .../src/client/ModelsSection.tsx | 7 +- .../src/client/ProviderEditor.tsx | 75 +++++---- .../ui-settings-models/src/client/index.ts | 4 +- .../ui-settings-models/src/client/store.ts | 30 ++-- .../tests/components.client.spec.tsx | 18 ++- .../tests/onboarding-dialog.client.spec.tsx | 3 +- .../tests/provider-form.client.spec.tsx | 11 +- .../tests/settings-schema.client.ts | 5 + .../tests/store.client.spec.ts | 25 +-- .../client/ui-settings-models/tsconfig.json | 3 - .../tests/apply.client.spec.ts | 4 +- packages/client/ui-settings/package.json | 12 +- .../client/ui-settings/src/client/index.ts | 6 +- .../client/ui-settings/src/client/schema.ts | 121 ++++++++++++++ .../ui-settings/src/client/settings-scope.ts | 9 +- .../tests/settings-scope.client.spec.ts | 5 +- packages/client/ui-settings/tsconfig.json | 2 +- .../ui-theme/tests/apply.client.spec.ts | 4 +- 38 files changed, 312 insertions(+), 568 deletions(-) delete mode 100644 packages/client/schema-form/README.i18n.yaml delete mode 100644 packages/client/schema-form/README.md delete mode 100644 packages/client/schema-form/README.zh.md delete mode 100644 packages/client/schema-form/package.json delete mode 100644 packages/client/schema-form/src/index.ts delete mode 100644 packages/client/schema-form/src/invariant.ts delete mode 100644 packages/client/schema-form/src/model.ts delete mode 100644 packages/client/schema-form/tests/invariant.client.spec.ts delete mode 100644 packages/client/schema-form/tests/model.client.spec.ts delete mode 100644 packages/client/schema-form/tsconfig.json delete mode 100644 packages/client/schema-form/tsdown.config.ts create mode 100644 packages/client/ui-settings-models/tests/settings-schema.client.ts create mode 100644 packages/client/ui-settings/src/client/schema.ts diff --git a/packages/client/locale/tests/apply.client.spec.ts b/packages/client/locale/tests/apply.client.spec.ts index dd38786073..c54644275c 100644 --- a/packages/client/locale/tests/apply.client.spec.ts +++ b/packages/client/locale/tests/apply.client.spec.ts @@ -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 }, diff --git a/packages/client/schema-form/README.i18n.yaml b/packages/client/schema-form/README.i18n.yaml deleted file mode 100644 index e0d2db8a38..0000000000 --- a/packages/client/schema-form/README.i18n.yaml +++ /dev/null @@ -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 diff --git a/packages/client/schema-form/README.md b/packages/client/schema-form/README.md deleted file mode 100644 index ef1d2f9d8c..0000000000 --- a/packages/client/schema-form/README.md +++ /dev/null @@ -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. diff --git a/packages/client/schema-form/README.zh.md b/packages/client/schema-form/README.zh.md deleted file mode 100644 index 315e508ab1..0000000000 --- a/packages/client/schema-form/README.zh.md +++ /dev/null @@ -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) 记录该权衡。 diff --git a/packages/client/schema-form/package.json b/packages/client/schema-form/package.json deleted file mode 100644 index 4951dee5c5..0000000000 --- a/packages/client/schema-form/package.json +++ /dev/null @@ -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" - ] -} diff --git a/packages/client/schema-form/src/index.ts b/packages/client/schema-form/src/index.ts deleted file mode 100644 index 3a8c35edcb..0000000000 --- a/packages/client/schema-form/src/index.ts +++ /dev/null @@ -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' diff --git a/packages/client/schema-form/src/invariant.ts b/packages/client/schema-form/src/invariant.ts deleted file mode 100644 index 90636e5d67..0000000000 --- a/packages/client/schema-form/src/invariant.ts +++ /dev/null @@ -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 */ diff --git a/packages/client/schema-form/src/model.ts b/packages/client/schema-form/src/model.ts deleted file mode 100644 index 5cfb624eb4..0000000000 --- a/packages/client/schema-form/src/model.ts +++ /dev/null @@ -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 | 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)[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 | unknown[] { - if (Array.isArray(container)) return [...container as unknown[]] - if (typeof container === 'object' && container !== null) return { ...container as Record } - // 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, path: readonly string[]): { - result: Record - parent: Record | unknown[] - leaf: string -} { - const result = { ...root } - let target: Record | 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, path: readonly string[], value: unknown): Record { - 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, path: readonly string[]): Record { - 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 -} diff --git a/packages/client/schema-form/tests/invariant.client.spec.ts b/packages/client/schema-form/tests/invariant.client.spec.ts deleted file mode 100644 index 6e63f8f995..0000000000 --- a/packages/client/schema-form/tests/invariant.client.spec.ts +++ /dev/null @@ -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() - }) -}) diff --git a/packages/client/schema-form/tests/model.client.spec.ts b/packages/client/schema-form/tests/model.client.spec.ts deleted file mode 100644 index 1a95e88903..0000000000 --- a/packages/client/schema-form/tests/model.client.spec.ts +++ /dev/null @@ -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[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() - }) -}) diff --git a/packages/client/schema-form/tsconfig.json b/packages/client/schema-form/tsconfig.json deleted file mode 100644 index 34abf11c47..0000000000 --- a/packages/client/schema-form/tsconfig.json +++ /dev/null @@ -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" - } - ] -} diff --git a/packages/client/schema-form/tsdown.config.ts b/packages/client/schema-form/tsdown.config.ts deleted file mode 100644 index b03542c74e..0000000000 --- a/packages/client/schema-form/tsdown.config.ts +++ /dev/null @@ -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'], -) diff --git a/packages/client/ui-permission-presets/package.json b/packages/client/ui-permission-presets/package.json index 3da9b90cfb..09fd84275b 100644 --- a/packages/client/ui-permission-presets/package.json +++ b/packages/client/ui-permission-presets/package.json @@ -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:^", diff --git a/packages/client/ui-permission-presets/src/client/index.ts b/packages/client/ui-permission-presets/src/client/index.ts index aec82bf9d9..68606219bc 100644 --- a/packages/client/ui-permission-presets/src/client/index.ts +++ b/packages/client/ui-permission-presets/src/client/index.ts @@ -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 => controller.load() const select = (preset: string): Promise => controller.select(preset) const injected = (): PermissionRowInjected => ({ diff --git a/packages/client/ui-permission-presets/src/client/settings-store.ts b/packages/client/ui-permission-presets/src/client/settings-store.ts index 6e7199f1be..f69f6a6ec6 100644 --- a/packages/client/ui-permission-presets/src/client/settings-store.ts +++ b/packages/client/ui-permission-presets/src/client/settings-store.ts @@ -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) {} + constructor( + private readonly api: Pick, + 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' diff --git a/packages/client/ui-permission-presets/tests/permission-presets-row.client.spec.tsx b/packages/client/ui-permission-presets/tests/permission-presets-row.client.spec.tsx index 9df3920bd5..f7a1ad0737 100644 --- a/packages/client/ui-permission-presets/tests/permission-presets-row.client.spec.tsx +++ b/packages/client/ui-permission-presets/tests/permission-presets-row.client.spec.tsx @@ -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[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({ diff --git a/packages/client/ui-permission-presets/tests/settings-store.client.spec.ts b/packages/client/ui-permission-presets/tests/settings-store.client.spec.ts index e4e218fe86..b04c954d83 100644 --- a/packages/client/ui-permission-presets/tests/settings-store.client.spec.ts +++ b/packages/client/ui-permission-presets/tests/settings-store.client.spec.ts @@ -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[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(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>>() - const disposedWrite = new PermissionPresetSettingsController({ + const disposedWrite = createController({ settings: { describe: () => Promise.resolve(ok({ writable: true, hasDocument: false, namespaces: [view('read-only')] })), mutate: () => rejectedMutation.promise, diff --git a/packages/client/ui-permission-presets/tsconfig.json b/packages/client/ui-permission-presets/tsconfig.json index e614c95723..5c72a81455 100644 --- a/packages/client/ui-permission-presets/tsconfig.json +++ b/packages/client/ui-permission-presets/tsconfig.json @@ -17,9 +17,6 @@ { "path": "../runtime" }, - { - "path": "../schema-form" - }, { "path": "../ui-commands" }, diff --git a/packages/client/ui-settings-models/package.json b/packages/client/ui-settings-models/package.json index dd312defcf..82b04435ab 100644 --- a/packages/client/ui-settings-models/package.json +++ b/packages/client/ui-settings-models/package.json @@ -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:^", diff --git a/packages/client/ui-settings-models/src/client/DeepSeekOnboardingDialog.tsx b/packages/client/ui-settings-models/src/client/DeepSeekOnboardingDialog.tsx index 24e2112096..4ee5c5688e 100644 --- a/packages/client/ui-settings-models/src/client/DeepSeekOnboardingDialog.tsx +++ b/packages/client/ui-settings-models/src/client/DeepSeekOnboardingDialog.tsx @@ -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} diff --git a/packages/client/ui-settings-models/src/client/ModelsSection.tsx b/packages/client/ui-settings-models/src/client/ModelsSection.tsx index 5fe5647b88..c5e72c8b44 100644 --- a/packages/client/ui-settings-models/src/client/ModelsSection.tsx +++ b/packages/client/ui-settings-models/src/client/ModelsSection.tsx @@ -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 (
@@ -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} diff --git a/packages/client/ui-settings-models/src/client/ProviderEditor.tsx b/packages/client/ui-settings-models/src/client/ProviderEditor.tsx index 63d25b2eb6..76e6bc0ded 100644 --- a/packages/client/ui-settings-models/src/client/ProviderEditor.tsx +++ b/packages/client/ui-settings-models/src/client/ProviderEditor.tsx @@ -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 { - const subtree = getPath(namespace.user, path) +function draftAt( + schema: SettingsSchemaService, + namespace: SettingsNamespaceView, + path: readonly string[], +): Record { + const subtree = schema.getPath(namespace.user, path) if (typeof subtree !== 'object' || subtree === null || Array.isArray(subtree)) return {} return structuredClone(subtree) as Record } @@ -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>(() => draftAt(namespace, settingsPath)) + const { namespace, schema, settingsPath, api, t } = props + const [draft, setDraft] = useState>(() => draftAt(schema, namespace, settingsPath)) const [keyDraft, setKeyDraft] = useState('') const [keyState, setKeyState] = useState(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( - () => 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[]) => { - 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} diff --git a/packages/client/ui-settings-models/src/client/index.ts b/packages/client/ui-settings-models/src/client/index.ts index dc7f32e370..d4bb0dfb03 100644 --- a/packages/client/ui-settings-models/src/client/index.ts +++ b/packages/client/ui-settings-models/src/client/index.ts @@ -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. diff --git a/packages/client/ui-settings-models/src/client/store.ts b/packages/client/ui-settings-models/src/client/store.ts index 4389b9a6cb..e970602815 100644 --- a/packages/client/ui-settings-models/src/client/store.ts +++ b/packages/client/ui-settings-models/src/client/store.ts @@ -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) {} + constructor( + private readonly api: Pick, + 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, } }) diff --git a/packages/client/ui-settings-models/tests/components.client.spec.tsx b/packages/client/ui-settings-models/tests/components.client.spec.tsx index 66ba8d33a4..4a90b5f353 100644 --- a/packages/client/ui-settings-models/tests/components.client.spec.tsx +++ b/packages/client/ui-settings-models/tests/components.client.spec.tsx @@ -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[0] async function mountFace(scripted: ReturnType) { 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( { 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( { 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( { 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( { 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( { 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( { 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 diff --git a/packages/client/ui-settings-models/tests/provider-form.client.spec.tsx b/packages/client/ui-settings-models/tests/provider-form.client.spec.tsx index 246c7d64b1..6681b9e4d7 100644 --- a/packages/client/ui-settings-models/tests/provider-form.client.spec.tsx +++ b/packages/client/ui-settings-models/tests/provider-form.client.spec.tsx @@ -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): MutateCall { async function mountSection(options: Parameters[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((value: T): RpcResponse { @@ -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 diff --git a/packages/client/ui-settings-models/tsconfig.json b/packages/client/ui-settings-models/tsconfig.json index a85bfbcc90..2dc6cc2a32 100644 --- a/packages/client/ui-settings-models/tsconfig.json +++ b/packages/client/ui-settings-models/tsconfig.json @@ -17,9 +17,6 @@ { "path": "../runtime" }, - { - "path": "../schema-form" - }, { "path": "../ui-primitives" }, diff --git a/packages/client/ui-settings-plugins/tests/apply.client.spec.ts b/packages/client/ui-settings-plugins/tests/apply.client.spec.ts index 2934097b94..56bcdb0d98 100644 --- a/packages/client/ui-settings-plugins/tests/apply.client.spec.ts +++ b/packages/client/ui-settings-plugins/tests/apply.client.spec.ts @@ -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 } } diff --git a/packages/client/ui-settings/package.json b/packages/client/ui-settings/package.json index d86f5f86f2..1d3669d008 100644 --- a/packages/client/ui-settings/package.json +++ b/packages/client/ui-settings/package.json @@ -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", diff --git a/packages/client/ui-settings/src/client/index.ts b/packages/client/ui-settings/src/client/index.ts index 2ace9e56b1..6e8310242e 100644 --- a/packages/client/ui-settings/src/client/index.ts +++ b/packages/client/ui-settings/src/client/index.ts @@ -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) } diff --git a/packages/client/ui-settings/src/client/schema.ts b/packages/client/ui-settings/src/client/schema.ts new file mode 100644 index 0000000000..4d922bd4e7 --- /dev/null +++ b/packages/client/ui-settings/src/client/schema.ts @@ -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 | unknown[] { + if (Array.isArray(container)) return [...container as unknown[]] + if (typeof container === 'object' && container !== null) return { ...container as Record } + return /^\d+$/.test(key) ? [] : {} +} + +function cloneSpine(root: Record, path: readonly string[]): { + result: Record + parent: Record | unknown[] + leaf: string +} { + const result = { ...root } + let target: Record | 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 | 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)[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, path: readonly string[], value: unknown): Record { + 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, path: readonly string[]): Record { + 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 + } +} diff --git a/packages/client/ui-settings/src/client/settings-scope.ts b/packages/client/ui-settings/src/client/settings-scope.ts index 4668c4924b..c3b941663e 100644 --- a/packages/client/ui-settings/src/client/settings-scope.ts +++ b/packages/client/ui-settings/src/client/settings-scope.ts @@ -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 /** @@ -55,6 +55,7 @@ export class SettingsScopeController implements SettingsScope { private readonly api: SettingsFace, private readonly spec: SettingsScopeSpec, private readonly persistence: 'host' | 'memory' = 'host', + private readonly schema?: SettingsSchemaService, ) { this.store = createSnapshotStore>({ status: persistence === 'host' ? 'loading' : 'unavailable', @@ -201,7 +202,8 @@ export class SettingsScopeController implements SettingsScope { 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 => { diff --git a/packages/client/ui-settings/tests/settings-scope.client.spec.ts b/packages/client/ui-settings/tests/settings-scope.client.spec.ts index 429002028a..627034f217 100644 --- a/packages/client/ui-settings/tests/settings-scope.client.spec.ts +++ b/packages/client/ui-settings/tests/settings-scope.client.spec.ts @@ -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 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 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) => { diff --git a/packages/client/ui-settings/tsconfig.json b/packages/client/ui-settings/tsconfig.json index 5ef3ae74a0..fa84d80082 100644 --- a/packages/client/ui-settings/tsconfig.json +++ b/packages/client/ui-settings/tsconfig.json @@ -18,7 +18,7 @@ "path": "../runtime" }, { - "path": "../schema-form" + "path": "../../../vendor/schemastery" }, { "path": "../../api/remotes/tsconfig.client.json" diff --git a/packages/client/ui-theme/tests/apply.client.spec.ts b/packages/client/ui-theme/tests/apply.client.spec.ts index fb84c9860d..67a10937ce 100644 --- a/packages/client/ui-theme/tests/apply.client.spec.ts +++ b/packages/client/ui-theme/tests/apply.client.spec.ts @@ -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 },