Ver Fonte

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

imccyu há 1 mês atrás
pai
commit
56dff07c4e
38 ficheiros alterados com 312 adições e 568 exclusões
  1. 2 2
      packages/client/locale/tests/apply.client.spec.ts
  2. 0 6
      packages/client/schema-form/README.i18n.yaml
  3. 0 23
      packages/client/schema-form/README.md
  4. 0 23
      packages/client/schema-form/README.zh.md
  5. 0 45
      packages/client/schema-form/package.json
  6. 0 12
      packages/client/schema-form/src/index.ts
  7. 0 32
      packages/client/schema-form/src/invariant.ts
  8. 0 151
      packages/client/schema-form/src/model.ts
  9. 0 12
      packages/client/schema-form/tests/invariant.client.spec.ts
  10. 0 100
      packages/client/schema-form/tests/model.client.spec.ts
  11. 0 18
      packages/client/schema-form/tsconfig.json
  12. 0 6
      packages/client/schema-form/tsdown.config.ts
  13. 1 6
      packages/client/ui-permission-presets/package.json
  14. 2 2
      packages/client/ui-permission-presets/src/client/index.ts
  15. 8 7
      packages/client/ui-permission-presets/src/client/settings-store.ts
  16. 13 5
      packages/client/ui-permission-presets/tests/permission-presets-row.client.spec.tsx
  17. 30 18
      packages/client/ui-permission-presets/tests/settings-store.client.spec.ts
  18. 0 3
      packages/client/ui-permission-presets/tsconfig.json
  19. 2 6
      packages/client/ui-settings-models/package.json
  20. 1 0
      packages/client/ui-settings-models/src/client/DeepSeekOnboardingDialog.tsx
  21. 5 2
      packages/client/ui-settings-models/src/client/ModelsSection.tsx
  22. 43 32
      packages/client/ui-settings-models/src/client/ProviderEditor.tsx
  23. 2 2
      packages/client/ui-settings-models/src/client/index.ts
  24. 20 10
      packages/client/ui-settings-models/src/client/store.ts
  25. 11 7
      packages/client/ui-settings-models/tests/components.client.spec.tsx
  26. 2 1
      packages/client/ui-settings-models/tests/onboarding-dialog.client.spec.tsx
  27. 6 5
      packages/client/ui-settings-models/tests/provider-form.client.spec.tsx
  28. 5 0
      packages/client/ui-settings-models/tests/settings-schema.client.ts
  29. 13 12
      packages/client/ui-settings-models/tests/store.client.spec.ts
  30. 0 3
      packages/client/ui-settings-models/tsconfig.json
  31. 2 2
      packages/client/ui-settings-plugins/tests/apply.client.spec.ts
  32. 6 6
      packages/client/ui-settings/package.json
  33. 5 1
      packages/client/ui-settings/src/client/index.ts
  34. 121 0
      packages/client/ui-settings/src/client/schema.ts
  35. 6 3
      packages/client/ui-settings/src/client/settings-scope.ts
  36. 3 2
      packages/client/ui-settings/tests/settings-scope.client.spec.ts
  37. 1 1
      packages/client/ui-settings/tsconfig.json
  38. 2 2
      packages/client/ui-theme/tests/apply.client.spec.ts

+ 2 - 2
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 },

+ 0 - 6
packages/client/schema-form/README.i18n.yaml

@@ -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

+ 0 - 23
packages/client/schema-form/README.md

@@ -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.

+ 0 - 23
packages/client/schema-form/README.zh.md

@@ -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) 记录该权衡。

+ 0 - 45
packages/client/schema-form/package.json

@@ -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"
-  ]
-}

+ 0 - 12
packages/client/schema-form/src/index.ts

@@ -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'

+ 0 - 32
packages/client/schema-form/src/invariant.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 */

+ 0 - 151
packages/client/schema-form/src/model.ts

@@ -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
-}

+ 0 - 12
packages/client/schema-form/tests/invariant.client.spec.ts

@@ -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()
-  })
-})

+ 0 - 100
packages/client/schema-form/tests/model.client.spec.ts

@@ -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()
-  })
-})

+ 0 - 18
packages/client/schema-form/tsconfig.json

@@ -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"
-    }
-  ]
-}

+ 0 - 6
packages/client/schema-form/tsdown.config.ts

@@ -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'],
-)

+ 1 - 6
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:^",

+ 2 - 2
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<void> => controller.load()
   const select = (preset: string): Promise<void> => controller.select(preset)
   const injected = (): PermissionRowInjected => ({

+ 8 - 7
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<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'

+ 13 - 5
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<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({

+ 30 - 18
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<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,

+ 0 - 3
packages/client/ui-permission-presets/tsconfig.json

@@ -17,9 +17,6 @@
     {
       "path": "../runtime"
     },
-    {
-      "path": "../schema-form"
-    },
     {
       "path": "../ui-commands"
     },

+ 2 - 6
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:^",

+ 1 - 0
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}

+ 5 - 2
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 (
     <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}

+ 43 - 32
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<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}

+ 2 - 2
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.

+ 20 - 10
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<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,
       }
     })

+ 11 - 7
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<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)}

+ 2 - 1
packages/client/ui-settings-models/tests/onboarding-dialog.client.spec.tsx

@@ -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

+ 6 - 5
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<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}

+ 5 - 0
packages/client/ui-settings-models/tests/settings-schema.client.ts

@@ -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())

+ 13 - 12
packages/client/ui-settings-models/tests/store.client.spec.ts

@@ -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

+ 0 - 3
packages/client/ui-settings-models/tsconfig.json

@@ -17,9 +17,6 @@
     {
       "path": "../runtime"
     },
-    {
-      "path": "../schema-form"
-    },
     {
       "path": "../ui-primitives"
     },

+ 2 - 2
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 }
 }
 

+ 6 - 6
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",

+ 5 - 1
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)
 }

+ 121 - 0
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<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
+  }
+}

+ 6 - 3
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<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 => {

+ 3 - 2
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<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
packages/client/ui-settings/tsconfig.json

@@ -18,7 +18,7 @@
       "path": "../runtime"
     },
     {
-      "path": "../schema-form"
+      "path": "../../../vendor/schemastery"
     },
     {
       "path": "../../api/remotes/tsconfig.client.json"

+ 2 - 2
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 },