feat(web): configure custom DeepSeek models

This commit is contained in:
Yichen Jiang
2026-07-31 14:08:59 +08:00
parent 4a061f33d0
commit a332f2f333
42 changed files with 948 additions and 211 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .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

View File

@@ -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 `<ROUTE>_API_KEY` when none exists (the pi-ai profile records the derivation), so `settings.yaml` never carries a key value and the wholesale `settings.replace` a removal needs can never drop a sibling's secret. An edit without removals lands as a minimal `settings.update` merge patch; clearing a fold field back to inherited or deleting a row replaces the whole user section, because merge semantics cannot express removal.
**The Models page is a three-domain join with seam-shaped apply semantics.** Rows are configured providers; the add card's select is the dormant directory remainder; badges come from route liveness. The key path stays reference-shaped without ever showing a reference: a typed key stores **write-only** through `credentials.set` under the profile's `apiKeyEnv`, deriving `<ROUTE>_API_KEY` when none exists (the pi-ai profile records the derivation), so `settings.yaml` never carries a key value. 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.

View File

@@ -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` 之下,引用不存在时便派生 `<ROUTE>_API_KEY`pi-ai profile 会记录该派生),因此 `settings.yaml` 从不携带密钥值,删除所需的整体 `settings.replace` 也绝不可能丢掉兄弟条目的机密。不含删除的编辑以一次最小的 `settings.update` 合并 patch 落地;把折叠区字段清回继承值或删除整行则经 `settings.replace` 替换整个用户分节,因为合并语义表达不了删除
**Models 页是一次三领域联接,应用语义与 seam 同形。**每一行是一个已配置的提供方;「新增」卡片的选择框是可配置提供方目录中剩余的休眠条目;徽标来自路由存活状态。密钥通道保持引用形态,却从不展示任何引用:键入的密钥经 `credentials.set` **只写**存入 profile 的 `apiKeyEnv` 之下,引用不存在时便派生 `<ROUTE>_API_KEY`pi-ai profile 会记录该派生),因此 `settings.yaml` 从不携带密钥值。可见的 profile 编辑以 `settings.mutate` 路径操作落到已存储的脱敏分节上,因此 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 变更:应用语义、脱敏与目录联接从一开始就与渲染器无关。为从未声明可配置性的存活路由提供页面地址仍然暂缓

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .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

View File

@@ -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.

View File

@@ -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,并在当前精确模型支持时提供 EffortModel 可深入提供方分组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 EffortModel 可深入提供方分组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 推理强度,发起一个轮次,并验证下一条生成的响应会报告两个 IDDeepSeek 配置 fixture 会删除活动目录行,在选择替代模型之前固定该回退

View File

@@ -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()

View File

@@ -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)

View File

@@ -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'])
})
})

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -17,4 +17,6 @@
- text: minimax-cn 已启用
- button "编辑"
- button "删除"
- button "+ 添加提供方"
- button "添加提供方":
- img
- text: 添加提供方

View File

@@ -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: 添加提供方

View File

@@ -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

View File

@@ -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

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-model/README.md
README.md: 267717c78434f7a73b1c1eebca0cc0f9d65c3642
README.zh.md: 6d6f433315336812a51b5110ceeac3eecbd9bbd4
README.md: e456371095166569ed9e36fab19627ece9a751b0
README.zh.md: c72eb33163aa5ef855489cbbd46cac9edb93afb2

View File

@@ -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.

View File

@@ -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` 及其状态形状、坑位注入面类型。

View File

@@ -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;

View File

@@ -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 && (
<span className={css.description}>{model.description}</span>
)}
{model.unlisted === true && (
<span className={css.unlisted}>{t('option.currentUnlisted')}</span>
)}
</span>
<span className={css.check}>
{selected ? <IconCheckOutline16 /> : null}

View File

@@ -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 } : {}),
})

View File

@@ -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<string, string>
@@ -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<ModelKey, string>

View File

@@ -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(<ModelSelect
locked={false}
directory={directory}
load={vi.fn()}
select={select}
t={t}
/>)
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()
})
})

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-models/README.md
README.md: adfbc084e1b0e227d50032cb6c924401b81c6a79
README.zh.md: 4ee7d4efa729fdccee392ab8e55078b5a4a239ef
README.md: bbd1ad70afd925b2c0b28f444cf8118e241a7723
README.zh.md: 99ae1e432e261def378a5fac242d24d36a870905

View File

@@ -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 `<ROUTE>_API_KEY` when the profile has none, and the pi-ai profile records that derivation as `apiKeyEnv`, so `settings.yaml` never carries a key value. The collapsed 自定义设置 fold carries the curated extras — `baseURL` for both families (the deepseek placeholder shows the public endpoint), plus `reasoningEffort` (deepseek) or `reasoning` (pi-ai); every other profile field stays owned by `settings.yaml`. A row is deletable only when the user layer alone carries it (removal restores the composition base).
Rows are the *configured* providers (their profile resolves in the owning namespace); a whole-section provider whose key is not configured anywhere (the first-run DeepSeek posture) renders as its open setup card instead of a row, and the add flow is a card carrying the dormant-directory provider select — a bare-mounted `llm-pi-ai` offers its whole installed catalog before any route exists. The editor is a hand-written card per adapter family: the primary field is a single **API key** input — the page never asks for an environment-variable name; a typed key stores **write-only** through `credentials.set` under the profile's reference, deriving `<ROUTE>_API_KEY` when the profile has none, and the pi-ai profile records that derivation as `apiKeyEnv`, so `settings.yaml` never carries a key value. The collapsed 自定义设置 fold carries `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.

View File

@@ -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 没有引用时便派生 `<ROUTE>_API_KEY`pi-ai profile 会把这次派生记录为 `apiKeyEnv`,因此 `settings.yaml` 从不携带密钥值。收起的「自定义设置」折叠区承载精选的额外字段——两个家族都有 `baseURL`deepseek 的占位符显示公共端点),另加 `reasoningEffort`deepseek `reasoning`pi-ai其余每个 profile 字段仍归 `settings.yaml` 所有。只有当某行仅由用户层承载时它才可删除(删除会还原组合 base
行是*已配置*的提供方(其 profile 在所属 namespace 中解析得出密钥未在任何地方配置的整分节提供方DeepSeek 的首次运行姿态)会渲染为其展开的设置卡片而非一行,「新增」流程则是一张承载休眠目录提供方选择框的卡片——裸挂载的 `llm-pi-ai` 在任何路由存在之前就能提供其完整的已安装 catalog。编辑器是每个适配器家族各一张的手写卡片主字段是单独一个 **API 密钥**输入框——页面从不询问环境变量名;键入的密钥经 `credentials.set` 以**只写**方式存入 profile 的引用之下profile 没有引用时便派生 `<ROUTE>_API_KEY`pi-ai profile 会把这次派生记录为 `apiKeyEnv`,因此 `settings.yaml` 从不携带密钥值。收起的「自定义设置」折叠区承载两个家族 `baseURL`deepseek 的 `reasoningEffort` 或 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 Noteagent 决策记录)](../../../.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 Noteagent 决策记录)](../../../.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 地址;它在各选择器中仍然可见,但不会出现在本页的行里。

View File

@@ -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<string, unknown>
/** 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<string>()
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 (
<section className={styles['modelCatalog']} aria-label={props.t('models')}>
<div className={styles['modelCatalogHeader']}>
<div className={styles['modelCatalogHeading']}>
<span className={styles['modelCatalogTitle']}>{props.t('models')}</span>
<span className={styles['modelCatalogMeta']}>
{props.overridden ? props.t('modelsCustomized') : props.t('modelsInherited')}
</span>
</div>
{props.overridden
? (
<button
type="button"
className={styles['linkButton']}
disabled={props.disabled}
onClick={props.onReset}
>
{props.t('resetModels')}
</button>
)
: null}
</div>
{props.models.length === 0
? <p className={styles['modelEmpty']}>{props.t('modelsEmpty')}</p>
: (
<div className={styles['modelTable']}>
{/* Captions sit above the rows and are hidden from assistive tech:
every field already carries the indexed `aria-label` naming it. */}
<div className={styles['modelColumns']} aria-hidden="true">
<span>{props.t('modelId')}</span>
<span>{props.t('modelName')}</span>
<span>{props.t('contextWindow')}</span>
</div>
{props.models.map((model, index) => (
<div className={styles['modelRow']} key={index}>
<input
className={styles['input']}
type="text"
value={typeof model['id'] === 'string' ? model['id'] : ''}
aria-label={`${props.t('modelId')} ${String(index + 1)}`}
disabled={props.disabled}
onChange={(event) => { update(index, 'id', event.target.value) }}
/>
<input
className={styles['input']}
type="text"
value={typeof model['name'] === 'string' ? model['name'] : ''}
placeholder={props.t('modelNamePlaceholder')}
aria-label={`${props.t('modelName')} ${String(index + 1)}`}
disabled={props.disabled}
onChange={(event) => {
update(index, 'name', event.target.value === '' ? undefined : event.target.value)
}}
/>
<input
className={styles['input']}
type="number"
min={1}
step={1}
value={typeof model['contextWindow'] === 'number' ? model['contextWindow'] : ''}
placeholder={props.defaultContextWindow === undefined
? props.t('contextWindowPlaceholder')
: String(props.defaultContextWindow)}
aria-label={`${props.t('contextWindow')} ${String(index + 1)}`}
disabled={props.disabled}
onChange={(event) => {
update(
index,
'contextWindow',
event.target.value === '' ? undefined : Number(event.target.value),
)
}}
/>
<button
type="button"
className={styles['rowDelete']}
disabled={props.disabled}
onClick={() => { remove(index) }}
>
<IconTrashOutline16 size={14} />
<span className={styles['hiddenLabel']}>{props.t('removeModel')}</span>
</button>
</div>
))}
</div>
)}
<button
type="button"
className={styles['addModelButton']}
disabled={props.disabled}
onClick={() => { props.onChange([...props.models.map(model => ({ ...model })), { id: '' }]) }}
>
<IconPlusOutline16 size={14} />
{props.t('addModel')}
</button>
</section>
)
}

View File

@@ -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;
}
}

View File

@@ -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')}`}
<IconPlusOutline16 size={14} />
{t('add')}
</button>
)}
</div>

View File

@@ -5,19 +5,23 @@
* under the profile's reference, deriving `<ROUTE>_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 (
<>
<div className={styles['field']}>
@@ -289,6 +303,21 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
))}
</select>
</div>
{family === 'deepseek'
? (
<DeepSeekModelsEditor
models={models}
overridden={modelsOverridden}
defaultContextWindow={typeof defaultContextWindow === 'number'
? defaultContextWindow
: undefined}
t={t}
disabled={disabled}
onChange={(next) => { setDraft(current => setPath(current, ['models'], next)) }}
onReset={() => { setDraft(current => deletePath(current, ['models'])) }}
/>
)
: null}
</div>
</details>
</>

View File

@@ -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 官方模型,即可开始使用。',

View File

@@ -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(<DeepSeekModelsEditor
models={[{}]}
overridden={false}
defaultContextWindow={undefined}
t={t}
disabled={true}
onChange={vi.fn()}
onReset={vi.fn()}
/>)
expect(screen.getByLabelText<HTMLInputElement>(`${en.modelId} 1`).value).toBe('')
expect(screen.getByLabelText<HTMLInputElement>(`${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<HTMLSelectElement>(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<HTMLSelectElement>(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<HTMLInputElement>(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<HTMLButtonElement>(en.remove).every(button => button.disabled)).toBe(true)
expect(screen.getByText<HTMLButtonElement>(`+ ${en.add}`).disabled).toBe(true)
expect(screen.getByText<HTMLButtonElement>(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()
})

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/host/apiproxy/README.md
README.md: 73d8afb32f868ca82dfa2d350df089a5d0b9b358
README.zh.md: 47af18f76302e261e18f682e0d3cf0ee903933db
README.md: e57ea657c5432612ef024fc585febc0896a3ab43
README.zh.md: 57d2604dedbd86102ab5ef9fbe156792aeb5905f

View File

@@ -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.

View File

@@ -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 标识无法在重启或资源释放后存活。客户端绝不根据轮次或状态事件推断项已退役。

View File

@@ -126,30 +126,19 @@ function ok<T>(request: RpcRequest<unknown>, value: T): RpcResponse<T> {
/**
* 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 })
},

View File

@@ -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'

View File

@@ -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<Wire<ModelCatalogModel>>

View File

@@ -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
}

View File

@@ -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<T>(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([

View File

@@ -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' },