feat(web): route onboarding to Models settings

This commit is contained in:
Yichen Jiang
2026-07-30 13:11:07 +08:00
parent 7fc1b5a777
commit 42d0f3c7ba
16 changed files with 83 additions and 349 deletions
@@ -2,51 +2,6 @@
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;
font-weight: 500;
color: var(--dsw-alias-label-secondary);
}
.input {
width: 100%;
height: 36px;
box-sizing: border-box;
padding-inline: 12px;
border-radius: 10px;
}
.input > input {
width: 100%;
font-size: 13px;
}
.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;
@@ -1,14 +1,13 @@
/**
* 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.
* provider/settings/credential join as the Models page; the prompt only
* routes the user to that page's single credential editor.
*/
import { useEffect, useState } from 'react'
import type { ReactNode } from 'react'
import type { 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 { Button, Modal } from '@deepseek-ai/dsh-client-ui-primitives'
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-web-react'
import type { ModelsSettingsState, ModelsSettingsStore } from './store.ts'
import { deepSeekReadiness } from './store.ts'
@@ -21,8 +20,6 @@ export interface DeepSeekOnboardingInjected {
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
}
@@ -31,40 +28,23 @@ export interface DeepSeekOnboardingInjected {
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.
* Prompt a first-run user to open Models while the official adapter exists
* and its effective credential is not configured.
* @param props - settings-shell owner state and Models feature dependencies.
* @returns the controlled modal or null when onboarding needs no intervention.
*/
export function DeepSeekOnboardingDialog(props: DeepSeekOnboardingDialogProps): ReactNode {
const { active, openSection, controller, useSnapshot, credentials, t } = props
const { active, openSection, controller, useSnapshot, 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)
}
@@ -73,42 +53,6 @@ export function DeepSeekOnboardingDialog(props: DeepSeekOnboardingDialogProps):
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
@@ -116,9 +60,6 @@ export function DeepSeekOnboardingDialog(props: DeepSeekOnboardingDialogProps):
const diagnostic = unavailable && readiness.reason === 'credentials-unavailable'
? t('onboardingCredentialsUnavailable')
: t('onboardingConfigurationUnavailable')
const displayName = readiness.kind === 'credential-missing'
? readiness.displayName
: 'DeepSeek'
return (
<Modal
@@ -132,55 +73,13 @@ export function DeepSeekOnboardingDialog(props: DeepSeekOnboardingDialogProps):
<Button
variant="primary"
className={styles['primary']}
disabled={busy || (!unavailable && keyDraft.length === 0)}
onClick={() => { void (unavailable ? retry() : save()) }}
onClick={openModels}
>
{busy
? t('onboardingSaving')
: unavailable
? t('retry')
: t('onboardingSave')}
{t('onboardingGoToSettings')}
</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>
{unavailable ? <p className={styles['diagnostic']}>{diagnostic}</p> : undefined}
</Modal>
)
}
@@ -1,9 +1,9 @@
/**
* 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.
* one provider/settings/credential join; the overlay routes missing-key users
* to the full page's single credential editor. Export discipline:
* packages/client/AGENTS.md.
*/
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
import { deferRegistration } from '@deepseek-ai/dsh-client-ui-slots'
@@ -68,7 +68,6 @@ export function apply(ctx: ClientContext): void {
const onboardingInjected = (): DeepSeekOnboardingInjected => ({
controller,
useSnapshot,
credentials: connection.api.credentials,
t,
})
@@ -27,16 +27,10 @@ export const en = {
effort: 'Reasoning effort',
effortInherit: 'Default',
advancedHint: 'Other fields live in settings.yaml; edit that section directly.',
onboardingTitle: 'Add a DeepSeek API key',
onboardingTitle: 'Add an API key to get started',
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…',
onboardingGoToSettings: 'Go to settings',
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.',
@@ -69,16 +63,10 @@ export const zh: typeof en = {
effort: '推理强度',
effortInherit: '默认',
advancedHint: '其余字段在 settings.yaml 中,请直接编辑对应段。',
onboardingTitle: '添加 DeepSeek API 密钥',
onboardingTitle: '添加一个 API Key 开始使用',
onboardingDescription: '配置 DeepSeek 官方模型,即可开始使用。',
onboardingKey: 'API 密钥',
onboardingKeyPlaceholder: '输入 DeepSeek API 密钥',
onboardingAdvanced: '模型高级设置',
onboardingSave: '保存并继续',
onboardingSaving: '保存中…',
onboardingGoToSettings: '前往配置',
onboardingLater: '稍后配置',
onboardingSaveFailed: '无法保存 API 密钥',
onboardingVerifyFailed: '密钥已写入,但无法确认配置状态。请重试。',
onboardingUnavailableTitle: '无法在此配置 DeepSeek',
onboardingCredentialsUnavailable: '当前部署没有可写的凭据存储。请挂载 @deepseek-ai/dsh-credentials-local 后重试。',
onboardingConfigurationUnavailable: '无法在此解析 DeepSeek 的实时配置能力。请检查部署组合后重试。',
@@ -181,7 +181,7 @@ 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: 'credential-missing' }
| {
kind: 'unavailable'
reason:
@@ -196,7 +196,7 @@ export type DeepSeekReadiness =
/**
* 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.
* is not mounted and therefore cannot be repaired by navigating to Models.
* @param state - current shared Models join snapshot.
* @returns the onboarding state without reading a parallel fact source.
*/
@@ -264,9 +264,5 @@ export function deepSeekReadiness(state: ModelsSettingsState): DeepSeekReadiness
message: `credential reference "${row.apiKeyEnv}" is missing and read-only`,
}
}
return {
kind: 'credential-missing',
displayName: row.entry.displayName,
ref: row.apiKeyEnv,
}
return { kind: 'credential-missing' }
}