From 9182db00efa5468814f921e539cb17e64dc9a5be Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 30 Jul 2026 12:41:09 +0800 Subject: [PATCH] feat(web): configure DeepSeek during onboarding --- .../client/connection/src/client/fixture.ts | 29 +- .../client/connection/tests/fixture.spec.ts | 30 ++ packages/client/ui-models/package.json | 4 +- .../DeepSeekOnboardingDialog.module.css | 54 ++++ .../src/client/DeepSeekOnboardingDialog.tsx | 186 ++++++++++++ .../ui-models/src/client/ModelsSection.tsx | 2 +- packages/client/ui-models/src/client/index.ts | 41 ++- .../client/ui-models/src/client/locales.ts | 26 ++ packages/client/ui-models/src/client/store.ts | 135 ++++++++- packages/client/ui-models/tests/apply.spec.ts | 33 ++- .../ui-models/tests/components.spec.tsx | 18 +- .../tests/onboarding-dialog.spec.tsx | 265 ++++++++++++++++++ .../client/ui-models/tests/readiness.spec.ts | 112 ++++++++ packages/client/ui-models/tests/store.spec.ts | 48 ++++ packages/client/ui-models/tsconfig.json | 3 + packages/client/ui-primitives/src/Modal.tsx | 6 +- .../client/ui-primitives/tests/atoms.spec.tsx | 3 +- packages/client/ui-settings/package.json | 2 +- .../ui-settings/src/client/SettingsRoot.tsx | 35 ++- .../ui-settings/src/client/contract/slots.ts | 19 +- .../client/ui-settings/src/client/index.ts | 14 +- .../client/ui-settings/tests/apply.spec.ts | 7 +- .../ui-settings/tests/settings-root.spec.tsx | 29 +- pnpm-lock.yaml | 3 + 24 files changed, 1051 insertions(+), 53 deletions(-) create mode 100644 packages/client/ui-models/src/client/DeepSeekOnboardingDialog.module.css create mode 100644 packages/client/ui-models/src/client/DeepSeekOnboardingDialog.tsx create mode 100644 packages/client/ui-models/tests/onboarding-dialog.spec.tsx create mode 100644 packages/client/ui-models/tests/readiness.spec.ts diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index 1c90718aa3..00416ea2c7 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -588,7 +588,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() + const fixtureCredentials = new Map([ + // 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([[sid('fx-alpha'), 60]]) let nextSession = 1 let nextRpc = 1 @@ -1284,19 +1288,26 @@ 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; 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 }], + }], + }), 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 }, }), }, @@ -1309,7 +1320,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) => { diff --git a/packages/client/connection/tests/fixture.spec.ts b/packages/client/connection/tests/fixture.spec.ts index 8f51861283..146206022c 100644 --- a/packages/client/connection/tests/fixture.spec.ts +++ b/packages/client/connection/tests/fixture.spec.ts @@ -108,6 +108,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 })) diff --git a/packages/client/ui-models/package.json b/packages/client/ui-models/package.json index 9909ed3fb8..825e56d359 100644 --- a/packages/client/ui-models/package.json +++ b/packages/client/ui-models/package.json @@ -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 credential UI 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:^", diff --git a/packages/client/ui-models/src/client/DeepSeekOnboardingDialog.module.css b/packages/client/ui-models/src/client/DeepSeekOnboardingDialog.module.css new file mode 100644 index 0000000000..bce0eafa4d --- /dev/null +++ b/packages/client/ui-models/src/client/DeepSeekOnboardingDialog.module.css @@ -0,0 +1,54 @@ +.dialog { + width: min(420px, 100%); +} + +.fields { + display: flex; + flex-direction: column; + gap: 14px; +} + +.field { + display: flex; + flex-direction: column; + gap: 6px; +} + +.label { + font-size: 12px; + line-height: 18px; + color: var(--dsw-alias-label-secondary); +} + +.input { + width: 100%; + box-sizing: border-box; +} + +.input > input { + width: 100%; +} + +.advanced { + align-self: flex-start; + padding-inline: 0; + color: var(--dsw-alias-label-secondary); +} + +.error { + margin: 0; + font-size: 12px; + line-height: 18px; + color: var(--dsw-alias-state-error-primary); +} + +.diagnostic { + margin: 0; + font-size: 13px; + line-height: 20px; + color: var(--dsw-alias-label-secondary); +} + +.primary { + width: 100%; +} diff --git a/packages/client/ui-models/src/client/DeepSeekOnboardingDialog.tsx b/packages/client/ui-models/src/client/DeepSeekOnboardingDialog.tsx new file mode 100644 index 0000000000..efec759096 --- /dev/null +++ b/packages/client/ui-models/src/client/DeepSeekOnboardingDialog.tsx @@ -0,0 +1,186 @@ +/** + * Official-DeepSeek first-run dialog. Readiness comes from the same + * provider/settings/credential join as the Models page; the component holds + * only the write-only draft and viewing state. + */ + +import { useEffect, useState } from 'react' +import type { ReactNode } from 'react' +import type { IApiClient } from '@deepseek-ai/dsh-client-connection/client' +import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import { Button, Input, 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 + /** Write-only credential wire face. */ + credentials: IApiClient['credentials'] + /** Feature copy. */ + t: (key: keyof typeof en) => string +} + +/** Slot owner props plus the feature's injected dependencies. */ +export type DeepSeekOnboardingDialogProps = + PropsRuntime<'settings.onboarding'> & DeepSeekOnboardingInjected + +/** Remove the submitted non-empty secret from any error text before it reaches the DOM. */ +function redactSecret(message: string, secret: string): string { + return message.split(secret).join('[redacted]') +} + +/** + * Render the first-run credential dialog while the official adapter exists + * and its effective reference is writable but unconfigured. + * @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, credentials, t } = props + const state = useSnapshot(snapshot => snapshot) + const readiness = deepSeekReadiness(state) + const [dismissed, setDismissed] = useState(false) + const [keyDraft, setKeyDraft] = useState('') + const [busy, setBusy] = useState(false) + const [failure, setFailure] = useState(undefined) + + useEffect(() => { + if (active && !dismissed && state.status === 'idle') void controller.load() + }, [active, controller, dismissed, state.status]) + + useEffect(() => { + if (!active || readiness.kind !== 'credential-missing') { + setKeyDraft('') + setFailure(undefined) + } + }, [active, readiness.kind, readiness.kind === 'credential-missing' ? readiness.ref : undefined]) + + const close = (): void => { + setKeyDraft('') + setFailure(undefined) + setDismissed(true) + } + + const openModels = (): void => { + close() + openSection('models') + } + + const save = async (): Promise => { + /* v8 ignore next -- the form only attaches save while missing and disables it for an empty draft */ + if (readiness.kind !== 'credential-missing' || keyDraft.length === 0) return + const secret = keyDraft + const ref = readiness.ref + setBusy(true) + setFailure(undefined) + try { + const response = await credentials.set({ ref, value: secret }) + if (!response.result.ok) { + setFailure(`${t('onboardingSaveFailed')}: ${redactSecret(response.result.error.message, secret)}`) + return + } + await controller.load() + if (deepSeekReadiness(controller.store.getSnapshot()).kind !== 'configured') { + setFailure(t('onboardingVerifyFailed')) + return + } + setKeyDraft('') + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + setFailure(`${t('onboardingSaveFailed')}: ${redactSecret(message, secret)}`) + } finally { + setBusy(false) + } + } + + const retry = async (): Promise => { + setBusy(true) + try { + await controller.load() + } finally { + setBusy(false) + } + } + + if (!active || dismissed || readiness.kind === 'loading' + || readiness.kind === 'adapter-absent' || readiness.kind === 'configured') return null + + const unavailable = readiness.kind === 'unavailable' + const diagnostic = unavailable && readiness.reason === 'credentials-unavailable' + ? t('onboardingCredentialsUnavailable') + : t('onboardingConfigurationUnavailable') + const displayName = readiness.kind === 'credential-missing' + ? readiness.displayName + : 'DeepSeek' + + return ( + { void (unavailable ? retry() : save()) }} + > + {busy + ? t('onboardingSaving') + : unavailable + ? t('retry') + : t('onboardingSave')} + + )} + > +
+ + {readiness.kind === 'credential-missing' + ? ( + + ) + :

{diagnostic}

} + + {failure !== undefined ?

{failure}

: null} +
+
+ ) +} diff --git a/packages/client/ui-models/src/client/ModelsSection.tsx b/packages/client/ui-models/src/client/ModelsSection.tsx index 7a08441ea9..b76341abed 100644 --- a/packages/client/ui-models/src/client/ModelsSection.tsx +++ b/packages/client/ui-models/src/client/ModelsSection.tsx @@ -68,7 +68,7 @@ function StatusBadges({ row, t }: { row: ProviderRow; t: ModelsSectionInjected[' {row.entry.active ? {t('active')} : {t('dormant')}} - {row.credential !== undefined && !row.credential.configured + {!row.literalApiKeyConfigured && row.credential !== undefined && !row.credential.configured ? {t('keyMissing')} : null} diff --git a/packages/client/ui-models/src/client/index.ts b/packages/client/ui-models/src/client/index.ts index de260dec88..a6ee6478db 100644 --- a/packages/client/ui-models/src/client/index.ts +++ b/packages/client/ui-models/src/client/index.ts @@ -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 full page edits through the + * schema-driven form while onboarding exposes only write-only credential + * setup. 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,12 @@ export function apply(ctx: ClientContext): void { api: connection.api, t, }) + const onboardingInjected = (): DeepSeekOnboardingInjected => ({ + controller, + useSnapshot, + credentials: connection.api.credentials, + t, + }) // Pushed invalidations converge every open surface without polling: any // settings/credentials/topology change refetches once the page loaded. @@ -78,7 +86,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 +94,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') } diff --git a/packages/client/ui-models/src/client/locales.ts b/packages/client/ui-models/src/client/locales.ts index da525dcb5e..7dd377a1e6 100644 --- a/packages/client/ui-models/src/client/locales.ts +++ b/packages/client/ui-models/src/client/locales.ts @@ -33,6 +33,19 @@ export const en = { secretUnset: 'Not configured', inherited: 'Default', unsupported: 'This field has no form control; edit the settings document directly.', + onboardingTitle: 'Add a DeepSeek API key', + onboardingDescription: 'Configure the official DeepSeek provider to start building.', + onboardingKey: 'API key', + onboardingKeyPlaceholder: 'Enter your DeepSeek API key', + onboardingAdvanced: 'Advanced model settings', + onboardingSave: 'Save and continue', + onboardingSaving: 'Saving…', + onboardingLater: 'Configure later', + onboardingSaveFailed: 'Could not save the API key', + onboardingVerifyFailed: 'The key was saved, but its configured state could not be verified. Try again.', + onboardingUnavailableTitle: 'DeepSeek setup is unavailable', + onboardingCredentialsUnavailable: 'This deployment does not expose writable credential storage. Mount @deepseek-ai/dsh-credentials-local, then retry.', + onboardingConfigurationUnavailable: 'The live DeepSeek configuration capability cannot be resolved here. Check the deployment composition, then retry.', } /** Chinese strings (same keys as {@link en}). */ @@ -68,4 +81,17 @@ export const zh: typeof en = { secretUnset: '未设置', inherited: '默认', unsupported: '该字段没有对应表单控件;请直接编辑设置文档。', + onboardingTitle: '添加 DeepSeek API 密钥', + onboardingDescription: '配置 DeepSeek 官方模型,即可开始使用。', + onboardingKey: 'API 密钥', + onboardingKeyPlaceholder: '输入 DeepSeek API 密钥', + onboardingAdvanced: '模型高级设置', + onboardingSave: '保存并继续', + onboardingSaving: '保存中…', + onboardingLater: '稍后配置', + onboardingSaveFailed: '无法保存 API 密钥', + onboardingVerifyFailed: '密钥已写入,但无法确认配置状态。请重试。', + onboardingUnavailableTitle: '无法在此配置 DeepSeek', + onboardingCredentialsUnavailable: '当前部署没有可写的凭据存储。请挂载 @deepseek-ai/dsh-credentials-local 后重试。', + onboardingConfigurationUnavailable: '无法在此解析 DeepSeek 的实时配置能力。请检查部署组合后重试。', } diff --git a/packages/client/ui-models/src/client/store.ts b/packages/client/ui-models/src/client/store.ts index 13b1d611df..44fd256f16 100644 --- a/packages/client/ui-models/src/client/store.ts +++ b/packages/client/ui-models/src/client/store.ts @@ -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. */ @@ -49,11 +53,29 @@ 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])) +} + +/** Safe display text for a rejected transport or business response. */ +function errorText(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} + /** The models settings page controller (one per settings surface). */ export class ModelsSettingsStore { /** The snapshot the section renders from (uSES-safe store). */ readonly store: SnapshotStore = createSnapshotStore({ - 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. */ @@ -109,20 +131,28 @@ 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 = {} + let credentialError: string | null = null if (refs.length > 0) { - const response = await this.api.credentials.describe({ refs }) - // Credential state is an enrichment: rows render without it, so a - // missing credential provider degrades the badge, not the page. - if (response.result.ok) credentials = response.result.value.credentials + try { + const response = await this.api.credentials.describe({ refs }) + // Credential state is an enrichment for the Models page, while the + // onboarding readiness projection below reports its failure. + if (response.result.ok) credentials = response.result.value.credentials + else credentialError = response.result.error.message + } catch (error) { + credentialError = errorText(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, @@ -134,3 +164,98 @@ export class ModelsSettingsStore { }) } } + +/** DeepSeek onboarding readiness derived only from the shared Models join. */ +export type DeepSeekReadiness = + | { kind: 'loading' } + | { kind: 'adapter-absent' } + | { kind: 'configured'; source: 'literal' | 'credential'; ref?: string; credential?: CredentialView } + | { kind: 'credential-missing'; displayName: string; ref: string } + | { + kind: 'unavailable' + reason: + | 'provider-inactive' + | 'settings-unavailable' + | 'credential-ref-unavailable' + | 'credentials-unavailable' + | 'credential-read-only' + message: string + } + +/** + * Project official-DeepSeek readiness from the provider/settings/credential + * join used by the Models page. A missing directory entry means the adapter + * is not mounted and therefore cannot be repaired by a key form. + * @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: 'settings-unavailable', + message: state.error ?? 'provider/settings describe failed', + } + } + const row = state.rows.find(candidate => candidate.entry.provider === 'deepseek-official') + if (row === undefined) return { kind: 'adapter-absent' } + if (!row.entry.active) { + return { + kind: 'unavailable', + reason: 'provider-inactive', + message: 'the deepseek-official route is not active', + } + } + if (!row.configured) { + return { + kind: 'unavailable', + reason: 'settings-unavailable', + message: `settings namespace "${row.entry.settingsNs}" did not resolve the provider profile`, + } + } + if (row.literalApiKeyConfigured) return { kind: 'configured', source: 'literal' } + if (row.apiKeyEnv === undefined) { + return { + kind: 'unavailable', + reason: 'credential-ref-unavailable', + message: 'the resolved DeepSeek settings do not name an apiKeyEnv credential reference', + } + } + if (state.credentialError !== null) { + return { + kind: 'unavailable', + reason: 'credentials-unavailable', + message: state.credentialError, + } + } + if (row.credential === undefined) { + return { + kind: 'unavailable', + reason: 'credentials-unavailable', + message: `credential reference "${row.apiKeyEnv}" was not described`, + } + } + if (row.credential.configured) { + return { + kind: 'configured', + source: 'credential', + ref: row.apiKeyEnv, + credential: row.credential, + } + } + if (!row.credential.writable) { + return { + kind: 'unavailable', + reason: 'credential-read-only', + message: `credential reference "${row.apiKeyEnv}" is missing and read-only`, + } + } + return { + kind: 'credential-missing', + displayName: row.entry.displayName, + ref: row.apiKeyEnv, + } +} diff --git a/packages/client/ui-models/tests/apply.spec.ts b/packages/client/ui-models/tests/apply.spec.ts index 7b05930d98..c247960cc4 100644 --- a/packages/client/ui-models/tests/apply.spec.ts +++ b/packages/client/ui-models/tests/apply.spec.ts @@ -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) + }) }) diff --git a/packages/client/ui-models/tests/components.spec.tsx b/packages/client/ui-models/tests/components.spec.tsx index 32d4eb73b6..a8f0f227b6 100644 --- a/packages/client/ui-models/tests/components.spec.tsx +++ b/packages/client/ui-models/tests/components.spec.tsx @@ -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, removeProviderProfile } from '../src/client/ModelsSection.tsx' -import type { ModelsSectionInjected } from '../src/client/ModelsSection.tsx' +import type { ModelsSectionInjected, ModelsSectionProps } from '../src/client/ModelsSection.tsx' import { ModelsSettingsStore } from '../src/client/store.ts' import { en } from '../src/client/locales.ts' @@ -121,6 +121,12 @@ async function mountSection(overrides: Parameters[0] = {}) } describe('ModelsSection', () => { + it('renders nothing before the slot injects its dependencies', () => { + const uninjected = {} as ModelsSectionProps + render() + expect(document.body.textContent).toBe('') + }) + it('renders configured rows with status badges and the add vocabulary', async () => { await mountSection() expect(screen.getByText('DeepSeek')).toBeTruthy() @@ -135,6 +141,16 @@ describe('ModelsSection', () => { expect(screen.getAllByText(en.remove)).toHaveLength(2) }) + it('does not mark a provider with a configured literal key as missing', async () => { + const { controller } = await mountSection() + controller.store.update((state) => { + state.rows = state.rows.map(row => row.entry.provider === 'deepseek-official' + ? { ...row, literalApiKeyConfigured: true } + : row) + }) + await waitFor(() => { expect(screen.queryByText(en.keyMissing)).toBeNull() }) + }) + it('opens the editor, applies an edit as a merge patch, and reloads', async () => { const { update, face } = await mountSection() fireEvent.click(screen.getAllByText(en.edit)[1] as HTMLElement) diff --git a/packages/client/ui-models/tests/onboarding-dialog.spec.tsx b/packages/client/ui-models/tests/onboarding-dialog.spec.tsx new file mode 100644 index 0000000000..fa4fd1dac2 --- /dev/null +++ b/packages/client/ui-models/tests/onboarding-dialog.spec.tsx @@ -0,0 +1,265 @@ +// @vitest-environment jsdom +/** First-run DeepSeek dialog 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(value: T): RpcResponse { + return { rpcId: `onboarding-${nextRpc++}` as never, result: { ok: true, value } } +} +function fail(message: string): RpcResponse { + return { + rpcId: `onboarding-${nextRpc++}` as never, + result: { ok: false, error: { code: 'internal', message, details: {} } }, + } +} + +function harness(options: { + provider?: boolean + literal?: boolean + configured?: () => boolean + credential?: { source?: string; writable: boolean } + describeFailure?: string + set?: (payload: { ref: string; value: string }) => Promise> +} = {}) { + let fileConfigured = false + const configured = options.configured ?? (() => fileConfigured) + const set = vi.fn(options.set ?? ((payload: { ref: string; value: string }) => { + fileConfigured = payload.value.length > 0 + return Promise.resolve(ok({})) + })) + const face = { + llm: { + providers: () => Promise.resolve(ok({ + providers: options.provider === false + ? [] + : [{ + provider: 'deepseek-official', + displayName: 'DeepSeek', + settingsNs: 'llm-deepseek', + settingsPath: [], + active: true, + }], + })), + }, + settings: { + describe: () => Promise.resolve(ok({ + writable: true, + namespaces: [{ + ns: 'llm-deepseek', + schema: {}, + value: { apiKeyEnv: 'DEEPSEEK_API_KEY' }, + applies: 'live' as const, + secrets: [{ path: ['apiKey'], set: options.literal === true }], + }], + })), + }, + 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)), + set, + }, + } + 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), + credentials: face.credentials as never, + t: key => en[key], + } + return { controller, face, openSection, props, set, configure: () => { fileConfigured = true } } +} + +describe('DeepSeekOnboardingDialog', () => { + it('loads on first entry and presents an accessible write-only key form', async () => { + const h = harness() + render() + const dialog = await screen.findByRole('dialog', { name: en.onboardingTitle }) + expect(dialog).toBeTruthy() + expect(screen.getByLabelText(en.provider).value).toBe('DeepSeek') + const key = screen.getByLabelText(en.onboardingKey) + expect(key.type).toBe('password') + expect(key.autocomplete).toBe('off') + expect(key.getAttribute('spellcheck')).toBe('false') + }) + + it('stores through credentials.set, verifies through describe, clears the draft, and closes', async () => { + const h = harness() + render() + const key = await screen.findByLabelText(en.onboardingKey) + const secret = 'test-onboarding-secret' + fireEvent.change(key, { target: { value: secret } }) + fireEvent.click(screen.getByRole('button', { name: en.onboardingSave })) + await waitFor(() => { expect(screen.queryByRole('dialog')).toBeNull() }) + expect(h.set).toHaveBeenCalledWith({ ref: 'DEEPSEEK_API_KEY', value: secret }) + expect(document.body.textContent).not.toContain(secret) + expect(document.documentElement.outerHTML).not.toContain(secret) + }) + + it('keeps a business failure open without echoing the secret', async () => { + const secret = 'business-secret' + const h = harness({ + set: payload => Promise.resolve(fail(`refused ${payload.value}`)), + }) + render() + const key = await screen.findByLabelText(en.onboardingKey) + fireEvent.change(key, { target: { value: secret } }) + fireEvent.click(screen.getByRole('button', { name: en.onboardingSave })) + const alert = await screen.findByRole('alert') + expect(alert.textContent).toContain('[redacted]') + expect(alert.textContent).not.toContain(secret) + expect(screen.getByRole('button', { name: en.onboardingSave }).disabled).toBe(false) + expect(screen.getByRole('dialog')).toBeTruthy() + fireEvent.change(key, { target: { value: 'replacement' } }) + expect(screen.queryByRole('alert')).toBeNull() + }) + + it('shows saving state and reports a failed configured-state verification', async () => { + let settle: (() => void) | undefined + const pending = new Promise((resolve) => { settle = resolve }) + const h = harness({ + set: async () => { + await pending + return ok({}) + }, + }) + render() + fireEvent.change(await screen.findByLabelText(en.onboardingKey), { target: { value: 'verify-secret' } }) + fireEvent.click(screen.getByRole('button', { name: en.onboardingSave })) + expect(screen.getByRole('button', { name: en.onboardingSaving })).toBeTruthy() + settle?.() + expect((await screen.findByRole('alert')).textContent).toBe(en.onboardingVerifyFailed) + expect(screen.getByRole('button', { name: en.onboardingSave }).disabled).toBe(false) + }) + + it('recovers busy state after a transport rejection without an unhandled rejection', async () => { + const secret = 'transport-secret' + const h = harness({ + set: () => Promise.reject(new Error(`transport rejected ${secret}`)), + }) + const unhandled = vi.fn() + window.addEventListener('unhandledrejection', unhandled) + try { + render() + const key = await screen.findByLabelText(en.onboardingKey) + fireEvent.change(key, { target: { value: secret } }) + fireEvent.click(screen.getByRole('button', { name: en.onboardingSave })) + const alert = await screen.findByRole('alert') + expect(alert.textContent).not.toContain(secret) + expect(screen.getByRole('button', { name: en.onboardingSave }).disabled).toBe(false) + expect(unhandled).not.toHaveBeenCalled() + } finally { + window.removeEventListener('unhandledrejection', unhandled) + } + }) + + it('stringifies a non-Error transport rejection without exposing its secret', async () => { + const secret = 'plain-rejection-secret' + const h = harness({ + // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors + set: () => Promise.reject(`transport refused ${secret}`), + }) + render() + fireEvent.change(await screen.findByLabelText(en.onboardingKey), { target: { value: secret } }) + fireEvent.click(screen.getByRole('button', { name: en.onboardingSave })) + const alert = await screen.findByRole('alert') + expect(alert.textContent).toContain('[redacted]') + expect(alert.textContent).not.toContain(secret) + }) + + it('cancels without writing and opens the Models section through the owner callback', async () => { + const cancelled = harness() + const first = render() + await screen.findByRole('dialog') + fireEvent.click(screen.getByRole('button', { name: en.onboardingLater })) + expect(screen.queryByRole('dialog')).toBeNull() + expect(cancelled.set).not.toHaveBeenCalled() + first.unmount() + + const advanced = harness() + render() + await screen.findByRole('dialog') + fireEvent.click(screen.getByRole('button', { name: en.onboardingAdvanced })) + expect(advanced.openSection).toHaveBeenCalledWith('models') + expect(screen.queryByRole('dialog')).toBeNull() + expect(advanced.set).not.toHaveBeenCalled() + }) + + it('shows an actionable deployment diagnostic when credentials are unavailable', async () => { + const h = harness({ describeFailure: 'credentials service is absent' }) + render() + await screen.findByRole('dialog', { name: en.onboardingUnavailableTitle }) + expect(screen.getByText(en.onboardingCredentialsUnavailable)).toBeTruthy() + expect(screen.queryByLabelText(en.onboardingKey)).toBeNull() + fireEvent.click(screen.getByRole('button', { name: en.retry })) + await waitFor(() => { + expect(screen.getByRole('button', { name: en.retry }).disabled).toBe(false) + }) + }) + + it('uses the deployment diagnostic for a missing read-only credential', async () => { + const h = harness({ credential: { writable: false } }) + render() + await screen.findByRole('dialog', { name: en.onboardingUnavailableTitle }) + expect(screen.getByText(en.onboardingConfigurationUnavailable)).toBeTruthy() + expect(screen.queryByLabelText(en.onboardingKey)).toBeNull() + }) + + it('skips an absent adapter and already-configured literal or environment credentials', async () => { + for (const h of [ + harness({ provider: false }), + harness({ literal: true, describeFailure: 'credential seam absent' }), + harness({ configured: () => true, credential: { source: 'env', writable: false } }), + ]) { + const view = render() + 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() + await screen.findByRole('dialog') + h.configure() + await act(async () => { await h.controller.load() }) + await waitFor(() => { expect(screen.queryByRole('dialog')).toBeNull() }) + }) + + it('clears a typed draft when the onboarding owner becomes inactive', async () => { + const h = harness() + const view = render() + const key = await screen.findByLabelText(en.onboardingKey) + fireEvent.change(key, { target: { value: 'ephemeral' } }) + view.rerender() + expect(screen.queryByRole('dialog')).toBeNull() + view.rerender() + expect((await screen.findByLabelText(en.onboardingKey)).value).toBe('') + }) +}) diff --git a/packages/client/ui-models/tests/readiness.spec.ts b/packages/client/ui-models/tests/readiness.spec.ts new file mode 100644 index 0000000000..275c3ad4cf --- /dev/null +++ b/packages/client/ui-models/tests/readiness.spec.ts @@ -0,0 +1,112 @@ +/** 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 { + 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 { + 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' }) + }) + + it('addresses the effective credential reference when it is missing and writable', () => { + expect(deepSeekReadiness(state())).toEqual({ + kind: 'credential-missing', + displayName: 'DeepSeek', + ref: 'DEEPSEEK_API_KEY', + }) + }) + + it('accepts file and process-environment credentials without prompting', () => { + expect(deepSeekReadiness(state({ + rows: [row({ credential: { configured: true, source: 'file', writable: true } })], + }))).toMatchObject({ + kind: 'configured', + source: 'credential', + ref: 'DEEPSEEK_API_KEY', + credential: { source: 'file', writable: true }, + }) + expect(deepSeekReadiness(state({ + rows: [row({ credential: { configured: true, source: 'env', writable: false } })], + }))).toMatchObject({ + kind: 'configured', + source: 'credential', + credential: { source: 'env', writable: false }, + }) + }) + + 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', source: 'literal' }) + }) + + it('turns missing capabilities and inconsistent descriptors into diagnostics', () => { + expect(deepSeekReadiness(state({ status: 'error', error: 'settings down' }))).toEqual({ + kind: 'unavailable', + reason: 'settings-unavailable', + message: 'settings down', + }) + expect(deepSeekReadiness(state({ status: 'error', error: null }))).toMatchObject({ + kind: 'unavailable', + reason: 'settings-unavailable', + }) + expect(deepSeekReadiness(state({ + rows: [row({ entry: { ...row().entry, active: false } })], + }))).toMatchObject({ kind: 'unavailable', reason: 'provider-inactive' }) + expect(deepSeekReadiness(state({ + rows: [row({ configured: false })], + }))).toMatchObject({ kind: 'unavailable', reason: 'settings-unavailable' }) + expect(deepSeekReadiness(state({ + rows: [row({ apiKeyEnv: undefined })], + }))).toMatchObject({ kind: 'unavailable', reason: 'credential-ref-unavailable' }) + expect(deepSeekReadiness(state({ + credentialError: 'credentials service is absent', + }))).toMatchObject({ + kind: 'unavailable', + reason: 'credentials-unavailable', + message: 'credentials service is absent', + }) + expect(deepSeekReadiness(state({ + rows: [row({ credential: undefined })], + }))).toMatchObject({ kind: 'unavailable', reason: 'credentials-unavailable' }) + expect(deepSeekReadiness(state({ + rows: [row({ credential: { configured: false, writable: false } })], + }))).toMatchObject({ kind: 'unavailable', reason: 'credential-read-only' }) + }) +}) diff --git a/packages/client/ui-models/tests/store.spec.ts b/packages/client/ui-models/tests/store.spec.ts index eadeb0d913..d5e50b474a 100644 --- a/packages/client/ui-models/tests/store.spec.ts +++ b/packages/client/ui-models/tests/store.spec.ts @@ -75,6 +75,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({ @@ -82,6 +83,7 @@ describe('ModelsSettingsStore', () => { removable: false, apiKeyEnv: 'DEEPSEEK_API_KEY', credential: { configured: false, writable: true }, + literalApiKeyConfigured: false, }) expect(byProvider.get('openai')).toMatchObject({ configured: true, @@ -101,9 +103,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({ + // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors + 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) diff --git a/packages/client/ui-models/tsconfig.json b/packages/client/ui-models/tsconfig.json index 7fda5bbb04..79e61ffcba 100644 --- a/packages/client/ui-models/tsconfig.json +++ b/packages/client/ui-models/tsconfig.json @@ -23,6 +23,9 @@ { "path": "../schema-form" }, + { + "path": "../ui-primitives" + }, { "path": "../web-react" }, diff --git a/packages/client/ui-primitives/src/Modal.tsx b/packages/client/ui-primitives/src/Modal.tsx index 820ff3d7a3..3cca69004d 100644 --- a/packages/client/ui-primitives/src/Modal.tsx +++ b/packages/client/ui-primitives/src/Modal.tsx @@ -13,15 +13,17 @@ 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. + * @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). * @returns null when closed; otherwise the overlay tree. */ -export function Modal({ open, onClose, title, description, children, footer, className }: { +export function Modal({ open, onClose, title, closeLabel = 'Close', description, children, footer, className }: { open: boolean onClose: () => void title: string + closeLabel?: string description?: string children?: ReactNode footer?: ReactNode @@ -50,7 +52,7 @@ export function Modal({ open, onClose, title, description, children, footer, cla

{title}

-
diff --git a/packages/client/ui-primitives/tests/atoms.spec.tsx b/packages/client/ui-primitives/tests/atoms.spec.tsx index 7724afa493..dfcf875b1a 100644 --- a/packages/client/ui-primitives/tests/atoms.spec.tsx +++ b/packages/client/ui-primitives/tests/atoms.spec.tsx @@ -322,10 +322,11 @@ describe('Modal', () => { body) expect(screen.queryByRole('dialog')).toBeNull() rerender( - Create}> + Create}> ) 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() diff --git a/packages/client/ui-settings/package.json b/packages/client/ui-settings/package.json index efadf3190f..8c65eee5b2 100644 --- a/packages/client/ui-settings/package.json +++ b/packages/client/ui-settings/package.json @@ -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", diff --git a/packages/client/ui-settings/src/client/SettingsRoot.tsx b/packages/client/ui-settings/src/client/SettingsRoot.tsx index c3480e1d18..4fa5b075b6 100644 --- a/packages/client/ui-settings/src/client/SettingsRoot.tsx +++ b/packages/client/ui-settings/src/client/SettingsRoot.tsx @@ -22,6 +22,8 @@ function navIcon(id: string) { type PanelProps = { rows: readonly SettingsSectionRow[] renderSlot: SettingsRootComponentProps['renderSlot'] + activeId: string | undefined + onSelect: (id: string) => void onClose: () => void } @@ -30,10 +32,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(undefined) const active = rows.find(r => r.id === activeId)?.id ?? rows[0]?.id const titleId = useId() @@ -62,7 +63,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)} {row.label} @@ -92,14 +93,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(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 +124,16 @@ export function SettingsRoot(props: SettingsRootComponentProps) { > {renderSlot('settings.trigger', { wide })} - {open && } + {open && ( + + )} + {renderSlot('settings.onboarding', { active: onboardingActive, openSection })} ) } diff --git a/packages/client/ui-settings/src/client/contract/slots.ts b/packages/client/ui-settings/src/client/contract/slots.ts index c20a041858..37847832bf 100644 --- a/packages/client/ui-settings/src/client/contract/slots.ts +++ b/packages/client/ui-settings/src/client/contract/slots.ts @@ -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 diff --git a/packages/client/ui-settings/src/client/index.ts b/packages/client/ui-settings/src/client/index.ts index f858be9c37..dad2f89e77 100644 --- a/packages/client/ui-settings/src/client/index.ts +++ b/packages/client/ui-settings/src/client/index.ts @@ -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)) diff --git a/packages/client/ui-settings/tests/apply.spec.ts b/packages/client/ui-settings/tests/apply.spec.ts index caec65f3f5..de50c88d87 100644 --- a/packages/client/ui-settings/tests/apply.spec.ts +++ b/packages/client/ui-settings/tests/apply.spec.ts @@ -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 }) diff --git a/packages/client/ui-settings/tests/settings-root.spec.tsx b/packages/client/ui-settings/tests/settings-root.spec.tsx index dd340dc2ea..a7df311672 100644 --- a/packages/client/ui-settings/tests/settings-root.spec.tsx +++ b/packages/client/ui-settings/tests/settings-root.spec.tsx @@ -18,11 +18,12 @@ const SEAT_CONTENT: Record = { 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() diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4250410f63..5a59fb0652 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1212,6 +1212,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