feat(web): configure DeepSeek during onboarding

This commit is contained in:
Yichen Jiang
2026-07-30 12:41:09 +08:00
parent 65bd54f8b4
commit 9182db00ef
24 changed files with 1051 additions and 53 deletions

View File

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

View File

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

View File

@@ -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<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
literal?: boolean
configured?: () => boolean
credential?: { source?: string; writable: boolean }
describeFailure?: string
set?: (payload: { ref: string; value: string }) => Promise<RpcResponse<{}>>
} = {}) {
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(<DeepSeekOnboardingDialog {...h.props} />)
const dialog = await screen.findByRole('dialog', { name: en.onboardingTitle })
expect(dialog).toBeTruthy()
expect(screen.getByLabelText<HTMLInputElement>(en.provider).value).toBe('DeepSeek')
const key = screen.getByLabelText<HTMLInputElement>(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(<DeepSeekOnboardingDialog {...h.props} />)
const key = await screen.findByLabelText<HTMLInputElement>(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(<DeepSeekOnboardingDialog {...h.props} />)
const key = await screen.findByLabelText<HTMLInputElement>(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<HTMLButtonElement>('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<void>((resolve) => { settle = resolve })
const h = harness({
set: async () => {
await pending
return ok({})
},
})
render(<DeepSeekOnboardingDialog {...h.props} />)
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<HTMLButtonElement>('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(<DeepSeekOnboardingDialog {...h.props} />)
const key = await screen.findByLabelText<HTMLInputElement>(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<HTMLButtonElement>('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(<DeepSeekOnboardingDialog {...h.props} />)
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(<DeepSeekOnboardingDialog {...cancelled.props} />)
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(<DeepSeekOnboardingDialog {...advanced.props} />)
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(<DeepSeekOnboardingDialog {...h.props} />)
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<HTMLButtonElement>('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(<DeepSeekOnboardingDialog {...h.props} />)
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(<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('clears a typed draft when the onboarding owner becomes inactive', async () => {
const h = harness()
const view = render(<DeepSeekOnboardingDialog {...h.props} />)
const key = await screen.findByLabelText<HTMLInputElement>(en.onboardingKey)
fireEvent.change(key, { target: { value: 'ephemeral' } })
view.rerender(<DeepSeekOnboardingDialog {...h.props} active={false} />)
expect(screen.queryByRole('dialog')).toBeNull()
view.rerender(<DeepSeekOnboardingDialog {...h.props} active />)
expect((await screen.findByLabelText<HTMLInputElement>(en.onboardingKey)).value).toBe('')
})
})

View File

@@ -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> = {}): 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' })
})
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' })
})
})

View File

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