Kaynağa Gözat

feat(web): mount the config plane in dsh web and pin the Models page keyless

apps/cli/cordis.yml gains settings-local, credentials-local, and the bare
dormant llm-pi-ai row (manifest deps added for the resolver contract);
llm-deepseek drops its !!js apiKey inline for per-request credential
resolution. Both adapters tag apiKeyEnv role('credential-ref') so the
form mounts the credential control. The web e2e scaffold isolates a
harness home per run — an in-process boot must never touch the
developer's real ~/.dsh — and the new models-settings scenario pins the
whole loop through the shipped app: dormant directory as add vocabulary,
schema-driven editor apply landing in settings.yaml, the route
registering live (topology frame), and a write-only key landing in the
temp .env with the configured badge converging. A hermetic test-owned
reference name keeps a developer's real provider keys from flipping the
badge. schema-form joins the platform module table (seed + externals)
so client bundles share one instance.
Yichen Jiang 2 ay önce
ebeveyn
işleme
0d96676f35

+ 22 - 3
apps/cli/cordis.yml

@@ -78,14 +78,33 @@
   config:
     agents: []
 
-# The native DeepSeek adapter; reads the key/base-url the boot's layered
-# .env loading (cwd then $DSH_HOME) left in the environment.
+# User-settings document (`$DSH_HOME/settings.yaml`, hot-reloaded): the web
+# settings page writes it through `settings.update`/`settings.replace`, and an
+# external edit converges every open surface through `host/settings-changed`.
+- id: settings
+  name: '@deepseek-ai/dsh-settings-local'
+
+# Credential store: the live process environment over `$DSH_HOME/.env`
+# (owner-only file, hot-reloaded). The web page's key inputs write it through
+# `credentials.set`; adapters resolve references per request.
+- id: credentials
+  name: '@deepseek-ai/dsh-credentials-local'
+
+# The native DeepSeek adapter; the API key resolves per request through the
+# credential store above (default reference DEEPSEEK_API_KEY), so no key is
+# inlined here and a missing one fails the request, not the boot.
 - id: llm-deepseek
   name: '@deepseek-ai/dsh-llm-deepseek'
   config:
-    apiKey: !!js process.env.DEEPSEEK_API_KEY
     baseURL: !!js process.env.DEEPSEEK_BASE_URL
 
+# The pi-ai multi-provider twin, mounted dormant: zero routes until the
+# `llm-pi-ai:` settings section supplies provider profiles — exactly what the
+# web Models page writes. Configured routes register live and drop when the
+# section empties.
+- id: llm-pi-ai
+  name: '@deepseek-ai/dsh-llm-pi-ai'
+
 # Transient-failure recovery around the loop's model calls (same policy as
 # the TUI's agent-spine composition; defaults: 2 retries, 500ms→10s backoff).
 - id: llm-retry

+ 3 - 0
apps/cli/package.json

@@ -47,6 +47,7 @@
     "@deepseek-ai/dsh-command-goal": "workspace:^",
     "@deepseek-ai/dsh-commands": "workspace:^",
     "@deepseek-ai/dsh-compact-basic": "workspace:^",
+    "@deepseek-ai/dsh-credentials-local": "workspace:^",
     "@deepseek-ai/dsh-frontend": "workspace:^",
     "@deepseek-ai/dsh-fs-local": "workspace:^",
     "@deepseek-ai/dsh-fs-policy": "workspace:^",
@@ -56,6 +57,7 @@
     "@deepseek-ai/dsh-host-webserver": "workspace:^",
     "@deepseek-ai/dsh-llm": "workspace:^",
     "@deepseek-ai/dsh-llm-deepseek": "workspace:^",
+    "@deepseek-ai/dsh-llm-pi-ai": "workspace:^",
     "@deepseek-ai/dsh-llm-retry": "workspace:^",
     "@deepseek-ai/dsh-paths": "workspace:^",
     "@deepseek-ai/dsh-plan-mode": "workspace:^",
@@ -65,6 +67,7 @@
     "@deepseek-ai/dsh-session-projection-cache": "workspace:^",
     "@deepseek-ai/dsh-session-title": "workspace:^",
     "@deepseek-ai/dsh-session-title-first-message-llm": "workspace:^",
+    "@deepseek-ai/dsh-settings-local": "workspace:^",
     "@deepseek-ai/dsh-skill": "workspace:^",
     "@deepseek-ai/dsh-skill-local": "workspace:^",
     "@deepseek-ai/dsh-spill-local": "workspace:^",

+ 116 - 0
apps/web/tests/models-settings.e2e.ts

@@ -0,0 +1,116 @@
+// Web e2e scenario: the Models settings page end to end through the real
+// wire — the dormant pi-ai directory renders as the add vocabulary, adding a
+// provider writes the settings document and registers the route live (the
+// row's 已启用 badge is the topology invalidation landing), and the key input
+// stores a credential write-only into the harness home's .env. Zero model
+// calls: configuration is pure settings/credentials/llm-domain traffic, so
+// there is no fixture and a stray stream would fail loud on the open seam.
+import { readFile } from 'node:fs/promises'
+import { fileURLToPath } from 'node:url'
+import { join } from 'node:path'
+import type { Browser, Page } from 'playwright'
+import { chromium } from 'playwright'
+import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
+import {
+  assertFixtureInventory, captureStableAria, compareOrRefreshGolden,
+  launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold,
+} from './scaffold.ts'
+import { saveFailureShot } from './support.ts'
+
+const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/models-settings', import.meta.url))
+const EMPTY_EXPECTED = join(SNAPSHOT_DIR, 'empty.expected.md')
+const CONFIGURED_EXPECTED = join(SNAPSHOT_DIR, 'configured.expected.md')
+const MODE = webSnapshotMode()
+
+describe('web e2e: Models settings page configures a dormant provider', () => {
+  let scaffold: WebScaffold
+  let browser: Browser
+  let page: Page
+  let tripwire: ReturnType<typeof watchConsole>
+
+  beforeAll(async () => {
+    scaffold = await launchWebScaffold({})
+    browser = await chromium.launch()
+    page = await browser.newPage({ viewport: { width: 1680, height: 1000 } })
+    tripwire = watchConsole(page)
+    await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
+    await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
+  }, 120_000)
+
+  afterAll(async () => {
+    await browser?.close()
+    await scaffold?.close()
+  })
+
+  it('renders the dormant directory as the add vocabulary', async () => {
+    onTestFailed(() => saveFailureShot(page, 'web-e2e-models-empty'))
+    await page.getByRole('button', { name: '设置', exact: true }).click()
+    const dialog = page.getByRole('dialog', { name: '设置' })
+    await dialog.waitFor({ timeout: 10_000 })
+    await dialog.getByRole('button', { name: '模型' }).click()
+    await dialog.getByText('填入各提供方的 API 密钥即可使用其模型。').waitFor({ timeout: 10_000 })
+    // The dormant pi-ai adapter contributes its whole installed catalog; no
+    // provider is configured yet, so the page is one add-select.
+    const add = dialog.getByLabel('添加提供方')
+    await add.waitFor({ timeout: 10_000 })
+    // The select renders before the directory join settles; poll until the
+    // dormant catalog landed.
+    await expect.poll(async () => add.locator('option').count(), { timeout: 10_000 }).toBeGreaterThan(30)
+    const options = await add.locator('option').allTextContents()
+    expect(options).toContain('anthropic')
+    expect(options).toContain('openai')
+    const snapshot = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd)
+    await compareOrRefreshGolden(EMPTY_EXPECTED, snapshot, MODE)
+  }, 60_000)
+
+  it('adds a provider through the schema-driven editor and the route registers live', async () => {
+    onTestFailed(() => saveFailureShot(page, 'web-e2e-models-add'))
+    const dialog = page.getByRole('dialog', { name: '设置' })
+    await dialog.getByLabel('添加提供方').selectOption('anthropic')
+    // The editor is the real pi-ai profile schema rendered field by field;
+    // the credential-reference control is the role-tagged override.
+    const ref = dialog.getByLabel('API 密钥环境变量')
+    await ref.waitFor({ timeout: 10_000 })
+    // A test-owned reference name keeps this hermetic: a developer's real
+    // ANTHROPIC_API_KEY in the process environment must not flip the badge.
+    await ref.fill('E2E_ANTHROPIC_KEY')
+    await dialog.getByRole('button', { name: '保存', exact: true }).click()
+    // The write lands in settings.yaml, the dormant route registers, the
+    // topology frame invalidates the page, and the reloaded join shows the
+    // row live with its credential still missing.
+    const row = dialog.getByText('anthropic', { exact: true }).first()
+    await row.waitFor({ timeout: 10_000 })
+    await dialog.getByText('已启用').waitFor({ timeout: 10_000 })
+    await dialog.getByText('缺少密钥').waitFor({ timeout: 10_000 })
+    const document = await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8')
+    expect(document).toContain('llm-pi-ai:')
+    expect(document).toContain('anthropic:')
+    expect(document).toContain('apiKeyEnv: E2E_ANTHROPIC_KEY')
+  }, 60_000)
+
+  it('stores the API key write-only and the badge flips configured', async () => {
+    onTestFailed(() => saveFailureShot(page, 'web-e2e-models-key'))
+    const dialog = page.getByRole('dialog', { name: '设置' })
+    await dialog.getByRole('button', { name: '编辑' }).click()
+    const key = dialog.getByLabel('API 密钥', { exact: true })
+    await key.waitFor({ timeout: 10_000 })
+    await key.fill('sk-ant-e2e-test')
+    await dialog.getByRole('button', { name: '保存密钥' }).click()
+    await dialog.getByText('已配置', { exact: true }).waitFor({ timeout: 10_000 })
+    // The value went to the harness home's .env — and nowhere in the DOM.
+    const stored = await readFile(join(scaffold.harnessHome, '.env'), 'utf8')
+    expect(stored).toContain('E2E_ANTHROPIC_KEY=sk-ant-e2e-test')
+    expect(await page.content()).not.toContain('sk-ant-e2e-test')
+    await dialog.getByRole('button', { name: '取消' }).click()
+    // The row badge converges from the credentials invalidation.
+    await expect.poll(async () => dialog.getByText('缺少密钥').count(), { timeout: 10_000 }).toBe(0)
+    const snapshot = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd)
+    await compareOrRefreshGolden(CONFIGURED_EXPECTED, snapshot, MODE)
+    await page.keyboard.press('Escape')
+    expect(tripwire.pageErrors).toEqual([])
+  }, 60_000)
+
+  it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => {
+    await assertFixtureInventory(SNAPSHOT_DIR, ['configured.expected.md', 'empty.expected.md'])
+  })
+})

+ 9 - 0
apps/web/tests/scaffold.ts

@@ -86,6 +86,8 @@ export interface WebScaffold {
   workspaceCwd: string
   /** Temp persistence root (seeded sessions land here through the real API). */
   persistenceRoot: string
+  /** Isolated harness home the settings/credentials rows write ($DSH_HOME double). */
+  harnessHome: string
   /** Await a settled turn end: in-process turn/end, then the agent's idle flip (which follows the persistence flush). */
   whenTurnSettled(timeoutMs?: number): Promise<SessionId>
   /** Tear everything down; asserts the replay fixture was fully consumed first (replay/refresh). */
@@ -150,6 +152,10 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
     }
   }
   const workspaceCwd = await realpath(await mkdtemp(join(tmpdir(), 'dsh-web-e2e-ws-')))
+  // Isolated harness home: the settings/credentials rows resolve $DSH_HOME
+  // paths at load, and an in-process boot must NEVER touch the developer's
+  // real ~/.dsh document or credential file.
+  const harnessHome = join(workspaceCwd, '.dsh-home')
   let persistenceRoot: string
   try {
     persistenceRoot = await mkdtemp(join(tmpdir(), 'dsh-web-e2e-sessions-'))
@@ -175,6 +181,8 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
     { id: 'workspace-context', disabled: true },
     { id: 'session-title-llm', disabled: true },
     { id: 'webserver', config: { host: '127.0.0.1', port: 0, distIndex: DIST_INDEX } },
+    { id: 'settings', config: { dshHome: harnessHome } },
+    { id: 'credentials', config: { dshHome: harnessHome } },
     ...options.toolsMode === undefined ? [] : [{ id: 'tools', config: { mode: options.toolsMode } }],
     ...options.cordisTools === true
       ? [{ insert: [{ id: 'tool-cordis', name: 'cordis:tool-cordis' }] }]
@@ -232,6 +240,7 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
   }
 
   return {
+    harnessHome,
     mode,
     baseUrl: `http://127.0.0.1:${port}`,
     ctx,

+ 1 - 1
apps/web/tests/settings-chrome.e2e.ts

@@ -58,7 +58,7 @@ describe('web e2e: settings modal, appearance gesture, language switch', () => {
     // Golden of the freshly opened dialog (default zh, General active).
     const snapshot = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd)
     await compareOrRefreshGolden(DIALOG_EXPECTED, snapshot, MODE)
-    // Section switch: aria-current moves; Models is deliberately empty.
+    // Section switch: aria-current moves (the Models page itself has its own scenario file).
     await dialog.getByRole('button', { name: '模型' }).click()
     await expect.poll(() => dialog.getByRole('button', { name: '模型' }).getAttribute('aria-current'), { timeout: 5_000 }).toBe('true')
     expect(await dialog.getByRole('button', { name: '通用设置' }).getAttribute('aria-current')).toBeNull()

+ 57 - 0
apps/web/tests/snapshots/models-settings/configured.expected.md

@@ -0,0 +1,57 @@
+- dialog "设置":
+  - navigation:
+    - text: 设置
+    - button "通用设置":
+      - img
+      - text: 通用设置
+    - button "模型":
+      - img
+      - text: 模型
+  - button "关闭":
+    - img
+    - text: 关闭
+  - heading "模型" [level=2]
+  - paragraph: 填入各提供方的 API 密钥即可使用其模型。
+  - list:
+    - listitem:
+      - text: anthropic 已启用
+      - button "编辑"
+      - button "删除"
+  - combobox "添加提供方":
+    - option "+ 添加提供方" [selected]
+    - option "amazon-bedrock"
+    - option "ant-ling"
+    - option "azure-openai-responses"
+    - option "cerebras"
+    - option "cloudflare-ai-gateway"
+    - option "cloudflare-workers-ai"
+    - option "deepseek"
+    - option "fireworks"
+    - option "github-copilot"
+    - option "google"
+    - option "google-vertex"
+    - option "groq"
+    - option "huggingface"
+    - option "kimi-coding"
+    - option "minimax"
+    - option "minimax-cn"
+    - option "mistral"
+    - option "moonshotai"
+    - option "moonshotai-cn"
+    - option "nvidia"
+    - option "openai"
+    - option "openai-codex"
+    - option "opencode"
+    - option "opencode-go"
+    - option "openrouter"
+    - option "qwen-token-plan"
+    - option "qwen-token-plan-cn"
+    - option "together"
+    - option "vercel-ai-gateway"
+    - option "xai"
+    - option "xiaomi"
+    - option "xiaomi-token-plan-ams"
+    - option "xiaomi-token-plan-cn"
+    - option "xiaomi-token-plan-sgp"
+    - option "zai"
+    - option "zai-coding-cn"

+ 54 - 0
apps/web/tests/snapshots/models-settings/empty.expected.md

@@ -0,0 +1,54 @@
+- dialog "设置":
+  - navigation:
+    - text: 设置
+    - button "通用设置":
+      - img
+      - text: 通用设置
+    - button "模型":
+      - img
+      - text: 模型
+  - button "关闭":
+    - img
+    - text: 关闭
+  - heading "模型" [level=2]
+  - paragraph: 填入各提供方的 API 密钥即可使用其模型。
+  - list
+  - combobox "添加提供方":
+    - option "+ 添加提供方" [selected]
+    - option "amazon-bedrock"
+    - option "ant-ling"
+    - option "anthropic"
+    - option "azure-openai-responses"
+    - option "cerebras"
+    - option "cloudflare-ai-gateway"
+    - option "cloudflare-workers-ai"
+    - option "deepseek"
+    - option "fireworks"
+    - option "github-copilot"
+    - option "google"
+    - option "google-vertex"
+    - option "groq"
+    - option "huggingface"
+    - option "kimi-coding"
+    - option "minimax"
+    - option "minimax-cn"
+    - option "mistral"
+    - option "moonshotai"
+    - option "moonshotai-cn"
+    - option "nvidia"
+    - option "openai"
+    - option "openai-codex"
+    - option "opencode"
+    - option "opencode-go"
+    - option "openrouter"
+    - option "qwen-token-plan"
+    - option "qwen-token-plan-cn"
+    - option "together"
+    - option "vercel-ai-gateway"
+    - option "xai"
+    - option "xiaomi"
+    - option "xiaomi-token-plan-ams"
+    - option "xiaomi-token-plan-cn"
+    - option "xiaomi-token-plan-sgp"
+    - option "zai"
+    - option "zai-coding-cn"

+ 1 - 0
apps/web/tsconfig.json

@@ -29,6 +29,7 @@
     "tests/navigation-panes.e2e.ts",
     "tests/lifecycle-chrome.e2e.ts",
     "tests/settings-chrome.e2e.ts",
+    "tests/models-settings.e2e.ts",
     "tests/workspace-management.e2e.ts",
     "tests/replay-round-trip.e2e.ts",
     "tests/seeded-history.e2e.ts",

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

@@ -0,0 +1,29 @@
+import { defineConfig } from 'tsdown'
+
+/**
+ * schema-form is browser-only, but its lib bundle is imported under plain
+ * Node through consumer lib chains (same posture as ui-primitives). CSS
+ * imports are stubbed to empty modules: the hashed class maps only matter in
+ * bundler contexts, which compile src directly and never read lib.
+ */
+export default defineConfig({
+  entry: ['lib/types/index.js', 'lib/types/invariant.js'],
+  outDir: 'lib',
+  format: ['esm'],
+  platform: 'neutral',
+  target: 'es2024',
+  fixedExtension: false,
+  dts: false,
+  clean: false,
+  plugins: [{
+    name: 'dsh-css-stub',
+    resolveId(source: string) {
+      if (!source.endsWith('.css')) return null
+      return `\0dsh-css-stub:${source}.mjs`
+    },
+    load(id: string) {
+      if (!id.startsWith('\0dsh-css-stub:')) return null
+      return 'export default {};'
+    },
+  }],
+})

+ 8 - 7
packages/client/ui-models/src/client/ModelsSection.tsx

@@ -28,10 +28,11 @@ export interface ModelsSectionInjected {
   t: (key: keyof typeof en) => string
 }
 
-/** Props delivered by the slot outlet. */
-export interface ModelsSectionProps {
-  injected?: ModelsSectionInjected
-}
+/**
+ * Props delivered by the slot outlet: the inject face spread flat (the
+ * renderer erases the share boundary at the render call).
+ */
+export type ModelsSectionProps = Partial<ModelsSectionInjected>
 
 /** The editor target: an existing row or a dormant directory entry. */
 interface EditorTarget {
@@ -80,9 +81,9 @@ function StatusBadges({ row, t }: { row: ProviderRow; t: ModelsSectionInjected['
  * @returns the section, or null while the shell has not injected yet.
  */
 export function ModelsSection(props: ModelsSectionProps): ReactNode {
-  const injected = props.injected
-  if (injected === undefined) return null
-  return <Loaded injected={injected} />
+  const { controller, useSnapshot, api, t } = props
+  if (controller === undefined || useSnapshot === undefined || api === undefined || t === undefined) return null
+  return <Loaded injected={{ controller, useSnapshot, api, t }} />
 }
 
 function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode {

+ 25 - 23
packages/client/ui-models/tests/components.spec.tsx

@@ -104,9 +104,11 @@ function scriptedFace(overrides: {
   return { face, update, replace, set }
 }
 
+type WireFace = ConstructorParameters<typeof ModelsSettingsStore>[0]
+
 async function mountSection(overrides: Parameters<typeof scriptedFace>[0] = {}) {
   const { face, update, replace, set } = scriptedFace(overrides)
-  const controller = new ModelsSettingsStore(face as never)
+  const controller = new ModelsSettingsStore(face as unknown as WireFace)
   await controller.load()
   const injected: ModelsSectionInjected = {
     controller,
@@ -114,7 +116,7 @@ async function mountSection(overrides: Parameters<typeof scriptedFace>[0] = {})
     api: face as never,
     t,
   }
-  const view = render(<ModelsSection injected={injected} />)
+  const view = render(<ModelsSection {...injected} />)
   return { view, face, update, replace, set, controller }
 }
 
@@ -275,14 +277,14 @@ 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 never)
+    const controller = new ModelsSettingsStore(face.face as unknown as WireFace)
     await controller.load()
-    render(<ModelsSection injected={{
-      controller,
-      useSnapshot: bindSnapshotSelector(controller.store),
-      api: face.face as never,
-      t,
-    }} />)
+    render(<ModelsSection
+      controller={controller}
+      useSnapshot={bindSnapshotSelector(controller.store)}
+      api={face.face as never}
+      t={t}
+    />)
     expect(screen.getByText(/directory down/)).toBeTruthy()
     fireEvent.click(screen.getByText(en.retry))
     await waitFor(() => { expect(screen.queryByText(/directory down/)).toBeNull() })
@@ -294,15 +296,15 @@ describe('ModelsSection', () => {
       writable: false,
       namespaces: wireNamespaces(),
     })))
-    const controller = new ModelsSettingsStore(face as never)
+    const controller = new ModelsSettingsStore(face as unknown as WireFace)
     await controller.load()
     cleanup()
-    render(<ModelsSection injected={{
-      controller,
-      useSnapshot: bindSnapshotSelector(controller.store),
-      api: face as never,
-      t,
-    }} />)
+    render(<ModelsSection
+      controller={controller}
+      useSnapshot={bindSnapshotSelector(controller.store)}
+      api={face as never}
+      t={t}
+    />)
     expect(screen.getByText(en.readOnly)).toBeTruthy()
     expect(screen.getAllByText<HTMLButtonElement>(en.remove).every(button => button.disabled)).toBe(true)
   })
@@ -359,13 +361,13 @@ describe('ModelsSection', () => {
 
   it('loads on first render of an idle controller', async () => {
     const { face } = scriptedFace()
-    const controller = new ModelsSettingsStore(face as never)
-    render(<ModelsSection injected={{
-      controller,
-      useSnapshot: bindSnapshotSelector(controller.store),
-      api: face as never,
-      t,
-    }} />)
+    const controller = new ModelsSettingsStore(face as unknown as WireFace)
+    render(<ModelsSection
+      controller={controller}
+      useSnapshot={bindSnapshotSelector(controller.store)}
+      api={face as never}
+      t={t}
+    />)
     await screen.findByText('DeepSeek')
   })
 

+ 1 - 0
packages/client/web/package.json

@@ -21,6 +21,7 @@
   "license": "BSD-3-Clause",
   "dependencies": {
     "@deepseek-ai/dsh-client-modules": "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-ui-theme": "workspace:^",

+ 1 - 0
packages/client/web/src/platform.ts

@@ -10,6 +10,7 @@ export const PLATFORM_MODULES = [
   '@deepseek-ai/dsh-client-ui-slots',
   '@deepseek-ai/dsh-client-web-react',
   '@deepseek-ai/dsh-client-ui-primitives',
+  '@deepseek-ai/dsh-client-schema-form',
 ] as const
 
 /** One platform module specifier (a seed-table key). */

+ 2 - 0
packages/client/web/src/seed.ts

@@ -14,6 +14,7 @@ import * as Cordis from 'cordis'
 import * as UiSlots from '@deepseek-ai/dsh-client-ui-slots'
 import * as WebReact from '@deepseek-ai/dsh-client-web-react'
 import * as UiPrimitives from '@deepseek-ai/dsh-client-ui-primitives'
+import * as SchemaForm from '@deepseek-ai/dsh-client-schema-form'
 import type { PlatformModule } from './platform.ts'
 
 /**
@@ -33,5 +34,6 @@ export function getStaticModules(): Record<string, unknown> {
     '@deepseek-ai/dsh-client-ui-slots': UiSlots,
     '@deepseek-ai/dsh-client-web-react': WebReact,
     '@deepseek-ai/dsh-client-ui-primitives': UiPrimitives,
+    '@deepseek-ai/dsh-client-schema-form': SchemaForm,
   } satisfies Record<PlatformModule, unknown>
 }

+ 3 - 0
packages/client/web/tsconfig.json

@@ -23,6 +23,9 @@
     {
       "path": "../ui-primitives"
     },
+    {
+      "path": "../schema-form"
+    },
     {
       "path": "../web-react"
     },

+ 1 - 1
packages/llm/llm-deepseek/src/index.ts

@@ -76,7 +76,7 @@ const catalogModel: z<DeepSeekCatalogModel> = z.object({
 
 export const Config: z<Config> = z.object({
   apiKey: z.string().role('secret'),
-  apiKeyEnv: z.string().default(DEFAULT_API_KEY_ENV),
+  apiKeyEnv: z.string().role('credential-ref').default(DEFAULT_API_KEY_ENV),
   baseURL: z.string(),
   thinking: z.union(['enabled', 'disabled']),
   reasoningEffort: z.union(['off', 'high', 'max']),

+ 1 - 1
packages/llm/llm-pi-ai/src/config.ts

@@ -77,7 +77,7 @@ const thinkingBudgets = z.object({
 
 const profile = z.object({
   apiKey: z.string().role('secret'),
-  apiKeyEnv: z.string(),
+  apiKeyEnv: z.string().role('credential-ref'),
   baseURL: z.string(),
   headers: z.dict(z.string()),
   reasoning: z.union(['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max']),

+ 12 - 0
pnpm-lock.yaml

@@ -209,6 +209,9 @@ importers:
       '@deepseek-ai/dsh-compact-basic':
         specifier: workspace:^
         version: link:../../packages/compact/compact-basic
+      '@deepseek-ai/dsh-credentials-local':
+        specifier: workspace:^
+        version: link:../../packages/credentials/credentials-local
       '@deepseek-ai/dsh-frontend':
         specifier: workspace:^
         version: link:../web
@@ -236,6 +239,9 @@ importers:
       '@deepseek-ai/dsh-llm-deepseek':
         specifier: workspace:^
         version: link:../../packages/llm/llm-deepseek
+      '@deepseek-ai/dsh-llm-pi-ai':
+        specifier: workspace:^
+        version: link:../../packages/llm/llm-pi-ai
       '@deepseek-ai/dsh-llm-retry':
         specifier: workspace:^
         version: link:../../packages/llm/llm-retry
@@ -263,6 +269,9 @@ importers:
       '@deepseek-ai/dsh-session-title-first-message-llm':
         specifier: workspace:^
         version: link:../../packages/session-title/session-title-first-message-llm
+      '@deepseek-ai/dsh-settings-local':
+        specifier: workspace:^
+        version: link:../../packages/settings/settings-local
       '@deepseek-ai/dsh-skill':
         specifier: workspace:^
         version: link:../../packages/skill/skill
@@ -1612,6 +1621,9 @@ importers:
       '@deepseek-ai/dsh-client-modules':
         specifier: workspace:^
         version: link:../modules
+      '@deepseek-ai/dsh-client-schema-form':
+        specifier: workspace:^
+        version: link:../schema-form
       '@deepseek-ai/dsh-client-ui-primitives':
         specifier: workspace:^
         version: link:../ui-primitives

+ 1 - 0
tsconfig.host.json

@@ -16,6 +16,7 @@
     "apps/web/tests/navigation-panes.e2e.ts",
     "apps/web/tests/lifecycle-chrome.e2e.ts",
     "apps/web/tests/settings-chrome.e2e.ts",
+    "apps/web/tests/models-settings.e2e.ts",
     "apps/web/tests/workspace-management.e2e.ts",
     "apps/web/tests/replay-round-trip.e2e.ts",
     "apps/web/tests/seeded-history.e2e.ts",