mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
fix(web): address onboarding review feedback
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 .agents/notes/implemented/feature/2026-07-30-deepseek-onboarding-credential-setup.md
|
||||
2026-07-30-deepseek-onboarding-credential-setup.md: 9249b173f8f6da5dc2abf2fb147a3c9aba99c00f
|
||||
2026-07-30-deepseek-onboarding-credential-setup.zh.md: f3c647669bd9e0974b2c9f0c407eba0c600bc656
|
||||
2026-07-30-deepseek-onboarding-credential-setup.md: 3f75a0893623afc0908cb48f2b838321ed9dedd3
|
||||
2026-07-30-deepseek-onboarding-credential-setup.zh.md: 62f8f0b99f167b22051aaddf7331a043bd2ea812
|
||||
|
||||
@@ -16,7 +16,7 @@ The [web configuration plane](../architecture/2026-07-30-web-config-plane.md) ma
|
||||
|
||||
**The prompt routes to the one credential editor.** A mounted, active adapter with a resolved, writable, unconfigured reference presents one action that opens Settings on Models. The existing DeepSeek setup card there exclusively owns the password input, `credentials.set({ref, value})`, write failures, and post-write refresh; the onboarding overlay never holds or submits a secret. An unavailable settings or credential capability keeps its deployment diagnostic and routes to the same page, while an absent adapter remains skipped because navigation cannot mount a Cordis plugin.
|
||||
|
||||
**Unavailable capability states stay honest.** An absent configurable-provider entry suppresses the form because it cannot repair the composition. A present provider whose settings or credential capability cannot be resolved renders an actionable deployment diagnostic. Cancel dismisses the overlay for the current mounted surface and writes no completion fact. Settings, credential, provider-topology, and connection invalidations all refresh the shared join, so an external credential update closes an open prompt without a reload.
|
||||
**Unavailable states stay honest.** An absent configurable-provider entry suppresses the prompt because navigation cannot repair the composition. A present provider whose settings or credential capability cannot be resolved renders an actionable deployment diagnostic; a failed initial join names the connection problem and leads to the Models retry surface. Configure later dismisses the overlay for the current mounted surface and writes no completion fact. Settings, credential, provider-topology, and connection invalidations all refresh the shared join, so an external credential update closes an open prompt without a reload.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
@@ -26,7 +26,7 @@ The [web configuration plane](../architecture/2026-07-30-web-config-plane.md) ma
|
||||
|
||||
**Writing the API key into provider settings** — rejected because a literal secret would enter the settings mutation path and whole-section replacement cannot safely reconstruct redacted values. Credential storage is already the product seam and supplies immediate invalidation.
|
||||
|
||||
**Showing the same key form when `llm-deepseek` is absent** — rejected because success would only store an unused environment reference; the browser has no supported operation that mounts the missing Cordis plugin.
|
||||
**Showing the prompt when `llm-deepseek` is absent** — rejected because browser navigation has no supported operation that mounts the missing Cordis plugin.
|
||||
|
||||
## Consequences
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ Status: implemented
|
||||
|
||||
**浮层只负责跳转到唯一的凭据编辑器。**适配器已挂载且处于活跃状态,其引用可解析、可写但尚未配置时,界面会显示一个操作按钮,用于打开「设置」的 Models 分区。该分区已有的 DeepSeek 设置卡片全权负责密码输入框、`credentials.set({ref, value})`、写入失败处理和写入后刷新;首次使用浮层绝不持有或提交 secret。设置或凭据能力不可用时会保留部署诊断,并提供前往同一页面的入口;适配器缺失时仍直接跳过,因为导航无法挂载 Cordis 插件。
|
||||
|
||||
**能力不可用时如实呈现。**可配置提供方条目缺失时不显示表单,因为它无法修复当前组合。提供方存在,但设置或凭据能力无法解析时,界面会显示可采取操作的部署诊断。取消只会在当前已挂载界面中关闭浮层,不写入任何完成状态。设置、凭据、提供方拓扑和连接失效事件都会刷新共享联接,因此外部凭据更新无需重新加载页面即可关闭已打开的浮层。
|
||||
**不可用状态如实呈现。**可配置提供方条目缺失时不显示浮层,因为导航无法修复当前组合。提供方存在,但设置或凭据能力无法解析时,界面会显示可采取操作的部署诊断;初始联接失败时会明确指出连接问题,并引导前往 Models 的重试界面。「稍后配置」只会在当前已挂载界面中关闭浮层,不写入任何完成状态。设置、凭据、提供方拓扑和连接失效事件都会刷新共享联接,因此外部凭据更新无需重新加载页面即可关闭已打开的浮层。
|
||||
|
||||
## 曾考虑的替代方案
|
||||
|
||||
@@ -26,7 +26,7 @@ Status: implemented
|
||||
|
||||
**把 API key 写入提供方设置**:不予采用,因为字面量 secret 会进入设置变更路径,而整个分节替换无法安全重建脱敏值。凭据存储已经是产品 seam,并能立即发出失效事件。
|
||||
|
||||
**`llm-deepseek` 缺失时仍显示同一个密钥表单**:不予采用,因为提交成功也只会存储一个无人使用的环境引用;浏览器没有任何受支持的操作可以挂载缺失的 Cordis 插件。
|
||||
**`llm-deepseek` 缺失时仍显示浮层**:不予采用,因为浏览器导航没有任何受支持的操作可以挂载缺失的 Cordis 插件。
|
||||
|
||||
## 后果
|
||||
|
||||
|
||||
@@ -85,7 +85,7 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
}, 60_000)
|
||||
|
||||
it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => {
|
||||
it('keeps the fixture inventory closed', async () => {
|
||||
await assertFixtureInventory(SNAPSHOT_DIR, ['missing.expected.md'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -360,6 +360,7 @@ flowchart TD
|
||||
pkg_client_ui_models --> pkg_client_connection
|
||||
pkg_client_ui_models --> pkg_client_runtime
|
||||
pkg_client_ui_models --> pkg_client_schema_form
|
||||
pkg_client_ui_models --> pkg_client_ui_primitives
|
||||
pkg_client_ui_models --> pkg_client_ui_slots
|
||||
pkg_client_ui_models --> pkg_client_web_react
|
||||
pkg_client_ui_models --> pkg_invariants
|
||||
@@ -486,7 +487,6 @@ flowchart TD
|
||||
pkg_sandbox_local --> pkg_llm
|
||||
pkg_sandbox_local --> pkg_sandbox
|
||||
pkg_sandbox_policy --> pkg_invariants
|
||||
pkg_sandbox_policy --> pkg_paths
|
||||
pkg_sandbox_policy --> pkg_sandbox
|
||||
pkg_sandbox_policy --> pkg_session
|
||||
pkg_session_projection --> pkg_invariants
|
||||
@@ -1072,7 +1072,7 @@ flowchart TD
|
||||
| [`session`](../packages/core/session) | `core` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) |
|
||||
| [`system-prompt`](../packages/core/system-prompt) | `core` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) |
|
||||
| [`web`](../packages/web/web) | `web` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) |
|
||||
| [`client-ui-models`](../packages/client/ui-models) | `client` | [`client-connection`](../packages/client/connection), [`client-runtime`](../packages/client/runtime), [`client-schema-form`](../packages/client/schema-form), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`invariants`](../packages/support/invariants) |
|
||||
| [`client-ui-models`](../packages/client/ui-models) | `client` | [`client-connection`](../packages/client/connection), [`client-runtime`](../packages/client/runtime), [`client-schema-form`](../packages/client/schema-form), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`invariants`](../packages/support/invariants) |
|
||||
| [`client-ui-question`](../packages/client/ui-question) | `client` | [`client-locale`](../packages/client/locale), [`invariants`](../packages/support/invariants) |
|
||||
| [`client-ui-settings-general`](../packages/client/ui-settings-general) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
|
||||
| [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
|
||||
@@ -1105,7 +1105,7 @@ flowchart TD
|
||||
| [`code-runtime-worker`](../packages/code-runtime/code-runtime-worker) | `code-runtime` | [`code-runtime`](../packages/code-runtime/code-runtime), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) |
|
||||
| [`lsp-local`](../packages/lsp/lsp-local) | `lsp` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) |
|
||||
| [`sandbox-local`](../packages/sandbox/sandbox-local) | `sandbox` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) |
|
||||
| [`sandbox-policy`](../packages/sandbox/sandbox-policy) | `sandbox` | [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session) |
|
||||
| [`sandbox-policy`](../packages/sandbox/sandbox-policy) | `sandbox` | [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session) |
|
||||
| [`session-projection`](../packages/session-projection/session-projection) | `session-projection` | [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) |
|
||||
| [`llm-retry`](../packages/llm/llm-retry) | `llm` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) |
|
||||
| [`goal`](../packages/goal/goal) | `goal` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session-projection/session-projection) |
|
||||
|
||||
@@ -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: [{
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 配置。请检查部署组合。',
|
||||
}
|
||||
|
||||
@@ -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' }
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user