From f2050bfd1e6c3b655c041fb2a07fa92f25f62749 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 6 Aug 2026 16:44:04 +0800 Subject: [PATCH] fix(web): surface provider credential status --- ...06-provider-credential-lifecycle.i18n.yaml | 4 +- ...026-08-06-provider-credential-lifecycle.md | 4 +- ...-08-06-provider-credential-lifecycle.zh.md | 4 +- apps/web/tests/models-settings.e2e.ts | 15 +++- .../models-settings/configured.expected.md | 2 + .../models.expected.md | 1 + packages/client/ui-models/README.i18n.yaml | 4 +- packages/client/ui-models/README.md | 2 +- packages/client/ui-models/README.zh.md | 2 +- .../src/client/ModelsSection.module.css | 31 ++++++++ .../ui-models/src/client/ModelsSection.tsx | 70 +++++++++++++++---- .../client/ui-models/src/client/locales.ts | 6 ++ .../ui-models/tests/components.spec.tsx | 33 +++++++++ 13 files changed, 153 insertions(+), 25 deletions(-) diff --git a/.agents/notes/implemented/bug-fix/2026-08-06-provider-credential-lifecycle.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-06-provider-credential-lifecycle.i18n.yaml index 11ba2e0744..9f16a183b9 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-06-provider-credential-lifecycle.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-06-provider-credential-lifecycle.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 .agents/notes/implemented/bug-fix/2026-08-06-provider-credential-lifecycle.md -2026-08-06-provider-credential-lifecycle.md: 6965d573af6989dffd7b6066fd8b3e50872a6a25 -2026-08-06-provider-credential-lifecycle.zh.md: de6f76d0725e954e27ec99062832fe40c36fcfe9 +2026-08-06-provider-credential-lifecycle.md: ce45207e7ac7224f44e34945e36ba85db0971f09 +2026-08-06-provider-credential-lifecycle.zh.md: c476417517b8ed72036344a13720a8ba378775e6 diff --git a/.agents/notes/implemented/bug-fix/2026-08-06-provider-credential-lifecycle.md b/.agents/notes/implemented/bug-fix/2026-08-06-provider-credential-lifecycle.md index 6965d573af..ce45207e7a 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-06-provider-credential-lifecycle.md +++ b/.agents/notes/implemented/bug-fix/2026-08-06-provider-credential-lifecycle.md @@ -12,7 +12,7 @@ The Models editor spans independent settings and credential RPC domains. It prev Provider save remains a two-stage settings-then-credentials operation over the existing wire domains, but the card treats the successful settings response as a commit checkpoint. It replaces its comparison subtree and expected revision with the returned redacted descriptor before attempting `credentials.set`; if that second stage fails, the draft key and card stay visible, and retry produces no settings ops and repeats only the credential write. Genuine concurrent changes before the first settings commit still fail with `settings-conflict`. Typed keys are trimmed at the UI and direct DeepSeek resolver boundaries, and pi-ai records a derived reference only when the normalized key is non-empty; saving a blank key materializes an empty, reference-free profile for provider-native discovery. -Deletion removes a credential only when the joined row identifies the exact `_API_KEY` reference derived by this page and reports it configured and writable. It unsets that credential before the user-layer profile so a settings-stage failure leaves the row and its frozen target visible for retry; both unsets are idempotent. Custom references, environment credentials, missing credentials, and targets the join cannot identify are retained. The row's accessible Edit/Delete names and the destructive dialog title, description, and final action all use the same stable `Display Name (route-id)` identity, collapsing to the route id when both strings match. The dialog states whether the stored key will be removed and owns operation failures instead of replacing the whole page with a load-error banner. +Deletion removes a credential only when the joined row identifies the exact `_API_KEY` reference derived by this page and reports it configured and writable. It unsets that credential before the user-layer profile so a settings-stage failure leaves the row and its frozen target visible for retry; both unsets are idempotent. Custom references, environment credentials, missing credentials, and targets the join cannot identify are retained. The row's accessible Edit/Delete names and the destructive dialog title, description, and final action all use the same stable `Display Name (route-id)` identity, collapsing to the route id when both strings match. The dialog states whether the stored key will be removed and owns operation failures instead of replacing the whole page with a load-error banner. Rows expose API-key state only from the value-free join: a confirmed literal or referenced credential is a green solid dot, a confirmed missing named reference is a red solid dot, and reference-free provider-native authentication or unavailable credential enrichment has no dot. Each dot has accessible copy and a tooltip, while successful Apply uses the same provider identity in a local status message and never echoes secret material. ## Alternatives considered @@ -24,4 +24,4 @@ Deletion removes a credential only when the joined row identifies the exact `_API_KEY` 引用,并将其报告为已配置且可写时,删除操作才会清除该凭据。它会先取消设置该凭据,再取消设置用户层 profile;如果 settings 阶段失败,该行及其已冻结的目标仍可见,便于重试。两项 unset 都具备幂等性。自定义引用、环境凭据、缺失的凭据,以及联接无法识别目标的凭据均会保留。行的无障碍 Edit/Delete 名称以及破坏性对话框的标题、说明和最终操作都使用同一个稳定的 `Display Name (route-id)` 标识;当两个字符串相同时,标识会简化为路由 id。对话框会说明是否一并删除已存密钥,并在自身内显示操作失败,而不是用加载错误横幅替换整个页面。 +只有当联接所得的行识别出该页面派生的精确 `_API_KEY` 引用,并将其报告为已配置且可写时,删除操作才会清除该凭据。它会先取消设置该凭据,再取消设置用户层 profile;如果 settings 阶段失败,该行及其已冻结的目标仍可见,便于重试。两项 unset 都具备幂等性。自定义引用、环境凭据、缺失的凭据,以及联接无法识别目标的凭据均会保留。行的无障碍 Edit/Delete 名称以及破坏性对话框的标题、说明和最终操作都使用同一个稳定的 `Display Name (route-id)` 标识;当两个字符串相同时,标识会简化为路由 id。对话框会说明是否一并删除已存密钥,并在自身内显示操作失败,而不是用加载错误横幅替换整个页面。行只根据不含值的联接结果展示 API 密钥状态:确认已配置的字面密钥或引用凭据显示为绿色实心点,确认缺失的具名引用显示为红色实心点,无引用的提供方原生认证或无法取得凭据补充信息时则不显示状态点。每个状态点都有无障碍文案和工具提示;「应用」成功后的本地状态消息会使用同一个提供方标识,且绝不回显任何机密内容。 ## 曾考虑的替代方案 @@ -24,4 +24,4 @@ Models 编辑器横跨互相独立的 settings 与凭据 RPC 领域。之前它 ## 后果 -Models 页可以从任一第二阶段失败中恢复,无需重新加载,也不会泄露机密或产生虚假的并发冲突;空密钥的 pi-ai profile 会保留 Bedrock、Vertex 与其他提供方原生认证。删除由页面管理的提供方不再遗留可重用的本地密钥,而存在歧义的凭据会有意保留,交由手动管理。保存与删除在跨持久存储时仍非原子操作:进程可能在两个阶段之间崩溃,但它们的顺序与幂等性会留下可观察、可重试的状态。组件测试固定了部分成功后的重试、空密钥原生认证、标准化字面值、目标标识、清理所有权,以及凭据/settings 拒绝顺序;无密钥的浏览器场景固定了双语无障碍文案,并验证确认删除会同时清除 `settings.yaml` profile 与 `.env` 凭据。此决策细化了 [web 配置平面 note](../architecture/2026-07-30-web-config-plane.md) 中记录的 Models 应用语义。 +Models 页可以从任一第二阶段失败中恢复,无需重新加载,也不会泄露机密或产生虚假的并发冲突;空密钥的 pi-ai profile 会保留 Bedrock、Vertex 与其他提供方原生认证。已确认的状态清晰可见,同时不会把路由存活状态、原生认证或凭据查询失败误报为错误;即使该行继续显示绿色,密钥替换成功也仍然可观察。删除由页面管理的提供方不再遗留可重用的本地密钥,而存在歧义的凭据会有意保留,交由手动管理。保存与删除在跨持久存储时仍非原子操作:进程可能在两个阶段之间崩溃,但它们的顺序与幂等性会留下可观察、可重试的状态。组件测试固定了部分成功后的重试、空密钥原生认证、标准化字面值、状态可见性、目标标识、清理所有权,以及凭据/settings 拒绝顺序;无密钥的浏览器场景固定了双语无障碍文案,并验证确认删除会同时清除 `settings.yaml` profile 与 `.env` 凭据。此决策细化了 [web 配置平面 note](../architecture/2026-07-30-web-config-plane.md) 中记录的 Models 应用语义。 diff --git a/apps/web/tests/models-settings.e2e.ts b/apps/web/tests/models-settings.e2e.ts index 9078e53ff6..c688e12e6a 100644 --- a/apps/web/tests/models-settings.e2e.ts +++ b/apps/web/tests/models-settings.e2e.ts @@ -73,7 +73,7 @@ describe('web e2e: Models settings page configures a dormant provider', () => { expect(options).toContain('anthropic') expect(options).toContain('minimax-cn') await pick.selectOption('minimax-cn') - await dialog.getByLabel('API 密钥').waitFor({ timeout: 10_000 }) + await dialog.getByRole('textbox', { name: 'API 密钥', exact: true }).waitFor({ timeout: 10_000 }) const snapshot = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd) await compareOrRefreshGolden(EMPTY_EXPECTED, snapshot, MODE) }, 60_000) @@ -84,6 +84,9 @@ describe('web e2e: Models settings page configures a dormant provider', () => { await dialog.getByRole('button', { name: '保存', exact: true }).click() const row = dialog.getByText('minimax-cn', { exact: true }).first() await row.waitFor({ timeout: 10_000 }) + await dialog.getByText('已保存 minimax-cn。', { exact: true }).waitFor({ timeout: 10_000 }) + expect(await dialog.getByRole('img', { name: 'API 密钥已配置' }).count()).toBe(0) + expect(await dialog.getByRole('img', { name: 'API 密钥缺失' }).count()).toBe(0) const document = await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8') expect(document).toContain('minimax-cn: {}') expect(document).not.toContain('MINIMAX_CN_API_KEY') @@ -108,12 +111,17 @@ describe('web e2e: Models settings page configures a dormant provider', () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-models-add')) const dialog = page.getByRole('dialog', { name: '设置' }) await dialog.getByRole('button', { name: '编辑 minimax-cn' }).click() - await dialog.getByLabel('API 密钥').fill('sk-e2e-minimax') + await dialog.getByRole('textbox', { name: 'API 密钥', exact: true }).fill('sk-e2e-minimax') await dialog.getByRole('button', { name: '保存', exact: true }).click() // The profile lands in settings.yaml with only the derived reference, the // key value lands in the harness home's .env, the dormant route // registers, and the topology frame invalidates the page into the row. - await expect.poll(async () => dialog.getByLabel('API 密钥').count(), { timeout: 10_000 }).toBe(0) + await expect.poll( + async () => dialog.getByRole('textbox', { name: 'API 密钥', exact: true }).count(), + { timeout: 10_000 }, + ).toBe(0) + await dialog.getByRole('img', { name: 'API 密钥已配置' }).waitFor({ timeout: 10_000 }) + await dialog.getByText('已保存 minimax-cn。', { exact: true }).waitFor({ timeout: 10_000 }) const document = await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8') expect(document).toContain('minimax-cn:') expect(document).toContain('apiKeyEnv: MINIMAX_CN_API_KEY') @@ -138,6 +146,7 @@ describe('web e2e: Models settings page configures a dormant provider', () => { // The editor closes back to the row; the fold's write merged into the // stored profile beside the reference. await expect.poll(async () => dialog.getByLabel('推理强度').count(), { timeout: 10_000 }).toBe(0) + await dialog.getByText('已保存 minimax-cn。', { exact: true }).waitFor({ timeout: 10_000 }) const document = await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8') expect(document).toContain('reasoning: high') expect(document).toContain('apiKeyEnv: MINIMAX_CN_API_KEY') diff --git a/apps/web/tests/snapshots/models-settings/configured.expected.md b/apps/web/tests/snapshots/models-settings/configured.expected.md index 2c885817f1..4f861cd8a8 100644 --- a/apps/web/tests/snapshots/models-settings/configured.expected.md +++ b/apps/web/tests/snapshots/models-settings/configured.expected.md @@ -13,9 +13,11 @@ - text: 关闭 - heading "模型" [level=2] - paragraph: 填入各提供方的 API 密钥即可使用其模型。 + - status: 已保存 minimax-cn。 - list: - listitem: - text: minimax-cn + - img "API 密钥已配置" - button "编辑 minimax-cn": 编辑 - button "删除 minimax-cn": 删除 - button "添加提供方": diff --git a/apps/web/tests/snapshots/onboarding-deepseek-config/models.expected.md b/apps/web/tests/snapshots/onboarding-deepseek-config/models.expected.md index 3eaef94eef..1438c94822 100644 --- a/apps/web/tests/snapshots/onboarding-deepseek-config/models.expected.md +++ b/apps/web/tests/snapshots/onboarding-deepseek-config/models.expected.md @@ -16,6 +16,7 @@ - list: - listitem: - text: DeepSeek + - img "API 密钥已配置" - button "编辑 DeepSeek (deepseek-official)": 编辑 - text: DeepSeek deepseek-official API 密钥 - textbox "API 密钥": diff --git a/packages/client/ui-models/README.i18n.yaml b/packages/client/ui-models/README.i18n.yaml index b34caf8138..4b622ca023 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: 6ae0dd9d43c19f2a4350386104cf328d4d4a65d3 -README.zh.md: 77e2dcfb98ac3ac12a5ecb6975487b8159178937 +README.md: fdbd758e81e9631bf607797bfe2c2385c2b89ac6 +README.zh.md: 44498d728b4e292c717fa59c466a261b69cfa24b diff --git a/packages/client/ui-models/README.md b/packages/client/ui-models/README.md index 6ae0dd9d43..fdbd758e81 100644 --- a/packages/client/ui-models/README.md +++ b/packages/client/ui-models/README.md @@ -4,7 +4,7 @@ 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 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. The collapsed 自定义设置 fold carries the curated extras — `baseURL` for both families (the deepseek placeholder shows the public endpoint), `reasoningEffort` (deepseek) or `reasoning` (pi-ai), and the direct DeepSeek adapter's advisory model catalog. Each DeepSeek row edits `id`, optional display `name`, and optional `contextWindow`; 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. +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. A row labels API-key state with a green solid dot only when a literal key or 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), `reasoningEffort` (deepseek) or `reasoning` (pi-ai), and the direct DeepSeek adapter's advisory model catalog. Each DeepSeek row edits `id`, optional display `name`, and optional `contextWindow`; 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. 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 literal `apiKey` secret sidecar or 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. diff --git a/packages/client/ui-models/README.zh.md b/packages/client/ui-models/README.zh.md index 77e2dcfb98..44498d728b 100644 --- a/packages/client/ui-models/README.zh.md +++ b/packages/client/ui-models/README.zh.md @@ -4,7 +4,7 @@ 模型设置插件:提供方配置页和按条件显示的 DeepSeek 官方首次使用引导步骤。它把三个协议领域汇聚为一个共享快照:`llm.providers`(可配置提供方目录,含每条路由的存活/休眠状态)、`settings.describe`(序列化 schema、分层脱敏值、secret 槽位)与 `credentials.describe`(不含值的 configured/source/writable 徽标);页面据此渲染提供方行,一次只展开一张编辑卡片,且不把路由存活状态呈现为提供方状态。 -行是*已配置*的提供方(其 profile 在所属 namespace 中解析得出);密钥未在任何地方配置的整分节提供方(DeepSeek 的首次运行姿态)会渲染为其展开的设置卡片而非一行,「新增」流程则是一张承载休眠目录提供方选择框的卡片——裸挂载的 `llm-pi-ai` 在任何路由存在之前就能提供其完整的已安装 catalog。编辑器是每个适配器家族各一张的手写卡片:主字段是单独一个 **API 密钥**输入框——页面从不询问环境变量名;键入的密钥经 `credentials.set` 以**只写**方式存入 profile 的引用之下,profile 没有引用时便派生 `_API_KEY`,pi-ai profile 会把这次派生记录为 `apiKeyEnv`,因此 `settings.yaml` 从不携带密钥值。为新的 pi-ai 提供方留空密钥会保存一个不带引用的 profile,因此能保留提供方原生认证,例如 Bedrock 凭据链或 Vertex ADC。收起的「自定义设置」折叠区承载精选的额外字段——两个家族都有 `baseURL`(deepseek 的占位符显示公共端点),另有 `reasoningEffort`(deepseek)或 `reasoning`(pi-ai),以及直接 DeepSeek 适配器的建议性模型目录。每条 DeepSeek 模型行可编辑 `id`、可选的显示名称 `name` 与可选的 `contextWindow`;精选集合以外的现有字段会在编辑后保留,其余每个 profile 字段仍归 `settings.yaml` 所有。只有当某行仅由用户层承载时它才可删除(删除会还原组合 base),其本地化确认对话框会在标题、说明和最终操作中点名该提供方。 +行是*已配置*的提供方(其 profile 在所属 namespace 中解析得出);密钥未在任何地方配置的整分节提供方(DeepSeek 的首次运行姿态)会渲染为其展开的设置卡片而非一行,「新增」流程则是一张承载休眠目录提供方选择框的卡片——裸挂载的 `llm-pi-ai` 在任何路由存在之前就能提供其完整的已安装 catalog。只有确认字面密钥或引用的凭据已配置时,行才会以绿色实心点标示 API 密钥状态;只有确认具名引用缺失时,才会以红色实心点标示。无引用的提供方原生认证以及无法取得凭据补充信息时都不显示状态点。编辑器是每个适配器家族各一张的手写卡片:主字段是单独一个 **API 密钥**输入框——页面从不询问环境变量名;键入的密钥经 `credentials.set` 以**只写**方式存入 profile 的引用之下,profile 没有引用时便派生 `_API_KEY`,pi-ai profile 会把这次派生记录为 `apiKeyEnv`,因此 `settings.yaml` 从不携带密钥值。为新的 pi-ai 提供方留空密钥会保存一个不带引用的 profile,因此能保留提供方原生认证,例如 Bedrock 凭据链或 Vertex ADC。「应用」成功后会发出本地无障碍状态消息,且绝不回显任何机密内容。收起的「自定义设置」折叠区承载精选的额外字段——两个家族都有 `baseURL`(deepseek 的占位符显示公共端点),另有 `reasoningEffort`(deepseek)或 `reasoning`(pi-ai),以及直接 DeepSeek 适配器的建议性模型目录。每条 DeepSeek 模型行可编辑 `id`、可选的显示名称 `name` 与可选的 `contextWindow`;精选集合以外的现有字段会在编辑后保留,其余每个 profile 字段仍归 `settings.yaml` 所有。只有当某行仅由用户层承载时它才可删除(删除会还原组合 base),其本地化确认对话框会在标题、说明和最终操作中点名该提供方。 前序首次使用引导页面完成后,DeepSeek 步骤会从同一个联接快照得出 `deepseek-official` 的就绪状态。它通过 `llm-deepseek` 的可配置提供方声明识别官方适配器,因此同 id 但未声明的存活路由不属于可修复配置。若 `apiKey` 字面量对应的 secret 槽位标记为已设置,或凭据引用已配置,该步骤会直接完成而不渲染,其中包括来自启动环境且只读的凭据。只有已挂载且活跃、引用可写但尚未配置的适配器才会显示前往「设置」Models 分区的页面;密钥输入和 `credentials.set` 仅由该分区已有的设置卡片负责,该步骤绝不持有 secret。适配器缺失、路由不活跃、联接失败、部署只读或设置/凭据能力不可用时,该步骤均不渲染并直接完成,以免首次使用引导阻塞产品;Models 页仍是诊断界面。 diff --git a/packages/client/ui-models/src/client/ModelsSection.module.css b/packages/client/ui-models/src/client/ModelsSection.module.css index 6b87dbefe3..0615a9ec25 100644 --- a/packages/client/ui-models/src/client/ModelsSection.module.css +++ b/packages/client/ui-models/src/client/ModelsSection.module.css @@ -38,6 +38,13 @@ color: var(--dsw-alias-state-warn-label); } +.savedNotice { + margin: 0; + font-size: 12px; + line-height: 18px; + color: var(--dsw-alias-state-success-primary); +} + .rows { list-style: none; /* Extra air between the title/intro block and the first provider card. */ @@ -65,6 +72,13 @@ gap: 10px; } +.rowIdentity { + display: inline-flex; + align-items: center; + gap: 6px; + min-width: 0; +} + .rowName { font-size: 14px; line-height: 22px; @@ -72,6 +86,23 @@ color: var(--dsw-alias-label-primary); } +.credentialDot { + box-sizing: border-box; + display: inline-block; + flex: none; + width: 8px; + height: 8px; + border-radius: 50%; +} + +.credentialDotConfigured { + background: var(--dsw-alias-state-success-primary); +} + +.credentialDotMissing { + background: var(--dsw-alias-state-error-primary); +} + .rowActions { display: inline-flex; align-items: center; diff --git a/packages/client/ui-models/src/client/ModelsSection.tsx b/packages/client/ui-models/src/client/ModelsSection.tsx index 54b0db3c38..3abdea3a61 100644 --- a/packages/client/ui-models/src/client/ModelsSection.tsx +++ b/packages/client/ui-models/src/client/ModelsSection.tsx @@ -1,10 +1,11 @@ /** * Models settings section: the provider rows joined from the configurable * directory, settings namespaces, and credential states, with one editor - * card at a time. 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 + * 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. */ @@ -149,11 +150,15 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode { const [deleteTarget, setDeleteTarget] = useState(undefined) const [deleting, setDeleting] = useState(false) const [deleteFailure, setDeleteFailure] = useState(undefined) + const [savedTarget, setSavedTarget] = useState(undefined) - const closeEditor = (changed: boolean): void => { + const closeEditor = (changed: boolean, target: ProviderIdentity): void => { setEditing(undefined) setAdding(false) - if (changed) void controller.load() + if (changed) { + setSavedTarget(target) + void controller.load() + } } const closeDelete = (): void => { @@ -202,6 +207,13 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode {

{t('title')}

{t('intro')}

{!state.writable && state.status === 'ready' ?

{t('readOnly')}

: null} + {savedTarget === undefined + ? null + : ( +

+ {providerCopy(t('savedProvider'), savedTarget)} +

+ )}
    {configured.map((row) => { const target = targetOf(row) @@ -221,22 +233,51 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode { api={api} t={t} readOnly={!state.writable} - onClose={closeEditor} + onClose={(changed) => { closeEditor(changed, target) }} /> ) } const open = !adding && editing?.provider === row.entry.provider + const credentialConfigured = row.literalApiKeyConfigured || row.credential?.configured === true + const credentialMissing = !credentialConfigured + && row.apiKeyEnv !== undefined + && row.credential?.configured === false return (
  • - {row.entry.displayName} + + {row.entry.displayName} + {credentialConfigured + ? ( + + ) + : credentialMissing + ? ( + + ) + : null} + @@ -247,7 +288,11 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode { className={styles['dangerButton']} aria-label={providerCopy(t('removeProvider'), target)} disabled={!state.writable} - onClick={() => { setDeleteFailure(undefined); setDeleteTarget(target) }} + onClick={() => { + setSavedTarget(undefined) + setDeleteFailure(undefined) + setDeleteTarget(target) + }} > {t('remove')} @@ -265,7 +310,7 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode { api={api} t={t} readOnly={!state.writable} - onClose={closeEditor} + onClose={(changed) => { closeEditor(changed, target) }} /> ) : null} @@ -305,7 +350,7 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode { api={api} t={t} readOnly={!state.writable} - onClose={closeEditor} + onClose={(changed) => { closeEditor(changed, addTarget) }} />
    ) @@ -318,6 +363,7 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode { const first = addable[0] /* v8 ignore next -- the button is disabled while nothing is addable */ if (first === undefined) return + setSavedTarget(undefined) setAdding(true) setEditing(targetOf(first)) }} diff --git a/packages/client/ui-models/src/client/locales.ts b/packages/client/ui-models/src/client/locales.ts index d85a3dd964..6faa8ab2d1 100644 --- a/packages/client/ui-models/src/client/locales.ts +++ b/packages/client/ui-models/src/client/locales.ts @@ -20,6 +20,9 @@ export const en = { cancel: 'Cancel', apply: 'Apply', applying: 'Applying…', + savedProvider: 'Saved {provider}.', + credentialConfigured: 'API key configured', + credentialMissing: 'API key missing', readOnly: 'The settings document is read-only in this deployment.', loadFailed: 'Loading the provider directory failed', conflict: 'Someone else changed these settings while this card was open. Close it and reopen to edit the current values.', @@ -85,6 +88,9 @@ export const zh: typeof en = { cancel: '取消', apply: '保存', applying: '保存中…', + savedProvider: '已保存 {provider}。', + credentialConfigured: 'API 密钥已配置', + credentialMissing: 'API 密钥缺失', readOnly: '当前部署的设置文档为只读。', loadFailed: '加载提供方目录失败', conflict: '这张卡片打开期间,这些设置已被其他地方改动。请关闭后重新打开,在当前值上编辑。', diff --git a/packages/client/ui-models/tests/components.spec.tsx b/packages/client/ui-models/tests/components.spec.tsx index 29600642a2..4d6ca68670 100644 --- a/packages/client/ui-models/tests/components.spec.tsx +++ b/packages/client/ui-models/tests/components.spec.tsx @@ -213,9 +213,36 @@ describe('ModelsSection', () => { expect(screen.getByText('openai')).toBeTruthy() expect(screen.queryByText('Active')).toBeNull() expect(screen.queryByText('Inactive')).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() }) + it('marks only a confirmed missing reference and leaves native or unavailable state unmarked', async () => { + const { face } = scriptedFace() + face.credentials.describe.mockImplementation((payload: { refs: string[] }) => Promise.resolve(ok({ + credentials: Object.fromEntries(payload.refs.map(ref => [ref, { configured: false, writable: true }])), + }))) + const controller = new ModelsSettingsStore(face as unknown as WireFace) + await controller.load() + render() + + const missing = screen.getByRole('img', { name: en.credentialMissing }) + expect(missing.getAttribute('title')).toBe(en.credentialMissing) + expect(missing.className).toContain('credentialDotMissing') + expect(missing.closest('li')?.textContent).toContain('openai') + expect(screen.queryByRole('img', { name: en.credentialConfigured })).toBeNull() + expect(screen.getByText('zombie').closest('li')?.querySelector('[role="img"]')).toBeNull() + }) + it('turns the setup card into a row once the credential reports configured', async () => { const { face } = await mountSection() face.credentials.describe.mockImplementation((payload: { refs: string[] }) => Promise.resolve(ok({ @@ -286,6 +313,11 @@ describe('ModelsSection', () => { await waitFor(() => { expect(set).toHaveBeenCalledWith({ ref: 'DEEPSEEK_API_KEY', value: 'sk-live' }) }) expect(update).not.toHaveBeenCalled() await waitFor(() => { expect(face.settings.describe.mock.calls.length).toBeGreaterThan(1) }) + expect((await screen.findByRole('status')).textContent).toBe( + providerCopy(en.savedProvider, { provider: 'deepseek-official', displayName: 'DeepSeek' }), + ) + fireEvent.click(screen.getByText(en.add)) + expect(screen.queryByRole('status')).toBeNull() }) it('applies customized deepseek fields as path ops', async () => { @@ -943,6 +975,7 @@ describe('ModelsSection', () => { fireEvent.change(key, { target: { value: 'sk-live' } }) fireEvent.click(screen.getByText(en.apply)) await screen.findByText(/shadowed by the read-only environment/) + expect(screen.queryByRole('status')).toBeNull() }) it('locks the key input when the launch environment provides the credential', async () => {