Merge pull request #950 from deepseek-harness/worktree/onboarding-deepseek-config
feat(GUI): guide DeepSeek setup during onboarding
This commit is contained in:
+6
@@ -0,0 +1,6 @@
|
||||
# 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 .agents/notes/implemented/feature/2026-07-30-deepseek-onboarding-credential-setup.md
|
||||
2026-07-30-deepseek-onboarding-credential-setup.md: 571b81a1a2e6f392f2553070048964d49941aae9
|
||||
2026-07-30-deepseek-onboarding-credential-setup.zh.md: 744c30814f84d063f196ce20ba48fb993d0b7713
|
||||
@@ -0,0 +1,33 @@
|
||||
# Agent Note: official DeepSeek first-run credential setup
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-07-30-deepseek-onboarding-credential-setup.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The [web configuration plane](../architecture/2026-07-30-web-config-plane.md) makes provider settings and credentials live-editable, but a first-time user still lands on the empty conversation Hero without an actionable explanation when the shipped `deepseek-official` route has no credential. The Models page can repair that state, yet requiring the user to discover it weakens onboarding. A prompt must not confuse a missing credential with a missing adapter: the browser can store a value for an existing credential reference, but it cannot dynamically mount the `llm-deepseek` Cordis plugin.
|
||||
|
||||
## Decision
|
||||
|
||||
**One readiness projection owns both Models and onboarding facts.** `ui-models` keeps a single store that joins `llm.providers({})`, redacted `settings.describe({})`, and batched `credentials.describe({refs})`. The onboarding projection selects the `deepseek-official` configurable-provider entry owned by the `llm-deepseek` namespace and empty settings path, reads the effective `apiKeyEnv`, and evaluates the matching credential descriptor. A live route with the same provider id but no matching configurable-provider declaration is adapter-absent for onboarding. A configured literal `apiKey` secret sidecar is also ready, so compatibility configuration does not trigger a false prompt; a configured process-environment credential is ready and remains read-only.
|
||||
|
||||
**The settings shell contributes navigation state, not provider policy.** `ui-settings` declares a root-scoped `settings.onboarding` list slot and tells registrants whether the current surface is the empty Hero. Its private `openSection(id)` callback opens the settings panel on one registered section. `ui-models` registers the DeepSeek overlay through the same declaration-aware deferred-registration path as its Models section, so plugin load order does not become a contract.
|
||||
|
||||
**The prompt routes to the one credential editor.** A mounted, active adapter with a resolved, writable, unconfigured reference presents one action that opens Settings on Models. The existing DeepSeek setup card there exclusively owns the password input, `credentials.set({ref, value})`, write failures, and post-write refresh; the onboarding overlay never holds or submits a secret.
|
||||
|
||||
**Unavailable states do not capture the product.** An absent configurable-provider entry, inactive route, failed initial join, read-only deployment, or unresolved settings or credential capability suppresses the modal because the onboarding action cannot repair that state. The Models page remains the deployment diagnostic and retry surface. Configure later dismisses a missing-credential overlay for the current mounted surface and writes no completion fact. Settings, credential, provider-topology, and connection invalidations all refresh the shared join, so an external credential update closes an open prompt without a reload.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**A separate onboarding store and readiness RPC sequence** — rejected because it would create a second client-side interpretation of provider identity, settings paths, secret sidecars, credential references, and invalidation ordering beside the Models page.
|
||||
|
||||
**A second API-key editor inside onboarding** — rejected because the Models page already renders its DeepSeek setup card for exactly this state. Duplicating its secret draft, write errors, and configured-state convergence would add a second security-sensitive UI without another user capability.
|
||||
|
||||
**Writing the API key into provider settings** — rejected because a literal secret would enter the settings mutation path and whole-section replacement cannot safely reconstruct redacted values. Credential storage is already the product seam and supplies immediate invalidation.
|
||||
|
||||
**Showing the prompt when `llm-deepseek` is absent** — rejected because browser navigation has no supported operation that mounts the missing Cordis plugin.
|
||||
|
||||
## Consequences
|
||||
|
||||
The first-run flow leads to the shipped adapter's existing editor without restarting: a keyless browser test boots the real Web composition under an isolated harness home, follows the prompt to Models, stores a generated key through that page into the home's `.env`, verifies no key reaches DOM, ARIA, or browser console output, and confirms the running page reports configured. The full keyless Web replay lane also pins that a non-configurable replay route with the same provider id does not block unrelated journeys. Pure readiness and React tests pin literal, file, process-environment, missing-provider, missing-capability, navigation, cancellation, and external-invalidation behavior. The flow deliberately inherits the configuration plane's documented base limitations rather than adding local secret storage, redaction, or settings replacement workarounds.
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
# Agent Note: DeepSeek 官方首次使用凭据配置
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-07-30-deepseek-onboarding-credential-setup.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
[web 配置平面](../architecture/2026-07-30-web-config-plane.md)让提供方设置与凭据可以实时编辑,但首次使用的用户仍会进入空白对话 Hero;当随产品提供的 `deepseek-official` 路由缺少凭据时,界面没有给出可采取操作的说明。Models 页能修复该状态,但要求用户自行发现这个入口会削弱首次使用引导。界面不得混淆凭据缺失与适配器缺失:浏览器可以为现有凭据引用存入值,但无法动态挂载 `llm-deepseek` Cordis 插件。
|
||||
|
||||
## 决策
|
||||
|
||||
**Models 与首次使用引导共享同一个就绪状态投影。**`ui-models` 维护一个 store,把 `llm.providers({})`、脱敏后的 `settings.describe({})` 和批量调用的 `credentials.describe({refs})` 联接为同一份状态。首次使用投影选取由 `llm-deepseek` namespace 所有、设置路径为空的 `deepseek-official` 可配置提供方条目,读取生效的 `apiKeyEnv`,并检查对应的凭据描述符。同一提供方 ID 下的存活路由若没有匹配的可配置提供方声明,首次使用引导会将其视为适配器缺失。若 `apiKey` 字面量对应的 secret 槽位标记为已设置,也会判定为就绪,兼容配置因此不会误触发浮层;通过进程环境提供的凭据若已配置,同样判定为就绪并保持只读。
|
||||
|
||||
**设置外壳只贡献导航状态,不持有提供方策略。**`ui-settings` 声明一个根作用域的 `settings.onboarding` list slot,并告知注册方当前界面是否为空白 Hero。其私有 `openSection(id)` 回调会打开设置面板并切换到一个已注册分区。`ui-models` 沿用 Models 分区所使用、感知 slot 声明的延迟注册路径来注册 DeepSeek 浮层,因此插件加载顺序不会成为契约。
|
||||
|
||||
**浮层只负责跳转到唯一的凭据编辑器。**适配器已挂载且处于活跃状态,其引用可解析、可写但尚未配置时,界面会显示一个操作按钮,用于打开「设置」的 Models 分区。该分区已有的 DeepSeek 设置卡片全权负责密码输入框、`credentials.set({ref, value})`、写入失败处理和写入后刷新;首次使用浮层绝不持有或提交 secret。
|
||||
|
||||
**不可用状态不会拦截产品交互。**可配置提供方条目缺失、路由未激活、初始联接失败、部署只读、设置能力无法解析或凭据能力无法解析时均不显示模态框,因为首次使用引导的操作无法修复这些状态。Models 页仍是部署诊断与重试界面。「稍后配置」只会在当前已挂载界面中关闭凭据缺失浮层,不写入任何完成状态。设置、凭据、提供方拓扑和连接失效事件都会刷新共享联接,因此外部凭据更新无需重新加载页面即可关闭已打开的浮层。
|
||||
|
||||
## 曾考虑的替代方案
|
||||
|
||||
**为首次使用引导单设 store 与就绪状态 RPC 调用序列**:不予采用,因为这会在 Models 页之外,再建立一套客户端解释,用于判定提供方身份、设置路径、secret 槽位的伴随信息、凭据引用及失效事件顺序。
|
||||
|
||||
**在首次使用引导中增设第二个 API key 编辑器**:不予采用,因为 Models 页已为这一状态渲染 DeepSeek 设置卡片。复制其中的 secret 草稿、写入错误处理和已配置状态收敛会增加第二个安全敏感的 UI,却不会带来新的用户能力。
|
||||
|
||||
**把 API key 写入提供方设置**:不予采用,因为字面量 secret 会进入设置变更路径,而整个分节替换无法安全重建脱敏值。凭据存储已经是产品 seam,并能立即发出失效事件。
|
||||
|
||||
**`llm-deepseek` 缺失时仍显示浮层**:不予采用,因为浏览器导航没有任何受支持的操作可以挂载缺失的 Cordis 插件。
|
||||
|
||||
## 后果
|
||||
|
||||
首次使用流程无需重启即可引导用户前往随产品提供的适配器已有的编辑器:无密钥浏览器测试在隔离的 harness 家目录下启动真实 Web 组合,依照浮层操作前往 Models,通过该页面把生成的密钥存入该目录的 `.env`,验证密钥未进入 DOM、ARIA 或浏览器控制台输出,并确认运行中的页面报告已配置。完整的无密钥 Web 回放链路还固化了同一提供方 ID 下的不可配置回放路由不会阻塞无关流程。纯就绪状态测试与 React 测试固化了字面量凭据、文件凭据、进程环境凭据、提供方缺失、能力缺失、导航、取消和外部失效行为。该流程直接继承配置平面已记录的基础限制,不会另加局部的机密存储、脱敏或设置替换变通方案。
|
||||
@@ -0,0 +1,91 @@
|
||||
// Keyless browser e2e: the shipped DeepSeek adapter stays mounted while its
|
||||
// credential is absent, onboarding routes to the real Models editor, and its
|
||||
// write lands in an isolated harness home without a reload or model call.
|
||||
import { randomBytes } from 'node:crypto'
|
||||
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/onboarding-deepseek-config', import.meta.url))
|
||||
const MISSING_EXPECTED = join(SNAPSHOT_DIR, 'missing.expected.md')
|
||||
const MODE = webSnapshotMode()
|
||||
|
||||
describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup', () => {
|
||||
let scaffold: WebScaffold
|
||||
let browser: Browser
|
||||
let page: Page
|
||||
let tripwire: ReturnType<typeof watchConsole>
|
||||
const browserConsole: string[] = []
|
||||
|
||||
beforeAll(async () => {
|
||||
scaffold = await launchWebScaffold({ deepSeekMissingCredential: true })
|
||||
browser = await chromium.launch()
|
||||
page = await browser.newPage({ viewport: { width: 1440, height: 960 } })
|
||||
tripwire = watchConsole(page)
|
||||
page.on('console', message => browserConsole.push(message.text()))
|
||||
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('stores a key write-only and observes configured state without restarting', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-onboarding-deepseek-config'))
|
||||
const dialog = page.getByRole('dialog', { name: '添加一个 API Key 开始使用' })
|
||||
await dialog.waitFor({ timeout: 15_000 })
|
||||
expect(await dialog.getByRole('textbox').count()).toBe(0)
|
||||
const initial = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd)
|
||||
await compareOrRefreshGolden(MISSING_EXPECTED, initial, MODE)
|
||||
|
||||
await dialog.getByRole('button', { name: '前往配置' }).click()
|
||||
await dialog.waitFor({ state: 'detached', timeout: 15_000 })
|
||||
const settings = page.getByRole('dialog', { name: '设置' })
|
||||
await settings.waitFor({ timeout: 10_000 })
|
||||
const keyInput = settings.getByLabel('API 密钥', { exact: true })
|
||||
await keyInput.waitFor({ timeout: 10_000 })
|
||||
|
||||
const secret = `dsh_onboarding_${randomBytes(12).toString('hex')}`
|
||||
await keyInput.fill(secret)
|
||||
await settings.getByRole('button', { name: '保存', exact: true }).click()
|
||||
await keyInput.waitFor({ state: 'detached', timeout: 15_000 })
|
||||
|
||||
const stored = await readFile(join(scaffold.harnessHome, '.env'), 'utf8')
|
||||
expect(stored.includes(`DEEPSEEK_API_KEY=${secret}`)).toBe(true)
|
||||
expect((await page.content()).includes(secret)).toBe(false)
|
||||
expect((await page.locator('body').ariaSnapshot()).includes(secret)).toBe(false)
|
||||
expect(browserConsole.some(line => line.includes(secret))).toBe(false)
|
||||
|
||||
// The same open Models surface reuses the refreshed join and exposes the
|
||||
// configured write-only placeholder without a reload.
|
||||
const deepSeekRow = settings.getByText('DeepSeek', { exact: true }).first()
|
||||
await deepSeekRow.waitFor({ timeout: 10_000 })
|
||||
await deepSeekRow.locator('xpath=ancestor::li').getByRole('button', { name: '编辑' }).click()
|
||||
const configuredInput = settings.getByLabel('API 密钥', { exact: true })
|
||||
await configuredInput.waitFor({ timeout: 10_000 })
|
||||
await expect.poll(
|
||||
() => configuredInput.getAttribute('placeholder'),
|
||||
{ timeout: 10_000 },
|
||||
).toBe('已配置——输入新值可替换')
|
||||
|
||||
expect((await page.content()).includes(secret)).toBe(false)
|
||||
expect((await page.locator('body').ariaSnapshot()).includes(secret)).toBe(false)
|
||||
expect(browserConsole.some(line => line.includes(secret))).toBe(false)
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
}, 60_000)
|
||||
|
||||
it('keeps the fixture inventory closed', async () => {
|
||||
await assertFixtureInventory(SNAPSHOT_DIR, ['missing.expected.md'])
|
||||
})
|
||||
})
|
||||
+42
-11
@@ -4,17 +4,19 @@
|
||||
// the vendored Loader (the same include boot AppCLIEntry drives), patched the
|
||||
// snapshot way — so a real chromium exercises the real HTTP/SSE wire, the
|
||||
// api-gateway, agent loop, tools, and persistence. Modes ride $DSH_SNAPSHOT:
|
||||
// replay (default, keyless: llm-deepseek row disabled, dsh-llm-replay row
|
||||
// inserted in providers mode), record (real adapter + key, harvests fixtures
|
||||
// from live session memory), refresh (keyless replay that rewrites goldens).
|
||||
// replay (default, keyless: normally disables the llm-deepseek row and
|
||||
// inserts dsh-llm-replay in providers mode), record (real adapter + key,
|
||||
// harvests fixtures from live session memory), refresh (keyless replay that
|
||||
// rewrites goldens). A first-run option keeps the real adapter mounted while
|
||||
// masking its credential, without making a model call.
|
||||
//
|
||||
// Composition divergences from `dsh web`, all deliberate, all via include
|
||||
// patches after the shipped surface overlay: temp persistenceRoot; local skill
|
||||
// roots confined to the temp workspace; workspace-context disabled (recorded
|
||||
// fixtures must not embed this repo's AGENTS.md); session-title-llm disabled
|
||||
// (its fire-and-forget title call would race the loop for the session's replay
|
||||
// cursor); webserver pinned to port 0 with the built dist; keyless modes
|
||||
// disable llm-deepseek and fill the open llm seam post-boot with
|
||||
// cursor); webserver pinned to port 0 with the built dist; ordinary keyless
|
||||
// modes disable llm-deepseek and fill the open llm seam post-boot with
|
||||
// installLlmReplay on the settled root ctx
|
||||
// (the plugin-row path discards the ReplayHandle; the direct install keeps
|
||||
// assertConsumed for the teardown fixture-consumption check).
|
||||
@@ -127,6 +129,12 @@ export interface LaunchOptions {
|
||||
* remain reconstructable without making the tools a product default.
|
||||
*/
|
||||
cordisTools?: boolean
|
||||
/**
|
||||
* Keep the shipped DeepSeek adapter mounted while masking the process
|
||||
* environment's DEEPSEEK_API_KEY for this scaffold lifetime. This is the
|
||||
* keyless first-run configuration lane; the default disables the adapter.
|
||||
*/
|
||||
deepSeekMissingCredential?: boolean
|
||||
}
|
||||
|
||||
/** Dispose the booted tree and remove both owned temp roots, reporting every independent cleanup failure. */
|
||||
@@ -153,6 +161,21 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
|
||||
throw new Error('web e2e record mode needs DEEPSEEK_API_KEY (env or repo-root .env)')
|
||||
}
|
||||
}
|
||||
if (mode === 'record' && options.deepSeekMissingCredential === true) {
|
||||
throw new Error('deepSeekMissingCredential is a keyless replay/refresh option')
|
||||
}
|
||||
const maskDeepSeekCredential = mode !== 'record' && options.deepSeekMissingCredential === true
|
||||
const originalDeepSeekCredential = process.env.DEEPSEEK_API_KEY
|
||||
let credentialEnvironmentRestored = false
|
||||
const restoreCredentialEnvironment = (): void => {
|
||||
if (credentialEnvironmentRestored || !maskDeepSeekCredential) return
|
||||
credentialEnvironmentRestored = true
|
||||
if (originalDeepSeekCredential === undefined) {
|
||||
Reflect.deleteProperty(process.env, 'DEEPSEEK_API_KEY')
|
||||
} else {
|
||||
process.env.DEEPSEEK_API_KEY = originalDeepSeekCredential
|
||||
}
|
||||
}
|
||||
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
|
||||
@@ -167,6 +190,7 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
|
||||
if (failures.length > 1) throw new AggregateError(failures, 'web scaffold temp-root setup failed')
|
||||
throw error
|
||||
}
|
||||
if (maskDeepSeekCredential) Reflect.deleteProperty(process.env, 'DEEPSEEK_API_KEY')
|
||||
|
||||
// The include patch set — the same mechanism AppCLIEntry and the ACP
|
||||
// snapshot overlay use, applied over the SAME shipped tree (a patch id that
|
||||
@@ -212,7 +236,9 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
|
||||
...options.cordisTools === true
|
||||
? [{ insert: [{ id: 'tool-cordis', name: 'cordis:tool-cordis' }] }]
|
||||
: [],
|
||||
...mode === 'record' ? [] : [{ id: 'llm-deepseek', disabled: true }],
|
||||
...mode === 'record' || options.deepSeekMissingCredential === true
|
||||
? []
|
||||
: [{ id: 'llm-deepseek', disabled: true }],
|
||||
]
|
||||
|
||||
// Sessions inherit the gateway's process.cwd() default; run the boot from
|
||||
@@ -241,10 +267,10 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
|
||||
}
|
||||
port = boundPort
|
||||
|
||||
// Fill the open llm seam on the settled root ctx (llm-deepseek is disabled
|
||||
// in keyless modes; a scenario with no fixture leaves the seam empty so a
|
||||
// stray stream fails loud with NO_ADAPTER). The direct install, unlike the
|
||||
// plugin row, returns the ReplayHandle for the teardown consumption check.
|
||||
// Fill the open llm seam on the settled root ctx. Ordinary keyless modes
|
||||
// disable llm-deepseek; the first-run lane keeps it mounted but has no
|
||||
// replay fixture and never streams. The direct install, unlike the plugin
|
||||
// row, returns the ReplayHandle for the teardown consumption check.
|
||||
if (mode !== 'record' && options.replayFixture !== undefined) {
|
||||
replayHandle = installLlmReplay(ctx, {
|
||||
file: options.replayFixture,
|
||||
@@ -256,6 +282,7 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
|
||||
} catch (error) {
|
||||
if (process.cwd() !== originalCwd) process.chdir(originalCwd)
|
||||
const cleanupFailures = await cleanupScaffoldWorld(ctx, workspaceCwd, persistenceRoot)
|
||||
restoreCredentialEnvironment()
|
||||
if (cleanupFailures.length > 0) {
|
||||
throw new AggregateError([error, ...cleanupFailures], 'web scaffold setup failed and cleanup was incomplete')
|
||||
}
|
||||
@@ -304,7 +331,11 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
|
||||
} catch (error) {
|
||||
failures.push(error)
|
||||
}
|
||||
failures.push(...await cleanupScaffoldWorld(ctx, workspaceCwd, persistenceRoot))
|
||||
try {
|
||||
failures.push(...await cleanupScaffoldWorld(ctx, workspaceCwd, persistenceRoot))
|
||||
} finally {
|
||||
restoreCredentialEnvironment()
|
||||
}
|
||||
if (failures.length > 0) throw new AggregateError(failures, 'web scaffold teardown failed')
|
||||
},
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
- dialog "添加一个 API Key 开始使用":
|
||||
- heading "添加一个 API Key 开始使用" [level=2]
|
||||
- button "稍后配置":
|
||||
- img
|
||||
- paragraph: 配置 DeepSeek 官方模型,即可开始使用。
|
||||
- button "前往配置"
|
||||
@@ -34,6 +34,7 @@
|
||||
"tests/details-session-lifecycle.e2e.ts",
|
||||
"tests/settings-chrome.e2e.ts",
|
||||
"tests/models-settings.e2e.ts",
|
||||
"tests/onboarding-deepseek-config.e2e.ts",
|
||||
"tests/workspace-management.e2e.ts",
|
||||
"tests/replay-round-trip.e2e.ts",
|
||||
"tests/seeded-history.e2e.ts",
|
||||
|
||||
@@ -362,6 +362,7 @@ flowchart TD
|
||||
pkg_client_ui_models --> pkg_client_connection
|
||||
pkg_client_ui_models --> pkg_client_runtime
|
||||
pkg_client_ui_models --> pkg_client_schema_form
|
||||
pkg_client_ui_models --> pkg_client_ui_primitives
|
||||
pkg_client_ui_models --> pkg_client_ui_slots
|
||||
pkg_client_ui_models --> pkg_client_web_react
|
||||
pkg_client_ui_models --> pkg_invariants
|
||||
@@ -1070,7 +1071,7 @@ flowchart TD
|
||||
| [`session`](../packages/core/session) | `core` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) |
|
||||
| [`system-prompt`](../packages/core/system-prompt) | `core` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) |
|
||||
| [`web`](../packages/web/web) | `web` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) |
|
||||
| [`client-ui-models`](../packages/client/ui-models) | `client` | [`client-connection`](../packages/client/connection), [`client-runtime`](../packages/client/runtime), [`client-schema-form`](../packages/client/schema-form), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`invariants`](../packages/support/invariants) |
|
||||
| [`client-ui-models`](../packages/client/ui-models) | `client` | [`client-connection`](../packages/client/connection), [`client-runtime`](../packages/client/runtime), [`client-schema-form`](../packages/client/schema-form), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`invariants`](../packages/support/invariants) |
|
||||
| [`client-ui-question`](../packages/client/ui-question) | `client` | [`client-locale`](../packages/client/locale), [`invariants`](../packages/support/invariants) |
|
||||
| [`client-ui-settings-general`](../packages/client/ui-settings-general) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
|
||||
| [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
|
||||
|
||||
@@ -716,7 +716,11 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
|
||||
{ provider: 'deepseek-official', model: 'deepseek-v4-flash' },
|
||||
]))
|
||||
/** Credential store double: set/unset flip the describe badge, values never read back. */
|
||||
const fixtureCredentials = new Map<string, string>()
|
||||
const fixtureCredentials = new Map<string, true>([
|
||||
// The assembled fixture represents an already-configured shipped
|
||||
// DeepSeek route so unrelated GUI journeys do not enter first-run setup.
|
||||
['DEEPSEEK_API_KEY', true],
|
||||
])
|
||||
const nextTurn = new Map<SessionId, number>([[sid('fx-alpha'), 60]])
|
||||
let nextSession = 1
|
||||
let nextRpc = 1
|
||||
@@ -1580,19 +1584,28 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
|
||||
},
|
||||
},
|
||||
settings: {
|
||||
// The fixture registers no namespaces yet: the Models surface renders
|
||||
// its provider list from llm.providers alone, and a real settings form
|
||||
// rides the HTTP transport (a hand-written schema envelope here would
|
||||
// drift from schemastery's real serialization).
|
||||
describe: request => ok(request, { writable: true, namespaces: [] }),
|
||||
// Only the resolved DeepSeek address needed by first-run readiness is
|
||||
// represented here. Fixture-backed journeys do not open its Models
|
||||
// editor; real schema-driven forms ride the HTTP transport.
|
||||
describe: request => ok(request, {
|
||||
writable: true,
|
||||
namespaces: [{
|
||||
ns: 'llm-deepseek',
|
||||
schema: {},
|
||||
value: { apiKeyEnv: 'DEEPSEEK_API_KEY' },
|
||||
applies: 'live',
|
||||
secrets: [{ path: ['apiKey'], set: false }],
|
||||
revision: 0,
|
||||
}],
|
||||
}),
|
||||
update: request => err(request, {
|
||||
code: 'settings-rejected',
|
||||
message: 'fixture: no settings namespaces are registered',
|
||||
message: 'fixture: the minimal readiness settings descriptor is read-only',
|
||||
details: { ns: request.payload.ns },
|
||||
}),
|
||||
replace: request => err(request, {
|
||||
code: 'settings-rejected',
|
||||
message: 'fixture: no settings namespaces are registered',
|
||||
message: 'fixture: the minimal readiness settings descriptor is read-only',
|
||||
details: { ns: request.payload.ns },
|
||||
}),
|
||||
mutate: request => err(request, {
|
||||
@@ -1610,7 +1623,7 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
|
||||
}])),
|
||||
}),
|
||||
set: (request) => {
|
||||
fixtureCredentials.set(request.payload.ref, request.payload.value)
|
||||
fixtureCredentials.set(request.payload.ref, true)
|
||||
return ok(request, {})
|
||||
},
|
||||
unset: (request) => {
|
||||
|
||||
@@ -119,6 +119,36 @@ describe('createFixtureApi', () => {
|
||||
expect(JSON.stringify(after.result.value.events)).toContain('openai/gpt-5')
|
||||
})
|
||||
|
||||
it('serves configured DeepSeek readiness and keeps credential values write-only', async () => {
|
||||
const api = createFixtureApi()
|
||||
const settings = await api.settings.describe(req({}))
|
||||
if (!settings.result.ok) throw new Error('settings describe failed')
|
||||
expect(settings.result.value.namespaces).toMatchObject([{
|
||||
ns: 'llm-deepseek',
|
||||
value: { apiKeyEnv: 'DEEPSEEK_API_KEY' },
|
||||
secrets: [{ path: ['apiKey'], set: false }],
|
||||
}])
|
||||
|
||||
const initial = await api.credentials.describe(req({ refs: ['DEEPSEEK_API_KEY', 'TEST_API_KEY'] }))
|
||||
if (!initial.result.ok) throw new Error('credential describe failed')
|
||||
expect(initial.result.value.credentials).toEqual({
|
||||
DEEPSEEK_API_KEY: { configured: true, source: 'file', writable: true },
|
||||
TEST_API_KEY: { configured: false, writable: true },
|
||||
})
|
||||
await api.credentials.set(req({ ref: 'TEST_API_KEY', value: 'write-only-fixture-secret' }))
|
||||
const configured = await api.credentials.describe(req({ refs: ['TEST_API_KEY'] }))
|
||||
if (!configured.result.ok) throw new Error('credential describe failed')
|
||||
expect(configured.result.value.credentials.TEST_API_KEY).toEqual({
|
||||
configured: true,
|
||||
source: 'file',
|
||||
writable: true,
|
||||
})
|
||||
await api.credentials.unset(req({ ref: 'TEST_API_KEY' }))
|
||||
const cleared = await api.credentials.describe(req({ refs: ['TEST_API_KEY'] }))
|
||||
if (!cleared.result.ok) throw new Error('credential describe failed')
|
||||
expect(cleared.result.value.credentials.TEST_API_KEY).toEqual({ configured: false, writable: true })
|
||||
})
|
||||
|
||||
it('emits the todo/write snapshot at the real tool boundary: between tool/call and tool/result, timestamps monotonic', async () => {
|
||||
const api = createFixtureApi()
|
||||
const tail = await api.sessions.history(req({ sessionId: sid('fx-alpha'), maxMessages: 10 }))
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# 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/ui-models/README.md
|
||||
README.md: 30eb4a3a10caf961d50517048ca05490dac02799
|
||||
README.zh.md: b58a0adb388d247ade7fe375f5daba2964467a61
|
||||
README.md: adfbc084e1b0e227d50032cb6c924401b81c6a79
|
||||
README.zh.md: 4ee7d4efa729fdccee392ab8e55078b5a4a239ef
|
||||
@@ -2,10 +2,12 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Models settings section plugin: the provider configuration page. It joins three wire domains into one surface — `llm.providers` (the configurable-provider directory with each route's live/dormant state), `settings.describe` (serialized schemas, layered redacted values, secret slots), and `credentials.describe` (value-free configured/source/writable badges) — and renders provider rows with one editor card at a time.
|
||||
Models settings plugin: the provider configuration page and official-DeepSeek first-run routing overlay. It joins three wire domains into one shared snapshot — `llm.providers` (the configurable-provider directory with each route's live/dormant state), `settings.describe` (serialized schemas, layered redacted values, secret slots), and `credentials.describe` (value-free configured/source/writable badges) — and renders provider rows with one editor card at a time.
|
||||
|
||||
Rows are the *configured* providers (their profile resolves in the owning namespace); a whole-section provider whose key is not configured anywhere (the first-run DeepSeek posture) renders as its open setup card instead of a row, and the add flow is a card carrying the dormant-directory provider select — a bare-mounted `llm-pi-ai` offers its whole installed catalog before any route exists. The editor is a hand-written card per adapter family: the primary field is a single **API key** input — the page never asks for an environment-variable name; a typed key stores **write-only** through `credentials.set` under the profile's reference, deriving `<ROUTE>_API_KEY` when the profile has none, and the pi-ai profile records that derivation as `apiKeyEnv`, so `settings.yaml` never carries a key value. The collapsed 自定义设置 fold carries the curated extras — `baseURL` for both families (the deepseek placeholder shows the public endpoint), plus `reasoningEffort` (deepseek) or `reasoning` (pi-ai); every other profile field stays owned by `settings.yaml`. A row is deletable only when the user layer alone carries it (removal restores the composition base).
|
||||
|
||||
The first-run overlay projects `deepseek-official` readiness from that same joined snapshot. It recognizes the official adapter through its `llm-deepseek` configurable-provider declaration, so an undeclared live route with the same provider id is not treated as repairable configuration. A configured literal `apiKey` secret sidecar or configured credential reference suppresses the prompt, including a read-only launch-environment credential. Only a mounted adapter with a missing writable reference shows the action that opens Settings on the Models section, whose existing setup card exclusively owns key input and `credentials.set`; the overlay never holds a secret. An absent adapter, inactive route, failed join, read-only deployment, or unusable settings or credential capability is skipped so onboarding cannot block the rest of the product; the Models page remains the diagnostic surface.
|
||||
|
||||
Every edit lands as `settings.mutate` path ops against the stored section — a set per changed field, an unset per cleared one, and a single unset for a deleted row. The page only ever holds the REDACTED descriptor, so it names the fields it can see rather than rebuilding a section: a stored literal secret it never received is mentioned by no op and survives. Each write carries the `revision` the card opened at, so a concurrent write from another tab or an external `settings.yaml` edit is refused as `settings-conflict` and the card asks the user to reopen instead of replaying its stale snapshot. The page refetches on the pushed invalidations (`settings/changed`, `credentials/changed`, `models/changed`, and `connection/reset`) once it has loaded, so an external `settings.yaml` edit, a second tab, or a settings-born route converges without polling.
|
||||
|
||||
## Model Experience
|
||||
|
||||
@@ -2,11 +2,13 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
模型设置分区插件:提供方配置页。它把三个协议领域汇聚为一个界面——`llm.providers`(可配置提供方目录,含每条路由的存活/休眠状态)、`settings.describe`(序列化 schema、分层脱敏值、secret 槽位)与 `credentials.describe`(不含值的 configured/source/writable 徽标)——并渲染提供方行,一次只展开一张编辑卡片。
|
||||
模型设置插件:提供方配置页和 DeepSeek 官方首次使用跳转浮层。它把三个协议领域汇聚为一个共享快照:`llm.providers`(可配置提供方目录,含每条路由的存活/休眠状态)、`settings.describe`(序列化 schema、分层脱敏值、secret 槽位)与 `credentials.describe`(不含值的 configured/source/writable 徽标);页面据此渲染提供方行,一次只展开一张编辑卡片。
|
||||
|
||||
行是*已配置*的提供方(其 profile 在所属 namespace 中解析得出);密钥未在任何地方配置的整分节提供方(DeepSeek 的首次运行姿态)会渲染为其展开的设置卡片而非一行,「新增」流程则是一张承载休眠目录提供方选择框的卡片——裸挂载的 `llm-pi-ai` 在任何路由存在之前就能提供其完整的已安装 catalog。编辑器是每个适配器家族各一张的手写卡片:主字段是单独一个 **API 密钥**输入框——页面从不询问环境变量名;键入的密钥经 `credentials.set` 以**只写**方式存入 profile 的引用之下,profile 没有引用时便派生 `<ROUTE>_API_KEY`,pi-ai profile 会把这次派生记录为 `apiKeyEnv`,因此 `settings.yaml` 从不携带密钥值。收起的「自定义设置」折叠区承载精选的额外字段——两个家族都有 `baseURL`(deepseek 的占位符显示公共端点),另加 `reasoningEffort`(deepseek)或 `reasoning`(pi-ai);其余每个 profile 字段仍归 `settings.yaml` 所有。只有当某行仅由用户层承载时它才可删除(删除会还原组合 base)。
|
||||
|
||||
每一次编辑都以 `settings.mutate` 的路径 op 落到已存分节上——每个变更字段一条 set、每个清空字段一条 unset、删除整行则是单独一条 unset。页面自始至终只持有**脱敏后**的 descriptor,因此它点名自己看得见的字段,而不是重建分节:一个它从未收到过的已存字面机密不会被任何 op 提及,也就得以留存。每次写入都携带该卡片打开时的 `revision`,因此来自另一个标签页或对 `settings.yaml` 的外部编辑所产生的并发写入会以 `settings-conflict` 被拒绝,卡片会请用户重新打开,而不是把自己过期的快照重放上去。
|
||||
首次使用浮层从同一个联接快照得出 `deepseek-official` 的就绪状态。它通过 `llm-deepseek` 的可配置提供方声明识别官方适配器,因此不会把同一提供方 ID 下没有相应声明的存活路由视为可通过配置修复。若 `apiKey` 字面量对应的 secret 槽位标记为已设置,或凭据引用已配置,浮层就不再显示,其中包括来自启动环境且只读的凭据。只有适配器已挂载、引用可写但尚未配置时,浮层才显示一个操作按钮,用于打开「设置」的 Models 分区;密钥输入和 `credentials.set` 仅由该分区已有的设置卡片负责,浮层绝不持有 secret。适配器缺失、路由未激活、联接失败、部署只读、设置能力不可用或凭据能力不可用时均跳过,以免首次使用引导阻塞产品的其他部分;Models 页仍是诊断界面。
|
||||
|
||||
每一次编辑都以 `settings.mutate` 的路径 op 落到已存分节上——每个变更字段一条 set、每个清空字段一条 unset、删除整行则是单独一条 unset。页面自始至终只持有**脱敏后**的 descriptor,因此它点名自己看得见的字段,而不是重建分节:一个它从未收到过的已存字面机密不会被任何 op 提及,也就得以留存。每次写入都携带该卡片打开时的 `revision`,因此来自另一个标签页或对 `settings.yaml` 的外部编辑所产生的并发写入会以 `settings-conflict` 被拒绝,卡片会请用户重新打开,而不是把自己的陈旧快照重放上去。页面加载完成后会在推送的失效事件(`settings/changed`、`credentials/changed`、`models/changed` 与 `connection/reset`)上重拉,因此外部的 `settings.yaml` 编辑、第二个标签页或 settings 新生的路由都无需轮询即可收敛。
|
||||
|
||||
## 模型体验
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-client-ui-models",
|
||||
"description": "Models feature plugin: registers its Settings section (nav entry, empty content column; model management lands later)",
|
||||
"description": "Models settings and official-DeepSeek first-run routing over one live provider/settings/credential join",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
@@ -39,6 +39,7 @@
|
||||
"@deepseek-ai/dsh-client-connection": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-runtime": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-schema-form": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-web-react": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
@@ -50,6 +51,7 @@
|
||||
"@deepseek-ai/dsh-client-locale": "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-settings": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-web-react": "workspace:^",
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
.dialog {
|
||||
width: min(420px, 100%);
|
||||
}
|
||||
|
||||
.primary {
|
||||
width: 100%;
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* Official-DeepSeek first-run dialog. Readiness comes from the same
|
||||
* provider/settings/credential join as the Models page; the prompt only
|
||||
* routes the user to that page's single credential editor.
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { Button, Modal } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-web-react'
|
||||
import type { ModelsSettingsState, ModelsSettingsStore } from './store.ts'
|
||||
import { deepSeekReadiness } from './store.ts'
|
||||
import type { en } from './locales.ts'
|
||||
import styles from './DeepSeekOnboardingDialog.module.css'
|
||||
|
||||
/** Injected dependencies of {@link DeepSeekOnboardingDialog}. */
|
||||
export interface DeepSeekOnboardingInjected {
|
||||
/** Shared Models-page join controller. */
|
||||
controller: ModelsSettingsStore
|
||||
/** Subscription hook bound to the shared join snapshot. */
|
||||
useSnapshot: SnapshotSelectorHook<ModelsSettingsState>
|
||||
/** Feature copy. */
|
||||
t: (key: keyof typeof en) => string
|
||||
}
|
||||
|
||||
/** Slot owner props plus the feature's injected dependencies. */
|
||||
export type DeepSeekOnboardingDialogProps =
|
||||
PropsRuntime<'settings.onboarding'> & DeepSeekOnboardingInjected
|
||||
|
||||
/* v8 ignore next 3 -- closed-union defaults only defend future source widening */
|
||||
function assertNever(_value: never): never {
|
||||
throw new Error('unexpected DeepSeek onboarding state')
|
||||
}
|
||||
|
||||
/**
|
||||
* Prompt a first-run user to open Models while the official adapter exists
|
||||
* and its effective credential is not configured.
|
||||
* @param props - settings-shell owner state and Models feature dependencies.
|
||||
* @returns the controlled modal or null when onboarding needs no intervention.
|
||||
*/
|
||||
export function DeepSeekOnboardingDialog(props: DeepSeekOnboardingDialogProps): ReactNode {
|
||||
const { active, openSection, controller, useSnapshot, t } = props
|
||||
const state = useSnapshot(snapshot => snapshot)
|
||||
const readiness = deepSeekReadiness(state)
|
||||
const [dismissed, setDismissed] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (active && !dismissed && state.status === 'idle') void controller.load()
|
||||
}, [active, controller, dismissed, state.status])
|
||||
|
||||
const close = (): void => {
|
||||
setDismissed(true)
|
||||
}
|
||||
|
||||
const openModels = (): void => {
|
||||
close()
|
||||
openSection('models')
|
||||
}
|
||||
|
||||
if (!active || dismissed) return null
|
||||
|
||||
switch (readiness.kind) {
|
||||
case 'loading':
|
||||
case 'adapter-absent':
|
||||
case 'configured':
|
||||
case 'unavailable':
|
||||
return null
|
||||
case 'credential-missing':
|
||||
break
|
||||
/* v8 ignore next -- every current readiness variant is handled above */
|
||||
default:
|
||||
return assertNever(readiness)
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open
|
||||
onClose={close}
|
||||
title={t('onboardingTitle')}
|
||||
closeLabel={t('onboardingLater')}
|
||||
description={t('onboardingDescription')}
|
||||
className={styles['dialog'] as string}
|
||||
footer={(
|
||||
<Button
|
||||
variant="primary"
|
||||
className={styles['primary']}
|
||||
autoFocus
|
||||
onClick={openModels}
|
||||
>
|
||||
{t('onboardingGoToSettings')}
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -10,7 +10,7 @@
|
||||
|
||||
import { useState } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import type { IApiClient, SettingsNamespaceView } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { IApiClient } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-web-react'
|
||||
import { messageOf } from './store.ts'
|
||||
import type { ModelsSettingsState, ModelsSettingsStore, ProviderRow } from './store.ts'
|
||||
@@ -81,14 +81,12 @@ export async function removeProviderProfile(
|
||||
* the credential configured and no literal `apiKey` is stored, so the page
|
||||
* opens the setup card instead of showing a row.
|
||||
* @param row - the joined provider row.
|
||||
* @param namespace - the owning namespace view.
|
||||
* @returns whether to render the setup card.
|
||||
*/
|
||||
export function needsSetup(row: ProviderRow, namespace: SettingsNamespaceView): boolean {
|
||||
export function needsSetup(row: ProviderRow): boolean {
|
||||
if (row.entry.settingsPath.length > 0) return false
|
||||
if (row.credential?.configured === true) return false
|
||||
return !namespace.secrets.some(secret =>
|
||||
secret.set && secret.path.length === 1 && secret.path[0] === 'apiKey')
|
||||
return !row.literalApiKeyConfigured
|
||||
}
|
||||
|
||||
function targetOf(row: ProviderRow): EditorTarget {
|
||||
@@ -153,7 +151,7 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode {
|
||||
const namespace = state.namespaces.get(target.settingsNs)
|
||||
/* v8 ignore next -- the join marks a row configured only when its namespace resolved */
|
||||
if (namespace === undefined) return null
|
||||
if (needsSetup(row, namespace)) {
|
||||
if (needsSetup(row)) {
|
||||
// First-run posture: the provider exists but has no key — the
|
||||
// setup card IS its presence on the page.
|
||||
return (
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
/**
|
||||
* Models settings section plugin, browser half. Registers the `models` nav
|
||||
* entry into the shell-declared `settings.section` list slot and mounts the
|
||||
* provider configuration page: the configurable-provider directory joined
|
||||
* with settings namespaces and credential states, edited through the
|
||||
* schema-driven form. Export discipline: packages/client/AGENTS.md.
|
||||
* Models settings plugin, browser half. Registers the `models` nav entry and
|
||||
* official-DeepSeek first-run overlay into shell-declared slots. Both consume
|
||||
* one provider/settings/credential join; the overlay routes missing-key users
|
||||
* to the full page's single credential editor. Export discipline:
|
||||
* packages/client/AGENTS.md.
|
||||
*/
|
||||
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { deferRegistration } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
@@ -15,6 +15,8 @@ import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
|
||||
import type {} from '@deepseek-ai/dsh-client-locale/client'
|
||||
import { ModelsSection } from './ModelsSection.tsx'
|
||||
import type { ModelsSectionInjected } from './ModelsSection.tsx'
|
||||
import { DeepSeekOnboardingDialog } from './DeepSeekOnboardingDialog.tsx'
|
||||
import type { DeepSeekOnboardingInjected } from './DeepSeekOnboardingDialog.tsx'
|
||||
import { ModelsSettingsStore } from './store.ts'
|
||||
import { en, zh } from './locales.ts'
|
||||
|
||||
@@ -63,6 +65,11 @@ export function apply(ctx: ClientContext): void {
|
||||
api: connection.api,
|
||||
t,
|
||||
})
|
||||
const onboardingInjected = (): DeepSeekOnboardingInjected => ({
|
||||
controller,
|
||||
useSnapshot,
|
||||
t,
|
||||
})
|
||||
|
||||
// Pushed invalidations converge every open surface without polling: any
|
||||
// settings/credentials/topology change refetches once the page loaded.
|
||||
@@ -78,7 +85,7 @@ export function apply(ctx: ClientContext): void {
|
||||
}, 'ui-models: pushed invalidations')
|
||||
|
||||
ctx.effect(() => {
|
||||
const deferred = deferRegistration(ctx.slots, 'settings.section', ModelsSection, () =>
|
||||
const section = deferRegistration(ctx.slots, 'settings.section', ModelsSection, () =>
|
||||
ctx.slots.register({
|
||||
name: 'settings.section',
|
||||
id: 'models',
|
||||
@@ -86,12 +93,27 @@ export function apply(ctx: ClientContext): void {
|
||||
label: t('nav'),
|
||||
inject: injected,
|
||||
}, ModelsSection))
|
||||
const onboarding = deferRegistration(
|
||||
ctx.slots,
|
||||
'settings.onboarding',
|
||||
DeepSeekOnboardingDialog,
|
||||
() => ctx.slots.register({
|
||||
name: 'settings.onboarding',
|
||||
id: 'deepseek-official',
|
||||
order: 0,
|
||||
inject: onboardingInjected,
|
||||
}, DeepSeekOnboardingDialog),
|
||||
)
|
||||
// Nav labels are registrant-localized: refresh on locale change so the
|
||||
// ledger carries fresh text (the version bump re-renders the shell).
|
||||
const offLocale = ctx.on('locale/change', () => { deferred.refresh() })
|
||||
const offLocale = ctx.on('locale/change', () => {
|
||||
section.refresh()
|
||||
onboarding.refresh()
|
||||
})
|
||||
return () => {
|
||||
offLocale()
|
||||
deferred.dispose()
|
||||
section.dispose()
|
||||
onboarding.dispose()
|
||||
}
|
||||
}, 'ui-models: settings section registration')
|
||||
}, 'ui-models: settings registrations')
|
||||
}
|
||||
@@ -28,6 +28,10 @@ export const en = {
|
||||
effort: 'Reasoning effort',
|
||||
effortInherit: 'Default',
|
||||
advancedHint: 'Other fields live in settings.yaml; edit that section directly.',
|
||||
onboardingTitle: 'Add an API key to get started',
|
||||
onboardingDescription: 'Configure the official DeepSeek provider to start building.',
|
||||
onboardingGoToSettings: 'Go to settings',
|
||||
onboardingLater: 'Configure later',
|
||||
}
|
||||
|
||||
/** Chinese strings (same keys as {@link en}). */
|
||||
@@ -58,4 +62,8 @@ export const zh: typeof en = {
|
||||
effort: '推理强度',
|
||||
effortInherit: '默认',
|
||||
advancedHint: '其余字段在 settings.yaml 中,请直接编辑对应段。',
|
||||
onboardingTitle: '添加一个 API Key 开始使用',
|
||||
onboardingDescription: '配置 DeepSeek 官方模型,即可开始使用。',
|
||||
onboardingGoToSettings: '前往配置',
|
||||
onboardingLater: '稍后配置',
|
||||
}
|
||||
@@ -25,6 +25,8 @@ export interface ProviderRow {
|
||||
apiKeyEnv: string | undefined
|
||||
/** Credential state for {@link apiKeyEnv}, once described. */
|
||||
credential: CredentialView | undefined
|
||||
/** Whether the redacted secret sidecar reports an effective literal `apiKey`. */
|
||||
literalApiKeyConfigured: boolean
|
||||
}
|
||||
|
||||
/** Page snapshot. */
|
||||
@@ -32,6 +34,8 @@ export interface ModelsSettingsState {
|
||||
status: 'idle' | 'loading' | 'ready' | 'error'
|
||||
/** Whole-load failure text; row-level write failures stay in the editor. */
|
||||
error: string | null
|
||||
/** Credential enrichment failure; provider/settings rows remain usable. */
|
||||
credentialError: string | null
|
||||
/** Whether the settings provider accepts writes. */
|
||||
writable: boolean
|
||||
/** Every configurable provider joined with its configured/credential state. */
|
||||
@@ -71,11 +75,24 @@ function apiKeyEnvOf(namespace: SettingsNamespaceView | undefined, path: readonl
|
||||
return typeof ref === 'string' && ref.length > 0 ? ref : undefined
|
||||
}
|
||||
|
||||
/** Whether one namespace's redacted sidecar reports a set literal API key. */
|
||||
function literalApiKeyConfigured(
|
||||
namespace: SettingsNamespaceView | undefined,
|
||||
path: readonly string[],
|
||||
): boolean {
|
||||
if (namespace === undefined) return false
|
||||
const secretPath = [...path, 'apiKey']
|
||||
return namespace.secrets.some(secret =>
|
||||
secret.set
|
||||
&& secret.path.length === secretPath.length
|
||||
&& secret.path.every((key, index) => key === secretPath[index]))
|
||||
}
|
||||
|
||||
/** The models settings page controller (one per settings surface). */
|
||||
export class ModelsSettingsStore {
|
||||
/** The snapshot the section renders from (uSES-safe store). */
|
||||
readonly store: SnapshotStore<ModelsSettingsState> = createSnapshotStore<ModelsSettingsState>({
|
||||
status: 'idle', error: null, writable: false, rows: [], namespaces: new Map(),
|
||||
status: 'idle', error: null, credentialError: null, writable: false, rows: [], namespaces: new Map(),
|
||||
})
|
||||
|
||||
/** Latest load wins; an older response never overwrites a newer one. */
|
||||
@@ -143,22 +160,29 @@ export class ModelsSettingsStore {
|
||||
removable,
|
||||
apiKeyEnv: apiKeyEnvOf(namespace, entry.settingsPath),
|
||||
credential: undefined,
|
||||
literalApiKeyConfigured: literalApiKeyConfigured(namespace, entry.settingsPath),
|
||||
}
|
||||
})
|
||||
const refs = [...new Set(rows.flatMap(row => row.apiKeyEnv === undefined ? [] : [row.apiKeyEnv]))]
|
||||
let credentials: Record<string, CredentialView> = {}
|
||||
let credentialError: string | null = null
|
||||
if (refs.length > 0) {
|
||||
// Credential state is an enrichment: rows render without it, so neither
|
||||
// a business rejection nor a transport failure (disconnect, a request
|
||||
// the host refuses) may fail the load — an escaping rejection would
|
||||
// leave the page stuck in `loading` with no error shown.
|
||||
const response = await this.api.credentials.describe({ refs }).catch(() => undefined)
|
||||
if (response?.result.ok === true) credentials = response.result.value.credentials
|
||||
try {
|
||||
const response = await this.api.credentials.describe({ refs })
|
||||
// Credential state is an enrichment for the Models page: neither a
|
||||
// business rejection nor a transport failure fails the load. The
|
||||
// onboarding projection below retains the failure distinction.
|
||||
if (response.result.ok) credentials = response.result.value.credentials
|
||||
else credentialError = response.result.error.message
|
||||
} catch (error) {
|
||||
credentialError = messageOf(error)
|
||||
}
|
||||
}
|
||||
if (generation !== this.generation) return
|
||||
this.store.update((s) => {
|
||||
s.status = 'ready'
|
||||
s.error = null
|
||||
s.credentialError = credentialError
|
||||
s.writable = writable
|
||||
s.rows = rows.map(row => ({
|
||||
...row,
|
||||
@@ -170,3 +194,92 @@ export class ModelsSettingsStore {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/** DeepSeek onboarding readiness derived only from the shared Models join. */
|
||||
export type DeepSeekReadiness =
|
||||
| { kind: 'loading' }
|
||||
| { kind: 'adapter-absent' }
|
||||
| { kind: 'configured' }
|
||||
| { kind: 'credential-missing' }
|
||||
| {
|
||||
kind: 'unavailable'
|
||||
reason:
|
||||
| 'load-failed'
|
||||
| 'provider-inactive'
|
||||
| 'settings-unavailable'
|
||||
| 'credential-ref-unavailable'
|
||||
| 'credentials-unavailable'
|
||||
| 'settings-read-only'
|
||||
| 'credential-read-only'
|
||||
}
|
||||
|
||||
/**
|
||||
* Project official-DeepSeek readiness from the provider/settings/credential
|
||||
* join used by the Models page. A missing official configurable-provider
|
||||
* declaration means the adapter is not repairable by navigating to Models.
|
||||
* @param state - current shared Models join snapshot.
|
||||
* @returns the onboarding state without reading a parallel fact source.
|
||||
*/
|
||||
export function deepSeekReadiness(state: ModelsSettingsState): DeepSeekReadiness {
|
||||
if ((state.status === 'idle' || state.status === 'loading') && state.rows.length === 0) {
|
||||
return { kind: 'loading' }
|
||||
}
|
||||
if (state.status === 'error') {
|
||||
return {
|
||||
kind: 'unavailable',
|
||||
reason: 'load-failed',
|
||||
}
|
||||
}
|
||||
const row = state.rows.find(candidate =>
|
||||
candidate.entry.provider === 'deepseek-official'
|
||||
&& candidate.entry.settingsNs === 'llm-deepseek'
|
||||
&& candidate.entry.settingsPath.length === 0)
|
||||
if (row === undefined) return { kind: 'adapter-absent' }
|
||||
if (!row.entry.active) {
|
||||
return {
|
||||
kind: 'unavailable',
|
||||
reason: 'provider-inactive',
|
||||
}
|
||||
}
|
||||
if (!row.configured) {
|
||||
return {
|
||||
kind: 'unavailable',
|
||||
reason: 'settings-unavailable',
|
||||
}
|
||||
}
|
||||
if (row.literalApiKeyConfigured) return { kind: 'configured' }
|
||||
if (row.apiKeyEnv === undefined) {
|
||||
return {
|
||||
kind: 'unavailable',
|
||||
reason: 'credential-ref-unavailable',
|
||||
}
|
||||
}
|
||||
if (state.credentialError !== null) {
|
||||
return {
|
||||
kind: 'unavailable',
|
||||
reason: 'credentials-unavailable',
|
||||
}
|
||||
}
|
||||
if (row.credential === undefined) {
|
||||
return {
|
||||
kind: 'unavailable',
|
||||
reason: 'credentials-unavailable',
|
||||
}
|
||||
}
|
||||
if (row.credential.configured) {
|
||||
return { kind: 'configured' }
|
||||
}
|
||||
if (!state.writable) {
|
||||
return {
|
||||
kind: 'unavailable',
|
||||
reason: 'settings-read-only',
|
||||
}
|
||||
}
|
||||
if (!row.credential.writable) {
|
||||
return {
|
||||
kind: 'unavailable',
|
||||
reason: 'credential-read-only',
|
||||
}
|
||||
}
|
||||
return { kind: 'credential-missing' }
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
/** Models section registration: declaration-aware deferral, locale re-registration, and HMR recovery. */
|
||||
import { Context } from 'cordis'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import { apply, inject, refreshIfLoaded } from '@deepseek-ai/dsh-client-ui-models/client'
|
||||
import { ModelsSection } from '../src/client/ModelsSection.tsx'
|
||||
import { DeepSeekOnboardingDialog } from '../src/client/DeepSeekOnboardingDialog.tsx'
|
||||
|
||||
async function bench() {
|
||||
const ctx = new Context()
|
||||
@@ -19,7 +20,13 @@ async function bench() {
|
||||
|
||||
function declare(slots: SlotsService): () => void {
|
||||
return slots.register(
|
||||
{ name: 'root', children: { 'settings.section': { kind: 'list', scope: 'root' } } } as never,
|
||||
{
|
||||
name: 'root',
|
||||
children: {
|
||||
'settings.section': { kind: 'list', scope: 'root' },
|
||||
'settings.onboarding': { kind: 'list', scope: 'root' },
|
||||
},
|
||||
} as never,
|
||||
() => null,
|
||||
)
|
||||
}
|
||||
@@ -41,13 +48,18 @@ describe('ui-models apply', () => {
|
||||
expect(typeof injected.controller.load).toBe('function')
|
||||
expect(typeof injected.useSnapshot).toBe('function')
|
||||
expect(injected.api).toBeDefined()
|
||||
const onboarding = before.slots.entries('settings.onboarding')[0]!
|
||||
expect(onboarding.component).toBe(DeepSeekOnboardingDialog)
|
||||
expect(onboarding.options).toMatchObject({ id: 'deepseek-official', order: 0 })
|
||||
|
||||
const after = await bench()
|
||||
await after.ctx.plugin({ inject: [...inject], apply }).await()
|
||||
expect(after.slots.entries('settings.section')).toHaveLength(0)
|
||||
expect(after.slots.entries('settings.onboarding')).toHaveLength(0)
|
||||
declare(after.slots)
|
||||
await Promise.resolve()
|
||||
expect(after.slots.entries('settings.section')[0]!.component).toBe(ModelsSection)
|
||||
expect(after.slots.entries('settings.onboarding')[0]!.component).toBe(DeepSeekOnboardingDialog)
|
||||
// The self-inflicted ledger notifications hit the duplicate guard.
|
||||
expect(after.slots.entries('settings.section')).toHaveLength(1)
|
||||
})
|
||||
@@ -79,9 +91,11 @@ describe('ui-models apply', () => {
|
||||
// disposer variable goes stale.
|
||||
redeclare()
|
||||
expect(b.slots.entries('settings.section')).toHaveLength(0)
|
||||
expect(b.slots.entries('settings.onboarding')).toHaveLength(0)
|
||||
declare(b.slots)
|
||||
await Promise.resolve()
|
||||
expect(b.slots.entries('settings.section')[0]!.component).toBe(ModelsSection)
|
||||
expect(b.slots.entries('settings.onboarding')[0]!.component).toBe(DeepSeekOnboardingDialog)
|
||||
// The locale path also recovers through the same ledger re-check.
|
||||
b.locale.setLocale('en')
|
||||
expect(b.slots.entries('settings.section')[0]!.options.label).toBe('Models')
|
||||
@@ -96,6 +110,7 @@ describe('ui-models apply', () => {
|
||||
expect(b.locale.bind('settings.models')('nav')).toBe('模型')
|
||||
await fiber.dispose()
|
||||
expect(b.slots.entries('settings.section')).toHaveLength(0)
|
||||
expect(b.slots.entries('settings.onboarding')).toHaveLength(0)
|
||||
// The (ns, locale) seats are free again — the dictionary disposers ran.
|
||||
expect(() => b.locale.register('settings.models', 'zh', {})).not.toThrow()
|
||||
expect(() => b.locale.register('settings.models', 'en', {})).not.toThrow()
|
||||
@@ -129,4 +144,18 @@ describe('pushed invalidations', () => {
|
||||
refreshIfLoaded(idle as unknown as import('../src/client/store.ts').ModelsSettingsStore)
|
||||
expect(loads).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('routes pushed credential invalidation into the shared onboarding join', async () => {
|
||||
const b = await bench()
|
||||
declare(b.slots)
|
||||
await b.ctx.plugin({ inject: [...inject], apply }).await()
|
||||
const injected = (
|
||||
b.slots.entries('settings.onboarding')[0]!.inject as unknown as
|
||||
() => import('../src/client/DeepSeekOnboardingDialog.tsx').DeepSeekOnboardingInjected
|
||||
)()
|
||||
injected.controller.store.update((state) => { state.status = 'ready' })
|
||||
const load = vi.spyOn(injected.controller, 'load').mockResolvedValue()
|
||||
b.ctx.emit('credentials/changed', 'DEEPSEEK_API_KEY')
|
||||
expect(load).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -6,7 +6,7 @@ import Schema from 'schemastery'
|
||||
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
|
||||
import type { RpcResponse, SettingsNamespaceView } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import { ModelsSection, needsSetup, removeProviderProfile } from '../src/client/ModelsSection.tsx'
|
||||
import type { ModelsSectionInjected } from '../src/client/ModelsSection.tsx'
|
||||
import type { ModelsSectionInjected, ModelsSectionProps } from '../src/client/ModelsSection.tsx'
|
||||
import { pathOps } from '../src/client/ProviderEditor.tsx'
|
||||
import { deriveKeyRef, ModelsSettingsStore } from '../src/client/store.ts'
|
||||
import type { ProviderRow } from '../src/client/store.ts'
|
||||
@@ -141,6 +141,12 @@ async function mountSection(overrides: Parameters<typeof scriptedFace>[0] = {})
|
||||
}
|
||||
|
||||
describe('ModelsSection', () => {
|
||||
it('renders nothing before the slot injects its dependencies', () => {
|
||||
const uninjected = {} as ModelsSectionProps
|
||||
render(<ModelsSection {...uninjected} />)
|
||||
expect(document.body.textContent).toBe('')
|
||||
})
|
||||
|
||||
it('renders the unkeyed whole-section provider as an open setup card beside the rows', async () => {
|
||||
await mountSection()
|
||||
// DeepSeek has no configured credential and no stored apiKey → setup card.
|
||||
@@ -172,17 +178,24 @@ describe('ModelsSection', () => {
|
||||
expect(screen.queryByLabelText(en.keyInput)).toBeNull()
|
||||
})
|
||||
|
||||
it('decides setup need from the credential state and the stored apiKey slot', () => {
|
||||
const namespace = wireNamespaces()[0] as SettingsNamespaceView
|
||||
it('decides setup need from the joined credential state and literal-key sidecar', () => {
|
||||
const entry = { provider: 'p', displayName: 'p', settingsNs: 'llm-deepseek', settingsPath: [], active: true }
|
||||
const row = (credential: ProviderRow['credential']): ProviderRow =>
|
||||
({ entry, configured: true, removable: false, apiKeyEnv: 'X', credential })
|
||||
expect(needsSetup(row(undefined), namespace)).toBe(true)
|
||||
expect(needsSetup(row({ configured: true, writable: true }), namespace)).toBe(false)
|
||||
const stored: SettingsNamespaceView = { ...namespace, secrets: [{ path: ['apiKey'], set: true }] }
|
||||
expect(needsSetup(row(undefined), stored)).toBe(false)
|
||||
const row = (
|
||||
credential: ProviderRow['credential'],
|
||||
literalApiKeyConfigured = false,
|
||||
): ProviderRow => ({
|
||||
entry,
|
||||
configured: true,
|
||||
removable: false,
|
||||
apiKeyEnv: 'X',
|
||||
credential,
|
||||
literalApiKeyConfigured,
|
||||
})
|
||||
expect(needsSetup(row(undefined))).toBe(true)
|
||||
expect(needsSetup(row({ configured: true, writable: true }))).toBe(false)
|
||||
expect(needsSetup(row(undefined, true))).toBe(false)
|
||||
const nested = { ...row(undefined), entry: { ...entry, settingsPath: ['providers', 'x'] } }
|
||||
expect(needsSetup(nested, namespace)).toBe(false)
|
||||
expect(needsSetup(nested)).toBe(false)
|
||||
})
|
||||
|
||||
it('derives conventional credential references from route ids', () => {
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
// @vitest-environment jsdom
|
||||
/** First-run DeepSeek prompt behavior over the shared Models join. */
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { RpcResponse } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
|
||||
import { DeepSeekOnboardingDialog } from '../src/client/DeepSeekOnboardingDialog.tsx'
|
||||
import type { DeepSeekOnboardingDialogProps } from '../src/client/DeepSeekOnboardingDialog.tsx'
|
||||
import { ModelsSettingsStore } from '../src/client/store.ts'
|
||||
import { en } from '../src/client/locales.ts'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
let nextRpc = 0
|
||||
function ok<T>(value: T): RpcResponse<T> {
|
||||
return { rpcId: `onboarding-${nextRpc++}` as never, result: { ok: true, value } }
|
||||
}
|
||||
function fail<T>(message: string): RpcResponse<T> {
|
||||
return {
|
||||
rpcId: `onboarding-${nextRpc++}` as never,
|
||||
result: { ok: false, error: { code: 'internal', message, details: {} } },
|
||||
}
|
||||
}
|
||||
|
||||
function harness(options: {
|
||||
provider?: boolean
|
||||
providerActive?: boolean
|
||||
providerSettingsNs?: string
|
||||
settingsNamespace?: boolean
|
||||
apiKeyEnv?: string | null
|
||||
literal?: boolean
|
||||
configured?: () => boolean
|
||||
credential?: { source?: string; writable: boolean }
|
||||
describeFailure?: string
|
||||
settingsWritable?: boolean
|
||||
providersReject?: boolean
|
||||
} = {}) {
|
||||
let fileConfigured = false
|
||||
const configured = options.configured ?? (() => fileConfigured)
|
||||
const face = {
|
||||
llm: {
|
||||
providers: () => {
|
||||
if (options.providersReject === true) {
|
||||
return Promise.reject(new Error('provider transport unavailable'))
|
||||
}
|
||||
return Promise.resolve(ok({
|
||||
providers: options.provider === false
|
||||
? []
|
||||
: [{
|
||||
provider: 'deepseek-official',
|
||||
displayName: 'DeepSeek',
|
||||
settingsNs: options.providerSettingsNs ?? 'llm-deepseek',
|
||||
settingsPath: [],
|
||||
active: options.providerActive ?? true,
|
||||
}],
|
||||
}))
|
||||
},
|
||||
},
|
||||
settings: {
|
||||
describe: () => Promise.resolve(ok({
|
||||
writable: options.settingsWritable ?? true,
|
||||
namespaces: options.settingsNamespace === false
|
||||
? []
|
||||
: [{
|
||||
ns: 'llm-deepseek',
|
||||
schema: {},
|
||||
value: options.apiKeyEnv === null
|
||||
? {}
|
||||
: { apiKeyEnv: options.apiKeyEnv ?? 'DEEPSEEK_API_KEY' },
|
||||
applies: 'live' as const,
|
||||
secrets: [{ path: ['apiKey'], set: options.literal === true }],
|
||||
revision: 0,
|
||||
}],
|
||||
})),
|
||||
},
|
||||
credentials: {
|
||||
describe: () => options.describeFailure === undefined
|
||||
? Promise.resolve(ok({
|
||||
credentials: {
|
||||
DEEPSEEK_API_KEY: {
|
||||
configured: configured(),
|
||||
...configured() && options.credential?.source !== undefined
|
||||
? { source: options.credential.source }
|
||||
: {},
|
||||
writable: options.credential?.writable ?? true,
|
||||
},
|
||||
},
|
||||
}))
|
||||
: Promise.resolve(fail(options.describeFailure)),
|
||||
},
|
||||
}
|
||||
const controller = new ModelsSettingsStore(face as never)
|
||||
const openSection = vi.fn()
|
||||
const unusedHook = (() => { throw new Error('unused standard hook') }) as never
|
||||
const props: DeepSeekOnboardingDialogProps = {
|
||||
active: true,
|
||||
openSection,
|
||||
useSessions: unusedHook,
|
||||
useWorkspaces: unusedHook,
|
||||
controller,
|
||||
useSnapshot: bindSnapshotSelector(controller.store),
|
||||
t: key => en[key],
|
||||
}
|
||||
return { controller, openSection, props, configure: () => { fileConfigured = true } }
|
||||
}
|
||||
|
||||
describe('DeepSeekOnboardingDialog', () => {
|
||||
it('loads on first entry and presents one accessible route to Models', async () => {
|
||||
const h = harness()
|
||||
render(<DeepSeekOnboardingDialog {...h.props} />)
|
||||
expect(await screen.findByRole('dialog', { name: en.onboardingTitle })).toBeTruthy()
|
||||
expect(screen.getByText(en.onboardingDescription)).toBeTruthy()
|
||||
const action = screen.getByRole('button', { name: en.onboardingGoToSettings })
|
||||
expect(action).toBeTruthy()
|
||||
expect(document.activeElement).toBe(action)
|
||||
expect(screen.queryByRole('textbox')).toBeNull()
|
||||
})
|
||||
|
||||
it('opens the Models section and dismisses the prompt', async () => {
|
||||
const h = harness()
|
||||
render(<DeepSeekOnboardingDialog {...h.props} />)
|
||||
await screen.findByRole('dialog')
|
||||
fireEvent.click(screen.getByRole('button', { name: en.onboardingGoToSettings }))
|
||||
expect(h.openSection).toHaveBeenCalledWith('models')
|
||||
expect(screen.queryByRole('dialog', { name: en.onboardingTitle })).toBeNull()
|
||||
})
|
||||
|
||||
it('allows configure-later dismissal without opening settings', async () => {
|
||||
const h = harness()
|
||||
render(<DeepSeekOnboardingDialog {...h.props} />)
|
||||
await screen.findByRole('dialog')
|
||||
fireEvent.click(screen.getByRole('button', { name: en.onboardingLater }))
|
||||
expect(screen.queryByRole('dialog')).toBeNull()
|
||||
expect(h.openSection).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not block the product when DeepSeek setup is unavailable', async () => {
|
||||
for (const h of [
|
||||
harness({ describeFailure: 'credentials service is absent' }),
|
||||
harness({ credential: { writable: false } }),
|
||||
harness({ settingsWritable: false }),
|
||||
harness({ providersReject: true }),
|
||||
harness({ providerActive: false }),
|
||||
harness({ settingsNamespace: false }),
|
||||
harness({ apiKeyEnv: null }),
|
||||
]) {
|
||||
const view = render(<DeepSeekOnboardingDialog {...h.props} />)
|
||||
await act(async () => { await h.controller.load() })
|
||||
expect(screen.queryByRole('dialog')).toBeNull()
|
||||
expect(h.openSection).not.toHaveBeenCalled()
|
||||
view.unmount()
|
||||
}
|
||||
})
|
||||
|
||||
it('skips an absent adapter and already-configured literal or environment credentials', async () => {
|
||||
for (const h of [
|
||||
harness({ provider: false }),
|
||||
harness({ providerSettingsNs: '' }),
|
||||
harness({ literal: true, describeFailure: 'credential seam absent' }),
|
||||
harness({ configured: () => true, credential: { source: 'env', writable: false } }),
|
||||
]) {
|
||||
const view = render(<DeepSeekOnboardingDialog {...h.props} />)
|
||||
await act(async () => { await h.controller.load() })
|
||||
expect(screen.queryByRole('dialog')).toBeNull()
|
||||
view.unmount()
|
||||
}
|
||||
})
|
||||
|
||||
it('closes when an external credential invalidation refreshes the shared join', async () => {
|
||||
const h = harness()
|
||||
render(<DeepSeekOnboardingDialog {...h.props} />)
|
||||
await screen.findByRole('dialog')
|
||||
h.configure()
|
||||
await act(async () => { await h.controller.load() })
|
||||
await waitFor(() => { expect(screen.queryByRole('dialog')).toBeNull() })
|
||||
})
|
||||
|
||||
it('stays hidden while the onboarding owner is inactive', async () => {
|
||||
const h = harness()
|
||||
const view = render(<DeepSeekOnboardingDialog {...h.props} active={false} />)
|
||||
await act(async () => { await h.controller.load() })
|
||||
expect(screen.queryByRole('dialog')).toBeNull()
|
||||
view.rerender(<DeepSeekOnboardingDialog {...h.props} active />)
|
||||
expect(await screen.findByRole('dialog', { name: en.onboardingTitle })).toBeTruthy()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,105 @@
|
||||
/** Pure official-DeepSeek readiness projection over the shared Models join. */
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { CredentialView } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { ModelsSettingsState, ProviderRow } from '../src/client/store.ts'
|
||||
import { deepSeekReadiness } from '../src/client/store.ts'
|
||||
|
||||
const missingCredential: CredentialView = { configured: false, writable: true }
|
||||
|
||||
function row(overrides: Partial<ProviderRow> = {}): ProviderRow {
|
||||
return {
|
||||
entry: {
|
||||
provider: 'deepseek-official',
|
||||
displayName: 'DeepSeek',
|
||||
settingsNs: 'llm-deepseek',
|
||||
settingsPath: [],
|
||||
active: true,
|
||||
},
|
||||
configured: true,
|
||||
removable: false,
|
||||
apiKeyEnv: 'DEEPSEEK_API_KEY',
|
||||
credential: missingCredential,
|
||||
literalApiKeyConfigured: false,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function state(overrides: Partial<ModelsSettingsState> = {}): ModelsSettingsState {
|
||||
return {
|
||||
status: 'ready',
|
||||
error: null,
|
||||
credentialError: null,
|
||||
writable: true,
|
||||
rows: [row()],
|
||||
namespaces: new Map(),
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('deepSeekReadiness', () => {
|
||||
it('waits for the first join and skips onboarding when the adapter directory entry is absent', () => {
|
||||
expect(deepSeekReadiness(state({ status: 'idle', rows: [] }))).toEqual({ kind: 'loading' })
|
||||
expect(deepSeekReadiness(state({ status: 'loading', rows: [] }))).toEqual({ kind: 'loading' })
|
||||
expect(deepSeekReadiness(state({ rows: [] }))).toEqual({ kind: 'adapter-absent' })
|
||||
expect(deepSeekReadiness(state({
|
||||
rows: [row({
|
||||
entry: {
|
||||
...row().entry,
|
||||
settingsNs: '',
|
||||
},
|
||||
})],
|
||||
}))).toEqual({ kind: 'adapter-absent' })
|
||||
})
|
||||
|
||||
it('reports a missing writable effective credential', () => {
|
||||
expect(deepSeekReadiness(state())).toEqual({ kind: 'credential-missing' })
|
||||
})
|
||||
|
||||
it('accepts file and process-environment credentials without prompting', () => {
|
||||
expect(deepSeekReadiness(state({
|
||||
rows: [row({ credential: { configured: true, source: 'file', writable: true } })],
|
||||
}))).toEqual({ kind: 'configured' })
|
||||
expect(deepSeekReadiness(state({
|
||||
rows: [row({ credential: { configured: true, source: 'env', writable: false } })],
|
||||
}))).toEqual({ kind: 'configured' })
|
||||
})
|
||||
|
||||
it('accepts the redacted literal-key sidecar before judging the credential domain', () => {
|
||||
expect(deepSeekReadiness(state({
|
||||
credentialError: 'credentials service absent',
|
||||
rows: [row({ literalApiKeyConfigured: true, credential: undefined })],
|
||||
}))).toEqual({ kind: 'configured' })
|
||||
})
|
||||
|
||||
it('turns missing capabilities and inconsistent descriptors into diagnostics', () => {
|
||||
expect(deepSeekReadiness(state({ status: 'error', error: 'settings down' }))).toEqual({
|
||||
kind: 'unavailable',
|
||||
reason: 'load-failed',
|
||||
})
|
||||
expect(deepSeekReadiness(state({
|
||||
rows: [row({ entry: { ...row().entry, active: false } })],
|
||||
}))).toEqual({ kind: 'unavailable', reason: 'provider-inactive' })
|
||||
expect(deepSeekReadiness(state({
|
||||
rows: [row({ configured: false })],
|
||||
}))).toEqual({ kind: 'unavailable', reason: 'settings-unavailable' })
|
||||
expect(deepSeekReadiness(state({
|
||||
rows: [row({ apiKeyEnv: undefined })],
|
||||
}))).toEqual({ kind: 'unavailable', reason: 'credential-ref-unavailable' })
|
||||
expect(deepSeekReadiness(state({
|
||||
credentialError: 'credentials service is absent',
|
||||
}))).toEqual({
|
||||
kind: 'unavailable',
|
||||
reason: 'credentials-unavailable',
|
||||
})
|
||||
expect(deepSeekReadiness(state({
|
||||
rows: [row({ credential: undefined })],
|
||||
}))).toEqual({ kind: 'unavailable', reason: 'credentials-unavailable' })
|
||||
expect(deepSeekReadiness(state({
|
||||
rows: [row({ credential: { configured: false, writable: false } })],
|
||||
}))).toEqual({ kind: 'unavailable', reason: 'credential-read-only' })
|
||||
expect(deepSeekReadiness(state({ writable: false }))).toEqual({
|
||||
kind: 'unavailable',
|
||||
reason: 'settings-read-only',
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -77,6 +77,7 @@ describe('ModelsSettingsStore', () => {
|
||||
const state = store.store.getSnapshot()
|
||||
expect(state.status).toBe('ready')
|
||||
expect(state.writable).toBe(true)
|
||||
expect(state.credentialError).toBeNull()
|
||||
expect(seenRefs).toEqual([['DEEPSEEK_API_KEY', 'OPENAI_API_KEY']])
|
||||
const byProvider = new Map(state.rows.map(row => [row.entry.provider, row]))
|
||||
expect(byProvider.get('deepseek-official')).toMatchObject({
|
||||
@@ -84,6 +85,7 @@ describe('ModelsSettingsStore', () => {
|
||||
removable: false,
|
||||
apiKeyEnv: 'DEEPSEEK_API_KEY',
|
||||
credential: { configured: false, writable: true },
|
||||
literalApiKeyConfigured: false,
|
||||
})
|
||||
expect(byProvider.get('openai')).toMatchObject({
|
||||
configured: true,
|
||||
@@ -103,9 +105,55 @@ describe('ModelsSettingsStore', () => {
|
||||
await store.load()
|
||||
const state = store.store.getSnapshot()
|
||||
expect(state.status).toBe('ready')
|
||||
expect(state.credentialError).toBe('no provider')
|
||||
expect(state.rows.every(row => row.credential === undefined)).toBe(true)
|
||||
})
|
||||
|
||||
it('settles a credential transport rejection without leaving the store loading', async () => {
|
||||
const { face } = api({
|
||||
describeCredentials: () => Promise.reject(new Error('credential transport down')),
|
||||
})
|
||||
const store = new ModelsSettingsStore(face)
|
||||
await expect(store.load()).resolves.toBeUndefined()
|
||||
expect(store.store.getSnapshot()).toMatchObject({
|
||||
status: 'ready',
|
||||
credentialError: 'credential transport down',
|
||||
})
|
||||
})
|
||||
|
||||
it('stringifies a non-Error credential transport rejection', async () => {
|
||||
const { face } = api({
|
||||
// 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)
|
||||
await expect(store.load()).resolves.toBeUndefined()
|
||||
expect(store.store.getSnapshot().credentialError).toBe('credential transport refusal')
|
||||
})
|
||||
|
||||
it('joins a configured literal key from the redacted secret sidecar', async () => {
|
||||
const { face } = api({
|
||||
describeSettings: () => Promise.resolve(ok({
|
||||
writable: true,
|
||||
namespaces: [{
|
||||
...NAMESPACES[0],
|
||||
secrets: [
|
||||
{ path: ['apiKey', 'nested'], set: true },
|
||||
{ path: ['different'], set: true },
|
||||
{ path: ['apiKey'], set: true },
|
||||
],
|
||||
}] as never,
|
||||
})),
|
||||
providers: () => Promise.resolve(ok({ providers: [DIRECTORY[0]] as never })),
|
||||
})
|
||||
const store = new ModelsSettingsStore(face)
|
||||
await store.load()
|
||||
expect(store.store.getSnapshot().rows[0]).toMatchObject({
|
||||
literalApiKeyConfigured: true,
|
||||
apiKeyEnv: 'DEEPSEEK_API_KEY',
|
||||
})
|
||||
})
|
||||
|
||||
it('surfaces a directory failure and keeps the last good rows', async () => {
|
||||
const { face } = api()
|
||||
const store = new ModelsSettingsStore(face)
|
||||
@@ -196,7 +244,7 @@ describe('edge joins', () => {
|
||||
|
||||
it('stringifies a non-Error load failure', async () => {
|
||||
// The wire can surface non-Error throwables; the store must stringify them.
|
||||
// eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors
|
||||
// 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)
|
||||
await store.load()
|
||||
|
||||
@@ -23,6 +23,9 @@
|
||||
{
|
||||
"path": "../schema-form"
|
||||
},
|
||||
{
|
||||
"path": "../ui-primitives"
|
||||
},
|
||||
{
|
||||
"path": "../web-react"
|
||||
},
|
||||
|
||||
@@ -13,6 +13,7 @@ import css from './Modal.module.css'
|
||||
* @param props.open - whether the dialog is showing.
|
||||
* @param props.onClose - Escape or mask click.
|
||||
* @param props.title - dialog heading (aria-label in every mode).
|
||||
* @param props.closeLabel - accessible close-button label.
|
||||
* @param props.description - optional supporting sentence under the title.
|
||||
* @param props.children - body (inputs, etc.).
|
||||
* @param props.footer - action row (Cancel / Create).
|
||||
@@ -21,10 +22,13 @@ import css from './Modal.module.css'
|
||||
* header structure; mask, card, Escape, and aria-label remain.
|
||||
* @returns null when closed; otherwise the overlay tree.
|
||||
*/
|
||||
export function Modal({ open, onClose, title, description, children, footer, className, headless = false }: {
|
||||
export function Modal({
|
||||
open, onClose, title, closeLabel = 'Close', description, children, footer, className, headless = false,
|
||||
}: {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
title: string
|
||||
closeLabel?: string
|
||||
description?: string
|
||||
children?: ReactNode
|
||||
footer?: ReactNode
|
||||
@@ -58,7 +62,7 @@ export function Modal({ open, onClose, title, description, children, footer, cla
|
||||
<div className={css.content}>
|
||||
<div className={css.header}>
|
||||
<h2 className={css.title}>{title}</h2>
|
||||
<button type="button" className={css.close} aria-label="Close" onClick={onClose}>
|
||||
<button type="button" className={css.close} aria-label={closeLabel} onClick={onClose}>
|
||||
<IconCloseOutline16 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -324,10 +324,11 @@ describe('Modal', () => {
|
||||
<Modal open={false} onClose={onClose} title="Create new workspace">body</Modal>)
|
||||
expect(screen.queryByRole('dialog')).toBeNull()
|
||||
rerender(
|
||||
<Modal open onClose={onClose} title="Create new workspace" description="Name it." footer={<button type="button">Create</button>}>
|
||||
<Modal open onClose={onClose} title="Create new workspace" closeLabel="Configure later" description="Name it." footer={<button type="button">Create</button>}>
|
||||
<input aria-label="name" />
|
||||
</Modal>)
|
||||
expect(screen.getByRole('dialog', { name: 'Create new workspace' })).toBeDefined()
|
||||
expect(screen.getByRole('button', { name: 'Configure later' })).toBeDefined()
|
||||
expect(screen.getByText('Name it.')).toBeDefined()
|
||||
fireEvent.keyDown(document, { key: 'a' })
|
||||
expect(onClose).not.toHaveBeenCalled()
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# 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/ui-settings/README.md
|
||||
README.md: bb99f9b37927eec57650aa4025deb043b369c78e
|
||||
README.zh.md: 64b207aadfbcd7d25c005b3dcd5358013d53c173
|
||||
README.md: 9388e9dd3a984bfcebc85b6b1a35bcce4b9b116e
|
||||
README.zh.md: 57c91ac5dd0bcc0a3e5e029359bf6c3a2be58ec7
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Settings shell plugin: a pure composition face. It occupies `sidebar.settings` with the trigger chrome and the modal settings panel, and declares the slots registrants fill: `settings.trigger` / `settings.header` / `settings.close` (chrome content) and `settings.section` (one page per feature). The shell ships no copy and reads no locale state — all text arrives from registrants (ui-settings-general owns chrome and General; features own their sections and rows), so the section ledger bump is its only re-render trigger.
|
||||
Settings shell plugin: a pure composition face. It occupies `sidebar.settings` with the trigger chrome and modal settings panel, and declares the slots registrants fill: `settings.trigger` / `settings.header` / `settings.close` (chrome content), `settings.section` (one page per feature), and `settings.onboarding` (feature-owned overlays on the empty Hero). The shell ships no copy and reads no locale state — all text arrives from registrants (ui-settings-general owns chrome and General; features own their sections, rows, and onboarding overlays).
|
||||
|
||||
The shell supplies onboarding registrants only two navigation facts: whether the session surface is the empty Hero and an `openSection(id)` callback that opens the panel on a registered section. Registrants own capability readiness, dismissal, copy, and mutations; the shell therefore does not become a second configuration fact source.
|
||||
|
||||
## Model Experience
|
||||
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
设置外壳插件:一个纯组合表层。它以触发控件和模态设置面板占用 `sidebar.settings`,并声明由注册方填充的 slot:`settings.trigger`/`settings.header`/`settings.close`(界面框架内容)和 `settings.section`(每项功能一页)。外壳不自带文案,也不读取 locale 状态:所有文本都来自注册方(ui-settings-general 拥有界面框架和「通用」分区;各功能拥有各自的分区和行),因此只有分区账本更新会触发它重新渲染。
|
||||
设置外壳插件:一个纯组合表层。它以触发控件和模态设置面板占用 `sidebar.settings`,并声明由注册方填充的 slot:`settings.trigger`/`settings.header`/`settings.close`(界面框架内容)、`settings.section`(每项功能一页)和 `settings.onboarding`(由各功能持有、覆盖在空白 Hero 之上的浮层)。外壳不自带文案,也不读取 locale 状态:所有文本都来自注册方(ui-settings-general 拥有界面框架和「通用」分区;各功能拥有各自的分区、行和首次使用浮层)。
|
||||
|
||||
外壳只向首次使用注册方提供两个导航事实:当前会话界面是否为空白 Hero,以及一个 `openSection(id)` 回调;后者会打开设置面板并切换到已注册的指定分区。能力就绪状态、浮层关闭、文案和变更操作均由注册方持有,因此外壳不会成为第二个配置事实来源。
|
||||
|
||||
## 模型体验
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-client-ui-settings",
|
||||
"description": "Settings shell plugin: sidebar trigger + modal panel occupying sidebar.settings; declares the settings.section list slot",
|
||||
"description": "Settings shell plugin: sidebar trigger, modal panel, feature sections, and root-scoped onboarding overlays",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -5,7 +5,9 @@
|
||||
* close label, sections) arrives from registrants through slots; accessible
|
||||
* names resolve to that content (trigger: its own text; dialog:
|
||||
* aria-labelledby the title node; close: visually-hidden slot text). Modal
|
||||
* open state and the active section id are component-local viewing state.
|
||||
* open state and the active section id are component-local viewing state;
|
||||
* the onboarding slot receives the sessions-derived empty-Hero fact and a
|
||||
* private callback that opens one registered section.
|
||||
*/
|
||||
import { useCallback, useEffect, useId, useRef, useState } from 'react'
|
||||
import clsx from 'clsx'
|
||||
@@ -22,6 +24,8 @@ function navIcon(id: string) {
|
||||
type PanelProps = {
|
||||
rows: readonly SettingsSectionRow[]
|
||||
renderSlot: SettingsRootComponentProps['renderSlot']
|
||||
activeId: string | undefined
|
||||
onSelect: (id: string) => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
@@ -30,10 +34,9 @@ type PanelProps = {
|
||||
* header button, a mask click, and document-level Escape (mounted only while
|
||||
* open, so the listener lifetime is the panel's).
|
||||
*/
|
||||
function SettingsPanel({ rows, renderSlot, onClose }: PanelProps) {
|
||||
// Local selection; entries can unmount underneath it, so the render-time
|
||||
function SettingsPanel({ rows, renderSlot, activeId, onSelect, onClose }: PanelProps) {
|
||||
// Entries can unmount underneath the requested id, so the render-time
|
||||
// projection falls back to the first row when the id is gone.
|
||||
const [activeId, setActiveId] = useState<string | undefined>(undefined)
|
||||
const active = rows.find(r => r.id === activeId)?.id ?? rows[0]?.id
|
||||
const titleId = useId()
|
||||
|
||||
@@ -62,7 +65,7 @@ function SettingsPanel({ rows, renderSlot, onClose }: PanelProps) {
|
||||
type="button"
|
||||
className={clsx(css.navCell, row.id === active && css.active)}
|
||||
aria-current={row.id === active ? 'true' : undefined}
|
||||
onClick={() => { setActiveId(row.id) }}
|
||||
onClick={() => { onSelect(row.id) }}
|
||||
>
|
||||
{navIcon(row.id)}
|
||||
<span className={css.navLabel}>{row.label}</span>
|
||||
@@ -92,14 +95,25 @@ function SettingsPanel({ rows, renderSlot, onClose }: PanelProps) {
|
||||
* @returns the settings shell element tree.
|
||||
*/
|
||||
export function SettingsRoot(props: SettingsRootComponentProps) {
|
||||
const { wide, useSections, renderSlot } = props
|
||||
const { wide, useSections, useSessions, renderSlot } = props
|
||||
const [open, setOpen] = useState(false)
|
||||
const close = useCallback(() => { setOpen(false) }, [])
|
||||
const [activeId, setActiveId] = useState<string | undefined>(undefined)
|
||||
const close = useCallback(() => {
|
||||
setOpen(false)
|
||||
setActiveId(undefined)
|
||||
}, [])
|
||||
const openSection = useCallback((id: string) => {
|
||||
setActiveId(id)
|
||||
setOpen(true)
|
||||
}, [])
|
||||
|
||||
// The ledger tick keeps the nav rows fresh: registrants re-register with
|
||||
// freshly localized text on locale change, and the trigger/header/close
|
||||
// seats re-render through their own outlets' subscriptions.
|
||||
const rows = useSections(s => s)
|
||||
const onboardingActive = useSessions(state =>
|
||||
state.phase === 'ready'
|
||||
&& (state.current === undefined || state.byId[state.current]?.blank === true))
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -112,7 +126,16 @@ export function SettingsRoot(props: SettingsRootComponentProps) {
|
||||
>
|
||||
{renderSlot('settings.trigger', { wide })}
|
||||
</button>
|
||||
{open && <SettingsPanel rows={rows} renderSlot={renderSlot} onClose={close} />}
|
||||
{open && (
|
||||
<SettingsPanel
|
||||
rows={rows}
|
||||
renderSlot={renderSlot}
|
||||
activeId={activeId}
|
||||
onSelect={setActiveId}
|
||||
onClose={close}
|
||||
/>
|
||||
)}
|
||||
{renderSlot('settings.onboarding', { active: onboardingActive, openSection })}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -47,6 +47,13 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
* item registrant; the shell neither declares nor renders it.)
|
||||
*/
|
||||
'settings.section': { kind: 'list'; scope: 'root'; owner: SettingsSectionOwnerProps }
|
||||
/**
|
||||
* Root-scoped onboarding overlays contributed by settings features. The
|
||||
* shell supplies whether the current navigation state is the empty Hero
|
||||
* and a private callback that opens one settings section; registrants own
|
||||
* readiness, copy, and dialog behavior.
|
||||
*/
|
||||
'settings.onboarding': { kind: 'list'; scope: 'root'; owner: SettingsOnboardingOwnerProps }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,6 +79,14 @@ export interface SettingsSectionOwnerProps {
|
||||
children?: never
|
||||
}
|
||||
|
||||
/** Owner share of a settings-backed onboarding overlay. */
|
||||
export interface SettingsOnboardingOwnerProps {
|
||||
/** Whether the current UI is in its empty Hero/onboarding state. */
|
||||
active: boolean
|
||||
/** Open the settings panel directly on one registered section. */
|
||||
openSection: (id: string) => void
|
||||
}
|
||||
|
||||
/** One nav row projected from a settings.section registration's options. */
|
||||
export interface SettingsSectionRow {
|
||||
id: string
|
||||
@@ -99,5 +114,7 @@ export type SettingsRootInjected = {
|
||||
*/
|
||||
export type SettingsRootComponentProps =
|
||||
PropsRuntime<'sidebar.settings'>
|
||||
& PropsRenderSlots<'settings.trigger' | 'settings.header' | 'settings.close' | 'settings.section'>
|
||||
& PropsRenderSlots<
|
||||
'settings.trigger' | 'settings.header' | 'settings.close' | 'settings.section' | 'settings.onboarding'
|
||||
>
|
||||
& InjectFace<SettingsRootInjected>
|
||||
@@ -1,12 +1,11 @@
|
||||
/**
|
||||
* Settings shell plugin, browser half. A pure composition face: occupies the
|
||||
* sidebar-owned `sidebar.settings` hole with the trigger chrome + modal
|
||||
* panel, declares the `settings.trigger` / `settings.header` /
|
||||
* `settings.section` slots, and projects the section ledger into the panel
|
||||
* navigation. The shell ships no copy and reads no locale state — all text
|
||||
* arrives from registrants (ui-settings-general owns the chrome and General
|
||||
* content; features own their rows and sections). Export discipline:
|
||||
* packages/client/AGENTS.md.
|
||||
* panel, declares its chrome, section, and onboarding slots, and projects the
|
||||
* section ledger into panel navigation. The shell ships no copy and reads no
|
||||
* locale state — all text arrives from registrants (ui-settings-general owns
|
||||
* the chrome and General content; features own their rows, sections, and
|
||||
* onboarding overlays). Export discipline: packages/client/AGENTS.md.
|
||||
*/
|
||||
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { deferRegistration } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
@@ -15,7 +14,7 @@ import { SettingsRoot } from './SettingsRoot.tsx'
|
||||
|
||||
export type {
|
||||
SettingsHeaderOwnerProps, SettingsRootComponentProps, SettingsRootInjected,
|
||||
SettingsSectionOwnerProps, SettingsSectionRow, SettingsTriggerOwnerProps,
|
||||
SettingsOnboardingOwnerProps, SettingsSectionOwnerProps, SettingsSectionRow, SettingsTriggerOwnerProps,
|
||||
} from './contract/slots.ts'
|
||||
|
||||
/**
|
||||
@@ -67,6 +66,7 @@ export function apply(ctx: ClientContext): void {
|
||||
'settings.header': { kind: 'single', scope: 'root' },
|
||||
'settings.close': { kind: 'single', scope: 'root' },
|
||||
'settings.section': { kind: 'list', scope: 'root' },
|
||||
'settings.onboarding': { kind: 'list', scope: 'root' },
|
||||
},
|
||||
inject: injected,
|
||||
}, SettingsRoot))
|
||||
|
||||
@@ -24,12 +24,13 @@ function injectedOf(slots: SlotsService): SettingsRootInjected {
|
||||
return (entry.inject as () => SettingsRootInjected)()
|
||||
}
|
||||
|
||||
/** The shell's four child declarations (chrome seats + the section list). */
|
||||
/** The shell's five child declarations (chrome, sections, and onboarding overlays). */
|
||||
const CHILD_SPECS = {
|
||||
'settings.trigger': { kind: 'single', scope: 'root' },
|
||||
'settings.header': { kind: 'single', scope: 'root' },
|
||||
'settings.close': { kind: 'single', scope: 'root' },
|
||||
'settings.section': { kind: 'list', scope: 'root' },
|
||||
'settings.onboarding': { kind: 'list', scope: 'root' },
|
||||
} as const
|
||||
|
||||
describe('ui-settings apply', () => {
|
||||
@@ -37,7 +38,7 @@ describe('ui-settings apply', () => {
|
||||
expect(inject).toEqual(['slots'])
|
||||
})
|
||||
|
||||
it('registers the shell and declares the four child slots, before or after the declaration', async () => {
|
||||
it('registers the shell and declares the five child slots, before or after the declaration', async () => {
|
||||
const before = await bench()
|
||||
declare(before.slots)
|
||||
await before.ctx.plugin({ inject: [...inject], apply }).await()
|
||||
@@ -100,7 +101,7 @@ describe('ui-settings apply', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('unregisters the shell and collapses all four child slots on teardown', async () => {
|
||||
it('unregisters the shell and collapses all five child slots on teardown', async () => {
|
||||
const b = await bench()
|
||||
declare(b.slots)
|
||||
const fiber = b.ctx.plugin({ inject: [...inject], apply })
|
||||
|
||||
@@ -18,11 +18,12 @@ const SEAT_CONTENT: Record<string, string> = {
|
||||
|
||||
function mount({
|
||||
wide = true,
|
||||
onboardingActive = true,
|
||||
rows = [
|
||||
{ id: 'general', order: 0, label: 'General' },
|
||||
{ id: 'models', order: 10, label: 'Models' },
|
||||
],
|
||||
}: { wide?: boolean; rows?: Row[] } = {}) {
|
||||
}: { wide?: boolean; onboardingActive?: boolean; rows?: Row[] } = {}) {
|
||||
// Mutable row source standing in for the bound useSections hook; bump()
|
||||
// plays a ledger change through the same observable contract.
|
||||
let current = rows
|
||||
@@ -33,10 +34,16 @@ function mount({
|
||||
return SEAT_CONTENT[key]
|
||||
}) as SettingsRootComponentProps['renderSlot'],
|
||||
)
|
||||
// Global standard kit stubs: the shell consumes neither hook.
|
||||
const useSessions = ((select: (state: unknown) => unknown) => select(onboardingActive
|
||||
? { phase: 'ready', current: undefined, byId: {} }
|
||||
: {
|
||||
phase: 'ready',
|
||||
current: 'active-session',
|
||||
byId: { 'active-session': { blank: false } },
|
||||
})) as never
|
||||
const unusedHook = (() => { throw new Error('unused by SettingsRoot') }) as never
|
||||
const props: SettingsRootComponentProps = {
|
||||
useSessions: unusedHook,
|
||||
useSessions,
|
||||
useWorkspaces: unusedHook,
|
||||
wide,
|
||||
useSections: (select) => {
|
||||
@@ -157,6 +164,22 @@ describe('SettingsPanel navigation', () => {
|
||||
expect(screen.queryByTestId('section-general')).toBeNull()
|
||||
})
|
||||
|
||||
it('hands Hero readiness and a direct section opener to onboarding registrants', () => {
|
||||
const { renderSlot } = mount()
|
||||
const onboardingCall = renderSlot.mock.calls.find(call => call[0] === 'settings.onboarding')
|
||||
expect(onboardingCall?.[1]).toMatchObject({ active: true })
|
||||
act(() => {
|
||||
(onboardingCall?.[1] as { openSection: (id: string) => void }).openSection('models')
|
||||
})
|
||||
expect(screen.getByRole('dialog')).toBeTruthy()
|
||||
expect(screen.getByTestId('section-models')).toBeTruthy()
|
||||
|
||||
cleanup()
|
||||
const active = mount({ onboardingActive: false }).renderSlot.mock.calls
|
||||
.find(call => call[0] === 'settings.onboarding')
|
||||
expect(active?.[1]).toMatchObject({ active: false })
|
||||
})
|
||||
|
||||
it('falls back to the first row when the active entry unregisters', () => {
|
||||
const { bump } = mount()
|
||||
openPanel()
|
||||
|
||||
Generated
+3
@@ -1353,6 +1353,9 @@ importers:
|
||||
'@deepseek-ai/dsh-client-schema-form':
|
||||
specifier: workspace:^
|
||||
version: link:../schema-form
|
||||
'@deepseek-ai/dsh-client-ui-primitives':
|
||||
specifier: workspace:^
|
||||
version: link:../ui-primitives
|
||||
'@deepseek-ai/dsh-client-ui-settings':
|
||||
specifier: workspace:^
|
||||
version: link:../ui-settings
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
"apps/web/tests/details-session-lifecycle.e2e.ts",
|
||||
"apps/web/tests/settings-chrome.e2e.ts",
|
||||
"apps/web/tests/models-settings.e2e.ts",
|
||||
"apps/web/tests/onboarding-deepseek-config.e2e.ts",
|
||||
"apps/web/tests/workspace-management.e2e.ts",
|
||||
"apps/web/tests/replay-round-trip.e2e.ts",
|
||||
"apps/web/tests/seeded-history.e2e.ts",
|
||||
|
||||
Reference in New Issue
Block a user