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

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-models/README.md
README.md: de3c5b93e5f89e7e51236bea436ac71d226b6684
README.zh.md: 1688f09104ee441e64f1747a07dd73f1be08fc1d
README.md: eea761859a187b13e08e3cd48e2120cc0cdead15
README.zh.md: 31e91b0eb1ef52bb3eb6782bdf115602205d5760

View File

@@ -2,11 +2,11 @@
English | [中文](README.zh.md)
Models settings plugin: the provider configuration page and official-DeepSeek first-run credential overlay. It joins three wire domains into one shared snapshot — `llm.providers` (the configurable-provider directory with each route's live/dormant state), `settings.describe` (serialized schemas, layered redacted values, secret slots), and `credentials.describe` (value-free configured/source/writable badges) — and renders provider rows with one editor card at a time.
Models settings plugin: the provider configuration page and official-DeepSeek first-run routing overlay. It joins three wire domains into one shared snapshot — `llm.providers` (the configurable-provider directory with each route's live/dormant state), `settings.describe` (serialized schemas, layered redacted values, secret slots), and `credentials.describe` (value-free configured/source/writable badges) — and renders provider rows with one editor card at a time.
Rows are the *configured* providers (their profile resolves in the owning namespace); a whole-section provider whose key is not configured anywhere (the first-run DeepSeek posture) renders as its open setup card instead of a row, and the add flow is a card carrying the dormant-directory provider select — a bare-mounted `llm-pi-ai` offers its whole installed catalog before any route exists. The editor is a hand-written card per adapter family: the primary field is a single **API key** input — the page never asks for an environment-variable name; a typed key stores **write-only** through `credentials.set` under the profile's reference, deriving `<ROUTE>_API_KEY` when the profile has none, and the pi-ai profile records that derivation as `apiKeyEnv`, so `settings.yaml` never carries a key value. The collapsed 自定义设置 fold carries the curated extras — `baseURL` for both families (the deepseek placeholder shows the public endpoint), plus `reasoningEffort` (deepseek) or `reasoning` (pi-ai); every other profile field stays owned by `settings.yaml`. A row is deletable only when the user layer alone carries it (removal restores the composition base).
The first-run overlay projects `deepseek-official` readiness from that same joined snapshot. A configured literal `apiKey` secret sidecar or configured credential reference suppresses the prompt, including a read-only launch-environment credential. A mounted adapter with a writable missing reference opens the password form and writes only through `credentials.set`; success is accepted only after a fresh describe reports configured. An absent adapter is skipped because a browser form cannot mount Cordis plugins, while a present but unusable settings or credential capability produces a deployment diagnostic and an advanced link opens the Models section.
The first-run overlay projects `deepseek-official` readiness from that same joined snapshot. A configured literal `apiKey` secret sidecar or configured credential reference suppresses the prompt, including a read-only launch-environment credential. A mounted adapter with a missing writable reference shows one action that opens Settings on the Models section, whose existing setup card exclusively owns key input and `credentials.set`; the overlay never holds a secret. An absent adapter is skipped because browser navigation cannot mount Cordis plugins, while an unusable settings or credential capability produces a deployment diagnostic with the same route to Models.
Apply semantics mirror the settings seam: an edit without removals lands as a minimal `settings.update` merge patch, while clearing a fold field back to inherited or deleting a row lands through `settings.replace` of the whole user section so removals actually take effect — safe wholesale, because the section stores key references, never key values. The page refetches on the pushed invalidations (`settings/changed`, `credentials/changed`, `models/changed`, and `connection/reset`) once it has loaded, so an external `settings.yaml` edit, a second tab, or a settings-born route converges without polling.

View File

@@ -2,11 +2,11 @@
[English](README.md) | 中文
模型设置插件:提供方配置页和 DeepSeek 官方首次使用凭据浮层。它把三个协议领域汇聚为一个共享快照:`llm.providers`(可配置提供方目录,含每条路由的存活/休眠状态)、`settings.describe`(序列化 schema、分层脱敏值、secret 槽位)与 `credentials.describe`(不含值的 configured/source/writable 徽标);页面据此渲染提供方行,一次只展开一张编辑卡片。
模型设置插件:提供方配置页和 DeepSeek 官方首次使用跳转浮层。它把三个协议领域汇聚为一个共享快照:`llm.providers`(可配置提供方目录,含每条路由的存活/休眠状态)、`settings.describe`(序列化 schema、分层脱敏值、secret 槽位)与 `credentials.describe`(不含值的 configured/source/writable 徽标);页面据此渲染提供方行,一次只展开一张编辑卡片。
行是*已配置*的提供方(其 profile 在所属 namespace 中解析得出);密钥未在任何地方配置的整分节提供方(DeepSeek 的首次运行姿态)会渲染为其展开的设置卡片而非一行,「新增」流程则是一张承载休眠目录提供方选择框的卡片——裸挂载的 `llm-pi-ai` 在任何路由存在之前就能提供其完整的已安装 catalog。编辑器是每个适配器家族各一张的手写卡片:主字段是单独一个 **API 密钥**输入框——页面从不询问环境变量名;键入的密钥经 `credentials.set` 以**只写**方式存入 profile 的引用之下,profile 没有引用时便派生 `<ROUTE>_API_KEY`,pi-ai profile 会把这次派生记录为 `apiKeyEnv`,因此 `settings.yaml` 从不携带密钥值。收起的「自定义设置」折叠区承载精选的额外字段——两个家族都有 `baseURL`(deepseek 的占位符显示公共端点),另加 `reasoningEffort`(deepseek)或 `reasoning`(pi-ai);其余每个 profile 字段仍归 `settings.yaml` 所有。只有当某行仅由用户层承载时它才可删除(删除会还原组合 base)。
首次使用浮层从同一个联接快照得出 `deepseek-official` 的就绪状态。若 `apiKey` 字面量对应的 secret 槽位标记为已设置,或凭据引用已配置,浮层就不再显示,其中包括来自启动环境且只读的凭据。适配器已挂载、引用可写但尚未配置时,浮层会打开密码表单,且只经 `credentials.set` 写入;只有重新调用 describe 并确认已配置后,才会接受此次提交。适配器缺失时直接跳过,因为浏览器表单无法挂载 Cordis 插件;提供方存在但设置或凭据能力不可用时,则显示部署诊断,并通过高级设置链接打开 Models 分区。
首次使用浮层从同一个联接快照得出 `deepseek-official` 的就绪状态。若 `apiKey` 字面量对应的 secret 槽位标记为已设置,或凭据引用已配置,浮层就不再显示,其中包括来自启动环境且只读的凭据。适配器已挂载、引用可写但尚未配置时,浮层只显示一个操作按钮,用于打开「设置」的 Models 分区;密钥输入和 `credentials.set` 仅由该分区已有的设置卡片负责,浮层绝不持有 secret。适配器缺失时直接跳过,因为浏览器导航无法挂载 Cordis 插件;设置或凭据能力不可用时则显示部署诊断,并提供同一个前往 Models 的入口。
「应用」语义与 settings seam 呈镜像:不含删除的编辑以最小的 `settings.update` 合并 patch 落地,把折叠区字段清回继承值或删除整行则经对整个用户分节的 `settings.replace` 落地,使删除真正生效——整体替换是安全的,因为该分节存的是密钥引用,从不存密钥值。页面加载完成后会在推送的失效事件(`settings/changed`、`credentials/changed`、`models/changed` 与 `connection/reset`)上重拉,因此外部的 `settings.yaml` 编辑、第二个标签页或 settings 新生的路由都无需轮询即可收敛。

View File

@@ -1,6 +1,6 @@
{
"name": "@deepseek-ai/dsh-client-ui-models",
"description": "Models settings and official-DeepSeek first-run credential UI over one live provider/settings/credential join",
"description": "Models settings and official-DeepSeek first-run routing over one live provider/settings/credential join",
"version": "0.0.1",
"private": true,
"type": "module",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
// @vitest-environment jsdom
/** First-run DeepSeek dialog behavior over the shared Models join. */
/** First-run DeepSeek prompt 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'
@@ -28,14 +28,9 @@ function harness(options: {
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({
@@ -76,7 +71,6 @@ function harness(options: {
},
}))
: Promise.resolve(fail(options.describeFailure)),
set,
},
}
const controller = new ModelsSettingsStore(face as never)
@@ -89,145 +83,53 @@ function harness(options: {
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 } }
return { controller, openSection, props, configure: () => { fileConfigured = true } }
}
describe('DeepSeekOnboardingDialog', () => {
it('loads on first entry and presents an accessible write-only key form', async () => {
it('loads on first entry and presents one accessible route to Models', 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')
expect(await screen.findByRole('dialog', { name: en.onboardingTitle })).toBeTruthy()
expect(screen.getByText(en.onboardingDescription)).toBeTruthy()
expect(screen.getByRole('button', { name: en.onboardingGoToSettings })).toBeTruthy()
expect(screen.queryByRole('textbox')).toBeNull()
})
it('stores through credentials.set, verifies through describe, clears the draft, and closes', async () => {
it('opens the Models section and dismisses the prompt', 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)
await screen.findByRole('dialog')
fireEvent.click(screen.getByRole('button', { name: en.onboardingGoToSettings }))
expect(h.openSection).toHaveBeenCalledWith('models')
expect(screen.queryByRole('dialog', { name: en.onboardingTitle })).toBeNull()
})
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}`)),
})
it('allows configure-later dismissal without opening settings', async () => {
const h = harness()
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()
expect(h.openSection).not.toHaveBeenCalled()
})
it('shows an actionable deployment diagnostic when credentials are unavailable', async () => {
it('routes an unavailable credential deployment to Models with a diagnostic', 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)
})
fireEvent.click(screen.getByRole('button', { name: en.onboardingGoToSettings }))
expect(h.openSection).toHaveBeenCalledWith('models')
})
it('uses the deployment diagnostic for a missing read-only credential', async () => {
it('uses the general 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 () => {
@@ -252,14 +154,12 @@ describe('DeepSeekOnboardingDialog', () => {
await waitFor(() => { expect(screen.queryByRole('dialog')).toBeNull() })
})
it('clears a typed draft when the onboarding owner becomes inactive', async () => {
it('stays hidden while the onboarding owner is 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} />)
const view = render(<DeepSeekOnboardingDialog {...h.props} active={false} />)
await act(async () => { await h.controller.load() })
expect(screen.queryByRole('dialog')).toBeNull()
view.rerender(<DeepSeekOnboardingDialog {...h.props} active />)
expect((await screen.findByLabelText<HTMLInputElement>(en.onboardingKey)).value).toBe('')
expect(await screen.findByRole('dialog', { name: en.onboardingTitle })).toBeTruthy()
})
})

View File

@@ -43,12 +43,8 @@ describe('deepSeekReadiness', () => {
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('reports a missing writable effective credential', () => {
expect(deepSeekReadiness(state())).toEqual({ kind: 'credential-missing' })
})
it('accepts file and process-environment credentials without prompting', () => {