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

@@ -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%;
}

View File

@@ -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<ModelsSettingsState>
/** 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<string | undefined>(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<void> => {
/* 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<void> => {
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 (
<Modal
open
onClose={close}
title={unavailable ? t('onboardingUnavailableTitle') : t('onboardingTitle')}
closeLabel={t('onboardingLater')}
{...(unavailable ? {} : { description: t('onboardingDescription') })}
className={styles['dialog'] as string}
footer={(
<Button
variant="primary"
className={styles['primary']}
disabled={busy || (!unavailable && keyDraft.length === 0)}
onClick={() => { void (unavailable ? retry() : save()) }}
>
{busy
? t('onboardingSaving')
: unavailable
? t('retry')
: t('onboardingSave')}
</Button>
)}
>
<div className={styles['fields']}>
<label className={styles['field']}>
<span className={styles['label']}>{t('provider')}</span>
<Input
className={styles['input'] as string}
type="text"
aria-label={t('provider')}
value={displayName}
readOnly
/>
</label>
{readiness.kind === 'credential-missing'
? (
<label className={styles['field']}>
<span className={styles['label']}>{t('onboardingKey')}</span>
<Input
className={styles['input'] as string}
type="password"
autoComplete="off"
autoCapitalize="none"
spellCheck={false}
aria-label={t('onboardingKey')}
placeholder={t('onboardingKeyPlaceholder')}
value={keyDraft}
disabled={busy}
onChange={(event) => {
setKeyDraft(event.target.value)
setFailure(undefined)
}}
/>
</label>
)
: <p className={styles['diagnostic']}>{diagnostic}</p>}
<Button variant="ghost" size="sm" className={styles['advanced']} onClick={openModels}>
{t('onboardingAdvanced')}
</Button>
{failure !== undefined ? <p className={styles['error']} role="alert">{failure}</p> : null}
</div>
</Modal>
)
}

View File

@@ -68,7 +68,7 @@ function StatusBadges({ row, t }: { row: ProviderRow; t: ModelsSectionInjected['
{row.entry.active
? <span className={styles['badgeOk']}>{t('active')}</span>
: <span className={styles['badgeMuted']}>{t('dormant')}</span>}
{row.credential !== undefined && !row.credential.configured
{!row.literalApiKeyConfigured && row.credential !== undefined && !row.credential.configured
? <span className={styles['badgeWarn']}>{t('keyMissing')}</span>
: null}
</span>

View File

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

View File

@@ -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 的实时配置能力。请检查部署组合后重试。',
}

View File

@@ -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<ModelsSettingsState> = createSnapshotStore<ModelsSettingsState>({
status: 'idle', error: null, writable: false, rows: [], namespaces: new Map(),
status: 'idle', error: null, credentialError: null, writable: false, rows: [], namespaces: new Map(),
})
/** Latest load wins; an older response never overwrites a newer one. */
@@ -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<string, CredentialView> = {}
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,
}
}