diff --git a/.agents/notes/implemented/bug-fix/2026-08-12-onboarding-reads-every-provider.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-12-onboarding-reads-every-provider.i18n.yaml new file mode 100644 index 0000000000..cc3873f137 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-12-onboarding-reads-every-provider.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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/bug-fix/2026-08-12-onboarding-reads-every-provider.md +2026-08-12-onboarding-reads-every-provider.md: 1f247a6c93257c24052f55eb4297ec3c9c3df06d +2026-08-12-onboarding-reads-every-provider.zh.md: fc6e43195a46eaea881f8b4bee3219b5e583b284 diff --git a/.agents/notes/implemented/bug-fix/2026-08-12-onboarding-reads-every-provider.md b/.agents/notes/implemented/bug-fix/2026-08-12-onboarding-reads-every-provider.md new file mode 100644 index 0000000000..1f247a6c93 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-12-onboarding-reads-every-provider.md @@ -0,0 +1,38 @@ +# Agent Note: First-run readiness reads every provider, and the setup card closes + +Status: implemented + +English | [中文](2026-08-12-onboarding-reads-every-provider.zh.md) + +## Problem + +The first-run step and the Models page both asked one question — is `deepseek-official`'s credential stored? — of a join that describes every provider. Two defects followed from that single reading. + +A user who configured some other provider (a pi-ai gateway, a self-hosted route) and never wanted the official DeepSeek endpoint was taken over by the full-screen credential prompt on every blank session, with a working model already selected in the composer behind it. Nothing they could do short of storing a DeepSeek key would end it, because the step's readiness projection never looked at the row they had configured. + +On the Models page the same reading opened the DeepSeek setup card over them on every visit, and that card could not be closed: it was rendered from row data with no local state a Cancel could flip, so its Cancel button did nothing visible. Worse, it shared the row-editor/add/declare close handler, which unconditionally clears all three of those states — so cancelling the card that owned none of them discarded the add card's draft while staying open itself. + +## Decision + +One predicate answers what both surfaces actually need. `providerUsable(row)` is true when the route is registered with the adapter registry (`entry.active`) and whatever credential its resolved profile names is stored; a profile naming no reference authenticates through the provider's own path, as does a live route with no settings address, so neither owes this page a key. + +`onboardingReadiness` (renamed from `deepSeekReadiness`, which no longer describes what it reads) returns `provider-ready` as soon as any joined row is usable. Only a user with none of those reaches the official DeepSeek lookup, which is unchanged: it is the one route the prompt can offer a key field for. The gate subsumes two diagnostics the old projection carried — `settings-unavailable` and `credential-ref-unavailable` — because both described an active route the new gate now calls usable; the outcome for the user was already identical (the step completed without rendering). + +`needsSetup(row, anyUsable)` takes the same fact, so the setup card is the first-run posture alone. With another provider reachable, DeepSeek is an ordinary row carrying the missing-key dot, one Edit click from the same card. + +Each card kind now owns its own close handler. `closeSetup` records the provider in a component-local `dismissedSetup` set and touches nothing else; `closeEditor` keeps clearing the three states its cards own. Both route the post-save reload through one `announceSaved` helper. Dismissal is viewing state, like the open editor and the add card: a reload restores the first-run posture for a user still in it. + +## Alternatives considered + +- **Deriving readiness from the model catalog (`llm.models`) instead of the join.** It answers "can the user talk to something" most directly, but it costs a per-provider listing round trip on a surface that already holds the join, and a provider whose listing fails transiently would re-open onboarding. +- **Requiring `row.configured` in `providerUsable`.** It reads as the stricter check, and would exclude exactly the routes a deployment mounts through `cordis.yml` without a configurable-provider declaration — live routes serving models that this page cannot configure. Registration, not configurability, is what makes a provider usable. +- **Only adding the dismissal, leaving the card auto-opening.** It fixes the Cancel button and nothing else: a user with a working provider would still be handed the DeepSeek form on every visit to Models, which is the same misreading in a quieter form. +- **Persisting the dismissal to settings.** A durable "do not ask about DeepSeek" flag is a second fact about first-run state that can disagree with the join. The credential itself already ends the posture permanently, and every other card on this page is session-local. + +## Consequences + +Onboarding now ends for reasons the DeepSeek route knows nothing about, so the step's name is the last thing tying it to that adapter; a future step that offers more than one route to configure would replace the prompt, not the readiness projection. The narrowed diagnostic union means an unresolvable `llm-deepseek` settings address is reported as `provider-ready` rather than as its own reason — the user-visible behavior is unchanged, and the Models page remains the diagnostic surface. + +## Testing + +Package tests pin `providerUsable` over the four join states and `onboardingReadiness` over both the new gate and every surviving diagnostic; the section tests cover the first-run posture, the plain-row posture, and the cancel that collapses the setup card while the add card keeps its draft. The `onboarding-usable-provider` web e2e lane replays the whole scenario through the real wire: cancel with both cards open, configure `minimax-cn` instead, reload, and find no takeover — with one aria golden of the dismissed state. diff --git a/.agents/notes/implemented/bug-fix/2026-08-12-onboarding-reads-every-provider.zh.md b/.agents/notes/implemented/bug-fix/2026-08-12-onboarding-reads-every-provider.zh.md new file mode 100644 index 0000000000..fc6e43195a --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-12-onboarding-reads-every-provider.zh.md @@ -0,0 +1,38 @@ +# Agent Note: First-run readiness reads every provider, and the setup card closes + +Status: implemented + +[English](2026-08-12-onboarding-reads-every-provider.md) | 中文 + +## Problem + +首次使用引导步骤与 Models 页都只向一个描述全部提供方的联接快照提出了同一个问题——`deepseek-official` 的凭据存了吗?两个缺陷由这一次读取而来。 + +配置了别的提供方(某个 pi-ai 网关、某条自建路由)、根本不打算用 DeepSeek 官方端点的用户,会在每一个空白会话上被全屏凭据提示接管,而其背后输入框里早已选好了一个可用模型。除了存入一把 DeepSeek 密钥,他们做什么都结束不了它——因为该步骤的就绪投影从不看他们已经配好的那一行。 + +在 Models 页上,同一次读取每次进入都会把 DeepSeek 设置卡片展开在他们面前,而这张卡片关不掉:它由行数据渲染而来,没有任何本地状态可供「取消」翻转,因此那颗取消按钮不产生任何可见效果。更糟的是,它与行内编辑卡/新增卡/自定义声明卡共用同一个关闭回调,而该回调会无条件清空那三个状态——于是取消一张它们一个都不拥有的卡片,反而丢弃了新增卡里的草稿,自己却仍然开着。 + +## Decision + +一个谓词回答两处界面真正需要的事实。`providerUsable(row)` 在路由已注册进适配器注册表(`entry.active`)、且其解析后 profile 所指名的凭据已存储时为真;不指名任何引用的 profile 走提供方自己的认证路径,没有 settings 地址的存活路由亦然,因此二者都不欠这个页面一把密钥。 + +`onboardingReadiness`(原名 `deepSeekReadiness`,该名称已不再描述它读取的内容)只要联接中有任意一行可用,就返回 `provider-ready`。只有二者皆无的用户才会走到官方 DeepSeek 查找,那部分保持不变:它是这条提示唯一能为其提供密钥输入框的路由。这道门槛吸收了旧投影携带的两个诊断——`settings-unavailable` 与 `credential-ref-unavailable`——因为二者描述的都是新门槛现在判为可用的活跃路由;对用户而言结果本就一致(该步骤不渲染直接完成)。 + +`needsSetup(row, anyUsable)` 接受同一个事实,因此设置卡片仅代表首次运行姿态。当另有可触达的提供方时,DeepSeek 就是一行带缺失密钥点的普通行,距离同一张卡片只有一次「编辑」点击。 + +现在每一类卡片各自拥有自己的关闭回调。`closeSetup` 把该提供方记入组件本地的 `dismissedSetup` 集合,别的一概不碰;`closeEditor` 继续清空它那些卡片所拥有的三个状态。两者都经由同一个 `announceSaved` 助手完成保存后的重载。关闭状态属于查看态,与展开的编辑卡和新增卡一样:对仍处于首次运行姿态的用户,重载会恢复该姿态。 + +## Alternatives considered + +- **从模型目录(`llm.models`)而非联接推导就绪状态。** 它最直接地回答「用户有没有能对话的东西」,但会在一个已经持有联接的界面上多花每提供方一次列举往返,而且某个提供方列举的瞬时失败会让引导重新弹出。 +- **在 `providerUsable` 中要求 `row.configured`。** 它读起来更严格,却会恰好排除部署通过 `cordis.yml` 挂载、没有可配置提供方声明的那些路由——它们是正在提供模型、只是这个页面配置不了的存活路由。使一个提供方可用的是注册,不是可配置性。 +- **只加关闭状态,保留卡片自动展开。** 那只修好取消按钮,别的什么都没修:已有可用提供方的用户每次进入 Models 仍会被塞一张 DeepSeek 表单,那是同一个误读的安静版本。 +- **把关闭状态持久化到 settings。** 一个「别再问 DeepSeek」的持久标志,是关于首次运行状态的第二个事实,可能与联接互相矛盾。凭据本身已经永久结束该姿态,而这个页面上其他每一张卡片都是会话内的。 + +## Consequences + +引导现在会因为 DeepSeek 路由一无所知的理由而结束,因此该步骤的名字是最后一处把它和那个适配器绑在一起的东西;未来若有一个步骤能提供不止一条可配置路由,替换掉的会是提示本身,而非就绪投影。收窄后的诊断联合意味着无法解析的 `llm-deepseek` settings 地址会被报为 `provider-ready` 而非它自己的理由——用户可见行为不变,Models 页仍是诊断界面。 + +## Testing + +包内测试针对四种联接状态钉住 `providerUsable`,并针对新门槛与每一个存留的诊断钉住 `onboardingReadiness`;分区测试覆盖首次运行姿态、普通行姿态,以及在新增卡保住草稿的同时折叠设置卡片的那次取消。`onboarding-usable-provider` web e2e 泳道通过真实协议重放整个场景:两张卡片都开着时取消、改配 `minimax-cn`、重载,然后不再出现接管——并附一份关闭后状态的 aria golden。 diff --git a/apps/web/tests/onboarding-usable-provider.e2e.ts b/apps/web/tests/onboarding-usable-provider.e2e.ts new file mode 100644 index 0000000000..09e3923064 --- /dev/null +++ b/apps/web/tests/onboarding-usable-provider.e2e.ts @@ -0,0 +1,128 @@ +// Keyless browser e2e: a user who configures some OTHER provider is not asked +// for the official DeepSeek key again, and the first-run setup card is a card +// they can close. The shipped DeepSeek adapter stays mounted without a +// credential throughout, so the only thing that ends onboarding here is the +// pi-ai route the user configures through the real wire. Zero model calls: +// configuration is pure settings/credentials/llm-domain traffic. +import { readFile } from 'node:fs/promises' +import { fileURLToPath } from 'node:url' +import { join } from 'node:path' +import type { Browser, Page } from 'playwright' +import { chromium } from 'playwright' +import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import { + acknowledgeReloadConnectionLoss, assertFixtureInventory, captureStableAria, compareOrRefreshGolden, + launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold, +} from './scaffold.ts' +import { ZH_BROWSER_LOCALE, saveFailureShot } from './support.ts' + +const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/onboarding-usable-provider', import.meta.url)) +const DISMISSED_EXPECTED = join(SNAPSHOT_DIR, 'dismissed.expected.md') +const MODE = webSnapshotMode() +const CREDENTIAL_STEP = '添加一个 API Key 开始使用' + +describe.skipIf(MODE === 'record')('web e2e: another usable provider ends first-run onboarding', () => { + let scaffold: WebScaffold + let browser: Browser + let page: Page + let tripwire: ReturnType + + beforeAll(async () => { + scaffold = await launchWebScaffold({ deepSeekMissingCredential: true }) + browser = await chromium.launch() + // The scenario asserts the shipped Chinese copy, so the browser asks for it. + page = await browser.newPage({ viewport: { width: 1440, height: 960 }, locale: ZH_BROWSER_LOCALE }) + tripwire = watchConsole(page) + await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + }, 120_000) + + afterAll(async () => { + await browser?.close() + await scaffold?.close() + }) + + it('closes the setup card without discarding the add card beside it', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-onboarding-setup-card-cancel')) + const credentialStep = page.getByRole('region', { name: CREDENTIAL_STEP }) + await credentialStep.waitFor({ timeout: 15_000 }) + await credentialStep.getByRole('button', { name: '前往配置' }).click() + await credentialStep.waitFor({ state: 'detached', timeout: 15_000 }) + + const settings = page.getByRole('dialog', { name: '设置' }) + await settings.waitFor({ timeout: 10_000 }) + // Nothing is reachable yet, so DeepSeek presents itself as its open card. + const setupKey = settings.getByRole('textbox', { name: 'API 密钥', exact: true }) + await setupKey.waitFor({ timeout: 10_000 }) + + const add = settings.getByRole('button', { name: '添加提供方' }) + await expect.poll(async () => add.isEnabled(), { timeout: 10_000 }).toBe(true) + await add.click() + const pick = settings.getByLabel('提供方') + await pick.waitFor({ timeout: 10_000 }) + await pick.selectOption('minimax-cn') + await expect.poll( + async () => settings.getByRole('textbox', { name: 'API 密钥', exact: true }).count(), + { timeout: 10_000 }, + ).toBe(2) + + // Cancelling the setup card is the regression: it used to leave itself open + // and close the add card, discarding that draft. + await settings.getByRole('button', { name: '取消', exact: true }).first().click() + expect(await settings.getByLabel('提供方').count()).toBe(1) + await expect.poll( + async () => settings.getByRole('textbox', { name: 'API 密钥', exact: true }).count(), + { timeout: 10_000 }, + ).toBe(1) + // DeepSeek is now an ordinary row: a missing-key dot and an Edit button. + await settings.getByRole('button', { name: '编辑 DeepSeek (deepseek-official)' }).waitFor({ timeout: 10_000 }) + const dismissed = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd) + await compareOrRefreshGolden(DISMISSED_EXPECTED, dismissed, MODE) + + expect(tripwire.warnings).toEqual([]) + expect(tripwire.pageErrors).toEqual([]) + }, 60_000) + + it('stops prompting for DeepSeek once the other provider can serve requests', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-onboarding-other-provider')) + const settings = page.getByRole('dialog', { name: '设置' }) + await settings.getByRole('textbox', { name: 'API 密钥', exact: true }).fill('sk-e2e-minimax') + await settings.getByRole('button', { name: '保存', exact: true }).click() + await settings.getByText('已保存 minimax-cn。', { exact: true }).waitFor({ timeout: 15_000 }) + + // Only minimax-cn is reachable; DeepSeek still holds no credential. + const document = await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8') + expect(document).toContain('apiKeyEnv: MINIMAX_CN_API_KEY') + const credentials = await readFile(join(scaffold.harnessHome, '.credentials.yaml'), 'utf8') + expect(credentials).toContain('MINIMAX_CN_API_KEY: sk-e2e-minimax') + expect(credentials).not.toContain('DEEPSEEK_API_KEY') + + const warningsBefore = tripwire.warnings.length + await page.reload({ waitUntil: 'load' }) + acknowledgeReloadConnectionLoss(tripwire, warningsBefore) + await page.waitForSelector('[class*="frame"]', { timeout: 15_000 }) + // The regression: the step read only the official route's credential, so a + // fully configured user was taken over on every blank session. + await expect.poll( + async () => page.getByRole('region', { name: CREDENTIAL_STEP }).count(), + { timeout: 10_000 }, + ).toBe(0) + expect(await page.locator('[class*="onboardingStage"]').count()).toBe(0) + expect(await page.locator('#root').evaluate(root => (root as HTMLElement).inert)).toBe(false) + + // The Models page agrees: DeepSeek stays a row rather than reopening its + // setup card over a user who already has somewhere to send a request. + await page.getByRole('button', { name: '设置', exact: true }).click() + await settings.waitFor({ timeout: 10_000 }) + await settings.getByRole('button', { name: '模型' }).click() + await settings.getByRole('button', { name: '编辑 DeepSeek (deepseek-official)' }).waitFor({ timeout: 10_000 }) + expect(await settings.getByRole('textbox', { name: 'API 密钥', exact: true }).count()).toBe(0) + + expect((await page.content()).includes('sk-e2e-minimax')).toBe(false) + expect(tripwire.pageErrors).toEqual([]) + }, 60_000) + + it('keeps the fixture inventory closed', async () => { + await assertFixtureInventory(SNAPSHOT_DIR, ['dismissed.expected.md']) + }) +}) diff --git a/apps/web/tests/snapshots/onboarding-usable-provider/dismissed.expected.md b/apps/web/tests/snapshots/onboarding-usable-provider/dismissed.expected.md new file mode 100644 index 0000000000..182fadf973 --- /dev/null +++ b/apps/web/tests/snapshots/onboarding-usable-provider/dismissed.expected.md @@ -0,0 +1,71 @@ +- dialog "设置": + - navigation: + - text: 设置 + - button "通用设置": + - img + - text: 通用设置 + - button "模型": + - img + - text: 模型 + - button "Agent 预设": + - img + - text: Agent 预设 + - button "插件配置": + - img + - text: 插件配置 + - button "打开配置文件" + - button "关闭": + - img + - text: 关闭 + - heading "模型" [level=2] + - paragraph: 填入各提供方的 API 密钥即可使用其模型。 + - list: + - listitem: + - text: DeepSeek + - img "API 密钥缺失" + - button "编辑 DeepSeek (deepseek-official)": 编辑 + - text: 提供方 + - combobox "提供方": + - option "amazon-bedrock" + - option "ant-ling" + - option "anthropic" + - option "azure-openai-responses" + - option "cerebras" + - option "cloudflare-ai-gateway" + - option "cloudflare-workers-ai" + - option "deepseek" + - option "fireworks" + - option "github-copilot" + - option "google" + - option "google-vertex" + - option "groq" + - option "huggingface" + - option "kimi-coding" + - option "minimax" + - option "minimax-cn" [selected] + - option "mistral" + - option "moonshotai" + - option "moonshotai-cn" + - option "nvidia" + - option "openai" + - option "openai-codex" + - option "opencode" + - option "opencode-go" + - option "openrouter" + - option "qwen-token-plan" + - option "qwen-token-plan-cn" + - option "together" + - option "vercel-ai-gateway" + - option "xai" + - option "xiaomi" + - option "xiaomi-token-plan-ams" + - option "xiaomi-token-plan-cn" + - option "xiaomi-token-plan-sgp" + - option "zai" + - option "zai-coding-cn" + - text: API 密钥 + - textbox "API 密钥": + - /placeholder: 输入 API 密钥,或留空使用环境认证 + - group: 自定义设置 + - button "取消" + - button "保存" diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index b1153f4b8e..e656b099ea 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -42,6 +42,7 @@ "tests/default-model.e2e.ts", "tests/declared-reasoning.e2e.ts", "tests/onboarding-deepseek-config.e2e.ts", + "tests/onboarding-usable-provider.e2e.ts", "tests/remote-welcome.e2e.ts", "tests/workspace-management.e2e.ts", "tests/replay-round-trip.e2e.ts", diff --git a/packages/client/ui-models/README.i18n.yaml b/packages/client/ui-models/README.i18n.yaml index 1a4c53cc8d..671d4a2bfd 100644 --- a/packages/client/ui-models/README.i18n.yaml +++ b/packages/client/ui-models/README.i18n.yaml @@ -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: f6604f822412e9eb4574696f5b99e73fb7bd98ff -README.zh.md: 2500bbae0982571a9a88dd5c259749e3504728de +README.md: a8d030b7676e87709fb36b87a6599decc43e0b4b +README.zh.md: 63fb1b486acc2bca34792f485ffd89fb32749e43 diff --git a/packages/client/ui-models/README.md b/packages/client/ui-models/README.md index f6604f8224..a8d030b767 100644 --- a/packages/client/ui-models/README.md +++ b/packages/client/ui-models/README.md @@ -4,9 +4,9 @@ English | [中文](README.zh.md) Models settings plugin: the provider configuration page and official-DeepSeek conditional onboarding step. 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, without presenting route liveness as provider status. -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 pi-ai card additionally edits that route's **model list** and can ask the provider what it serves. A row labels API-key state with a green solid dot only when a referenced credential is confirmed configured, and with a red solid dot only when a named reference is confirmed missing; reference-free provider-native authentication and unavailable credential enrichment remain unmarked. 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 `_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. Leaving a new pi-ai provider's key blank saves a reference-free profile and therefore preserves provider-native authentication such as the Bedrock credential chain or Vertex ADC. A successful Apply emits a local accessible status message without echoing secret material. The collapsed 自定义设置 fold carries the curated extras — `baseURL` for both families (the deepseek placeholder shows the public endpoint), each adapter's model catalog, and the **display name** and **API protocol** of a pi-ai route the adapter does not ship. Those two are what a hand-declared route names for itself: the create card asks for both because nothing can default them, so the editor reaches both rather than leaving them to `settings.yaml`. Clearing the name unsets it and the route falls back to its id, which is what the placeholder shows; the protocol has no such fallback. A catalog route gets neither — it defaults its name from its catalog entry, and its models each carry their own protocol, so a route-level one could only override every one of them. The Provider ID stays fixed: it is the settings key, the name every other namespace and every logged session references, and the stem of a credential reference the page cannot read back to move. Reasoning effort is deliberately NOT among them: it is a per-model capability and the models under one provider disagree about which levels they accept, so a provider-scoped control could only be set to a value some of them reject — which would hide even the models that support the level. The composer's model picker offers each model its own levels, and a switch there records provider, model, and effort together as the default for the next session. The profile field stays in `settings.yaml` for a deployment that knows its route. Each DeepSeek row edits `id`, optional display `name`, and optional `contextWindow`/`maxTokens`; existing fields outside that curated set survive edits, while 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), and its localized confirmation dialog names the provider in the title, description, and final action. A row is tagged **Custom** when the directory entry says the owning adapter ships nothing under that key. The tag follows that answer alone: having a stored profile does not make a route custom — narrowing a shipped provider's models stores one too — and an adapter that reports nothing leaves its rows untagged rather than being read as shipped. +Rows are the *configured* providers (their profile resolves in the owning namespace); a whole-section provider whose key is not configured anywhere renders as its open setup card instead of a row, but only in the first-run posture — while no provider is registered with the credential its profile names — and only until the user closes that card, after which it is an ordinary row carrying the missing-key dot. Each card kind owns its own open state, so closing one never discards a draft in another. 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 pi-ai card additionally edits that route's **model list** and can ask the provider what it serves. A row labels API-key state with a green solid dot only when a referenced credential is confirmed configured, and with a red solid dot only when a named reference is confirmed missing; reference-free provider-native authentication and unavailable credential enrichment remain unmarked. 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 `_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. Leaving a new pi-ai provider's key blank saves a reference-free profile and therefore preserves provider-native authentication such as the Bedrock credential chain or Vertex ADC. A successful Apply emits a local accessible status message without echoing secret material. The collapsed 自定义设置 fold carries the curated extras — `baseURL` for both families (the deepseek placeholder shows the public endpoint), each adapter's model catalog, and the **display name** and **API protocol** of a pi-ai route the adapter does not ship. Those two are what a hand-declared route names for itself: the create card asks for both because nothing can default them, so the editor reaches both rather than leaving them to `settings.yaml`. Clearing the name unsets it and the route falls back to its id, which is what the placeholder shows; the protocol has no such fallback. A catalog route gets neither — it defaults its name from its catalog entry, and its models each carry their own protocol, so a route-level one could only override every one of them. The Provider ID stays fixed: it is the settings key, the name every other namespace and every logged session references, and the stem of a credential reference the page cannot read back to move. Reasoning effort is deliberately NOT among them: it is a per-model capability and the models under one provider disagree about which levels they accept, so a provider-scoped control could only be set to a value some of them reject — which would hide even the models that support the level. The composer's model picker offers each model its own levels, and a switch there records provider, model, and effort together as the default for the next session. The profile field stays in `settings.yaml` for a deployment that knows its route. Each DeepSeek row edits `id`, optional display `name`, and optional `contextWindow`/`maxTokens`; existing fields outside that curated set survive edits, while 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), and its localized confirmation dialog names the provider in the title, description, and final action. A row is tagged **Custom** when the directory entry says the owning adapter ships nothing under that key. The tag follows that answer alone: having a stored profile does not make a route custom — narrowing a shipped provider's models stores one too — and an adapter that reports nothing leaves its rows untagged rather than being read as shipped. -The DeepSeek step projects `deepseek-official` readiness from that same joined snapshot after earlier onboarding pages complete. 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 credential reference completes the step without rendering, including a read-only launch-environment credential. Only a mounted, active adapter with a missing writable reference shows the page that opens Settings on Models, whose existing setup card exclusively owns key input and `credentials.set`; the step never holds a secret. An absent adapter, inactive route, failed join, read-only deployment, or unusable settings or credential capability completes the step without rendering so onboarding cannot block the product; Models remains the diagnostic surface. +The DeepSeek step projects first-run readiness from that same joined snapshot after earlier onboarding pages complete. The step exists to leave the user with a model to talk to, so ANY provider they can already reach ends it without rendering — a registered route whose named credential reference is stored, including a read-only launch-environment credential, or one whose profile names no reference at all and therefore authenticates natively. Only a user with none of those is asked about DeepSeek, the one route the prompt can offer a key field for. 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. Only a mounted, active adapter with a missing writable reference shows the page that opens Settings on Models, whose existing setup card exclusively owns key input and `credentials.set`; the step never holds a secret. An absent adapter, inactive route, failed join, read-only deployment, or unusable settings or credential capability completes the step without rendering so onboarding cannot block the product; Models 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 provider row. The page only ever holds the REDACTED descriptor, so it mutates the fields it can see rather than rebuilding a section. DeepSeek's `models` is one replace-by-value array: the editor shows inherited effective rows until the first model edit materializes the complete array in the user layer, while reset unsets that override. A row carries the model id and display name; its context window and output cap sit behind the row's own disclosure, with the same fields the pi-ai provider form uses. Either capacity is typed as a count with an optional decimal `K` or `M` suffix (`256K`, `1M`; `1M` is 1000K) and stored as the plain count, spelled back in the shortest form that round-trips. Empty ids, duplicate ids, empty explicit names, and unreadable, non-positive, or fractional capacities fail before any write. A typed API key is judged on its own field the same way: after trimming, it must be non-empty and every character must be printable ASCII (`[\x21-\x7E]`), which is exactly what an HTTP header value can carry — the twin of `normalizeApiKey` in `@deepseek-ai/dsh-llm`, mirrored here because the source-plane split forbids importing it. A value matching a pasted `NAME=value` environment line or wrapped in matching quotes is refused as the same format failure; that pasted-line check runs only in the browser, since a false positive in a resolver would leave the environment refusing the key as well. A field holding only whitespace fails rather than being silently dropped, while an empty field is not a failure at all: it means keep the stored key on an editor card, and authenticate some other way on a create card. A refused key blocks both the write and the endpoint interrogation, so the page never spends a round trip to be told what the field already says. Each settings write carries the card's current `revision`, so a concurrent write from another tab or an external `settings.yaml` edit is refused as `settings-conflict`; after settings commit, the card adopts the returned redacted user subtree and revision before storing the credential, which makes a failed credential stage retry only that stage. Deletion removes a configured, writable credential only when the profile names the page's derived `_API_KEY` target, then unsets the profile; both operations are idempotent, and a partial failure remains in the identified confirmation dialog for retry. Environment credentials, custom references, and credentials whose target cannot be identified remain untouched. Once loaded, the page subscribes directly to forwarded `settings/document-updated`, `credentials/updated`, and `llm/adapters-updated` owner events, plus local `connection/reset`, so an external `settings.yaml` edit, a second tab, or a settings-born route converges without polling. diff --git a/packages/client/ui-models/README.zh.md b/packages/client/ui-models/README.zh.md index 2500bbae09..63fb1b486a 100644 --- a/packages/client/ui-models/README.zh.md +++ b/packages/client/ui-models/README.zh.md @@ -4,9 +4,9 @@ 模型设置插件:提供方配置页和按条件显示的 DeepSeek 官方首次使用引导步骤。它把三个协议领域汇聚为一个共享快照:`llm.providers`(可配置提供方目录,含每条路由的存活/休眠状态)、`settings.describe`(序列化 schema、分层脱敏值、secret 槽位)与 `credentials.describe`(不含值的 configured/source/writable 徽标);页面据此渲染提供方行,一次只展开一张编辑卡片,且不把路由存活状态呈现为提供方状态。 -行是*已配置*的提供方(其 profile 在所属 namespace 中解析得出);密钥未在任何地方配置的整分节提供方(DeepSeek 的首次运行姿态)会渲染为其展开的设置卡片而非一行,「新增」流程则是一张承载休眠目录提供方选择框的卡片——裸挂载的 `llm-pi-ai` 在任何路由存在之前就能提供其完整的已安装 catalog。pi-ai 卡片还会编辑该路由的**模型列表**,并可查询提供方所提供的模型。只有确认引用的凭据已配置时,行才会以绿色实心点标示 API 密钥状态;只有确认具名引用缺失时,才会以红色实心点标示。无引用的提供方原生认证以及无法取得凭据补充信息时都不显示状态点。编辑器是每个适配器家族各一张的手写卡片:主字段是单独一个 **API 密钥**输入框——页面从不询问环境变量名;键入的密钥经 `credentials.set` 以**只写**方式存入 profile 的引用之下,profile 没有引用时便派生 `_API_KEY`,pi-ai profile 会把这次派生记录为 `apiKeyEnv`,因此 `settings.yaml` 从不携带密钥值。为新的 pi-ai 提供方留空密钥会保存一个不带引用的 profile,因此能保留提供方原生认证,例如 Bedrock 凭据链或 Vertex ADC。「应用」成功后会发出本地无障碍状态消息,且绝不回显任何机密内容。收起的「自定义设置」折叠区承载精选的额外字段——两个家族都有 `baseURL`(deepseek 的占位符显示公共端点)、各适配器自己的模型目录,以及适配器未提供的那类 pi-ai 路由的**显示名称**与 **API 协议**。这两个字段是手工声明路由为自己命名的东西:创建卡片之所以索要它们,正因为没有东西能为它们兜底,因此编辑器也够得着这两个,而不是把它们留给 `settings.yaml`。清空名称即取消设置,路由退回自己的 id——占位符显示的就是它;协议没有这样的兜底。内置目录路由两个都不给:它的名称由目录条目兜底,它的每个模型各自带着自己的协议,路由级协议只可能把它们全部覆盖掉。Provider ID 保持固定:它是 settings 的键、是其他每个 namespace 与每一条已记录会话引用的名字,也是页面读不回、因而搬不走的凭据引用词干。推理等级刻意**不在**其中:它是按模型的能力,而同一提供方下各模型接受的档位并不一致,因此提供方级的控件只可能被设成其中一些模型会拒绝的值——那会连支持该档位的模型也一并隐藏。输入框的模型选择器为每个模型提供它自己的档位,在那里切换会把提供方、模型、推理等级一并记为下一个会话的默认值。profile 字段仍留在 `settings.yaml`,供清楚自己路由的部署使用。每条 DeepSeek 模型行可编辑 `id`、可选的显示名称 `name` 与可选的 `contextWindow`/`maxTokens`;精选集合以外的现有字段会在编辑后保留,其余每个 profile 字段仍归 `settings.yaml` 所有。只有当某行仅由用户层承载时它才可删除(删除会还原组合 base),其本地化确认对话框会在标题、说明和最终操作中点名该提供方。当目录条目表明拥有该路由的适配器在这个键下什么都没有时,该行会带上 **自定义** 标签。标签只跟随这个答案:存了 profile 并不使一条路由成为自定义——收窄一个内置提供方的模型同样会存下 profile——而什么都不回答的适配器,其路由保持无标签,不会被当成内置。 +行是*已配置*的提供方(其 profile 在所属 namespace 中解析得出);密钥未在任何地方配置的整分节提供方会渲染为其展开的设置卡片而非一行,但仅限首次运行姿态——即尚无任何提供方已注册且备齐其 profile 所指名的凭据——且仅持续到用户关闭该卡片为止,此后它就是一行带缺失密钥点的普通行。每一类卡片各自持有自己的展开状态,因此关掉其中一张绝不会丢弃另一张里的草稿。「新增」流程则是一张承载休眠目录提供方选择框的卡片——裸挂载的 `llm-pi-ai` 在任何路由存在之前就能提供其完整的已安装 catalog。pi-ai 卡片还会编辑该路由的**模型列表**,并可查询提供方所提供的模型。只有确认引用的凭据已配置时,行才会以绿色实心点标示 API 密钥状态;只有确认具名引用缺失时,才会以红色实心点标示。无引用的提供方原生认证以及无法取得凭据补充信息时都不显示状态点。编辑器是每个适配器家族各一张的手写卡片:主字段是单独一个 **API 密钥**输入框——页面从不询问环境变量名;键入的密钥经 `credentials.set` 以**只写**方式存入 profile 的引用之下,profile 没有引用时便派生 `_API_KEY`,pi-ai profile 会把这次派生记录为 `apiKeyEnv`,因此 `settings.yaml` 从不携带密钥值。为新的 pi-ai 提供方留空密钥会保存一个不带引用的 profile,因此能保留提供方原生认证,例如 Bedrock 凭据链或 Vertex ADC。「应用」成功后会发出本地无障碍状态消息,且绝不回显任何机密内容。收起的「自定义设置」折叠区承载精选的额外字段——两个家族都有 `baseURL`(deepseek 的占位符显示公共端点)、各适配器自己的模型目录,以及适配器未提供的那类 pi-ai 路由的**显示名称**与 **API 协议**。这两个字段是手工声明路由为自己命名的东西:创建卡片之所以索要它们,正因为没有东西能为它们兜底,因此编辑器也够得着这两个,而不是把它们留给 `settings.yaml`。清空名称即取消设置,路由退回自己的 id——占位符显示的就是它;协议没有这样的兜底。内置目录路由两个都不给:它的名称由目录条目兜底,它的每个模型各自带着自己的协议,路由级协议只可能把它们全部覆盖掉。Provider ID 保持固定:它是 settings 的键、是其他每个 namespace 与每一条已记录会话引用的名字,也是页面读不回、因而搬不走的凭据引用词干。推理等级刻意**不在**其中:它是按模型的能力,而同一提供方下各模型接受的档位并不一致,因此提供方级的控件只可能被设成其中一些模型会拒绝的值——那会连支持该档位的模型也一并隐藏。输入框的模型选择器为每个模型提供它自己的档位,在那里切换会把提供方、模型、推理等级一并记为下一个会话的默认值。profile 字段仍留在 `settings.yaml`,供清楚自己路由的部署使用。每条 DeepSeek 模型行可编辑 `id`、可选的显示名称 `name` 与可选的 `contextWindow`/`maxTokens`;精选集合以外的现有字段会在编辑后保留,其余每个 profile 字段仍归 `settings.yaml` 所有。只有当某行仅由用户层承载时它才可删除(删除会还原组合 base),其本地化确认对话框会在标题、说明和最终操作中点名该提供方。当目录条目表明拥有该路由的适配器在这个键下什么都没有时,该行会带上 **自定义** 标签。标签只跟随这个答案:存了 profile 并不使一条路由成为自定义——收窄一个内置提供方的模型同样会存下 profile——而什么都不回答的适配器,其路由保持无标签,不会被当成内置。 -前序首次使用引导页面完成后,DeepSeek 步骤会从同一个联接快照得出 `deepseek-official` 的就绪状态。它通过 `llm-deepseek` 的可配置提供方声明识别官方适配器,因此同 id 但未声明的存活路由不属于可修复配置。凭据引用已配置时,该步骤会直接完成而不渲染,其中包括来自启动环境且只读的凭据。只有已挂载且活跃、引用可写但尚未配置的适配器才会显示前往「设置」Models 分区的页面;密钥输入和 `credentials.set` 仅由该分区已有的设置卡片负责,该步骤绝不持有 secret。适配器缺失、路由不活跃、联接失败、部署只读或设置/凭据能力不可用时,该步骤均不渲染并直接完成,以免首次使用引导阻塞产品;Models 页仍是诊断界面。 +前序首次使用引导页面完成后,DeepSeek 步骤会从同一个联接快照得出首次运行就绪状态。该步骤的存在是为了让用户手上有一个可对话的模型,因此只要用户已经能触达**任何**一个提供方,它就直接完成而不渲染——已注册且其具名凭据引用已存储的路由(包括来自启动环境且只读的凭据),或 profile 根本不指名任何引用、因而走原生认证的路由。只有二者皆无的用户才会被问到 DeepSeek,即这条提示唯一能为其提供密钥输入框的路由。它通过 `llm-deepseek` 的可配置提供方声明识别官方适配器,因此同 id 但未声明的存活路由不属于可修复配置。只有已挂载且活跃、引用可写但尚未配置的适配器才会显示前往「设置」Models 分区的页面;密钥输入和 `credentials.set` 仅由该分区已有的设置卡片负责,该步骤绝不持有 secret。适配器缺失、路由不活跃、联接失败、部署只读或设置/凭据能力不可用时,该步骤均不渲染并直接完成,以免首次使用引导阻塞产品;Models 页仍是诊断界面。 每一次编辑都以 `settings.mutate` 的路径 op 落到已存分节上——每个变更字段一条 set、每个清空字段一条 unset、删除提供方行则是单独一条 unset。页面自始至终只持有**脱敏后**的 descriptor,因此它只修改自己看得见的字段,而不重建分节。DeepSeek 的 `models` 是一个按值整体替换的数组:编辑器会显示继承而来的生效模型行,直到第一次模型编辑将完整数组具化到用户层;重置则会取消该覆盖。每个模型行承载模型 ID 与显示名称,其上下文窗口与最大输出 token 数则收在该行自己的折叠区里,使用与 pi-ai 提供方表单相同的字段。两项容量都按数值键入,可带十进制的 `K` 或 `M` 后缀(`256K`、`1M`;`1M` 即 1000K),存储为纯数值,回显时写成能够往返的最短形式。空 ID、重复 ID、显式填写的空名称,以及无法读取、非正数或非整数的容量都会在写入前失败。键入的 API 密钥同样在它自己的字段上被判定:trim 之后必须非空,且每个字符都是可打印 ASCII(`[\x21-\x7E]`)——这正是 HTTP 标头值所能承载的范围,是 `@deepseek-ai/dsh-llm` 中 `normalizeApiKey` 的孪生体,因源码平面分割禁止直接引入而在此镜像。与整行粘贴的 `NAME=value` 环境变量匹配或首尾成对引号包裹的值,会以同一条格式失败被拒绝;这项粘贴行检查只在浏览器中运行,因为 resolver 中的一次误判会连带让环境变量这条路也拒绝该密钥。只含空白的输入框会失败而不是被静默丢弃;留空则完全不是失败:在编辑卡片上意味着保持已存储的密钥,在新建卡片上则意味着以其他方式鉴权。被拒绝的密钥会同时拦截写入与端点探测,因此页面不会白花一次往返去换取字段上已经写明的答案。每次 settings 写入都携带卡片当前的 `revision`,因此来自另一个标签页或对 `settings.yaml` 的外部编辑所产生的并发写入会以 `settings-conflict` 被拒绝;settings 提交成功后,卡片会在存储凭据前采用响应返回的脱敏用户子树与 revision,因此凭据阶段失败时,重试只会重复该阶段。删除操作只会在 profile 指向页面派生的 `_API_KEY` 目标时清除已配置且可写的凭据,随后取消设置 profile;两项操作都具备幂等性,部分失败会停留在点名目标的确认对话框中供重试。环境凭据、自定义引用和无法识别目标的凭据保持不变。页面加载完成后会直接订阅转发的 owner 事件 `settings/document-updated`、`credentials/updated`、`llm/adapters-updated`,以及本地 `connection/reset`,因此外部的 `settings.yaml` 编辑、第二个标签页或 settings 新生的路由都无需轮询即可收敛。 diff --git a/packages/client/ui-models/src/client/DeepSeekOnboardingDialog.tsx b/packages/client/ui-models/src/client/DeepSeekOnboardingDialog.tsx index c8668c3700..302d4592f8 100644 --- a/packages/client/ui-models/src/client/DeepSeekOnboardingDialog.tsx +++ b/packages/client/ui-models/src/client/DeepSeekOnboardingDialog.tsx @@ -1,7 +1,9 @@ /** * Official-DeepSeek first-run step. Readiness comes from the same - * provider/settings/credential join as the Models page; the prompt only - * routes the user to that page's single credential editor. + * provider/settings/credential join as the Models page: any provider the user + * can already talk to ends the step, and only a user with none is offered the + * official DeepSeek route. The prompt itself only routes to that page's single + * credential editor. */ import { useEffect, useRef } from 'react' @@ -10,7 +12,7 @@ import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import { BrandWordmark, Button, OnboardingSurface } 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' +import { onboardingReadiness } from './store.ts' import type { en } from './locales.ts' import styles from './DeepSeekOnboardingDialog.module.css' @@ -34,15 +36,15 @@ function assertNever(_value: never): never { } /** - * Prompt a first-run user to open Models while the official adapter exists - * and its effective credential is not configured. + * Prompt a first-run user to open Models while no provider can serve requests + * and the official adapter exists with an unconfigured effective credential. * @param props - settings-shell owner state and Models feature dependencies. * @returns the onboarding page or null when onboarding needs no intervention. */ export function DeepSeekOnboardingDialog(props: DeepSeekOnboardingDialogProps): ReactNode { const { complete, openSection, controller, useSnapshot, t } = props const state = useSnapshot(snapshot => snapshot) - const readiness = deepSeekReadiness(state) + const readiness = onboardingReadiness(state) const titleRef = useRef(null) useEffect(() => { @@ -52,7 +54,7 @@ export function DeepSeekOnboardingDialog(props: DeepSeekOnboardingDialogProps): useEffect(() => { if ( readiness.kind === 'adapter-absent' - || readiness.kind === 'configured' + || readiness.kind === 'provider-ready' || readiness.kind === 'unavailable' ) complete() }, [complete, readiness.kind]) @@ -72,7 +74,7 @@ export function DeepSeekOnboardingDialog(props: DeepSeekOnboardingDialogProps): switch (readiness.kind) { case 'loading': case 'adapter-absent': - case 'configured': + case 'provider-ready': case 'unavailable': return null case 'credential-missing': diff --git a/packages/client/ui-models/src/client/ModelsSection.tsx b/packages/client/ui-models/src/client/ModelsSection.tsx index 1eba48903e..5fe5647b88 100644 --- a/packages/client/ui-models/src/client/ModelsSection.tsx +++ b/packages/client/ui-models/src/client/ModelsSection.tsx @@ -3,11 +3,13 @@ * directory, settings namespaces, and credential states, with one editor * card at a time. Rows expose only confirmed API-key state through accessible * solid configured or missing dots. A whole-section provider without a - * configured key (the unconfigured DeepSeek posture) renders as its open setup - * card instead of a row; the add flow is a card carrying the dormant-provider - * select. Every mutation writes through the wire, while a provider removal first requires - * confirmation; the page re-renders from pushed invalidations or the - * post-apply reload. + * configured key renders as its open setup card instead of a row, but only in + * the first-run posture — no provider on the page can serve requests yet — and + * only until the user closes that card; the add flow is a card carrying the + * dormant-provider select. Each card kind owns its own open state, so closing + * one never discards a draft in another. Every mutation writes through the + * wire, while a provider removal first requires confirmation; the page + * re-renders from pushed invalidations or the post-apply reload. */ import { useState } from 'react' @@ -16,7 +18,7 @@ import type { IApiClient } from '@deepseek-ai/dsh-api-remotes/client' import { Button, IconPlusOutline16, Modal } from '@deepseek-ai/dsh-client-ui-primitives' import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-web-react' import { CustomProviderCard } from './CustomProviderCard.tsx' -import { deriveKeyRef, messageOf, protocolChoices } from './store.ts' +import { deriveKeyRef, messageOf, protocolChoices, providerUsable } from './store.ts' import type { ModelsSettingsState, ModelsSettingsStore, ProviderRow } from './store.ts' import { ProviderEditor, type ProviderEditorProps } from './ProviderEditor.tsx' import type { en } from './locales.ts' @@ -116,11 +118,15 @@ export async function removeProviderProfile( /** * Whether a whole-section provider still needs its first key: an unconfigured - * credential opens the setup card instead of showing a row. + * credential opens the setup card instead of showing a row. This is the + * first-run posture alone — a user who can already reach some provider gets an + * ordinary row with the missing-key dot, since nothing here is blocking them. * @param row - the joined provider row. + * @param anyUsable - whether any joined row can already serve requests. * @returns whether to render the setup card. */ -export function needsSetup(row: ProviderRow): boolean { +export function needsSetup(row: ProviderRow, anyUsable: boolean): boolean { + if (anyUsable) return false if (row.entry.settingsPath.length > 0) return false return row.credential?.configured !== true } @@ -178,17 +184,32 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode { const [deleteFailure, setDeleteFailure] = useState(undefined) const [savedTarget, setSavedTarget] = useState(undefined) const [declaring, setDeclaring] = useState(false) + const [dismissedSetup, setDismissedSetup] = useState>(() => new Set()) + + const announceSaved = (target: ProviderIdentity): void => { + // Announced only once the refreshed directory is in the snapshot the + // notice reads its name from: an apply can rename the route, and the + // target captured when the card opened still carries the old name. + void controller.load().then(() => { setSavedTarget(target) }) + } const closeEditor = (changed: boolean, target: ProviderIdentity): void => { setEditing(undefined) setAdding(false) setDeclaring(false) - if (changed) { - // Announced only once the refreshed directory is in the snapshot the - // notice reads its name from: an apply can rename the route, and the - // target captured when the card opened still carries the old name. - void controller.load().then(() => { setSavedTarget(target) }) - } + if (changed) announceSaved(target) + } + + /** + * Close a setup card, which owns none of the state above: the row-editor, + * add, and declare cards each own one of those, so clearing them here would + * discard a draft the user opened beside this card. Dismissal is this card's + * own — the provider falls back to an ordinary row for the rest of the + * session, and reopens through Edit. + */ + const closeSetup = (changed: boolean, target: ProviderIdentity): void => { + setDismissedSetup(previous => new Set([...previous, target.provider])) + if (changed) announceSaved(target) } const closeDelete = (): void => { @@ -238,6 +259,9 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode { ? savedTarget : { provider: savedRow.entry.provider, displayName: savedRow.entry.displayName } + // One fact decides both first-run postures on this page and the onboarding + // step: whether the user already has a provider to talk to. + const anyUsable = state.rows.some(providerUsable) const configured = state.rows.filter(row => row.configured) const addable = state.rows.filter(row => !row.configured && row.entry.settingsNs !== '') const addTarget = adding ? editing : undefined @@ -265,9 +289,9 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode { const namespace = state.namespaces.get(target.settingsNs) /* v8 ignore next -- the join marks a row configured only when its namespace resolved */ if (namespace === undefined) return null - if (needsSetup(row)) { + if (needsSetup(row, anyUsable) && !dismissedSetup.has(row.entry.provider)) { // First-run posture: the provider exists but has no key — the - // setup card IS its presence on the page. + // setup card IS its presence on the page, until the user closes it. return (
  • {renderProviderEditor({ @@ -276,7 +300,7 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode { api, t, readOnly: !state.writable, - onClose: (changed) => { closeEditor(changed, target) }, + onClose: (changed) => { closeSetup(changed, target) }, })}
  • ) diff --git a/packages/client/ui-models/src/client/store.ts b/packages/client/ui-models/src/client/store.ts index 9cc2cb7c77..4389b9a6cb 100644 --- a/packages/client/ui-models/src/client/store.ts +++ b/packages/client/ui-models/src/client/store.ts @@ -189,32 +189,49 @@ export class ModelsSettingsStore { } } -/** DeepSeek onboarding readiness derived only from the shared Models join. */ -export type DeepSeekReadiness = +/** + * Whether a joined row can serve model requests as it stands: the route is + * registered with the adapter registry, and whatever credential its resolved + * profile names is stored. A profile naming no reference authenticates through + * the provider's own path (the Bedrock chain, Vertex ADC, a gateway that needs + * nothing), as does a live route with no settings address at all, so neither + * owes this page a key. + * @param row - one joined provider row. + * @returns whether the user already has this provider to talk to. + */ +export function providerUsable(row: ProviderRow): boolean { + if (!row.entry.active) return false + if (row.apiKeyEnv === undefined) return true + return row.credential?.configured === true +} + +/** First-run onboarding readiness derived only from the shared Models join. */ +export type OnboardingReadiness = | { kind: 'loading' } | { kind: 'adapter-absent' } - | { kind: 'configured' } + | { kind: 'provider-ready' } | { kind: 'credential-missing' } | { kind: 'unavailable' reason: | 'load-failed' | 'provider-inactive' - | 'settings-unavailable' - | 'credential-ref-unavailable' | 'credentials-unavailable' | 'settings-read-only' | 'credential-read-only' } /** - * Project official-DeepSeek readiness from the provider/settings/credential - * join used by the Models page. A missing official configurable-provider + * Project first-run readiness from the provider/settings/credential join used + * by the Models page. The step exists to leave the user with a model to talk + * to, so ANY usable provider ends it; only when none exists does the official + * DeepSeek route — the one route the prompt can offer a key field for — decide + * whether prompting can help. 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. */ -export function deepSeekReadiness(state: ModelsSettingsState): DeepSeekReadiness { +export function onboardingReadiness(state: ModelsSettingsState): OnboardingReadiness { if ((state.status === 'idle' || state.status === 'loading') && state.rows.length === 0) { return { kind: 'loading' } } @@ -224,6 +241,7 @@ export function deepSeekReadiness(state: ModelsSettingsState): DeepSeekReadiness reason: 'load-failed', } } + if (state.rows.some(providerUsable)) return { kind: 'provider-ready' } const row = state.rows.find(candidate => candidate.entry.provider === 'deepseek-official' && candidate.entry.settingsNs === 'llm-deepseek' @@ -235,33 +253,14 @@ export function deepSeekReadiness(state: ModelsSettingsState): DeepSeekReadiness reason: 'provider-inactive', } } - if (!row.configured) { - return { - kind: 'unavailable', - reason: 'settings-unavailable', - } - } - if (row.apiKeyEnv === undefined) { - return { - kind: 'unavailable', - reason: 'credential-ref-unavailable', - } - } - if (state.credentialError !== null) { + // Past the usable gate an active route names a reference it has no stored + // credential for, so the remaining questions are all about that credential. + if (state.credentialError !== null || row.credential === undefined) { return { kind: 'unavailable', reason: 'credentials-unavailable', } } - if (row.credential === undefined) { - return { - kind: 'unavailable', - reason: 'credentials-unavailable', - } - } - if (row.credential.configured) { - return { kind: 'configured' } - } if (!state.writable) { return { kind: 'unavailable', diff --git a/packages/client/ui-models/tests/components.client.spec.tsx b/packages/client/ui-models/tests/components.client.spec.tsx index b1582a5fb8..01f0a32349 100644 --- a/packages/client/ui-models/tests/components.client.spec.tsx +++ b/packages/client/ui-models/tests/components.client.spec.tsx @@ -23,6 +23,8 @@ afterEach(cleanup) const t: ModelsSectionInjected['t'] = key => en[key] const OPENAI_TARGET = { provider: 'openai', displayName: 'openai' } const openaiCopy = (template: string): string => providerCopy(template, OPENAI_TARGET) +const DEEPSEEK_TARGET = { provider: 'deepseek-official', displayName: 'DeepSeek' } +const deepSeekCopy = (template: string): string => providerCopy(template, DEEPSEEK_TARGET) /** Open one row's capacity disclosure (1-based, as the labels read). */ function expandRow(position: number): void { @@ -181,8 +183,8 @@ function scriptedFace(overrides: { type WireFace = ConstructorParameters[0] -async function mountSection(overrides: Parameters[0] = {}) { - const { face, update, replace, mutate, set, unset } = scriptedFace(overrides) +async function mountFace(scripted: ReturnType) { + const { face, update, replace, mutate, set, unset } = scripted const controller = new ModelsSettingsStore(face as unknown as WireFace) await controller.load() const injected: ModelsSectionInjected = { @@ -195,6 +197,34 @@ async function mountSection(overrides: Parameters[0] = {}) return { view, face, update, replace, mutate, set, unset, controller } } +async function mountSection(overrides: Parameters[0] = {}) { + return mountFace(scriptedFace(overrides)) +} + +/** + * Mount for a user who cannot reach any provider yet: no credential is stored + * anywhere, so the whole-section DeepSeek route owns the first-run setup card. + */ +async function mountFirstRun(overrides: Parameters[0] = {}) { + const scripted = scriptedFace(overrides) + scripted.face.credentials.describe.mockImplementation((payload: { refs: string[] }) => + Promise.resolve(ok({ + credentials: Object.fromEntries(payload.refs.map(ref => [ref, { configured: false, writable: true }])), + }))) + return mountFace(scripted) +} + +/** + * Mount and open the DeepSeek editor. The shared fixture already has a usable + * openai route, so DeepSeek is an ordinary row whose card opens through Edit + * rather than by itself. + */ +async function mountDeepSeekCard(overrides: Parameters[0] = {}) { + const mounted = await mountSection(overrides) + fireEvent.click(screen.getByRole('button', { name: deepSeekCopy(en.editProvider) })) + return mounted +} + describe('ModelsSection', () => { it('renders nothing before the slot injects its dependencies', () => { const uninjected = {} as ModelsSectionProps @@ -202,20 +232,32 @@ describe('ModelsSection', () => { expect(document.body.textContent).toBe('') }) - it('renders the unkeyed whole-section provider as an open setup card beside the rows', async () => { - await mountSection() - // DeepSeek has no configured credential and no stored apiKey → setup card. + it('renders the unkeyed whole-section provider as an open setup card in the first-run posture', async () => { + await mountFirstRun() + // Nothing is reachable yet, and DeepSeek has no configured credential and + // no stored apiKey → setup card. expect(screen.getByText('DeepSeek')).toBeTruthy() expect(screen.getByLabelText(en.keyInput)).toBeTruthy() expect(screen.getByText('openai')).toBeTruthy() expect(screen.queryByText('Active')).toBeNull() expect(screen.queryByText('Inactive')).toBeNull() + expect(screen.getByText(en.add)).toBeTruthy() + }) + + it('leaves the unkeyed provider a plain row once another provider is usable', async () => { + await mountSection() + // openai's key is stored, so the user is not blocked and nothing on the + // page opens itself over them. + expect(screen.queryByLabelText(en.keyInput)).toBeNull() const configured = screen.getByRole('img', { name: en.credentialConfigured }) expect(configured.getAttribute('title')).toBe(en.credentialConfigured) expect(configured.className).toContain('credentialDotConfigured') expect(configured.closest('li')?.textContent).toContain('openai') - expect(screen.queryByRole('img', { name: en.credentialMissing })).toBeNull() - expect(screen.getByText(en.add)).toBeTruthy() + const missing = screen.getByRole('img', { name: en.credentialMissing }) + expect(missing.closest('li')?.textContent).toContain('DeepSeek') + // The card is still one click away. + fireEvent.click(screen.getByRole('button', { name: deepSeekCopy(en.editProvider) })) + expect(screen.getByLabelText(en.keyInput)).toBeTruthy() }) it('marks only a confirmed missing reference and leaves native or unavailable state unmarked', async () => { @@ -241,7 +283,7 @@ describe('ModelsSection', () => { }) it('turns the setup card into a row once the credential reports configured', async () => { - const { face } = await mountSection() + const { face } = await mountFirstRun() face.credentials.describe.mockImplementation((payload: { refs: string[] }) => Promise.resolve(ok({ credentials: Object.fromEntries(payload.refs.map(ref => [ref, { configured: true, writable: true }])), }))) @@ -259,7 +301,7 @@ describe('ModelsSection', () => { expect(screen.queryByLabelText(en.keyInput)).toBeNull() }) - it('decides setup need from the joined credential state', () => { + it('decides setup need from the joined credential state and the first-run posture', () => { const entry = { provider: 'p', displayName: 'p', settingsNs: 'llm-deepseek', settingsPath: [], active: true } const row = (credential: ProviderRow['credential']): ProviderRow => ({ entry, @@ -268,10 +310,13 @@ describe('ModelsSection', () => { apiKeyEnv: 'X', credential, }) - expect(needsSetup(row(undefined))).toBe(true) - expect(needsSetup(row({ configured: true, writable: true }))).toBe(false) + expect(needsSetup(row(undefined), false)).toBe(true) + expect(needsSetup(row({ configured: true, writable: true }), false)).toBe(false) const nested = { ...row(undefined), entry: { ...entry, settingsPath: ['providers', 'x'] } } - expect(needsSetup(nested)).toBe(false) + expect(needsSetup(nested, false)).toBe(false) + // A user who can already reach some provider is not in the first-run + // posture, so nothing on the page opens itself. + expect(needsSetup(row(undefined), true)).toBe(false) }) it('derives conventional credential references from route ids', () => { @@ -296,7 +341,7 @@ describe('ModelsSection', () => { }) it('stores a typed key write-only from the setup card without touching settings', async () => { - const { set, update, face } = await mountSection() + const { set, update, face } = await mountFirstRun() const key = screen.getByLabelText(en.keyInput) fireEvent.change(key, { target: { value: ' sk-live ' } }) fireEvent.click(screen.getByText(en.apply)) @@ -311,7 +356,7 @@ describe('ModelsSection', () => { }) it('applies customized deepseek fields as path ops', async () => { - const { mutate } = await mountSection({ + const { mutate } = await mountDeepSeekCard({ mutate: vi.fn(() => Promise.resolve(ok(wireNamespaces()[0]))), }) fireEvent.click(screen.getByText(en.customized)) @@ -332,7 +377,7 @@ describe('ModelsSection', () => { }) it('materializes inherited models and adds an arbitrary DeepSeek id', async () => { - const { mutate } = await mountSection({ + const { mutate } = await mountDeepSeekCard({ mutate: vi.fn(() => Promise.resolve(ok(wireNamespaces()[0]))), }) fireEvent.click(screen.getByText(en.customized)) @@ -366,7 +411,7 @@ describe('ModelsSection', () => { }) it('rejects duplicate DeepSeek model ids before writing', async () => { - const { mutate } = await mountSection() + const { mutate } = await mountDeepSeekCard() fireEvent.click(screen.getByText(en.customized)) fireEvent.click(screen.getByText(en.addModel)) const ids = screen.getAllByLabelText(new RegExp(en.modelId)) @@ -436,7 +481,7 @@ describe('ModelsSection', () => { }) it('accepts a suffixed context window and stores the plain count', async () => { - const { mutate } = await mountSection({ + const { mutate } = await mountDeepSeekCard({ mutate: vi.fn(() => Promise.resolve(ok(wireNamespaces()[0]))), }) fireEvent.click(screen.getByText(en.customized)) @@ -476,7 +521,7 @@ describe('ModelsSection', () => { }) it('keeps unreadable context-window text on screen and refuses the write', async () => { - const { mutate } = await mountSection() + const { mutate } = await mountDeepSeekCard() fireEvent.click(screen.getByText(en.customized)) expandRow(1) expandRow(2) @@ -539,7 +584,7 @@ describe('ModelsSection', () => { // The regression: one active buffer meant editing a second row displaced // the first, which then fell back to rendering its stored NaN as `NaN` — // losing the text the user was told they could still correct. - await mountSection() + await mountDeepSeekCard() fireEvent.click(screen.getByText(en.customized)) expandRow(1) expandRow(2) @@ -553,7 +598,7 @@ describe('ModelsSection', () => { }) it('re-keys the typed text around a removed row', async () => { - await mountSection() + await mountDeepSeekCard() fireEvent.click(screen.getByText(en.customized)) const windows = (): HTMLInputElement[] => capacityInputs(en.contextWindow) const removeRow = (at: number): void => { @@ -587,7 +632,7 @@ describe('ModelsSection', () => { // The regression: reset removed the override but left the buffer, so an // inherited row displayed text no settings layer stores — and because an // unreadable buffer never settles, it stayed there indefinitely. - const { mutate } = await mountSection({ + const { mutate } = await mountDeepSeekCard({ mutate: vi.fn(() => Promise.resolve(ok(wireNamespaces()[0]))), }) fireEvent.click(screen.getByText(en.customized)) @@ -605,12 +650,12 @@ describe('ModelsSection', () => { // Reset put the draft back where it started, so Apply writes nothing at // all rather than persisting whatever the stale text had parsed to. fireEvent.click(screen.getByText(en.apply)) - await waitFor(() => { expect(screen.getByText(en.apply)).toBeTruthy() }) + await waitFor(() => { expect(screen.queryByText(en.apply)).toBeNull() }) expect(mutate).not.toHaveBeenCalled() }) it('edits an output cap per model and carries its text across a removal', async () => { - const { mutate } = await mountSection({ + const { mutate } = await mountDeepSeekCard({ mutate: vi.fn(() => Promise.resolve(ok(wireNamespaces()[0]))), }) fireEvent.click(screen.getByText(en.customized)) @@ -644,7 +689,7 @@ describe('ModelsSection', () => { }) it('settles a pasted id and refuses whitespace that would never match', async () => { - await mountSection() + await mountDeepSeekCard() fireEvent.click(screen.getByText(en.customized)) const ids = screen.getAllByLabelText(new RegExp(en.modelId)) fireEvent.change(ids[0] as HTMLInputElement, { target: { value: ' deepseek-v4-flash ' } }) @@ -681,7 +726,7 @@ describe('ModelsSection', () => { }) it('can empty and reset the model override, then clear optional fields without dropping hidden data', async () => { - const { mutate } = await mountSection({ + const { mutate } = await mountDeepSeekCard({ mutate: vi.fn(() => Promise.resolve(ok(wireNamespaces()[0]))), }) fireEvent.click(screen.getByText(en.customized)) @@ -715,7 +760,7 @@ describe('ModelsSection', () => { it('clears an inherited override with an unset op, never a whole-section replace', async () => { // A whole-section replace would clobber sibling overrides to clear one field. - const { replace, update, mutate } = await mountSection() + const { replace, update, mutate } = await mountDeepSeekCard() fireEvent.click(screen.getByText(en.customized)) const url = screen.getByLabelText(en.baseUrl) expect(url.value).toBe('https://base') @@ -762,7 +807,7 @@ describe('ModelsSection', () => { }) it('rejects an invalid draft before writing', async () => { - const { update } = await mountSection() + const { update } = await mountDeepSeekCard() fireEvent.click(screen.getByText(en.customized)) fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: 'not-a-url' } }) fireEvent.click(screen.getByText(en.apply)) @@ -772,19 +817,17 @@ describe('ModelsSection', () => { it('edits a pi-ai profile with the curated fields only', async () => { const { mutate } = await mountSection() - fireEvent.click(screen.getAllByText(en.edit)[0] as HTMLElement) + fireEvent.click(screen.getByRole('button', { name: openaiCopy(en.editProvider) })) // The configured credential shows as the stored placeholder. - const keys = await screen.findAllByLabelText(en.keyInput) - const editorKey = keys[keys.length - 1] as HTMLInputElement + const editorKey = await screen.findByLabelText(en.keyInput) await waitFor(() => { expect(editorKey.placeholder).toBe(en.keyStored) }) // pi-ai carries Base URL too: the stored override shows as the value and // the effective profile endpoint as its placeholder source. - fireEvent.click(screen.getAllByText(en.customized)[1] as HTMLElement) - const urls = screen.getAllByLabelText(en.baseUrl) - expect(urls).toHaveLength(2) - expect((urls[1] as HTMLInputElement).value).toBe('https://proxy') - fireEvent.change(urls[1] as HTMLInputElement, { target: { value: 'https://proxy/v2' } }) - fireEvent.click(screen.getAllByText(en.apply)[1] as HTMLElement) + fireEvent.click(screen.getByText(en.customized)) + const url = screen.getByLabelText(en.baseUrl) + expect(url.value).toBe('https://proxy') + fireEvent.change(url, { target: { value: 'https://proxy/v2' } }) + fireEvent.click(screen.getByText(en.apply)) await waitFor(() => { expect(mutate).toHaveBeenCalledTimes(1) }) // Only the edited field travels: apiKeyEnv and headers were already stored // with these values, so no op restates them. @@ -803,14 +846,12 @@ describe('ModelsSection', () => { expect(pick.value).toBe('anthropic') // A dormant profile has no endpoint anywhere: the pi-ai placeholder // falls back to the provider-default wording. - fireEvent.click(screen.getAllByText(en.customized)[1] as HTMLElement) - const urls = screen.getAllByLabelText(en.baseUrl) - expect((urls[1] as HTMLInputElement).placeholder).toBe(en.baseUrlDefault) - const keys = screen.getAllByLabelText(en.keyInput) - const addKey = keys[keys.length - 1] as HTMLInputElement + fireEvent.click(screen.getByText(en.customized)) + expect(screen.getByLabelText(en.baseUrl).placeholder).toBe(en.baseUrlDefault) + const addKey = screen.getByLabelText(en.keyInput) expect(addKey.placeholder).toBe(en.keyPlaceholderNative) fireEvent.change(addKey, { target: { value: 'sk-ant' } }) - fireEvent.click(screen.getAllByText(en.apply)[1] as HTMLElement) + fireEvent.click(screen.getByText(en.apply)) await waitFor(() => { expect(mutate).toHaveBeenCalledTimes(1) }) expect(mutate.mock.calls[0]?.[0]).toEqual({ ns: 'llm-pi-ai', @@ -824,7 +865,7 @@ describe('ModelsSection', () => { const { mutate, set } = await mountSection() fireEvent.click(screen.getByText(en.add)) await screen.findByLabelText(en.provider) - fireEvent.click(screen.getAllByText(en.apply)[1] as HTMLElement) + fireEvent.click(screen.getByText(en.apply)) await waitFor(() => { expect(mutate).toHaveBeenCalledOnce() }) expect(mutate.mock.calls[0]?.[0]).toEqual({ ns: 'llm-pi-ai', @@ -855,9 +896,8 @@ describe('ModelsSection', () => { const { face, controller } = await mountSection({ mutate, set }) fireEvent.click(screen.getByText(en.add)) await screen.findByLabelText(en.provider) - const keys = screen.getAllByLabelText(en.keyInput) - fireEvent.change(keys[keys.length - 1] as HTMLInputElement, { target: { value: 'sk-ant' } }) - fireEvent.click(screen.getAllByText(en.apply)[1] as HTMLElement) + fireEvent.change(screen.getByLabelText(en.keyInput), { target: { value: 'sk-ant' } }) + fireEvent.click(screen.getByText(en.apply)) await screen.findByText('credential store unavailable') expect(mutate).toHaveBeenCalledOnce() face.settings.describe.mockResolvedValue(ok({ @@ -867,7 +907,7 @@ describe('ModelsSection', () => { })) await act(async () => { await controller.load() }) expect(controller.store.getSnapshot().namespaces.get('llm-pi-ai')?.revision).toBe(1) - fireEvent.click(screen.getAllByText(en.apply)[1] as HTMLElement) + fireEvent.click(screen.getByText(en.apply)) await waitFor(() => { expect(set).toHaveBeenCalledTimes(2) }) expect(mutate).toHaveBeenCalledOnce() expect(set).toHaveBeenLastCalledWith({ ref: 'ANTHROPIC_API_KEY', value: 'sk-ant' }) @@ -883,10 +923,9 @@ describe('ModelsSection', () => { await waitFor(() => { expect(screen.getAllByText(content => content.includes(en.advancedHint)).length).toBeGreaterThan(0) }) - // The hint-only card cannot apply anything. - const applies = screen.getAllByText(en.apply) - expect((applies[applies.length - 1] as HTMLButtonElement).disabled).toBe(true) - expect(screen.getAllByLabelText(en.keyInput)).toHaveLength(1) + // The hint-only card cannot apply anything, and offers no key field. + expect(screen.getByText(en.apply).disabled).toBe(true) + expect(screen.queryAllByLabelText(en.keyInput)).toHaveLength(0) }) it('surfaces a rejected settings write and never stores the key after it', async () => { @@ -895,9 +934,8 @@ describe('ModelsSection', () => { }) fireEvent.click(screen.getByText(en.add)) await screen.findByLabelText(en.provider) - const keys = screen.getAllByLabelText(en.keyInput) - fireEvent.change(keys[keys.length - 1] as HTMLInputElement, { target: { value: 'sk-x' } }) - fireEvent.click(screen.getAllByText(en.apply)[1] as HTMLElement) + fireEvent.change(screen.getByLabelText(en.keyInput), { target: { value: 'sk-x' } }) + fireEvent.click(screen.getByText(en.apply)) await screen.findByText(/unknown pi-ai provider/) expect(set).not.toHaveBeenCalled() }) @@ -930,7 +968,7 @@ describe('ModelsSection', () => { it('tells the user to reopen when another writer moved the namespace first', async () => { // The stale-draft overwrite: two tabs open the same card, the other saves, // and this one must be refused rather than replay its opening snapshot. - const { set } = await mountSection({ + const { set } = await mountDeepSeekCard({ mutate: vi.fn(() => Promise.resolve(fail('changed since it was read', 'settings-conflict'))), }) fireEvent.click(screen.getByText(en.customized)) @@ -944,7 +982,7 @@ describe('ModelsSection', () => { // A transport failure (disconnect, or the 403 a non-loopback browser now // gets on the whole configuration plane) rejects rather than returning a // failed envelope: without a catch the card would stay busy forever. - await mountSection({ mutate: vi.fn(() => Promise.reject(new Error('connection lost'))) }) + await mountDeepSeekCard({ mutate: vi.fn(() => Promise.reject(new Error('connection lost'))) }) fireEvent.click(screen.getByText(en.customized)) fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: 'https://next' } }) fireEvent.click(screen.getByText(en.apply)) @@ -954,7 +992,7 @@ describe('ModelsSection', () => { }) it('surfaces a shadowed credential write on the card', async () => { - await mountSection({ + await mountFirstRun({ set: vi.fn(() => Promise.resolve(fail('credentials: DEEPSEEK_API_KEY is shadowed by the read-only environment', 'credential-rejected'))), }) const key = screen.getByLabelText(en.keyInput) @@ -971,9 +1009,8 @@ describe('ModelsSection', () => { configured: ref === 'OPENAI_API_KEY', source: 'env', writable: false, }])), }))) - fireEvent.click(screen.getAllByText(en.edit)[0] as HTMLElement) - const keys = await screen.findAllByLabelText(en.keyInput) - const editorKey = keys[keys.length - 1] as HTMLInputElement + fireEvent.click(screen.getByRole('button', { name: openaiCopy(en.editProvider) })) + const editorKey = await screen.findByLabelText(en.keyInput) await waitFor(() => { expect(editorKey.placeholder).toBe(en.keyEnvLocked) }) expect(editorKey.disabled).toBe(true) }) @@ -981,12 +1018,11 @@ describe('ModelsSection', () => { it('keeps a failed credential describe silent and the input usable', async () => { const { face, set } = await mountSection() face.credentials.describe.mockImplementation(() => Promise.resolve(fail('down', 'internal')) as never) - fireEvent.click(screen.getAllByText(en.edit)[0] as HTMLElement) - const keys = await screen.findAllByLabelText(en.keyInput) - const editorKey = keys[keys.length - 1] as HTMLInputElement + fireEvent.click(screen.getByRole('button', { name: openaiCopy(en.editProvider) })) + const editorKey = await screen.findByLabelText(en.keyInput) expect(editorKey.placeholder).toBe(en.keyPlaceholderNative) fireEvent.change(editorKey, { target: { value: 'sk-live' } }) - fireEvent.click(screen.getAllByText(en.apply)[1] as HTMLElement) + fireEvent.click(screen.getByText(en.apply)) await waitFor(() => { expect(set).toHaveBeenCalledTimes(1) }) }) @@ -1085,15 +1121,15 @@ describe('ModelsSection', () => { it('toggles the row editor closed on a second edit click and on cancel', async () => { const { update } = await mountSection() - const edit = screen.getAllByText(en.edit)[0] as HTMLElement + const edit = screen.getByRole('button', { name: openaiCopy(en.editProvider) }) fireEvent.click(edit) - await waitFor(() => { expect(screen.getAllByLabelText(en.keyInput).length).toBe(2) }) + await waitFor(() => { expect(screen.queryAllByLabelText(en.keyInput).length).toBe(1) }) fireEvent.click(edit) - expect(screen.getAllByLabelText(en.keyInput)).toHaveLength(1) + expect(screen.queryAllByLabelText(en.keyInput)).toHaveLength(0) fireEvent.click(edit) - await waitFor(() => { expect(screen.getAllByLabelText(en.keyInput).length).toBe(2) }) - fireEvent.click(screen.getAllByText(en.cancel)[1] as HTMLElement) - expect(screen.getAllByLabelText(en.keyInput)).toHaveLength(1) + await waitFor(() => { expect(screen.queryAllByLabelText(en.keyInput).length).toBe(1) }) + fireEvent.click(screen.getByText(en.cancel)) + expect(screen.queryAllByLabelText(en.keyInput)).toHaveLength(0) expect(update).not.toHaveBeenCalled() }) @@ -1101,11 +1137,34 @@ describe('ModelsSection', () => { await mountSection() fireEvent.click(screen.getByText(en.add)) await screen.findByLabelText(en.provider) - fireEvent.click(screen.getAllByText(en.cancel)[1] as HTMLElement) + fireEvent.click(screen.getByText(en.cancel)) await screen.findByText(en.add) expect(screen.queryByLabelText(en.provider)).toBeNull() }) + it('collapses the setup card on cancel without disturbing another open card', async () => { + // The regression: the setup card shared the row/add/declare close handler, + // so cancelling it discarded the add card's draft while staying open itself. + await mountFirstRun() + expect(screen.getAllByLabelText(en.keyInput)).toHaveLength(1) + fireEvent.click(screen.getByText(en.add)) + await screen.findByLabelText(en.provider) + expect(screen.getAllByLabelText(en.keyInput)).toHaveLength(2) + + // The setup card is the first one on the page, above the add block. + fireEvent.click(screen.getAllByText(en.cancel)[0] as HTMLElement) + // The add card kept its draft… + expect(screen.getByLabelText(en.provider)).toBeTruthy() + // …and DeepSeek collapsed to an ordinary row carrying the missing-key dot. + expect(screen.getAllByLabelText(en.keyInput)).toHaveLength(1) + expect(screen.getAllByRole('img', { name: en.credentialMissing }) + .some(dot => dot.closest('li')?.textContent?.includes('DeepSeek') === true)).toBe(true) + // Its card reopens through Edit, which closes the add card as any row does. + fireEvent.click(screen.getByRole('button', { name: deepSeekCopy(en.editProvider) })) + expect(screen.getAllByLabelText(en.keyInput)).toHaveLength(1) + expect(screen.queryByLabelText(en.provider)).toBeNull() + }) + it('loads on first render of an idle controller', async () => { const { face } = scriptedFace() const controller = new ModelsSettingsStore(face as unknown as WireFace) diff --git a/packages/client/ui-models/tests/readiness.client.spec.ts b/packages/client/ui-models/tests/readiness.client.spec.ts index 8647a2da83..f01e821767 100644 --- a/packages/client/ui-models/tests/readiness.client.spec.ts +++ b/packages/client/ui-models/tests/readiness.client.spec.ts @@ -1,8 +1,8 @@ -/** Pure official-DeepSeek readiness projection over the shared Models join. */ +/** Pure first-run readiness projection over the shared Models join. */ import { describe, expect, it } from 'vitest' import type { CredentialView } from '@deepseek-ai/dsh-api-remotes/client' import type { ModelsSettingsState, ProviderRow } from '../src/client/store.ts' -import { deepSeekReadiness } from '../src/client/store.ts' +import { onboardingReadiness, providerUsable } from '../src/client/store.ts' const missingCredential: CredentialView = { configured: false, writable: true } @@ -23,6 +23,24 @@ function row(overrides: Partial = {}): ProviderRow { } } +/** A second provider the user configured themselves. */ +function otherRow(overrides: Partial = {}): ProviderRow { + return { + entry: { + provider: 'hfai', + displayName: 'HFAI', + settingsNs: 'llm-pi-ai', + settingsPath: ['providers', 'hfai'], + active: true, + }, + configured: true, + removable: true, + apiKeyEnv: 'HFAI_API_KEY', + credential: { configured: true, source: 'file', writable: true }, + ...overrides, + } +} + function state(overrides: Partial = {}): ModelsSettingsState { return { status: 'ready', @@ -35,12 +53,25 @@ function state(overrides: Partial = {}): ModelsSettingsStat } } -describe('deepSeekReadiness', () => { +describe('providerUsable', () => { + it('requires a registered route and a stored key for every named reference', () => { + expect(providerUsable(otherRow())).toBe(true) + expect(providerUsable(otherRow({ entry: { ...otherRow().entry, active: false } }))).toBe(false) + expect(providerUsable(otherRow({ credential: missingCredential }))).toBe(false) + expect(providerUsable(otherRow({ credential: undefined }))).toBe(false) + }) + + it('treats a reference-free registered route as provider-native authentication', () => { + expect(providerUsable(otherRow({ apiKeyEnv: undefined, credential: undefined }))).toBe(true) + }) +}) + +describe('onboardingReadiness', () => { it('waits for the first join and skips onboarding when the adapter directory entry is absent', () => { - 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({ + expect(onboardingReadiness(state({ status: 'idle', rows: [] }))).toEqual({ kind: 'loading' }) + expect(onboardingReadiness(state({ status: 'loading', rows: [] }))).toEqual({ kind: 'loading' }) + expect(onboardingReadiness(state({ rows: [] }))).toEqual({ kind: 'adapter-absent' }) + expect(onboardingReadiness(state({ rows: [row({ entry: { ...row().entry, @@ -51,45 +82,47 @@ describe('deepSeekReadiness', () => { }) it('reports a missing writable effective credential', () => { - expect(deepSeekReadiness(state())).toEqual({ kind: 'credential-missing' }) + expect(onboardingReadiness(state())).toEqual({ kind: 'credential-missing' }) + }) + + it('ends onboarding once any other registered provider can serve requests', () => { + expect(onboardingReadiness(state({ rows: [row(), otherRow()] }))).toEqual({ kind: 'provider-ready' }) + // A provider the user cannot reach yet leaves the prompt in place. + expect(onboardingReadiness(state({ + rows: [row(), otherRow({ credential: missingCredential })], + }))).toEqual({ kind: 'credential-missing' }) }) it('accepts file and process-environment credentials without prompting', () => { - expect(deepSeekReadiness(state({ + expect(onboardingReadiness(state({ rows: [row({ credential: { configured: true, source: 'file', writable: true } })], - }))).toEqual({ kind: 'configured' }) - expect(deepSeekReadiness(state({ + }))).toEqual({ kind: 'provider-ready' }) + expect(onboardingReadiness(state({ rows: [row({ credential: { configured: true, source: 'env', writable: false } })], - }))).toEqual({ kind: 'configured' }) + }))).toEqual({ kind: 'provider-ready' }) }) - it('turns missing capabilities and inconsistent descriptors into diagnostics', () => { - expect(deepSeekReadiness(state({ status: 'error', error: 'settings down' }))).toEqual({ + it('turns missing capabilities into diagnostics that never block the product', () => { + expect(onboardingReadiness(state({ status: 'error', error: 'settings down' }))).toEqual({ kind: 'unavailable', reason: 'load-failed', }) - expect(deepSeekReadiness(state({ + expect(onboardingReadiness(state({ rows: [row({ entry: { ...row().entry, active: false } })], }))).toEqual({ kind: 'unavailable', reason: 'provider-inactive' }) - expect(deepSeekReadiness(state({ - rows: [row({ configured: false })], - }))).toEqual({ kind: 'unavailable', reason: 'settings-unavailable' }) - expect(deepSeekReadiness(state({ - rows: [row({ apiKeyEnv: undefined })], - }))).toEqual({ kind: 'unavailable', reason: 'credential-ref-unavailable' }) - expect(deepSeekReadiness(state({ + expect(onboardingReadiness(state({ credentialError: 'credentials service is absent', }))).toEqual({ kind: 'unavailable', reason: 'credentials-unavailable', }) - expect(deepSeekReadiness(state({ + expect(onboardingReadiness(state({ rows: [row({ credential: undefined })], }))).toEqual({ kind: 'unavailable', reason: 'credentials-unavailable' }) - expect(deepSeekReadiness(state({ + expect(onboardingReadiness(state({ rows: [row({ credential: { configured: false, writable: false } })], }))).toEqual({ kind: 'unavailable', reason: 'credential-read-only' }) - expect(deepSeekReadiness(state({ writable: false }))).toEqual({ + expect(onboardingReadiness(state({ writable: false }))).toEqual({ kind: 'unavailable', reason: 'settings-read-only', }) diff --git a/tsconfig.host.json b/tsconfig.host.json index fbb0e829fa..bf82f0e965 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -29,6 +29,7 @@ "apps/web/tests/settings-chrome.e2e.ts", "apps/web/tests/models-settings.e2e.ts", "apps/web/tests/onboarding-deepseek-config.e2e.ts", + "apps/web/tests/onboarding-usable-provider.e2e.ts", "apps/web/tests/remote-welcome.e2e.ts", "apps/web/tests/workspace-management.e2e.ts", "apps/web/tests/replay-round-trip.e2e.ts",