Merge pull request #1031 from deepseek-harness/worktree/fix-models-settings

fix(web): improve Models settings presentation and safety
This commit is contained in:
Yichen Jiang
2026-07-31 13:44:11 +08:00
committed by GitHub
15 changed files with 224 additions and 100 deletions
@@ -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/architecture/2026-07-30-web-config-plane.md
2026-07-30-web-config-plane.md: 95ede6264026f7b32e95749d00fe841f57dbf867
2026-07-30-web-config-plane.zh.md: 6e06b69218a405055621cbd40781f9fbda9f9e6b
2026-07-30-web-config-plane.md: 6d1a8c242c1888ee4fca9e21ebc814f7a345d633
2026-07-30-web-config-plane.zh.md: c3255cacfdd1f06d12f7bb2631f95273536b7ef9
@@ -20,7 +20,7 @@ PR1 made LLM adapter configuration restart-free at the seam, but the only writer
**A hand-written editor over a schema model layer.** `dsh-client-schema-form` rehydrates the wire's `toJSON()` envelope into live schemastery nodes for validation, path resolution, and immutable draft editing — but no generic rendering: the first cut shipped a full schema-driven form renderer, and the resulting page was an unstyled schema dump (every advanced field flattened onto the card, raw field names as labels, the `retryPolicy` unsupported-fallback in the main flow). The user chose the hand-written direction over adding a hint/grouping system, and a second round removed the reference input entirely: the card's primary field is one **API key** input, a whole-section provider without a configured key opens as its setup card, and the collapsed 自定义设置 fold carries the curated per-family extras (`baseURL` for both families, plus `reasoningEffort` for deepseek / `reasoning` for pi-ai), with every other field owned by `settings.yaml`. Validation still runs the rehydrated schema before writing, so a hand-coded field that drifts from its schema fails loud on save rather than silently.
**The Models page is a three-domain join with seam-shaped apply semantics.** Rows are configured providers; the add card's select is the dormant directory remainder; badges come from route liveness. The key path stays reference-shaped without ever showing a reference: a typed key stores **write-only** through `credentials.set` under the profile's `apiKeyEnv`, deriving `<ROUTE>_API_KEY` when none exists (the pi-ai profile records the derivation), so `settings.yaml` never carries a key value and the wholesale `settings.replace` a removal needs can never drop a sibling's secret. An edit without removals lands as a minimal `settings.update` merge patch; clearing a fold field back to inherited or deleting a row replaces the whole user section, because merge semantics cannot express removal.
**The Models page is a three-domain join with seam-shaped apply semantics.** Rows are configured providers; the add card's select is the dormant directory remainder. Route liveness still gates readiness and invalidates the join, but the page does not render it as provider status because configuration presence and runtime availability are distinct. The key path stays reference-shaped without ever showing a reference: a typed key stores **write-only** through `credentials.set` under the profile's `apiKeyEnv`, deriving `<ROUTE>_API_KEY` when none exists (the pi-ai profile records the derivation), so `settings.yaml` never carries a key value. Profile edits and removals land as minimal path-addressed `settings.mutate` operations against the redacted user section, which never names a secret the page did not receive. Removing a user-layer provider first opens a localized model-provider confirmation dialog; cancellation, its close button, and its mask leave the profile untouched, while the destructive confirmation submits the single unset and blocks duplicate submission until it settles.
## Alternatives considered
@@ -33,4 +33,4 @@ PR1 made LLM adapter configuration restart-free at the seam, but the only writer
## Consequences
The whole loop is pinned keyless in the browser lane (`apps/web/tests/models-settings.e2e.ts`): the add card offers the dormant pi-ai catalog, adding `minimax-cn` with a typed key writes the reference-only profile into `settings.yaml`, stores the value into the harness home's `.env` under the derived `MINIMAX_CN_API_KEY`, registers the route live on the topology frame, and the customized fold merges `reasoning` beside the reference — zero model calls, ARIA goldens for the add-card and configured states, plus a scaffold `harnessHome` so tests never touch a real `~/.dsh` (the provider under test is one whose derived reference cannot collide with a developer's exported keys). The rename touched 239 files (fixtures, goldens, docs, python) in one commit with no compatibility alias. The renderer replacement cost one commit and no wire change: apply semantics, redaction, and the directory join were renderer-agnostic all along. Deferred: a per-row models preview (the picker already lists models), a page address for live routes that never declared configurability, and the documented reset edge — a `settings.replace` cannot re-supply a stored *literal* secret in the replaced subtree, which the reference-based default makes unreachable.
The whole loop is pinned keyless in the browser lane (`apps/web/tests/models-settings.e2e.ts`): the add card offers the dormant pi-ai catalog, adding `minimax-cn` with a typed key writes the reference-only profile into `settings.yaml`, stores the value into the harness home's `.env` under the derived `MINIMAX_CN_API_KEY`, registers the route live on the topology frame, and the customized fold merges `reasoning` beside the reference — zero model calls, ARIA goldens for the add-card, configured, and delete-confirmation states, plus a scaffold `harnessHome` so tests never touch a real `~/.dsh` (the provider under test is one whose derived reference cannot collide with a developer's exported keys). The removal scenario proves cancellation leaves the profile intact, confirmation removes it, and the intentionally retained credential survives. The rename touched 239 files (fixtures, goldens, docs, python) in one commit with no compatibility alias. The renderer replacement cost one commit and no wire change: apply semantics, redaction, and the directory join were renderer-agnostic all along. Deferred: a per-row models preview (the picker already lists models), a page address for live routes that never declared configurability, and explicit removal of a provider's retained credential.
@@ -20,7 +20,7 @@ PR1 让 LLM(大语言模型)适配器配置在 seam 层面免重启,但唯
**架在 schema 模型层之上的手写编辑器。**`dsh-client-schema-form` 把 wire 的 `toJSON()` 信封还原(rehydrate)为活的 schemastery 节点,用于校验、路径解析与不可变草稿编辑——但不做通用渲染:第一版交付了完整的 schema 驱动表单渲染器,得到的却是一个未加样式、把 schema 原样倾倒出来的页面(每个进阶字段都平铺到卡片上、原始字段名直接充当标签、`retryPolicy` 的「不支持」回退落在主流程里)。用户没有再加一套提示/分组系统,而是选择了手写方向,第二轮又把引用输入框整个移除:卡片的主字段是一个 **API 密钥**输入框,未配置密钥的整分节提供方会以其设置卡片的形式打开,收起的「自定义设置」折叠区承载按家族精选的额外字段(两个家族都有 `baseURL`,另加 deepseek 的 `reasoningEffort`pi-ai 的 `reasoning`),其余每个字段都归 `settings.yaml` 所有。校验仍会在写入前运行还原出的 schema,因此偏离其 schema 的手写字段会在保存时大声失败,而非静默失败。
**Models 页是一次三领域联接,应用语义与 seam 同形。**每一行是一个已配置的提供方;「新增」卡片的选择框是可配置提供方目录中剩余的休眠条目;徽标来自路由存活状态。密钥通道保持引用形态,却从不展示任何引用:键入的密钥经 `credentials.set` **只写**存入 profile 的 `apiKeyEnv` 之下,引用不存在时便派生 `<ROUTE>_API_KEY`pi-ai profile 会记录该派生),因此 `settings.yaml` 从不携带密钥值,删除所需的整体 `settings.replace` 也绝不可能丢掉兄弟条目的机密。不含删除的编辑以一次最小 `settings.update` 合并 patch 落地;把折叠区字段清回继承值或删除整行则经 `settings.replace` 替换整个用户分节,因为合并语义表达不了删除
**Models 页是一次三领域联接,应用语义与 seam 同形。**每一行是一个已配置的提供方;「新增」卡片的选择框是可配置提供方目录中剩余的休眠条目路由存活状态仍用于就绪判定,并会使该联接失效,但页面不将其渲染为提供方状态,因为配置存在与运行时可用性是两个不同概念。密钥通道保持引用形态,却从不展示任何引用:键入的密钥经 `credentials.set` **只写**存入 profile 的 `apiKeyEnv` 之下,引用不存在时便派生 `<ROUTE>_API_KEY`pi-ai profile 会记录该派生),因此 `settings.yaml` 从不携带密钥值。profile 的编辑和删除会针对脱敏后的用户分节,以按路径寻址的最小 `settings.mutate` 操作落地,绝不会点名页面未收到的机密。删除用户层提供方时,会先打开本地化的模型提供方确认对话框;取消操作、关闭按钮和遮罩均不会改动 profile,而破坏性确认会提交唯一一条 unset,并在其完成前阻止重复提交
## 曾考虑的替代方案
@@ -33,4 +33,4 @@ PR1 让 LLM(大语言模型)适配器配置在 seam 层面免重启,但唯
## 后果
整条闭环以无密钥方式固定在浏览器测试通道(`apps/web/tests/models-settings.e2e.ts`):「新增」卡片提供休眠的 pi-ai catalog,携键入的密钥添加 `minimax-cn` 会把只含引用的 profile 写入 `settings.yaml`、把密钥值存入 harness 家目录 `.env` 中派生的 `MINIMAX_CN_API_KEY` 之下、路由随拓扑帧注册为存活,「自定义设置」折叠区则把 `reasoning` 合并到引用旁边——全程零模型调用,「新增」卡片态已配置态各有 ARIA golden,另有脚手架式的 `harnessHome`,测试绝不触碰真实的 `~/.dsh`(受测提供方是派生引用不可能与开发者已导出密钥相撞的那一个)。这次重命名在一次提交中触及 239 个文件(fixture(测试前置数据)、golden、文档、python),未保留兼容别名。替换渲染器只花了一次提交,且没有任何 wire 变更:应用语义、脱敏与目录联接从一开始就与渲染器无关。延后事项:每行的模型预览(选择器已能列出模型)、为从未声明可配置性的存活路由提供页面地址,以及已记录在案的重置边界情形——`settings.replace` 无法在被替换的子树里重新补上已存储的*字面量*机密,而基于引用的默认形态让这种情况根本无从出现
整条闭环以无密钥方式固定在浏览器测试通道(`apps/web/tests/models-settings.e2e.ts`):「新增」卡片提供休眠的 pi-ai catalog,携键入的密钥添加 `minimax-cn` 会把只含引用的 profile 写入 `settings.yaml`、把密钥值存入 harness 家目录 `.env` 中派生的 `MINIMAX_CN_API_KEY` 之下、路由随拓扑帧注册为存活,「自定义设置」折叠区则把 `reasoning` 合并到引用旁边——全程零模型调用,「新增」卡片态已配置态与删除确认态各有 ARIA golden,另有脚手架式的 `harnessHome`,测试绝不触碰真实的 `~/.dsh`(受测提供方是派生引用不可能与开发者已导出密钥相撞的那一个)。删除场景证明:取消后 profile 保持原样,确认后会将其删除,而刻意保留的凭据依然存在。这次重命名在一次提交中触及 239 个文件(fixture(测试前置数据)、golden、文档、python),未保留兼容别名。替换渲染器只花了一次提交,且没有任何 wire 变更:应用语义、脱敏与目录联接从一开始就与渲染器无关。延后事项:每行的模型预览(选择器已能列出模型)、为从未声明可配置性的存活路由提供页面地址,以及显式删除提供方所保留的凭据
+39 -7
View File
@@ -1,14 +1,15 @@
// Web e2e scenario: the Models settings page end to end through the real
// wire — the add card offers the dormant pi-ai catalog, typing an API key
// stores it write-only under the derived reference (`MINIMAX_CN_API_KEY`)
// while the settings document records only that reference, and the saved
// route registers live (the row's 已启用 badge is the topology invalidation
// landing). The customized-settings fold writes the curated reasoning field
// as a merge patch. Zero model calls: configuration is pure
// while the settings document records only that reference; the saved row
// appears after the route topology invalidation without presenting liveness
// as provider status. The customized-settings fold writes the curated
// reasoning field as a merge patch. Zero model calls: configuration is pure
// settings/credentials/llm-domain traffic, so there is no fixture and a
// stray stream would fail loud on the open seam. The provider under test is
// minimax-cn so a developer's real ANTHROPIC/OPENAI environment keys can
// never shadow the derived reference.
// never shadow the derived reference. Removing that row is guarded by the
// localized provider-confirmation dialog before the unset reaches the wire.
import { readFile } from 'node:fs/promises'
import { fileURLToPath } from 'node:url'
import { join } from 'node:path'
@@ -24,6 +25,7 @@ import { saveFailureShot } from './support.ts'
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/models-settings', import.meta.url))
const EMPTY_EXPECTED = join(SNAPSHOT_DIR, 'empty.expected.md')
const CONFIGURED_EXPECTED = join(SNAPSHOT_DIR, 'configured.expected.md')
const DELETE_EXPECTED = join(SNAPSHOT_DIR, 'delete.expected.md')
const MODE = webSnapshotMode()
describe('web e2e: Models settings page configures a dormant provider', () => {
@@ -82,7 +84,6 @@ describe('web e2e: Models settings page configures a dormant provider', () => {
// registers, and the topology frame invalidates the page into the row.
const row = dialog.getByText('minimax-cn', { exact: true }).first()
await row.waitFor({ timeout: 10_000 })
await dialog.getByText('已启用').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')
@@ -109,11 +110,42 @@ describe('web e2e: Models settings page configures a dormant provider', () => {
expect(document).toContain('apiKeyEnv: MINIMAX_CN_API_KEY')
const snapshot = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd)
await compareOrRefreshGolden(CONFIGURED_EXPECTED, snapshot, MODE)
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
it('confirms provider deletion before removing its settings profile', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-models-delete'))
const settingsDialog = page.getByRole('dialog', { name: '设置' })
await settingsDialog.getByRole('button', { name: '删除', exact: true }).click()
const deleteDialog = page.getByRole('dialog', { name: '删除模型提供方?' })
await deleteDialog.waitFor({ timeout: 10_000 })
const snapshot = await captureStableAria(
page,
'[role="dialog"][aria-label="删除模型提供方?"]',
scaffold.workspaceCwd,
)
await compareOrRefreshGolden(DELETE_EXPECTED, snapshot, MODE)
await deleteDialog.getByRole('button', { name: '取消', exact: true }).click()
expect(await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8')).toContain('minimax-cn:')
await settingsDialog.getByRole('button', { name: '删除', exact: true }).click()
await page.getByRole('dialog', { name: '删除模型提供方?' })
.getByRole('button', { name: '删除提供方', exact: true }).click()
await expect.poll(
async () => readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8'),
{ timeout: 10_000 },
).not.toContain('minimax-cn:')
expect(await readFile(join(scaffold.harnessHome, '.env'), 'utf8'))
.toContain('MINIMAX_CN_API_KEY=sk-e2e-minimax')
await expect.poll(
async () => page.getByRole('dialog', { name: '删除模型提供方?' }).count(),
{ timeout: 10_000 },
).toBe(0)
await page.keyboard.press('Escape')
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => {
await assertFixtureInventory(SNAPSHOT_DIR, ['configured.expected.md', 'empty.expected.md'])
await assertFixtureInventory(SNAPSHOT_DIR, ['configured.expected.md', 'delete.expected.md', 'empty.expected.md'])
})
})
@@ -14,7 +14,7 @@
- paragraph: 填入各提供方的 API 密钥即可使用其模型。
- list:
- listitem:
- text: minimax-cn 已启用
- text: minimax-cn
- button "编辑"
- button "删除"
- button "+ 添加提供方"
@@ -0,0 +1,7 @@
- dialog "删除模型提供方?":
- heading "删除模型提供方?" [level=2]
- button "关闭":
- img
- paragraph: 删除此模型提供方会移除其配置。在重新添加前,你将无法继续使用其模型。
- button "取消"
- button "删除提供方"
+2 -2
View File
@@ -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: adfbc084e1b0e227d50032cb6c924401b81c6a79
README.zh.md: 4ee7d4efa729fdccee392ab8e55078b5a4a239ef
README.md: 4edb34ccbe8f628c04e410a6dd2f002e247623f3
README.zh.md: 68a1e0ee205d3ba764620bcfeba7c11a88ee8736
+2 -2
View File
@@ -2,9 +2,9 @@
English | [中文](README.zh.md)
Models settings plugin: the provider configuration page and official-DeepSeek first-run routing overlay. It joins three wire domains into one shared snapshot — `llm.providers` (the configurable-provider directory with each route's live/dormant state), `settings.describe` (serialized schemas, layered redacted values, secret slots), and `credentials.describe` (value-free configured/source/writable badges) — and renders provider rows with one editor card at a time.
Models settings plugin: the provider configuration page and official-DeepSeek first-run routing overlay. It joins three wire domains into one shared snapshot — `llm.providers` (the configurable-provider directory with each route's live/dormant state), `settings.describe` (serialized schemas, layered redacted values, secret slots), and `credentials.describe` (value-free configured/source/writable badges) — and renders provider rows with one editor card at a time, 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 `<ROUTE>_API_KEY` when the profile has none, and the pi-ai profile records that derivation as `apiKeyEnv`, so `settings.yaml` never carries a key value. The collapsed 自定义设置 fold carries the curated extras — `baseURL` for both families (the deepseek placeholder shows the public endpoint), plus `reasoningEffort` (deepseek) or `reasoning` (pi-ai); every other profile field stays owned by `settings.yaml`. A row is deletable only when the user layer alone carries it (removal restores the composition base).
Rows are the *configured* providers (their profile resolves in the owning namespace); a whole-section provider whose key is not configured anywhere (the first-run DeepSeek posture) renders as its open setup card instead of a row, and the add flow is a card carrying the dormant-directory provider select — a bare-mounted `llm-pi-ai` offers its whole installed catalog before any route exists. The editor is a hand-written card per adapter family: the primary field is a single **API key** input — the page never asks for an environment-variable name; a typed key stores **write-only** through `credentials.set` under the profile's reference, deriving `<ROUTE>_API_KEY` when the profile has none, and the pi-ai profile records that derivation as `apiKeyEnv`, so `settings.yaml` never carries a key value. The collapsed 自定义设置 fold carries the curated extras — `baseURL` for both families (the deepseek placeholder shows the public endpoint), plus `reasoningEffort` (deepseek) or `reasoning` (pi-ai); every other profile field stays owned by `settings.yaml`. A row is deletable only when the user layer alone carries it (removal restores the composition base), and a localized confirmation dialog must complete before the page submits that destructive unset.
The first-run overlay projects `deepseek-official` readiness from that same joined snapshot. It recognizes the official adapter through its `llm-deepseek` configurable-provider declaration, so an undeclared live route with the same provider id is not treated as repairable configuration. A configured literal `apiKey` secret sidecar or configured credential reference suppresses the prompt, including a read-only launch-environment credential. Only a mounted adapter with a missing writable reference shows the action that opens Settings on the Models section, whose existing setup card exclusively owns key input and `credentials.set`; the overlay never holds a secret. An absent adapter, inactive route, failed join, read-only deployment, or unusable settings or credential capability is skipped so onboarding cannot block the rest of the product; the Models page remains the diagnostic surface.
+2 -2
View File
@@ -2,9 +2,9 @@
[English](README.md) | 中文
模型设置插件:提供方配置页和 DeepSeek 官方首次使用跳转浮层。它把三个协议领域汇聚为一个共享快照:`llm.providers`(可配置提供方目录,含每条路由的存活/休眠状态)、`settings.describe`(序列化 schema、分层脱敏值、secret 槽位)与 `credentials.describe`(不含值的 configured/source/writable 徽标);页面据此渲染提供方行,一次只展开一张编辑卡片。
模型设置插件:提供方配置页和 DeepSeek 官方首次使用跳转浮层。它把三个协议领域汇聚为一个共享快照:`llm.providers`(可配置提供方目录,含每条路由的存活/休眠状态)、`settings.describe`(序列化 schema、分层脱敏值、secret 槽位)与 `credentials.describe`(不含值的 configured/source/writable 徽标);页面据此渲染提供方行,一次只展开一张编辑卡片,且不把路由存活状态呈现为提供方状态
行是*已配置*的提供方(其 profile 在所属 namespace 中解析得出);密钥未在任何地方配置的整分节提供方(DeepSeek 的首次运行姿态)会渲染为其展开的设置卡片而非一行,「新增」流程则是一张承载休眠目录提供方选择框的卡片——裸挂载的 `llm-pi-ai` 在任何路由存在之前就能提供其完整的已安装 catalog。编辑器是每个适配器家族各一张的手写卡片:主字段是单独一个 **API 密钥**输入框——页面从不询问环境变量名;键入的密钥经 `credentials.set` 以**只写**方式存入 profile 的引用之下,profile 没有引用时便派生 `<ROUTE>_API_KEY`pi-ai profile 会把这次派生记录为 `apiKeyEnv`,因此 `settings.yaml` 从不携带密钥值。收起的「自定义设置」折叠区承载精选的额外字段——两个家族都有 `baseURL`(deepseek 的占位符显示公共端点),另加 `reasoningEffort`deepseek)或 `reasoning`pi-ai);其余每个 profile 字段仍归 `settings.yaml` 所有。只有当某行仅由用户层承载时它才可删除(删除会还原组合 base)。
行是*已配置*的提供方(其 profile 在所属 namespace 中解析得出);密钥未在任何地方配置的整分节提供方(DeepSeek 的首次运行姿态)会渲染为其展开的设置卡片而非一行,「新增」流程则是一张承载休眠目录提供方选择框的卡片——裸挂载的 `llm-pi-ai` 在任何路由存在之前就能提供其完整的已安装 catalog。编辑器是每个适配器家族各一张的手写卡片:主字段是单独一个 **API 密钥**输入框——页面从不询问环境变量名;键入的密钥经 `credentials.set` 以**只写**方式存入 profile 的引用之下,profile 没有引用时便派生 `<ROUTE>_API_KEY`pi-ai profile 会把这次派生记录为 `apiKeyEnv`,因此 `settings.yaml` 从不携带密钥值。收起的「自定义设置」折叠区承载精选的额外字段——两个家族都有 `baseURL`(deepseek 的占位符显示公共端点),另加 `reasoningEffort`deepseek)或 `reasoning`pi-ai);其余每个 profile 字段仍归 `settings.yaml` 所有。只有当某行仅由用户层承载时它才可删除(删除会还原组合 base),而且必须先在本地化对话框中确认,页面才会提交这次破坏性的 unset
首次使用浮层从同一个联接快照得出 `deepseek-official` 的就绪状态。它通过 `llm-deepseek` 的可配置提供方声明识别官方适配器,因此不会把同一提供方 ID 下没有相应声明的存活路由视为可通过配置修复。若 `apiKey` 字面量对应的 secret 槽位标记为已设置,或凭据引用已配置,浮层就不再显示,其中包括来自启动环境且只读的凭据。只有适配器已挂载、引用可写但尚未配置时,浮层才显示一个操作按钮,用于打开「设置」的 Models 分区;密钥输入和 `credentials.set` 仅由该分区已有的设置卡片负责,浮层绝不持有 secret。适配器缺失、路由未激活、联接失败、部署只读、设置能力不可用或凭据能力不可用时均跳过,以免首次使用引导阻塞产品的其他部分;Models 页仍是诊断界面。
@@ -3,6 +3,7 @@
flex-direction: column;
gap: 12px;
max-width: 720px;
color: var(--dsw-alias-label-primary);
}
.title {
@@ -14,13 +15,13 @@
.intro {
margin: 0;
font-size: 13px;
color: var(--text-tertiary, #888);
color: var(--dsw-alias-label-tertiary);
}
.notice {
margin: 0;
font-size: 12px;
color: var(--text-warning, #a15c00);
color: var(--dsw-alias-state-warn-label);
}
.rows {
@@ -33,13 +34,13 @@
}
.rowCard {
border: 1px solid var(--border, #e2e2e2);
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 12px;
padding: 12px 14px;
display: flex;
flex-direction: column;
gap: 12px;
background: var(--surface, #fff);
background: var(--dsw-alias-bg-layer-3);
}
.rowHead {
@@ -53,58 +54,27 @@
font-weight: 600;
}
.badges {
display: inline-flex;
gap: 6px;
flex: 1;
}
.badgeOk {
display: inline-flex;
align-items: center;
gap: 5px;
color: var(--text-success, #0a7d33);
font-size: 12px;
}
.badgeOk::before {
content: '';
width: 6px;
height: 6px;
border-radius: 999px;
background: currentcolor;
}
.badgeMuted {
color: var(--text-tertiary, #999);
font-size: 12px;
}
.badgeWarn {
color: var(--text-warning, #a15c00);
font-size: 12px;
}
.rowActions {
display: inline-flex;
gap: 8px;
margin-left: auto;
}
.primaryButton {
border: none;
border-radius: 999px;
padding: 8px 18px;
background: var(--accent-strong, #111);
color: var(--text-inverse, #fff);
background: var(--dsw-alias-button-primary-fill);
color: var(--dsw-alias-label-primary-foreground);
font: inherit;
cursor: pointer;
}
.secondaryButton {
border: 1px solid var(--border, #d9d9d9);
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 999px;
padding: 6px 14px;
background: var(--surface, #fff);
background: var(--dsw-alias-bg-layer-3);
color: inherit;
font: inherit;
cursor: pointer;
@@ -113,7 +83,7 @@
.dangerButton {
border: none;
background: none;
color: var(--text-danger, #c0392b);
color: var(--dsw-alias-state-error-primary);
font: inherit;
cursor: pointer;
}
@@ -126,9 +96,9 @@
}
.editor {
border: 1px solid var(--border, #e6e6e6);
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 12px;
background: var(--surface-secondary, #f7f7f8);
background: var(--dsw-alias-bg-layer-2);
padding: 14px 16px;
display: flex;
flex-direction: column;
@@ -148,7 +118,7 @@
.editorRoute {
font-size: 12px;
color: var(--text-tertiary, #999);
color: var(--dsw-alias-label-tertiary);
}
.field {
@@ -163,14 +133,14 @@
gap: 10px;
font-size: 12px;
font-weight: 500;
color: var(--text-secondary, #555);
color: var(--dsw-alias-label-secondary);
}
.linkButton {
border: none;
background: none;
padding: 0;
color: var(--text-tertiary, #888);
color: var(--dsw-alias-label-tertiary);
font: inherit;
font-size: 12px;
text-decoration: underline;
@@ -185,7 +155,7 @@
.advancedHint {
margin: 0;
font-size: 12px;
color: var(--text-tertiary, #999);
color: var(--dsw-alias-label-tertiary);
}
.editorActions {
@@ -202,12 +172,12 @@
.addButton {
align-self: flex-start;
border: 1px solid var(--border, #d9d9d9);
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 999px;
padding: 8px 16px;
font: inherit;
font-size: 13px;
background: var(--surface, #fff);
background: var(--dsw-alias-bg-layer-3);
color: inherit;
cursor: pointer;
}
@@ -219,9 +189,9 @@
.addCard,
.setupCard {
border: 1px solid var(--border, #e6e6e6);
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 12px;
background: var(--surface-secondary, #f7f7f8);
background: var(--dsw-alias-bg-layer-3);
padding: 14px 16px;
display: flex;
flex-direction: column;
@@ -237,7 +207,7 @@
}
.customized {
border-top: 1px solid var(--border, #ececec);
border-top: 1px solid var(--dsw-alias-border-l2);
padding-top: 10px;
}
@@ -245,7 +215,7 @@
cursor: pointer;
font-size: 12px;
font-weight: 500;
color: var(--text-secondary, #555);
color: var(--dsw-alias-label-secondary);
list-style: revert;
}
@@ -259,25 +229,38 @@
.input {
box-sizing: border-box;
padding: 9px 12px;
border: 1px solid var(--border, #d9d9d9);
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 10px;
font: inherit;
font-size: 13px;
background: var(--surface, #fff);
color: inherit;
background: var(--dsw-alias-bg-layer-1);
color: var(--dsw-alias-label-primary);
}
.input:focus {
outline: none;
border-color: var(--accent-strong, #111);
border-color: var(--dsw-alias-brand-primary);
}
.input::placeholder {
color: var(--text-tertiary, #aaa);
color: var(--dsw-alias-label-dimmed);
}
.error {
margin: 0;
font-size: 12px;
color: var(--text-danger, #c0392b);
color: var(--dsw-alias-state-error-primary);
}
.deleteDialog {
width: min(480px, 100%);
}
.deleteConfirm:not(:disabled) {
border-color: var(--dsw-alias-state-error-primary);
color: var(--dsw-alias-state-error-primary);
}
.deleteConfirm:hover:not(:disabled) {
background: var(--dsw-alias-interactive-bg-hover-danger);
}
@@ -4,13 +4,15 @@
* 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; the page re-renders from the pushed
* invalidations or the post-apply reload.
* 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'
import type { ReactNode } from 'react'
import type { IApiClient } from '@deepseek-ai/dsh-client-connection/client'
import { Button, Modal } from '@deepseek-ai/dsh-client-ui-primitives'
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-web-react'
import { messageOf } from './store.ts'
import type { ModelsSettingsState, ModelsSettingsStore, ProviderRow } from './store.ts'
@@ -114,6 +116,8 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode {
const state = injected.useSnapshot(snapshot => snapshot)
const [editing, setEditing] = useState<EditorTarget | undefined>(undefined)
const [adding, setAdding] = useState(false)
const [deleteTarget, setDeleteTarget] = useState<EditorTarget | undefined>(undefined)
const [deleting, setDeleting] = useState(false)
const closeEditor = (changed: boolean): void => {
setEditing(undefined)
@@ -121,6 +125,26 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode {
if (changed) void controller.load()
}
const closeDelete = (): void => {
if (deleting) return
setDeleteTarget(undefined)
}
const confirmDelete = (): void => {
/* v8 ignore next -- the action only renders with a target and is disabled while a deletion is pending */
if (deleteTarget === undefined || deleting) return
setDeleting(true)
void removeProviderProfile(api, controller, deleteTarget)
.then((failure) => {
if (failure !== undefined) {
controller.fail(failure)
return
}
setDeleteTarget(undefined)
})
.finally(() => { setDeleting(false) })
}
if (state.status === 'idle') void controller.load()
if (state.status === 'error') {
/* v8 ignore next -- an error status always carries text; the fallback satisfies the nullable type */
@@ -174,11 +198,6 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode {
<li key={row.entry.provider} className={styles['rowCard']}>
<div className={styles['rowHead']}>
<span className={styles['rowName']}>{row.entry.displayName}</span>
<span className={styles['badges']}>
{row.entry.active
? <span className={styles['badgeOk']}>{t('active')}</span>
: <span className={styles['badgeMuted']}>{t('dormant')}</span>}
</span>
<span className={styles['rowActions']}>
<button
type="button"
@@ -193,11 +212,7 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode {
type="button"
className={styles['dangerButton']}
disabled={!state.writable}
onClick={() => {
void removeProviderProfile(api, controller, target).then((failure) => {
if (failure !== undefined) controller.fail(failure)
})
}}
onClick={() => { setDeleteTarget(target) }}
>
{t('remove')}
</button>
@@ -276,6 +291,29 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode {
</button>
)}
</div>
<Modal
open={deleteTarget !== undefined}
onClose={closeDelete}
title={t('deleteTitle')}
closeLabel={t('close')}
description={t('deleteDescription')}
className={styles['deleteDialog'] as string}
footer={(
<>
<Button variant="outline" autoFocus disabled={deleting} onClick={closeDelete}>
{t('cancel')}
</Button>
<Button
variant="outline"
className={styles['deleteConfirm']}
disabled={deleting}
onClick={confirmDelete}
>
{deleting ? t('deleting') : t('deleteConfirm')}
</Button>
</>
)}
/>
</div>
)
}
@@ -5,12 +5,15 @@ export const en = {
nav: 'Models',
title: 'Models',
intro: 'Enter your API keys to use models from the following providers.',
active: 'Active',
dormant: 'Inactive',
edit: 'Edit',
remove: 'Delete',
deleteTitle: 'Delete model provider?',
deleteDescription: 'Deleting this model provider removes its configuration. You will not be able to use its models until you add the provider again.',
deleteConfirm: 'Delete provider',
deleting: 'Deleting provider…',
add: 'Add provider',
provider: 'Provider',
close: 'Close',
cancel: 'Cancel',
apply: 'Apply',
applying: 'Applying…',
@@ -42,12 +45,15 @@ export const zh: typeof en = {
nav: '模型',
title: '模型',
intro: '填入各提供方的 API 密钥即可使用其模型。',
active: '已启用',
dormant: '未启用',
edit: '编辑',
remove: '删除',
deleteTitle: '删除模型提供方?',
deleteDescription: '删除此模型提供方会移除其配置。在重新添加前,你将无法继续使用其模型。',
deleteConfirm: '删除提供方',
deleting: '正在删除提供方…',
add: '添加提供方',
provider: '提供方',
close: '关闭',
cancel: '取消',
apply: '保存',
applying: '保存中…',
@@ -48,6 +48,7 @@ describe('ui-models apply', () => {
expect(resolveSlotLabel(entry.options.label)).toBe('模型')
const injected = (entry.inject as unknown as () => import('../src/client/ModelsSection.tsx').ModelsSectionInjected)()
expect(injected.t('nav')).toBe('模型')
expect(injected.t('deleteTitle')).toBe('删除模型提供方?')
expect(typeof injected.controller.load).toBe('function')
expect(typeof injected.useSnapshot).toBe('function')
expect(injected.api).toBeDefined()
@@ -73,8 +74,11 @@ describe('ui-models apply', () => {
await b.ctx.plugin({ inject: [...inject], apply }).await()
b.locale.setLocale('en')
expect(resolveSlotLabel(b.slots.entries('settings.section')[0]!.options.label)).toBe('Models')
const injected = b.slots.entries('settings.section')[0]!.inject as unknown as () => import('../src/client/ModelsSection.tsx').ModelsSectionInjected
expect(injected().t('deleteTitle')).toBe('Delete model provider?')
b.locale.setLocale('zh')
expect(resolveSlotLabel(b.slots.entries('settings.section')[0]!.options.label)).toBe('模型')
expect(injected().t('deleteTitle')).toBe('删除模型提供方?')
})
it('locale change while the slot is undeclared stays a no-op', async () => {
@@ -1,6 +1,6 @@
// @vitest-environment jsdom
/** Section, setup-card, and hand-written editor behavior over a scripted wire face. */
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import Schema from 'schemastery'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
@@ -152,10 +152,9 @@ describe('ModelsSection', () => {
// DeepSeek has no configured credential and no stored apiKey → setup card.
expect(screen.getByText('DeepSeek')).toBeTruthy()
expect(screen.getByLabelText(en.keyInput)).toBeTruthy()
// Configured pi-ai profiles render as rows with liveness badges only.
expect(screen.getByText('openai')).toBeTruthy()
expect(screen.getAllByText(en.active)).toHaveLength(1)
expect(screen.getByText(en.dormant)).toBeTruthy()
expect(screen.queryByText('Active')).toBeNull()
expect(screen.queryByText('Inactive')).toBeNull()
expect(screen.getByText(`+ ${en.add}`)).toBeTruthy()
})
@@ -471,10 +470,28 @@ describe('ModelsSection', () => {
await waitFor(() => { expect(set).toHaveBeenCalledTimes(1) })
})
it('removes a user-added provider by unsetting its path', async () => {
it('requires confirmation before removing a user-added provider', async () => {
const { replace, mutate } = await mountSection()
fireEvent.click(screen.getAllByText(en.remove)[0] as HTMLElement)
const dialog = screen.getByRole('dialog', { name: en.deleteTitle })
expect(dialog.textContent).toContain(en.deleteDescription)
expect(document.activeElement).toBe(within(dialog).getByRole('button', { name: en.cancel }))
expect(mutate).not.toHaveBeenCalled()
fireEvent.click(within(dialog).getByRole('button', { name: en.cancel }))
expect(screen.queryByRole('dialog', { name: en.deleteTitle })).toBeNull()
expect(mutate).not.toHaveBeenCalled()
fireEvent.click(screen.getAllByText(en.remove)[0] as HTMLElement)
fireEvent.click(within(screen.getByRole('dialog', { name: en.deleteTitle }))
.getByRole('button', { name: en.close }))
expect(screen.queryByRole('dialog', { name: en.deleteTitle })).toBeNull()
expect(mutate).not.toHaveBeenCalled()
fireEvent.click(screen.getAllByText(en.remove)[0] as HTMLElement)
fireEvent.click(within(screen.getByRole('dialog', { name: en.deleteTitle }))
.getByRole('button', { name: en.deleteConfirm }))
await waitFor(() => { expect(mutate).toHaveBeenCalledTimes(1) })
expect(screen.queryByRole('dialog', { name: en.deleteTitle })).toBeNull()
expect(replace).not.toHaveBeenCalled()
expect(mutate.mock.calls[0]?.[0]).toEqual({
ns: 'llm-pi-ai',
@@ -482,6 +499,28 @@ describe('ModelsSection', () => {
})
})
it('blocks duplicate deletion while the confirmed removal is pending', async () => {
let resolveRemoval!: (response: RpcResponse<SettingsNamespaceView>) => void
const mutate = vi.fn(() => new Promise<RpcResponse<SettingsNamespaceView>>((resolve) => {
resolveRemoval = resolve
}))
await mountSection({ mutate })
fireEvent.click(screen.getAllByText(en.remove)[0] as HTMLElement)
const dialog = screen.getByRole('dialog', { name: en.deleteTitle })
const confirm = within(dialog).getByRole<HTMLButtonElement>('button', { name: en.deleteConfirm })
fireEvent.click(confirm)
fireEvent.click(confirm)
expect(mutate).toHaveBeenCalledOnce()
expect(confirm.disabled).toBe(true)
expect(within(dialog).getByRole<HTMLButtonElement>('button', { name: en.cancel }).disabled).toBe(true)
expect(within(dialog).getByRole('button', { name: en.deleting })).toBe(confirm)
fireEvent.click(within(dialog).getByRole('button', { name: en.close }))
expect(screen.getByRole('dialog', { name: en.deleteTitle })).toBe(dialog)
expect(mutate).toHaveBeenCalledOnce()
await act(async () => { resolveRemoval(ok(wireNamespaces()[2]!)) })
await waitFor(() => { expect(screen.queryByRole('dialog', { name: en.deleteTitle })).toBeNull() })
})
it('renders the load failure with a retry control', async () => {
const face = scriptedFace()
face.face.llm.providers = vi.fn(() => Promise.resolve(fail('directory down', 'internal'))) as never
@@ -589,6 +628,8 @@ describe('ModelsSection', () => {
// would appear — rather than the row silently staying put.
await mountSection({ mutate: vi.fn(() => Promise.reject(new Error('the host refused'))) })
fireEvent.click(screen.getAllByText(en.remove)[0] as HTMLElement)
fireEvent.click(within(screen.getByRole('dialog', { name: en.deleteTitle }))
.getByRole('button', { name: en.deleteConfirm }))
await screen.findByText(`${en.loadFailed}: the host refused`)
})
@@ -0,0 +1,13 @@
import { readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
const css = readFileSync(fileURLToPath(new URL('../src/client/ModelsSection.module.css', import.meta.url)), 'utf8')
describe('ModelsSection theme styles', () => {
it('uses the shared theme tokens without light-only fallbacks', () => {
expect(css).not.toMatch(/var\(--(?:surface|text-|border|accent-strong)/)
expect(css).toContain('background: var(--dsw-alias-bg-layer-3)')
expect(css).toContain('color: var(--dsw-alias-label-primary)')
})
})