fix(web): address onboarding review feedback

This commit is contained in:
Yichen Jiang
2026-07-30 18:56:56 +08:00
parent 234018032d
commit fc8f992cde
12 changed files with 168 additions and 87 deletions

View File

@@ -1535,7 +1535,8 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
},
settings: {
// Only the resolved DeepSeek address needed by first-run readiness is
// represented here; real schema-driven forms ride the HTTP transport.
// 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: [{

View File

@@ -9,7 +9,7 @@ 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 type { DeepSeekReadiness, ModelsSettingsState, ModelsSettingsStore } from './store.ts'
import { deepSeekReadiness } from './store.ts'
import type { en } from './locales.ts'
import styles from './DeepSeekOnboardingDialog.module.css'
@@ -28,6 +28,35 @@ export interface DeepSeekOnboardingInjected {
export type DeepSeekOnboardingDialogProps =
PropsRuntime<'settings.onboarding'> & DeepSeekOnboardingInjected
type UnavailableReason = Extract<DeepSeekReadiness, { kind: 'unavailable' }>['reason']
/* v8 ignore next 3 -- closed-union defaults only defend future source widening */
function assertNever(_value: never): never {
throw new Error('unexpected DeepSeek onboarding state')
}
function unavailableDiagnostic(
reason: UnavailableReason,
t: DeepSeekOnboardingInjected['t'],
): string {
switch (reason) {
case 'load-failed':
return t('onboardingLoadFailed')
case 'credentials-unavailable':
return t('onboardingCredentialsUnavailable')
case 'settings-read-only':
case 'credential-read-only':
return t('onboardingReadOnly')
case 'provider-inactive':
case 'settings-unavailable':
case 'credential-ref-unavailable':
return t('onboardingConfigurationUnavailable')
/* v8 ignore next -- every current unavailable reason is handled above */
default:
return assertNever(reason)
}
}
/**
* Prompt a first-run user to open Models while the official adapter exists
* and its effective credential is not configured.
@@ -53,13 +82,28 @@ export function DeepSeekOnboardingDialog(props: DeepSeekOnboardingDialogProps):
openSection('models')
}
if (!active || dismissed || readiness.kind === 'loading'
|| readiness.kind === 'adapter-absent' || readiness.kind === 'configured') return null
if (!active || dismissed) return null
const unavailable = readiness.kind === 'unavailable'
const diagnostic = unavailable && readiness.reason === 'credentials-unavailable'
? t('onboardingCredentialsUnavailable')
: t('onboardingConfigurationUnavailable')
let unavailableReason: UnavailableReason | undefined
switch (readiness.kind) {
case 'loading':
case 'adapter-absent':
case 'configured':
return null
case 'credential-missing':
unavailableReason = undefined
break
case 'unavailable':
unavailableReason = readiness.reason
break
/* v8 ignore next -- every current readiness variant is handled above */
default:
return assertNever(readiness)
}
const unavailable = unavailableReason !== undefined
const diagnostic = unavailableReason === undefined
? undefined
: unavailableDiagnostic(unavailableReason, t)
return (
<Modal
@@ -73,13 +117,14 @@ export function DeepSeekOnboardingDialog(props: DeepSeekOnboardingDialogProps):
<Button
variant="primary"
className={styles['primary']}
autoFocus
onClick={openModels}
>
{t('onboardingGoToSettings')}
</Button>
)}
>
{unavailable ? <p className={styles['diagnostic']}>{diagnostic}</p> : undefined}
{diagnostic === undefined ? undefined : <p className={styles['diagnostic']}>{diagnostic}</p>}
</Modal>
)
}

View File

@@ -32,8 +32,10 @@ export const en = {
onboardingGoToSettings: 'Go to settings',
onboardingLater: 'Configure later',
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.',
onboardingLoadFailed: 'DeepSeek configuration could not be loaded. Check the connection and try again in Models.',
onboardingCredentialsUnavailable: 'Credential storage is unavailable in this deployment. Check the deployment configuration.',
onboardingReadOnly: 'This deployment does not allow the DeepSeek API key to be changed here. Ask an administrator to provide the credential.',
onboardingConfigurationUnavailable: 'DeepSeek configuration is unavailable in this deployment. Check the deployment composition.',
}
/** Chinese strings (same keys as {@link en}). */
@@ -68,6 +70,8 @@ export const zh: typeof en = {
onboardingGoToSettings: '前往配置',
onboardingLater: '稍后配置',
onboardingUnavailableTitle: '无法在此配置 DeepSeek',
onboardingCredentialsUnavailable: '当前部署没有可写的凭据存储。请挂载 @deepseek-ai/dsh-credentials-local 后重试。',
onboardingConfigurationUnavailable: '无法在此解析 DeepSeek 的实时配置能力。请检查部署组合后重试。',
onboardingLoadFailed: '无法加载 DeepSeek 配置。请检查连接,然后在模型设置中重试。',
onboardingCredentialsUnavailable: '当前部署无法使用凭据存储。请检查部署配置。',
onboardingReadOnly: '当前部署不允许在此修改 DeepSeek API 密钥。请联系管理员提供凭据。',
onboardingConfigurationUnavailable: '当前部署无法使用 DeepSeek 配置。请检查部署组合。',
}

View File

@@ -180,17 +180,18 @@ export class ModelsSettingsStore {
export type DeepSeekReadiness =
| { kind: 'loading' }
| { kind: 'adapter-absent' }
| { kind: 'configured'; source: 'literal' | 'credential'; ref?: string; credential?: CredentialView }
| { 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'
message: string
}
/**
@@ -207,8 +208,7 @@ export function deepSeekReadiness(state: ModelsSettingsState): DeepSeekReadiness
if (state.status === 'error') {
return {
kind: 'unavailable',
reason: 'settings-unavailable',
message: state.error ?? 'provider/settings describe failed',
reason: 'load-failed',
}
}
const row = state.rows.find(candidate => candidate.entry.provider === 'deepseek-official')
@@ -217,51 +217,46 @@ export function deepSeekReadiness(state: ModelsSettingsState): DeepSeekReadiness
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.literalApiKeyConfigured) return { kind: 'configured' }
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' }
}
if (!state.writable) {
return {
kind: 'configured',
source: 'credential',
ref: row.apiKeyEnv,
credential: row.credential,
kind: 'unavailable',
reason: 'settings-read-only',
}
}
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' }

View File

@@ -24,37 +24,53 @@ function fail<T>(message: string): RpcResponse<T> {
function harness(options: {
provider?: boolean
providerActive?: boolean
settingsNamespace?: boolean
apiKeyEnv?: string | null
literal?: boolean
configured?: () => boolean
credential?: { source?: string; writable: boolean }
describeFailure?: string
settingsWritable?: boolean
providersRejectOnce?: boolean
} = {}) {
let fileConfigured = false
let rejectProviders = options.providersRejectOnce === true
const configured = options.configured ?? (() => fileConfigured)
const face = {
llm: {
providers: () => Promise.resolve(ok({
providers: options.provider === false
? []
: [{
provider: 'deepseek-official',
displayName: 'DeepSeek',
settingsNs: 'llm-deepseek',
settingsPath: [],
active: true,
}],
})),
providers: () => {
if (rejectProviders) {
rejectProviders = false
return Promise.reject(new Error('provider transport unavailable'))
}
return Promise.resolve(ok({
providers: options.provider === false
? []
: [{
provider: 'deepseek-official',
displayName: 'DeepSeek',
settingsNs: 'llm-deepseek',
settingsPath: [],
active: options.providerActive ?? 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 }],
}],
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 }],
}],
})),
},
credentials: {
@@ -94,7 +110,9 @@ describe('DeepSeekOnboardingDialog', () => {
render(<DeepSeekOnboardingDialog {...h.props} />)
expect(await screen.findByRole('dialog', { name: en.onboardingTitle })).toBeTruthy()
expect(screen.getByText(en.onboardingDescription)).toBeTruthy()
expect(screen.getByRole('button', { name: en.onboardingGoToSettings })).toBeTruthy()
const action = screen.getByRole('button', { name: en.onboardingGoToSettings })
expect(action).toBeTruthy()
expect(document.activeElement).toBe(action)
expect(screen.queryByRole('textbox')).toBeNull()
})
@@ -125,11 +143,38 @@ describe('DeepSeekOnboardingDialog', () => {
expect(h.openSection).toHaveBeenCalledWith('models')
})
it('uses the general diagnostic for a missing read-only credential', async () => {
const h = harness({ credential: { writable: false } })
it('explains read-only credential and settings deployments', async () => {
for (const h of [
harness({ credential: { writable: false } }),
harness({ settingsWritable: false }),
]) {
const view = render(<DeepSeekOnboardingDialog {...h.props} />)
await screen.findByRole('dialog', { name: en.onboardingUnavailableTitle })
expect(screen.getByText(en.onboardingReadOnly)).toBeTruthy()
view.unmount()
}
})
it('distinguishes an initial transport failure from deployment misconfiguration', async () => {
const h = harness({ providersRejectOnce: true })
render(<DeepSeekOnboardingDialog {...h.props} />)
await screen.findByRole('dialog', { name: en.onboardingUnavailableTitle })
expect(screen.getByText(en.onboardingConfigurationUnavailable)).toBeTruthy()
expect(screen.getByText(en.onboardingLoadFailed)).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: en.onboardingGoToSettings }))
expect(h.openSection).toHaveBeenCalledWith('models')
})
it('uses the configuration diagnostic for inactive or unresolvable adapters', async () => {
for (const h of [
harness({ providerActive: false }),
harness({ settingsNamespace: false }),
harness({ apiKeyEnv: null }),
]) {
const view = render(<DeepSeekOnboardingDialog {...h.props} />)
await screen.findByRole('dialog', { name: en.onboardingUnavailableTitle })
expect(screen.getByText(en.onboardingConfigurationUnavailable)).toBeTruthy()
view.unmount()
}
})
it('skips an absent adapter and already-configured literal or environment credentials', async () => {

View File

@@ -50,59 +50,48 @@ describe('deepSeekReadiness', () => {
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 },
})
}))).toEqual({ kind: 'configured' })
expect(deepSeekReadiness(state({
rows: [row({ credential: { configured: true, source: 'env', writable: false } })],
}))).toMatchObject({
kind: 'configured',
source: 'credential',
credential: { 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', source: 'literal' })
}))).toEqual({ kind: 'configured' })
})
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',
reason: 'load-failed',
})
expect(deepSeekReadiness(state({
rows: [row({ entry: { ...row().entry, active: false } })],
}))).toMatchObject({ kind: 'unavailable', reason: 'provider-inactive' })
}))).toEqual({ kind: 'unavailable', reason: 'provider-inactive' })
expect(deepSeekReadiness(state({
rows: [row({ configured: false })],
}))).toMatchObject({ kind: 'unavailable', reason: 'settings-unavailable' })
}))).toEqual({ kind: 'unavailable', reason: 'settings-unavailable' })
expect(deepSeekReadiness(state({
rows: [row({ apiKeyEnv: undefined })],
}))).toMatchObject({ kind: 'unavailable', reason: 'credential-ref-unavailable' })
}))).toEqual({ kind: 'unavailable', reason: 'credential-ref-unavailable' })
expect(deepSeekReadiness(state({
credentialError: 'credentials service is absent',
}))).toMatchObject({
}))).toEqual({
kind: 'unavailable',
reason: 'credentials-unavailable',
message: 'credentials service is absent',
})
expect(deepSeekReadiness(state({
rows: [row({ credential: undefined })],
}))).toMatchObject({ kind: 'unavailable', reason: 'credentials-unavailable' })
}))).toEqual({ kind: 'unavailable', reason: 'credentials-unavailable' })
expect(deepSeekReadiness(state({
rows: [row({ credential: { configured: false, writable: false } })],
}))).toMatchObject({ kind: 'unavailable', reason: 'credential-read-only' })
}))).toEqual({ kind: 'unavailable', reason: 'credential-read-only' })
expect(deepSeekReadiness(state({ writable: false }))).toEqual({
kind: 'unavailable',
reason: 'settings-read-only',
})
})
})

View File

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