fix(web): skip unavailable DeepSeek onboarding modal
This commit is contained in:
@@ -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: aac437a13f6465196fcf1f8908b1d9ea2ccef401
|
||||
README.zh.md: 468537ac217a46395f2ec78174efa1f5d75a679d
|
||||
README.md: adfbc084e1b0e227d50032cb6c924401b81c6a79
|
||||
README.zh.md: 4ee7d4efa729fdccee392ab8e55078b5a4a239ef
|
||||
|
||||
@@ -6,7 +6,7 @@ Models settings plugin: the provider configuration page and official-DeepSeek fi
|
||||
|
||||
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 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.
|
||||
The first-run overlay projects `deepseek-official` readiness from that same joined snapshot. It recognizes the official adapter through its `llm-deepseek` configurable-provider declaration, so an undeclared live route with the same provider id is not treated as repairable configuration. A configured literal `apiKey` secret sidecar or configured credential reference suppresses the prompt, including a read-only launch-environment credential. Only a mounted adapter with a missing writable reference shows the 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, inactive route, failed join, read-only deployment, or unusable settings or credential capability is skipped so onboarding cannot block the rest of the product; the Models page remains the diagnostic surface.
|
||||
|
||||
Every edit lands as `settings.mutate` path ops against the stored section — a set per changed field, an unset per cleared one, and a single unset for a deleted row. The page only ever holds the REDACTED descriptor, so it names the fields it can see rather than rebuilding a section: a stored literal secret it never received is mentioned by no op and survives. Each write carries the `revision` the card opened at, so a concurrent write from another tab or an external `settings.yaml` edit is refused as `settings-conflict` and the card asks the user to reopen instead of replaying its stale snapshot. 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.
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
行是*已配置*的提供方(其 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 槽位标记为已设置,或凭据引用已配置,浮层就不再显示,其中包括来自启动环境且只读的凭据。适配器已挂载、引用可写但尚未配置时,浮层只显示一个操作按钮,用于打开「设置」的 Models 分区;密钥输入和 `credentials.set` 仅由该分区已有的设置卡片负责,浮层绝不持有 secret。适配器缺失时直接跳过,因为浏览器导航无法挂载 Cordis 插件;设置或凭据能力不可用时则显示部署诊断,并提供同一个前往 Models 的入口。
|
||||
首次使用浮层从同一个联接快照得出 `deepseek-official` 的就绪状态。它通过 `llm-deepseek` 的可配置提供方声明识别官方适配器,因此不会把同一提供方 ID 下没有相应声明的存活路由视为可通过配置修复。若 `apiKey` 字面量对应的 secret 槽位标记为已设置,或凭据引用已配置,浮层就不再显示,其中包括来自启动环境且只读的凭据。只有适配器已挂载、引用可写但尚未配置时,浮层才显示一个操作按钮,用于打开「设置」的 Models 分区;密钥输入和 `credentials.set` 仅由该分区已有的设置卡片负责,浮层绝不持有 secret。适配器缺失、路由未激活、联接失败、部署只读、设置能力不可用或凭据能力不可用时均跳过,以免首次使用引导阻塞产品的其他部分;Models 页仍是诊断界面。
|
||||
|
||||
每一次编辑都以 `settings.mutate` 的路径 op 落到已存分节上——每个变更字段一条 set、每个清空字段一条 unset、删除整行则是单独一条 unset。页面自始至终只持有**脱敏后**的 descriptor,因此它点名自己看得见的字段,而不是重建分节:一个它从未收到过的已存字面机密不会被任何 op 提及,也就得以留存。每次写入都携带该卡片打开时的 `revision`,因此来自另一个标签页或对 `settings.yaml` 的外部编辑所产生的并发写入会以 `settings-conflict` 被拒绝,卡片会请用户重新打开,而不是把自己的陈旧快照重放上去。页面加载完成后会在推送的失效事件(`settings/changed`、`credentials/changed`、`models/changed` 与 `connection/reset`)上重拉,因此外部的 `settings.yaml` 编辑、第二个标签页或 settings 新生的路由都无需轮询即可收敛。
|
||||
|
||||
|
||||
@@ -2,13 +2,6 @@
|
||||
width: min(420px, 100%);
|
||||
}
|
||||
|
||||
.diagnostic {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
.primary {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@@ -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 { DeepSeekReadiness, ModelsSettingsState, ModelsSettingsStore } from './store.ts'
|
||||
import type { ModelsSettingsState, ModelsSettingsStore } from './store.ts'
|
||||
import { deepSeekReadiness } from './store.ts'
|
||||
import type { en } from './locales.ts'
|
||||
import styles from './DeepSeekOnboardingDialog.module.css'
|
||||
@@ -28,35 +28,11 @@ 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.
|
||||
@@ -84,34 +60,26 @@ export function DeepSeekOnboardingDialog(props: DeepSeekOnboardingDialogProps):
|
||||
|
||||
if (!active || dismissed) return null
|
||||
|
||||
let unavailableReason: UnavailableReason | undefined
|
||||
switch (readiness.kind) {
|
||||
case 'loading':
|
||||
case 'adapter-absent':
|
||||
case 'configured':
|
||||
case 'unavailable':
|
||||
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
|
||||
open
|
||||
onClose={close}
|
||||
title={unavailable ? t('onboardingUnavailableTitle') : t('onboardingTitle')}
|
||||
title={t('onboardingTitle')}
|
||||
closeLabel={t('onboardingLater')}
|
||||
{...(unavailable ? {} : { description: t('onboardingDescription') })}
|
||||
description={t('onboardingDescription')}
|
||||
className={styles['dialog'] as string}
|
||||
footer={(
|
||||
<Button
|
||||
@@ -123,8 +91,6 @@ export function DeepSeekOnboardingDialog(props: DeepSeekOnboardingDialogProps):
|
||||
{t('onboardingGoToSettings')}
|
||||
</Button>
|
||||
)}
|
||||
>
|
||||
{diagnostic === undefined ? undefined : <p className={styles['diagnostic']}>{diagnostic}</p>}
|
||||
</Modal>
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -32,11 +32,6 @@ export const en = {
|
||||
onboardingDescription: 'Configure the official DeepSeek provider to start building.',
|
||||
onboardingGoToSettings: 'Go to settings',
|
||||
onboardingLater: 'Configure later',
|
||||
onboardingUnavailableTitle: 'DeepSeek setup is unavailable',
|
||||
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}). */
|
||||
@@ -71,9 +66,4 @@ export const zh: typeof en = {
|
||||
onboardingDescription: '配置 DeepSeek 官方模型,即可开始使用。',
|
||||
onboardingGoToSettings: '前往配置',
|
||||
onboardingLater: '稍后配置',
|
||||
onboardingUnavailableTitle: '无法在此配置 DeepSeek',
|
||||
onboardingLoadFailed: '无法加载 DeepSeek 配置。请检查连接,然后在模型设置中重试。',
|
||||
onboardingCredentialsUnavailable: '当前部署无法使用凭据存储。请检查部署配置。',
|
||||
onboardingReadOnly: '当前部署不允许在此修改 DeepSeek API 密钥。请联系管理员提供凭据。',
|
||||
onboardingConfigurationUnavailable: '当前部署无法使用 DeepSeek 配置。请检查部署组合。',
|
||||
}
|
||||
|
||||
@@ -215,8 +215,8 @@ 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 navigating to Models.
|
||||
* join used by the Models page. A missing official configurable-provider
|
||||
* declaration means the adapter is not repairable by navigating to Models.
|
||||
* @param state - current shared Models join snapshot.
|
||||
* @returns the onboarding state without reading a parallel fact source.
|
||||
*/
|
||||
@@ -230,7 +230,10 @@ export function deepSeekReadiness(state: ModelsSettingsState): DeepSeekReadiness
|
||||
reason: 'load-failed',
|
||||
}
|
||||
}
|
||||
const row = state.rows.find(candidate => candidate.entry.provider === 'deepseek-official')
|
||||
const row = state.rows.find(candidate =>
|
||||
candidate.entry.provider === 'deepseek-official'
|
||||
&& candidate.entry.settingsNs === 'llm-deepseek'
|
||||
&& candidate.entry.settingsPath.length === 0)
|
||||
if (row === undefined) return { kind: 'adapter-absent' }
|
||||
if (!row.entry.active) {
|
||||
return {
|
||||
|
||||
@@ -25,6 +25,7 @@ function fail<T>(message: string): RpcResponse<T> {
|
||||
function harness(options: {
|
||||
provider?: boolean
|
||||
providerActive?: boolean
|
||||
providerSettingsNs?: string
|
||||
settingsNamespace?: boolean
|
||||
apiKeyEnv?: string | null
|
||||
literal?: boolean
|
||||
@@ -32,16 +33,14 @@ function harness(options: {
|
||||
credential?: { source?: string; writable: boolean }
|
||||
describeFailure?: string
|
||||
settingsWritable?: boolean
|
||||
providersRejectOnce?: boolean
|
||||
providersReject?: boolean
|
||||
} = {}) {
|
||||
let fileConfigured = false
|
||||
let rejectProviders = options.providersRejectOnce === true
|
||||
const configured = options.configured ?? (() => fileConfigured)
|
||||
const face = {
|
||||
llm: {
|
||||
providers: () => {
|
||||
if (rejectProviders) {
|
||||
rejectProviders = false
|
||||
if (options.providersReject === true) {
|
||||
return Promise.reject(new Error('provider transport unavailable'))
|
||||
}
|
||||
return Promise.resolve(ok({
|
||||
@@ -50,7 +49,7 @@ function harness(options: {
|
||||
: [{
|
||||
provider: 'deepseek-official',
|
||||
displayName: 'DeepSeek',
|
||||
settingsNs: 'llm-deepseek',
|
||||
settingsNs: options.providerSettingsNs ?? 'llm-deepseek',
|
||||
settingsPath: [],
|
||||
active: options.providerActive ?? true,
|
||||
}],
|
||||
@@ -135,45 +134,20 @@ describe('DeepSeekOnboardingDialog', () => {
|
||||
expect(h.openSection).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
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()
|
||||
fireEvent.click(screen.getByRole('button', { name: en.onboardingGoToSettings }))
|
||||
expect(h.openSection).toHaveBeenCalledWith('models')
|
||||
})
|
||||
|
||||
it('explains read-only credential and settings deployments', async () => {
|
||||
it('does not block the product when DeepSeek setup is unavailable', async () => {
|
||||
for (const h of [
|
||||
harness({ describeFailure: 'credentials service is absent' }),
|
||||
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.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({ providersReject: true }),
|
||||
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()
|
||||
await act(async () => { await h.controller.load() })
|
||||
expect(screen.queryByRole('dialog')).toBeNull()
|
||||
expect(h.openSection).not.toHaveBeenCalled()
|
||||
view.unmount()
|
||||
}
|
||||
})
|
||||
@@ -181,6 +155,7 @@ describe('DeepSeekOnboardingDialog', () => {
|
||||
it('skips an absent adapter and already-configured literal or environment credentials', async () => {
|
||||
for (const h of [
|
||||
harness({ provider: false }),
|
||||
harness({ providerSettingsNs: '' }),
|
||||
harness({ literal: true, describeFailure: 'credential seam absent' }),
|
||||
harness({ configured: () => true, credential: { source: 'env', writable: false } }),
|
||||
]) {
|
||||
|
||||
@@ -41,6 +41,14 @@ describe('deepSeekReadiness', () => {
|
||||
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' })
|
||||
expect(deepSeekReadiness(state({
|
||||
rows: [row({
|
||||
entry: {
|
||||
...row().entry,
|
||||
settingsNs: '',
|
||||
},
|
||||
})],
|
||||
}))).toEqual({ kind: 'adapter-absent' })
|
||||
})
|
||||
|
||||
it('reports a missing writable effective credential', () => {
|
||||
|
||||
Reference in New Issue
Block a user