From a332f2f3330e9b3a3c6b649f6518567c968ad975 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Fri, 31 Jul 2026 14:08:59 +0800 Subject: [PATCH 01/29] feat(web): configure custom DeepSeek models --- .../2026-07-30-web-config-plane.i18n.yaml | 4 +- .../2026-07-30-web-config-plane.md | 6 +- .../2026-07-30-web-config-plane.zh.md | 6 +- ...07-24-web-session-model-selector.i18n.yaml | 4 +- .../2026-07-24-web-session-model-selector.md | 6 +- ...026-07-24-web-session-model-selector.zh.md | 6 +- apps/web/tests/message-actions.e2e.ts | 5 +- apps/web/tests/models-settings.e2e.ts | 2 +- .../tests/onboarding-deepseek-config.e2e.ts | 44 +- apps/web/tests/seeded-history.e2e.ts | 11 +- .../lifecycle-chrome/plan-active.expected.md | 4 +- .../snapshots/message-actions/ui.expected.md | 4 +- .../models-settings/configured.expected.md | 4 +- .../models.expected.md | 58 +++ .../seeded-history/command-row.expected.md | 4 +- .../snapshots/seeded-history/ui.expected.md | 4 +- packages/client/ui-model/README.i18n.yaml | 4 +- packages/client/ui-model/README.md | 2 +- packages/client/ui-model/README.zh.md | 2 +- .../src/client/ModelSelect.module.css | 7 +- .../ui-model/src/client/ModelSelect.tsx | 14 +- packages/client/ui-model/src/client/index.ts | 4 +- .../client/ui-model/src/client/locales.ts | 6 +- .../ui-model/tests/model-select.spec.tsx | 22 + packages/client/ui-models/README.i18n.yaml | 4 +- packages/client/ui-models/README.md | 7 +- packages/client/ui-models/README.zh.md | 7 +- .../src/client/DeepSeekModelsEditor.tsx | 196 +++++++++ .../src/client/ModelsSection.module.css | 412 ++++++++++++++---- .../ui-models/src/client/ModelsSection.tsx | 4 +- .../ui-models/src/client/ProviderEditor.tsx | 41 +- .../client/ui-models/src/client/locales.ts | 34 ++ .../ui-models/tests/components.spec.tsx | 153 ++++++- packages/host/apiproxy/README.i18n.yaml | 4 +- packages/host/apiproxy/README.md | 2 +- packages/host/apiproxy/README.zh.md | 2 +- packages/host/apiproxy/src/api-proxy.ts | 39 +- packages/host/apiproxy/src/api/llm.ts | 4 +- .../host/apiproxy/src/api/sessions.schema.ts | 1 - packages/host/apiproxy/src/api/sessions.ts | 2 - .../apiproxy/tests/api-proxy-models.spec.ts | 13 +- .../host/apiproxy/tests/rpc-schemas.spec.ts | 1 - 42 files changed, 948 insertions(+), 211 deletions(-) create mode 100644 apps/web/tests/snapshots/onboarding-deepseek-config/models.expected.md create mode 100644 packages/client/ui-models/src/client/DeepSeekModelsEditor.tsx diff --git a/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.i18n.yaml index ac37214ebf..d652bd075a 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.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/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: e00c0ed8b5852d416baec73a993102aadded9cd3 +2026-07-30-web-config-plane.zh.md: d5dd5e3c044dc5788367eca55e1cc4110ff81180 diff --git a/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md b/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md index 95ede62640..e00c0ed8b5 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md +++ b/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md @@ -18,9 +18,9 @@ PR1 made LLM adapter configuration restart-free at the seam, but the only writer **The llm seam declares configurability and announces topology.** `registerConfigurableProviders()` is an all-or-nothing, fiber-scoped directory of `{provider, displayName, settingsNs, settingsPath}` — the addressing a config page needs to open the right settings subtree for a route that may not exist yet; `listConfigurableProviders()` merges with live routes in the wire handler so undeclared live routes still report active. The zero-payload `'llm/adapters-updated'` event fires from all four registration/unregistration commit points with contained listener dispatch (INVARIANT rethrow), following the settings/commands precedent. `llm-deepseek`'s route renamed to `deepseek-official` because the pi-ai catalog legitimately owns `deepseek` as an aggregator entry; pre-release stance, no alias. -**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. +**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, `reasoningEffort` for deepseek / `reasoning` for pi-ai, plus direct DeepSeek model rows with `id`, `name`, and `contextWindow`). Existing model fields outside that visible set survive array edits; retry policy, timeouts, and other fields remain owned by `settings.yaml`. Validation still runs the rehydrated schema before writing, while adapter-specific checks reject catalog invariants that the serialized schema cannot express. The card's colors resolve through the `--dsw-alias-*` design tokens; it had named `--border`/`--surface`/`--text-*`, which nothing in this app defines, so it rendered their light-mode fallbacks and stayed light under the dark theme. The model catalog is one caption strip over a row of `id`/`name`/`contextWindow` fields per model rather than a labelled card each; every field keeps the indexed `aria-label` that names it, and the captions are hidden from assistive tech so that name is not announced twice. -**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 `_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; 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 `_API_KEY` when none exists (the pi-ai profile records the derivation), so `settings.yaml` never carries a key value. Visible profile edits land as `settings.mutate` path operations against the stored redacted section, so a set or unset never rebuilds and drops an unseen secret. DeepSeek's model list is array-replace configuration: inherited effective rows remain visible until the first edit materializes the complete list in the user layer, and reset unsets the list override. ## 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 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 DeepSeek onboarding fixture edits the default catalog into a user-owned list, persists an arbitrary model id/name/context window, removes the active row, and observes the model selector's empty-selection fallback. 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. A page address for live routes that never declared configurability remains deferred. diff --git a/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.zh.md b/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.zh.md index 6e06b69218..d5dd5e3c04 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.zh.md @@ -18,9 +18,9 @@ PR1 让 LLM(大语言模型)适配器配置在 seam 层面免重启,但唯 **llm seam 声明可配置性并公布拓扑。**`registerConfigurableProviders()` 是一个全有或全无、以 fiber 为作用域的目录,条目为 `{provider, displayName, settingsNs, settingsPath}`——这正是配置页要为一条可能尚不存在的路由打开正确设置子树时所需要的寻址;`listConfigurableProviders()` 在 wire 处理器里与存活路由合并,未声明的存活路由因此仍报告为激活。零负载的 `'llm/adapters-updated'` 事件从全部四个注册/注销提交点触发,listener 派发带异常隔离(INVARIANT 重抛),沿用 settings/commands 的先例。`llm-deepseek` 的路由重命名为 `deepseek-official`,因为 pi-ai catalog 名正言顺地拥有 `deepseek` 这个聚合器条目;依预发布立场,不设别名。 -**架在 schema 模型层之上的手写编辑器。**`dsh-client-schema-form` 把 wire 的 `toJSON()` 信封还原(rehydrate)为活的 schemastery 节点,用于校验、路径解析与不可变草稿编辑——但不做通用渲染:第一版交付了完整的 schema 驱动表单渲染器,得到的却是一个未加样式、把 schema 原样倾倒出来的页面(每个进阶字段都平铺到卡片上、原始字段名直接充当标签、`retryPolicy` 的「不支持」回退落在主流程里)。用户没有再加一套提示/分组系统,而是选择了手写方向,第二轮又把引用输入框整个移除:卡片的主字段是一个 **API 密钥**输入框,未配置密钥的整分节提供方会以其设置卡片的形式打开,收起的「自定义设置」折叠区承载按家族精选的额外字段(两个家族都有 `baseURL`,另加 deepseek 的 `reasoningEffort`/pi-ai 的 `reasoning`),其余每个字段都归 `settings.yaml` 所有。校验仍会在写入前运行还原出的 schema,因此偏离其 schema 的手写字段会在保存时大声失败,而非静默失败。 +**架在 schema 模型层之上的手写编辑器。**`dsh-client-schema-form` 把 wire 的 `toJSON()` 信封还原(rehydrate)为活的 schemastery 节点,用于校验、路径解析与不可变草稿编辑——但不做通用渲染:第一版交付了完整的 schema 驱动表单渲染器,得到的却是一个未加样式、把 schema 原样倾倒出来的页面(每个进阶字段都平铺到卡片上、原始字段名直接充当标签、`retryPolicy` 的「不支持」回退落在主流程里)。用户没有再加一套提示/分组系统,而是选择了手写方向,第二轮又把引用输入框整个移除:卡片的主字段是一个 **API 密钥**输入框,未配置密钥的整分节提供方会以其设置卡片的形式打开,收起的「自定义设置」折叠区承载按家族精选的额外字段(两个家族都有 `baseURL`,deepseek 有 `reasoningEffort`/pi-ai 有 `reasoning`,另有直接 DeepSeek 模型行的 `id`、`name` 和 `contextWindow`)。现有模型字段中不在可见集合内的部分会在数组编辑后保留;重试策略、超时及其他字段仍归 `settings.yaml` 所有。校验仍会在写入前运行还原出的 schema,适配器特有的检查则会拒绝序列化 schema 无法表达的目录不变量。卡片的颜色经 `--dsw-alias-*` 设计 token 解析;它此前引用的 `--border`/`--surface`/`--text-*` 在本应用中无人定义,于是渲染出的是它们的亮色模式回退值,在暗色主题下依旧保持亮色。模型目录是一条列名说明行,其下每个模型占一行 `id`/`name`/`contextWindow` 字段,而不是每个模型各一张带标签的卡片;每个字段都保留那个为其命名的带序号 `aria-label`,列名则对辅助技术隐藏,以免该名称被播报两次。 -**Models 页是一次三领域联接,应用语义与 seam 同形。**每一行是一个已配置的提供方;「新增」卡片的选择框是可配置提供方目录中剩余的休眠条目;徽标来自路由存活状态。密钥通道保持引用形态,却从不展示任何引用:键入的密钥经 `credentials.set` **只写**存入 profile 的 `apiKeyEnv` 之下,引用不存在时便派生 `_API_KEY`(pi-ai profile 会记录该派生),因此 `settings.yaml` 从不携带密钥值,删除所需的整体 `settings.replace` 也绝不可能丢掉兄弟条目的机密。不含删除的编辑以一次最小的 `settings.update` 合并 patch 落地;把折叠区字段清回继承值或删除整行则经 `settings.replace` 替换整个用户分节,因为合并语义表达不了删除。 +**Models 页是一次三领域联接,应用语义与 seam 同形。**每一行是一个已配置的提供方;「新增」卡片的选择框是可配置提供方目录中剩余的休眠条目;徽标来自路由存活状态。密钥通道保持引用形态,却从不展示任何引用:键入的密钥经 `credentials.set` **只写**存入 profile 的 `apiKeyEnv` 之下,引用不存在时便派生 `_API_KEY`(pi-ai profile 会记录该派生),因此 `settings.yaml` 从不携带密钥值。可见的 profile 编辑以 `settings.mutate` 路径操作落到已存储的脱敏分节上,因此 set 或 unset 都不会重建分节并丢掉不可见的机密。DeepSeek 的模型列表是数组替换配置:继承而来的生效模型行会一直显示,直到第一次编辑将完整列表具化到用户层;重置则会取消设置该列表覆盖。 ## 曾考虑的替代方案 @@ -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`(受测提供方是派生引用不可能与开发者已导出密钥相撞的那一个)。DeepSeek 首次使用 fixture 会把默认目录编辑为用户自有列表、持久化任意模型的 ID/名称/上下文窗口、移除活动模型行,并观察模型选择器的空选择回退。这次重命名在一次提交中触及 239 个文件(fixture(测试前置数据)、golden、文档、python),未保留兼容别名。替换渲染器只花了一次提交,且没有任何 wire 变更:应用语义、脱敏与目录联接从一开始就与渲染器无关。为从未声明可配置性的存活路由提供页面地址仍然暂缓。 diff --git a/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.i18n.yaml b/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.i18n.yaml index b1e6e8b7d1..898a0142fb 100644 --- a/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.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/feature/2026-07-24-web-session-model-selector.md -2026-07-24-web-session-model-selector.md: 003c0b5ac1c6963701e1d93e4c3ff8615fc45dd5 -2026-07-24-web-session-model-selector.zh.md: ba76b87b9bd43fff97bf9ea76624f5f75b10e135 +2026-07-24-web-session-model-selector.md: ca13bebbb49aec5deabd147ed1c2a8f3b246ca42 +2026-07-24-web-session-model-selector.zh.md: 16c7d773b0eea82bf991bc17c44b26c8e820aba1 diff --git a/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.md b/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.md index 003c0b5ac1..ca13bebbb4 100644 --- a/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.md +++ b/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.md @@ -12,11 +12,11 @@ The Web conversation displayed and sent through the Host's fixed provider/model The Web Host reuses `installAgentLlmTarget` for every created or resumed agent. The provider/model/reasoning target starts from the latest `request/header` when the session has used a model, otherwise from the Host default route. `session.selectModel` changes the session-local mutable target, and prompt assembly captures it with request routing; a switch during a running step therefore applies to the next assembled step. The next consumed target persists through the existing full `request/header` snapshot, while a choice that has not reached a request remains process-local. -The session RPC domain exposes a `session.models` directory and `session.selectModel`. The directory is built dynamically from the LLM registry and grouped by provider; each listed model's exact metadata adds adapter-owned reasoning effort ids, names, descriptions, and optional default. Provider catalogs and exact metadata load concurrently by provider and fail independently, so successful groups remain usable alongside retryable failure records. Catalog membership stays advisory: the current model is inserted as an unlisted row when its registered provider omits it, while exact resolution decides whether a route and explicit effort are available. Selection uses `resolveCallConfig` to reject unsupported effort ids and materialize an adapter-configured default before updating the target. +The session RPC domain exposes a `session.models` directory and `session.selectModel`. The directory is built dynamically from the LLM registry and grouped by provider; each listed model's exact metadata adds adapter-owned reasoning effort ids, names, descriptions, and optional default. Provider catalogs and exact metadata load concurrently by provider and fail independently, so successful groups remain usable alongside retryable failure records. Catalog membership stays advisory: `session.models.current` is returned independently and can remain routable when absent from every group, but the Host does not synthesize an unlisted row after its provider stops advertising it. Exact resolution decides whether a route and explicit effort are available. Selection uses `resolveCallConfig` to reject unsupported effort ids and materialize an adapter-configured default before updating the target. The browser `ModelService` owns one `ModelDirectory` per live session. Its snapshot contains the current complete target, grouped catalog, provider failures, operation error, and `idle`/`loading`/`ready`/`selecting`/`error` state. Mounting primes the trigger label and each menu open refreshes the directory. Directory and selection calls share an operation generation so older responses cannot replace a newer result; connection reset discards the process-local projection before restoring the Host target. Failures retain the previous current target and usable groups. -`@deepseek-ai/dsh-client-ui-conversation` declares the session-scoped single slot `conversation.input.model` as a child of its composer-bar entry. InputBar renders the seat in its trailing controls immediately before the pending indicator and primary button; the seat receives the bar's `locked` owner prop and session scope. `@deepseek-ai/dsh-client-ui-model` occupies that seat and also contributes `/model` over the same directory. Its compact trigger displays the catalog model name and effective reasoning label, falling back to ids when metadata is absent. The upward menu first offers Model and, when the current exact model supports it, Effort; Model drills into provider groups, while Effort drills into the adapter-ordered levels. The provider-default row appears only when the adapter does not configure a model default. +`@deepseek-ai/dsh-client-ui-conversation` declares the session-scoped single slot `conversation.input.model` as a child of its composer-bar entry. InputBar renders the seat in its trailing controls immediately before the pending indicator and primary button; the seat receives the bar's `locked` owner prop and session scope. `@deepseek-ai/dsh-client-ui-model` occupies that seat and also contributes `/model` over the same directory. Its compact trigger displays the exact catalog model name and effective reasoning label. When the current target is absent from the groups, the trigger instead displays `Select model`, the model list marks no row active, and the Effort row stays absent; choosing a listed model replaces the complete target through the existing selection path. The upward menu otherwise first offers Model and Effort; Model drills into provider groups, while Effort drills into the adapter-ordered levels. The provider-default row appears only when the adapter does not configure a model default. The production browser roster is assembled from `apps/cli/config/base.cordis.yml` plus `apps/cli/config/web.cordis.yml`; the model feature is one `dshClient` row rather than a package hardcoded in Web boot code. Its package manifest orders it after the runtime and command feature, while Cordis service injection waits for the conversation slot before registering the composer occupant. @@ -40,4 +40,4 @@ Any Host-backed Web conversation, including a blank session, can switch among dy ## Testing -Host tests pin grouped discovery, catalog and exact-metadata failure isolation, logged effort restoration, unlisted current targets, unsupported effort rejection, default materialization, and next-assembly switching. Client tests pin the shared directory, reconnect restoration, and complete-target submission. Component tests pin dynamic effort labels, descriptions, provider-default exposure, and effort submission. The keyless built-app fixture loads the production model plugin, selects OpenAI's GPT-5 and its Max effort, sends a turn, and verifies that the next generated response reports both ids. +Host tests pin grouped discovery, catalog and exact-metadata failure isolation, logged effort restoration without stale-row injection, advisory unlisted selection, unsupported effort rejection, default materialization, and next-assembly switching. Client tests pin the shared directory, reconnect restoration, and complete-target submission. Component tests pin dynamic effort labels, descriptions, provider-default exposure, effort submission, and the `Select model` fallback for a removed row. The keyless built-app fixture loads the production model plugin, selects OpenAI's GPT-5 and its Max effort, sends a turn, and verifies that the next generated response reports both ids; the DeepSeek configuration fixture removes the active catalog row and pins the fallback before choosing a replacement. diff --git a/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.zh.md b/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.zh.md index ba76b87b9b..16c7d773b0 100644 --- a/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.zh.md +++ b/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.zh.md @@ -12,11 +12,11 @@ Web 对话原本通过 Host 固定的提供方与模型路由显示并发送消 Web Host 为每个新建或恢复的 agent(智能体)复用 `installAgentLlmTarget`。如果会话已经使用过模型,提供方/模型/推理(reasoning)目标从最新的 `request/header` 开始;否则采用 Host 默认路由。`session.selectModel` 会更改会话级可变目标,提示词组装则将该目标与请求路由一并捕获,因此运行中步骤发生的切换会应用于下一个组装步骤。下一条实际采用的目标通过现有的完整 `request/header` 快照持久化;尚未进入请求的选择则仅保存在当前进程中。 -会话 RPC 领域公开 `session.models` 模型目录与 `session.selectModel`。该目录从 LLM(大语言模型)注册表动态构建,并按提供方分组;每个已列出模型的精确元数据还会加入由适配器持有的推理强度 ID、名称、说明和可选默认值。各提供方的目录与精确元数据会按提供方并发加载,且彼此独立失败,因此成功加载的分组仍可与可重试的失败记录一同使用。模型是否位于目录仅供参考:如果当前模型的已注册提供方没有列出该模型,系统会将其作为未列出行插入;精确解析则决定路由与显式推理强度是否可用。选择操作通过 `resolveCallConfig` 拒绝不支持的推理强度 ID,并在更新目标前具体化适配器配置的默认值。 +会话 RPC 领域公开 `session.models` 模型目录与 `session.selectModel`。该目录从 LLM(大语言模型)注册表动态构建,并按提供方分组;每个已列出模型的精确元数据还会加入由适配器持有的推理强度 ID、名称、说明和可选默认值。各提供方的目录与精确元数据会按提供方并发加载,且彼此独立失败,因此成功加载的分组仍可与可重试的失败记录一同使用。模型是否位于目录仅供参考:`session.models.current` 独立返回,即使不在任何分组中也仍然可以路由,但提供方停止公布该模型后,Host 不会合成未列出行。精确解析决定路由与显式推理强度是否可用。选择操作通过 `resolveCallConfig` 拒绝不支持的推理强度 ID,并在更新目标前具体化适配器配置的默认值。 浏览器中的 `ModelService` 为每个实时会话持有一个 `ModelDirectory`。其快照包含当前完整目标、分组目录、提供方失败记录、操作错误,以及 `idle`、`loading`、`ready`、`selecting`、`error` 状态。挂载时会预先填充触发器标签,此后每次打开菜单都会刷新目录。目录与选择调用共用操作代次,防止较早响应覆盖较新结果;连接重置会先丢弃当前进程中的投影,再恢复 Host 目标。失败时保留先前的当前目标和可用分组。 -`@deepseek-ai/dsh-client-ui-conversation` 将会话作用域的单实例 slot `conversation.input.model` 声明为其输入栏 entry 的子 slot。InputBar 在尾部控件区将该 seat 渲染于 pending 指示器与主按钮之前;该 seat 接收输入栏的 `locked` owner prop 与会话作用域。`@deepseek-ai/dsh-client-ui-model` 占用该 seat,并在同一目录上提供 `/model`。其紧凑型触发器显示目录中的模型名称与生效的推理强度标签;元数据缺失时则回退到相应 ID。向上展开的菜单首先提供 Model,并在当前精确模型支持时提供 Effort;Model 可深入提供方分组,Effort 可深入适配器排序的级别。仅当适配器没有配置模型默认值时,才显示提供方默认值行。 +`@deepseek-ai/dsh-client-ui-conversation` 将会话作用域的单实例 slot `conversation.input.model` 声明为其输入栏 entry 的子 slot。InputBar 在尾部控件区将该 seat 渲染于 pending 指示器与主按钮之前;该 seat 接收输入栏的 `locked` owner prop 与会话作用域。`@deepseek-ai/dsh-client-ui-model` 占用该 seat,并在同一目录上提供 `/model`。其紧凑型触发器显示目录中精确模型的名称与生效的推理强度标签。当前目标不在分组中时,触发器改为显示 `Select model`,模型列表不标记任何活动行,Effort 行也保持隐藏;选择一个已列出的模型,会通过现有选择路径替换完整目标。除此情形外,向上展开的菜单会首先提供 Model 与 Effort;Model 可深入提供方分组,Effort 可深入适配器排序的级别。仅当适配器没有配置模型默认值时,才显示提供方默认值行。 生产环境的浏览器名册由 `apps/cli/config/base.cordis.yml` 与 `apps/cli/config/web.cordis.yml` 共同组装;模型功能对应其中一行 `dshClient` 配置项,而不是 Web boot 代码中硬编码的包。其包 manifest(元数据清单)将加载顺序置于运行时与命令功能之后;Cordis 服务注入则等待 conversation slot 可用,再注册 composer 占用方。 @@ -40,4 +40,4 @@ Web Host 为每个新建或恢复的 agent(智能体)复用 `installAgentLlm ## 测试 -Host 测试固定分组发现、目录与精确元数据失败隔离、已记录推理强度恢复、当前未列出目标、不支持的推理强度拒绝、默认值具体化,以及切换仅影响下一次组装。客户端测试固定共享目录、重连恢复与完整目标提交。组件测试固定动态推理强度标签、说明、提供方默认值展示与推理强度提交。无密钥 built-app fixture(测试前置数据)加载生产模型插件,选择 OpenAI 的 GPT-5 及其 Max 推理强度,发起一个轮次,并验证下一条生成的响应会报告两个 ID。 +Host 测试固定分组发现、目录与精确元数据失败隔离、已记录推理强度恢复且不注入陈旧行、建议性的未列出模型选择、不支持的推理强度拒绝、默认值具体化,以及切换仅影响下一次组装。客户端测试固定共享目录、重连恢复与完整目标提交。组件测试固定动态推理强度标签、说明、提供方默认值展示、推理强度提交,以及已删除模型行的 `Select model` 回退。无密钥 built-app fixture(测试前置数据)加载生产模型插件,选择 OpenAI 的 GPT-5 及其 Max 推理强度,发起一个轮次,并验证下一条生成的响应会报告两个 ID;DeepSeek 配置 fixture 会删除活动目录行,在选择替代模型之前固定该回退。 diff --git a/apps/web/tests/message-actions.e2e.ts b/apps/web/tests/message-actions.e2e.ts index 4d798e11ed..c44bdfdd69 100644 --- a/apps/web/tests/message-actions.e2e.ts +++ b/apps/web/tests/message-actions.e2e.ts @@ -76,9 +76,8 @@ describe('web e2e: message IconActions and clocks on settled history', () => { it.skipIf(MODE === 'record')('matches the conversation aria golden with IconActions and clocks', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-message-actions-aria')) - await page.getByRole('button', { - name: 'Select model, current deepseek-v4-flash', - }).waitFor({ timeout: 10_000 }) + await page.getByRole('button', { name: 'Select model', exact: true }) + .waitFor({ timeout: 10_000 }) // Keep a footer focused so opacity-hidden actions stay in the a11y tree // as an active/focused control during the capture. await page.getByRole('button', { name: 'Copy' }).first().focus() diff --git a/apps/web/tests/models-settings.e2e.ts b/apps/web/tests/models-settings.e2e.ts index 28c423b0a0..e23a5a9e1e 100644 --- a/apps/web/tests/models-settings.e2e.ts +++ b/apps/web/tests/models-settings.e2e.ts @@ -55,7 +55,7 @@ describe('web e2e: Models settings page configures a dormant provider', () => { await dialog.getByText('填入各提供方的 API 密钥即可使用其模型。').waitFor({ timeout: 10_000 }) // The dormant pi-ai adapter contributes its whole installed catalog; no // provider is configured yet, so the page is one add button. - const add = dialog.getByRole('button', { name: '+ 添加提供方' }) + const add = dialog.getByRole('button', { name: '添加提供方' }) await add.waitFor({ timeout: 10_000 }) // The button enables once the dormant catalog lands in the join. await expect.poll(async () => add.isEnabled(), { timeout: 10_000 }).toBe(true) diff --git a/apps/web/tests/onboarding-deepseek-config.e2e.ts b/apps/web/tests/onboarding-deepseek-config.e2e.ts index 62dd129982..9ce68c6318 100644 --- a/apps/web/tests/onboarding-deepseek-config.e2e.ts +++ b/apps/web/tests/onboarding-deepseek-config.e2e.ts @@ -16,6 +16,7 @@ import { saveFailureShot } from './support.ts' const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/onboarding-deepseek-config', import.meta.url)) const MISSING_EXPECTED = join(SNAPSHOT_DIR, 'missing.expected.md') +const MODELS_EXPECTED = join(SNAPSHOT_DIR, 'models.expected.md') const MODE = webSnapshotMode() describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup', () => { @@ -85,7 +86,48 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup expect(tripwire.pageErrors).toEqual([]) }, 60_000) + it('configures arbitrary DeepSeek models and prompts after the selected model is removed', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-onboarding-deepseek-models')) + const settings = page.getByRole('dialog', { name: '设置' }) + await settings.getByText('自定义设置').click() + await settings.getByRole('button', { name: '删除模型' }).first().click() + await settings.getByRole('button', { name: '添加模型' }).click() + const customModelId = settings.getByLabel('模型 ID 2') + await customModelId.fill('private-preview') + await settings.getByLabel('显示名称 2').fill('Private Preview') + await settings.getByLabel('上下文窗口 2').fill('131072') + + const modelEditor = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd) + await compareOrRefreshGolden(MODELS_EXPECTED, modelEditor, MODE) + await settings.getByRole('button', { name: '保存', exact: true }).click() + await customModelId.waitFor({ state: 'detached', timeout: 15_000 }) + + const document = await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8') + expect(document).toContain('id: deepseek-v4-pro') + expect(document).toContain('id: private-preview') + expect(document).toContain('name: Private Preview') + expect(document).toContain('contextWindow: 131072') + expect(document).not.toContain('id: deepseek-v4-flash') + + await page.keyboard.press('Escape') + await page.getByRole('button', { name: '创建工作区', exact: true }).click() + await page.getByRole('menuitem', { name: '新建工作区', exact: true }).click() + const workspaceDialog = page.getByRole('dialog', { name: '新建工作区' }) + await workspaceDialog.getByLabel('新工作区名称').fill('model-fallback-e2e') + await workspaceDialog.getByRole('button', { name: '创建工作区', exact: true }).click() + await workspaceDialog.waitFor({ state: 'detached', timeout: 10_000 }) + + const modelTrigger = page.getByRole('button', { name: '选择模型', exact: true }) + await modelTrigger.waitFor({ timeout: 10_000 }) + await modelTrigger.click() + await page.getByRole('menuitem', { name: /模型/ }).click() + expect(await page.getByText('deepseek-v4-flash', { exact: true }).count()).toBe(0) + await page.getByRole('menuitemradio', { name: 'Private Preview' }).waitFor({ timeout: 10_000 }) + expect(tripwire.warnings).toEqual([]) + expect(tripwire.pageErrors).toEqual([]) + }, 60_000) + it('keeps the fixture inventory closed', async () => { - await assertFixtureInventory(SNAPSHOT_DIR, ['missing.expected.md']) + await assertFixtureInventory(SNAPSHOT_DIR, ['missing.expected.md', 'models.expected.md']) }) }) diff --git a/apps/web/tests/seeded-history.e2e.ts b/apps/web/tests/seeded-history.e2e.ts index 3437c41e18..2e4882627a 100644 --- a/apps/web/tests/seeded-history.e2e.ts +++ b/apps/web/tests/seeded-history.e2e.ts @@ -152,12 +152,11 @@ describe('web e2e: seeded history renders through cold resume', () => { it.skipIf(MODE === 'record')('matches the historical conversation aria golden', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-seeded-aria')) - await page.getByRole('button', { - // This scenario deliberately leaves the LLM seam open to prove zero - // model calls. History still restores the selected id, but no catalog - // adapter exists to provide its presentation name. - name: 'Select model, current deepseek-v4-flash', - }).waitFor({ timeout: 10_000 }) + // This scenario deliberately leaves the LLM seam open to prove zero + // model calls. History still restores the routed id, but without an + // advertised catalog row the selector prompts for a listed replacement. + await page.getByRole('button', { name: 'Select model', exact: true }) + .waitFor({ timeout: 10_000 }) const snapshot = (await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)) .split(SEED_ID).join('{{seededId}}') await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE) diff --git a/apps/web/tests/snapshots/lifecycle-chrome/plan-active.expected.md b/apps/web/tests/snapshots/lifecycle-chrome/plan-active.expected.md index 8be9ff8b86..eb1002fd5d 100644 --- a/apps/web/tests/snapshots/lifecycle-chrome/plan-active.expected.md +++ b/apps/web/tests/snapshots/lifecycle-chrome/plan-active.expected.md @@ -30,8 +30,8 @@ - img - 'button "Access mode, current: Danger Full Access"': Danger Full Access - button "Plan mode on, press to turn off": Plan -- button "Select model, current deepseek-v4-flash": - - text: deepseek-v4-flash +- button "Select model": + - text: Select model - img - button "Send message" [disabled] - text: Details diff --git a/apps/web/tests/snapshots/message-actions/ui.expected.md b/apps/web/tests/snapshots/message-actions/ui.expected.md index d423f6f53a..6f7e8cf865 100644 --- a/apps/web/tests/snapshots/message-actions/ui.expected.md +++ b/apps/web/tests/snapshots/message-actions/ui.expected.md @@ -40,8 +40,8 @@ - button "Commands": - img - 'button "Access mode, current: Danger Full Access"': Danger Full Access -- button "Select model, current deepseek-v4-flash": - - text: deepseek-v4-flash +- button "Select model": + - text: Select model - img - button "Send message" [disabled] - text: 1 turns · 2 steps Tool call {{duration}} Cache hit 98% Input 15.8K tok · Output 135 tok diff --git a/apps/web/tests/snapshots/models-settings/configured.expected.md b/apps/web/tests/snapshots/models-settings/configured.expected.md index 8b9c4ad6e1..e4697dfa4f 100644 --- a/apps/web/tests/snapshots/models-settings/configured.expected.md +++ b/apps/web/tests/snapshots/models-settings/configured.expected.md @@ -17,4 +17,6 @@ - text: minimax-cn 已启用 - button "编辑" - button "删除" - - button "+ 添加提供方" + - button "添加提供方": + - img + - text: 添加提供方 diff --git a/apps/web/tests/snapshots/onboarding-deepseek-config/models.expected.md b/apps/web/tests/snapshots/onboarding-deepseek-config/models.expected.md new file mode 100644 index 0000000000..31b6c7b1b4 --- /dev/null +++ b/apps/web/tests/snapshots/onboarding-deepseek-config/models.expected.md @@ -0,0 +1,58 @@ +- dialog "设置": + - navigation: + - text: 设置 + - button "通用设置": + - img + - text: 通用设置 + - button "模型": + - img + - text: 模型 + - button "关闭": + - img + - text: 关闭 + - heading "模型" [level=2] + - paragraph: 填入各提供方的 API 密钥即可使用其模型。 + - list: + - listitem: + - text: DeepSeek 已启用 + - button "编辑" + - text: DeepSeek deepseek-official API 密钥 + - textbox "API 密钥": + - /placeholder: 已配置——输入新值可替换 + - group: + - text: 自定义设置 API 地址 + - textbox "API 地址": + - /placeholder: https://api.deepseek.com + - text: 推理强度 + - combobox "推理强度": + - option "默认" [selected] + - option "off" + - option "high" + - option "max" + - region "模型目录": + - text: 模型目录 已自定义模型目录 + - button "恢复默认模型" + - textbox "模型 ID 1": deepseek-v4-pro + - textbox "显示名称 1": + - /placeholder: 留空时使用模型 ID + - text: DeepSeek-V4-Pro + - spinbutton "上下文窗口 1": "1000000" + - button "删除模型": + - img + - text: 删除模型 + - textbox "模型 ID 2": private-preview + - textbox "显示名称 2": + - /placeholder: 留空时使用模型 ID + - text: Private Preview + - spinbutton "上下文窗口 2": "131072" + - button "删除模型": + - img + - text: 删除模型 + - button "添加模型": + - img + - text: 添加模型 + - button "取消" + - button "保存" + - button "添加提供方": + - img + - text: 添加提供方 diff --git a/apps/web/tests/snapshots/seeded-history/command-row.expected.md b/apps/web/tests/snapshots/seeded-history/command-row.expected.md index f722bb36ae..2d8fb1fce9 100644 --- a/apps/web/tests/snapshots/seeded-history/command-row.expected.md +++ b/apps/web/tests/snapshots/seeded-history/command-row.expected.md @@ -45,8 +45,8 @@ - button "Commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write -- button "Select model, current deepseek-v4-flash": - - text: deepseek-v4-flash +- button "Select model": + - text: Select model - img - button "Send message" [disabled] - text: 1 turns · 2 steps Tool call {{duration}} Cache hit 98% Input 15.8K tok · Output 135 tok diff --git a/apps/web/tests/snapshots/seeded-history/ui.expected.md b/apps/web/tests/snapshots/seeded-history/ui.expected.md index 61d3996414..a2d14e611a 100644 --- a/apps/web/tests/snapshots/seeded-history/ui.expected.md +++ b/apps/web/tests/snapshots/seeded-history/ui.expected.md @@ -43,8 +43,8 @@ - button "Commands": - img - 'button "Access mode, current: Danger Full Access"': Danger Full Access -- button "Select model, current deepseek-v4-flash": - - text: deepseek-v4-flash +- button "Select model": + - text: Select model - img - button "Send message" [disabled] - text: 1 turns · 2 steps Tool call {{duration}} Cache hit 98% Input 15.8K tok · Output 135 tok diff --git a/packages/client/ui-model/README.i18n.yaml b/packages/client/ui-model/README.i18n.yaml index 9620b15513..5d40ab44b9 100644 --- a/packages/client/ui-model/README.i18n.yaml +++ b/packages/client/ui-model/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-model/README.md -README.md: 267717c78434f7a73b1c1eebca0cc0f9d65c3642 -README.zh.md: 6d6f433315336812a51b5110ceeac3eecbd9bbd4 +README.md: e456371095166569ed9e36fab19627ece9a751b0 +README.zh.md: c72eb33163aa5ef855489cbbd46cac9edb93afb2 diff --git a/packages/client/ui-model/README.md b/packages/client/ui-model/README.md index 267717c784..e456371095 100644 --- a/packages/client/ui-model/README.md +++ b/packages/client/ui-model/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Model selection plugin, browser half: TWO entries over ONE per-session directory owned by `ModelService` (`ctx.models`). The `/model` popupSelect contribution (registered through `ctx.command`) and the composer's named `conversation.input.model` seat both load the session's advisory directory through `session.models` and submit through `session.selectModel` via the same `ModelDirectory` instance. The compact composer trigger opens a two-level Model/Effort menu: models stay provider-grouped, while the selected exact model supplies its adapter-owned effort names, descriptions, and default. The Host-reported provider/model/reasoning target is the single fact both entries echo; `/model` applies the selected model's default effort, and the composer can then choose any advertised effort. Directory loads and selections share a generation counter so an older response never overwrites a newer one; a connection reset drops every resident projection and repulls the Host-restored target before display. Provider-local metadata failures list inline while usable groups stay selectable, and selection failures retain the prior target and directory. Directories are per-session, resolved lazily through `ctx.models.directoryFor(sessionId)`, and disposed with the session scope. +Model selection plugin, browser half: TWO entries over ONE per-session directory owned by `ModelService` (`ctx.models`). The `/model` popupSelect contribution (registered through `ctx.command`) and the composer's named `conversation.input.model` seat both load the session's advisory directory through `session.models` and submit through `session.selectModel` via the same `ModelDirectory` instance. The compact composer trigger opens a two-level Model/Effort menu: models stay provider-grouped, while the selected exact model supplies its adapter-owned effort names, descriptions, and default. The Host-reported provider/model/reasoning target is the single selection fact, but it is echoed only when the exact route remains in the advertised groups; removing that catalog row leaves the routable target intact while the trigger prompts `Select model`, no stale row is synthesized, and no Effort row is shown until the user picks an advertised model. `/model` applies the selected model's default effort, and the composer can then choose any advertised effort. Directory loads and selections share a generation counter so an older response never overwrites a newer one; a connection reset drops every resident projection and repulls the Host-restored target before display. Provider-local metadata failures list inline while usable groups stay selectable, and selection failures retain the prior target and directory. Directories are per-session, resolved lazily through `ctx.models.directoryFor(sessionId)`, and disposed with the session scope. The `/client` export surface is the plugin body (`apply`/`inject`), `ModelService`, `ModelDirectory` with its state shape, and the seat's injected face type. diff --git a/packages/client/ui-model/README.zh.md b/packages/client/ui-model/README.zh.md index 6d6f433315..c72eb33163 100644 --- a/packages/client/ui-model/README.zh.md +++ b/packages/client/ui-model/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -模型选择插件(浏览器侧):**两个入口共用一份会话级目录**,由 `ModelService`(`ctx.models`)持有。`/model` popupSelect 贡献项(经 `ctx.command` 注册)与 composer 的具名 `conversation.input.model` 坑位都通过同一个 `ModelDirectory` 实例,经 `session.models` 加载会话的建议目录,并经 `session.selectModel` 提交。紧凑型 composer 触发器会打开两级 Model/Effort 菜单:模型仍按提供方分组,所选具体模型则提供由其适配器持有的推理强度名称、说明和默认值。Host 报告的提供方/模型/推理(reasoning)目标是两个入口共同回显的唯一事实;`/model` 应用所选模型的默认推理强度,composer 随后可以选择任一已公布的推理强度。目录加载与选择共享一个代次计数器,旧响应不会覆盖新结果;连接重置会丢弃所有常驻目录投影,并在显示前重新拉取 Host 恢复的目标。各提供方的元数据获取失败会内联列出,同时可用分组仍可选择;选择失败会保留先前的目标和目录。目录按会话惰性解析(`ctx.models.directoryFor(sessionId)`),随会话作用域一并释放。 +模型选择插件(浏览器侧):**两个入口共用一份会话级目录**,由 `ModelService`(`ctx.models`)持有。`/model` popupSelect 贡献项(经 `ctx.command` 注册)与 composer 的具名 `conversation.input.model` 坑位都通过同一个 `ModelDirectory` 实例,经 `session.models` 加载会话的建议目录,并经 `session.selectModel` 提交。紧凑型 composer 触发器会打开两级 Model/Effort 菜单:模型仍按提供方分组,所选具体模型则提供由其适配器持有的推理强度名称、说明和默认值。Host 报告的提供方/模型/推理(reasoning)目标是唯一的选择事实,但只有当该精确路由仍在已公布分组中时才会回显;删除该目录行会保留仍可路由的目标,但触发器会提示 `Select model`,系统不会合成陈旧行,且在用户选择已公布的模型之前不会显示 Effort 行。`/model` 应用所选模型的默认推理强度,composer 随后可以选择任一已公布的推理强度。目录加载与选择共享一个代次计数器,旧响应不会覆盖新结果;连接重置会丢弃所有常驻目录投影,并在显示前重新拉取 Host 恢复的目标。各提供方的元数据获取失败会内联列出,同时可用分组仍可选择;选择失败会保留先前的目标和目录。目录按会话惰性解析(`ctx.models.directoryFor(sessionId)`),随会话作用域一并释放。 `/client` 导出面为插件本体(`apply`/`inject`)、`ModelService`、`ModelDirectory` 及其状态形状、坑位注入面类型。 diff --git a/packages/client/ui-model/src/client/ModelSelect.module.css b/packages/client/ui-model/src/client/ModelSelect.module.css index f9d6cc10e6..fce9b2c28f 100644 --- a/packages/client/ui-model/src/client/ModelSelect.module.css +++ b/packages/client/ui-model/src/client/ModelSelect.module.css @@ -197,8 +197,7 @@ white-space: nowrap; } -.description, -.unlisted { +.description { overflow: hidden; color: var(--dsw-alias-label-tertiary); font-size: 12px; @@ -207,10 +206,6 @@ white-space: nowrap; } -.unlisted { - color: var(--dsw-alias-state-warn-label); -} - .check { display: grid; place-items: center; diff --git a/packages/client/ui-model/src/client/ModelSelect.tsx b/packages/client/ui-model/src/client/ModelSelect.tsx index 4cc2687832..8396e42b5a 100644 --- a/packages/client/ui-model/src/client/ModelSelect.tsx +++ b/packages/client/ui-model/src/client/ModelSelect.tsx @@ -169,8 +169,13 @@ export function ModelSelect( }) } - const modelLabel = choices[selectedIndex]?.model.name ?? state.current?.model ?? t('trigger.fallback') + const modelLabel = currentChoice?.model.name ?? t('trigger.fallback') const triggerLabel = effortLabel === undefined ? modelLabel : `${modelLabel} · ${effortLabel}` + const triggerAria = currentChoice === undefined + ? t('trigger.selectAria') + : effortLabel === undefined + ? t('trigger.aria', { model: modelLabel }) + : t('trigger.ariaEffort', { model: modelLabel, effort: effortLabel }) itemRefs.current = [] let itemIndex = 0 const itemRef = () => { @@ -184,9 +189,7 @@ export function ModelSelect( ref={triggerRef} type="button" className={css.trigger} - aria-label={effortLabel === undefined - ? t('trigger.aria', { model: modelLabel }) - : t('trigger.ariaEffort', { model: modelLabel, effort: effortLabel })} + aria-label={triggerAria} aria-haspopup="menu" aria-expanded={open} aria-controls={open ? `${id}-menu` : undefined} @@ -272,9 +275,6 @@ export function ModelSelect( {model.description !== undefined && ( {model.description} )} - {model.unlisted === true && ( - {t('option.currentUnlisted')} - )} {selected ? : null} diff --git a/packages/client/ui-model/src/client/index.ts b/packages/client/ui-model/src/client/index.ts index a672c6935a..deebf3b000 100644 --- a/packages/client/ui-model/src/client/index.ts +++ b/packages/client/ui-model/src/client/index.ts @@ -49,9 +49,7 @@ function optionsOf(directory: SessionModels, t: TranslateNS<'model'>): SelectOpt rows.push({ id: rowId(group.id, model.id), label: model.name, - detail: model.unlisted === true - ? t('option.unlisted', { group: group.name }) - : model.description !== undefined ? `${group.name} · ${model.description}` : group.name, + detail: model.description !== undefined ? `${group.name} · ${model.description}` : group.name, ...(directory.current.provider === group.id && directory.current.model === model.id ? { active: true } : {}), }) diff --git a/packages/client/ui-model/src/client/locales.ts b/packages/client/ui-model/src/client/locales.ts index 856c95e470..8b83870c40 100644 --- a/packages/client/ui-model/src/client/locales.ts +++ b/packages/client/ui-model/src/client/locales.ts @@ -3,9 +3,9 @@ /** Simplified Chinese dictionary (the key-set source of truth). */ export const zh = { 'command.description': '选择本会话使用的模型', - 'option.unlisted': '{group} · 未列入目录', 'option.loadError': '目录加载失败:{message}', 'trigger.fallback': '选择模型', + 'trigger.selectAria': '选择模型', 'trigger.aria': '选择模型,当前 {model}', 'trigger.ariaEffort': '选择模型,当前 {model},推理等级 {effort}', 'menu.aria': '模型与推理等级', @@ -16,7 +16,6 @@ export const zh = { 'error.action': '模型操作失败:{message}', 'action.reload': '重新加载', 'warning.groupLoad': '{name} 加载失败:{message}', - 'option.currentUnlisted': '当前模型 · 未列入目录', 'empty.models': '没有可用的模型。', 'empty.efforts': '当前模型未提供推理等级。', } satisfies Record @@ -27,9 +26,9 @@ export type ModelKey = keyof typeof zh /** English dictionary, checked complete against the zh key set. */ export const en = { 'command.description': 'Select the model for this conversation', - 'option.unlisted': '{group} · Not in catalog', 'option.loadError': 'Catalog failed to load: {message}', 'trigger.fallback': 'Select model', + 'trigger.selectAria': 'Select model', 'trigger.aria': 'Select model, current {model}', 'trigger.ariaEffort': 'Select model, current {model}, reasoning effort {effort}', 'menu.aria': 'Model and reasoning effort', @@ -40,7 +39,6 @@ export const en = { 'error.action': 'Model operation failed: {message}', 'action.reload': 'Reload', 'warning.groupLoad': '{name} failed to load: {message}', - 'option.currentUnlisted': 'Current model · Not in catalog', 'empty.models': 'No models available.', 'empty.efforts': 'This model provides no reasoning effort levels.', } satisfies Record diff --git a/packages/client/ui-model/tests/model-select.spec.tsx b/packages/client/ui-model/tests/model-select.spec.tsx index d1dc4f7d1b..3fc459b9c1 100644 --- a/packages/client/ui-model/tests/model-select.spec.tsx +++ b/packages/client/ui-model/tests/model-select.spec.tsx @@ -108,4 +108,26 @@ describe('ModelSelect reasoning effort', () => { expect(screen.getAllByRole('menuitemradio').map(item => item.textContent)) .toEqual(['Default', 'Standard']) }) + + it('prompts for a new selection when the current target is no longer advertised', () => { + const directory = createSnapshotStore(state({ + current: { provider: 'deepseek-official', model: 'removed-model' }, + })) + const select = vi.fn().mockResolvedValue(true) + render() + + const trigger = screen.getByRole('button', { name: '选择模型' }) + expect(trigger.textContent).toContain('选择模型') + fireEvent.click(trigger) + expect(screen.queryByRole('menuitem', { name: /推理等级/ })).toBeNull() + fireEvent.click(screen.getByRole('menuitem', { name: /模型/ })) + expect(screen.queryByText('removed-model')).toBeNull() + expect(screen.getByRole('menuitemradio', { name: 'DeepSeek-V4-Flash' })).toBeTruthy() + }) }) diff --git a/packages/client/ui-models/README.i18n.yaml b/packages/client/ui-models/README.i18n.yaml index 951b1d04fe..2cf8870b64 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: adfbc084e1b0e227d50032cb6c924401b81c6a79 -README.zh.md: 4ee7d4efa729fdccee392ab8e55078b5a4a239ef +README.md: bbd1ad70afd925b2c0b28f444cf8118e241a7723 +README.zh.md: 99ae1e432e261def378a5fac242d24d36a870905 diff --git a/packages/client/ui-models/README.md b/packages/client/ui-models/README.md index adfbc084e1..bbd1ad70af 100644 --- a/packages/client/ui-models/README.md +++ b/packages/client/ui-models/README.md @@ -4,11 +4,11 @@ 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. -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. 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 `_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 `baseURL` for both families, `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. A provider row is deletable only when the user layer alone carries it (removal restores the composition base). The first-run overlay projects `deepseek-official` readiness from that same joined snapshot. 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. -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 row. The page only ever holds the REDACTED descriptor, so it names the fields it can see rather than rebuilding a section: a stored literal secret it never received is mentioned by no op and survives. Each write carries the `revision` the card opened at, so a concurrent write from another tab or an external `settings.yaml` edit is refused as `settings-conflict` and the card asks the user to reopen instead of replaying its stale snapshot. The page refetches on the pushed invalidations (`settings/changed`, `credentials/changed`, `models/changed`, and `connection/reset`) once it has loaded, so an external `settings.yaml` edit, a second tab, or a settings-born route converges without polling. +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 names the fields it can see rather than rebuilding a section: a stored literal secret it never received is mentioned by no op and survives. 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. Empty ids, duplicate ids, empty explicit names, and non-positive or fractional context windows fail before any write. Each write carries the `revision` the card opened at, so a concurrent write from another tab or an external `settings.yaml` edit is refused as `settings-conflict` and the card asks the user to reopen instead of replaying its stale snapshot. The page refetches on the pushed invalidations (`settings/changed`, `credentials/changed`, `models/changed`, and `connection/reset`) once it has loaded, so an external `settings.yaml` edit, a second tab, or a settings-born route converges without polling. ## Model Experience @@ -20,7 +20,6 @@ None; this package neither assembles nor sends a provider request. ## Known Limitations and Deferred Work -- **Only the API key and the curated fold fields are editable on the card** — the hand-written editor traded schema-generic field coverage for the mockup layout ([Agent Note](../../../.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md)); advanced fields (`models`, retry policy, timeouts…) are edited in `settings.yaml`, which the fold points at. A profile schema without the conventional fields renders the hint alone, and the two curated layouts key on the `llm-deepseek`/`llm-pi-ai` namespaces by name. +- **Only the API key and curated fold fields are editable on the card** — the hand-written editor traded schema-generic field coverage for the mockup layout ([Agent Note](../../../.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md)). DeepSeek exposes `baseURL`, `reasoningEffort`, and model `id`/`name`/`contextWindow`; pi-ai exposes `baseURL` and `reasoning`. Retry policy, timeouts, DeepSeek model descriptions, and other advanced fields remain in `settings.yaml`; existing model fields the editor does not show are preserved. A profile schema without the conventional fields renders the hint alone, and the two curated layouts key on the `llm-deepseek`/`llm-pi-ai` namespaces by name. - **Deleting a row leaves its stored key in `.env`** — removal unsets the settings profile but deliberately does not unset the derived credential; re-adding the provider finds the key already configured. An explicit key-removal control is deferred. -- **No per-provider model listing on the page** — the picker surfaces models; this page shows route state only. A models preview per row is deferred until a consumer needs it. - **Undeclared live routes render nowhere** — a route registered without a configurable-provider declaration has no settings address; it stays visible in pickers but not on this page's rows. diff --git a/packages/client/ui-models/README.zh.md b/packages/client/ui-models/README.zh.md index 4ee7d4efa7..99ae1e432e 100644 --- a/packages/client/ui-models/README.zh.md +++ b/packages/client/ui-models/README.zh.md @@ -4,11 +4,11 @@ 模型设置插件:提供方配置页和 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` 从不携带密钥值。收起的「自定义设置」折叠区承载精选的额外字段——两个家族都有 `baseURL`(deepseek 的占位符显示公共端点),另加 `reasoningEffort`(deepseek)或 `reasoning`(pi-ai);其余每个 profile 字段仍归 `settings.yaml` 所有。只有当某行仅由用户层承载时它才可删除(删除会还原组合 base)。 +行是*已配置*的提供方(其 profile 在所属 namespace 中解析得出);密钥未在任何地方配置的整分节提供方(DeepSeek 的首次运行姿态)会渲染为其展开的设置卡片而非一行,「新增」流程则是一张承载休眠目录提供方选择框的卡片——裸挂载的 `llm-pi-ai` 在任何路由存在之前就能提供其完整的已安装 catalog。编辑器是每个适配器家族各一张的手写卡片:主字段是单独一个 **API 密钥**输入框——页面从不询问环境变量名;键入的密钥经 `credentials.set` 以**只写**方式存入 profile 的引用之下,profile 没有引用时便派生 `_API_KEY`,pi-ai profile 会把这次派生记录为 `apiKeyEnv`,因此 `settings.yaml` 从不携带密钥值。收起的「自定义设置」折叠区承载两个家族的 `baseURL`、deepseek 的 `reasoningEffort` 或 pi-ai 的 `reasoning`,以及直接 DeepSeek 适配器的建议性模型目录。每条 DeepSeek 模型行可编辑 `id`、可选的显示名称 `name` 与可选的 `contextWindow`;精选集合以外的现有字段会在编辑后保留。只有当某个提供方行仅由用户层承载时它才可删除(删除会还原组合 base)。 首次使用浮层从同一个联接快照得出 `deepseek-official` 的就绪状态。它通过 `llm-deepseek` 的可配置提供方声明识别官方适配器,因此不会把同一提供方 ID 下没有相应声明的存活路由视为可通过配置修复。若 `apiKey` 字面量对应的 secret 槽位标记为已设置,或凭据引用已配置,浮层就不再显示,其中包括来自启动环境且只读的凭据。只有适配器已挂载、引用可写但尚未配置时,浮层才显示一个操作按钮,用于打开「设置」的 Models 分区;密钥输入和 `credentials.set` 仅由该分区已有的设置卡片负责,浮层绝不持有 secret。适配器缺失、路由未激活、联接失败、部署只读、设置能力不可用或凭据能力不可用时均跳过,以免首次使用引导阻塞产品的其他部分;Models 页仍是诊断界面。 -每一次编辑都以 `settings.mutate` 的路径 op 落到已存分节上——每个变更字段一条 set、每个清空字段一条 unset、删除整行则是单独一条 unset。页面自始至终只持有**脱敏后**的 descriptor,因此它点名自己看得见的字段,而不是重建分节:一个它从未收到过的已存字面机密不会被任何 op 提及,也就得以留存。每次写入都携带该卡片打开时的 `revision`,因此来自另一个标签页或对 `settings.yaml` 的外部编辑所产生的并发写入会以 `settings-conflict` 被拒绝,卡片会请用户重新打开,而不是把自己的陈旧快照重放上去。页面加载完成后会在推送的失效事件(`settings/changed`、`credentials/changed`、`models/changed` 与 `connection/reset`)上重拉,因此外部的 `settings.yaml` 编辑、第二个标签页或 settings 新生的路由都无需轮询即可收敛。 +每一次编辑都以 `settings.mutate` 的路径 op 落到已存分节上——每个变更字段一条 set、每个清空字段一条 unset、删除提供方行则是单独一条 unset。页面自始至终只持有**脱敏后**的 descriptor,因此它点名自己看得见的字段,而不是重建分节:一个它从未收到过的已存字面机密不会被任何 op 提及,也就得以留存。DeepSeek 的 `models` 是一个按值整体替换的数组:编辑器会显示继承而来的生效模型行,直到第一次模型编辑将完整数组具化到用户层;重置则会取消该覆盖。空 ID、重复 ID、显式填写的空名称,以及非正数或非整数的上下文窗口都会在写入前失败。每次写入都携带该卡片打开时的 `revision`,因此来自另一个标签页或对 `settings.yaml` 的外部编辑所产生的并发写入会以 `settings-conflict` 被拒绝,卡片会请用户重新打开,而不是把自己的陈旧快照重放上去。页面加载完成后会在推送的失效事件(`settings/changed`、`credentials/changed`、`models/changed` 与 `connection/reset`)上重拉,因此外部的 `settings.yaml` 编辑、第二个标签页或 settings 新生的路由都无需轮询即可收敛。 ## 模型体验 @@ -20,7 +20,6 @@ ## 已知限制与暂缓事项 -- **卡片上可编辑的只有 API 密钥与精选折叠区字段**:手写编辑器用 schema 通用的字段覆盖面换来了设计稿上的布局([Agent Note(agent 决策记录)](../../../.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md));进阶字段(`models`、重试策略、超时……)在 `settings.yaml` 中编辑,折叠区会指向它。不带这些约定字段的 profile schema 只渲染该提示,两套精选布局则以 `llm-deepseek`/`llm-pi-ai` 这两个 namespace 的名字为键。 +- **卡片上可编辑的只有 API 密钥与精选折叠区字段**:手写编辑器用 schema 通用的字段覆盖面换来了设计稿上的布局([Agent Note(agent 决策记录)](../../../.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md))。DeepSeek 公开 `baseURL`、`reasoningEffort` 与模型的 `id`/`name`/`contextWindow`;pi-ai 公开 `baseURL` 与 `reasoning`。重试策略、超时、DeepSeek 模型说明及其他进阶字段仍留在 `settings.yaml` 中;编辑器未展示的现有模型字段会予以保留。不带这些约定字段的 profile schema 只渲染该提示,两套精选布局则以 `llm-deepseek`/`llm-pi-ai` 这两个 namespace 的名字为键。 - **删除一行会把它已存储的密钥留在 `.env` 里**:删除取消设置的是 settings profile,却刻意不清除那条派生凭据;重新添加该提供方时会发现密钥已配置。显式的密钥移除控件暂缓。 -- **页面上没有逐提供方的模型列表**:模型由选择器呈现;本页只展示路由状态。逐行的模型预览暂缓,待有消费方需要时再实现。 - **未声明的存活路由无处渲染**:未附带可配置提供方声明即注册的路由没有 settings 地址;它在各选择器中仍然可见,但不会出现在本页的行里。 diff --git a/packages/client/ui-models/src/client/DeepSeekModelsEditor.tsx b/packages/client/ui-models/src/client/DeepSeekModelsEditor.tsx new file mode 100644 index 0000000000..c79eb57412 --- /dev/null +++ b/packages/client/ui-models/src/client/DeepSeekModelsEditor.tsx @@ -0,0 +1,196 @@ +/** + * Curated editor for the direct DeepSeek adapter's advisory model catalog. + * The settings layer replaces `models` as one array, so the parent supplies + * the effective inherited rows until the first edit materializes a user + * override; reset removes that override instead of copying defaults into it. + */ + +import type { ReactNode } from 'react' +import { IconPlusOutline16, IconTrashOutline16 } from '@deepseek-ai/dsh-client-ui-primitives' +import type { en } from './locales.ts' +import styles from './ModelsSection.module.css' + +/** One catalog entry kept structurally open so hidden or future fields survive an edit. */ +export type DeepSeekModelDraft = Record + +/** A localized validation failure for one user-owned model array. */ +export interface DeepSeekModelsValidationFailure { + /** Zero-based model position. */ + index: number + /** Message key owned by the Models settings section. */ + key: 'modelIdRequired' | 'modelIdDuplicate' | 'modelNameInvalid' | 'modelContextInvalid' +} + +/** Convert a schema-validated catalog value into records without dropping hidden fields. */ +export function modelDrafts(value: unknown): DeepSeekModelDraft[] { + if (!Array.isArray(value)) return [] + return value.map(entry => + typeof entry === 'object' && entry !== null && !Array.isArray(entry) + ? entry as DeepSeekModelDraft + : {}) +} + +/** + * Validate adapter constraints that the serialized schema cannot express. + * @param value - user-owned `models` value, or undefined while inherited. + * @returns the first invalid row, or undefined when the adapter will accept it. + */ +export function validateDeepSeekModels(value: unknown): DeepSeekModelsValidationFailure | undefined { + if (value === undefined) return undefined + const models = modelDrafts(value) + const seen = new Set() + for (const [index, model] of models.entries()) { + const id = model['id'] + if (typeof id !== 'string' || id.length === 0) return { index, key: 'modelIdRequired' } + if (seen.has(id)) return { index, key: 'modelIdDuplicate' } + seen.add(id) + const name = model['name'] + if (name !== undefined && (typeof name !== 'string' || name.length === 0)) { + return { index, key: 'modelNameInvalid' } + } + const contextWindow = model['contextWindow'] + if (contextWindow !== undefined + && (typeof contextWindow !== 'number' || !Number.isInteger(contextWindow) || contextWindow <= 0)) { + return { index, key: 'modelContextInvalid' } + } + } + return undefined +} + +/** Props of {@link DeepSeekModelsEditor}. */ +export interface DeepSeekModelsEditorProps { + /** Effective rows: inherited until the parent materializes an override. */ + models: readonly DeepSeekModelDraft[] + /** Whether the user layer currently owns the whole array. */ + overridden: boolean + /** Fallback capacity used when a row omits its exact value. */ + defaultContextWindow: number | undefined + /** Section copy. */ + t: (key: keyof typeof en) => string + /** Disable every mutation. */ + disabled: boolean + /** Replace the user-owned array after one visible edit. */ + onChange: (models: DeepSeekModelDraft[]) => void + /** Remove the user-owned array and return to inheritance. */ + onReset: () => void +} + +/** + * Render the direct DeepSeek adapter's id/name/context-window catalog. + * @param props - effective rows plus the array-level override actions. + * @returns the catalog editor. + */ +export function DeepSeekModelsEditor(props: DeepSeekModelsEditorProps): ReactNode { + const update = (index: number, key: 'id' | 'name' | 'contextWindow', value: unknown): void => { + const next = props.models.map((model, at) => { + const copy = { ...model } + if (at !== index) return copy + if (value === undefined) Reflect.deleteProperty(copy, key) + else copy[key] = value + return copy + }) + props.onChange(next) + } + + const remove = (index: number): void => { + props.onChange(props.models.filter((_model, at) => at !== index).map(model => ({ ...model }))) + } + + return ( +
+
+
+ {props.t('models')} + + {props.overridden ? props.t('modelsCustomized') : props.t('modelsInherited')} + +
+ {props.overridden + ? ( + + ) + : null} +
+ {props.models.length === 0 + ?

{props.t('modelsEmpty')}

+ : ( +
+ {/* Captions sit above the rows and are hidden from assistive tech: + every field already carries the indexed `aria-label` naming it. */} + + {props.models.map((model, index) => ( +
+ { update(index, 'id', event.target.value) }} + /> + { + update(index, 'name', event.target.value === '' ? undefined : event.target.value) + }} + /> + { + update( + index, + 'contextWindow', + event.target.value === '' ? undefined : Number(event.target.value), + ) + }} + /> + +
+ ))} +
+ )} + +
+ ) +} diff --git a/packages/client/ui-models/src/client/ModelsSection.module.css b/packages/client/ui-models/src/client/ModelsSection.module.css index a2be484a63..798f45126c 100644 --- a/packages/client/ui-models/src/client/ModelsSection.module.css +++ b/packages/client/ui-models/src/client/ModelsSection.module.css @@ -1,3 +1,13 @@ +/* Models settings section, in the settings-panel design language: 14/22 body, + * 12/18 caption, capsule controls (h36 r18; h28 r14 where a row is dense), + * 32px fields, and `border-l2` hairlines — the vocabulary GeneralSection and + * the Button/Input primitives already use. + * + * Every color resolves through a `--dsw-alias-*` token. The section used to + * name `--border` / `--surface` / `--text-*`, which nothing in this app + * defines, so it always rendered the light-mode literals written as their + * fallbacks and stayed light under the dark theme. */ + .section { display: flex; flex-direction: column; @@ -7,20 +17,24 @@ .title { margin: 0; - font-size: 18px; - font-weight: 600; + font-size: 16px; + line-height: 24px; + font-weight: 500; + color: var(--dsw-alias-label-primary); } .intro { margin: 0; - font-size: 13px; - color: var(--text-tertiary, #888); + font-size: 14px; + line-height: 22px; + color: var(--dsw-alias-label-tertiary); } .notice { margin: 0; font-size: 12px; - color: var(--text-warning, #a15c00); + line-height: 18px; + color: var(--dsw-alias-state-warn-label); } .rows { @@ -29,17 +43,18 @@ padding: 0; display: flex; flex-direction: column; - gap: 10px; + gap: 8px; } +/* A configured provider: outlined on the panel fill, so the filled editor + card it expands into reads as the nested object. */ .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); } .rowHead { @@ -49,8 +64,10 @@ } .rowName { - font-size: 15px; - font-weight: 600; + font-size: 14px; + line-height: 22px; + font-weight: 500; + color: var(--dsw-alias-label-primary); } .badges { @@ -63,8 +80,9 @@ display: inline-flex; align-items: center; gap: 5px; - color: var(--text-success, #0a7d33); + color: var(--dsw-alias-state-success-primary); font-size: 12px; + line-height: 18px; } .badgeOk::before { @@ -76,59 +94,118 @@ } .badgeMuted { - color: var(--text-tertiary, #999); - font-size: 12px; -} - -.badgeWarn { - color: var(--text-warning, #a15c00); + color: var(--dsw-alias-label-tertiary); font-size: 12px; + line-height: 18px; } .rowActions { display: inline-flex; - gap: 8px; + align-items: center; + gap: 4px; +} + +/* `box-sizing` on every control here: the app has no global border-box reset, + so without it the outlined variants stand 2px taller than the filled ones + they sit beside (Cancel next to Apply, Edit next to Delete). */ +.primaryButton, +.secondaryButton, +.addButton { + box-sizing: border-box; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 4px; + height: 36px; + padding: 0 14px; + border: none; + border-radius: 18px; + font: inherit; + font-size: 14px; + line-height: 22px; + cursor: pointer; } .primaryButton { - border: none; - border-radius: 999px; - padding: 8px 18px; - background: var(--accent-strong, #111); - color: var(--text-inverse, #fff); - font: inherit; - cursor: pointer; + background: var(--dsw-alias-button-primary-fill); + color: var(--dsw-alias-label-primary-foreground); } -.secondaryButton { - border: 1px solid var(--border, #d9d9d9); - border-radius: 999px; - padding: 6px 14px; - background: var(--surface, #fff); - color: inherit; - font: inherit; - cursor: pointer; +.primaryButton:hover:not(:disabled) { + background: var(--dsw-alias-button-primary-hover); +} + +.secondaryButton, +.addButton { + border: 1px solid var(--dsw-alias-border-l2); + background: transparent; + color: var(--dsw-alias-label-primary); +} + +.secondaryButton:hover:not(:disabled), +.addButton:hover:not(:disabled) { + background: var(--dsw-alias-interactive-bg-hover); } .dangerButton { + box-sizing: border-box; + display: inline-flex; + align-items: center; + justify-content: center; + height: 36px; + padding: 0 14px; border: none; - background: none; - color: var(--text-danger, #c0392b); + border-radius: 18px; + background: transparent; + color: var(--dsw-alias-state-error-primary); font: inherit; + font-size: 14px; + line-height: 22px; cursor: pointer; } +.dangerButton:hover:not(:disabled) { + background: var(--dsw-alias-interactive-bg-hover-danger); +} + +/* Provider-row controls take the dense capsule (Button `.sm`). */ +.rowActions .secondaryButton, +.rowActions .dangerButton { + height: 28px; + padding: 0 10px; + border-radius: 14px; + font-size: 12px; + line-height: 18px; +} + .primaryButton:disabled, .secondaryButton:disabled, -.dangerButton:disabled { - opacity: 0.5; +.dangerButton:disabled, +.addButton:disabled, +.linkButton:disabled, +.addModelButton:disabled, +.rowDelete:disabled { + opacity: 0.4; cursor: default; } +.primaryButton:focus-visible, +.secondaryButton:focus-visible, +.dangerButton:focus-visible, +.addButton:focus-visible, +.linkButton:focus-visible, +.addModelButton:focus-visible, +.rowDelete:focus-visible, +.customizedSummary:focus-visible { + outline: none; + box-shadow: 0 0 0 2px var(--dsw-alias-border-l3); +} + +/* Editing surface: a filled module on the panel, matching the settings + selector fill rather than adding another outline inside the row. */ .editor { - border: 1px solid var(--border, #e6e6e6); border-radius: 12px; - background: var(--surface-secondary, #f7f7f8); + background: var(--dsw-alias-bg-module-platform); padding: 14px 16px; display: flex; flex-direction: column; @@ -143,12 +220,15 @@ .editorTitle { font-size: 14px; - font-weight: 600; + line-height: 22px; + font-weight: 500; + color: var(--dsw-alias-label-primary); } .editorRoute { font-size: 12px; - color: var(--text-tertiary, #999); + line-height: 18px; + color: var(--dsw-alias-label-tertiary); } .field { @@ -162,30 +242,37 @@ align-items: center; gap: 10px; font-size: 12px; + line-height: 18px; font-weight: 500; - color: var(--text-secondary, #555); + color: var(--dsw-alias-label-secondary); } .linkButton { + box-sizing: border-box; + display: inline-flex; + align-items: center; + height: 28px; + padding: 0 10px; border: none; - background: none; - padding: 0; - color: var(--text-tertiary, #888); + border-radius: 14px; + background: transparent; + color: var(--dsw-alias-label-tertiary); font: inherit; font-size: 12px; - text-decoration: underline; + line-height: 18px; cursor: pointer; } -.linkButton:disabled { - opacity: 0.5; - cursor: default; +.linkButton:hover:not(:disabled) { + background: var(--dsw-alias-interactive-bg-hover); + color: var(--dsw-alias-label-secondary); } .advancedHint { margin: 0; font-size: 12px; - color: var(--text-tertiary, #999); + line-height: 18px; + color: var(--dsw-alias-label-tertiary); } .editorActions { @@ -202,26 +289,12 @@ .addButton { align-self: flex-start; - border: 1px solid var(--border, #d9d9d9); - border-radius: 999px; - padding: 8px 16px; - font: inherit; - font-size: 13px; - background: var(--surface, #fff); - color: inherit; - cursor: pointer; -} - -.addButton:disabled { - opacity: 0.5; - cursor: default; } .addCard, .setupCard { - border: 1px solid var(--border, #e6e6e6); border-radius: 12px; - background: var(--surface-secondary, #f7f7f8); + background: var(--dsw-alias-bg-module-platform); padding: 14px 16px; display: flex; flex-direction: column; @@ -229,24 +302,56 @@ list-style: none; } +/* Nested in a card that already carries the module chrome. */ .addCard .editor, .setupCard .editor { - border: none; background: none; padding: 0; } .customized { - border-top: 1px solid var(--border, #ececec); + border-top: 1px solid var(--dsw-alias-border-l2); padding-top: 10px; } +/* Native disclosure marker replaced by a rotating chevron: the built-in + triangle differs per engine and cannot take the label color. */ .customizedSummary { + display: flex; + align-items: center; + gap: 6px; + width: fit-content; + padding: 2px 4px; + margin-left: -4px; + border-radius: 6px; cursor: pointer; font-size: 12px; + line-height: 18px; font-weight: 500; - color: var(--text-secondary, #555); - list-style: revert; + color: var(--dsw-alias-label-secondary); + list-style: none; +} + +.customizedSummary::-webkit-details-marker { + display: none; +} + +.customizedSummary::before { + content: ''; + width: 5px; + height: 5px; + border-right: 1.5px solid currentcolor; + border-bottom: 1.5px solid currentcolor; + transform: rotate(-45deg) translate(-1px, -1px); + transition: transform 120ms ease; +} + +.customized[open] > .customizedSummary::before { + transform: rotate(45deg) translate(-1px, -1px); +} + +.customizedSummary:hover { + color: var(--dsw-alias-label-primary); } .customizedBody { @@ -256,28 +361,179 @@ padding-top: 12px; } +/* Model catalog: a table, not a stack of cards. The column captions are + written once above the rows, so a row is one line of fields plus its + delete control; each field still carries the indexed `aria-label` that + names it, and the caption strip is hidden from assistive tech to keep + that name from being announced twice. */ +.modelCatalog { + display: flex; + flex-direction: column; + gap: 10px; + padding-top: 12px; + border-top: 1px solid var(--dsw-alias-border-l2); +} + +.modelCatalogHeader { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; +} + +.modelCatalogHeading { + display: flex; + flex-direction: column; + gap: 2px; +} + +.modelCatalogTitle { + font-size: 12px; + line-height: 18px; + font-weight: 500; + color: var(--dsw-alias-label-secondary); +} + +.modelCatalogMeta, +.modelEmpty { + margin: 0; + color: var(--dsw-alias-label-tertiary); + font-size: 12px; + line-height: 18px; +} + +.modelTable { + display: flex; + flex-direction: column; + gap: 6px; +} + +/* Captions and rows share one track list so the columns line up. */ +.modelColumns, +.modelRow { + display: grid; + grid-template-columns: minmax(0, 1.25fr) minmax(0, 1.25fr) minmax(88px, 0.75fr) 28px; + align-items: center; + gap: 8px; +} + +.modelColumns { + color: var(--dsw-alias-label-tertiary); + font-size: 12px; + line-height: 18px; +} + +/* The inset belongs on the caption cell, not the strip: padding on the grid + container would narrow its tracks against the rows' and walk the captions + left column by column. 1px border + 10px padding is the field text inset. */ +.modelColumns > span { + padding-left: 11px; +} + +.rowDelete { + box-sizing: border-box; + position: relative; + display: grid; + place-items: center; + width: 28px; + height: 28px; + padding: 0; + border: none; + border-radius: 8px; + background: transparent; + color: var(--dsw-alias-label-tertiary); + cursor: pointer; +} + +.rowDelete:hover:not(:disabled) { + background: var(--dsw-alias-interactive-bg-hover-danger); + color: var(--dsw-alias-state-error-primary); +} + +.modelEmpty { + padding: 12px; + border: 1px dashed var(--dsw-alias-border-l3); + border-radius: 8px; + text-align: center; +} + +.addModelButton { + box-sizing: border-box; + align-self: flex-start; + display: inline-flex; + align-items: center; + gap: 4px; + height: 28px; + padding: 0 10px; + border: 1px solid var(--dsw-alias-border-l2); + border-radius: 14px; + background: transparent; + color: var(--dsw-alias-label-primary); + font: inherit; + font-size: 12px; + line-height: 18px; + cursor: pointer; +} + +.addModelButton:hover:not(:disabled) { + background: var(--dsw-alias-interactive-bg-hover); +} + .input { box-sizing: border-box; - padding: 9px 12px; - border: 1px solid var(--border, #d9d9d9); - border-radius: 10px; + width: 100%; + height: 32px; + padding: 0 10px; + border: 1px solid var(--dsw-alias-border-l2); + border-radius: 8px; font: inherit; - font-size: 13px; - background: var(--surface, #fff); - color: inherit; + font-size: 14px; + line-height: 22px; + background: var(--dsw-alias-bg-layer-1); + color: var(--dsw-alias-label-primary); +} + +/* Enum pickers hold a handful of short options; a field-width dropdown reads + as a text field the user is expected to fill. */ +select.input { + max-width: 240px; + cursor: pointer; } .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); +} + +.input:disabled { + opacity: 0.6; + cursor: default; } .error { margin: 0; font-size: 12px; - color: var(--text-danger, #c0392b); + line-height: 18px; + color: var(--dsw-alias-state-error-primary); +} + +/* Icon-button label seat: named for assistive tech and for the tests that + query these controls by their text. */ +.hiddenLabel { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0 0 0 0); + white-space: nowrap; +} + +@media (prefers-reduced-motion: reduce) { + .customizedSummary::before { + transition: none; + } } diff --git a/packages/client/ui-models/src/client/ModelsSection.tsx b/packages/client/ui-models/src/client/ModelsSection.tsx index d095acb86c..4dea825a2d 100644 --- a/packages/client/ui-models/src/client/ModelsSection.tsx +++ b/packages/client/ui-models/src/client/ModelsSection.tsx @@ -11,6 +11,7 @@ import { useState } from 'react' import type { ReactNode } from 'react' import type { IApiClient } from '@deepseek-ai/dsh-client-connection/client' +import { IconPlusOutline16 } 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' @@ -272,7 +273,8 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode { setEditing(targetOf(first)) }} > - {`+ ${t('add')}`} + + {t('add')} )} diff --git a/packages/client/ui-models/src/client/ProviderEditor.tsx b/packages/client/ui-models/src/client/ProviderEditor.tsx index 07b5dfae54..746d1cba35 100644 --- a/packages/client/ui-models/src/client/ProviderEditor.tsx +++ b/packages/client/ui-models/src/client/ProviderEditor.tsx @@ -5,19 +5,23 @@ * under the profile's reference, deriving `_API_KEY` when the profile * has none, and the pi-ai profile records that derivation as `apiKeyEnv`); * the collapsed 自定义设置 area carries the per-family extras (`baseURL` for - * both families, plus `reasoningEffort` for deepseek / `reasoning` for - * pi-ai). Everything else stays owned by `settings.yaml`. Profile edits land as - * minimal `settings.mutate` path ops against the stored section — the card - * reads the redacted descriptor, so it names only the fields it can see and a - * stored literal secret is never collaterally removed. + * both families, `reasoningEffort` for deepseek / `reasoning` for pi-ai, and + * DeepSeek's id/name/context-window model catalog). Everything else stays + * owned by `settings.yaml`. Profile edits land as minimal `settings.mutate` + * path ops against the stored section — the card reads the redacted + * descriptor, so it names only the fields it can see and a stored literal + * secret is never collaterally removed. */ import { useEffect, useMemo, useState } from 'react' import type { ReactNode } from 'react' import type { CredentialView, IApiClient, SettingsNamespaceView, SettingsPathOpView } from '@deepseek-ai/dsh-client-connection/client' import { - deletePath, getPath, nodeAtPath, rehydrateSchema, setPath, validateDraft, + deletePath, getPath, hasPath, nodeAtPath, rehydrateSchema, setPath, validateDraft, } from '@deepseek-ai/dsh-client-schema-form' +import { + DeepSeekModelsEditor, modelDrafts, validateDeepSeekModels, +} from './DeepSeekModelsEditor.tsx' import { deriveKeyRef, messageOf } from './store.ts' import type { en } from './locales.ts' import styles from './ModelsSection.module.css' @@ -179,6 +183,12 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode { && stringAt(fallback, 'apiKeyEnv') === undefined ? setPath(draft, ['apiKeyEnv'], keyRef) : draft + if (layout === 'deepseek') { + const modelFailure = validateDeepSeekModels(getPath(next, ['models'])) + if (modelFailure !== undefined) { + return `${t('model')} ${String(modelFailure.index + 1)}: ${t(modelFailure.key)}` + } + } /* v8 ignore next -- apply is only reachable from the rendered card, which required a resolved node */ if (node !== undefined && settingsPath.length === 0) { const sectionError = validateDraft(node, next) @@ -236,6 +246,10 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode { */ const curatedFields = (family: 'deepseek' | 'pi-ai'): ReactNode => { const effortField = EFFORT_FIELD[family] + const customModels = getPath(draft, ['models']) + const modelsOverridden = hasPath(draft, ['models']) + const models = modelDrafts(modelsOverridden ? customModels : getPath(fallback, ['models'])) + const defaultContextWindow = getPath(fallback, ['defaultContextWindow']) return ( <>
@@ -289,6 +303,21 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode { ))}
+ {family === 'deepseek' + ? ( + { setDraft(current => setPath(current, ['models'], next)) }} + onReset={() => { setDraft(current => deletePath(current, ['models'])) }} + /> + ) + : null} diff --git a/packages/client/ui-models/src/client/locales.ts b/packages/client/ui-models/src/client/locales.ts index 48431ddacf..c41846216b 100644 --- a/packages/client/ui-models/src/client/locales.ts +++ b/packages/client/ui-models/src/client/locales.ts @@ -27,6 +27,23 @@ export const en = { baseUrlDefault: 'Provider default', effort: 'Reasoning effort', effortInherit: 'Default', + models: 'Models', + modelsInherited: 'Using the adapter defaults', + modelsCustomized: 'Customized model catalog', + resetModels: 'Restore defaults', + model: 'Model', + modelId: 'Model ID', + modelName: 'Display name', + modelNamePlaceholder: 'Uses the model ID when empty', + contextWindow: 'Context window', + contextWindowPlaceholder: 'Uses the provider default', + addModel: 'Add model', + removeModel: 'Delete model', + modelsEmpty: 'No models will be shown in the selector. Unlisted IDs can still be sent directly.', + modelIdRequired: 'Model ID is required.', + modelIdDuplicate: 'Model ID must be unique.', + modelNameInvalid: 'Display name cannot be empty.', + modelContextInvalid: 'Context window must be a positive integer.', advancedHint: 'Other fields live in settings.yaml; edit that section directly.', onboardingTitle: 'Add an API key to get started', onboardingDescription: 'Configure the official DeepSeek provider to start building.', @@ -64,6 +81,23 @@ export const zh: typeof en = { baseUrlDefault: '提供方默认', effort: '推理强度', effortInherit: '默认', + models: '模型目录', + modelsInherited: '正在使用适配器默认模型', + modelsCustomized: '已自定义模型目录', + resetModels: '恢复默认模型', + model: '模型', + modelId: '模型 ID', + modelName: '显示名称', + modelNamePlaceholder: '留空时使用模型 ID', + contextWindow: '上下文窗口', + contextWindowPlaceholder: '使用提供方默认值', + addModel: '添加模型', + removeModel: '删除模型', + modelsEmpty: '模型选择器中将不显示任何模型;目录外 ID 仍可直接发送。', + modelIdRequired: '模型 ID 不能为空。', + modelIdDuplicate: '模型 ID 不能重复。', + modelNameInvalid: '显示名称不能为空。', + modelContextInvalid: '上下文窗口必须是正整数。', advancedHint: '其余字段在 settings.yaml 中,请直接编辑对应段。', onboardingTitle: '添加一个 API Key 开始使用', onboardingDescription: '配置 DeepSeek 官方模型,即可开始使用。', diff --git a/packages/client/ui-models/tests/components.spec.tsx b/packages/client/ui-models/tests/components.spec.tsx index a4f3734fbd..bbfe4782da 100644 --- a/packages/client/ui-models/tests/components.spec.tsx +++ b/packages/client/ui-models/tests/components.spec.tsx @@ -8,6 +8,9 @@ import type { RpcResponse, SettingsNamespaceView } from '@deepseek-ai/dsh-client import { ModelsSection, needsSetup, removeProviderProfile } from '../src/client/ModelsSection.tsx' import type { ModelsSectionInjected, ModelsSectionProps } from '../src/client/ModelsSection.tsx' import { pathOps } from '../src/client/ProviderEditor.tsx' +import { + DeepSeekModelsEditor, modelDrafts, validateDeepSeekModels, +} from '../src/client/DeepSeekModelsEditor.tsx' import { deriveKeyRef, ModelsSettingsStore } from '../src/client/store.ts' import type { ProviderRow } from '../src/client/store.ts' import { en } from '../src/client/locales.ts' @@ -32,15 +35,38 @@ const DeepSeekConfig = Schema.object({ apiKeyEnv: Schema.string().role('credential-ref'), baseURL: Schema.string().pattern(/^https:\/\//), reasoningEffort: Schema.union(['off', 'high', 'max']), + defaultContextWindow: Schema.number().step(1).min(1), + models: Schema.array(Schema.object({ + id: Schema.string().required(), + name: Schema.string(), + description: Schema.string(), + contextWindow: Schema.number().step(1).min(1), + })), }) +const DEFAULT_DEEPSEEK_MODELS = [ + { + id: 'deepseek-v4-flash', + name: 'DeepSeek-V4-Flash', + description: 'Preserved hidden detail', + contextWindow: 1_000_000, + }, + { id: 'deepseek-v4-pro', name: 'DeepSeek-V4-Pro', contextWindow: 1_000_000 }, +] + function wireNamespaces(): SettingsNamespaceView[] { return [ { ns: 'llm-deepseek', schema: JSON.parse(JSON.stringify(DeepSeekConfig.toJSON())) as unknown, - value: { apiKeyEnv: 'DEEPSEEK_API_KEY', baseURL: 'https://base', reasoningEffort: 'high' }, - base: {}, + value: { + apiKeyEnv: 'DEEPSEEK_API_KEY', + baseURL: 'https://base', + reasoningEffort: 'high', + defaultContextWindow: 1_000_000, + models: DEFAULT_DEEPSEEK_MODELS, + }, + base: { defaultContextWindow: 1_000_000, models: DEFAULT_DEEPSEEK_MODELS }, user: { reasoningEffort: 'high' }, applies: 'live', secrets: [{ path: ['apiKey'], set: false }], @@ -156,7 +182,7 @@ describe('ModelsSection', () => { expect(screen.getByText('openai')).toBeTruthy() expect(screen.getAllByText(en.active)).toHaveLength(1) expect(screen.getByText(en.dormant)).toBeTruthy() - expect(screen.getByText(`+ ${en.add}`)).toBeTruthy() + expect(screen.getByText(en.add)).toBeTruthy() }) it('turns the setup card into a row once the credential reports configured', async () => { @@ -245,6 +271,115 @@ describe('ModelsSection', () => { }) }) + it('materializes inherited models and adds an arbitrary DeepSeek id', async () => { + const { mutate } = await mountSection({ + mutate: vi.fn(() => Promise.resolve(ok(wireNamespaces()[0]))), + }) + fireEvent.click(screen.getByText(en.customized)) + expect(screen.getByText(en.modelsInherited)).toBeTruthy() + expect(screen.getAllByLabelText(new RegExp(en.modelId)).map(input => (input as HTMLInputElement).value)) + .toEqual(['deepseek-v4-flash', 'deepseek-v4-pro']) + + fireEvent.click(screen.getByText(en.addModel)) + const ids = screen.getAllByLabelText(new RegExp(en.modelId)) + const names = screen.getAllByLabelText(new RegExp(en.modelName)) + const windows = screen.getAllByLabelText(new RegExp(en.contextWindow)) + fireEvent.change(ids[2] as HTMLInputElement, { target: { value: 'private-preview' } }) + fireEvent.change(names[2] as HTMLInputElement, { target: { value: 'Private Preview' } }) + fireEvent.change(windows[2] as HTMLInputElement, { target: { value: '131072' } }) + fireEvent.click(screen.getByText(en.apply)) + + await waitFor(() => { expect(mutate).toHaveBeenCalledTimes(1) }) + expect(mutate.mock.calls[0]?.[0]).toEqual({ + ns: 'llm-deepseek', + ops: [{ + op: 'set', + path: ['models'], + value: [ + ...DEFAULT_DEEPSEEK_MODELS, + { id: 'private-preview', name: 'Private Preview', contextWindow: 131_072 }, + ], + }], + expectedRevision: 0, + }) + }) + + it('rejects duplicate DeepSeek model ids before writing', async () => { + const { mutate } = await mountSection() + fireEvent.click(screen.getByText(en.customized)) + fireEvent.click(screen.getByText(en.addModel)) + const ids = screen.getAllByLabelText(new RegExp(en.modelId)) + fireEvent.change(ids[2] as HTMLInputElement, { target: { value: 'deepseek-v4-flash' } }) + fireEvent.click(screen.getByText(en.apply)) + + await screen.findByText(`Model 3: ${en.modelIdDuplicate}`) + expect(mutate).not.toHaveBeenCalled() + }) + + it('validates every adapter-owned model catalog invariant', () => { + expect(modelDrafts(undefined)).toEqual([]) + expect(modelDrafts([null, 'bad', { id: 'ok' }])).toEqual([{}, {}, { id: 'ok' }]) + expect(validateDeepSeekModels([{}])).toEqual({ index: 0, key: 'modelIdRequired' }) + expect(validateDeepSeekModels([{ id: 'same' }, { id: 'same' }])) + .toEqual({ index: 1, key: 'modelIdDuplicate' }) + expect(validateDeepSeekModels([{ id: 'model', name: '' }])) + .toEqual({ index: 0, key: 'modelNameInvalid' }) + expect(validateDeepSeekModels([{ id: 'model', contextWindow: null }])) + .toEqual({ index: 0, key: 'modelContextInvalid' }) + expect(validateDeepSeekModels([{ id: 'model', contextWindow: 1.5 }])) + .toEqual({ index: 0, key: 'modelContextInvalid' }) + expect(validateDeepSeekModels([{ id: 'model', contextWindow: 0 }])) + .toEqual({ index: 0, key: 'modelContextInvalid' }) + expect(validateDeepSeekModels([{ id: 'model', contextWindow: 1 }])).toBeUndefined() + }) + + it('renders malformed draft fallbacks without inventing catalog values', () => { + render() + expect(screen.getByLabelText(`${en.modelId} 1`).value).toBe('') + expect(screen.getByLabelText(`${en.contextWindow} 1`).placeholder) + .toBe(en.contextWindowPlaceholder) + }) + + it('can empty and reset the model override, then clear optional fields without dropping hidden data', async () => { + const { mutate } = await mountSection({ + mutate: vi.fn(() => Promise.resolve(ok(wireNamespaces()[0]))), + }) + fireEvent.click(screen.getByText(en.customized)) + fireEvent.click(screen.getAllByText(en.removeModel)[0] as HTMLElement) + fireEvent.click(screen.getByText(en.removeModel)) + expect(screen.getByText(en.modelsEmpty)).toBeTruthy() + fireEvent.click(screen.getByText(en.resetModels)) + expect(screen.getByText(en.modelsInherited)).toBeTruthy() + + const names = screen.getAllByLabelText(new RegExp(en.modelName)) + const windows = screen.getAllByLabelText(new RegExp(en.contextWindow)) + fireEvent.change(names[0] as HTMLInputElement, { target: { value: '' } }) + fireEvent.change(windows[0] as HTMLInputElement, { target: { value: '' } }) + fireEvent.click(screen.getByText(en.apply)) + + await waitFor(() => { expect(mutate).toHaveBeenCalledTimes(1) }) + expect(mutate.mock.calls[0]?.[0]).toEqual({ + ns: 'llm-deepseek', + ops: [{ + op: 'set', + path: ['models'], + value: [ + { id: 'deepseek-v4-flash', description: 'Preserved hidden detail' }, + DEFAULT_DEEPSEEK_MODELS[1], + ], + }], + expectedRevision: 0, + }) + }) + it('clears an inherited override with an unset op, never a whole-section replace', async () => { // The data-loss shape: the old path rebuilt the section from the REDACTED // user layer and replaced it wholesale, deleting any stored literal key. @@ -333,7 +468,7 @@ describe('ModelsSection', () => { it('adds a dormant provider with a derived reference and stores its key', async () => { const { mutate, set } = await mountSection() - fireEvent.click(screen.getByText(`+ ${en.add}`)) + fireEvent.click(screen.getByText(en.add)) const pick = await screen.findByLabelText(en.provider) expect([...pick.options].map(option => option.value)).toEqual(['anthropic', 'broken', 'plain']) expect(pick.value).toBe('anthropic') @@ -357,7 +492,7 @@ describe('ModelsSection', () => { it('switches the add card target and degrades unknown or broken targets loudly', async () => { await mountSection() - fireEvent.click(screen.getByText(`+ ${en.add}`)) + fireEvent.click(screen.getByText(en.add)) const pick = await screen.findByLabelText(en.provider) fireEvent.change(pick, { target: { value: 'broken' } }) await screen.findByText(/unresolvable settings path/) @@ -375,7 +510,7 @@ describe('ModelsSection', () => { const { set } = await mountSection({ mutate: vi.fn(() => Promise.resolve(fail('llm-pi-ai: unknown pi-ai provider "bogus"'))), }) - fireEvent.click(screen.getByText(`+ ${en.add}`)) + 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' } }) @@ -515,7 +650,7 @@ describe('ModelsSection', () => { />) expect(screen.getByText(en.readOnly)).toBeTruthy() expect(screen.getAllByText(en.remove).every(button => button.disabled)).toBe(true) - expect(screen.getByText(`+ ${en.add}`).disabled).toBe(true) + expect(screen.getByText(en.add).disabled).toBe(true) }) it('toggles the row editor closed on a second edit click and on cancel', async () => { @@ -534,10 +669,10 @@ describe('ModelsSection', () => { it('cancels the add card back to the add button', async () => { await mountSection() - fireEvent.click(screen.getByText(`+ ${en.add}`)) + fireEvent.click(screen.getByText(en.add)) await screen.findByLabelText(en.provider) fireEvent.click(screen.getAllByText(en.cancel)[1] as HTMLElement) - await screen.findByText(`+ ${en.add}`) + await screen.findByText(en.add) expect(screen.queryByLabelText(en.provider)).toBeNull() }) diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml index 02cf787a27..a962d9dfeb 100644 --- a/packages/host/apiproxy/README.i18n.yaml +++ b/packages/host/apiproxy/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/host/apiproxy/README.md -README.md: 73d8afb32f868ca82dfa2d350df089a5d0b9b358 -README.zh.md: 47af18f76302e261e18f682e0d3cf0ee903933db +README.md: e57ea657c5432612ef024fc585febc0896a3ab43 +README.zh.md: 57d2604dedbd86102ab5ef9fbe156792aeb5905f diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index 73d8afb32f..e57ea657c5 100644 --- a/packages/host/apiproxy/README.md +++ b/packages/host/apiproxy/README.md @@ -18,7 +18,7 @@ Session titles ride the generic projection pair like every other domain — the `session.fork` maps an optional event anchor to the first `turn/end` at or after it, letting a message action include that message's whole turn. An omitted or past-end anchor selects the last completed turn; an in-log anchor whose turn remains open returns `fork-unavailable` rather than clipping backward. The published child inherits the source's seeded history, cwd, latest logged provider/model/reasoning target, and lineage before joining the source Workspace. If Workspace attachment fails, `workspace-attach-failed` carries the already-published child id so clients can reconcile it. The [SessionStore fork decision](../../../.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.md) owns the boundary rationale. -Session model routing is a session-domain contract. `session.models` returns the selected provider/model/reasoning target with provider-grouped advisory models, exact-route reasoning metadata, and provider-local lookup failures. `session.selectModel` validates the optional adapter-owned reasoning effort and replaces the complete target selected for the next prompt-assembly boundary. Catalog membership is not validation: an adapter may resolve an unlisted model, while an unavailable route or unsupported effort returns `model-unavailable`. +Session model routing is a session-domain contract. `session.models` returns the selected provider/model/reasoning target separately from provider-grouped advisory models, exact-route reasoning metadata, and provider-local lookup failures. The current target may be absent from the groups and is never injected as a synthetic row; clients can prompt for a replacement without turning the directory into a routing whitelist. `session.selectModel` validates the optional adapter-owned reasoning effort and replaces the complete target selected for the next prompt-assembly boundary. Catalog membership is not validation: an adapter may resolve an unlisted model, while an unavailable route or unsupported effort returns `model-unavailable`. Pending queued input is a live control-plane contract, not session history. The gateway mirrors queued `InboxItem` occurrences from `agent/inbox/*` and broadcasts authoritative `session/queue` snapshots on every queued change and reconnect; pending steering stays outside this Web projection. `session.updateQueue` addresses one `InboxItemId`: edit replaces pending content and remove discards it. A driver claim wins races by retiring the address before admission; a later operation returns `queue-item-not-found`. The operation queries only an attached Agent and never resumes a cold session because process-local inbox identities do not survive restart or disposal. The client never infers retirement from turn or status events. diff --git a/packages/host/apiproxy/README.zh.md b/packages/host/apiproxy/README.zh.md index 47af18f763..57d2604ded 100644 --- a/packages/host/apiproxy/README.zh.md +++ b/packages/host/apiproxy/README.zh.md @@ -18,7 +18,7 @@ `session.fork` 将可选事件锚点映射到该锚点处或其后的首个 `turn/end`,使消息操作可包含该消息所在的完整轮次。锚点省略或超过末尾时,选择最后一个已完成轮次;若锚点已在日志中,而其所在轮次仍开放,则返回 `fork-unavailable`,不会向较早位置裁剪。发布后的子会话会先继承源会话的种子历史、cwd、日志中最新的提供方/模型/推理(reasoning)目标及谱系,再加入源 Workspace。如果附加到 Workspace 失败,`workspace-attach-failed` 会携带已发布的子会话 id,供客户端对账。[SessionStore fork 决策](../../../.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.md)给出边界设计的理由。 -会话模型路由属于会话领域契约。`session.models` 返回选中的提供方/模型/推理目标,以及按提供方分组的建议性模型、精确路由推理元数据和逐提供方查询失败记录。`session.selectModel` 校验由适配器持有的可选推理强度,并替换将在下一提示词组装边界使用的完整目标。目录成员关系不构成校验:适配器可以解析未列出的模型,而不可用路由或不受支持的推理强度会返回 `model-unavailable`。 +会话模型路由属于会话领域契约。`session.models` 将选中的提供方/模型/推理目标,与按提供方分组的建议性模型、精确路由推理元数据和逐提供方查询失败记录分开返回。当前目标可能不在这些分组中,也绝不会作为合成行注入;客户端可以提示用户选择替代目标,而无需把目录变成路由白名单。`session.selectModel` 校验由适配器持有的可选推理强度,并替换将在下一提示词组装边界使用的完整目标。目录成员关系不构成校验:适配器可以解析未列出的模型,而不可用路由或不受支持的推理强度会返回 `model-unavailable`。 待处理的 queued 输入属于实时控制平面契约,而非会话历史。网关镜像来自 `agent/inbox/*` 的 queued `InboxItem` 入队项,并在每次 queued 变更和重连时广播权威的 `session/queue` 快照;待处理 steering(中途引导)不进入此 Web 投影。`session.updateQueue` 通过 `InboxItemId` 寻址单个项:编辑会替换待处理内容,移除会将其丢弃。驱动器在接纳前退役寻址标识,因此认领会赢得竞态;之后的操作返回 `queue-item-not-found`。该操作只查询当前已挂载的 Agent,绝不恢复冷会话,因为进程本地 inbox 标识无法在重启或资源释放后存活。客户端绝不根据轮次或状态事件推断项已退役。 diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index e224608c72..018f1ea053 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -126,30 +126,19 @@ function ok(request: RpcRequest, value: T): RpcResponse { /** * Build the provider/model catalog over every registered route. Shared by the - * session-scoped `session.models` (which passes the session's current target - * so an unlisted current model still renders selectable) and the host-scoped - * `llm.models` (no current). Per-provider failures ride `failures` without - * failing the sound groups; groups that advertise nothing are dropped. + * session-scoped `session.models` and host-scoped `llm.models`. Catalog + * membership stays advisory: an unlisted session target remains valid for + * provider dispatch, but is not injected back into the selector after its + * owning catalog stops advertising it. Per-provider failures ride `failures` + * without failing the sound groups; groups that advertise nothing are dropped. */ -async function buildModelCatalog( - ctx: Context, - current?: { provider: string; model: string }, -): Promise<{ groups: ModelProviderGroup[]; failures: ModelCatalogFailure[] }> { +async function buildModelCatalog(ctx: Context): Promise<{ + groups: ModelProviderGroup[] + failures: ModelCatalogFailure[] +}> { const catalog = await Promise.all(ctx.llm.listProviders().map(async (provider) => { try { - const advertised = await ctx.llm.listModels(provider.id) - const models = [...advertised] - if ( - current !== undefined - && provider.id === current.provider - && !models.some(model => model.id === current.model) - ) { - models.push({ - provider: provider.id, - id: current.model, - name: current.model, - }) - } + const models = await ctx.llm.listModels(provider.id) const entries = await Promise.all(models.map(async (model) => { const resolved = await ctx.llm.resolveModelInfo(provider.id, model.id) const reasoning: ModelReasoning | undefined = resolved.reasoning === undefined @@ -170,12 +159,6 @@ async function buildModelCatalog( id: model.id, name: model.name, ...model.description === undefined ? {} : { description: model.description }, - ...current !== undefined - && provider.id === current.provider - && model.id === current.model - && !advertised.some(candidate => candidate.id === current.model) - ? { unlisted: true as const } - : {}, ...reasoning === undefined ? {} : { reasoning }, } })) @@ -1394,7 +1377,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro const found = await agentFor(sessionId) if ('error' in found) return err(request, found.error) const current = targetFor(found.agent).current - const { groups, failures } = await buildModelCatalog(ctx, current) + const { groups, failures } = await buildModelCatalog(ctx) return ok(request, { current: { ...current }, groups, failures }) }, diff --git a/packages/host/apiproxy/src/api/llm.ts b/packages/host/apiproxy/src/api/llm.ts index 59a21cf12a..a62319fd62 100644 --- a/packages/host/apiproxy/src/api/llm.ts +++ b/packages/host/apiproxy/src/api/llm.ts @@ -3,8 +3,8 @@ * surfaces. `llm.providers` merges the configurable-provider directory * (which providers CAN be configured, and where their settings live) with the * live route registry; `llm.models` is the session-independent model catalog - * (`session.models` minus the per-session current/unlisted logic). Both - * invalidate on the `host/models-changed` frame. + * (the same groups as `session.models`, without the per-session current + * target). Both invalidate on the `host/models-changed` frame. */ import type { RpcRequest, RpcResponse } from './rpc.ts' diff --git a/packages/host/apiproxy/src/api/sessions.schema.ts b/packages/host/apiproxy/src/api/sessions.schema.ts index bb044d4428..5189a6a9ec 100644 --- a/packages/host/apiproxy/src/api/sessions.schema.ts +++ b/packages/host/apiproxy/src/api/sessions.schema.ts @@ -164,7 +164,6 @@ export const modelCatalogModelSchema = z.object({ id: z.string().min(1), name: z.string().min(1), description: z.string().optional(), - unlisted: z.literal(true).optional(), reasoning: modelReasoningSchema.optional(), }) satisfies z.ZodType> diff --git a/packages/host/apiproxy/src/api/sessions.ts b/packages/host/apiproxy/src/api/sessions.ts index e6f93c0dae..0372fe3283 100644 --- a/packages/host/apiproxy/src/api/sessions.ts +++ b/packages/host/apiproxy/src/api/sessions.ts @@ -89,8 +89,6 @@ export interface ModelCatalogModel { name: string /** Optional provider-supplied description. */ description?: string - /** The current model was inserted because the advisory catalog omitted it. */ - unlisted?: true /** Exact-route reasoning metadata when the adapter exposes it. */ reasoning?: ModelReasoning } diff --git a/packages/host/apiproxy/tests/api-proxy-models.spec.ts b/packages/host/apiproxy/tests/api-proxy-models.spec.ts index 5197d96436..2a4754f144 100644 --- a/packages/host/apiproxy/tests/api-proxy-models.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-models.spec.ts @@ -1,7 +1,8 @@ /** * Web session model-directory and selection behavior: dynamic provider grouping, - * provider-local catalog failures, logged-target restoration, advisory unlisted - * models, and the prompt-assembly boundary for a running selection change. + * provider-local catalog failures, logged-target restoration without stale + * catalog injection, advisory pass-through models, and the prompt-assembly + * boundary for a running selection change. */ import { describe, expect, it } from 'vitest' @@ -118,7 +119,7 @@ function expectValue(response: { result: { ok: true; value: T } | { ok: false } describe('Web session model selection', () => { - it('groups successful providers, isolates failures, and preserves an unlisted current model', async () => { + it('groups successful providers and leaves an unlisted current target out of the catalog', async () => { const { ctx, sessionId } = await harness({ provider: 'deepseek-official', model: 'private-preview', @@ -143,12 +144,6 @@ describe('Web session model selection', () => { description: 'Reasoning model', reasoning: REASONING, }, - { - id: 'private-preview', - name: 'private-preview', - unlisted: true, - reasoning: REASONING, - }, ], }]) expect(catalog.failures).toEqual([ diff --git a/packages/host/apiproxy/tests/rpc-schemas.spec.ts b/packages/host/apiproxy/tests/rpc-schemas.spec.ts index aa9c46d9ae..fa6b5abe8c 100644 --- a/packages/host/apiproxy/tests/rpc-schemas.spec.ts +++ b/packages/host/apiproxy/tests/rpc-schemas.spec.ts @@ -203,7 +203,6 @@ describe('sessions domain schemas', () => { id: 'deepseek-v4-flash', name: 'DeepSeek V4 Flash', description: 'fast', - unlisted: true, reasoning: { efforts: [ { id: 'off', name: 'Off' }, From 935578ed9878d5cc8388204c7344cabe181bfaa6 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Fri, 31 Jul 2026 14:36:16 +0800 Subject: [PATCH 02/29] feat(web): accept K and M suffixes in the context window field MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The catalog's context window is now a text field that reads a decimal K or M suffix — 1M is 1000K, matching how model capacities are quoted — and stores the plain token count, so settings.yaml and the adapter are unchanged. A stored count reads back in the shortest form that round-trips: 1000000 as 1M, 256000 as 256K, and 131072 written out, because it is not a whole number of thousands. The field holds the typed text while its row has focus, since re-deriving it from the parsed count on every keystroke would rewrite 1000 to 1K mid-word; text that does not parse stays on screen so the save-time rejection names a row the user can still see and correct. --- .../2026-07-30-web-config-plane.i18n.yaml | 4 +- .../2026-07-30-web-config-plane.md | 2 +- .../2026-07-30-web-config-plane.zh.md | 2 +- .../models.expected.md | 10 ++- 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/DeepSeekModelsEditor.tsx | 81 ++++++++++++++--- .../client/ui-models/src/client/locales.ts | 4 +- .../ui-models/tests/components.spec.tsx | 90 ++++++++++++++++++- 10 files changed, 177 insertions(+), 24 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.i18n.yaml index 2421df88f6..32137fe409 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.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/architecture/2026-07-30-web-config-plane.md -2026-07-30-web-config-plane.md: 0e457b6d712cf9e7e2b005f61b97c580a0f2597e -2026-07-30-web-config-plane.zh.md: 4b2a0bd87040e60bc6602c48482bc936e90011ec +2026-07-30-web-config-plane.md: a4d474d450009b3bcf929eaea870e045602268ac +2026-07-30-web-config-plane.zh.md: db7201408f46a1e68b4359346d1c74b8d728a0d3 diff --git a/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md b/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md index 0e457b6d71..a4d474d450 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md +++ b/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md @@ -18,7 +18,7 @@ PR1 made LLM adapter configuration restart-free at the seam, but the only writer **The llm seam declares configurability and announces topology.** `registerConfigurableProviders()` is an all-or-nothing, fiber-scoped directory of `{provider, displayName, settingsNs, settingsPath}` — the addressing a config page needs to open the right settings subtree for a route that may not exist yet; `listConfigurableProviders()` merges with live routes in the wire handler so undeclared live routes still report active. The zero-payload `'llm/adapters-updated'` event fires from all four registration/unregistration commit points with contained listener dispatch (INVARIANT rethrow), following the settings/commands precedent. `llm-deepseek`'s route renamed to `deepseek-official` because the pi-ai catalog legitimately owns `deepseek` as an aggregator entry; pre-release stance, no alias. -**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, `reasoningEffort` for deepseek / `reasoning` for pi-ai, plus direct DeepSeek model rows with `id`, `name`, and `contextWindow`). Existing model fields outside that visible set survive array edits; retry policy, timeouts, and other fields remain owned by `settings.yaml`. Validation still runs the rehydrated schema before writing, while adapter-specific checks reject catalog invariants that the serialized schema cannot express. The card's colors resolve through the `--dsw-alias-*` design tokens; it had named `--border`/`--surface`/`--text-*`, which nothing in this app defines, so it rendered their light-mode fallbacks and stayed light under the dark theme. The model catalog is one caption strip over a row of `id`/`name`/`contextWindow` fields per model rather than a labelled card each; every field keeps the indexed `aria-label` that names it, and the captions are hidden from assistive tech so that name is not announced twice. +**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, `reasoningEffort` for deepseek / `reasoning` for pi-ai, plus direct DeepSeek model rows with `id`, `name`, and `contextWindow`). Existing model fields outside that visible set survive array edits; retry policy, timeouts, and other fields remain owned by `settings.yaml`. Validation still runs the rehydrated schema before writing, while adapter-specific checks reject catalog invariants that the serialized schema cannot express. The card's colors resolve through the `--dsw-alias-*` design tokens; it had named `--border`/`--surface`/`--text-*`, which nothing in this app defines, so it rendered their light-mode fallbacks and stayed light under the dark theme. The model catalog is one caption strip over a row of `id`/`name`/`contextWindow` fields per model rather than a labelled card each; every field keeps the indexed `aria-label` that names it, and the captions are hidden from assistive tech so that name is not announced twice. The context window is a text field reading a decimal `K`/`M` suffix (`1M` is 1000K, matching how capacities are quoted) and storing the plain count: the field holds the typed text while the row has focus, because re-deriving it from the parsed count on every keystroke would rewrite `1000` to `1K` mid-word, and text that does not parse stays on screen so the save-time rejection names a row the user can still see. **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 `_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. DeepSeek's model list is array-replace configuration: inherited effective rows remain visible until the first edit materializes the complete list in the user layer, and reset unsets the list override. diff --git a/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.zh.md b/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.zh.md index 4b2a0bd870..db7201408f 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.zh.md @@ -18,7 +18,7 @@ PR1 让 LLM(大语言模型)适配器配置在 seam 层面免重启,但唯 **llm seam 声明可配置性并公布拓扑。**`registerConfigurableProviders()` 是一个全有或全无、以 fiber 为作用域的目录,条目为 `{provider, displayName, settingsNs, settingsPath}`——这正是配置页要为一条可能尚不存在的路由打开正确设置子树时所需要的寻址;`listConfigurableProviders()` 在 wire 处理器里与存活路由合并,未声明的存活路由因此仍报告为激活。零负载的 `'llm/adapters-updated'` 事件从全部四个注册/注销提交点触发,listener 派发带异常隔离(INVARIANT 重抛),沿用 settings/commands 的先例。`llm-deepseek` 的路由重命名为 `deepseek-official`,因为 pi-ai catalog 名正言顺地拥有 `deepseek` 这个聚合器条目;依预发布立场,不设别名。 -**架在 schema 模型层之上的手写编辑器。**`dsh-client-schema-form` 把 wire 的 `toJSON()` 信封还原(rehydrate)为活的 schemastery 节点,用于校验、路径解析与不可变草稿编辑——但不做通用渲染:第一版交付了完整的 schema 驱动表单渲染器,得到的却是一个未加样式、把 schema 原样倾倒出来的页面(每个进阶字段都平铺到卡片上、原始字段名直接充当标签、`retryPolicy` 的「不支持」回退落在主流程里)。用户没有再加一套提示/分组系统,而是选择了手写方向,第二轮又把引用输入框整个移除:卡片的主字段是一个 **API 密钥**输入框,未配置密钥的整分节提供方会以其设置卡片的形式打开,收起的「自定义设置」折叠区承载按家族精选的额外字段(两个家族都有 `baseURL`,deepseek 有 `reasoningEffort`/pi-ai 有 `reasoning`,另有直接 DeepSeek 模型行的 `id`、`name` 和 `contextWindow`)。现有模型字段中不在可见集合内的部分会在数组编辑后保留;重试策略、超时及其他字段仍归 `settings.yaml` 所有。校验仍会在写入前运行还原出的 schema,适配器特有的检查则会拒绝序列化 schema 无法表达的目录不变量。卡片的颜色经 `--dsw-alias-*` 设计 token 解析;它此前引用的 `--border`/`--surface`/`--text-*` 在本应用中无人定义,于是渲染出的是它们的亮色模式回退值,在暗色主题下依旧保持亮色。模型目录是一条列名说明行,其下每个模型占一行 `id`/`name`/`contextWindow` 字段,而不是每个模型各一张带标签的卡片;每个字段都保留那个为其命名的带序号 `aria-label`,列名则对辅助技术隐藏,以免该名称被播报两次。 +**架在 schema 模型层之上的手写编辑器。**`dsh-client-schema-form` 把 wire 的 `toJSON()` 信封还原(rehydrate)为活的 schemastery 节点,用于校验、路径解析与不可变草稿编辑——但不做通用渲染:第一版交付了完整的 schema 驱动表单渲染器,得到的却是一个未加样式、把 schema 原样倾倒出来的页面(每个进阶字段都平铺到卡片上、原始字段名直接充当标签、`retryPolicy` 的「不支持」回退落在主流程里)。用户没有再加一套提示/分组系统,而是选择了手写方向,第二轮又把引用输入框整个移除:卡片的主字段是一个 **API 密钥**输入框,未配置密钥的整分节提供方会以其设置卡片的形式打开,收起的「自定义设置」折叠区承载按家族精选的额外字段(两个家族都有 `baseURL`,deepseek 有 `reasoningEffort`/pi-ai 有 `reasoning`,另有直接 DeepSeek 模型行的 `id`、`name` 和 `contextWindow`)。现有模型字段中不在可见集合内的部分会在数组编辑后保留;重试策略、超时及其他字段仍归 `settings.yaml` 所有。校验仍会在写入前运行还原出的 schema,适配器特有的检查则会拒绝序列化 schema 无法表达的目录不变量。卡片的颜色经 `--dsw-alias-*` 设计 token 解析;它此前引用的 `--border`/`--surface`/`--text-*` 在本应用中无人定义,于是渲染出的是它们的亮色模式回退值,在暗色主题下依旧保持亮色。模型目录是一条列名说明行,其下每个模型占一行 `id`/`name`/`contextWindow` 字段,而不是每个模型各一张带标签的卡片;每个字段都保留那个为其命名的带序号 `aria-label`,列名则对辅助技术隐藏,以免该名称被播报两次。上下文窗口是一个文本输入框,读取十进制的 `K`/`M` 后缀(`1M` 即 1000K,与容量的通行标注方式一致)并存储纯数值:该行持有焦点期间,字段保留键入的文本,因为若每次按键都从解析出的数值重新推导该文本,`1000` 会在尚未输完时就被改写成 `1K`;无法解析的文本也会留在屏幕上,因此保存时的拒绝点名的是用户仍能看见的那一行。 **Models 页是一次三领域联接,应用语义与 seam 同形。**每一行是一个已配置的提供方;「新增」卡片的选择框是可配置提供方目录中剩余的休眠条目。路由存活状态仍用于就绪判定,并会使该联接失效,但页面不将其渲染为提供方状态,因为配置存在与运行时可用性是两个不同概念。密钥通道保持引用形态,却从不展示任何引用:键入的密钥经 `credentials.set` **只写**存入 profile 的 `apiKeyEnv` 之下,引用不存在时便派生 `_API_KEY`(pi-ai profile 会记录该派生),因此 `settings.yaml` 从不携带密钥值。profile 的编辑和删除会针对脱敏后的用户分节,以按路径寻址的最小 `settings.mutate` 操作落地,绝不会点名页面未收到的机密。删除用户层提供方时,会先打开本地化的模型提供方确认对话框;取消操作、关闭按钮和遮罩均不会改动 profile,而破坏性确认会提交唯一一条 unset,并在其完成前阻止重复提交。DeepSeek 的模型列表是数组替换配置:继承而来的生效模型行会一直显示,直到第一次编辑将完整列表具化到用户层;重置则会取消设置该列表覆盖。 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 31b6c7b1b4..4a709fdf5a 100644 --- a/apps/web/tests/snapshots/onboarding-deepseek-config/models.expected.md +++ b/apps/web/tests/snapshots/onboarding-deepseek-config/models.expected.md @@ -14,7 +14,7 @@ - paragraph: 填入各提供方的 API 密钥即可使用其模型。 - list: - listitem: - - text: DeepSeek 已启用 + - text: DeepSeek - button "编辑" - text: DeepSeek deepseek-official API 密钥 - textbox "API 密钥": @@ -36,7 +36,9 @@ - textbox "显示名称 1": - /placeholder: 留空时使用模型 ID - text: DeepSeek-V4-Pro - - spinbutton "上下文窗口 1": "1000000" + - textbox "上下文窗口 1": + - /placeholder: 1M + - text: 1M - button "删除模型": - img - text: 删除模型 @@ -44,7 +46,9 @@ - textbox "显示名称 2": - /placeholder: 留空时使用模型 ID - text: Private Preview - - spinbutton "上下文窗口 2": "131072" + - textbox "上下文窗口 2": + - /placeholder: 1M + - text: "131072" - button "删除模型": - img - text: 删除模型 diff --git a/packages/client/ui-models/README.i18n.yaml b/packages/client/ui-models/README.i18n.yaml index 8f7f098dfc..7b4f1ef045 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: 97dd6cca4ba1715dc75211929c47fc6f3e632d78 -README.zh.md: d142e9a94c86a4df3f49ad2ee210e24c267f92e0 +README.md: a1d5233eb47043f86d3ae562419ac9ec22404d60 +README.zh.md: 0a5f3fd97f1d54c91d1305ec7b759c5f2934d961 diff --git a/packages/client/ui-models/README.md b/packages/client/ui-models/README.md index 97dd6cca4b..a1d5233eb4 100644 --- a/packages/client/ui-models/README.md +++ b/packages/client/ui-models/README.md @@ -8,7 +8,7 @@ Rows are the *configured* providers (their profile resolves in the owning namesp 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. -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 names the fields it can see rather than rebuilding a section: a stored literal secret it never received is mentioned by no op and survives. 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. Empty ids, duplicate ids, empty explicit names, and non-positive or fractional context windows fail before any write. Each write carries the `revision` the card opened at, so a concurrent write from another tab or an external `settings.yaml` edit is refused as `settings-conflict` and the card asks the user to reopen instead of replaying its stale snapshot. The page refetches on the pushed invalidations (`settings/changed`, `credentials/changed`, `models/changed`, and `connection/reset`) once it has loaded, so an external `settings.yaml` edit, a second tab, or a settings-born route converges without polling. +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 names the fields it can see rather than rebuilding a section: a stored literal secret it never received is mentioned by no op and survives. 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 context window 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 context windows fail before any write. Each write carries the `revision` the card opened at, so a concurrent write from another tab or an external `settings.yaml` edit is refused as `settings-conflict` and the card asks the user to reopen instead of replaying its stale snapshot. The page refetches on the pushed invalidations (`settings/changed`, `credentials/changed`, `models/changed`, and `connection/reset`) once it has loaded, so an external `settings.yaml` edit, a second tab, or a settings-born route converges without polling. ## Model Experience diff --git a/packages/client/ui-models/README.zh.md b/packages/client/ui-models/README.zh.md index d142e9a94c..0a5f3fd97f 100644 --- a/packages/client/ui-models/README.zh.md +++ b/packages/client/ui-models/README.zh.md @@ -8,7 +8,7 @@ 首次使用浮层从同一个联接快照得出 `deepseek-official` 的就绪状态。它通过 `llm-deepseek` 的可配置提供方声明识别官方适配器,因此不会把同一提供方 ID 下没有相应声明的存活路由视为可通过配置修复。若 `apiKey` 字面量对应的 secret 槽位标记为已设置,或凭据引用已配置,浮层就不再显示,其中包括来自启动环境且只读的凭据。只有适配器已挂载、引用可写但尚未配置时,浮层才显示一个操作按钮,用于打开「设置」的 Models 分区;密钥输入和 `credentials.set` 仅由该分区已有的设置卡片负责,浮层绝不持有 secret。适配器缺失、路由未激活、联接失败、部署只读、设置能力不可用或凭据能力不可用时均跳过,以免首次使用引导阻塞产品的其他部分;Models 页仍是诊断界面。 -每一次编辑都以 `settings.mutate` 的路径 op 落到已存分节上——每个变更字段一条 set、每个清空字段一条 unset、删除提供方行则是单独一条 unset。页面自始至终只持有**脱敏后**的 descriptor,因此它点名自己看得见的字段,而不是重建分节:一个它从未收到过的已存字面机密不会被任何 op 提及,也就得以留存。DeepSeek 的 `models` 是一个按值整体替换的数组:编辑器会显示继承而来的生效模型行,直到第一次模型编辑将完整数组具化到用户层;重置则会取消该覆盖。空 ID、重复 ID、显式填写的空名称,以及非正数或非整数的上下文窗口都会在写入前失败。每次写入都携带该卡片打开时的 `revision`,因此来自另一个标签页或对 `settings.yaml` 的外部编辑所产生的并发写入会以 `settings-conflict` 被拒绝,卡片会请用户重新打开,而不是把自己的陈旧快照重放上去。页面加载完成后会在推送的失效事件(`settings/changed`、`credentials/changed`、`models/changed` 与 `connection/reset`)上重拉,因此外部的 `settings.yaml` 编辑、第二个标签页或 settings 新生的路由都无需轮询即可收敛。 +每一次编辑都以 `settings.mutate` 的路径 op 落到已存分节上——每个变更字段一条 set、每个清空字段一条 unset、删除提供方行则是单独一条 unset。页面自始至终只持有**脱敏后**的 descriptor,因此它点名自己看得见的字段,而不是重建分节:一个它从未收到过的已存字面机密不会被任何 op 提及,也就得以留存。DeepSeek 的 `models` 是一个按值整体替换的数组:编辑器会显示继承而来的生效模型行,直到第一次模型编辑将完整数组具化到用户层;重置则会取消该覆盖。上下文窗口按数值键入,可带十进制的 `K` 或 `M` 后缀(`256K`、`1M`;`1M` 即 1000K),存储为纯数值,回显时写成能够往返的最短形式。空 ID、重复 ID、显式填写的空名称,以及无法读取、非正数或非整数的上下文窗口都会在写入前失败。每次写入都携带该卡片打开时的 `revision`,因此来自另一个标签页或对 `settings.yaml` 的外部编辑所产生的并发写入会以 `settings-conflict` 被拒绝,卡片会请用户重新打开,而不是把自己的陈旧快照重放上去。页面加载完成后会在推送的失效事件(`settings/changed`、`credentials/changed`、`models/changed` 与 `connection/reset`)上重拉,因此外部的 `settings.yaml` 编辑、第二个标签页或 settings 新生的路由都无需轮询即可收敛。 ## 模型体验 diff --git a/packages/client/ui-models/src/client/DeepSeekModelsEditor.tsx b/packages/client/ui-models/src/client/DeepSeekModelsEditor.tsx index c79eb57412..d80ab9f741 100644 --- a/packages/client/ui-models/src/client/DeepSeekModelsEditor.tsx +++ b/packages/client/ui-models/src/client/DeepSeekModelsEditor.tsx @@ -5,6 +5,7 @@ * override; reset removes that override instead of copying defaults into it. */ +import { useState } from 'react' import type { ReactNode } from 'react' import { IconPlusOutline16, IconTrashOutline16 } from '@deepseek-ai/dsh-client-ui-primitives' import type { en } from './locales.ts' @@ -13,6 +14,47 @@ import styles from './ModelsSection.module.css' /** One catalog entry kept structurally open so hidden or future fields survive an edit. */ export type DeepSeekModelDraft = Record +/** Accepted context-window spellings: a decimal count with an optional K/M suffix. */ +const CONTEXT_WINDOW_PATTERN = /^(\d+(?:\.\d+)?)([km])?$/i + +/** Decimal suffix scales — `1M` is 1000K, matching how model capacities are quoted. */ +const CONTEXT_WINDOW_SCALE = { k: 1_000, m: 1_000_000 } as const + +/** + * Read a typed context window, so a user can write `256K` or `1M` instead of + * counting zeroes. The stored value stays a plain token count. + * @param text - raw field text. + * @returns the count; `undefined` when blank (inherit), `NaN` when unreadable + * (rejected by {@link validateDeepSeekModels} before any write). + */ +export function parseContextWindow(text: string): number | undefined { + const trimmed = text.trim() + if (trimmed.length === 0) return undefined + const match = CONTEXT_WINDOW_PATTERN.exec(trimmed) + if (match === null) return Number.NaN + const suffix = match[2]?.toLowerCase() + const scale = suffix === 'k' || suffix === 'm' ? CONTEXT_WINDOW_SCALE[suffix] : 1 + const scaled = Number(match[1]) * scale + // A decimal multiple is exact in intent but not in binary floating point + // (2.3 * 1e6 lands a few ULPs high), so an integral intent snaps back. + const rounded = Math.round(scaled) + return Math.abs(scaled - rounded) < 1e-6 ? rounded : scaled +} + +/** + * Spell a stored count back in the shortest form that survives a round trip + * through {@link parseContextWindow}; a count that is not a whole number of + * thousands stays written out. + * @param value - stored context window. + * @returns the field text. + */ +export function formatContextWindow(value: number): string { + if (!Number.isInteger(value) || value <= 0) return String(value) + if (value % CONTEXT_WINDOW_SCALE.m === 0) return `${String(value / CONTEXT_WINDOW_SCALE.m)}M` + if (value % CONTEXT_WINDOW_SCALE.k === 0) return `${String(value / CONTEXT_WINDOW_SCALE.k)}K` + return String(value) +} + /** A localized validation failure for one user-owned model array. */ export interface DeepSeekModelsValidationFailure { /** Zero-based model position. */ @@ -81,6 +123,11 @@ export interface DeepSeekModelsEditorProps { * @returns the catalog editor. */ export function DeepSeekModelsEditor(props: DeepSeekModelsEditorProps): ReactNode { + // The context-window field is edited as text, so the keystrokes are held + // here while one row has focus: re-deriving the text from the parsed count + // on every change would rewrite `1000` to `1K` mid-word. + const [editing, setEditing] = useState<{ index: number; text: string } | undefined>(undefined) + const update = (index: number, key: 'id' | 'name' | 'contextWindow', value: unknown): void => { const next = props.models.map((model, at) => { const copy = { ...model } @@ -93,9 +140,27 @@ export function DeepSeekModelsEditor(props: DeepSeekModelsEditorProps): ReactNod } const remove = (index: number): void => { + setEditing(undefined) props.onChange(props.models.filter((_model, at) => at !== index).map(model => ({ ...model }))) } + /** The row's field text: the live keystrokes, else the stored count spelled short. */ + const contextText = (model: DeepSeekModelDraft, index: number): string => { + if (editing?.index === index) return editing.text + const value = model['contextWindow'] + return typeof value === 'number' ? formatContextWindow(value) : '' + } + + const settleContext = (index: number): void => { + setEditing((current) => { + if (current?.index !== index) return current + // Unreadable text stays on screen: the save-time rejection names a row + // the user can still see and correct. + const parsed = parseContextWindow(current.text) + return parsed !== undefined && Number.isNaN(parsed) ? current : undefined + }) + } + return (
@@ -152,22 +217,18 @@ export function DeepSeekModelsEditor(props: DeepSeekModelsEditorProps): ReactNod /> { - update( - index, - 'contextWindow', - event.target.value === '' ? undefined : Number(event.target.value), - ) + setEditing({ index, text: event.target.value }) + update(index, 'contextWindow', parseContextWindow(event.target.value)) }} + onBlur={() => { settleContext(index) }} /> @@ -203,6 +232,12 @@ export function DeepSeekModelsEditor(props: DeepSeekModelsEditorProps): ReactNod aria-label={`${props.t('modelId')} ${String(index + 1)}`} disabled={props.disabled} onChange={(event) => { update(index, 'id', event.target.value) }} + onBlur={(event) => { + // Settle a pasted id rather than trimming per keystroke, + // which would stop the user typing an interior space. + const trimmed = event.target.value.trim() + if (trimmed !== event.target.value) update(index, 'id', trimmed) + }} /> { - setEditing({ index, text: event.target.value }) - update(index, 'contextWindow', parseContextWindow(event.target.value)) + const text = event.target.value + setEditing(current => new Map(current).set(index, text)) + update(index, 'contextWindow', parseContextWindow(text)) }} onBlur={() => { settleContext(index) }} /> diff --git a/packages/client/ui-models/tests/components.spec.tsx b/packages/client/ui-models/tests/components.spec.tsx index d9ebadd1f3..7346332a2f 100644 --- a/packages/client/ui-models/tests/components.spec.tsx +++ b/packages/client/ui-models/tests/components.spec.tsx @@ -477,6 +477,91 @@ describe('ModelsSection', () => { .toEqual(base === undefined ? ['deepseek-v4-flash', 'deepseek-v4-pro'] : ['pinned-by-deployment']) }) + it('keeps every row\'s unreadable text, not just the last one edited', async () => { + // 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() + fireEvent.click(screen.getByText(en.customized)) + const windows = screen.getAllByLabelText(new RegExp(en.contextWindow)) + fireEvent.change(windows[0] as HTMLInputElement, { target: { value: 'not a number' } }) + fireEvent.blur(windows[0] as HTMLInputElement) + fireEvent.change(windows[1] as HTMLInputElement, { target: { value: '2M' } }) + + expect((windows[0] as HTMLInputElement).value).toBe('not a number') + expect((windows[1] as HTMLInputElement).value).toBe('2M') + }) + + it('re-keys the typed text around a removed row', async () => { + await mountSection() + fireEvent.click(screen.getByText(en.customized)) + const windows = (): HTMLInputElement[] => + screen.getAllByLabelText(new RegExp(en.contextWindow)) + const removeRow = (at: number): void => { + fireEvent.click(screen.getAllByText(en.removeModel)[at] as HTMLElement) + } + // Three rows, with text parked on the outer two. + fireEvent.click(screen.getByText(en.addModel)) + fireEvent.change(windows()[0] as HTMLInputElement, { target: { value: 'top text' } }) + fireEvent.blur(windows()[0] as HTMLInputElement) + fireEvent.change(windows()[2] as HTMLInputElement, { target: { value: 'bottom text' } }) + fireEvent.blur(windows()[2] as HTMLInputElement) + + // Dropping the middle row leaves the row above untouched and carries the + // row below down with its own text, rather than stranding it. + removeRow(1) + expect(windows()).toHaveLength(2) + expect((windows()[0] as HTMLInputElement).value).toBe('top text') + expect((windows()[1] as HTMLInputElement).value).toBe('bottom text') + + // Dropping a row that holds text takes that text with it; the survivor + // keeps its own rather than inheriting the deleted row's. + removeRow(0) + expect(windows()).toHaveLength(1) + expect((windows()[0] as HTMLInputElement).value).toBe('bottom text') + }) + + it('drops the typed text when reset replaces the rows it annotated', async () => { + // 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({ + mutate: vi.fn(() => Promise.resolve(ok(wireNamespaces()[0]))), + }) + fireEvent.click(screen.getByText(en.customized)) + const windows = screen.getAllByLabelText(new RegExp(en.contextWindow)) + fireEvent.change(windows[0] as HTMLInputElement, { target: { value: 'garbage' } }) + fireEvent.blur(windows[0] as HTMLInputElement) + fireEvent.click(screen.getByText(en.resetModels)) + + const restored = screen.getAllByLabelText(new RegExp(en.contextWindow)) + expect((restored[0] as HTMLInputElement).value).toBe('1M') + + // 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() }) + expect(mutate).not.toHaveBeenCalled() + }) + + it('settles a pasted id and refuses whitespace that would never match', async () => { + await mountSection() + 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 ' } }) + fireEvent.blur(ids[0] as HTMLInputElement) + expect((ids[0] as HTMLInputElement).value).toBe('deepseek-v4-flash') + // A settled id needs no second trim. + fireEvent.blur(ids[0] as HTMLInputElement) + expect((ids[0] as HTMLInputElement).value).toBe('deepseek-v4-flash') + + // An id that is only whitespace is as absent as an empty one, and a padded + // id no longer slips past the duplicate check against its own twin. + expect(validateDeepSeekModels([{ id: ' ' }])).toEqual({ index: 0, key: 'modelIdRequired' }) + expect(validateDeepSeekModels([{ id: 'model' }, { id: 'model ' }])) + .toEqual({ index: 1, key: 'modelIdDuplicate' }) + }) + it('renders malformed draft fallbacks without inventing catalog values', () => { render( Date: Fri, 31 Jul 2026 15:11:02 +0800 Subject: [PATCH 07/29] testing(web): pin the row card against the editor it expands into The previous assertion pinned the literal `bg-layer-3` fill that was just reverted. What matters is the relationship it broke: `bg-layer-3` and `bg-module-platform` both resolve to neutral-bluish-800 under the dark theme, so filling the row with either erases the nested editor's boundary. --- .../client/ui-models/tests/styles.spec.ts | 20 ++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/packages/client/ui-models/tests/styles.spec.ts b/packages/client/ui-models/tests/styles.spec.ts index 478046454b..9139d01f4e 100644 --- a/packages/client/ui-models/tests/styles.spec.ts +++ b/packages/client/ui-models/tests/styles.spec.ts @@ -4,10 +4,28 @@ import { describe, expect, it } from 'vitest' const css = readFileSync(fileURLToPath(new URL('../src/client/ModelsSection.module.css', import.meta.url)), 'utf8') +/** The declarations of one top-level rule, by selector. */ +function block(selector: string): string { + const match = new RegExp(`^\\${selector} \\{([^}]*)\\}`, 'm').exec(css) + if (match === null) throw new Error(`ModelsSection.module.css has no \`${selector}\` rule`) + return match[1] ?? '' +} + describe('ModelsSection theme styles', () => { it('uses the shared theme tokens without light-only fallbacks', () => { + // The section once named `--border`/`--surface`/`--text-*`/`--accent-strong`, + // which nothing in this app defines, so it rendered the light-mode literals + // written as their fallbacks and stayed light under the dark theme. 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)') }) + + it('separates the row card from the editor it expands into', () => { + // `bg-layer-3` and `bg-module-platform` both resolve to neutral-bluish-800 + // under the dark theme, so filling the row with either erases the nested + // editor's boundary. The row is outlined; the fill is the editor's alone. + expect(block('.editor')).toContain('background: var(--dsw-alias-bg-module-platform)') + expect(block('.rowCard')).toContain('border: 1px solid var(--dsw-alias-border-l2)') + expect(block('.rowCard')).not.toMatch(/\bbackground\s*:/) + }) }) From f12d4f698e53456eb768b507f1e7fdb64b7349b0 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Fri, 31 Jul 2026 15:11:11 +0800 Subject: [PATCH 08/29] doc(web): own the Web/TUI split on an unlisted current model The TUI still renders the unlisted current model as its own row and marks it current; Web shows the unset trigger label and asks for a replacement. The note recorded the Host decision but not that the two surfaces present it differently, which reads as a missed migration rather than a choice. Also records why `trigger.selectAria` and `trigger.fallback` hold identical strings, so a future coalescing cleanup does not merge them. --- .../2026-07-24-web-session-model-selector.i18n.yaml | 4 ++-- .../feature/2026-07-24-web-session-model-selector.md | 2 +- .../2026-07-24-web-session-model-selector.zh.md | 2 +- packages/client/ui-model/src/client/locales.ts | 10 +++++++++- 4 files changed, 13 insertions(+), 5 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.i18n.yaml b/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.i18n.yaml index 898a0142fb..f79f7d9955 100644 --- a/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.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/feature/2026-07-24-web-session-model-selector.md -2026-07-24-web-session-model-selector.md: ca13bebbb49aec5deabd147ed1c2a8f3b246ca42 -2026-07-24-web-session-model-selector.zh.md: 16c7d773b0eea82bf991bc17c44b26c8e820aba1 +2026-07-24-web-session-model-selector.md: 05e923fb3b5df72485ffb8d6ff5aa0104b19a8a0 +2026-07-24-web-session-model-selector.zh.md: 705b7acaf809597d1ead450e2ff5b86e0f3af049 diff --git a/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.md b/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.md index ca13bebbb4..05e923fb3b 100644 --- a/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.md +++ b/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.md @@ -12,7 +12,7 @@ The Web conversation displayed and sent through the Host's fixed provider/model The Web Host reuses `installAgentLlmTarget` for every created or resumed agent. The provider/model/reasoning target starts from the latest `request/header` when the session has used a model, otherwise from the Host default route. `session.selectModel` changes the session-local mutable target, and prompt assembly captures it with request routing; a switch during a running step therefore applies to the next assembled step. The next consumed target persists through the existing full `request/header` snapshot, while a choice that has not reached a request remains process-local. -The session RPC domain exposes a `session.models` directory and `session.selectModel`. The directory is built dynamically from the LLM registry and grouped by provider; each listed model's exact metadata adds adapter-owned reasoning effort ids, names, descriptions, and optional default. Provider catalogs and exact metadata load concurrently by provider and fail independently, so successful groups remain usable alongside retryable failure records. Catalog membership stays advisory: `session.models.current` is returned independently and can remain routable when absent from every group, but the Host does not synthesize an unlisted row after its provider stops advertising it. Exact resolution decides whether a route and explicit effort are available. Selection uses `resolveCallConfig` to reject unsupported effort ids and materialize an adapter-configured default before updating the target. +The session RPC domain exposes a `session.models` directory and `session.selectModel`. The directory is built dynamically from the LLM registry and grouped by provider; each listed model's exact metadata adds adapter-owned reasoning effort ids, names, descriptions, and optional default. Provider catalogs and exact metadata load concurrently by provider and fail independently, so successful groups remain usable alongside retryable failure records. Catalog membership stays advisory: `session.models.current` is returned independently and can remain routable when absent from every group, but the Host does not synthesize an unlisted row after its provider stops advertising it. The two surfaces answer that state differently on purpose: the TUI still renders the unlisted current model as its own row and marks it current, while Web shows the unset trigger label and asks for a replacement. Web is the surface where a catalog is edited, so a target the user just deleted should read as a decision to make rather than a selection to keep; the TUI, which only picks from what exists, has no such edit to reconcile. The cost is real and accepted — a Web composer showing the unset label can still send to the routed target — and the divergence is deliberate, not a missed migration. Exact resolution decides whether a route and explicit effort are available. Selection uses `resolveCallConfig` to reject unsupported effort ids and materialize an adapter-configured default before updating the target. The browser `ModelService` owns one `ModelDirectory` per live session. Its snapshot contains the current complete target, grouped catalog, provider failures, operation error, and `idle`/`loading`/`ready`/`selecting`/`error` state. Mounting primes the trigger label and each menu open refreshes the directory. Directory and selection calls share an operation generation so older responses cannot replace a newer result; connection reset discards the process-local projection before restoring the Host target. Failures retain the previous current target and usable groups. diff --git a/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.zh.md b/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.zh.md index 16c7d773b0..705b7acaf8 100644 --- a/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.zh.md +++ b/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.zh.md @@ -12,7 +12,7 @@ Web 对话原本通过 Host 固定的提供方与模型路由显示并发送消 Web Host 为每个新建或恢复的 agent(智能体)复用 `installAgentLlmTarget`。如果会话已经使用过模型,提供方/模型/推理(reasoning)目标从最新的 `request/header` 开始;否则采用 Host 默认路由。`session.selectModel` 会更改会话级可变目标,提示词组装则将该目标与请求路由一并捕获,因此运行中步骤发生的切换会应用于下一个组装步骤。下一条实际采用的目标通过现有的完整 `request/header` 快照持久化;尚未进入请求的选择则仅保存在当前进程中。 -会话 RPC 领域公开 `session.models` 模型目录与 `session.selectModel`。该目录从 LLM(大语言模型)注册表动态构建,并按提供方分组;每个已列出模型的精确元数据还会加入由适配器持有的推理强度 ID、名称、说明和可选默认值。各提供方的目录与精确元数据会按提供方并发加载,且彼此独立失败,因此成功加载的分组仍可与可重试的失败记录一同使用。模型是否位于目录仅供参考:`session.models.current` 独立返回,即使不在任何分组中也仍然可以路由,但提供方停止公布该模型后,Host 不会合成未列出行。精确解析决定路由与显式推理强度是否可用。选择操作通过 `resolveCallConfig` 拒绝不支持的推理强度 ID,并在更新目标前具体化适配器配置的默认值。 +会话 RPC 领域公开 `session.models` 模型目录与 `session.selectModel`。该目录从 LLM(大语言模型)注册表动态构建,并按提供方分组;每个已列出模型的精确元数据还会加入由适配器持有的推理强度 ID、名称、说明和可选默认值。各提供方的目录与精确元数据会按提供方并发加载,且彼此独立失败,因此成功加载的分组仍可与可重试的失败记录一同使用。模型是否位于目录仅供参考:`session.models.current` 独立返回,即使不在任何分组中也仍然可以路由,但提供方停止公布该模型后,Host 不会合成未列出行。两个前门有意对这一状态给出不同回答:TUI 仍把未列出的当前模型渲染为独立一行并标记为当前,Web 则显示未设置状态的触发器标签并要求选择替代模型。Web 正是编辑目录所在的前门,因此用户刚刚删除的目标应当读作一个有待作出的决定,而不是一项可以保留的选择;TUI 只在已存在的模型中挑选,没有这类编辑需要调和。这一代价真实存在且已被接受——显示未设置标签的 Web composer 仍会发送到实际路由的目标——这一分歧是有意为之,而不是一处遗漏的迁移。精确解析决定路由与显式推理强度是否可用。选择操作通过 `resolveCallConfig` 拒绝不支持的推理强度 ID,并在更新目标前具体化适配器配置的默认值。 浏览器中的 `ModelService` 为每个实时会话持有一个 `ModelDirectory`。其快照包含当前完整目标、分组目录、提供方失败记录、操作错误,以及 `idle`、`loading`、`ready`、`selecting`、`error` 状态。挂载时会预先填充触发器标签,此后每次打开菜单都会刷新目录。目录与选择调用共用操作代次,防止较早响应覆盖较新结果;连接重置会先丢弃当前进程中的投影,再恢复 Host 目标。失败时保留先前的当前目标和可用分组。 diff --git a/packages/client/ui-model/src/client/locales.ts b/packages/client/ui-model/src/client/locales.ts index 8b83870c40..f2b8bf1f01 100644 --- a/packages/client/ui-model/src/client/locales.ts +++ b/packages/client/ui-model/src/client/locales.ts @@ -1,4 +1,12 @@ -/** `model` namespace dictionaries. */ +/** + * `model` namespace dictionaries. + * + * `trigger.selectAria` reads identically to `trigger.fallback` today and is + * still a separate key: the visible fallback label and the accessible name of + * an unset trigger are free to diverge per locale, and folding it into + * `trigger.aria` would announce the degenerate "Select model, current Select + * model". + */ /** Simplified Chinese dictionary (the key-set source of truth). */ export const zh = { From 00e08d1ec034e7d3389598823d81a2a22b71e738 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Fri, 31 Jul 2026 15:19:13 +0800 Subject: [PATCH 09/29] testing(web): open the settings dialog in the model catalog test The test inherited an open dialog with the DeepSeek editor already expanded from the credential test above it. That test now reloads the page to exercise the welcome step, so nothing carries the dialog across and the catalog test timed out looking for a fold that was no longer on screen. The review flagged this coupling as two links deep; the merge proved it. --- apps/web/tests/onboarding-deepseek-config.e2e.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/apps/web/tests/onboarding-deepseek-config.e2e.ts b/apps/web/tests/onboarding-deepseek-config.e2e.ts index e3fd668343..5f24ca5a6d 100644 --- a/apps/web/tests/onboarding-deepseek-config.e2e.ts +++ b/apps/web/tests/onboarding-deepseek-config.e2e.ts @@ -162,7 +162,15 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup it('configures arbitrary DeepSeek models and prompts after the selected model is removed', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-onboarding-deepseek-models')) + // Opened here rather than inherited: the credential test reloads the page + // to exercise the welcome step, so nothing carries an open dialog across. + await page.getByRole('button', { name: '设置', exact: true }).click() const settings = page.getByRole('dialog', { name: '设置' }) + await settings.waitFor({ timeout: 10_000 }) + await settings.getByRole('button', { name: '模型' }).click() + const deepSeek = settings.getByText('DeepSeek', { exact: true }).first() + await deepSeek.waitFor({ timeout: 10_000 }) + await deepSeek.locator('xpath=ancestor::li').getByRole('button', { name: '编辑' }).click() await settings.getByText('自定义设置').click() await settings.getByRole('button', { name: '删除模型' }).first().click() await settings.getByRole('button', { name: '添加模型' }).click() From 5e05838365e1edd35b5cbc9d6fa5393ea7796fd3 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 3 Aug 2026 19:10:55 +0800 Subject: [PATCH 10/29] testing(web): re-record the markdown image golden for the model trigger The scenario arrived with master and recorded its trigger while the Host still synthesized an unlisted current model. This branch stopped doing that, so the trigger reads `Select model` until the user picks an advertised one. --- apps/web/tests/snapshots/markdown-images/ui.expected.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/tests/snapshots/markdown-images/ui.expected.md b/apps/web/tests/snapshots/markdown-images/ui.expected.md index 58b72e0d65..ed8fee9178 100644 --- a/apps/web/tests/snapshots/markdown-images/ui.expected.md +++ b/apps/web/tests/snapshots/markdown-images/ui.expected.md @@ -24,8 +24,8 @@ - button "Commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write -- button "Select model, current deepseek-v4-flash": - - text: deepseek-v4-flash +- button "Select model": + - text: Select model - img - button "Send message" [disabled] - text: 1 turns · 1 steps Input 0 tok · Output 0 tok From 1d2ea70e9b33f41a4399c768365f453df6e36209 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Tue, 4 Aug 2026 14:53:02 +0800 Subject: [PATCH 11/29] feat(llm-deepseek): per-model output cap in the advisory catalog A catalog entry may now carry its own `maxTokens`, matching the shape the pi-ai adapter already exposes. Exact-model resolution prefers it over the profile value, so capping one model no longer means capping the route. Defaults are unchanged: an entry without a cap, and any unlisted pass-through id, still resolve to the profile `maxTokens` (256,000), and the shipped catalog keeps its context windows. --- docs/config-catalog.md | 4 +++- packages/llm/llm-deepseek/README.i18n.yaml | 4 ++-- packages/llm/llm-deepseek/README.md | 2 +- packages/llm/llm-deepseek/README.zh.md | 2 +- packages/llm/llm-deepseek/src/adapter.ts | 4 +++- packages/llm/llm-deepseek/src/index.ts | 10 +++++++++- .../llm/llm-deepseek/tests/adapter.spec.ts | 20 +++++++++++++++++++ 7 files changed, 39 insertions(+), 7 deletions(-) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index f07a12f4a8..1613d39de6 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -638,7 +638,7 @@ export interface Config { thinking?: 'enabled' | 'disabled' /** Default thinking effort (default `high`); `off` disables thinking per request. */ reasoningEffort?: 'off' | 'high' | 'max' - /** Default per-request output cap (default 256,000); explicit request values win. */ + /** Default per-request output cap (default 256,000); a model's own cap and explicit request values win. */ maxTokens?: number /** Positive context capacity used when the selected model has no exact value (default 1,000,000). */ defaultContextWindow?: number @@ -660,6 +660,8 @@ export interface DeepSeekCatalogModel { description?: string /** Known combined request/response context capacity; omitted when deployment metadata is unavailable. */ contextWindow?: number + /** Per-request output cap for this model; omission falls back to the profile's {@link DeepSeekConnectionOptions.maxTokens}. */ + maxTokens?: number } ``` diff --git a/packages/llm/llm-deepseek/README.i18n.yaml b/packages/llm/llm-deepseek/README.i18n.yaml index 45d9cee054..e75778f9f0 100644 --- a/packages/llm/llm-deepseek/README.i18n.yaml +++ b/packages/llm/llm-deepseek/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/llm/llm-deepseek/README.md -README.md: 020aa65073495526be3f32912b7cd06667c52a2e -README.zh.md: 4c655e90ba00340c056f6ac16159621f7a8c1ddb +README.md: 72b04f5982ec7fdef024835ab23e7a9f0005f84c +README.zh.md: 33eb1986a90079a2b8d6768368287b121e2fbd6b diff --git a/packages/llm/llm-deepseek/README.md b/packages/llm/llm-deepseek/README.md index 020aa65073..72b04f5982 100644 --- a/packages/llm/llm-deepseek/README.md +++ b/packages/llm/llm-deepseek/README.md @@ -40,7 +40,7 @@ The plugin registers the single provider route `deepseek-official` together with `contextWindow` is optional per configured model and is not exposed through the advisory catalog. `ctx.llm.resolveModelInfo('deepseek-official', model).context` returns an exact model value first, then `defaultContextWindow` for an entry without capacity or an unlisted pass-through id. The adapter default is 1,000,000; pressure-sensitive plugins therefore get deployment-owned capacity without treating the model selector as authoritative. Registering another adapter for `deepseek-official` throws `LlmError('DUPLICATE_ADAPTER')`. -`maxTokens` is the adapter-configured output cap for conversation requests and defaults to 256,000. Exact-model resolution exposes it as `defaultMaxTokens`; `LlmService` materializes that value into `GenerateOptions.maxTokens` before the agent loop writes `request/header`, so the wire request remains reconstructable. An explicit request or `AgentOptions.maxTokens` value wins and is serialized as `max_tokens`. The adapter does not clamp this request budget against `contextWindow`; deployments with a smaller context or provider output limit must configure a compatible `maxTokens`. +`maxTokens` is the adapter-configured output cap for conversation requests and defaults to 256,000. A catalog entry may carry its own `maxTokens`, which wins for that model; an entry without one, and any unlisted pass-through id, resolve to the profile value, so adding a per-model cap changes one model rather than the route. Exact-model resolution exposes the winner as `defaultMaxTokens`; `LlmService` materializes that value into `GenerateOptions.maxTokens` before the agent loop writes `request/header`, so the wire request remains reconstructable. An explicit request or `AgentOptions.maxTokens` value wins and is serialized as `max_tokens`. The adapter does not clamp this request budget against `contextWindow`; deployments with a smaller context or provider output limit must configure a compatible `maxTokens`. The same exact-model result exposes ordered `off`, `high`, and `max` efforts under `reasoning` for every pass-through model when deployment policy permits thinking. `reasoningEffort` selects the deployment default and falls back to `high` when omitted. `agent/request` can replace it on each conversation step; the resolved value is logged in `request/header`. `high` and `max` enable thinking and serialize as the official top-level `reasoning_effort`; adapter-owned `off` instead serializes `thinking.type: disabled` and omits `reasoning_effort`. An unsupported value fails with `UNSUPPORTED_REASONING_EFFORT` before network I/O. diff --git a/packages/llm/llm-deepseek/README.zh.md b/packages/llm/llm-deepseek/README.zh.md index 4c655e90ba..33eb1986a9 100644 --- a/packages/llm/llm-deepseek/README.zh.md +++ b/packages/llm/llm-deepseek/README.zh.md @@ -40,7 +40,7 @@ harness LLM(大语言模型)seam 的 DeepSeek chat-completions 适配器: `contextWindow` 对每个已配置模型都可选,不会通过建议 catalog 公开。`ctx.llm.resolveModelInfo('deepseek-official', model).context` 先返回精确模型值,再对不含容量的配置项或未列出原样传递 id 返回 `defaultContextWindow`。适配器默认值为 1,000,000;因此,压力敏感插件可以获得由部署决定的容量,不会将模型 selector 视为权威。为 `deepseek-official` 注册另一个适配器会抛出 `LlmError('DUPLICATE_ADAPTER')`。 -`maxTokens` 是适配器为对话请求配置的输出上限,默认值为 256,000。确切模型解析会将其公开为 `defaultMaxTokens`;`LlmService` 会在 agent loop(智能体循环)写入 `request/header` 前,将该值填入 `GenerateOptions.maxTokens`,从而仍可根据持久记录重建协议请求。显式的请求值或 `AgentOptions.maxTokens` 值优先,并会序列化为 `max_tokens`。适配器不会根据 `contextWindow` 自动调低该请求预算;上下文或提供方输出上限较小的部署必须配置与其相容的 `maxTokens`。 +`maxTokens` 是适配器为对话请求配置的输出上限,默认值为 256,000。Catalog 配置项可以自带 `maxTokens`,它对该模型胜出;不含该上限的配置项以及任何未列出原样传递 id 都解析为 profile 值,因此新增按模型的上限只改变一个模型,而非整条路由。确切模型解析会将胜出值公开为 `defaultMaxTokens`;`LlmService` 会在 agent loop(智能体循环)写入 `request/header` 前,将该值填入 `GenerateOptions.maxTokens`,从而仍可根据持久记录重建协议请求。显式的请求值或 `AgentOptions.maxTokens` 值优先,并会序列化为 `max_tokens`。适配器不会根据 `contextWindow` 自动调低该请求预算;上下文或提供方输出上限较小的部署必须配置与其相容的 `maxTokens`。 同一确切模型结果会在部署策略允许思考时,为每个原样传递模型在 `reasoning` 下公开有序的 `off`、`high` 和 `max` 推理(reasoning)强度。`reasoningEffort` 选择部署默认值,省略时回退为 `high`。`agent/request` 可以在每个会话步骤替换它;解析后的值会记录在 `request/header`。`high` 和 `max` 会启用思考,并序列化为官方顶层 `reasoning_effort`;适配器持有的 `off` 则序列化为 `thinking.type: disabled`,且省略 `reasoning_effort`。不支持的值会在网络 I/O 前以 `UNSUPPORTED_REASONING_EFFORT` 失败。 diff --git a/packages/llm/llm-deepseek/src/adapter.ts b/packages/llm/llm-deepseek/src/adapter.ts index 85985c41d8..c0d74d3f75 100644 --- a/packages/llm/llm-deepseek/src/adapter.ts +++ b/packages/llm/llm-deepseek/src/adapter.ts @@ -35,6 +35,8 @@ export interface DeepSeekCatalogModel { description?: string /** Known combined request/response context capacity; omitted when deployment metadata is unavailable. */ contextWindow?: number + /** Per-request output cap for this model; omission falls back to the profile's {@link DeepSeekConnectionOptions.maxTokens}. */ + maxTokens?: number } /** @@ -181,7 +183,7 @@ export class DeepSeekAdapter extends LlmAdapter { ? { provider, id: model, name: model } : modelInfo(provider, configured), context: { contextWindow }, - defaultMaxTokens: connection.maxTokens, + defaultMaxTokens: configured?.maxTokens ?? connection.maxTokens, ...connection.defaults.thinking === 'disabled' ? { reasoning: { diff --git a/packages/llm/llm-deepseek/src/index.ts b/packages/llm/llm-deepseek/src/index.ts index 3ecc0bec77..cd2bb9a24e 100644 --- a/packages/llm/llm-deepseek/src/index.ts +++ b/packages/llm/llm-deepseek/src/index.ts @@ -68,7 +68,7 @@ export interface Config { thinking?: 'enabled' | 'disabled' /** Default thinking effort (default `high`); `off` disables thinking per request. */ reasoningEffort?: 'off' | 'high' | 'max' - /** Default per-request output cap (default 256,000); explicit request values win. */ + /** Default per-request output cap (default 256,000); a model's own cap and explicit request values win. */ maxTokens?: number /** Positive context capacity used when the selected model has no exact value (default 1,000,000). */ defaultContextWindow?: number @@ -85,6 +85,7 @@ const catalogModel: z = z.object({ name: z.string(), description: z.string(), contextWindow: z.number().step(1).min(1), + maxTokens: z.number().step(1).min(1), }) export const Config: z = z.object({ @@ -125,6 +126,12 @@ function resolveModels(models: readonly DeepSeekCatalogModel[] | undefined): Dee `llm-deepseek: catalog model "${model.id}" contextWindow must be a positive integer`, ) } + if (model.maxTokens !== undefined + && (!Number.isInteger(model.maxTokens) || model.maxTokens <= 0)) { + throw new Error( + `llm-deepseek: catalog model "${model.id}" maxTokens must be a positive integer`, + ) + } if (seen.has(model.id)) throw new Error(`llm-deepseek: duplicate catalog model "${model.id}"`) seen.add(model.id) return { @@ -132,6 +139,7 @@ function resolveModels(models: readonly DeepSeekCatalogModel[] | undefined): Dee ...model.name === undefined ? {} : { name: model.name }, ...model.description === undefined ? {} : { description: model.description }, ...model.contextWindow === undefined ? {} : { contextWindow: model.contextWindow }, + ...model.maxTokens === undefined ? {} : { maxTokens: model.maxTokens }, } }) } diff --git a/packages/llm/llm-deepseek/tests/adapter.spec.ts b/packages/llm/llm-deepseek/tests/adapter.spec.ts index ec4a271f15..8ee2c81afe 100644 --- a/packages/llm/llm-deepseek/tests/adapter.spec.ts +++ b/packages/llm/llm-deepseek/tests/adapter.spec.ts @@ -793,6 +793,26 @@ describe('plugin registration and config', () => { expect(ctx.llm.listProviders()).toEqual([]) }) + it.each([0, 1.5])('rejects a per-model output cap of %s', (maxTokens) => { + expect(() => resolveAdapterOptions({ models: [{ id: 'bad-cap', maxTokens }] })) + .toThrow(/maxTokens must be a positive integer/) + }) + + it('prefers a model\'s own output cap over the profile default', async () => { + // The profile default stays what an unlisted or uncapped model resolves + // to, so adding a per-model cap changes one model rather than the route. + const adapter = adapterOf({ maxTokens: 4096, models: [ + { id: 'capped', maxTokens: 512 }, + { id: 'uncapped' }, + ] }) + await expect(adapter.resolveModel('deepseek-official', 'capped')) + .resolves.toMatchObject({ defaultMaxTokens: 512 }) + await expect(adapter.resolveModel('deepseek-official', 'uncapped')) + .resolves.toMatchObject({ defaultMaxTokens: 4096 }) + await expect(adapter.resolveModel('deepseek-official', 'not-in-catalog')) + .resolves.toMatchObject({ defaultMaxTokens: 4096 }) + }) + it('rejects invalid context capacity when apply is called directly', async () => { const ctx = new Context() await ctx.plugin(LlmService) From 17b480de51b1f4848eeeece172c3c7431b88423a Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Tue, 4 Aug 2026 14:53:17 +0800 Subject: [PATCH 12/29] feat(web): align the model catalog with the pi-ai provider form MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both editors live in `ui-models` and list the same thing, so they now share one row shape rather than diverging when #1368 lands: a bordered entry per model, id and display name on the row, and the capacities behind the row's own disclosure. The context window is joined there by the per-model output cap the adapter just gained; both read a decimal K/M suffix. The shared class names carry this file's token spellings, not that branch's. `--dsw-alias-border-subtle`, `--dsw-alias-text-tertiary`, and `--dsw-alias-text-primary` are undeclared, so they resolve to the light-mode literals in their fallback slots — the defect this section was moved off. A styles test now rejects any `--dsw-*` name the token sheet does not declare, so the next editor to name one fails instead of shipping a light-only surface. The keystroke buffer is now per capacity field rather than per row, since a row holds two of them. --- .../2026-07-30-web-config-plane.i18n.yaml | 4 +- .../2026-07-30-web-config-plane.md | 2 +- .../2026-07-30-web-config-plane.zh.md | 2 +- .../tests/onboarding-deepseek-config.e2e.ts | 6 +- .../models.expected.md | 32 +- 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/DeepSeekModelsEditor.tsx | 283 +++++++++++------- .../src/client/ModelsSection.module.css | 99 +++--- .../ui-models/src/client/ProviderEditor.tsx | 2 + .../client/ui-models/src/client/locales.ts | 8 + .../ui-models/tests/components.spec.tsx | 141 ++++++--- .../client/ui-models/tests/styles.spec.ts | 17 +- 14 files changed, 400 insertions(+), 204 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.i18n.yaml index 32137fe409..79e779ffdf 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.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/architecture/2026-07-30-web-config-plane.md -2026-07-30-web-config-plane.md: a4d474d450009b3bcf929eaea870e045602268ac -2026-07-30-web-config-plane.zh.md: db7201408f46a1e68b4359346d1c74b8d728a0d3 +2026-07-30-web-config-plane.md: 5970cfcea2e577998a235d08769ba497f5e8c18b +2026-07-30-web-config-plane.zh.md: 7a9fc6a7a0484ec7ae5d7691ffe26eddfd33fb54 diff --git a/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md b/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md index a4d474d450..5970cfcea2 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md +++ b/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md @@ -18,7 +18,7 @@ PR1 made LLM adapter configuration restart-free at the seam, but the only writer **The llm seam declares configurability and announces topology.** `registerConfigurableProviders()` is an all-or-nothing, fiber-scoped directory of `{provider, displayName, settingsNs, settingsPath}` — the addressing a config page needs to open the right settings subtree for a route that may not exist yet; `listConfigurableProviders()` merges with live routes in the wire handler so undeclared live routes still report active. The zero-payload `'llm/adapters-updated'` event fires from all four registration/unregistration commit points with contained listener dispatch (INVARIANT rethrow), following the settings/commands precedent. `llm-deepseek`'s route renamed to `deepseek-official` because the pi-ai catalog legitimately owns `deepseek` as an aggregator entry; pre-release stance, no alias. -**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, `reasoningEffort` for deepseek / `reasoning` for pi-ai, plus direct DeepSeek model rows with `id`, `name`, and `contextWindow`). Existing model fields outside that visible set survive array edits; retry policy, timeouts, and other fields remain owned by `settings.yaml`. Validation still runs the rehydrated schema before writing, while adapter-specific checks reject catalog invariants that the serialized schema cannot express. The card's colors resolve through the `--dsw-alias-*` design tokens; it had named `--border`/`--surface`/`--text-*`, which nothing in this app defines, so it rendered their light-mode fallbacks and stayed light under the dark theme. The model catalog is one caption strip over a row of `id`/`name`/`contextWindow` fields per model rather than a labelled card each; every field keeps the indexed `aria-label` that names it, and the captions are hidden from assistive tech so that name is not announced twice. The context window is a text field reading a decimal `K`/`M` suffix (`1M` is 1000K, matching how capacities are quoted) and storing the plain count: the field holds the typed text while the row has focus, because re-deriving it from the parsed count on every keystroke would rewrite `1000` to `1K` mid-word, and text that does not parse stays on screen so the save-time rejection names a row the user can still see. +**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, `reasoningEffort` for deepseek / `reasoning` for pi-ai, plus direct DeepSeek model rows with `id`, `name`, and `contextWindow`). Existing model fields outside that visible set survive array edits; retry policy, timeouts, and other fields remain owned by `settings.yaml`. Validation still runs the rehydrated schema before writing, while adapter-specific checks reject catalog invariants that the serialized schema cannot express. The card's colors resolve through the `--dsw-alias-*` design tokens; it had named `--border`/`--surface`/`--text-*`, which nothing in this app defines, so it rendered their light-mode fallbacks and stayed light under the dark theme. The model catalog takes the row shape the pi-ai provider form introduces: one bordered entry per model, id and display name on the row, and the capacities behind the row's own disclosure, so the two editors read as one design rather than diverging once both land. Every field keeps the indexed `aria-label` that names it. Both capacities are text fields reading a decimal `K`/`M` suffix (`1M` is 1000K, matching how capacities are quoted) and storing the plain count: a field holds the typed text while it has focus, because re-deriving it from the parsed count on every keystroke would rewrite `1000` to `1K` mid-word, and text that does not parse stays on screen so the save-time rejection names a row the user can still see. The shared class names carry this file's token spellings, not that branch's: `--dsw-alias-border-subtle`, `--dsw-alias-text-tertiary`, and `--dsw-alias-text-primary` are undeclared, so they resolve to the light-mode literals in their fallback slots — the defect this section was moved off. A styles test now rejects any `--dsw-*` name the token sheet does not declare, so the next editor to name one fails rather than shipping a light-only surface. **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 `_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. DeepSeek's model list is array-replace configuration: inherited effective rows remain visible until the first edit materializes the complete list in the user layer, and reset unsets the list override. diff --git a/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.zh.md b/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.zh.md index db7201408f..7a9fc6a7a0 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.zh.md @@ -18,7 +18,7 @@ PR1 让 LLM(大语言模型)适配器配置在 seam 层面免重启,但唯 **llm seam 声明可配置性并公布拓扑。**`registerConfigurableProviders()` 是一个全有或全无、以 fiber 为作用域的目录,条目为 `{provider, displayName, settingsNs, settingsPath}`——这正是配置页要为一条可能尚不存在的路由打开正确设置子树时所需要的寻址;`listConfigurableProviders()` 在 wire 处理器里与存活路由合并,未声明的存活路由因此仍报告为激活。零负载的 `'llm/adapters-updated'` 事件从全部四个注册/注销提交点触发,listener 派发带异常隔离(INVARIANT 重抛),沿用 settings/commands 的先例。`llm-deepseek` 的路由重命名为 `deepseek-official`,因为 pi-ai catalog 名正言顺地拥有 `deepseek` 这个聚合器条目;依预发布立场,不设别名。 -**架在 schema 模型层之上的手写编辑器。**`dsh-client-schema-form` 把 wire 的 `toJSON()` 信封还原(rehydrate)为活的 schemastery 节点,用于校验、路径解析与不可变草稿编辑——但不做通用渲染:第一版交付了完整的 schema 驱动表单渲染器,得到的却是一个未加样式、把 schema 原样倾倒出来的页面(每个进阶字段都平铺到卡片上、原始字段名直接充当标签、`retryPolicy` 的「不支持」回退落在主流程里)。用户没有再加一套提示/分组系统,而是选择了手写方向,第二轮又把引用输入框整个移除:卡片的主字段是一个 **API 密钥**输入框,未配置密钥的整分节提供方会以其设置卡片的形式打开,收起的「自定义设置」折叠区承载按家族精选的额外字段(两个家族都有 `baseURL`,deepseek 有 `reasoningEffort`/pi-ai 有 `reasoning`,另有直接 DeepSeek 模型行的 `id`、`name` 和 `contextWindow`)。现有模型字段中不在可见集合内的部分会在数组编辑后保留;重试策略、超时及其他字段仍归 `settings.yaml` 所有。校验仍会在写入前运行还原出的 schema,适配器特有的检查则会拒绝序列化 schema 无法表达的目录不变量。卡片的颜色经 `--dsw-alias-*` 设计 token 解析;它此前引用的 `--border`/`--surface`/`--text-*` 在本应用中无人定义,于是渲染出的是它们的亮色模式回退值,在暗色主题下依旧保持亮色。模型目录是一条列名说明行,其下每个模型占一行 `id`/`name`/`contextWindow` 字段,而不是每个模型各一张带标签的卡片;每个字段都保留那个为其命名的带序号 `aria-label`,列名则对辅助技术隐藏,以免该名称被播报两次。上下文窗口是一个文本输入框,读取十进制的 `K`/`M` 后缀(`1M` 即 1000K,与容量的通行标注方式一致)并存储纯数值:该行持有焦点期间,字段保留键入的文本,因为若每次按键都从解析出的数值重新推导该文本,`1000` 会在尚未输完时就被改写成 `1K`;无法解析的文本也会留在屏幕上,因此保存时的拒绝点名的是用户仍能看见的那一行。 +**架在 schema 模型层之上的手写编辑器。**`dsh-client-schema-form` 把 wire 的 `toJSON()` 信封还原(rehydrate)为活的 schemastery 节点,用于校验、路径解析与不可变草稿编辑——但不做通用渲染:第一版交付了完整的 schema 驱动表单渲染器,得到的却是一个未加样式、把 schema 原样倾倒出来的页面(每个进阶字段都平铺到卡片上、原始字段名直接充当标签、`retryPolicy` 的「不支持」回退落在主流程里)。用户没有再加一套提示/分组系统,而是选择了手写方向,第二轮又把引用输入框整个移除:卡片的主字段是一个 **API 密钥**输入框,未配置密钥的整分节提供方会以其设置卡片的形式打开,收起的「自定义设置」折叠区承载按家族精选的额外字段(两个家族都有 `baseURL`,deepseek 有 `reasoningEffort`/pi-ai 有 `reasoning`,另有直接 DeepSeek 模型行的 `id`、`name` 和 `contextWindow`)。现有模型字段中不在可见集合内的部分会在数组编辑后保留;重试策略、超时及其他字段仍归 `settings.yaml` 所有。校验仍会在写入前运行还原出的 schema,适配器特有的检查则会拒绝序列化 schema 无法表达的目录不变量。卡片的颜色经 `--dsw-alias-*` 设计 token 解析;它此前引用的 `--border`/`--surface`/`--text-*` 在本应用中无人定义,于是渲染出的是它们的亮色模式回退值,在暗色主题下依旧保持亮色。模型目录采用 pi-ai 提供方表单引入的行形态:每个模型一个带边框的条目,ID 与显示名称落在行上,容量则收在该行自己的折叠区里,使两个编辑器呈现为同一套设计,而不是在双方都落地后各自分岔。每个字段都保留那个为其命名的带序号 `aria-label`。两项容量都是文本输入框,读取十进制的 `K`/`M` 后缀(`1M` 即 1000K,与容量的通行标注方式一致)并存储纯数值:字段持有焦点期间保留键入的文本,因为若每次按键都从解析出的数值重新推导该文本,`1000` 会在尚未输完时就被改写成 `1K`;无法解析的文本也会留在屏幕上,因此保存时的拒绝点名的是用户仍能看见的那一行。共用的类名承载的是本文件的 token 写法,而非那个分支的:`--dsw-alias-border-subtle`、`--dsw-alias-text-tertiary` 和 `--dsw-alias-text-primary` 均未声明,于是它们解析为各自回退槽位中的亮色模式字面值——正是本节此前迁离的那个缺陷。现在有一个样式测试会拒绝 token 表未声明的任何 `--dsw-*` 名称,因此下一个写出这类名称的编辑者会当场失败,而不是交付一个只有亮色的界面。 **Models 页是一次三领域联接,应用语义与 seam 同形。**每一行是一个已配置的提供方;「新增」卡片的选择框是可配置提供方目录中剩余的休眠条目。路由存活状态仍用于就绪判定,并会使该联接失效,但页面不将其渲染为提供方状态,因为配置存在与运行时可用性是两个不同概念。密钥通道保持引用形态,却从不展示任何引用:键入的密钥经 `credentials.set` **只写**存入 profile 的 `apiKeyEnv` 之下,引用不存在时便派生 `_API_KEY`(pi-ai profile 会记录该派生),因此 `settings.yaml` 从不携带密钥值。profile 的编辑和删除会针对脱敏后的用户分节,以按路径寻址的最小 `settings.mutate` 操作落地,绝不会点名页面未收到的机密。删除用户层提供方时,会先打开本地化的模型提供方确认对话框;取消操作、关闭按钮和遮罩均不会改动 profile,而破坏性确认会提交唯一一条 unset,并在其完成前阻止重复提交。DeepSeek 的模型列表是数组替换配置:继承而来的生效模型行会一直显示,直到第一次编辑将完整列表具化到用户层;重置则会取消设置该列表覆盖。 diff --git a/apps/web/tests/onboarding-deepseek-config.e2e.ts b/apps/web/tests/onboarding-deepseek-config.e2e.ts index 984d19db05..51fa84af3d 100644 --- a/apps/web/tests/onboarding-deepseek-config.e2e.ts +++ b/apps/web/tests/onboarding-deepseek-config.e2e.ts @@ -173,12 +173,15 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup await deepSeek.waitFor({ timeout: 10_000 }) await deepSeek.locator('xpath=ancestor::li').getByRole('button', { name: '编辑' }).click() await settings.getByText('自定义设置').click() - await settings.getByRole('button', { name: '删除模型' }).first().click() + await settings.getByRole('button', { name: /删除模型/ }).first().click() await settings.getByRole('button', { name: '添加模型' }).click() const customModelId = settings.getByLabel('模型 ID 2') await customModelId.fill('private-preview') await settings.getByLabel('显示名称 2').fill('Private Preview') + // Capacities live behind the row's own disclosure, as in the pi-ai form. + await settings.getByRole('button', { name: '容量 2' }).click() await settings.getByLabel('上下文窗口 2').fill('131072') + await settings.getByLabel('最大输出 token 数 2').fill('64K') const modelEditor = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd) await compareOrRefreshGolden(MODELS_EXPECTED, modelEditor, MODE) @@ -190,6 +193,7 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup expect(document).toContain('id: private-preview') expect(document).toContain('name: Private Preview') expect(document).toContain('contextWindow: 131072') + expect(document).toContain('maxTokens: 64000') expect(document).not.toContain('id: deepseek-v4-flash') await page.keyboard.press('Escape') 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 4a709fdf5a..c71c1981ac 100644 --- a/apps/web/tests/snapshots/onboarding-deepseek-config/models.expected.md +++ b/apps/web/tests/snapshots/onboarding-deepseek-config/models.expected.md @@ -32,26 +32,34 @@ - region "模型目录": - text: 模型目录 已自定义模型目录 - button "恢复默认模型" - - textbox "模型 ID 1": deepseek-v4-pro + - textbox "模型 ID 1": + - /placeholder: 模型 ID + - text: deepseek-v4-pro - textbox "显示名称 1": - - /placeholder: 留空时使用模型 ID + - /placeholder: 显示名称 - text: DeepSeek-V4-Pro - - textbox "上下文窗口 1": - - /placeholder: 1M - - text: 1M - - button "删除模型": + - button "容量 1": - img - - text: 删除模型 - - textbox "模型 ID 2": private-preview + - button "删除模型 1": + - img + - textbox "模型 ID 2": + - /placeholder: 模型 ID + - text: private-preview - textbox "显示名称 2": - - /placeholder: 留空时使用模型 ID + - /placeholder: 显示名称 - text: Private Preview + - button "容量 2" [expanded]: + - img + - button "删除模型 2": + - img + - text: 上下文窗口 - textbox "上下文窗口 2": - /placeholder: 1M - text: "131072" - - button "删除模型": - - img - - text: 删除模型 + - text: 最大输出 token 数 + - textbox "最大输出 token 数 2": + - /placeholder: 256K + - text: 64K - button "添加模型": - img - text: 添加模型 diff --git a/packages/client/ui-models/README.i18n.yaml b/packages/client/ui-models/README.i18n.yaml index 65843fbadb..38b961df53 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: 6ce66b5e0614fc2b6820c5d95f294fcc1a30d4de -README.zh.md: 0a28aa627d4d0c8dd54cffe690d334d4e10fa69d +README.md: d403b15247173e6290214eeae7a401851e835700 +README.zh.md: adb37bb25420bf4e038153ba8a5c213b8a993791 diff --git a/packages/client/ui-models/README.md b/packages/client/ui-models/README.md index 6ce66b5e06..d403b15247 100644 --- a/packages/client/ui-models/README.md +++ b/packages/client/ui-models/README.md @@ -8,7 +8,7 @@ Rows are the *configured* providers (their profile resolves in the owning namesp 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. -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 names the fields it can see rather than rebuilding a section: a stored literal secret it never received is mentioned by no op and survives. 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 context window 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 context windows fail before any write. Each write carries the `revision` the card opened at, so a concurrent write from another tab or an external `settings.yaml` edit is refused as `settings-conflict` and the card asks the user to reopen instead of replaying its stale snapshot. The page refetches on the pushed invalidations (`settings/changed`, `credentials/changed`, `models/changed`, and `connection/reset`) once it has loaded, so an external `settings.yaml` edit, a second tab, or a settings-born route converges without polling. +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 names the fields it can see rather than rebuilding a section: a stored literal secret it never received is mentioned by no op and survives. 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, the same shape 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. Each write carries the `revision` the card opened at, so a concurrent write from another tab or an external `settings.yaml` edit is refused as `settings-conflict` and the card asks the user to reopen instead of replaying its stale snapshot. The page refetches on the pushed invalidations (`settings/changed`, `credentials/changed`, `models/changed`, and `connection/reset`) once it has loaded, so an external `settings.yaml` edit, a second tab, or a settings-born route converges without polling. ## Model Experience diff --git a/packages/client/ui-models/README.zh.md b/packages/client/ui-models/README.zh.md index 0a28aa627d..adb37bb254 100644 --- a/packages/client/ui-models/README.zh.md +++ b/packages/client/ui-models/README.zh.md @@ -8,7 +8,7 @@ 前序首次使用引导页面完成后,DeepSeek 步骤会从同一个联接快照得出 `deepseek-official` 的就绪状态。它通过 `llm-deepseek` 的可配置提供方声明识别官方适配器,因此同 id 但未声明的存活路由不属于可修复配置。若 `apiKey` 字面量对应的 secret 槽位标记为已设置,或凭据引用已配置,该步骤会直接完成而不渲染,其中包括来自启动环境且只读的凭据。只有已挂载且活跃、引用可写但尚未配置的适配器才会显示前往「设置」Models 分区的页面;密钥输入和 `credentials.set` 仅由该分区已有的设置卡片负责,该步骤绝不持有 secret。适配器缺失、路由不活跃、联接失败、部署只读或设置/凭据能力不可用时,该步骤均不渲染并直接完成,以免首次使用引导阻塞产品;Models 页仍是诊断界面。 -每一次编辑都以 `settings.mutate` 的路径 op 落到已存分节上——每个变更字段一条 set、每个清空字段一条 unset、删除提供方行则是单独一条 unset。页面自始至终只持有**脱敏后**的 descriptor,因此它点名自己看得见的字段,而不是重建分节:一个它从未收到过的已存字面机密不会被任何 op 提及,也就得以留存。DeepSeek 的 `models` 是一个按值整体替换的数组:编辑器会显示继承而来的生效模型行,直到第一次模型编辑将完整数组具化到用户层;重置则会取消该覆盖。上下文窗口按数值键入,可带十进制的 `K` 或 `M` 后缀(`256K`、`1M`;`1M` 即 1000K),存储为纯数值,回显时写成能够往返的最短形式。空 ID、重复 ID、显式填写的空名称,以及无法读取、非正数或非整数的上下文窗口都会在写入前失败。每次写入都携带该卡片打开时的 `revision`,因此来自另一个标签页或对 `settings.yaml` 的外部编辑所产生的并发写入会以 `settings-conflict` 被拒绝,卡片会请用户重新打开,而不是把自己的陈旧快照重放上去。页面加载完成后会在推送的失效事件(`settings/changed`、`credentials/changed`、`models/changed` 与 `connection/reset`)上重拉,因此外部的 `settings.yaml` 编辑、第二个标签页或 settings 新生的路由都无需轮询即可收敛。 +每一次编辑都以 `settings.mutate` 的路径 op 落到已存分节上——每个变更字段一条 set、每个清空字段一条 unset、删除提供方行则是单独一条 unset。页面自始至终只持有**脱敏后**的 descriptor,因此它点名自己看得见的字段,而不是重建分节:一个它从未收到过的已存字面机密不会被任何 op 提及,也就得以留存。DeepSeek 的 `models` 是一个按值整体替换的数组:编辑器会显示继承而来的生效模型行,直到第一次模型编辑将完整数组具化到用户层;重置则会取消该覆盖。每个模型行承载模型 ID 与显示名称,其上下文窗口与最大输出 token 数则收在该行自己的折叠区里,与 pi-ai 提供方表单采用的形态相同。两项容量都按数值键入,可带十进制的 `K` 或 `M` 后缀(`256K`、`1M`;`1M` 即 1000K),存储为纯数值,回显时写成能够往返的最短形式。空 ID、重复 ID、显式填写的空名称,以及无法读取、非正数或非整数的容量都会在写入前失败。每次写入都携带该卡片打开时的 `revision`,因此来自另一个标签页或对 `settings.yaml` 的外部编辑所产生的并发写入会以 `settings-conflict` 被拒绝,卡片会请用户重新打开,而不是把自己的陈旧快照重放上去。页面加载完成后会在推送的失效事件(`settings/changed`、`credentials/changed`、`models/changed` 与 `connection/reset`)上重拉,因此外部的 `settings.yaml` 编辑、第二个标签页或 settings 新生的路由都无需轮询即可收敛。 ## 模型体验 diff --git a/packages/client/ui-models/src/client/DeepSeekModelsEditor.tsx b/packages/client/ui-models/src/client/DeepSeekModelsEditor.tsx index 13aab18dab..57221cc93a 100644 --- a/packages/client/ui-models/src/client/DeepSeekModelsEditor.tsx +++ b/packages/client/ui-models/src/client/DeepSeekModelsEditor.tsx @@ -7,33 +7,46 @@ import { useState } from 'react' import type { ReactNode } from 'react' -import { IconPlusOutline16, IconTrashOutline16 } from '@deepseek-ai/dsh-client-ui-primitives' +import { + IconChevronDownOutline14, IconChevronRightOutline14, IconPlusOutline16, IconTrashOutline16, +} from '@deepseek-ai/dsh-client-ui-primitives' import type { en } from './locales.ts' import styles from './ModelsSection.module.css' /** One catalog entry kept structurally open so hidden or future fields survive an edit. */ export type DeepSeekModelDraft = Record -/** Accepted context-window spellings: a decimal count with an optional K/M suffix. */ -const CONTEXT_WINDOW_PATTERN = /^(\d+(?:\.\d+)?)([km])?$/i +/** The catalog fields this editor writes. */ +type CatalogField = 'id' | 'name' | 'contextWindow' | 'maxTokens' + +/** The two token counts edited as K/M-suffixed text behind a row's disclosure. */ +type CapacityField = 'contextWindow' | 'maxTokens' + +/** Row index encoded in an editing-buffer key. */ +function rowOf(key: string): number { + return Number(key.slice(0, key.indexOf(':'))) +} + +/** Accepted capacity spellings: a decimal count with an optional K/M suffix. */ +const CAPACITY_PATTERN = /^(\d+(?:\.\d+)?)([km])?$/i /** Decimal suffix scales — `1M` is 1000K, matching how model capacities are quoted. */ -const CONTEXT_WINDOW_SCALE = { k: 1_000, m: 1_000_000 } as const +const CAPACITY_SCALE = { k: 1_000, m: 1_000_000 } as const /** - * Read a typed context window, so a user can write `256K` or `1M` instead of - * counting zeroes. The stored value stays a plain token count. + * Read a typed capacity, so a user can write `256K` or `1M` instead of counting + * zeroes. The stored value stays a plain token count. * @param text - raw field text. * @returns the count; `undefined` when blank (inherit), `NaN` when unreadable * (rejected by {@link validateDeepSeekModels} before any write). */ -export function parseContextWindow(text: string): number | undefined { +export function parseCapacity(text: string): number | undefined { const trimmed = text.trim() if (trimmed.length === 0) return undefined - const match = CONTEXT_WINDOW_PATTERN.exec(trimmed) + const match = CAPACITY_PATTERN.exec(trimmed) if (match === null) return Number.NaN const suffix = match[2]?.toLowerCase() - const scale = suffix === 'k' || suffix === 'm' ? CONTEXT_WINDOW_SCALE[suffix] : 1 + const scale = suffix === 'k' || suffix === 'm' ? CAPACITY_SCALE[suffix] : 1 const scaled = Number(match[1]) * scale // A decimal multiple is exact in intent but not in binary floating point // (2.3 * 1e6 lands a few ULPs high), so an integral intent snaps back. @@ -43,15 +56,15 @@ export function parseContextWindow(text: string): number | undefined { /** * Spell a stored count back in the shortest form that survives a round trip - * through {@link parseContextWindow}; a count that is not a whole number of + * through {@link parseCapacity}; a count that is not a whole number of * thousands stays written out. - * @param value - stored context window. + * @param value - stored capacity. * @returns the field text. */ -export function formatContextWindow(value: number): string { +export function formatCapacity(value: number): string { if (!Number.isInteger(value) || value <= 0) return String(value) - if (value % CONTEXT_WINDOW_SCALE.m === 0) return `${String(value / CONTEXT_WINDOW_SCALE.m)}M` - if (value % CONTEXT_WINDOW_SCALE.k === 0) return `${String(value / CONTEXT_WINDOW_SCALE.k)}K` + if (value % CAPACITY_SCALE.m === 0) return `${String(value / CAPACITY_SCALE.m)}M` + if (value % CAPACITY_SCALE.k === 0) return `${String(value / CAPACITY_SCALE.k)}K` return String(value) } @@ -61,6 +74,7 @@ export interface DeepSeekModelsValidationFailure { index: number /** Message key owned by the Models settings section. */ key: 'modelIdRequired' | 'modelIdDuplicate' | 'modelNameInvalid' | 'modelContextInvalid' + | 'modelMaxTokensInvalid' } /** Convert a schema-validated catalog value into records without dropping hidden fields. */ @@ -99,6 +113,11 @@ export function validateDeepSeekModels(value: unknown): DeepSeekModelsValidation && (typeof contextWindow !== 'number' || !Number.isInteger(contextWindow) || contextWindow <= 0)) { return { index, key: 'modelContextInvalid' } } + const maxTokens = model['maxTokens'] + if (maxTokens !== undefined + && (typeof maxTokens !== 'number' || !Number.isInteger(maxTokens) || maxTokens <= 0)) { + return { index, key: 'modelMaxTokensInvalid' } + } } return undefined } @@ -109,8 +128,10 @@ export interface DeepSeekModelsEditorProps { models: readonly DeepSeekModelDraft[] /** Whether the user layer currently owns the whole array. */ overridden: boolean - /** Fallback capacity used when a row omits its exact value. */ + /** Fallback context capacity used when a row omits its exact value. */ defaultContextWindow: number | undefined + /** Fallback output cap used when a row omits its exact value. */ + defaultMaxTokens: number | undefined /** Section copy. */ t: (key: keyof typeof en) => string /** Disable every mutation. */ @@ -122,25 +143,27 @@ export interface DeepSeekModelsEditorProps { } /** - * Render the direct DeepSeek adapter's id/name/context-window catalog. + * Render the direct DeepSeek adapter's model catalog: id and display name on + * each row, capacities behind the row's own disclosure. * @param props - effective rows plus the array-level override actions. * @returns the catalog editor. */ export function DeepSeekModelsEditor(props: DeepSeekModelsEditorProps): ReactNode { - // Context windows are edited as text, so a row's keystrokes are held here + // Capacities are edited as text, so a field's keystrokes are held here // rather than re-derived from the parsed count on every change, which would // rewrite `1000` to `1K` mid-word. Unreadable text is kept past blur so the // save-time rejection names a row the user can still see — which is why - // this is one entry PER ROW: a single active buffer would be displaced by - // editing any other row, and the abandoned row would fall back to rendering - // its stored NaN as the literal `NaN`. + // this is one entry PER FIELD: a single active buffer would be displaced by + // editing any other field, and the abandoned one would fall back to + // rendering its stored NaN as the literal `NaN`. // - // Entries are keyed by row index, so the two operations that move indexes - // maintain them: `remove` re-keys around the dropped row, and reset clears - // them all because the rows they annotated are gone. - const [editing, setEditing] = useState>(() => new Map()) + // Keys carry the row index, so the two operations that move indexes maintain + // them: `remove` re-keys around the dropped row, and reset clears them all + // because the rows they annotated are gone. + const [editing, setEditing] = useState>(() => new Map()) + const [expanded, setExpanded] = useState>(() => new Set()) - const update = (index: number, key: 'id' | 'name' | 'contextWindow', value: unknown): void => { + const update = (index: number, key: CatalogField, value: unknown): void => { const next = props.models.map((model, at) => { const copy = { ...model } if (at !== index) return copy @@ -153,10 +176,20 @@ export function DeepSeekModelsEditor(props: DeepSeekModelsEditorProps): ReactNod const remove = (index: number): void => { setEditing((current) => { - const next = new Map() - for (const [at, text] of current) { + const next = new Map() + for (const [key, text] of current) { + const at = rowOf(key) if (at === index) continue - next.set(at > index ? at - 1 : at, text) + // Only the row number moves; the field half of the key is untouched. + next.set(at > index ? key.replace(/^\d+/, String(at - 1)) : key, text) + } + return next + }) + setExpanded((current) => { + const next = new Set() + for (const at of current) { + if (at === index) continue + next.add(at > index ? at - 1 : at) } return next }) @@ -165,34 +198,73 @@ export function DeepSeekModelsEditor(props: DeepSeekModelsEditorProps): ReactNod const reset = (): void => { setEditing(new Map()) + setExpanded(new Set()) props.onReset() } - /** The row's field text: its live keystrokes, else the stored count spelled short. */ - const contextText = (model: DeepSeekModelDraft, index: number): string => { - const typed = editing.get(index) - if (typed !== undefined) return typed - const value = model['contextWindow'] - return typeof value === 'number' ? formatContextWindow(value) : '' - } - - const settleContext = (index: number): void => { - const typed = editing.get(index) - if (typed === undefined) return - // Unreadable text stays on screen: the save-time rejection names a row the - // user can still see and correct. - const parsed = parseContextWindow(typed) - if (parsed !== undefined && Number.isNaN(parsed)) return - setEditing((current) => { - const next = new Map(current) - next.delete(index) + const toggle = (index: number): void => { + setExpanded((current) => { + const next = new Set(current) + if (!next.delete(index)) next.add(index) return next }) } + /** The field's text: its live keystrokes, else the stored count spelled short. */ + const capacityText = (model: DeepSeekModelDraft, index: number, field: CapacityField): string => { + const typed = editing.get(`${String(index)}:${field}`) + if (typed !== undefined) return typed + const value = model[field] + return typeof value === 'number' ? formatCapacity(value) : '' + } + + const settleCapacity = (index: number, field: CapacityField): void => { + const key = `${String(index)}:${field}` + const typed = editing.get(key) + if (typed === undefined) return + // Unreadable text stays on screen: the save-time rejection names a row the + // user can still see and correct. + const parsed = parseCapacity(typed) + if (parsed !== undefined && Number.isNaN(parsed)) return + setEditing((current) => { + const next = new Map(current) + next.delete(key) + return next + }) + } + + /** One capacity field of one row, rendered inside the row's disclosure. */ + const capacityField = ( + model: DeepSeekModelDraft, + index: number, + field: CapacityField, + fallback: number | undefined, + ): ReactNode => ( + + ) + return (
-
+
{props.t('models')} @@ -215,66 +287,65 @@ export function DeepSeekModelsEditor(props: DeepSeekModelsEditorProps): ReactNod {props.models.length === 0 ?

{props.t('modelsEmpty')}

: ( -
- {/* Captions sit above the rows and are hidden from assistive tech: - every field already carries the indexed `aria-label` naming it. */} - +
{props.models.map((model, index) => ( -
- { update(index, 'id', event.target.value) }} - onBlur={(event) => { - // Settle a pasted id rather than trimming per keystroke, - // which would stop the user typing an interior space. - const trimmed = event.target.value.trim() - if (trimmed !== event.target.value) update(index, 'id', trimmed) - }} - /> - { - update(index, 'name', event.target.value === '' ? undefined : event.target.value) - }} - /> - { - const text = event.target.value - setEditing(current => new Map(current).set(index, text)) - update(index, 'contextWindow', parseContextWindow(text)) - }} - onBlur={() => { settleContext(index) }} - /> - +
+
+ { update(index, 'id', event.target.value) }} + onBlur={(event) => { + // Settle a pasted id rather than trimming per keystroke, + // which would stop the user typing an interior space. + const trimmed = event.target.value.trim() + if (trimmed !== event.target.value) update(index, 'id', trimmed) + }} + /> + { + update(index, 'name', event.target.value === '' ? undefined : event.target.value) + }} + /> + + +
+ {expanded.has(index) + ? ( +
+ {capacityField(model, index, 'contextWindow', props.defaultContextWindow)} + {capacityField(model, index, 'maxTokens', props.defaultMaxTokens)} +
+ ) + : null}
))}
diff --git a/packages/client/ui-models/src/client/ModelsSection.module.css b/packages/client/ui-models/src/client/ModelsSection.module.css index eb000d4d99..2e7ea02326 100644 --- a/packages/client/ui-models/src/client/ModelsSection.module.css +++ b/packages/client/ui-models/src/client/ModelsSection.module.css @@ -156,8 +156,7 @@ .dangerButton:disabled, .addButton:disabled, .linkButton:disabled, -.addModelButton:disabled, -.rowDelete:disabled { +.addModelButton:disabled { opacity: 0.4; cursor: default; } @@ -168,7 +167,7 @@ .addButton:focus-visible, .linkButton:focus-visible, .addModelButton:focus-visible, -.rowDelete:focus-visible, +.iconButton:focus-visible, .customizedSummary:focus-visible { outline: none; box-shadow: 0 0 0 2px var(--dsw-alias-border-l3); @@ -347,13 +346,6 @@ border-top: 1px solid var(--dsw-alias-border-l2); } -.modelCatalogHeader { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 12px; -} - .modelCatalogHeading { display: flex; flex-direction: column; @@ -375,54 +367,89 @@ line-height: 18px; } -.modelTable { +/* Model list, shared with the pi-ai provider form (PR #1368): one bordered + entry per model, id and display name on the row, capacities behind the + row's own disclosure. The token names are this file's, not that branch's — + `--dsw-alias-border-subtle`, `--dsw-alias-text-tertiary`, and + `--dsw-alias-text-primary` are undefined here and resolve to their + light-mode literals, which is the defect this section was just moved off. */ +.modelList { display: flex; flex-direction: column; - gap: 6px; -} - -/* Captions and rows share one track list so the columns line up. */ -.modelColumns, -.modelRow { - display: grid; - grid-template-columns: minmax(0, 1.25fr) minmax(0, 1.25fr) minmax(88px, 0.75fr) 28px; - align-items: center; gap: 8px; } -.modelColumns { - color: var(--dsw-alias-label-tertiary); - font-size: 12px; - line-height: 18px; +.modelListHead { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; } -/* The inset belongs on the caption cell, not the strip: padding on the grid - container would narrow its tracks against the rows' and walk the captions - left column by column. 1px border + 10px padding is the field text inset. */ -.modelColumns > span { - padding-left: 11px; +.modelEntry { + border: 1px solid var(--dsw-alias-border-l2); + border-radius: 8px; + padding: 6px; } -.rowDelete { - box-sizing: border-box; - position: relative; +.modelRow { display: grid; - place-items: center; + grid-template-columns: minmax(0, 1.4fr) minmax(0, 1fr) auto auto; + align-items: center; + gap: 6px; +} + +/* Square, label-free affordances: the row's own inputs carry the meaning, so + the actions stay glyphs and announce themselves through aria-label. */ +.iconButton { + box-sizing: border-box; + display: inline-flex; + align-items: center; + justify-content: center; width: 28px; height: 28px; - padding: 0; border: none; - border-radius: 8px; + border-radius: 6px; background: transparent; color: var(--dsw-alias-label-tertiary); cursor: pointer; } -.rowDelete:hover:not(:disabled) { +.iconButton:hover:not(:disabled) { + background: var(--dsw-alias-interactive-bg-hover); + color: var(--dsw-alias-label-primary); +} + +.iconButton:disabled { + cursor: default; + opacity: 0.4; +} + +/* The delete glyph keeps the danger tint the rest of the section uses. */ +.iconButtonDanger:hover:not(:disabled) { background: var(--dsw-alias-interactive-bg-hover-danger); color: var(--dsw-alias-state-error-primary); } +.modelAdvanced { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); + gap: 8px; + padding: 8px 4px 2px; +} + +.modelField { + display: flex; + flex-direction: column; + gap: 4px; +} + +.modelFieldLabel { + color: var(--dsw-alias-label-tertiary); + font-size: 12px; + line-height: 18px; +} + .modelEmpty { padding: 12px; border: 1px dashed var(--dsw-alias-border-l3); diff --git a/packages/client/ui-models/src/client/ProviderEditor.tsx b/packages/client/ui-models/src/client/ProviderEditor.tsx index a27fa14002..6876e58356 100644 --- a/packages/client/ui-models/src/client/ProviderEditor.tsx +++ b/packages/client/ui-models/src/client/ProviderEditor.tsx @@ -262,6 +262,7 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode { const modelsOverridden = hasPath(draft, ['models']) const models = modelDrafts(modelsOverridden ? customModels : inheritedModels()) const defaultContextWindow = getPath(fallback, ['defaultContextWindow']) + const defaultMaxTokens = getPath(fallback, ['maxTokens']) return ( <>
@@ -323,6 +324,7 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode { defaultContextWindow={typeof defaultContextWindow === 'number' ? defaultContextWindow : undefined} + defaultMaxTokens={typeof defaultMaxTokens === 'number' ? defaultMaxTokens : undefined} t={t} disabled={disabled} onChange={(next) => { setDraft(current => setPath(current, ['models'], next)) }} diff --git a/packages/client/ui-models/src/client/locales.ts b/packages/client/ui-models/src/client/locales.ts index f64700b11f..bb1254e46b 100644 --- a/packages/client/ui-models/src/client/locales.ts +++ b/packages/client/ui-models/src/client/locales.ts @@ -40,6 +40,9 @@ export const en = { modelNamePlaceholder: 'Uses the model ID when empty', contextWindow: 'Context window', contextWindowPlaceholder: 'Uses the provider default', + maxTokens: 'Max output tokens', + maxTokensPlaceholder: 'Uses the provider default', + modelAdvanced: 'Capacities', addModel: 'Add model', removeModel: 'Delete model', modelsEmpty: 'No models will be shown in the selector. Unlisted IDs can still be sent directly.', @@ -47,6 +50,7 @@ export const en = { modelIdDuplicate: 'Model ID must be unique.', modelNameInvalid: 'Display name cannot be empty.', modelContextInvalid: 'Context window must be a positive count, like 131072, 256K, or 1M.', + modelMaxTokensInvalid: 'Max output tokens must be a positive count, like 8192, 64K, or 1M.', advancedHint: 'Other fields live in settings.yaml; edit that section directly.', onboardingTitle: 'Add an API key to get started', onboardingDescription: 'Configure the official DeepSeek provider to start building.', @@ -97,6 +101,9 @@ export const zh: typeof en = { modelNamePlaceholder: '留空时使用模型 ID', contextWindow: '上下文窗口', contextWindowPlaceholder: '使用提供方默认值', + maxTokens: '最大输出 token 数', + maxTokensPlaceholder: '使用提供方默认值', + modelAdvanced: '容量', addModel: '添加模型', removeModel: '删除模型', modelsEmpty: '模型选择器中将不显示任何模型;目录外 ID 仍可直接发送。', @@ -104,6 +111,7 @@ export const zh: typeof en = { modelIdDuplicate: '模型 ID 不能重复。', modelNameInvalid: '显示名称不能为空。', modelContextInvalid: '上下文窗口必须是正数,例如 131072、256K 或 1M。', + modelMaxTokensInvalid: '最大输出 token 数必须是正数,例如 8192、64K 或 1M。', advancedHint: '其余字段在 settings.yaml 中,请直接编辑对应段。', onboardingTitle: '添加一个 API Key 开始使用', onboardingDescription: '配置 DeepSeek 官方模型,即可开始使用。', diff --git a/packages/client/ui-models/tests/components.spec.tsx b/packages/client/ui-models/tests/components.spec.tsx index 7346332a2f..6c053bba59 100644 --- a/packages/client/ui-models/tests/components.spec.tsx +++ b/packages/client/ui-models/tests/components.spec.tsx @@ -9,7 +9,7 @@ import { ModelsSection, needsSetup, removeProviderProfile } from '../src/client/ import type { ModelsSectionInjected, ModelsSectionProps } from '../src/client/ModelsSection.tsx' import { pathOps } from '../src/client/ProviderEditor.tsx' import { - DeepSeekModelsEditor, formatContextWindow, modelDrafts, parseContextWindow, validateDeepSeekModels, + DeepSeekModelsEditor, formatCapacity, modelDrafts, parseCapacity, validateDeepSeekModels, } from '../src/client/DeepSeekModelsEditor.tsx' import { deriveKeyRef, ModelsSettingsStore } from '../src/client/store.ts' import type { ProviderRow } from '../src/client/store.ts' @@ -19,6 +19,16 @@ afterEach(cleanup) const t: ModelsSectionInjected['t'] = key => en[key] +/** Open one row's capacity disclosure (1-based, as the labels read). */ +function expandRow(position: number): void { + fireEvent.click(screen.getByLabelText(`${en.modelAdvanced} ${String(position)}`)) +} + +/** The capacity inputs of every open row, in row order. */ +function capacityInputs(label: string): HTMLInputElement[] { + return screen.getAllByLabelText(new RegExp(label)) +} + const PiAiConfig = Schema.object({ token: Schema.string().role('secret'), providers: Schema.dict(Schema.object({ @@ -79,9 +89,10 @@ function wireNamespaces(): SettingsNamespaceView[] { baseURL: 'https://base', reasoningEffort: 'high', defaultContextWindow: 1_000_000, + maxTokens: 256_000, models: DEFAULT_DEEPSEEK_MODELS, }, - base: { defaultContextWindow: 1_000_000, models: DEFAULT_DEEPSEEK_MODELS }, + base: { defaultContextWindow: 1_000_000, maxTokens: 256_000, models: DEFAULT_DEEPSEEK_MODELS }, user: { reasoningEffort: 'high' }, applies: 'live', secrets: [{ path: ['apiKey'], set: false }], @@ -297,10 +308,11 @@ describe('ModelsSection', () => { fireEvent.click(screen.getByText(en.addModel)) const ids = screen.getAllByLabelText(new RegExp(en.modelId)) const names = screen.getAllByLabelText(new RegExp(en.modelName)) - const windows = screen.getAllByLabelText(new RegExp(en.contextWindow)) + expandRow(3) fireEvent.change(ids[2] as HTMLInputElement, { target: { value: 'private-preview' } }) fireEvent.change(names[2] as HTMLInputElement, { target: { value: 'Private Preview' } }) - fireEvent.change(windows[2] as HTMLInputElement, { target: { value: '131072' } }) + // Only row 3 is open, so its capacity is addressed by its own label. + fireEvent.change(screen.getByLabelText(`${en.contextWindow} 3`), { target: { value: '131072' } }) fireEvent.click(screen.getByText(en.apply)) await waitFor(() => { expect(mutate).toHaveBeenCalledTimes(1) }) @@ -345,39 +357,46 @@ describe('ModelsSection', () => { expect(validateDeepSeekModels([{ id: 'model', contextWindow: 0 }])) .toEqual({ index: 0, key: 'modelContextInvalid' }) expect(validateDeepSeekModels([{ id: 'model', contextWindow: 1 }])).toBeUndefined() + expect(validateDeepSeekModels([{ id: 'model', maxTokens: null }])) + .toEqual({ index: 0, key: 'modelMaxTokensInvalid' }) + expect(validateDeepSeekModels([{ id: 'model', maxTokens: 1.5 }])) + .toEqual({ index: 0, key: 'modelMaxTokensInvalid' }) + expect(validateDeepSeekModels([{ id: 'model', maxTokens: 0 }])) + .toEqual({ index: 0, key: 'modelMaxTokensInvalid' }) + expect(validateDeepSeekModels([{ id: 'model', maxTokens: 8192 }])).toBeUndefined() }) it('reads context windows written as counts, thousands, or millions', () => { - expect(parseContextWindow('')).toBeUndefined() - expect(parseContextWindow(' ')).toBeUndefined() - expect(parseContextWindow('131072')).toBe(131_072) - expect(parseContextWindow(' 256K ')).toBe(256_000) - expect(parseContextWindow('256k')).toBe(256_000) - expect(parseContextWindow('1M')).toBe(1_000_000) - expect(parseContextWindow('1m')).toBe(1_000_000) + expect(parseCapacity('')).toBeUndefined() + expect(parseCapacity(' ')).toBeUndefined() + expect(parseCapacity('131072')).toBe(131_072) + expect(parseCapacity(' 256K ')).toBe(256_000) + expect(parseCapacity('256k')).toBe(256_000) + expect(parseCapacity('1M')).toBe(1_000_000) + expect(parseCapacity('1m')).toBe(1_000_000) // 1M is 1000K, not 1024K: capacities are quoted in decimal. - expect(parseContextWindow('1M')).toBe(parseContextWindow('1000K')) + expect(parseCapacity('1M')).toBe(parseCapacity('1000K')) // 2.3 * 1e6 is a few ULPs high in binary floating point; an integral // intent must not become a fractional count the validator rejects. - expect(parseContextWindow('2.3M')).toBe(2_300_000) - expect(Number.isInteger(parseContextWindow('1.5M'))).toBe(true) + expect(parseCapacity('2.3M')).toBe(2_300_000) + expect(Number.isInteger(parseCapacity('1.5M'))).toBe(true) // A genuinely fractional count survives as one, for the validator to reject. - expect(parseContextWindow('0.0001K')).toBeCloseTo(0.1) - expect(parseContextWindow('abc')).toBeNaN() - expect(parseContextWindow('1G')).toBeNaN() - expect(parseContextWindow('1M1')).toBeNaN() + expect(parseCapacity('0.0001K')).toBeCloseTo(0.1) + expect(parseCapacity('abc')).toBeNaN() + expect(parseCapacity('1G')).toBeNaN() + expect(parseCapacity('1M1')).toBeNaN() }) it('spells a stored count in the shortest form that round-trips', () => { - expect(formatContextWindow(1_000_000)).toBe('1M') - expect(formatContextWindow(256_000)).toBe('256K') - expect(formatContextWindow(1_500_000)).toBe('1500K') - expect(formatContextWindow(131_072)).toBe('131072') + expect(formatCapacity(1_000_000)).toBe('1M') + expect(formatCapacity(256_000)).toBe('256K') + expect(formatCapacity(1_500_000)).toBe('1500K') + expect(formatCapacity(131_072)).toBe('131072') // Values the validator will reject are shown as-is rather than dressed up. - expect(formatContextWindow(Number.NaN)).toBe('NaN') - expect(formatContextWindow(0)).toBe('0') + expect(formatCapacity(Number.NaN)).toBe('NaN') + expect(formatCapacity(0)).toBe('0') for (const text of ['1M', '256K', '131072', '1500K']) { - expect(formatContextWindow(parseContextWindow(text) as number)).toBe(text) + expect(formatCapacity(parseCapacity(text) as number)).toBe(text) } }) @@ -386,7 +405,9 @@ describe('ModelsSection', () => { mutate: vi.fn(() => Promise.resolve(ok(wireNamespaces()[0]))), }) fireEvent.click(screen.getByText(en.customized)) - const windows = screen.getAllByLabelText(new RegExp(en.contextWindow)) + expandRow(1) + expandRow(2) + const windows = capacityInputs(en.contextWindow) // The inherited 1000000 reads back short. expect((windows[0] as HTMLInputElement).value).toBe('1M') @@ -422,7 +443,9 @@ describe('ModelsSection', () => { it('keeps unreadable context-window text on screen and refuses the write', async () => { const { mutate } = await mountSection() fireEvent.click(screen.getByText(en.customized)) - const windows = screen.getAllByLabelText(new RegExp(en.contextWindow)) + expandRow(1) + expandRow(2) + const windows = capacityInputs(en.contextWindow) fireEvent.change(windows[0] as HTMLInputElement, { target: { value: '1 gazillion' } }) // Blurring a row that is not the edited one leaves the buffer alone. fireEvent.blur(windows[1] as HTMLInputElement) @@ -483,7 +506,9 @@ describe('ModelsSection', () => { // losing the text the user was told they could still correct. await mountSection() fireEvent.click(screen.getByText(en.customized)) - const windows = screen.getAllByLabelText(new RegExp(en.contextWindow)) + expandRow(1) + expandRow(2) + const windows = capacityInputs(en.contextWindow) fireEvent.change(windows[0] as HTMLInputElement, { target: { value: 'not a number' } }) fireEvent.blur(windows[0] as HTMLInputElement) fireEvent.change(windows[1] as HTMLInputElement, { target: { value: '2M' } }) @@ -495,13 +520,15 @@ describe('ModelsSection', () => { it('re-keys the typed text around a removed row', async () => { await mountSection() fireEvent.click(screen.getByText(en.customized)) - const windows = (): HTMLInputElement[] => - screen.getAllByLabelText(new RegExp(en.contextWindow)) + const windows = (): HTMLInputElement[] => capacityInputs(en.contextWindow) const removeRow = (at: number): void => { - fireEvent.click(screen.getAllByText(en.removeModel)[at] as HTMLElement) + fireEvent.click(screen.getAllByLabelText(new RegExp(en.removeModel))[at] as HTMLElement) } // Three rows, with text parked on the outer two. fireEvent.click(screen.getByText(en.addModel)) + expandRow(1) + expandRow(2) + expandRow(3) fireEvent.change(windows()[0] as HTMLInputElement, { target: { value: 'top text' } }) fireEvent.blur(windows()[0] as HTMLInputElement) fireEvent.change(windows()[2] as HTMLInputElement, { target: { value: 'bottom text' } }) @@ -529,12 +556,15 @@ describe('ModelsSection', () => { mutate: vi.fn(() => Promise.resolve(ok(wireNamespaces()[0]))), }) fireEvent.click(screen.getByText(en.customized)) - const windows = screen.getAllByLabelText(new RegExp(en.contextWindow)) + expandRow(1) + const windows = capacityInputs(en.contextWindow) fireEvent.change(windows[0] as HTMLInputElement, { target: { value: 'garbage' } }) fireEvent.blur(windows[0] as HTMLInputElement) fireEvent.click(screen.getByText(en.resetModels)) - const restored = screen.getAllByLabelText(new RegExp(en.contextWindow)) + // Reset collapses every row, so the restored capacity needs opening again. + expandRow(1) + const restored = capacityInputs(en.contextWindow) expect((restored[0] as HTMLInputElement).value).toBe('1M') // Reset put the draft back where it started, so Apply writes nothing at @@ -544,6 +574,40 @@ describe('ModelsSection', () => { expect(mutate).not.toHaveBeenCalled() }) + it('edits an output cap per model and carries its text across a removal', async () => { + const { mutate } = await mountSection({ + mutate: vi.fn(() => Promise.resolve(ok(wireNamespaces()[0]))), + }) + fireEvent.click(screen.getByText(en.customized)) + expandRow(1) + expandRow(2) + // The profile's own cap is the placeholder both rows inherit. + expect(capacityInputs(en.maxTokens).map(input => input.placeholder)).toEqual(['256K', '256K']) + + fireEvent.change(screen.getByLabelText(`${en.maxTokens} 2`), { target: { value: '64K' } }) + fireEvent.blur(screen.getByLabelText(`${en.maxTokens} 2`)) + expect(screen.getByLabelText(`${en.maxTokens} 2`).value).toBe('64K') + + // Dropping the row above carries the cap text down with its own row. + fireEvent.click(screen.getAllByLabelText(new RegExp(en.removeModel))[0] as HTMLElement) + expect(screen.getByLabelText(`${en.maxTokens} 1`).value).toBe('64K') + // The disclosure closes on a second press. + expandRow(1) + expect(screen.queryByLabelText(`${en.maxTokens} 1`)).toBeNull() + + fireEvent.click(screen.getByText(en.apply)) + await waitFor(() => { expect(mutate).toHaveBeenCalledTimes(1) }) + expect(mutate.mock.calls[0]?.[0]).toEqual({ + ns: 'llm-deepseek', + ops: [{ + op: 'set', + path: ['models'], + value: [{ ...DEFAULT_DEEPSEEK_MODELS[1], maxTokens: 64_000 }], + }], + expectedRevision: 0, + }) + }) + it('settles a pasted id and refuses whitespace that would never match', async () => { await mountSection() fireEvent.click(screen.getByText(en.customized)) @@ -567,14 +631,18 @@ describe('ModelsSection', () => { models={[{}]} overridden={false} defaultContextWindow={undefined} + defaultMaxTokens={undefined} t={t} disabled={true} onChange={vi.fn()} onReset={vi.fn()} />) expect(screen.getByLabelText(`${en.modelId} 1`).value).toBe('') + expandRow(1) expect(screen.getByLabelText(`${en.contextWindow} 1`).placeholder) .toBe(en.contextWindowPlaceholder) + expect(screen.getByLabelText(`${en.maxTokens} 1`).placeholder) + .toBe(en.maxTokensPlaceholder) }) it('can empty and reset the model override, then clear optional fields without dropping hidden data', async () => { @@ -582,14 +650,15 @@ describe('ModelsSection', () => { mutate: vi.fn(() => Promise.resolve(ok(wireNamespaces()[0]))), }) fireEvent.click(screen.getByText(en.customized)) - fireEvent.click(screen.getAllByText(en.removeModel)[0] as HTMLElement) - fireEvent.click(screen.getByText(en.removeModel)) + fireEvent.click(screen.getAllByLabelText(new RegExp(en.removeModel))[0] as HTMLElement) + fireEvent.click(screen.getByLabelText(new RegExp(en.removeModel))) expect(screen.getByText(en.modelsEmpty)).toBeTruthy() fireEvent.click(screen.getByText(en.resetModels)) expect(screen.getByText(en.modelsInherited)).toBeTruthy() const names = screen.getAllByLabelText(new RegExp(en.modelName)) - const windows = screen.getAllByLabelText(new RegExp(en.contextWindow)) + expandRow(1) + const windows = capacityInputs(en.contextWindow) fireEvent.change(names[0] as HTMLInputElement, { target: { value: '' } }) fireEvent.change(windows[0] as HTMLInputElement, { target: { value: '' } }) fireEvent.click(screen.getByText(en.apply)) diff --git a/packages/client/ui-models/tests/styles.spec.ts b/packages/client/ui-models/tests/styles.spec.ts index 9139d01f4e..879d9812a2 100644 --- a/packages/client/ui-models/tests/styles.spec.ts +++ b/packages/client/ui-models/tests/styles.spec.ts @@ -3,6 +3,10 @@ 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') +const tokens = readFileSync( + fileURLToPath(new URL('../../ui-theme/src/styles/design-platform.css', import.meta.url)), + 'utf8', +) /** The declarations of one top-level rule, by selector. */ function block(selector: string): string { @@ -12,12 +16,15 @@ function block(selector: string): string { } describe('ModelsSection theme styles', () => { - it('uses the shared theme tokens without light-only fallbacks', () => { - // The section once named `--border`/`--surface`/`--text-*`/`--accent-strong`, - // which nothing in this app defines, so it rendered the light-mode literals - // written as their fallbacks and stayed light under the dark theme. + it('names only theme variables the token sheet defines', () => { + // A `--dsw-*` name the sheet never declares is not a near miss: it silently + // resolves to whatever literal sits in its fallback slot, which is how this + // section stayed light under the dark theme before. Undeclared names have + // no fallback at all and inherit, so both spellings must fail here. + const named = [...css.matchAll(/var\((--dsw-[a-z0-9-]+)/g)].map(match => match[1]) + const undeclared = [...new Set(named)].filter(name => !tokens.includes(` ${String(name)}:`)) + expect(undeclared).toEqual([]) expect(css).not.toMatch(/var\(--(?:surface|text-|border|accent-strong)/) - expect(css).toContain('color: var(--dsw-alias-label-primary)') }) it('separates the row card from the editor it expands into', () => { From d907c8475fd1653c7b97596fd7a2753d9fa57957 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Tue, 4 Aug 2026 16:13:52 +0800 Subject: [PATCH 13/29] fix(web): keep the input card in place across view tabs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The composer seat is one node laid out against two different edges: in Chat it is a sticky child of the column's scroller and rides its content box, which a space-consuming scrollbar shortens, while a view declaring a composer overlay gets an absolutely positioned seat against the padding box, which no bar reduces. With the transcript scrolling the two tabs disagreed by the bar's width, so the centred input card moved 4px sideways on every switch — and the same 4px inside Chat when a growing transcript began to scroll. The column now reserves its scrollbar gutter unconditionally and states the overlay branch a scroll container on the same axes, so both states measure against the same width. --- ...-composer-tab-gutter-reservation.i18n.yaml | 6 + ...6-08-04-composer-tab-gutter-reservation.md | 50 +++ ...8-04-composer-tab-gutter-reservation.zh.md | 50 +++ apps/web/tests/composer-tab-geometry.e2e.ts | 388 ++++++++++++++++++ .../geometry.expected.md | 37 ++ apps/web/tsconfig.json | 1 + .../client/ui-conversation/README.i18n.yaml | 4 +- packages/client/ui-conversation/README.md | 2 +- packages/client/ui-conversation/README.zh.md | 2 +- .../skeleton/ConversationRoot.module.css | 22 +- tsconfig.host.json | 1 + 11 files changed, 558 insertions(+), 5 deletions(-) create mode 100644 .agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.i18n.yaml create mode 100644 .agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.md create mode 100644 .agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.zh.md create mode 100644 apps/web/tests/composer-tab-geometry.e2e.ts create mode 100644 apps/web/tests/snapshots/composer-tab-geometry/geometry.expected.md diff --git a/.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.i18n.yaml new file mode 100644 index 0000000000..176c649ecd --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.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-04-composer-tab-gutter-reservation.md +2026-08-04-composer-tab-gutter-reservation.md: f9a0c346c0c023fe2602486376b9794781f824db +2026-08-04-composer-tab-gutter-reservation.zh.md: 67a284ec0a12e464ad6f1dc17e954bdebd28e80d diff --git a/.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.md b/.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.md new file mode 100644 index 0000000000..f9a0c346c0 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.md @@ -0,0 +1,50 @@ +# Agent Note: The conversation column reserves one scrollbar gutter for every view + +Status: implemented + +English | [中文](2026-08-04-composer-tab-gutter-reservation.zh.md) + +## Problem + +The composer seat is one node in one place in the tree, and it was laid out against a different edge depending on which view tab was shown. + +In Chat it is a sticky CHILD of the column's scroller (`[data-conversation-scroll]`), so it rides that scroller's content box — the box a space-consuming scrollbar shortens by the bar's width. A view that declares `data-conversation-composer-overlay`, which Trajectory does, moves the column's scrolling into the view itself: the branch keyed on that attribute left the scroller `overflow: hidden` and positioned the seat absolutely, against the padding box, which no scrollbar reduces. + +So for as long as the transcript overflowed — the ordinary state of any session with history — the two tabs disagreed by exactly the bar's width. The input card is centred, so switching tabs moved it 4px sideways on an 8px bar, and its right-hand clearance changed by the full 8. The same displacement appeared inside Chat alone at the moment a growing transcript started to scroll, and again between the hero phase and the first scrolling turn. + +## Decision + +`.scrollBody` declares `scrollbar-gutter: stable` unconditionally, and the overlay branch declares the same box a scroll container on both axes — `overflow-x: hidden; overflow-y: auto` — instead of `overflow: hidden`. + +The two halves are one change. The reservation is what makes both states measure against the same width; declaring the overlay branch a scroll container is what makes the reservation reach it. `stable` rather than `auto` because `auto` reserves only while the box actually overflows, and the difference between overflowing and not is precisely the difference between the two tabs — an `auto` gutter would state the bug rather than fix it. + +The overlay state is a scroll container that nothing scrolls: the view fills it (`flex: 1 1 0` with its own clip) and the seat is out of flow, so no gesture and no clipping behavior changes. What changes is which declarations the engine honours. WebKit applies `scrollbar-gutter` to an `overflow-y: auto` box and ignores it on a hidden one — measured on this app's own composer layers and recorded in [the composer glyph-layer note](2026-07-31-composer-glyph-layer-tracks-the-textarea.md) — so a reservation left on a hidden box would hold in Chromium and silently not in Safari. + +The horizontal axis is declared rather than left to compute: a box that scrolls on one axis computes `visible` on the other to `auto`, and would grow a horizontal scrollbar of its own the first time a view's content reached past the column. + +The reservation is worth what it costs only because the bar takes layout space here at all, which is not the browser's default behavior but this client's: `::-webkit-scrollbar` carries a width in ui-theme's sheet ([themed scrollbars](2026-07-28-themed-scrollbars-and-reserved-gutter.md)), and the sidebar's session list already reserves its own gutter for the same reason. + +## Alternatives considered + +**Inset the overlay seat by the bar's width.** The narrow reading of the bug — the two states differ by 8px, so subtract 8px from one. Rejected because the number is the engine's, not ours: the WebKit path draws the sheet's 8px bar, the Firefox path draws whatever `scrollbar-width: thin` resolves to, and a hardcoded inset would line the two states up in Chromium while drifting everywhere else. The gutter asks the engine to reserve its own bar's width, whatever that is. + +**Keep `overflow: hidden` and add `scrollbar-gutter: stable` alone.** The one-line version. It fixes the visible symptom on the engine the browser lane runs, and leaves it in place on Safari, with no test failing anywhere — the failure mode the second half of the change exists to prevent. + +**Move the composer seat out of the scroller in Chat too, making the overlay geometry the only geometry.** This deletes the difference at its root rather than reconciling it, and gives up a deliberate property: the sticky seat sits inside the scroll flow, so a wheel over the composer moves the transcript ([sticky composer](2026-07-29-sticky-composer-conversation-scroll.md)), and the fade mask above it is painted by the seat's own background. Both are owned behavior with their own coverage; rebuilding them to remove 8px of asymmetry is the larger change, not the smaller one. + +**Pad the column by the bar's width instead of reserving a gutter.** Padding applies whether or not a bar is present, so it costs the width unconditionally in every state, and it pins a value in the stylesheet that the engine picks at layout time. Rejected for the same reason the sidebar list rejected it. + +## Consequences + +- Chat's content column is permanently 8px narrower — in the hero phase and while the transcript is short as well, where no bar is drawn. That is the trade: one card position at every content height, instead of the widest possible column. +- The fix covers three transitions with one declaration, because all three are the same difference: Chat ↔ Trajectory, short ↔ scrolling transcript within Chat, and hero ↔ first scrolling turn. +- The overlay state is now a scroll container. Nothing in it can overflow today; a future view that let its content exceed the column would scroll this box instead of clipping, and would need its own clip the way the Trajectory view already has one. +- The committed golden records the reserved band, so a change to the sheet's `::-webkit-scrollbar` width — the value that decides how wide the reservation is — arrives as a reviewable diff in this scenario as well as in the sidebar's. + +## Testing + +`apps/web/tests/composer-tab-geometry.e2e.ts` measures the input card's rectangle in both tabs, at a viewport where the card sits at its width cap and one where it shrinks with the column, and asserts the two rectangles are the same rectangle. Only a real engine reports this: jsdom gives every element a zero-sized box and no scrollbar, so a unit spec could assert the declarations exist but not that the two states land in the same place. For the same reason no CSS-text spec accompanies it — it would restate the declarations without adding a fact the browser lane does not already establish. + +The scenario launches chromium without Playwright's default `--hide-scrollbars`, which is load-bearing: under that argument a bar consumes no layout width, both tabs agree before this change as much as after it, and every comparison in the file holds vacuously. Measured, the pre-fix cascade leaves both bands at 0 under the argument, and at 8 and 0 with it dropped. + +The pre-fix cascade is then applied in the page — `scrollbar-gutter: auto` on the scroller, `overflow: hidden` on the overlay branch — and the same two tabs measured through it, which is what separates a card that does not move from a tab switch that never reached the layout. It reproduces the reported symptom as a number: 4px on each edge, half the 8px band. The golden records that control beside the fixed state, so the fixture carries the difference the change removes rather than only its absence. diff --git a/.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.zh.md b/.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.zh.md new file mode 100644 index 0000000000..67a284ec0a --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.zh.md @@ -0,0 +1,50 @@ +# Agent Note: 会话列为每个视图预留同一条滚动条槽 + +Status: implemented + +[English](2026-08-04-composer-tab-gutter-reservation.md) | 中文 + +## 问题 + +composer 座位在组件树中只有一个节点、一个位置,但它究竟对齐到哪条边,取决于当前展示的是哪个视图标签页。 + +在 Chat 中它是会话列滚动容器(`[data-conversation-scroll]`)的 sticky **子元素**,因而依附于该容器的 content box——而占布局宽度的滚动条会把这个盒子收窄一条滚动条的宽度。声明了 `data-conversation-composer-overlay` 的视图(Trajectory 即是其一)会把会话列的滚动搬进视图自身:以该属性为条件的那条分支把滚动容器留作 `overflow: hidden`,并把座位改为绝对定位——对齐的是 padding box,而滚动条从不收窄这个盒子。 + +于是只要对话记录超出一屏——任何带历史的会话的常态——两个标签页就恰好相差一条滚动条的宽度。输入卡片是居中的,因此在 8px 的滚动条下切换标签页会让它横向移动 4px,而右侧留白整整变化 8px。同一位移也出现在 Chat 内部:对话增长到开始滚动的那一刻,以及从 hero 态进入第一个可滚动轮次时。 + +## 决策 + +`.scrollBody` 无条件声明 `scrollbar-gutter: stable`,overlay 分支则把同一个盒子在两个轴向上都声明为滚动容器——`overflow-x: hidden; overflow-y: auto`——而不再是 `overflow: hidden`。 + +这两半是同一处改动。预留使两种状态依附于同一个宽度;把 overlay 分支声明为滚动容器,才使这条预留真正抵达它。选 `stable` 而非 `auto`,是因为 `auto` 只在盒子确实溢出时才预留,而"溢出与否"恰恰就是两个标签页之间的那点差别——`auto` 的写法只是把缺陷重述一遍,并不能修掉它。 + +overlay 状态是一个没有任何东西会去滚动它的滚动容器:视图把它填满(`flex: 1 1 0`,且自带裁剪),座位不在常规流中,因此没有任何手势与裁剪行为发生变化。变化的是引擎会认哪些声明。WebKit 对 `overflow-y: auto` 的盒子应用 `scrollbar-gutter`,对 hidden 的盒子则忽略它——这是在本应用 composer 自身的图层上实测所得,并记录于 [composer 字形层记录](2026-07-31-composer-glyph-layer-tracks-the-textarea.md)——所以把预留留在一个 hidden 盒子上,会在 Chromium 上成立,在 Safari 上悄无声息地不成立。 + +横向轴是显式声明的,而不是交给推导:单轴滚动的盒子会把另一轴的 `visible` 计算为 `auto`,于是只要某个视图的内容第一次伸出列外,它就会长出自己的横向滚动条。 + +这条预留之所以值回它的代价,前提是滚动条在这里确实占布局空间——这并非浏览器的默认行为,而是本客户端的选择:ui-theme 的样式表给 `::-webkit-scrollbar` 声明了宽度([滚动条主题化](2026-07-28-themed-scrollbars-and-reserved-gutter.md)),侧边栏的会话列表也正是出于同一原因预留了自己的滚动条槽。 + +## 曾考虑的替代方案 + +**把 overlay 座位按滚动条宽度内缩。** 这是对该缺陷最窄的一种解读——两种状态差 8px,那就从一侧减去 8px。之所以否决,是因为这个数字属于引擎而不属于我们:WebKit 路径绘制样式表里的 8px 滚动条,Firefox 路径绘制 `scrollbar-width: thin` 解析出的宽度,硬编码的内缩会让两种状态在 Chromium 上对齐、在别处继续漂移。滚动条槽是请引擎按它自己那条滚动条的宽度去预留,无论那是多少。 + +**保留 `overflow: hidden`,只加 `scrollbar-gutter: stable`。** 单行版本。它能在浏览器车道所用的引擎上修掉可见症状,却把症状原封不动留在 Safari 上,而且任何测试都不会失败——这正是改动的后一半所要防的失效模式。 + +**让 Chat 的 composer 座位也移出滚动容器,使 overlay 的几何成为唯一的几何。** 这是从根上删掉差异,而不是调和它,代价是放弃一项刻意的性质:sticky 座位位于滚动流之内,因此在 composer 上滚轮会带动对话记录([sticky composer](2026-07-29-sticky-composer-conversation-scroll.md)),其上方的渐隐遮罩也由座位自身的背景绘制。两者都是有主、有覆盖的既有行为;为了消除 8px 的不对称而重建它们,是更大的改动而非更小的。 + +**给会话列加上一条滚动条宽度的内边距,而不是预留滚动条槽。** 内边距无论是否存在滚动条都会生效,因此在每种状态下都无条件付出这份宽度,而且它把一个由引擎在布局期决定的值钉死在样式表里。否决理由与侧边栏列表当初否决它时相同。 + +## 后果 + +- Chat 的内容列永久变窄 8px——hero 态与对话记录尚短、根本不绘制滚动条时同样如此。这就是这笔交易:以最宽的列换取卡片在任何内容高度下都只有一个位置。 +- 一条声明覆盖三种切换,因为这三者本就是同一个差异:Chat ↔ Trajectory、Chat 内部的短对话 ↔ 可滚动对话,以及 hero ↔ 第一个可滚动轮次。 +- overlay 状态现在是一个滚动容器。今天其中没有任何内容会溢出;将来若有视图允许自身内容超出会话列,这个盒子会滚动而不是裁剪,那个视图就需要像 Trajectory 视图那样自带裁剪。 +- 提交的 golden 记录了预留的带宽,因此样式表中 `::-webkit-scrollbar` 宽度的变化——决定这条预留有多宽的那个值——会在本场景中与在侧边栏场景中一样,以可评审的 diff 形式出现。 + +## 测试 + +`apps/web/tests/composer-tab-geometry.e2e.ts` 在两个标签页下测量输入卡片的矩形,分别取卡片处于宽度上限的视口与卡片随列收缩的视口,并断言这两个矩形是同一个矩形。只有真实引擎能报告这件事:jsdom 给每个元素的盒子尺寸都是零,也没有滚动条,因此单元测试只能断言那些声明存在,无法断言两种状态落在同一位置。出于同一原因,本次没有附带读取 CSS 文本的单元测试——它只会把声明复述一遍,并不会补上浏览器车道尚未确立的事实。 + +该场景启动 chromium 时去掉了 Playwright 默认的 `--hide-scrollbars`,这一点是承重的:带上该参数时滚动条不占任何布局宽度,两个标签页在改动前后同样一致,文件中的每一处比较都会空洞地通过。实测:带上该参数时,改动前的层叠让两侧带宽都是 0;去掉它则是 8 与 0。 + +随后,改动前的层叠会被注入页面——滚动容器上 `scrollbar-gutter: auto`,overlay 分支上 `overflow: hidden`——并在其下测量同样的两个标签页,这正是把"卡片确实没动"与"标签页切换根本没到达布局"区分开的那一步。它把上报的症状复现为一个数字:每条边 4px,恰是 8px 带宽的一半。golden 把这份对照与修复后的状态并排记录,因此 fixture 承载的是这次改动所消除的那个差值,而不仅仅是它的缺席。 diff --git a/apps/web/tests/composer-tab-geometry.e2e.ts b/apps/web/tests/composer-tab-geometry.e2e.ts new file mode 100644 index 0000000000..a7206e0bbd --- /dev/null +++ b/apps/web/tests/composer-tab-geometry.e2e.ts @@ -0,0 +1,388 @@ +// Web e2e scenario: the input card holds one horizontal position across the +// Chat and Trajectory tabs. +// +// The composer seat is the same node in both tabs, but it measures itself +// against a different edge in each (see +// packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css). +// In Chat it is a sticky CHILD of the column's scroller, so it rides that +// scroller's content box — the box a space-consuming scrollbar shortens. A view +// that opts into a composer overlay (`data-conversation-composer-overlay`, which +// Trajectory declares and which moves the column's own scrolling into the view) +// gets an absolutely positioned seat instead, laid out against the padding box, +// which the scrollbar never reduces. +// +// So the two tabs disagreed by exactly the bar's width for as long as the +// transcript overflowed: the card jumped sideways on every tab switch, and +// inside Chat alone at the moment a growing transcript started to scroll. The +// column now reserves the gutter unconditionally (`scrollbar-gutter: stable`) +// and states the overlay branch as a scroll container on the same axes, so both +// edges are the same edge. +// +// Only a real engine can show this. The seat's geometry is layout: jsdom gives +// every element a zero-sized box and reports no scrollbar at all, so a unit spec +// can assert the declarations exist but not that the two states land in the same +// place. What is asserted here is the user-visible fact — the card does not move +// — measured as the distance between the two tabs' card rectangles. +// +// The browser is launched WITHOUT Playwright's default `--hide-scrollbars`, +// which is load-bearing rather than incidental. Under that argument a scroll +// container's bar consumes no layout width at all, so the two tabs agree before +// this change as much as after it and every comparison below holds vacuously — +// measured: the pre-fix cascade leaves both tabs' bands at 0 there, against 8 +// and 0 with the argument dropped. Dropping it is also the faithful +// configuration: ui-theme's scrollbar.css gives `::-webkit-scrollbar` a width, +// and a bar that occupies layout space is what the product actually draws. +// +// The scenario runs that pre-fix cascade in the page — `scrollbar-gutter: auto` +// on the scroller, `overflow: hidden` on the overlay branch — and measures the +// same two tabs through it, which is what keeps the equal rectangles above from +// being explained by a tab switch that never reached the layout. It is the +// reported symptom as a number: the card moves 4px, half the 8px band, on each +// edge. +// +// Zero model calls: a seeded cold session renders from its log, and switching +// tabs asks the host for nothing. A stray stream would fail loud with NO_ADAPTER. +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 { createChatScrollFixture } from './chat-scroll-fixture.ts' +import { + assertFixtureInventory, compareOrRefreshGolden, launchWebScaffold, seedSession, watchConsole, + webSnapshotMode, type WebScaffold, +} from './scaffold.ts' +import { newEnglishPage, saveFailureShot } from './support.ts' + +const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/composer-tab-geometry', import.meta.url)) +/** + * Committed golden of where the input card sits in each tab, at a wide viewport + * (card at its width cap) and a narrow one (card shrinking with the column). + * + * Absolute coordinates are deliberately absent: they depend on the sidebar's + * laid-out width and on font metrics, so committing them would produce a fixture + * that has to be re-recorded per platform. What is recorded is the distance + * between the two tabs' rectangles, which is zero when the reservation holds and + * the bar's width when it does not — including under the control, so the golden + * carries the difference the fix removes rather than only its absence. + */ +const GEOMETRY_EXPECTED = join(SNAPSHOT_DIR, 'geometry.expected.md') +const MODE = webSnapshotMode() + +/** Long enough that the transcript overflows the lane's 1000px viewport; the scenario asserts the overflow rather than trusting it. */ +const FIXTURE = createChatScrollFixture({ + markerPrefix: 'TAB_GEOMETRY', + title: 'COMPOSER_TAB_GEOMETRY long session', + turns: 24, +}) +const SEED_ID = 'composer-tab-geometry-web-e2e' + +/** Viewport widths the scenario measures at: the card capped, and the card shrinking with the column. */ +const WIDE_VIEWPORT = { width: 1680, height: 1000 } +const NARROW_VIEWPORT = { width: 900, height: 1000 } + +/** + * The pre-fix cascade, injected into the page: the reservation dropped and the + * overlay branch back to a hidden box. `!important` beats the module rules + * without a rebuild, and the id lets the control be lifted again in the same + * session. + */ +const CONTROL_STYLE_ID = 'composer-tab-geometry-control' +const CONTROL_CSS = ` +[data-conversation-scroll] { scrollbar-gutter: auto !important; } +[data-conversation-scroll]:has([data-conversation-composer-overlay]) { overflow: hidden !important; } +` + +/** The column scroller and the input card as the browser lays them out, in one tab. */ +interface TabMetrics { + /** Resolved `scrollbar-gutter` on the column's scroller. */ + gutter: string + /** Resolved `overflow-x`: `hidden` in both states, so neither grows a horizontal bar. */ + overflowX: string + /** Resolved `overflow-y`: `auto` in both states, which is the form WebKit honours the gutter on. */ + overflowY: string + /** Border-box width minus client width: the space the scrollbar takes out of the content area. */ + band: number + /** True when the column's scroller actually scrolls — only Chat does. */ + scrolls: boolean + /** Left edge of the input card in viewport coordinates. */ + cardLeft: number + /** Right edge of the input card. */ + cardRight: number + /** Width of the input card, capped at the composer card max width. */ + cardWidth: number +} + +/** One tab's metrics beside the other's, plus the distances between them. */ +interface TabComparison { + chat: TabMetrics + trajectory: TabMetrics + /** Distance between the two tabs' card left edges: 0 when the card holds its position. */ + leftShift: number + /** Distance between the two tabs' card right edges. */ + rightShift: number + /** Difference between the two tabs' card widths. */ + widthShift: number +} + +/** + * Measure the column scroller and the input card in the tab currently shown. + * @param page - the page under test. + * @returns the scroller's resolved overflow style and the card's rectangle. + */ +function measureTab(page: Page): Promise { + return page.evaluate(() => { + const host = document.querySelector('[data-conversation-scroll]') + if (host === null) throw new Error('conversation column scroller not in the DOM') + const card = host.querySelector('[data-composer-seat] [data-composer-card]') + if (card === null) throw new Error('no input card inside the composer seat') + const style = getComputedStyle(host) + const hostRect = host.getBoundingClientRect() + const cardRect = card.getBoundingClientRect() + return { + gutter: style.scrollbarGutter, + overflowX: style.overflowX, + overflowY: style.overflowY, + band: hostRect.width - host.clientWidth, + scrolls: host.scrollHeight > host.clientHeight, + cardLeft: cardRect.left, + cardRight: cardRect.right, + cardWidth: cardRect.width, + } + }) +} + +/** + * Show one tab and wait for the view that owns it to be laid out. + * @param page - the page under test. + * @param tab - the tab to show. + */ +async function showTab(page: Page, tab: 'Chat' | 'Trajectory'): Promise { + await page.getByRole('tab', { name: tab, exact: true }).click() + if (tab === 'Trajectory') await page.getByLabel('Trajectory timeline').waitFor({ timeout: 30_000 }) + else await page.locator('[data-conversation-scroll] [data-chat-anchor-key]').first().waitFor({ timeout: 30_000 }) + // Both measurements are taken after a paint, so a rectangle read mid-transition + // cannot be reported as a shift the cascade did not cause. + await page.evaluate(() => new Promise((settle) => { + requestAnimationFrame(() => { requestAnimationFrame(() => { settle() }) }) + })) +} + +/** + * Measure both tabs and the distances between them, leaving Chat shown. + * @param page - the page under test. + * @returns each tab's metrics and the card's displacement between them. + */ +async function compareTabs(page: Page): Promise { + await showTab(page, 'Chat') + const chat = await measureTab(page) + await showTab(page, 'Trajectory') + const trajectory = await measureTab(page) + await showTab(page, 'Chat') + return { + chat, + trajectory, + leftShift: Math.abs(trajectory.cardLeft - chat.cardLeft), + rightShift: Math.abs(trajectory.cardRight - chat.cardRight), + widthShift: Math.abs(trajectory.cardWidth - chat.cardWidth), + } +} + +/** + * Run the pre-fix cascade in the page for one measurement, then lift it. + * @param page - the page under test. + * @returns the comparison as the column laid out before this change. + */ +async function compareTabsWithoutReservation(page: Page): Promise { + await page.evaluate(({ id, css }) => { + const style = document.createElement('style') + style.id = id + style.textContent = css + document.head.append(style) + }, { id: CONTROL_STYLE_ID, css: CONTROL_CSS }) + try { + return await compareTabs(page) + } finally { + await page.evaluate((id) => { document.getElementById(id)?.remove() }, CONTROL_STYLE_ID) + } +} + +/** + * Open the seeded session from the sidebar search. + * + * Cold summaries carry the temp workspace's basename, so the persisted first + * message is the stable identity to search for, and the query itself drives the + * lazy content-index reconciliation. Hand-rolled polling because `expect.poll` + * is test-scoped and this runs in `beforeAll`. + * @param page - the page under test. + */ +async function openSeededSession(page: Page): Promise { + const search = page.getByRole('textbox', { name: 'Search name, keywords...', exact: true }) + await search.fill(FIXTURE.markers.user(1)) + const results = page.getByRole('tree', { name: 'Search results' }).getByRole('treeitem') + const deadline = Date.now() + 60_000 + for (;;) { + if (await results.count() === 1) break + if (Date.now() > deadline) throw new Error('seeded session never appeared in the sidebar search results') + await page.waitForTimeout(200) + } + await results.click() +} + +/** + * Render the golden body. + * @param wide - comparison at the viewport where the card sits at its width cap. + * @param narrow - comparison at the viewport where the card shrinks with the column. + * @param control - comparison at the wide viewport with the reservation removed. + * @returns the golden body, without a trailing newline. + */ +function renderGeometry(wide: TabComparison, narrow: TabComparison, control: TabComparison): string { + const section = (name: string, comparison: TabComparison): string[] => [ + `## ${name}`, + '', + `- Chat: scrollbar-gutter ${comparison.chat.gutter}, overflow ${comparison.chat.overflowX}/${comparison.chat.overflowY}`, + `- Chat scroller scrolls: ${String(comparison.chat.scrolls)}`, + `- Chat reserved band: ${String(comparison.chat.band)}px`, + `- Trajectory: scrollbar-gutter ${comparison.trajectory.gutter}, overflow ${comparison.trajectory.overflowX}/${comparison.trajectory.overflowY}`, + `- Trajectory scroller scrolls: ${String(comparison.trajectory.scrolls)}`, + `- Trajectory reserved band: ${String(comparison.trajectory.band)}px`, + `- input card left edge moves between tabs: ${String(comparison.leftShift)}px`, + `- input card right edge moves between tabs: ${String(comparison.rightShift)}px`, + `- input card width changes between tabs: ${String(comparison.widthShift)}px`, + '', + ] + return [ + '# Input card position across the Chat and Trajectory tabs', + '', + ...section(`Wide viewport (${String(WIDE_VIEWPORT.width)}px, card at its cap)`, wide), + ...section(`Narrow viewport (${String(NARROW_VIEWPORT.width)}px, card shrinking with the column)`, narrow), + ...section('Wide viewport, reservation removed in the page (control)', control), + ].join('\n').trimEnd() +} + +describe('web e2e: input card position across view tabs', () => { + let scaffold: WebScaffold + let browser: Browser + let page: Page + let tripwire: ReturnType + + beforeAll(async () => { + scaffold = await launchWebScaffold({}) + await seedSession(scaffold, FIXTURE.log, SEED_ID) + // Scrollbars must take layout space here or the scenario proves nothing; + // see the file header for the measurement behind dropping this argument. + browser = await chromium.launch({ ignoreDefaultArgs: ['--hide-scrollbars'] }) + page = await newEnglishPage(browser, WIDE_VIEWPORT.height) + tripwire = watchConsole(page) + await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + await openSeededSession(page) + await page.getByRole('tab', { name: 'Chat', exact: true }).waitFor({ timeout: 30_000 }) + await page.getByText(FIXTURE.markers.assistant(FIXTURE.turns), { exact: false }).last() + .waitFor({ timeout: 30_000 }) + }, 180_000) + + afterAll(async () => { + await browser?.close() + await scaffold?.close() + }) + + it('reserves the same gutter in both tabs while the transcript scrolls', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-tab-geometry-band')) + await page.setViewportSize(WIDE_VIEWPORT) + // Vacuity guard, in two parts. A transcript that does not overflow gives + // Chat no scrollbar, and a hidden or overlaid bar gives it no width; either + // would make the tabs agree without the reservation doing anything. + await expect.poll(async () => (await measureTab(page)).scrolls, { timeout: 10_000 }).toBe(true) + const comparison = await compareTabs(page) + expect(comparison.chat.band).toBeGreaterThan(0) + // The reservation reaches both states, which is the whole change: the same + // band, on a box that scrolls and on one that only holds a view. + expect(comparison.chat.gutter).toBe('stable') + expect(comparison.trajectory.gutter).toBe('stable') + expect(comparison.trajectory.band).toBe(comparison.chat.band) + // Declared as a scroll container on both axes rather than left to compute: + // `overflow: hidden` would drop the reservation in WebKit, and a `visible` + // horizontal axis computes to `auto` beside a scrolling one. + expect(comparison.trajectory.overflowY).toBe('auto') + expect(comparison.trajectory.overflowX).toBe('hidden') + // Only Chat scrolls this box; the Trajectory view owns its own scrollers. + expect(comparison.trajectory.scrolls).toBe(false) + expect(tripwire.pageErrors).toEqual([]) + }, 60_000) + + it('holds the input card in place when the tab changes', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-tab-geometry-wide')) + await page.setViewportSize(WIDE_VIEWPORT) + const comparison = await compareTabs(page) + // The reported symptom as a number. At this viewport the card sits at its + // width cap, so the pre-fix shift showed up as a centring difference — half + // the band on each edge — rather than as a width change. + expect(comparison.leftShift).toBe(0) + expect(comparison.rightShift).toBe(0) + expect(comparison.widthShift).toBe(0) + expect(tripwire.pageErrors).toEqual([]) + }, 60_000) + + it('holds the input card in place at a viewport where it shrinks with the column', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-tab-geometry-narrow')) + await page.setViewportSize(WIDE_VIEWPORT) + const capped = await measureTab(page) + await page.setViewportSize(NARROW_VIEWPORT) + const comparison = await compareTabs(page) + // The other geometry, and a different failure: below the cap the card takes + // the column's width, so an unreserved gutter changed its WIDTH by the whole + // band instead of shifting it by half. Asserted against the capped + // measurement rather than against the cap's pixel value, which belongs to + // the stylesheet. + expect(comparison.chat.cardWidth).toBeLessThan(capped.cardWidth) + expect(comparison.leftShift).toBe(0) + expect(comparison.rightShift).toBe(0) + expect(comparison.widthShift).toBe(0) + await page.setViewportSize(WIDE_VIEWPORT) + expect(tripwire.pageErrors).toEqual([]) + }, 60_000) + + it('moves the card again once the reservation is removed in the page', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-tab-geometry-control')) + await page.setViewportSize(WIDE_VIEWPORT) + // The control: without it, equal rectangles could also mean the tab switch + // never reached the layout. Under the pre-fix cascade the Chat scroller keeps + // its bar and the Trajectory branch goes back to a hidden box with none, and + // the card moves by half the band on each edge. + const comparison = await compareTabsWithoutReservation(page) + expect(comparison.chat.gutter).toBe('auto') + expect(comparison.chat.band).toBeGreaterThan(0) + expect(comparison.trajectory.band).toBe(0) + expect(comparison.leftShift).toBe(comparison.chat.band / 2) + expect(comparison.rightShift).toBe(comparison.chat.band / 2) + // Restoring the sheet restores the fix, so the control cannot leak into the + // remaining measurements. + const restored = await compareTabs(page) + expect(restored.leftShift).toBe(0) + expect(tripwire.pageErrors).toEqual([]) + }, 60_000) + + it('matches the committed tab geometry golden', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-tab-geometry-golden')) + await page.setViewportSize(WIDE_VIEWPORT) + const wide = await compareTabs(page) + await page.setViewportSize(NARROW_VIEWPORT) + const narrow = await compareTabs(page) + await page.setViewportSize(WIDE_VIEWPORT) + const control = await compareTabsWithoutReservation(page) + await compareOrRefreshGolden(GEOMETRY_EXPECTED, renderGeometry(wide, narrow, control), MODE) + expect(tripwire.pageErrors).toEqual([]) + }, 60_000) + + it('commits exactly the fixtures it reads', async () => { + // The seeded session is generated in-process, so the geometry golden is the + // whole inventory. + await assertFixtureInventory(SNAPSHOT_DIR, ['geometry.expected.md']) + }) + + it.skipIf(MODE === 'record')('issued zero model calls and stayed clean', () => { + expect(tripwire.warnings).toEqual([]) + expect(tripwire.pageErrors).toEqual([]) + }) +}) diff --git a/apps/web/tests/snapshots/composer-tab-geometry/geometry.expected.md b/apps/web/tests/snapshots/composer-tab-geometry/geometry.expected.md new file mode 100644 index 0000000000..a4f4319c93 --- /dev/null +++ b/apps/web/tests/snapshots/composer-tab-geometry/geometry.expected.md @@ -0,0 +1,37 @@ +# Input card position across the Chat and Trajectory tabs + +## Wide viewport (1680px, card at its cap) + +- Chat: scrollbar-gutter stable, overflow auto/auto +- Chat scroller scrolls: true +- Chat reserved band: 8px +- Trajectory: scrollbar-gutter stable, overflow hidden/auto +- Trajectory scroller scrolls: false +- Trajectory reserved band: 8px +- input card left edge moves between tabs: 0px +- input card right edge moves between tabs: 0px +- input card width changes between tabs: 0px + +## Narrow viewport (900px, card shrinking with the column) + +- Chat: scrollbar-gutter stable, overflow auto/auto +- Chat scroller scrolls: true +- Chat reserved band: 8px +- Trajectory: scrollbar-gutter stable, overflow hidden/auto +- Trajectory scroller scrolls: false +- Trajectory reserved band: 8px +- input card left edge moves between tabs: 0px +- input card right edge moves between tabs: 0px +- input card width changes between tabs: 0px + +## Wide viewport, reservation removed in the page (control) + +- Chat: scrollbar-gutter auto, overflow auto/auto +- Chat scroller scrolls: true +- Chat reserved band: 8px +- Trajectory: scrollbar-gutter auto, overflow hidden/hidden +- Trajectory scroller scrolls: false +- Trajectory reserved band: 0px +- input card left edge moves between tabs: 4px +- input card right edge moves between tabs: 4px +- input card width changes between tabs: 0px diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index ecb4f0db6c..112731204b 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -61,6 +61,7 @@ "tests/chat-scroll-contract.e2e.ts", "tests/chat-long-interactions.e2e.ts", "tests/chat-continuous-conversation.e2e.ts", + "tests/composer-tab-geometry.e2e.ts", "tests/complex-history.perf.ts" ], "references": [ diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index bf1e21a541..654eac8ede 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/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-conversation/README.md -README.md: 7d3a4b5fe07cc8858c2f2059e6f65b6e27602b4d -README.zh.md: d93af91381157bb4e8e4b6a14ad00edecd505246 +README.md: ce728fb8b35d9813050a00cf9fa6a4f268820fea +README.zh.md: 7e73a2b4a4d19d81ea62701dd71ed68f32492c48 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 7d3a4b5fe0..ce728fb8b3 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -6,7 +6,7 @@ Conversation domain: skeleton (header/tabs/composer/empty state), chat view (gro Compaction renders as one collapsed row at the checkpoint's flow position without replacing the transcript above it. The disclosure renders the checkpoint's `compact/summary` provenance; when that event is outside the loaded window, the row remains visible but non-expandable. The framed checkpoint payload is model-facing and never renders. -The resident conversation shell survives no-session and session transitions. Without a current session it renders a disabled input bar; its root-scoped `conversation.hero.workspace` slot hosts the Workspace picker. Selecting a Workspace connects or reuses its Host-owned blank session and opens that session without replacing the shell. Blank sessions render the same composer body as active sessions, while the InputHub carries drafts across Workspace switches and mirrors them into the session store. In the active phase the session header shows only the current session title and view tabs as ordinary column chrome; fork lineage remains session data and is not projected into the header. Beneath it a scrollport (`data-conversation-scroll`) holds the flowing views and the sticky composer stack (stats dock + input docks + bar). Wheel over the textarea chains: the capped draft scrolls locally until its edge, then forwards to that host. +The resident conversation shell survives no-session and session transitions. Without a current session it renders a disabled input bar; its root-scoped `conversation.hero.workspace` slot hosts the Workspace picker. Selecting a Workspace connects or reuses its Host-owned blank session and opens that session without replacing the shell. Blank sessions render the same composer body as active sessions, while the InputHub carries drafts across Workspace switches and mirrors them into the session store. In the active phase the session header shows only the current session title and view tabs as ordinary column chrome; fork lineage remains session data and is not projected into the header. Beneath it a scrollport (`data-conversation-scroll`) holds the flowing views and the sticky composer stack (stats dock + input docks + bar). That scrollport reserves its scrollbar gutter unconditionally, and a view opting into a composer overlay leaves it a scroll container, so the input card keeps one horizontal position whether or not the transcript scrolls and whichever view tab is shown ([decision](../../../.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.md)). Wheel over the textarea chains: the capped draft scrolls locally until its edge, then forwards to that host. The view ring IS a slot: the conversation registration declares the `'conversation.view'` list slot (session scope) in its `children` table, ConversationRoot renders the active entry through its renderSlot share (`only: `), and view tabs project from the ring ledger's registration options (`id`/`order`/`label`). The chat view is this package's own ring entry; other plugins (ui-trajectory) contribute tabs through plain `ctx.slots.register` — the former package-local view registry (`registerView`/`ViewEntry`/`ConversationViewMap` and the chrome attachment table) is retired, with per-view chrome dissolved into the view components themselves. diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index d93af91381..7e73a2b4a4 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -6,7 +6,7 @@ 压缩(compaction)在检查点自身的消息流位置渲染为一行折叠标记,不替换其上方的 transcript(文本记录)。展开内容来自检查点溯源的 `compact/summary`;该事件位于已加载窗口之外时,标记仍然可见但不可展开。面向模型的带框检查点载荷绝不渲染。 -常驻会话壳会跨无会话与会话状态切换而保留。没有当前会话时,它会渲染禁用输入栏;其根作用域的 `conversation.hero.workspace` slot 承载 Workspace 选择器。选择 Workspace 会连接或复用由 Host 拥有的空白会话,并在不替换会话壳的情况下打开该会话。空白会话与活跃会话渲染相同的输入区主体;InputHub 则在 Workspace 切换间携带草稿,并将草稿镜像到会话 store。活跃阶段,会话标题栏作为普通列 chrome,仅显示当前会话标题和视图标签;fork 谱系仍保留为会话数据,不投影到标题栏。其下滚动容器(`data-conversation-scroll`)承载流动排版的各视图与 sticky 编辑器栈(统计 dock+输入区 dock+输入栏)。textarea 上的滚轮会链式处理:限高草稿先在本地滚动,到达边缘后再转交给该宿主。 +常驻会话壳会跨无会话与会话状态切换而保留。没有当前会话时,它会渲染禁用输入栏;其根作用域的 `conversation.hero.workspace` slot 承载 Workspace 选择器。选择 Workspace 会连接或复用由 Host 拥有的空白会话,并在不替换会话壳的情况下打开该会话。空白会话与活跃会话渲染相同的输入区主体;InputHub 则在 Workspace 切换间携带草稿,并将草稿镜像到会话 store。活跃阶段,会话标题栏作为普通列 chrome,仅显示当前会话标题和视图标签;fork 谱系仍保留为会话数据,不投影到标题栏。其下滚动容器(`data-conversation-scroll`)承载流动排版的各视图与 sticky 编辑器栈(统计 dock+输入区 dock+输入栏)。该滚动容器无条件预留自己的滚动条槽,选用编辑器 overlay 的视图也仍把它保留为滚动容器,因此无论对话记录是否滚动、无论展示哪个视图标签,输入卡片都保持同一个横向位置([决策](../../../.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.md))。textarea 上的滚轮会链式处理:限高草稿先在本地滚动,到达边缘后再转交给该宿主。 视图环本身就是 slot:会话注册声明 `'conversation.view'` 列表 slot(Session scope),并将其列在 `children` 表中;ConversationRoot 通过 renderSlot share 渲染活跃配置项(`only: `);视图标签页从环账本的注册选项(`id`/`order`/`label`)投影而来。聊天视图是该包(package)自身的环配置项;其他插件(ui-trajectory)通过普通的 `ctx.slots.register` 贡献标签页。先前包内的视图注册表(`registerView`/`ViewEntry`/`ConversationViewMap` 及 chrome 附加表)已退役,逐视图 chrome 则被拆入视图组件自身。 diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css index 1a83efb551..b2ab6d558a 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css @@ -182,6 +182,17 @@ flex-direction: column; min-height: 0; overflow-y: auto; + /* One reserved gutter for every view, because the composer seat takes its + horizontal geometry from two different edges: in Chat it is a sticky CHILD + of this scroller and so rides the content box, which the scrollbar + shortens, while under a view's composer overlay it is positioned against + the padding box, which the scrollbar never reduces. `auto` would reserve + only while this box overflows, which is precisely the difference between + the two states, so the input card would move sideways by the bar's width + on every tab switch — and inside Chat alone the moment a growing + transcript starts to scroll. `stable` makes both edges the same edge at + every content height. */ + scrollbar-gutter: stable; } .root[data-phase='active'] .viewArea { @@ -212,7 +223,16 @@ ownership of the seat geometry and its active-phase precedence. */ .scrollBody:has([data-conversation-composer-overlay]) { position: relative; - overflow: hidden; + /* Still a box nothing scrolls out of — the view fills it and the seat is out + of flow — but declared as a scroll container on the same axes as the Chat + state instead of `overflow: hidden`, so the reservation above reaches this + state too: WebKit honours `scrollbar-gutter` on an `overflow-y: auto` box + and ignores it on a hidden one (measured for the composer's own layers, + see InputBar.module.css). The horizontal axis is declared rather than left + to compute, because a box that scrolls on one axis computes `visible` on + the other to `auto` and would grow a horizontal bar of its own. */ + overflow-x: hidden; + overflow-y: auto; } .scrollBody:has([data-conversation-composer-overlay]) > .viewArea { diff --git a/tsconfig.host.json b/tsconfig.host.json index bfc4f898e8..8205019f89 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -48,6 +48,7 @@ "apps/web/tests/chat-scroll-contract.e2e.ts", "apps/web/tests/chat-long-interactions.e2e.ts", "apps/web/tests/chat-continuous-conversation.e2e.ts", + "apps/web/tests/composer-tab-geometry.e2e.ts", "apps/web/tests/complex-history.perf.ts", "apps/web/stress-tests/reasoning-chunks.stress.ts", "apps/cli/tests/**/*.ts", From 1098dc983611a88e445e4511da019b0bc2fd815a Mon Sep 17 00:00:00 2001 From: creatixchu Date: Tue, 4 Aug 2026 16:21:55 +0800 Subject: [PATCH 14/29] docs(web): cite the surviving composer scrollport note for the WebKit gutter measurement --- .../2026-08-04-composer-tab-gutter-reservation.i18n.yaml | 4 ++-- .../bug-fix/2026-08-04-composer-tab-gutter-reservation.md | 2 +- .../bug-fix/2026-08-04-composer-tab-gutter-reservation.zh.md | 2 +- .../src/client/skeleton/ConversationRoot.module.css | 5 +++-- 4 files changed, 7 insertions(+), 6 deletions(-) diff --git a/.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.i18n.yaml index 176c649ecd..c4a8fd6268 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.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-04-composer-tab-gutter-reservation.md -2026-08-04-composer-tab-gutter-reservation.md: f9a0c346c0c023fe2602486376b9794781f824db -2026-08-04-composer-tab-gutter-reservation.zh.md: 67a284ec0a12e464ad6f1dc17e954bdebd28e80d +2026-08-04-composer-tab-gutter-reservation.md: 3b28c35c1f11676e41cabde76d1b0d16c688f034 +2026-08-04-composer-tab-gutter-reservation.zh.md: 26e8b6bff73a01e6518f3918d201330c1d029876 diff --git a/.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.md b/.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.md index f9a0c346c0..3b28c35c1f 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.md +++ b/.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.md @@ -18,7 +18,7 @@ So for as long as the transcript overflowed — the ordinary state of any sessio The two halves are one change. The reservation is what makes both states measure against the same width; declaring the overlay branch a scroll container is what makes the reservation reach it. `stable` rather than `auto` because `auto` reserves only while the box actually overflows, and the difference between overflowing and not is precisely the difference between the two tabs — an `auto` gutter would state the bug rather than fix it. -The overlay state is a scroll container that nothing scrolls: the view fills it (`flex: 1 1 0` with its own clip) and the seat is out of flow, so no gesture and no clipping behavior changes. What changes is which declarations the engine honours. WebKit applies `scrollbar-gutter` to an `overflow-y: auto` box and ignores it on a hidden one — measured on this app's own composer layers and recorded in [the composer glyph-layer note](2026-07-31-composer-glyph-layer-tracks-the-textarea.md) — so a reservation left on a hidden box would hold in Chromium and silently not in Safari. +The overlay state is a scroll container that nothing scrolls: the view fills it (`flex: 1 1 0` with its own clip) and the seat is out of flow, so no gesture and no clipping behavior changes. What changes is which declarations the engine honours. WebKit applies `scrollbar-gutter` to an `overflow-y: auto` box and ignores it on a hidden one — measured on this app's own composer layers and recorded in [the composer scrollport note](2026-07-31-composer-text-layers-share-one-scrollport.md) — so a reservation left on a hidden box would hold in Chromium and silently not in Safari. The horizontal axis is declared rather than left to compute: a box that scrolls on one axis computes `visible` on the other to `auto`, and would grow a horizontal scrollbar of its own the first time a view's content reached past the column. diff --git a/.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.zh.md b/.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.zh.md index 67a284ec0a..26e8b6bff7 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.zh.md @@ -18,7 +18,7 @@ composer 座位在组件树中只有一个节点、一个位置,但它究竟 这两半是同一处改动。预留使两种状态依附于同一个宽度;把 overlay 分支声明为滚动容器,才使这条预留真正抵达它。选 `stable` 而非 `auto`,是因为 `auto` 只在盒子确实溢出时才预留,而"溢出与否"恰恰就是两个标签页之间的那点差别——`auto` 的写法只是把缺陷重述一遍,并不能修掉它。 -overlay 状态是一个没有任何东西会去滚动它的滚动容器:视图把它填满(`flex: 1 1 0`,且自带裁剪),座位不在常规流中,因此没有任何手势与裁剪行为发生变化。变化的是引擎会认哪些声明。WebKit 对 `overflow-y: auto` 的盒子应用 `scrollbar-gutter`,对 hidden 的盒子则忽略它——这是在本应用 composer 自身的图层上实测所得,并记录于 [composer 字形层记录](2026-07-31-composer-glyph-layer-tracks-the-textarea.md)——所以把预留留在一个 hidden 盒子上,会在 Chromium 上成立,在 Safari 上悄无声息地不成立。 +overlay 状态是一个没有任何东西会去滚动它的滚动容器:视图把它填满(`flex: 1 1 0`,且自带裁剪),座位不在常规流中,因此没有任何手势与裁剪行为发生变化。变化的是引擎会认哪些声明。WebKit 对 `overflow-y: auto` 的盒子应用 `scrollbar-gutter`,对 hidden 的盒子则忽略它——这是在本应用 composer 自身的图层上实测所得,并记录于 [composer 滚动容器记录](2026-07-31-composer-text-layers-share-one-scrollport.md)——所以把预留留在一个 hidden 盒子上,会在 Chromium 上成立,在 Safari 上悄无声息地不成立。 横向轴是显式声明的,而不是交给推导:单轴滚动的盒子会把另一轴的 `visible` 计算为 `auto`,于是只要某个视图的内容第一次伸出列外,它就会长出自己的横向滚动条。 diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css index b2ab6d558a..eb2b412972 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css @@ -227,8 +227,9 @@ of flow — but declared as a scroll container on the same axes as the Chat state instead of `overflow: hidden`, so the reservation above reaches this state too: WebKit honours `scrollbar-gutter` on an `overflow-y: auto` box - and ignores it on a hidden one (measured for the composer's own layers, - see InputBar.module.css). The horizontal axis is declared rather than left + and ignores it on a hidden one (measured for the composer's own layers — + .agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.md + cites the measurement). The horizontal axis is declared rather than left to compute, because a box that scrolls on one axis computes `visible` on the other to `auto` and would grow a horizontal bar of its own. */ overflow-x: hidden; From 60e4509c1d4239828b1a513d623f83842597dcff Mon Sep 17 00:00:00 2001 From: creatixchu Date: Tue, 4 Aug 2026 16:30:45 +0800 Subject: [PATCH 15/29] docs(web): trim the column's gutter comments to contract plus note link --- .../skeleton/ConversationRoot.module.css | 28 ++++++------------- 1 file changed, 9 insertions(+), 19 deletions(-) diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css index eb2b412972..733e6b6ee0 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css @@ -182,16 +182,10 @@ flex-direction: column; min-height: 0; overflow-y: auto; - /* One reserved gutter for every view, because the composer seat takes its - horizontal geometry from two different edges: in Chat it is a sticky CHILD - of this scroller and so rides the content box, which the scrollbar - shortens, while under a view's composer overlay it is positioned against - the padding box, which the scrollbar never reduces. `auto` would reserve - only while this box overflows, which is precisely the difference between - the two states, so the input card would move sideways by the bar's width - on every tab switch — and inside Chat alone the moment a growing - transcript starts to scroll. `stable` makes both edges the same edge at - every content height. */ + /* Reserved unconditionally: the composer seat rides this box's content box in + Chat and its padding box under a view's composer overlay, so an `auto` + gutter moves the input card sideways by the bar's width whenever the two + differ ([decision](../../../../../../.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.md)). */ scrollbar-gutter: stable; } @@ -223,15 +217,11 @@ ownership of the seat geometry and its active-phase precedence. */ .scrollBody:has([data-conversation-composer-overlay]) { position: relative; - /* Still a box nothing scrolls out of — the view fills it and the seat is out - of flow — but declared as a scroll container on the same axes as the Chat - state instead of `overflow: hidden`, so the reservation above reaches this - state too: WebKit honours `scrollbar-gutter` on an `overflow-y: auto` box - and ignores it on a hidden one (measured for the composer's own layers — - .agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.md - cites the measurement). The horizontal axis is declared rather than left - to compute, because a box that scrolls on one axis computes `visible` on - the other to `auto` and would grow a horizontal bar of its own. */ + /* A clipping box nothing scrolls out of, stated as a scroll container on both + axes rather than `overflow: hidden`: WebKit honours the reservation above + only in the `overflow-y: auto` form, and a single-axis scroller computes + the other axis to `auto` + ([decision](../../../../../../.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.md)). */ overflow-x: hidden; overflow-y: auto; } From e31b7221e726f6e93c6462a1e46dddd122e19816 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Tue, 4 Aug 2026 16:33:35 +0800 Subject: [PATCH 16/29] feat(web): open the local settings file --- .../2026-07-30-web-config-plane.i18n.yaml | 4 +- .../2026-07-30-web-config-plane.md | 9 +- .../2026-07-30-web-config-plane.zh.md | 9 +- apps/web/tests/settings-chrome.e2e.ts | 24 ++++ .../settings-chrome/dialog.expected.md | 1 + docs/cordis-catalog/services.md | 8 ++ packages/client/connection/README.i18n.yaml | 4 +- packages/client/connection/README.md | 2 +- packages/client/connection/README.zh.md | 2 +- .../client/connection/src/client/fixture.ts | 3 + packages/client/connection/src/index.ts | 1 + packages/client/connection/tests/fake-api.ts | 1 + .../client/connection/tests/node-half.spec.ts | 4 +- packages/client/runtime/tests/fake-api.ts | 1 + .../ui-settings-general/README.i18n.yaml | 4 +- packages/client/ui-settings-general/README.md | 4 +- .../client/ui-settings-general/README.zh.md | 4 +- .../client/SettingsDocumentAction.module.css | 16 +++ .../src/client/SettingsDocumentAction.tsx | 50 +++++++ .../ui-settings-general/src/client/index.ts | 32 ++++- .../ui-settings-general/src/client/locales.ts | 4 + .../src/client/settings-document-store.ts | 87 ++++++++++++ .../ui-settings-general/src/invariant.ts | 5 +- .../ui-settings-general/tests/apply.spec.ts | 29 +++- .../tests/components.spec.tsx | 82 ++++++++++- .../tests/settings-document-store.spec.ts | 132 ++++++++++++++++++ packages/client/ui-settings/README.i18n.yaml | 4 +- packages/client/ui-settings/README.md | 2 +- packages/client/ui-settings/README.zh.md | 2 +- .../src/client/SettingsRoot.module.css | 12 +- .../ui-settings/src/client/SettingsRoot.tsx | 1 + .../ui-settings/src/client/contract/slots.ts | 17 ++- .../client/ui-settings/src/client/index.ts | 1 + .../client/ui-settings/tests/apply.spec.ts | 7 +- .../ui-settings/tests/settings-root.spec.tsx | 8 ++ .../cordis/tool-cordis/src/api-catalog.ts | 4 + packages/host/apiproxy/README.i18n.yaml | 4 +- packages/host/apiproxy/README.md | 2 +- packages/host/apiproxy/README.zh.md | 2 +- packages/host/apiproxy/src/api-proxy.ts | 110 ++++++++++++--- packages/host/apiproxy/src/api/rpc-map.ts | 1 + .../host/apiproxy/src/api/settings.schema.ts | 9 ++ packages/host/apiproxy/src/api/settings.ts | 23 ++- packages/host/apiproxy/src/fetch/client.ts | 6 +- packages/host/apiproxy/src/fetch/handler.ts | 9 +- .../host/apiproxy/src/native-path-opener.ts | 46 ++++-- .../apiproxy/tests/api-proxy-config.spec.ts | 59 +++++++- .../apiproxy/tests/client-handler.spec.ts | 11 +- .../host/apiproxy/tests/fetch-carrier.spec.ts | 3 + .../apiproxy/tests/native-path-opener.spec.ts | 14 +- .../settings/settings-local/README.i18n.yaml | 4 +- packages/settings/settings-local/README.md | 1 + packages/settings/settings-local/README.zh.md | 1 + packages/settings/settings-local/src/index.ts | 30 +++- .../settings-local/tests/local.spec.ts | 28 +++- .../settings-local/tests/lock-race.spec.ts | 53 +++++++ packages/settings/settings/README.i18n.yaml | 4 +- packages/settings/settings/README.md | 4 +- packages/settings/settings/README.zh.md | 4 +- packages/settings/settings/src/index.ts | 21 +++ .../settings/settings/tests/settings.spec.ts | 8 ++ 61 files changed, 939 insertions(+), 98 deletions(-) create mode 100644 packages/client/ui-settings-general/src/client/SettingsDocumentAction.module.css create mode 100644 packages/client/ui-settings-general/src/client/SettingsDocumentAction.tsx create mode 100644 packages/client/ui-settings-general/src/client/settings-document-store.ts create mode 100644 packages/client/ui-settings-general/tests/settings-document-store.spec.ts diff --git a/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.i18n.yaml index fedfc7e489..c453ef0502 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.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/architecture/2026-07-30-web-config-plane.md -2026-07-30-web-config-plane.md: 6d1a8c242c1888ee4fca9e21ebc814f7a345d633 -2026-07-30-web-config-plane.zh.md: c3255cacfdd1f06d12f7bb2631f95273536b7ef9 +2026-07-30-web-config-plane.md: 399684c782db86213ac7a0944e6e0bfe19145cea +2026-07-30-web-config-plane.zh.md: de4be026d7431e058e9ae0a6e48bb0a4aba769a7 diff --git a/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md b/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md index 6d1a8c242c..399684c782 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md +++ b/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md @@ -4,7 +4,7 @@ Status: implemented English | [中文](2026-07-30-web-config-plane.zh.md) -> Scope: the wire face and web UI deferred from the [request-level LLM configuration note](2026-07-29-request-level-llm-config-credentials.md) — the `settings.*`/`credentials.*`/`llm.*` RPC domains with pushed invalidations, layered+redacted `describe()`, the llm configurable-provider directory and topology event, the standalone `dsh-client-schema-form` model layer, and the Models settings page with its hand-written provider editor. The `deepseek` → `deepseek-official` provider-route rename rides along as the enabling breaking change. +> Scope: the wire face and web UI deferred from the [request-level LLM configuration note](2026-07-29-request-level-llm-config-credentials.md) — the `settings.*`/`credentials.*`/`llm.*` RPC domains with pushed invalidations, layered+redacted `describe()`, the local settings-document handoff, the llm configurable-provider directory and topology event, the standalone `dsh-client-schema-form` model layer, and the Models settings page with its hand-written provider editor. The `deepseek` → `deepseek-official` provider-route rename rides along as the enabling breaking change. ## Problem @@ -12,10 +12,12 @@ PR1 made LLM adapter configuration restart-free at the seam, but the only writer ## Decision -**Wire domains on the compiled RPC map, rejections as codes, invalidations as frames.** `settings.describe/update/replace`, `credentials.describe/set/unset`, `llm.providers`, and `llm.models` (claiming the reserved `host.listModels` surface) join `RpcMethodMap`, so the seven compiler-locked wiring sites keep contract, schema, handler, and client in lockstep. Seam rejections fold into `settings-rejected {ns}` / `credential-rejected {ref}` business errors (HTTP stays a carrier), and three `HostFrame`s — `host/settings-changed {ns}`, `host/credentials-changed {ref}`, `host/models-changed` — follow the `host/commands-changed` shape so every client converges without polling. Writes join `pickDirectory`/`openPath` in the connection guard's privileged set: loopback + same-origin or 403, because a LAN-exposed dsh web must not accept config mutation from another origin. +**Wire domains on the compiled RPC map, rejections as codes, invalidations as frames.** `settings.describe/openDocument/update/replace/mutate`, `credentials.describe/set/unset`, `llm.providers`, and `llm.models` (claiming the reserved `host.listModels` surface) join `RpcMethodMap`, so the seven compiler-locked wiring sites keep contract, schema, handler, and client in lockstep. Seam rejections fold into `settings-rejected {ns}` / `credential-rejected {ref}` business errors (HTTP stays a carrier), and three `HostFrame`s — `host/settings-changed {ns}`, `host/credentials-changed {ref}`, `host/models-changed` — follow the `host/commands-changed` shape so every client converges without polling. Settings reads, native actions, and writes join `pickDirectory`/`openPath` in the connection guard's privileged set: loopback + same-origin or 403, because a LAN-exposed dsh web must not accept configuration access from another origin. **`describe()` grows layers and structural secret redaction.** `SettingsDescriptor` carries `base`/`user` beside the effective value, so the form marks "overridden" by presence in the user layer, not value inequality (an override *equal* to the base is still an override). `describe({ redactSecrets: true })` — mandatory at every wire face — strips `role('secret')` subtrees from all three layers via a pure structural walk of the schema (object/dict/array containers; a secret-role subtree is one opaque leaf) and enumerates the stripped slots as `{path, set}`, so a page can render write-only inputs without ever receiving a value. +**The Host identifies and opens the local settings document.** The settings seam exposes optional `documentPath` provider metadata and a `prepareDocument()` operation; `settings-local` returns its fully resolved custom or `$DSH_HOME/settings.yaml` filename and exclusively creates an absent empty document with owner-only permissions, while non-file providers retain the base `undefined`. The loopback-only `settings.describe` response carries the path as availability metadata beside the redacted namespace views. `ui-settings-general` registers a `settings.action` entry only on loopback pages, shows it only after the metadata confirms a file, and invokes pathless `settings.openDocument`; the Host resolves the provider path again before a text-document handoff (`open -t` on macOS so an arbitrary YAML file association cannot redirect the gesture, `xdg-open` on Linux, and `Invoke-Item` on Windows). Generic workspace paths retain the existing default-application handoff. The browser neither derives `$DSH_HOME` nor receives a filesystem-target parameter; remote pages make no privileged settings read for this action. + **The llm seam declares configurability and announces topology.** `registerConfigurableProviders()` is an all-or-nothing, fiber-scoped directory of `{provider, displayName, settingsNs, settingsPath}` — the addressing a config page needs to open the right settings subtree for a route that may not exist yet; `listConfigurableProviders()` merges with live routes in the wire handler so undeclared live routes still report active. The zero-payload `'llm/adapters-updated'` event fires from all four registration/unregistration commit points with contained listener dispatch (INVARIANT rethrow), following the settings/commands precedent. `llm-deepseek`'s route renamed to `deepseek-official` because the pi-ai catalog legitimately owns `deepseek` as an aggregator entry; pre-release stance, no alias. **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. @@ -30,7 +32,8 @@ PR1 made LLM adapter configuration restart-free at the seam, but the only writer - **Storing the typed key as a literal `apiKey` setting** — the v1 "one API key input" requirement could have written the literal into the profile, but every UI removal path rebuilds the user section from the *redacted* layers, so any reset or row deletion would silently drop stored sibling keys; deriving a reference keeps the input single-field while keeping `settings.yaml` secret-free and every replace safe. - **A `models` bridge plugin owning provider configuration** — same rejection as PR1: per-plugin namespaces plus a four-field directory declaration give the UI everything it needs; the bridge's unified dict re-imports the adapter-mapping indirection. - **Page-side polling instead of pushed frames** — the mux already carries `host/commands-changed`; three more frames cost one shape each and make a second tab, an external `settings.yaml` edit, and a settings-born route converge at event speed. +- **Hard-coding `$DSH_HOME/settings.yaml` or returning `documentPath` through `host.openPath` in the browser** — rejected because `settings-local.path` may select another YAML/JSON document, non-file providers have no Host path, and a general path request makes the browser the authority for a local filesystem target. Provider preparation is the authoritative source, and the Host-owned operation feeds the existing opener. ## 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, 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. +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 settings-shell scenario intercepts the pathless native intent; seam, provider, wire, React, and native-opener tests separately pin provider absence, custom-path resolution, absent-file materialization, owner-only permissions, hidden remote/unavailable states, duplicate-click collapse, localized failure, macOS text-editor dispatch, and Linux/Windows desktop dispatch. 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. diff --git a/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.zh.md b/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.zh.md index c3255cacfd..de4be026d7 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.zh.md @@ -4,7 +4,7 @@ Status: implemented [English](2026-07-30-web-config-plane.md) | 中文 -> 范围:[请求级 LLM 配置 note](2026-07-29-request-level-llm-config-credentials.md) 中延后的 wire 面与 web UI——带推送式失效的 `settings.*`/`credentials.*`/`llm.*` RPC 领域、分层且脱敏的 `describe()`、llm 可配置提供方目录与拓扑事件、独立的 `dsh-client-schema-form` 模型层,以及带手写提供方编辑器的 Models 设置页。`deepseek` → `deepseek-official` 提供方路由重命名作为解锁前提的破坏性变更一并搭车合入。 +> 范围:[请求级 LLM 配置 note](2026-07-29-request-level-llm-config-credentials.md) 中延后的 wire 面与 web UI——带推送式失效的 `settings.*`/`credentials.*`/`llm.*` RPC 领域、分层且脱敏的 `describe()`、本地设置文档交接、llm 可配置提供方目录与拓扑事件、独立的 `dsh-client-schema-form` 模型层,以及带手写提供方编辑器的 Models 设置页。`deepseek` → `deepseek-official` 提供方路由重命名作为解锁前提的破坏性变更一并搭车合入。 ## 问题 @@ -12,10 +12,12 @@ PR1 让 LLM(大语言模型)适配器配置在 seam 层面免重启,但唯 ## 决策 -**wire 领域挂上编译期 RPC 映射,拒绝落为错误码,失效落为帧。**`settings.describe/update/replace`、`credentials.describe/set/unset`、`llm.providers` 与 `llm.models`(认领预留的 `host.listModels` 面)一同加入 `RpcMethodMap`,七处由编译器锁定的接线位点因此让契约、schema、处理器与客户端保持步调一致。seam 侧的拒绝折叠为 `settings-rejected {ns}`/`credential-rejected {ref}` 业务错误(HTTP 仍只是载体),三个 `HostFrame`——`host/settings-changed {ns}`、`host/credentials-changed {ref}`、`host/models-changed`——沿用 `host/commands-changed` 的形状,因此每个客户端都无需轮询即可收敛。写入与 `pickDirectory`/`openPath` 一起进入连接守卫的特权集合:回环 + 同源,否则 403,因为暴露在局域网上的 dsh web 绝不能接受来自其他源的配置修改。 +**wire 领域挂上编译期 RPC 映射,拒绝落为错误码,失效落为帧。**`settings.describe/openDocument/update/replace/mutate`、`credentials.describe/set/unset`、`llm.providers` 与 `llm.models`(认领预留的 `host.listModels` 面)一同加入 `RpcMethodMap`,七处由编译器锁定的接线位点因此让契约、schema、处理器与客户端保持步调一致。seam 侧的拒绝折叠为 `settings-rejected {ns}`/`credential-rejected {ref}` 业务错误(HTTP 仍只是载体),三个 `HostFrame`——`host/settings-changed {ns}`、`host/credentials-changed {ref}`、`host/models-changed`——沿用 `host/commands-changed` 的形状,因此每个客户端都无需轮询即可收敛。settings 读取、原生操作与写入和 `pickDirectory`/`openPath` 一起进入连接守卫的特权集合:回环 + 同源,否则 403,因为暴露在局域网上的 dsh web 绝不能接受来自其他源的配置访问。 **`describe()` 增加分层与结构化 secret 脱敏。**`SettingsDescriptor` 在生效值之外携带 `base`/`user`,表单据此按「字段是否出现在用户层」来标记「已覆盖」,而非按值是否不等(与 base *相等*的覆盖仍然是覆盖)。`describe({ redactSecrets: true })`——在每个 wire 面都强制启用——经由对 schema 的纯结构遍历(object/dict/array 容器;secret 角色子树整体是一个不透明叶节点)从全部三层剥除 `role('secret')` 子树,并把剥除的槽位枚举为 `{path, set}`,页面因此不必收到任何值就能渲染只写输入框。 +**Host 识别并打开本地设置文档。** settings seam 暴露可选的 `documentPath` 提供方元数据和 `prepareDocument()` 操作;`settings-local` 返回已完全解析的自定义文件名或 `$DSH_HOME/settings.yaml` 文件名,并在文档缺失时以仅属主可访问的权限独占创建空文档,非文件提供方则保留基类的 `undefined`。仅限回环访问的 `settings.describe` 响应会在脱敏 namespace 视图旁把该路径作为可用性元数据携带。`ui-settings-general` 只在回环页面注册一条 `settings.action` 条目,只有元数据确认存在文件后才显示,并调用无路径参数的 `settings.openDocument`;Host 会在文本文档交接前再次解析提供方路径(macOS 上使用 `open -t`,使任意 YAML 文件关联无法重定向这次操作;Linux 上使用 `xdg-open`;Windows 上使用 `Invoke-Item`)。通用 Workspace 路径仍保留现有的默认应用交接。浏览器既不推导 `$DSH_HOME`,也不会收到文件系统目标参数;远程页面不会为这项操作发起特权 settings 读取。 + **llm seam 声明可配置性并公布拓扑。**`registerConfigurableProviders()` 是一个全有或全无、以 fiber 为作用域的目录,条目为 `{provider, displayName, settingsNs, settingsPath}`——这正是配置页要为一条可能尚不存在的路由打开正确设置子树时所需要的寻址;`listConfigurableProviders()` 在 wire 处理器里与存活路由合并,未声明的存活路由因此仍报告为激活。零负载的 `'llm/adapters-updated'` 事件从全部四个注册/注销提交点触发,listener 派发带异常隔离(INVARIANT 重抛),沿用 settings/commands 的先例。`llm-deepseek` 的路由重命名为 `deepseek-official`,因为 pi-ai catalog 名正言顺地拥有 `deepseek` 这个聚合器条目;依预发布立场,不设别名。 **架在 schema 模型层之上的手写编辑器。**`dsh-client-schema-form` 把 wire 的 `toJSON()` 信封还原(rehydrate)为活的 schemastery 节点,用于校验、路径解析与不可变草稿编辑——但不做通用渲染:第一版交付了完整的 schema 驱动表单渲染器,得到的却是一个未加样式、把 schema 原样倾倒出来的页面(每个进阶字段都平铺到卡片上、原始字段名直接充当标签、`retryPolicy` 的「不支持」回退落在主流程里)。用户没有再加一套提示/分组系统,而是选择了手写方向,第二轮又把引用输入框整个移除:卡片的主字段是一个 **API 密钥**输入框,未配置密钥的整分节提供方会以其设置卡片的形式打开,收起的「自定义设置」折叠区承载按家族精选的额外字段(两个家族都有 `baseURL`,另加 deepseek 的 `reasoningEffort`/pi-ai 的 `reasoning`),其余每个字段都归 `settings.yaml` 所有。校验仍会在写入前运行还原出的 schema,因此偏离其 schema 的手写字段会在保存时大声失败,而非静默失败。 @@ -30,7 +32,8 @@ PR1 让 LLM(大语言模型)适配器配置在 seam 层面免重启,但唯 - **把键入的密钥存成字面 `apiKey` 设置**——v1「单个 API 密钥输入框」的需求本可以把字面量直接写进 profile,但 UI 的每条删除路径都会从*脱敏后的*各层重建用户分节,任何重置或整行删除都会静默丢掉已存储的兄弟密钥;派生引用让输入保持单字段,同时让 `settings.yaml` 不含机密、每一次 replace 都安全。 - **由 `models` 桥接插件持有提供方配置**——与 PR1 相同的否决理由:按插件划分的 namespace 加上四字段的目录声明已经给了 UI 需要的一切;桥接层的统一字典会把适配器映射那层间接重新引进来。 - **页面侧轮询而非推送帧**——mux 已经承载 `host/commands-changed`;再加三个帧各自只多一个形状的成本,就让第二个标签页、外部的 `settings.yaml` 编辑和由设置催生的路由都以事件速度收敛。 +- **在浏览器中硬编码 `$DSH_HOME/settings.yaml`,或经 `host.openPath` 回传 `documentPath`**——否决,因为 `settings-local.path` 可能选择另一份 YAML/JSON 文档、非文件提供方没有 Host 路径,而且通用路径请求会让浏览器成为本地文件系统目标的权威。提供方的准备操作才是权威来源,由 Host 持有的操作会把结果交给现有打开器。 ## 后果 -整条闭环以无密钥方式固定在浏览器测试通道(`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 变更:应用语义、脱敏与目录联接从一开始就与渲染器无关。延后事项:每行的模型预览(选择器已能列出模型)、为从未声明可配置性的存活路由提供页面地址,以及显式删除提供方所保留的凭据。 +整条闭环以无密钥方式固定在浏览器测试通道(`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`(受测提供方是派生引用不可能与开发者已导出密钥相撞的那一个)。设置外壳场景会截获无路径参数的原生意图;seam、提供方、wire、React 与原生打开器测试分别固定了提供方缺失、自定义路径解析、缺失文件创建、仅属主权限、远程/不可用时隐藏、重复点击合并、本地化失败、macOS 文本编辑器分发,以及 Linux/Windows 桌面分发。删除场景证明:取消后 profile 保持原样,确认后会将其删除,而刻意保留的凭据依然存在。这次重命名在一次提交中触及 239 个文件(fixture(测试前置数据)、golden、文档、python),未保留兼容别名。替换渲染器只花了一次提交,且没有任何 wire 变更:应用语义、脱敏与目录联接从一开始就与渲染器无关。延后事项:每行的模型预览(选择器已能列出模型)、为从未声明可配置性的存活路由提供页面地址,以及显式删除提供方所保留的凭据。 diff --git a/apps/web/tests/settings-chrome.e2e.ts b/apps/web/tests/settings-chrome.e2e.ts index 19e1b33538..43500585d8 100644 --- a/apps/web/tests/settings-chrome.e2e.ts +++ b/apps/web/tests/settings-chrome.e2e.ts @@ -61,6 +61,30 @@ describe('web e2e: settings modal and General preferences', () => { await dialog.getByRole('button', { name: 'Workspace Write' }).waitFor({ timeout: 10_000 }) await expect.poll(() => dialog.getByText('语言', { exact: true }).count(), { timeout: 5_000 }).toBe(1) await expect.poll(() => dialog.getByText('外观', { exact: true }).count(), { timeout: 5_000 }).toBe(1) + const openDocument = dialog.getByRole('button', { name: '打开配置文件' }) + await openDocument.waitFor({ timeout: 10_000 }) + let openRequests = 0 + await page.route('**/api/settings.openDocument', async (route) => { + const envelope = route.request().postDataJSON() as { + rpcId: string + payload: Record + } + expect(envelope.payload).toEqual({}) + openRequests += 1 + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + type: 'server-response', + rpcId: envelope.rpcId, + result: { ok: true, value: { opened: true } }, + }), + }) + }) + await openDocument.click() + await expect.poll(() => openRequests, { timeout: 5_000 }).toBe(1) + await expect.poll(() => openDocument.isEnabled(), { timeout: 5_000 }).toBe(true) + await page.unroute('**/api/settings.openDocument') // Golden of the freshly opened dialog (default zh, General active). const snapshot = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd) await compareOrRefreshGolden(DIALOG_EXPECTED, snapshot, MODE) diff --git a/apps/web/tests/snapshots/settings-chrome/dialog.expected.md b/apps/web/tests/snapshots/settings-chrome/dialog.expected.md index cb693b62a8..f358ff26f5 100644 --- a/apps/web/tests/snapshots/settings-chrome/dialog.expected.md +++ b/apps/web/tests/snapshots/settings-chrome/dialog.expected.md @@ -7,6 +7,7 @@ - button "模型": - img - text: 模型 + - button "打开配置文件" - button "关闭": - img - text: 关闭 diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index a713efa09c..40b6498a75 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -1727,6 +1727,14 @@ Source: [`packages/session-title/session-title/src/index.ts:261`](../../packages Abstract settings service. Providers implement raw-document storage (`load`/`persist`) and push external changes through Settings.publish; the base class owns namespace registration, resolution, validation, change detection, and the `settings/updated` commit event. ```ts cordis-catalog +/** + * Prepare the provider's user-editable document for a native editor. File + * providers may materialize an absent document before returning its path; + * non-file providers return undefined. + * @returns the absolute local document path, or undefined for non-file storage. + */ +prepareDocument(): Promise + /** * Register a namespace schema and receive its owner scope. The registration * is an effect on the calling plugin's fiber: disposing that fiber removes diff --git a/packages/client/connection/README.i18n.yaml b/packages/client/connection/README.i18n.yaml index a636d8bc49..bb48dfea60 100644 --- a/packages/client/connection/README.i18n.yaml +++ b/packages/client/connection/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/connection/README.md -README.md: f537fee3273e3b5d2411197cf1a1a6e0d34af5f9 -README.zh.md: a29d2c00e7df3f6290a03ffdad59b70b43702aca +README.md: 33ef3431a2a0a6c8c0f68e040da190741fb482a6 +README.zh.md: 2b6280dd957a809d61d12601cdca5af2eccbcdab diff --git a/packages/client/connection/README.md b/packages/client/connection/README.md index f537fee327..33ef3431a2 100644 --- a/packages/client/connection/README.md +++ b/packages/client/connection/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Wire consumer layer: the client plugin's apply mounts `ctx.connection` (shared api client + current-page loopback state + single-consumer stream-loop starter); the export face carries the wire contract types, the `AbstractApiClient` seam, and the loop's sink/config types. Loopback hostname classification stays package-internal: the `/api` Host fence uses it directly, while other client plugins consume the derived `ctx.connection.isLoopback` state. The node half's `/api` route pins the privileged method set (`host.pickDirectory`, `host.openPath`, and the whole configuration plane — `settings.describe`/`update`/`replace`/`mutate` and `credentials.describe`/`set`/`unset`, reads included, since describing returns the exposed configuration and probing an arbitrary reference reports where a credential comes from) to loopback by passing the trust fence with an empty trust list — a declared `trustedHosts` authority reaches every other method, while these stay loopback-local until a real authentication layer exists. The platform subclasses (WebApiClient/FixtureApiClient), the ConnectionController loop, and the fixture data source are package-internal — apply selects and drives them; tests reach them via src. Contract: api-contracts v3 §3. +Wire consumer layer: the client plugin's apply mounts `ctx.connection` (shared api client + current-page loopback state + single-consumer stream-loop starter); the export face carries the wire contract types, the `AbstractApiClient` seam, and the loop's sink/config types. Loopback hostname classification stays package-internal: the `/api` Host fence uses it directly, while other client plugins consume the derived `ctx.connection.isLoopback` state. The node half's `/api` route pins the privileged method set (`host.pickDirectory`, `host.openPath`, and the whole configuration plane — `settings.describe`/`openDocument`/`update`/`replace`/`mutate` and `credentials.describe`/`set`/`unset`, reads and native actions included, since describing returns the exposed configuration, opening acts on the Host desktop, and probing an arbitrary reference reports where a credential comes from) to loopback by passing the trust fence with an empty trust list — a declared `trustedHosts` authority reaches every other method, while these stay loopback-local until a real authentication layer exists. The platform subclasses (WebApiClient/FixtureApiClient), the ConnectionController loop, and the fixture data source are package-internal — apply selects and drives them; tests reach them via src. Contract: api-contracts v3 §3. ## /api browser-trust fence diff --git a/packages/client/connection/README.zh.md b/packages/client/connection/README.zh.md index a29d2c00e7..2b6280dd95 100644 --- a/packages/client/connection/README.zh.md +++ b/packages/client/connection/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -协议消费层:客户端插件的 apply 会挂载 `ctx.connection`(共享 API 客户端 + 当前页面的 loopback 状态 + 单消费方流循环启动器);导出表层携带协议契约类型、`AbstractApiClient` seam,以及循环的 sink/配置类型。Loopback hostname 判定逻辑留在包内部:`/api` Host fence 会直接使用它,其他客户端插件则消费派生的 `ctx.connection.isLoopback` 状态。node 半侧的 `/api` 路由让特权方法集(`host.pickDirectory`、`host.openPath`,以及整个配置面——`settings.describe`/`update`/`replace`/`mutate` 与 `credentials.describe`/`set`/`unset`,读取也在内,因为 describe 会返回已暴露的配置,而探测任意引用会报出某条凭据来自何处)以空信任表过信任 fence,从而钉在回环——已声明的 `trustedHosts` 授权可达其余全部方法,而这些方法在真正的认证层出现之前仍只限回环本机。平台子类(WebApiClient/FixtureApiClient)、ConnectionController 循环和 fixture 数据源都属于包内部:apply 负责选择并驱动它们,测试则通过 src 访问。契约:api-contracts v3 §3。 +协议消费层:客户端插件的 apply 会挂载 `ctx.connection`(共享 API 客户端 + 当前页面的 loopback 状态 + 单消费方流循环启动器);导出表层携带协议契约类型、`AbstractApiClient` seam,以及循环的 sink/配置类型。Loopback hostname 判定逻辑留在包内部:`/api` Host fence 会直接使用它,其他客户端插件则消费派生的 `ctx.connection.isLoopback` 状态。node 半侧的 `/api` 路由让特权方法集(`host.pickDirectory`、`host.openPath`,以及整个配置面——`settings.describe`/`openDocument`/`update`/`replace`/`mutate` 与 `credentials.describe`/`set`/`unset`,读取与原生操作也在内,因为 describe 会返回已暴露的配置、打开操作会作用于 Host 桌面,而探测任意引用会报出某条凭据来自何处)以空信任表过信任 fence,从而钉在回环——已声明的 `trustedHosts` 授权可达其余全部方法,而这些方法在真正的认证层出现之前仍只限回环本机。平台子类(WebApiClient/FixtureApiClient)、ConnectionController 循环和 fixture 数据源都属于包内部:apply 负责选择并驱动它们,测试则通过 src 访问。契约:api-contracts v3 §3。 ## /api 浏览器信任栅栏 diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index dff0069b87..4816ab70a5 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -2390,6 +2390,7 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy { // editor; real schema-driven forms ride the HTTP transport. describe: request => ok(request, { writable: true, + documentPath: `${FIXTURE_HOME}/settings.yaml`, namespaces: [{ ns: 'llm-deepseek', schema: {}, @@ -2399,6 +2400,7 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy { revision: 0, }], }), + openDocument: request => ok(request, { opened: true as const }), update: request => err(request, { code: 'settings-rejected', message: 'fixture: the minimal readiness settings descriptor is read-only', @@ -2549,6 +2551,7 @@ export class FixtureApiClient extends AbstractApiClient { case 'goal.complete': return this.api.goals.complete(request) case 'goal.clear': return this.api.goals.clear(request) case 'settings.describe': return this.api.settings.describe(request) + case 'settings.openDocument': return this.api.settings.openDocument(request, signal) case 'settings.update': return this.api.settings.update(request) case 'settings.replace': return this.api.settings.replace(request) case 'settings.mutate': return this.api.settings.mutate(request) diff --git a/packages/client/connection/src/index.ts b/packages/client/connection/src/index.ts index ed4af2d21f..5780518281 100644 --- a/packages/client/connection/src/index.ts +++ b/packages/client/connection/src/index.ts @@ -52,6 +52,7 @@ const PRIVILEGED_METHODS = new Set([ 'host.pickDirectory', 'host.openPath', 'settings.describe', + 'settings.openDocument', 'settings.update', 'settings.replace', 'settings.mutate', diff --git a/packages/client/connection/tests/fake-api.ts b/packages/client/connection/tests/fake-api.ts index 88708f0625..c70a8a7ad0 100644 --- a/packages/client/connection/tests/fake-api.ts +++ b/packages/client/connection/tests/fake-api.ts @@ -182,6 +182,7 @@ export class FakeApiClient implements IApiClient { readonly settings: IApiClient['settings'] = { describe: payload => this.record('settings.describe', payload, Promise.resolve(ok({ writable: true, namespaces: [] }))), + openDocument: payload => this.record('settings.openDocument', payload, Promise.resolve(ok({ opened: true as const }))), update: payload => this.record('settings.update', payload, Promise.resolve(ok({ ns: 'fake', schema: {}, value: {}, applies: 'live' as const, secrets: [], revision: 0 }))), replace: payload => this.record('settings.replace', payload, Promise.resolve(ok({ ns: 'fake', schema: {}, value: {}, applies: 'live' as const, secrets: [], revision: 0 }))), mutate: payload => this.record('settings.mutate', payload, Promise.resolve(ok({ ns: 'fake', schema: {}, value: {}, applies: 'live' as const, secrets: [], revision: 0 }))), diff --git a/packages/client/connection/tests/node-half.spec.ts b/packages/client/connection/tests/node-half.spec.ts index 08c65de2ba..de7d613f8e 100644 --- a/packages/client/connection/tests/node-half.spec.ts +++ b/packages/client/connection/tests/node-half.spec.ts @@ -93,7 +93,7 @@ describe('connection node half', () => { // passed), but each privileged method stays loopback-only and 403s. for (const method of [ 'host.pickDirectory', 'host.openPath', - 'settings.describe', 'settings.update', 'settings.replace', 'settings.mutate', + 'settings.describe', 'settings.openDocument', 'settings.update', 'settings.replace', 'settings.mutate', 'credentials.describe', 'credentials.set', 'credentials.unset', ]) { const denied = fakeResponse() @@ -177,7 +177,7 @@ describe('connection node half over a real HTTP server', () => { // Reads are as privileged as writes: describe returns the exposed // configuration, and credentials.describe probes arbitrary env-var names. for (const method of [ - 'settings.describe', 'settings.update', 'settings.replace', 'settings.mutate', + 'settings.describe', 'settings.openDocument', 'settings.update', 'settings.replace', 'settings.mutate', 'credentials.describe', 'credentials.set', 'credentials.unset', 'host.pickDirectory', 'host.openPath', ]) { diff --git a/packages/client/runtime/tests/fake-api.ts b/packages/client/runtime/tests/fake-api.ts index 3c0b86fec8..d2b5c13a5d 100644 --- a/packages/client/runtime/tests/fake-api.ts +++ b/packages/client/runtime/tests/fake-api.ts @@ -217,6 +217,7 @@ export class FakeApiClient implements IApiClient { readonly settings: IApiClient['settings'] = { describe: payload => this.record('settings.describe', payload, Promise.resolve(ok({ writable: true, namespaces: [] }))), + openDocument: payload => this.record('settings.openDocument', payload, Promise.resolve(ok({ opened: true as const }))), update: payload => this.record('settings.update', payload, Promise.resolve(ok({ ns: 'fake', schema: {}, value: {}, applies: 'live' as const, secrets: [], revision: 0 }))), replace: payload => this.record('settings.replace', payload, Promise.resolve(ok({ ns: 'fake', schema: {}, value: {}, applies: 'live' as const, secrets: [], revision: 0 }))), mutate: payload => this.record('settings.mutate', payload, Promise.resolve(ok({ ns: 'fake', schema: {}, value: {}, applies: 'live' as const, secrets: [], revision: 0 }))), diff --git a/packages/client/ui-settings-general/README.i18n.yaml b/packages/client/ui-settings-general/README.i18n.yaml index b14bd3ded3..a4b757d7fe 100644 --- a/packages/client/ui-settings-general/README.i18n.yaml +++ b/packages/client/ui-settings-general/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-settings-general/README.md -README.md: 0202d596f509feeba39a38254e8bab2fae27b649 -README.zh.md: adec73edda00d34e209772f0bcc54a994f593997 +README.md: d9aeaa37b3953f586821871118b009abd72b896d +README.zh.md: 96666e2d4d2087ca9579a1bcdd6a00ccfdaaa90d diff --git a/packages/client/ui-settings-general/README.md b/packages/client/ui-settings-general/README.md index 0202d596f5..d9aeaa37b3 100644 --- a/packages/client/ui-settings-general/README.md +++ b/packages/client/ui-settings-general/README.md @@ -2,7 +2,9 @@ English | [中文](README.zh.md) -Settings ownerless-copy and product-onboarding plugin: registers everything on the Settings surface that belongs to no single feature — the shell's trigger/header/close chrome content, the General section and its `settings.general.item` slot, the `settings` dictionaries, and the first ordered welcome step. Feature-owned rows (Permission, Language, Appearance), sections (Models), and conditional onboarding steps stay with their feature packages. +Settings ownerless-copy and product-onboarding plugin: registers everything on the Settings surface that belongs to no single feature — the shell's trigger/header/close chrome content, the local configuration-file action, the General section and its `settings.general.item` slot, the `settings` dictionaries, and the first ordered welcome step. Feature-owned rows (Permission, Language, Appearance), sections (Models), and conditional onboarding steps stay with their feature packages. + +A loopback browser loads the provider's optional `documentPath` through `settings.describe` and renders **Open configuration file** only when the Host confirms one local file. The action sends the pathless, loopback-only `settings.openDocument` request; the Host resolves the provider path again, materializes an absent document, and hands it to a native text editor (`open -t` on macOS, bypassing a browser file association; the desktop file association on Linux and Windows). Open failures keep the action available and render a localized error. Remote browsers never register the action and never issue the privileged settings read. `src/onboarding-copy.ts` is the single editable owner of the complete notice plus `WELCOME_NOTICE_VERSION`; both supported GUI locales intentionally render the same Chinese copy. The Host half registers `ui-onboarding` in the user-settings seam. A loopback browser compares `welcomeNoticeVersion` for exact equality and writes the current value only after Continue succeeds. The path mutation is idempotent across tabs and preserves sibling settings, while `host/settings-changed` makes an externally acknowledged notice advance without a reload. A non-loopback browser cannot access the privileged settings API: it still presents the notice, but Continue advances only the current browser process and a reload presents the notice again. A different version deliberately presents the notice again. The welcome page preserves every authored paragraph, gives the requested clause in the final paragraph the sole emphasis, initially focuses the title, and has no close, Escape, mask-click, or secondary path. None of its copy or acknowledgement enters a Session log or model request. The notice identifies `DSH_TELEMETRY_DISABLED=1` as the telemetry opt-out. diff --git a/packages/client/ui-settings-general/README.zh.md b/packages/client/ui-settings-general/README.zh.md index adec73edda..96666e2d4d 100644 --- a/packages/client/ui-settings-general/README.zh.md +++ b/packages/client/ui-settings-general/README.zh.md @@ -2,7 +2,9 @@ [English](README.md) | 中文 -设置界面无特定功能归属的文案与产品引导插件:在设置界面注册所有不属于单一功能的内容,包括外壳的触发器、标题栏与关闭控件内容,「通用」分区及其 `settings.general.item` slot、`settings` 字典,以及第一个有序欢迎步骤。归具体功能所有的行(「权限」、「语言」、「外观」)、分区(「模型」)和条件式首次使用引导步骤仍由各自的功能包提供。 +设置界面无特定功能归属的文案与产品引导插件:在设置界面注册所有不属于单一功能的内容,包括外壳的触发器、标题栏与关闭控件内容、本地配置文件操作,「通用」分区及其 `settings.general.item` slot、`settings` 字典,以及第一个有序欢迎步骤。归具体功能所有的行(「权限」、「语言」、「外观」)、分区(「模型」)和条件式首次使用引导步骤仍由各自的功能包提供。 + +回环浏览器通过 `settings.describe` 加载提供方可选的 `documentPath`,且只有在 Host 确认存在一个本地文件时才渲染**打开配置文件**。该操作发送无路径参数且仅限回环访问的 `settings.openDocument` 请求;Host 会再次解析提供方路径、在文档缺失时将其创建出来,并交给原生文本编辑器(macOS 上使用 `open -t`,绕过浏览器文件关联;Linux 和 Windows 上使用桌面文件关联)。打开失败时该操作仍可使用,并渲染本地化错误。远程浏览器从不注册该操作,也从不发起这项特权 settings 读取。 `src/onboarding-copy.ts` 是完整通知文案和 `WELCOME_NOTICE_VERSION` 的唯一可编辑来源;GUI 支持的两种 locale 都有意渲染同一份中文文案。宿主端在 user-settings seam 中注册 `ui-onboarding`。loopback 浏览器会比较 `welcomeNoticeVersion` 是否精确相等,仅在「继续」操作成功后写入当前值。该路径变更在不同标签页间幂等,并会保留同级设置;`host/settings-changed` 则让页面在通知被外部确认后,无需重新加载即可推进。非 loopback 浏览器不能访问受保护的 settings API:它仍会显示通知,但「继续」只推进当前浏览器进程,重新加载后会再次显示通知。版本不同时,系统也会有意重新显示通知。欢迎页保留原文的每个段落,仅强调最后一段中指定的句段,初始焦点落在标题上,并且没有关闭操作、Escape、点击遮罩或次要操作路径。其文案和确认状态均不会进入会话日志或模型请求。通知明确以 `DSH_TELEMETRY_DISABLED=1` 作为遥测关闭方式。 diff --git a/packages/client/ui-settings-general/src/client/SettingsDocumentAction.module.css b/packages/client/ui-settings-general/src/client/SettingsDocumentAction.module.css new file mode 100644 index 0000000000..c4bb82e12f --- /dev/null +++ b/packages/client/ui-settings-general/src/client/SettingsDocumentAction.module.css @@ -0,0 +1,16 @@ +.action { + display: flex; + min-width: 0; + align-items: center; + gap: 8px; +} + +.error { + max-width: 180px; + overflow: hidden; + color: var(--dsw-alias-state-error-primary); + font-size: 12px; + line-height: 18px; + text-overflow: ellipsis; + white-space: nowrap; +} diff --git a/packages/client/ui-settings-general/src/client/SettingsDocumentAction.tsx b/packages/client/ui-settings-general/src/client/SettingsDocumentAction.tsx new file mode 100644 index 0000000000..6904ae7183 --- /dev/null +++ b/packages/client/ui-settings-general/src/client/SettingsDocumentAction.tsx @@ -0,0 +1,50 @@ +/** Optional settings-header action for opening a file-backed Host document. */ + +import { useEffect } from 'react' +import type { ReactNode } from 'react' +import { Button } from '@deepseek-ai/dsh-client-ui-primitives' +import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-web-react' +import type { SettingsDocumentState, SettingsDocumentStore } from './settings-document-store.ts' +import css from './SettingsDocumentAction.module.css' + +/** Registrant-owned dependencies of {@link SettingsDocumentAction}. */ +export interface SettingsDocumentActionInjected { + /** Provider metadata and action state owner. */ + controller: SettingsDocumentStore + /** Bound selector hook for the controller snapshot. */ + useSnapshot: SnapshotSelectorHook +} + +/** Header-action owner share, localized copy, and the registrant's state face. */ +export type SettingsDocumentActionProps = + PropsRuntime<'settings.action'> & PropsLocale<'settings'> & SettingsDocumentActionInjected + +/** + * Render the open-document action only after Host metadata confirms a local file. + * @param props - header owner props, localized copy, and injected document state. + * @returns the action, or null while unavailable or unresolved. + */ +export function SettingsDocumentAction({ controller, useSnapshot, t }: SettingsDocumentActionProps): ReactNode { + const state = useSnapshot(snapshot => snapshot) + + useEffect(() => { + if (state.status === 'idle') void controller.load() + }, [controller, state.status]) + + if (state.status !== 'ready') return null + + return ( +
+ {state.error === null ? null : {t('openDocument.error')}} + +
+ ) +} diff --git a/packages/client/ui-settings-general/src/client/index.ts b/packages/client/ui-settings-general/src/client/index.ts index 87b05e86cb..bdcb2876e6 100644 --- a/packages/client/ui-settings-general/src/client/index.ts +++ b/packages/client/ui-settings-general/src/client/index.ts @@ -1,8 +1,8 @@ /** * Settings ownerless-copy plugin, browser half: registers everything on the * Settings surface that belongs to no single feature — the trigger/header - * chrome content, the General section, and the `settings` dictionaries. - * Feature-owned rows and sections stay with their features. + * chrome content, local-document action, General section, and `settings` + * dictionaries. Feature-owned rows and sections stay with their features. * Export discipline: packages/client/AGENTS.md. */ import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' @@ -15,6 +15,9 @@ import type {} from '@deepseek-ai/dsh-client-ui-settings/client' import type {} from '@deepseek-ai/dsh-client-locale/client' import { CloseLabel, HeaderContent, TriggerContent } from './chrome.tsx' import { GeneralSection } from './GeneralSection.tsx' +import { SettingsDocumentAction } from './SettingsDocumentAction.tsx' +import type { SettingsDocumentActionInjected } from './SettingsDocumentAction.tsx' +import { SettingsDocumentStore } from './settings-document-store.ts' import type { WelcomeNoticeInjected } from './WelcomeNotice.tsx' import { WelcomeNotice } from './WelcomeNotice.tsx' import { refreshWelcomeIfLoaded, WelcomeNoticeStore } from './welcome-store.ts' @@ -27,6 +30,9 @@ export type { export type { GeneralSectionComponentProps, } from './GeneralSection.tsx' +export type { SettingsDocumentActionInjected, SettingsDocumentActionProps } from './SettingsDocumentAction.tsx' +export type { SettingsDocumentState } from './settings-document-store.ts' +export { SettingsDocumentStore } from './settings-document-store.ts' export type { WelcomeNoticeInjected, WelcomeNoticeProps } from './WelcomeNotice.tsx' export type { WelcomeNoticeState } from './welcome-store.ts' export type { SettingsKey } from './locales.ts' @@ -61,6 +67,15 @@ export function apply(ctx: ClientContext): void { // locale/change re-registration wiring. const t = ctx.locale.bind(NS) const connection = ctx.get('connection') as ConnectionHandle + const documentController = connection.isLoopback + ? new SettingsDocumentStore(connection.api) + : undefined + const documentInjected = documentController === undefined + ? undefined + : (() => { + const useSnapshot = bindSnapshotSelector(documentController.store) + return (): SettingsDocumentActionInjected => ({ controller: documentController, useSnapshot }) + })() const welcomeController = new WelcomeNoticeStore(connection.api, connection.isLoopback ? 'host' : 'memory') const useWelcomeSnapshot = bindSnapshotSelector(welcomeController.store) const welcomeInjected = (): WelcomeNoticeInjected => ({ @@ -84,6 +99,16 @@ export function apply(ctx: ClientContext): void { ctx.slots.register({ name: 'settings.trigger', locale: NS }, TriggerContent)) const header = deferRegistration(ctx.slots, 'settings.header', HeaderContent, () => ctx.slots.register({ name: 'settings.header', locale: NS }, HeaderContent)) + const action = documentInjected === undefined + ? undefined + : deferRegistration(ctx.slots, 'settings.action', SettingsDocumentAction, () => + ctx.slots.register({ + name: 'settings.action', + id: 'open-document', + order: 0, + locale: NS, + inject: documentInjected, + }, SettingsDocumentAction)) const close = deferRegistration(ctx.slots, 'settings.close', CloseLabel, () => ctx.slots.register({ name: 'settings.close', locale: NS }, CloseLabel)) const general = deferRegistration(ctx.slots, 'settings.section', GeneralSection, () => @@ -106,9 +131,10 @@ export function apply(ctx: ClientContext): void { return () => { trigger.dispose() header.dispose() + action?.dispose() close.dispose() general.dispose() welcome.dispose() } - }, 'ui-settings-general: chrome, section, and onboarding registrations') + }, 'ui-settings-general: chrome, action, section, and onboarding registrations') } diff --git a/packages/client/ui-settings-general/src/client/locales.ts b/packages/client/ui-settings-general/src/client/locales.ts index ef300e8e1f..41a44eae3f 100644 --- a/packages/client/ui-settings-general/src/client/locales.ts +++ b/packages/client/ui-settings-general/src/client/locales.ts @@ -6,6 +6,8 @@ export const zh = { 'trigger': '设置', 'title': '设置', 'close': '关闭', + 'openDocument': '打开配置文件', + 'openDocument.error': '无法打开配置文件', 'general.nav': '通用设置', 'welcome.title': WELCOME_NOTICE_COPY.zh.title, 'welcome.paragraph.0': WELCOME_NOTICE_COPY.zh.paragraphs[0], @@ -24,6 +26,8 @@ export const en = { 'trigger': 'Settings', 'title': 'Settings', 'close': 'Close', + 'openDocument': 'Open configuration file', + 'openDocument.error': 'Could not open configuration file', 'general.nav': 'General', 'welcome.title': WELCOME_NOTICE_COPY.en.title, 'welcome.paragraph.0': WELCOME_NOTICE_COPY.en.paragraphs[0], diff --git a/packages/client/ui-settings-general/src/client/settings-document-store.ts b/packages/client/ui-settings-general/src/client/settings-document-store.ts new file mode 100644 index 0000000000..ff55517771 --- /dev/null +++ b/packages/client/ui-settings-general/src/client/settings-document-store.ts @@ -0,0 +1,87 @@ +/** State owner for the optional local settings-document action. */ + +import type { IApiClient } from '@deepseek-ai/dsh-client-connection/client' +import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' + +/** Browser state of the Host-owned settings document. */ +export interface SettingsDocumentState { + /** Metadata-loading phase; unavailable means the provider has no local file or the read failed. */ + status: 'idle' | 'loading' | 'ready' | 'unavailable' + /** Whether one native-open request is in flight. */ + opening: boolean + /** Last metadata/native-open diagnostic; UI exposes only localized copy. */ + error: string | null +} + +function messageOf(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} + +/** Loads local-document availability and invokes the pathless Host-owned open operation. */ +export class SettingsDocumentStore { + /** uSES-safe state source shared by the registered header action. */ + readonly store: SnapshotStore = createSnapshotStore({ + status: 'idle', opening: false, error: null, + }) + + private generation = 0 + + /** + * @param api - loopback settings wire face that reports and opens the provider document. + */ + constructor(private readonly api: Pick) {} + + /** + * Load the current provider's optional local document path. + * @returns after the latest metadata response updates the store. + */ + async load(): Promise { + const generation = ++this.generation + this.store.update((state) => { + state.status = 'loading' + state.error = null + }) + try { + const { result } = await this.api.settings.describe({}) + if (generation !== this.generation) return + if (!result.ok) { + this.store.update((state) => { + state.status = 'unavailable' + state.error = result.error.message + }) + return + } + this.store.update((state) => { + state.status = result.value.documentPath === undefined ? 'unavailable' : 'ready' + state.error = null + }) + } catch (error) { + if (generation !== this.generation) return + this.store.update((state) => { + state.status = 'unavailable' + state.error = messageOf(error) + }) + } + } + + /** + * Open the loaded document once; concurrent gestures collapse behind the in-flight action. + * @returns after the native-open request settles, or immediately when unavailable/already opening. + */ + async open(): Promise { + const current = this.store.getSnapshot() + if (current.status !== 'ready' || current.opening) return + this.store.update((state) => { + state.opening = true + state.error = null + }) + try { + const response = await this.api.settings.openDocument({}) + if (!response.result.ok) throw new Error(response.result.error.message) + } catch (error) { + this.store.update((state) => { state.error = messageOf(error) }) + } finally { + this.store.update((state) => { state.opening = false }) + } + } +} diff --git a/packages/client/ui-settings-general/src/invariant.ts b/packages/client/ui-settings-general/src/invariant.ts index d13ecc5cb8..c5917bd83b 100644 --- a/packages/client/ui-settings-general/src/invariant.ts +++ b/packages/client/ui-settings-general/src/invariant.ts @@ -16,8 +16,9 @@ export const inject = ['invariants'] /** * No runtime invariant: the settings seam validates and publishes the durable - * welcome section, while slot conflicts fail loud in the slot core; this - * package owns no additional event/data relationship between those systems. + * welcome section, while slot conflicts fail loud in the slot core. The local + * document action is browser state over typed RPC responses and is covered by + * store/component tests rather than a Cordis runtime relationship. */ const install: InvariantInstaller = () => {} diff --git a/packages/client/ui-settings-general/tests/apply.spec.ts b/packages/client/ui-settings-general/tests/apply.spec.ts index 73f6d8207e..0f79b0c4a0 100644 --- a/packages/client/ui-settings-general/tests/apply.spec.ts +++ b/packages/client/ui-settings-general/tests/apply.spec.ts @@ -1,4 +1,4 @@ -/** Ownerless-copy registrations: the four seats, the dictionaries, thunked labels, and HMR recovery. */ +/** Ownerless-copy registrations: the six seats, dictionaries, thunked labels, and HMR recovery. */ import { Context } from 'cordis' import { describe, expect, it, vi } from 'vitest' import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots' @@ -8,6 +8,8 @@ import { usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime' import { apply, inject } from '@deepseek-ai/dsh-client-ui-settings-general/client' import { CloseLabel, HeaderContent, TriggerContent } from '../src/client/chrome.tsx' import { GeneralSection } from '../src/client/GeneralSection.tsx' +import { SettingsDocumentAction } from '../src/client/SettingsDocumentAction.tsx' +import type { SettingsDocumentActionInjected } from '../src/client/SettingsDocumentAction.tsx' import { WelcomeNotice } from '../src/client/WelcomeNotice.tsx' import type { WelcomeNoticeInjected } from '../src/client/WelcomeNotice.tsx' import { WELCOME_NOTICE_SETTINGS_NAMESPACE } from '../src/onboarding-copy.ts' @@ -16,10 +18,11 @@ import { WELCOME_NOTICE_SETTINGS_NAMESPACE } from '../src/onboarding-copy.ts' // the shipped Chinese copy, so they state the browser they assume. usePinnedBrowserLanguages('zh-CN') -/** The five seats this plugin fills (slot name → expected component). */ +/** The seats this plugin fills for a loopback browser (slot name → expected component). */ const SEATS = [ ['settings.trigger', TriggerContent], ['settings.header', HeaderContent], + ['settings.action', SettingsDocumentAction], ['settings.close', CloseLabel], ['settings.section', GeneralSection], ['settings.onboarding', WelcomeNotice], @@ -36,6 +39,7 @@ async function bench(isLoopback = true) { ok: true as const, value: { writable: true, + documentPath: '/tmp/test-settings.yaml', namespaces: [{ ns: WELCOME_NOTICE_SETTINGS_NAMESPACE, schema: {}, @@ -47,11 +51,18 @@ async function bench(isLoopback = true) { }, }, })) - ctx.provide('connection', { api: { settings: { describe: settingsDescribe } }, isLoopback } as never) - return { ctx, slots: ctx.get('slots') as SlotsService, locale, settingsDescribe } + const settingsOpenDocument = vi.fn(() => Promise.resolve({ + rpcId: 'settings-open' as never, + result: { ok: true as const, value: { opened: true as const } }, + })) + ctx.provide('connection', { + api: { settings: { describe: settingsDescribe, openDocument: settingsOpenDocument } }, + isLoopback, + } as never) + return { ctx, slots: ctx.get('slots') as SlotsService, locale, settingsDescribe, settingsOpenDocument } } -/** Declare the shell's four child slots the way ui-settings' entry does. */ +/** Declare the shell's six child slots the way ui-settings' entry does. */ function declare(slots: SlotsService): () => void { return slots.register( { @@ -59,6 +70,7 @@ function declare(slots: SlotsService): () => void { children: { 'settings.trigger': { kind: 'single', scope: 'root' }, 'settings.header': { kind: 'single', scope: 'root' }, + 'settings.action': { kind: 'list', scope: 'root' }, 'settings.close': { kind: 'single', scope: 'root' }, 'settings.section': { kind: 'list', scope: 'root' }, 'settings.onboarding': { kind: 'list', scope: 'root' }, @@ -77,7 +89,7 @@ describe('ui-settings-general apply', () => { expect(inject).toEqual(['slots', 'locale', 'connection']) }) - it('fills all five seats for declarations before or after apply', async () => { + it('fills all six seats for declarations before or after apply', async () => { const before = await bench() declare(before.slots) await before.ctx.plugin({ inject: [...inject], apply }).await() @@ -92,6 +104,10 @@ describe('ui-settings-general apply', () => { expect(before.slots.entries('settings.general.item')).toEqual([]) const welcome = before.slots.entries('settings.onboarding')[0]! expect(welcome.options).toMatchObject({ id: 'welcome-notice', order: -100 }) + const action = before.slots.entries('settings.action')[0]! + const actionInjected = (action.inject as unknown as () => SettingsDocumentActionInjected)() + expect(actionInjected.controller.store.getSnapshot().status).toBe('idle') + expect(actionInjected.useSnapshot).toEqual(expect.any(Function)) // Copy rides the standard locale seat: every seat declares the namespace. for (const [name] of SEATS) { expect(before.slots.entries(name)[0]!.locale).toBe('settings') @@ -170,6 +186,7 @@ describe('ui-settings-general apply', () => { await expect(controller.acknowledge()).resolves.toBe(true) expect(controller.store.getSnapshot()).toMatchObject({ status: 'ready', acknowledged: true }) expect(b.settingsDescribe).not.toHaveBeenCalled() + expect(b.slots.entries('settings.action')).toEqual([]) }) it('re-registers after an HMR collapse of the declaring chain (stale disposers must not block)', async () => { diff --git a/packages/client/ui-settings-general/tests/components.spec.tsx b/packages/client/ui-settings-general/tests/components.spec.tsx index db6be78ccd..0b11a575f4 100644 --- a/packages/client/ui-settings-general/tests/components.spec.tsx +++ b/packages/client/ui-settings-general/tests/components.spec.tsx @@ -1,10 +1,13 @@ // @vitest-environment jsdom import { afterEach, describe, expect, it, vi } from 'vitest' -import { cleanup, render, screen } from '@testing-library/react' +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' import type { GeneralSectionComponentProps } from '../src/client/GeneralSection.tsx' import { GeneralSection } from '../src/client/GeneralSection.tsx' import { CloseLabel, HeaderContent, TriggerContent } from '../src/client/chrome.tsx' import type { TriggerContentProps } from '../src/client/chrome.tsx' +import { SettingsDocumentAction } from '../src/client/SettingsDocumentAction.tsx' +import { SettingsDocumentStore } from '../src/client/settings-document-store.ts' import { en } from '../src/client/locales.ts' afterEach(cleanup) @@ -54,3 +57,80 @@ describe('GeneralSection', () => { expect(screen.getByTestId('slot-settings.general.item')).toBeTruthy() }) }) + +describe('SettingsDocumentAction', () => { + it('appears only for a file-backed provider and requests its Host-owned document', async () => { + const openDocument = vi.fn(() => Promise.resolve({ + rpcId: 'document-open' as never, + result: { ok: true as const, value: { opened: true as const } }, + })) + const controller = new SettingsDocumentStore({ + settings: { + describe: vi.fn(() => Promise.resolve({ + rpcId: 'document-action' as never, + result: { + ok: true as const, + value: { writable: true, documentPath: '/tmp/custom.yaml', namespaces: [] }, + }, + })), + openDocument, + }, + } as never) + render() + const action = await screen.findByRole('button', { name: 'Open configuration file' }) + fireEvent.click(action) + await waitFor(() => { expect(openDocument).toHaveBeenCalledWith({}) }) + }) + + it('stays absent when the provider has no local document', async () => { + const controller = new SettingsDocumentStore({ + settings: { + describe: vi.fn(() => Promise.resolve({ + rpcId: 'document-action' as never, + result: { ok: true as const, value: { writable: true, namespaces: [] } }, + })), + openDocument: vi.fn(), + }, + } as never) + render() + await waitFor(() => { expect(controller.store.getSnapshot().status).toBe('unavailable') }) + expect(screen.queryByRole('button', { name: 'Open configuration file' })).toBeNull() + }) + + it('keeps the action available and reports a native-open failure', async () => { + const controller = new SettingsDocumentStore({ + settings: { + describe: vi.fn(() => Promise.resolve({ + rpcId: 'document-action' as never, + result: { + ok: true as const, + value: { writable: true, documentPath: '/tmp/settings.yaml', namespaces: [] }, + }, + })), + openDocument: vi.fn(() => Promise.resolve({ + rpcId: 'document-open-failed' as never, + result: { ok: false as const, error: { code: 'internal' as const, message: 'xdg-open missing', details: {} } }, + })), + }, + } as never) + render() + fireEvent.click(await screen.findByRole('button', { name: 'Open configuration file' })) + expect((await screen.findByRole('alert')).textContent).toBe('Could not open configuration file') + expect(screen.getByRole('button', { name: 'Open configuration file' })).toBeTruthy() + }) +}) diff --git a/packages/client/ui-settings-general/tests/settings-document-store.spec.ts b/packages/client/ui-settings-general/tests/settings-document-store.spec.ts new file mode 100644 index 0000000000..2104e3e1f1 --- /dev/null +++ b/packages/client/ui-settings-general/tests/settings-document-store.spec.ts @@ -0,0 +1,132 @@ +import { describe, expect, it, vi } from 'vitest' +import type { RpcResponse } from '@deepseek-ai/dsh-client-connection/client' +import { SettingsDocumentStore } from '../src/client/settings-document-store.ts' + +function response(documentPath?: string): RpcResponse<{ + writable: boolean + documentPath?: string + namespaces: [] +}> { + return { + rpcId: 'settings-document' as never, + result: { + ok: true, + value: { writable: true, ...documentPath === undefined ? {} : { documentPath }, namespaces: [] }, + }, + } +} + +function opened(): RpcResponse<{ opened: true }> { + return { + rpcId: 'settings-open' as never, + result: { ok: true, value: { opened: true } }, + } +} + +function describeFailed(message: string): RpcResponse { + return { + rpcId: 'settings-document-failed' as never, + result: { ok: false, error: { code: 'internal', message, details: {} } }, + } +} + +describe('SettingsDocumentStore', () => { + it('loads provider metadata and asks the settings domain to open its document', async () => { + const describe = vi.fn(() => Promise.resolve(response('/home/test/settings.yaml'))) + const openDocument = vi.fn(() => Promise.resolve(opened())) + const controller = new SettingsDocumentStore({ settings: { describe, openDocument } } as never) + await controller.load() + expect(controller.store.getSnapshot()).toEqual({ + status: 'ready', opening: false, error: null, + }) + await controller.open() + expect(openDocument).toHaveBeenCalledWith({}) + }) + + it('marks absent or failed metadata unavailable without opening anything', async () => { + const openDocument = vi.fn(() => Promise.resolve(opened())) + const absent = new SettingsDocumentStore({ + settings: { describe: () => Promise.resolve(response()), openDocument }, + } as never) + await absent.load() + await absent.open() + expect(absent.store.getSnapshot().status).toBe('unavailable') + expect(openDocument).not.toHaveBeenCalled() + + const failed = new SettingsDocumentStore({ + settings: { describe: () => Promise.reject(new Error('offline')), openDocument }, + } as never) + await failed.load() + expect(failed.store.getSnapshot()).toMatchObject({ status: 'unavailable', error: 'offline' }) + + const rejected = new SettingsDocumentStore({ + settings: { describe: () => Promise.resolve(describeFailed('provider failed')), openDocument }, + } as never) + await rejected.load() + expect(rejected.store.getSnapshot()).toMatchObject({ + status: 'unavailable', error: 'provider failed', + }) + }) + + it('collapses concurrent open gestures and recovers after a failure', async () => { + let resolveOpen!: (response: RpcResponse<{ opened: true }>) => void + const openDocument = vi.fn(() => new Promise>((resolve) => { resolveOpen = resolve })) + const controller = new SettingsDocumentStore({ + settings: { describe: () => Promise.resolve(response('/tmp/settings.yaml')), openDocument }, + } as never) + await controller.load() + const first = controller.open() + const second = controller.open() + expect(openDocument).toHaveBeenCalledOnce() + resolveOpen({ + rpcId: 'settings-open-failed' as never, + result: { ok: false, error: { code: 'internal', message: 'no default editor', details: {} } }, + }) + await Promise.all([first, second]) + expect(controller.store.getSnapshot()).toMatchObject({ + status: 'ready', opening: false, error: 'no default editor', + }) + }) + + it('ignores stale metadata completions and reports non-Error native failures', async () => { + let resolveFirst!: (value: ReturnType) => void + const first = new Promise>((resolve) => { resolveFirst = resolve }) + const describe = vi.fn() + .mockReturnValueOnce(first) + .mockResolvedValueOnce(response('/tmp/current.yaml')) + let rejectOpen!: (reason?: unknown) => void + const controller = new SettingsDocumentStore({ + settings: { + describe, + openDocument: () => new Promise((_, reject) => { rejectOpen = reject }), + }, + } as never) + const stale = controller.load() + await controller.load() + resolveFirst(response()) + await stale + expect(controller.store.getSnapshot().status).toBe('ready') + const opening = controller.open() + rejectOpen('native unavailable') + await opening + expect(controller.store.getSnapshot()).toMatchObject({ + status: 'ready', opening: false, error: 'native unavailable', + }) + + let rejectFirst!: (error: Error) => void + const rejectedFirst = new Promise>((_, reject) => { rejectFirst = reject }) + const caught = new SettingsDocumentStore({ + settings: { + describe: vi.fn() + .mockReturnValueOnce(rejectedFirst) + .mockResolvedValueOnce(response('/tmp/current.yaml')), + openDocument: vi.fn(), + }, + } as never) + const staleRejection = caught.load() + await caught.load() + rejectFirst(new Error('stale offline')) + await staleRejection + expect(caught.store.getSnapshot()).toMatchObject({ status: 'ready', error: null }) + }) +}) diff --git a/packages/client/ui-settings/README.i18n.yaml b/packages/client/ui-settings/README.i18n.yaml index 668853e41f..6c4fee22aa 100644 --- a/packages/client/ui-settings/README.i18n.yaml +++ b/packages/client/ui-settings/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-settings/README.md -README.md: 14c78c83467313a6efa7033c31fb9c9b1cd94e0c -README.zh.md: 8831842d03db4572f1dca5547b84c0d8a3be8f2d +README.md: de78d599b7833179339ceeb680fbd665b056bd83 +README.zh.md: fdc6ade1413c7d02afba85ed3b69a2b78b401836 diff --git a/packages/client/ui-settings/README.md b/packages/client/ui-settings/README.md index 14c78c8346..de78d599b7 100644 --- a/packages/client/ui-settings/README.md +++ b/packages/client/ui-settings/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Settings shell plugin: a pure composition face. It occupies `sidebar.settings` with the trigger chrome and modal settings panel, and declares the slots registrants fill: `settings.trigger` / `settings.header` / `settings.close` (chrome content), `settings.section` (one page per feature), and `settings.onboarding` (ordered feature-owned pages in a full-viewport stage). The shell ships no copy of its own — all text arrives from registrants (ui-settings-general owns chrome, General, and the product notice; features own their sections, rows, and conditional onboarding pages). Nav labels may be locale-following thunks, so the nav projection resolves them through `resolveSlotLabel` and re-renders on the section ledger bump or the locale revision (an optional `ctx.get('locale')` read; no hard locale dependency). +Settings shell plugin: a pure composition face. It occupies `sidebar.settings` with the trigger chrome and modal settings panel, and declares the slots registrants fill: `settings.trigger` / `settings.header` / `settings.close` (chrome content), `settings.action` (ordered content-header actions), `settings.section` (one page per feature), and `settings.onboarding` (ordered feature-owned pages in a full-viewport stage). The shell ships no copy of its own — all text arrives from registrants (ui-settings-general owns chrome, General, and the product notice; features own their actions, sections, rows, and conditional onboarding pages). Nav labels may be locale-following thunks, so the nav projection resolves them through `resolveSlotLabel` and re-renders on the section ledger bump or the locale revision (an optional `ctx.get('locale')` read; no hard locale dependency). The shell projects the onboarding ledger into ascending order and mounts exactly one page at a time in a body-level stage while marking the underlying app root inert. The active registrant receives its id, `complete()`, and an `openSection(id)` callback; completing or skipping transfers ownership to the next entry. Registrants own durable completion, capability readiness, copy, and mutations, so independently registered flows cannot stack and the shell does not become a second configuration fact source. diff --git a/packages/client/ui-settings/README.zh.md b/packages/client/ui-settings/README.zh.md index 8831842d03..fdc6ade141 100644 --- a/packages/client/ui-settings/README.zh.md +++ b/packages/client/ui-settings/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -设置外壳插件:一个纯组合表层。它以触发控件和模态设置面板占用 `sidebar.settings`,并声明由注册方填充的 slot:`settings.trigger`/`settings.header`/`settings.close`(界面框架内容)、`settings.section`(每项功能一页)和 `settings.onboarding`(由各功能持有、显示在全视口展示层中的有序页面)。外壳不自带文案:所有文本都来自注册方(ui-settings-general 拥有界面框架、「通用」分区和产品声明;各功能拥有各自的分区、行和条件式首次使用引导页面)。导航 label 可以是跟随语言的 thunk,因此导航投影经 `resolveSlotLabel` 解析,并在分区账本更新或 locale revision 变化时重新渲染(`ctx.get('locale')` 可选读取,无硬 locale 依赖)。 +设置外壳插件:一个纯组合表层。它以触发控件和模态设置面板占用 `sidebar.settings`,并声明由注册方填充的 slot:`settings.trigger`/`settings.header`/`settings.close`(界面框架内容)、`settings.action`(内容标题栏中的有序操作)、`settings.section`(每项功能一页)和 `settings.onboarding`(由各功能持有、显示在全视口展示层中的有序页面)。外壳不自带文案:所有文本都来自注册方(ui-settings-general 拥有界面框架、「通用」分区和产品声明;各功能拥有各自的操作、分区、行和条件式首次使用引导页面)。导航 label 可以是跟随语言的 thunk,因此导航投影经 `resolveSlotLabel` 解析,并在分区账本更新或 locale revision 变化时重新渲染(`ctx.get('locale')` 可选读取,无硬 locale 依赖)。 外壳将首次使用引导记录按升序投影,在 body 层级的展示层中每次只挂载一个页面,同时将下层应用根节点标记为 `inert`。当前注册方会收到该条目的 id、`complete()` 和 `openSection(id)` 回调;完成或跳过当前页面后,所有权转交给下一项。持久化完成状态、能力就绪状态、文案和变更操作均由注册方持有,因此独立注册的流程无法堆叠,外壳也不会成为第二个配置事实来源。 diff --git a/packages/client/ui-settings/src/client/SettingsRoot.module.css b/packages/client/ui-settings/src/client/SettingsRoot.module.css index 817ab38d9a..72c188e019 100644 --- a/packages/client/ui-settings/src/client/SettingsRoot.module.css +++ b/packages/client/ui-settings/src/client/SettingsRoot.module.css @@ -167,12 +167,22 @@ flex: none; display: flex; align-items: flex-start; - justify-content: flex-end; + justify-content: space-between; + gap: 8px; height: 54px; padding: 20px 14px 8px 10px; box-sizing: border-box; } +.actions { + min-width: 0; + display: flex; + align-items: center; + justify-content: flex-end; + gap: 8px; + margin-left: auto; +} + /* Close button (figma .Icon_container 501:29982): 28x28, r28, 14px glyph. */ .close { display: inline-flex; diff --git a/packages/client/ui-settings/src/client/SettingsRoot.tsx b/packages/client/ui-settings/src/client/SettingsRoot.tsx index 3eefbd4ef1..45055753ac 100644 --- a/packages/client/ui-settings/src/client/SettingsRoot.tsx +++ b/packages/client/ui-settings/src/client/SettingsRoot.tsx @@ -76,6 +76,7 @@ function SettingsPanel({ rows, renderSlot, activeId, onSelect, onClose }: PanelP
+
{renderSlot('settings.action', {})}