feat(web): route onboarding to Models settings
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: e715b3ee9bf4b082f52cf1229b0488799cb2dab6
|
||||
2026-07-30-deepseek-onboarding-credential-setup.zh.md: 0b81182ab074c2a41cae1290e6e3f2c7e43891fa
|
||||
2026-07-30-deepseek-onboarding-credential-setup.md: 9249b173f8f6da5dc2abf2fb147a3c9aba99c00f
|
||||
2026-07-30-deepseek-onboarding-credential-setup.zh.md: f3c647669bd9e0974b2c9f0c407eba0c600bc656
|
||||
|
||||
@@ -14,7 +14,7 @@ The [web configuration plane](../architecture/2026-07-30-web-config-plane.md) ma
|
||||
|
||||
**The settings shell contributes navigation state, not provider policy.** `ui-settings` declares a root-scoped `settings.onboarding` list slot and tells registrants whether the current surface is the empty Hero. Its private `openSection(id)` callback opens the settings panel on one registered section. `ui-models` registers the DeepSeek overlay through the same declaration-aware deferred-registration path as its Models section, so plugin load order does not become a contract.
|
||||
|
||||
**The prompt is a credential-only write path.** A mounted, active adapter with a resolved, writable, unconfigured reference presents a password input. Submit calls only `credentials.set({ref, value})`, clears the React draft after success, refetches the shared join, and closes only when the new descriptor reports `configured: true`. Business failures and transport rejections keep the dialog open, restore its busy state in `finally`, and redact the submitted value from rendered error text. The prompt never writes `apiKey`, `baseURL`, or a redacted settings section; advanced configuration opens Models instead.
|
||||
**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.
|
||||
|
||||
@@ -22,10 +22,12 @@ The [web configuration plane](../architecture/2026-07-30-web-config-plane.md) ma
|
||||
|
||||
**A separate onboarding store and readiness RPC sequence** — rejected because it would create a second client-side interpretation of provider identity, settings paths, secret sidecars, credential references, and invalidation ordering beside the Models page.
|
||||
|
||||
**A second API-key editor inside onboarding** — rejected because the Models page already renders its DeepSeek setup card for exactly this state. Duplicating its secret draft, write errors, and configured-state convergence would add a second security-sensitive UI without another user capability.
|
||||
|
||||
**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.
|
||||
|
||||
## Consequences
|
||||
|
||||
The first-run flow now repairs the shipped adapter without restarting: a keyless browser test boots the real Web composition under an isolated harness home, observes the dialog, stores a generated key into that home's `.env`, verifies no key reaches DOM, ARIA, or browser console output, and confirms the running Models page reports configured. Pure readiness and React tests pin literal, file, process-environment, missing-provider, missing-capability, business-error, transport-error, cancellation, and external-invalidation behavior. The flow deliberately inherits the configuration plane's documented base limitations rather than adding local secret storage, redaction, or settings replacement workarounds.
|
||||
The first-run flow now leads to the shipped adapter's existing editor without restarting: a keyless browser test boots the real Web composition under an isolated harness home, follows the prompt to Models, stores a generated key through that page into the home's `.env`, verifies no key reaches DOM, ARIA, or browser console output, and confirms the running page reports configured. Pure readiness and React tests pin literal, file, process-environment, missing-provider, missing-capability, navigation, cancellation, and external-invalidation behavior. The flow deliberately inherits the configuration plane's documented base limitations rather than adding local secret storage, redaction, or settings replacement workarounds.
|
||||
|
||||
@@ -14,7 +14,7 @@ Status: implemented
|
||||
|
||||
**设置外壳只贡献导航状态,不持有提供方策略。**`ui-settings` 声明一个根作用域的 `settings.onboarding` list slot,并告知注册方当前界面是否为空白 Hero。其私有 `openSection(id)` 回调会打开设置面板并切换到一个已注册分区。`ui-models` 沿用 Models 分区所使用、感知 slot 声明的延迟注册路径来注册 DeepSeek 浮层,因此插件加载顺序不会成为契约。
|
||||
|
||||
**浮层只通过凭据写入路径提交。**适配器已挂载且处于活跃状态,其引用可解析、可写但尚未配置时,界面会显示密码输入框。提交时只调用 `credentials.set({ref, value})`;成功后清空 React 草稿、重新拉取共享联接,并且仅在新描述符报告 `configured: true` 时关闭浮层。业务失败与传输层拒绝都会让对话框保持打开,在 `finally` 中解除忙碌状态,并从渲染的错误文本中脱敏已提交的值。该浮层绝不写入 `apiKey`、`baseURL` 或经过脱敏的设置分节;高级配置会转到 Models。
|
||||
**浮层只负责跳转到唯一的凭据编辑器。**适配器已挂载且处于活跃状态,其引用可解析、可写但尚未配置时,界面会显示一个操作按钮,用于打开「设置」的 Models 分区。该分区已有的 DeepSeek 设置卡片全权负责密码输入框、`credentials.set({ref, value})`、写入失败处理和写入后刷新;首次使用浮层绝不持有或提交 secret。设置或凭据能力不可用时会保留部署诊断,并提供前往同一页面的入口;适配器缺失时仍直接跳过,因为导航无法挂载 Cordis 插件。
|
||||
|
||||
**能力不可用时如实呈现。**可配置提供方条目缺失时不显示表单,因为它无法修复当前组合。提供方存在,但设置或凭据能力无法解析时,界面会显示可采取操作的部署诊断。取消只会在当前已挂载界面中关闭浮层,不写入任何完成状态。设置、凭据、提供方拓扑和连接失效事件都会刷新共享联接,因此外部凭据更新无需重新加载页面即可关闭已打开的浮层。
|
||||
|
||||
@@ -22,10 +22,12 @@ Status: implemented
|
||||
|
||||
**为首次使用引导单设 store 与就绪状态 RPC 调用序列**:不予采用,因为这会在 Models 页之外,再建立一套客户端解释,用于判定提供方身份、设置路径、secret 槽位的伴随信息、凭据引用及失效事件顺序。
|
||||
|
||||
**在首次使用引导中增设第二个 API key 编辑器**:不予采用,因为 Models 页已为这一状态渲染 DeepSeek 设置卡片。复制其中的 secret 草稿、写入错误处理和已配置状态收敛会增加第二个安全敏感的 UI,却不会带来新的用户能力。
|
||||
|
||||
**把 API key 写入提供方设置**:不予采用,因为字面量 secret 会进入设置变更路径,而整个分节替换无法安全重建脱敏值。凭据存储已经是产品 seam,并能立即发出失效事件。
|
||||
|
||||
**`llm-deepseek` 缺失时仍显示同一个密钥表单**:不予采用,因为提交成功也只会存储一个无人使用的环境引用;浏览器没有任何受支持的操作可以挂载缺失的 Cordis 插件。
|
||||
|
||||
## 后果
|
||||
|
||||
首次使用流程无需重启即可修复随产品提供的适配器:无密钥浏览器测试在隔离的 harness 家目录下启动真实 Web 组合,确认对话框出现,把生成的密钥存入该目录的 `.env`,验证密钥未进入 DOM、ARIA 或浏览器控制台输出,并确认运行中的 Models 页报告已配置。纯就绪状态测试与 React 测试固化了字面量凭据、文件凭据、进程环境凭据、提供方缺失、能力缺失、业务错误、传输错误、取消和外部失效行为。该流程直接继承配置平面已记录的基础限制,不会另加局部的机密存储、脱敏或设置替换变通方案。
|
||||
首次使用流程现在无需重启即可引导用户前往随产品提供的适配器已有的编辑器:无密钥浏览器测试在隔离的 harness 家目录下启动真实 Web 组合,依照浮层操作前往 Models,通过该页面把生成的密钥存入该目录的 `.env`,验证密钥未进入 DOM、ARIA 或浏览器控制台输出,并确认运行中的页面报告已配置。纯就绪状态测试与 React 测试固化了字面量凭据、文件凭据、进程环境凭据、提供方缺失、能力缺失、导航、取消和外部失效行为。该流程直接继承配置平面已记录的基础限制,不会另加局部的机密存储、脱敏或设置替换变通方案。
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
// Keyless browser e2e: the shipped DeepSeek adapter stays mounted while its
|
||||
// credential is absent, onboarding writes the effective reference through
|
||||
// the real wire into an isolated harness home, and the live page converges
|
||||
// without a reload or model call.
|
||||
// credential is absent, onboarding routes to the real Models editor, and its
|
||||
// write lands in an isolated harness home without a reload or model call.
|
||||
import { randomBytes } from 'node:crypto'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
@@ -43,16 +42,23 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup
|
||||
|
||||
it('stores a key write-only and observes configured state without restarting', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-onboarding-deepseek-config'))
|
||||
const dialog = page.getByRole('dialog', { name: '添加 DeepSeek API 密钥' })
|
||||
const dialog = page.getByRole('dialog', { name: '添加一个 API Key 开始使用' })
|
||||
await dialog.waitFor({ timeout: 15_000 })
|
||||
expect(await dialog.getByLabel('提供方').inputValue()).toBe('DeepSeek')
|
||||
expect(await dialog.getByRole('textbox').count()).toBe(0)
|
||||
const initial = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd)
|
||||
await compareOrRefreshGolden(MISSING_EXPECTED, initial, MODE)
|
||||
|
||||
const secret = `dsh_onboarding_${randomBytes(12).toString('hex')}`
|
||||
await dialog.getByLabel('API 密钥', { exact: true }).fill(secret)
|
||||
await dialog.getByRole('button', { name: '保存并继续' }).click()
|
||||
await dialog.getByRole('button', { name: '前往配置' }).click()
|
||||
await dialog.waitFor({ state: 'detached', timeout: 15_000 })
|
||||
const settings = page.getByRole('dialog', { name: '设置' })
|
||||
await settings.waitFor({ timeout: 10_000 })
|
||||
const keyInput = settings.getByLabel('API 密钥', { exact: true })
|
||||
await keyInput.waitFor({ timeout: 10_000 })
|
||||
|
||||
const secret = `dsh_onboarding_${randomBytes(12).toString('hex')}`
|
||||
await keyInput.fill(secret)
|
||||
await settings.getByRole('button', { name: '保存', exact: true }).click()
|
||||
await keyInput.waitFor({ state: 'detached', timeout: 15_000 })
|
||||
|
||||
const stored = await readFile(join(scaffold.harnessHome, '.env'), 'utf8')
|
||||
expect(stored.includes(`DEEPSEEK_API_KEY=${secret}`)).toBe(true)
|
||||
@@ -60,18 +66,15 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup
|
||||
expect((await page.locator('body').ariaSnapshot()).includes(secret)).toBe(false)
|
||||
expect(browserConsole.some(line => line.includes(secret))).toBe(false)
|
||||
|
||||
// The same running composition reuses the refreshed join. Opening Models
|
||||
// and its write-only key field proves the configured view without reload.
|
||||
await page.getByRole('button', { name: '设置', exact: true }).click()
|
||||
const settings = page.getByRole('dialog', { name: '设置' })
|
||||
await settings.getByRole('button', { name: '模型' }).click()
|
||||
// The same open Models surface reuses the refreshed join and exposes the
|
||||
// configured write-only placeholder without a reload.
|
||||
const deepSeekRow = settings.getByText('DeepSeek', { exact: true }).first()
|
||||
await deepSeekRow.waitFor({ timeout: 10_000 })
|
||||
await deepSeekRow.locator('xpath=ancestor::li').getByRole('button', { name: '编辑' }).click()
|
||||
const keyInput = settings.getByLabel('API 密钥', { exact: true })
|
||||
await keyInput.waitFor({ timeout: 10_000 })
|
||||
const configuredInput = settings.getByLabel('API 密钥', { exact: true })
|
||||
await configuredInput.waitFor({ timeout: 10_000 })
|
||||
await expect.poll(
|
||||
() => keyInput.getAttribute('placeholder'),
|
||||
() => configuredInput.getAttribute('placeholder'),
|
||||
{ timeout: 10_000 },
|
||||
).toBe('已配置——输入新值可替换')
|
||||
|
||||
|
||||
@@ -1,12 +1,6 @@
|
||||
- dialog "添加 DeepSeek API 密钥":
|
||||
- heading "添加 DeepSeek API 密钥" [level=2]
|
||||
- dialog "添加一个 API Key 开始使用":
|
||||
- heading "添加一个 API Key 开始使用" [level=2]
|
||||
- button "稍后配置":
|
||||
- img
|
||||
- paragraph: 配置 DeepSeek 官方模型,即可开始使用。
|
||||
- text: 提供方
|
||||
- textbox "提供方": DeepSeek
|
||||
- text: API 密钥
|
||||
- textbox "API 密钥":
|
||||
- /placeholder: 输入 DeepSeek API 密钥
|
||||
- button "模型高级设置"
|
||||
- button "保存并继续" [disabled]
|
||||
- button "前往配置"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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 新生的路由都无需轮询即可收敛。
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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' }
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user