feat(web): add session model selector

This commit is contained in:
Yichen Jiang
2026-07-24 14:55:54 +08:00
parent bc7a89b81f
commit 208a44a7ec
87 changed files with 2236 additions and 87 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
2026-07-15-llm-model-catalog-and-acp-selection.md: 6cc8afc6c7431fbf3eb29fc358b432db4f72b529
2026-07-15-llm-model-catalog-and-acp-selection.zh.md: 1cce7a58d0ec83dc01feaf72ccb61d294a78ddd5
2026-07-15-llm-model-catalog-and-acp-selection.md: e97eb9abec35cca5608f3a10d6b2363a2d936510
2026-07-15-llm-model-catalog-and-acp-selection.zh.md: d3c410d6cb4585f2bd67b376bbd00ed25565c48c

View File

@@ -22,7 +22,7 @@ ACP selection must also preserve the provider dimension. The same model id may a
Catalog membership is advisory. It drives selectors and diagnostics but never changes `stream()` routing and never rejects an otherwise valid request. Provider ownership remains exclusive and lifecycle-bound; model ids remain request-time adapter input.
`dsh-llm-pi-ai` maps the configured provider's installed `getModels(provider)` entries into the neutral catalog. Its existing request-time catalog lookup remains authoritative and still rejects unknown models with `UNKNOWN_MODEL`. `dsh-llm-deepseek` accepts an optional `models` config containing display entries, defaulting to `deepseek-v4-flash` and `deepseek-v4-pro`. An explicit list replaces those defaults and an empty list disables discovery. The entries improve selector UX for known public or private models, while every unlisted model id continues to pass through unchanged.
`dsh-llm-pi-ai` maps the configured provider's installed `getModels(provider)` entries into the neutral catalog. Its existing request-time catalog lookup remains authoritative and still rejects unknown models with `UNKNOWN_MODEL`. `dsh-llm-deepseek` accepts an optional `models` config containing display entries, defaulting to `deepseek-v4-flash` named `DeepSeek-V4-Flash` and `deepseek-v4-pro` named `DeepSeek-V4-Pro`. An explicit list replaces those defaults and an empty list disables discovery. The entries improve selector UX for known public or private models, while every unlisted model id continues to pass through unchanged.
### ACP session config option

View File

@@ -22,7 +22,7 @@ ACP 选择还必须保留提供方维度。同一个模型 ID 可能存在于多
目录成员关系仅提供建议。它驱动选择器与诊断,但不会改变 `stream()` 路由,也不会拒绝原本有效的请求。提供方所有权仍然具有排他性并绑定生命周期;模型 ID 仍是请求时传给适配器的输入。
`dsh-llm-pi-ai` 将已配置提供方的安装目录 `getModels(provider)` 映射为中立目录。其现有请求时目录查询仍是权威依据,未知模型仍以 `UNKNOWN_MODEL` 失败。`dsh-llm-deepseek` 接受可选的 `models` 配置作为展示条目,默认包含 `deepseek-v4-flash` `deepseek-v4-pro`。显式列表会替换这些默认值,空列表则关闭发现。这些条目改善已知公开或私有模型的选择体验,而所有未列出的模型 ID 仍会原样透传。
`dsh-llm-pi-ai` 将已配置提供方的安装目录 `getModels(provider)` 映射为中立目录。其现有请求时目录查询仍是权威依据,未知模型仍以 `UNKNOWN_MODEL` 失败。`dsh-llm-deepseek` 接受可选的 `models` 配置作为展示条目,默认包含名为 `DeepSeek-V4-Flash``deepseek-v4-flash` 和名为 `DeepSeek-V4-Pro` `deepseek-v4-pro`。显式列表会替换这些默认值,空列表则关闭发现。这些条目改善已知公开或私有模型的选择体验,而所有未列出的模型 ID 仍会原样透传。
### ACP 会话配置项

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
2026-07-19-gui-web-client-architecture.md: eeae5fb3ad8eb3e9842b497ee51258375760bc93
2026-07-19-gui-web-client-architecture.zh.md: c6f4b10c2a4c2d210c6bd7a7fa1ac470cab7c0a7
2026-07-19-gui-web-client-architecture.md: cfc2a7e62358e6282148b2d024ef3b162a903642
2026-07-19-gui-web-client-architecture.zh.md: b5b082c25f664cfcb0ddd3fcc6c4cd3d58472218

View File

@@ -30,7 +30,7 @@ Both ends run cordis. The host is a cordis plugin tree; the browser runs a secon
## The client cordis tree and the loading chain
The loading chain — the two package kinds (plain vs dshClient plugin), the module-system/plugin-governor split, the two-phase boot over the host-authored entry graph with revisions, and hot reload — is owned by the [client plugin loading RFC](2026-07-23-client-plugin-loading-model.md). The load-bearing facts for this document: the browser boots the same vendored `@cordisjs/plugin-loader` as the host with a client module system (`ctx.modules`, `packages/client/modules`) filling its `internal` seam; every unit with product behavior is an entry in the host-authored `__DSH_BOOT__` graph — all nine plugin packages (infrastructure included) carry the `dshClient` declaration and arrive as fetched `./client` tsdown closure bundles, `immediately` rows differing only in boot phase-one prefetch, while plain packages (react family, cordis, the not-yet-promoted libraries) stay shell-bundled, seeded, and invisible to the graph; bundles execute `window.__ModuleLoader__.load({ id, factory })` and their `require` is answered from the lazy CJS module table (seed words + registered factories, materialized and memoized on first require — cross-plugin value imports are a build error, cooperation goes through cordis services); plugin CSS is inlined in the bundle and injected as `<style data-plugin="<id>">` at materialization (CSS Modules hashing + ownership tag = isolation, removal on reload); hot reload is live in dev graphs — the webserver stat-polls the bundles it serves and broadcasts `rebuilt` SSE frames, and the `client-hmr` plugin swaps one fiber per frame. The settled flip (`loader.await()` + an all-ACTIVE sweep) still switches the shell from the loading page to the real UI in one pass — settled means every entry is created and every fiber reached ACTIVE, with FAILED/PENDING fibers listed loud; there is no partial-availability mode (progressive rendering is deferred work).
The loading chain — the two package kinds (plain vs dshClient plugin), the module-system/plugin-governor split, the two-phase boot over the host-authored entry graph with revisions, and hot reload — is owned by the [client plugin loading RFC](2026-07-23-client-plugin-loading-model.md). The load-bearing facts for this document: the browser boots the same vendored `@cordisjs/plugin-loader` as the host with a client module system (`ctx.modules`, `packages/client/modules`) filling its `internal` seam; every unit with product behavior is an entry in the host-authored `__DSH_BOOT__` graph — every production plugin package (infrastructure included) carries the `dshClient` declaration and arrives as a fetched `./client` tsdown closure bundle, `immediately` rows differing only in boot phase-one prefetch, while plain packages (react family, cordis, the not-yet-promoted libraries) stay shell-bundled, seeded, and invisible to the graph; bundles execute `window.__ModuleLoader__.load({ id, factory })` and their `require` is answered from the lazy CJS module table (seed words + registered factories, materialized and memoized on first require — cross-plugin value imports are a build error, cooperation goes through cordis services); plugin CSS is inlined in the bundle and injected as `<style data-plugin="<id>">` at materialization (CSS Modules hashing + ownership tag = isolation, removal on reload); hot reload is live in dev graphs — the webserver stat-polls the bundles it serves and broadcasts `rebuilt` SSE frames, and the `client-hmr` plugin swaps one fiber per frame. The settled flip (`loader.await()` + an all-ACTIVE sweep) still switches the shell from the loading page to the real UI in one pass — settled means every entry is created and every fiber reached ACTIVE, with FAILED/PENDING fibers listed loud; there is no partial-availability mode (progressive rendering is deferred work).
Type universes stay split at the aggregate level — `tsconfig.host.json` is the host program and `tsconfig.client.json` the client program, both referenced by the solution root `tsconfig.json` — because both sides merge cordis `Context` under the same keys (`sessions`, `loader`) with different services; client packages consume the wire vocabulary through pure type subpaths (`@deepseek-ai/dsh-session/types` and kin) so no host augmentation rides into the client program.

View File

@@ -30,7 +30,7 @@ Status: implemented
## client cordis 树与装载链
装载链——两类包(普通包 vs dshClient 插件)、模块系统/插件治理器之分、host 独家撰写的带修订号 entry 图之上的双层 boot、热重载——归 [client 插件装载 RFC](2026-07-23-client-plugin-loading-model.md) 所有。本篇赖以立足的事实:浏览器启动与 host 相同的 vendored `@cordisjs/plugin-loader`,由 client 模块系统(`ctx.modules``packages/client/modules`)填上其 `internal` seam凡带产品行为的单元都是 host 独家撰写的 `__DSH_BOOT__` 图里的 entry——全部九个插件包(含基础设施)都携带 `dshClient` 声明、以 fetch 到达的 `./client` tsdown 闭包 bundle 供给,`immediately` 行的差别仅在 boot 第一层预取而普通包react 家族、cordis、尚未升格的库保持打进壳、已播种、对图不可见bundle 执行 `window.__ModuleLoader__.load({ id, factory })`,其 `require` 由 lazy CJS 模块表应答(种子词条 + 已登记工厂,首次 require 时物化并记忆化——跨插件值 import 是构建错误,协作走 cordis 服务);插件 CSS 内联在 bundle 里、物化时注入为 `<style data-plugin="<id>">`CSS Modules 哈希 + 归属标记 = 隔离,重载时移除);热重载已在 dev 图落地——webserver 对自己供给的 bundle 做 stat 轮询并广播 `rebuilt` SSE 帧,`client-hmr` 插件每帧换掉一个 fiber。settled 翻转(`loader.await()` + 一次全 ACTIVE 扫描)依旧让壳从 loading 页一次切换到真 UI——settled 意味着每个 entry 已创建、每个 fiber 都到达 ACTIVEFAILED/PENDING 的 fiber 被大声列出;不存在部分可用模式(渐进渲染为后置工作)。
装载链——两类包(普通包 vs dshClient 插件)、模块系统/插件治理器之分、host 独家撰写的带修订号 entry 图之上的双层 boot、热重载——归 [client 插件装载 RFC](2026-07-23-client-plugin-loading-model.md) 所有。本篇赖以立足的事实:浏览器启动与 host 相同的 vendored `@cordisjs/plugin-loader`,由 client 模块系统(`ctx.modules``packages/client/modules`)填上其 `internal` seam凡带产品行为的单元都是 host 独家撰写的 `__DSH_BOOT__` 图里的 entry——每个生产插件包(含基础设施)都携带 `dshClient` 声明、以 fetch 到达的 `./client` tsdown 闭包 bundle 供给,`immediately` 行的差别仅在 boot 第一层预取而普通包react 家族、cordis、尚未升格的库保持打进壳、已播种、对图不可见bundle 执行 `window.__ModuleLoader__.load({ id, factory })`,其 `require` 由 lazy CJS 模块表应答(种子词条 + 已登记工厂,首次 require 时物化并记忆化——跨插件值 import 是构建错误,协作走 cordis 服务);插件 CSS 内联在 bundle 里、物化时注入为 `<style data-plugin="<id>">`CSS Modules 哈希 + 归属标记 = 隔离,重载时移除);热重载已在 dev 图落地——webserver 对自己供给的 bundle 做 stat 轮询并广播 `rebuilt` SSE 帧,`client-hmr` 插件每帧换掉一个 fiber。settled 翻转(`loader.await()` + 一次全 ACTIVE 扫描)依旧让壳从 loading 页一次切换到真 UI——settled 意味着每个 entry 已创建、每个 fiber 都到达 ACTIVEFAILED/PENDING 的 fiber 被大声列出;不存在部分可用模式(渐进渲染为后置工作)。
类型宇宙在聚合层拆分——`tsconfig.host.json` 是 host program、`tsconfig.client.json` 是 client program二者由 solution 根 `tsconfig.json` 引用,因为两侧都在相同键(`sessions``loader`)上对 cordis `Context` 做声明合并且服务不同client 包经纯类型子路径(`@deepseek-ai/dsh-session/types`消费协议词汇host 侧的声明合并不会搭车进入 client program。

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
2026-07-20-routed-model-context-and-compaction-policy.md: b637ba24d4ba5fc25c8cdd515a821ee97883a326
2026-07-20-routed-model-context-and-compaction-policy.zh.md: 084e762ec29ddc0aecb0bf422c147b9d3122726b
2026-07-20-routed-model-context-and-compaction-policy.md: 69cc1c86a708b81c77acbd7730bf1777a7f99cef
2026-07-20-routed-model-context-and-compaction-policy.zh.md: 63e6be32367cbef219f1d8cb129169940c5d099b

View File

@@ -16,7 +16,7 @@ Neither obvious configuration owner is sufficient. Compact-basic is optional and
`LlmAdapter.resolveModelContext(provider, model)` optionally returns `LlmModelContext` for one exact route. `LlmService.resolveModelContext()` selects the registered route owner, validates a positive integer `contextWindow`, and returns a detached value. The query is independent of `listModels()`: an unlisted dynamic model may have capacity metadata, and `undefined` means only that the adapter cannot describe capacity.
The hand-rolled DeepSeek adapter accepts optional `contextWindow` on each configured model plus an adapter-wide `defaultContextWindow`. Exact model capacity wins; an entry without capacity and an unlisted pass-through id inherit the adapter default, or return `undefined` when it is absent. The two built-in model entries each publish an exact 128,000-token capacity. The pi-ai adapter resolves capacity from the same catalog descriptor that authoritatively resolves the request model.
The hand-rolled DeepSeek adapter accepts optional `contextWindow` on each configured model plus an adapter-wide `defaultContextWindow`. Exact model capacity wins; an entry without capacity and an unlisted pass-through id inherit the adapter default, or return `undefined` when it is absent. The two built-in model entries each publish an exact 256,000-token capacity. The pi-ai adapter resolves capacity from the same catalog descriptor that authoritatively resolves the request model.
### Token measurement remains model-agnostic

View File

@@ -16,7 +16,7 @@ Status: implemented
`LlmAdapter.resolveModelContext(provider, model)` 可以为一条精确路由返回 `LlmModelContext``LlmService.resolveModelContext()` 选择已注册的路由所属方,验证 `contextWindow` 为正整数,并返回分离值。该查询独立于 `listModels()`:不在目录中的动态模型也可以拥有容量元数据,而 `undefined` 只表示适配器无法描述容量。
手写 DeepSeek 适配器允许每个已配置模型提供可选 `contextWindow`,并支持适配器级 `defaultContextWindow`。精确模型容量优先;未提供容量的模型项与未列出的透传 id 会继承适配器默认值,若默认值也不存在则返回 `undefined`。两个内置模型项都公开精确的 128,000 token 容量。pi-ai 适配器从同一个目录描述符解析容量,该描述符也用于权威解析请求模型。
手写 DeepSeek 适配器允许每个已配置模型提供可选 `contextWindow`,并支持适配器级 `defaultContextWindow`。精确模型容量优先;未提供容量的模型项与未列出的透传 id 会继承适配器默认值,若默认值也不存在则返回 `undefined`。两个内置模型项都公开精确的 256,000 token 容量。pi-ai 适配器从同一个目录描述符解析容量,该描述符也用于权威解析请求模型。
### Token 计量保持模型无关

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
2026-07-23-client-plugin-loading-model.md: 58651fd258a6b2929c58bb6f93b44adb6e8e1818
2026-07-23-client-plugin-loading-model.zh.md: f60b06c7bfaa9c70170082ac4384ba2bd899676e
2026-07-23-client-plugin-loading-model.md: 4894670d16c083093f970b0058fd88f79c150673
2026-07-23-client-plugin-loading-model.zh.md: 94f4e8891bcc065873b932c4a7e07741d6521b50

View File

@@ -27,7 +27,7 @@ The first-generation client loader (`createClientLoader`) hand-wrote both layers
What makes a package a plugin? One rule: **a package is a plugin package once its consumption is cordis dependency injection; until then it is a plain package.** How code reaches the page is not part of the taxonomy — arrival follows from the kind instead of defining it.
- **Plain packages** are the absolute base the module system itself needs, plus libraries not yet converted to DI: the react family, cordis, `@deepseek-ai/dsh-client-modules` (the module system itself — it can never be a plugin, because modules precede all modules), the web shell kernel, and — for now — ui-slots, web-react, ui-primitives. Plain packages are shell-bundled, seeded into the module table, and invisible to the host graph.
- **Plugin packages** are everything else. Each one carries a `dshClient` manifest declaration (`{ platform, inject, immediately? }`) and one uniform shape: the shared tsdown preset emits `lib/client.js`, and `exports["./client"]` points at that bundle. Each is a governed entry of the host-authored graph. Nine exist today: connection, runtime, ui-theme, i18n, hmr (dev graphs only), ui-layout, ui-sidebar, ui-conversation, ui-trajectory.
- **Plugin packages** are everything else. Each one carries a `dshClient` manifest declaration (`{ platform, inject, immediately? }`) and one uniform shape: the shared tsdown preset emits `lib/client.js`, and `exports["./client"]` points at that bundle. Each is a governed entry of the host-authored graph. The current set is connection, runtime, ui-theme, i18n, hmr (dev graphs only), ui-layout, ui-sidebar, ui-conversation, ui-model-selector, ui-question, and ui-trajectory.
The manifest owns the package's loading contract: its `inject` dependency edges, plus the optional `immediately` prefetch mark (absent means lazy). The composing app owns only the roster and the `--dev` switch.
@@ -39,7 +39,7 @@ Four edge rules govern imports across the two kinds. None of them depends on any
- **Plugin ↔ plugin value imports are a build error.** This holds regardless of either side's `immediately` declaration — the rule must not depend on a mark someone can flip. Cooperation goes through cordis inject/services. `import type` is exempt; the type chain is untouched. This rule is why `scopeOf` is a `SessionsService` method and why `transportError` lives in `dsh-host-apiproxy`'s wire layer (its `RpcResult` home, inline-safe).
- **Plugin → plain package value imports are externals**, judged against the platform list. That list is one constant in the shell (`platform.ts`: react family, cordis, ui-slots, web-react, ui-primitives), imported by both the tsdown preset (for the external judgement) and `seed.ts` (for the table warm-up). One constant, two consumers — the hand-sync drift class stays dead.
- **The purity gate covers all nine plugin packages.** Its three branches: platform imports become externals; INLINE_SAFE wire layers are inlined; any other workspace leak is a build error. The uniform bundle shape is what makes this coverage total — every plugin builds through the same preset, so no package can sit outside the gate.
- **The purity gate covers every plugin package.** Its three branches: platform imports become externals; INLINE_SAFE wire layers are inlined; any other workspace leak is a build error. The uniform bundle shape is what makes this coverage total — every plugin builds through the same preset, so no package can sit outside the gate.
- **The shell is self-sufficient.** The kernel (boot + loading page) value-imports no plugin package; its status stores are hand-rolled. The fail-loud presentation must not depend on the system whose failure it reports.
### One module system, one plugin governor
@@ -88,7 +88,7 @@ On the browser side, the driver reloads one plugin per frame, serialized:
6. `entry.refresh()` — re-imports, materializing the fresh factory. CSS re-injects here, under the same stable tag ids.
7. `fiber.await()` — rethrows loud.
All nine plugins share this one semantics; an `immediately` row reloads exactly like a lazy one. Dependency cascade costs zero client code: a fiber's activation epoch strings its service providers' uids, so replacing a provider's fiber re-loads every dependent through cordis itself. Reloading connection or runtime cascades the whole UI — correct, if heavy.
Every plugin shares this one semantics; an `immediately` row reloads exactly like a lazy one. Dependency cascade costs zero client code: a fiber's activation epoch strings its service providers' uids, so replacing a provider's fiber re-loads every dependent through cordis itself. Reloading connection or runtime cascades the whole UI — correct, if heavy.
The support boundary, stated honestly. Reload is coarse by design: fresh fiber, fresh components, React state lost, data layer untouched — react-refresh-grade state preservation conflicts with "re-executing the bundle re-runs the factory" and is deliberately out. Plain packages (react family, shell kernel, not-yet-promoted libraries) are not entries: changing them means a shell rebuild and a full page reload. No rollback in v1: an import failure leaves the entry fiberless and the next rebuilt frame retries from scratch; an apply failure leaves a FAILED fiber for the status projection; both log loudly. Self-reload works — the in-flight reload finishes in the old bundle's closure and the new apply opens a fresh SSE channel — but frames arriving in the gap are lost, and the next rebuild renotifies. One known dev-only race: a rebuilt frame overlapping a still-in-flight boot arrival shares that arrival's task and may materialize the pre-rebuild bytes; the next frame self-heals.

View File

@@ -27,7 +27,7 @@ host 侧cordis 插件装载站在 Node 的模块机制之上——require cac
什么让一个包成为插件?只有一条规则:**一个包的消费方式一旦是 cordis 依赖注入,它就是插件包;在此之前它是普通包。**代码怎么到达页面不属于分类体系——到达方式由包的类别推得,而不是反过来定义类别。
- **普通包**是模块系统自身所需的绝对基座,加上尚未转成 DI 的库react 家族、cordis、`@deepseek-ai/dsh-client-modules`模块系统本身——它永远不可能是插件因为模块先于一切模块、web 壳内核以及——暂时——ui-slots、web-react、ui-primitives。普通包打进壳 bundle、播种进模块表、对 host 图不可见。
- **插件包**是其余一切。每个都携带 `dshClient` manifest元数据清单声明`{ platform, inject, immediately? }`)和同一种统一形态:共享 tsdown 预设产出 `lib/client.js``exports["./client"]` 指向该 bundle。每个都是 host 独家撰写的图里受治理的 entry。现有九个connection、runtime、ui-theme、i18n、hmr仅进 dev 图、ui-layout、ui-sidebar、ui-conversation、ui-trajectory。
- **插件包**是其余一切。每个都携带 `dshClient` manifest元数据清单声明`{ platform, inject, immediately? }`)和同一种统一形态:共享 tsdown 预设产出 `lib/client.js``exports["./client"]` 指向该 bundle。每个都是 host 独家撰写的图里受治理的 entry。当前包括connection、runtime、ui-theme、i18n、hmr仅进 dev 图、ui-layout、ui-sidebar、ui-conversation、ui-model-selector、ui-question、ui-trajectory。
manifest 拥有包的装载契约:它的 `inject` 依赖边,加可选的 `immediately` 预取标记(缺省即 lazy。负责组合的 app 只拥有名册与 `--dev` 开关。
@@ -39,7 +39,7 @@ manifest 拥有包的装载契约:它的 `inject` 依赖边,加可选的 `im
- **插件 ↔ 插件的值 import 是构建错误。**与两侧的 `immediately` 声明无关——规则不得依赖一个人人可翻转的标记。协作走 cordis inject/服务。`import type` 豁免;类型链分毫未动。这条规则正是 `scopeOf``SessionsService` 方法、`transportError` 住在 `dsh-host-apiproxy` wire 层(它的 `RpcResult` 老家,内联安全)的原因。
- **插件 → 普通包的值 import 外置为 external**,按平台清单判定。清单是壳里的一个常量(`platform.ts`react 家族、cordis、ui-slots、web-react、ui-primitivestsdown 预设external 判定)与 `seed.ts`(模块表预热)都 import 它。一个常量、两个消费方——人肉同步这一漂移缺陷类死透。
- **纯度门禁覆盖全部九个插件包。**它的三条分支:平台 import 外置为 externalINLINE_SAFE wire 层内联;其余任何 workspace 泄漏即构建错误。正是统一的 bundle 形态让这一覆盖不留死角——每个插件都经同一预设构建,没有包能坐在门禁之外。
- **纯度门禁覆盖个插件包。**它的三条分支:平台 import 外置为 externalINLINE_SAFE wire 层内联;其余任何 workspace 泄漏即构建错误。正是统一的 bundle 形态让这一覆盖不留死角——每个插件都经同一预设构建,没有包能坐在门禁之外。
- **壳自足。**内核boot + loading 页)对任何插件包零值 import其状态 store 为手写。大声失败的呈现不得依赖它所报告失败的那个系统。
### 一套模块系统,一个插件治理器
@@ -88,7 +88,7 @@ vendored Loader 经其 `internal` seam 消费模块系统——唯一调用点
6. `entry.refresh()`——重新 import物化新工厂。CSS 在这里重新注入,沿用同一批稳定标签 id。
7. `fiber.await()`——让失败大声重抛。
个插件共享同一套语义;`immediately` 行的重载与 lazy 行分毫不差。依赖级联不花一行 client 代码fiber 的激活纪元串接着它各服务提供方的 uid因此换掉提供方的 fiber每个依赖方都会经 cordis 本身重新装载。重载 connection 或 runtime 会级联整个 UI——正确虽然重。
个插件共享同一套语义;`immediately` 行的重载与 lazy 行分毫不差。依赖级联不花一行 client 代码fiber 的激活纪元串接着它各服务提供方的 uid因此换掉提供方的 fiber每个依赖方都会经 cordis 本身重新装载。重载 connection 或 runtime 会级联整个 UI——正确虽然重。
支持边界,如实陈述。重载粒度刻意做粗:全新 fiber、全新组件、React 状态丢失、数据层不动——react-refresh 级的状态保留与「重执行 bundle 即重跑工厂」相冲突属刻意不做。普通包react 家族、壳内核、尚未升格的库)不是 entry改它们意味着壳重建加整页刷新。v1 不做回滚import 失败让 entry 失去 fiber下一个 rebuilt 帧从头重试apply 失败留下 FAILED fiber 交给状态投影;两者都大声记录。自我重载可行——在途的重载在旧 bundle 的闭包里跑完,新的 apply 再开一条新 SSE 通道——但空窗期到达的帧会丢失,下次重建会再次通知。一处已知的仅限 dev 竞态rebuilt 帧与仍在途的 boot 到达重叠时共享那次到达的任务,可能物化重建前的字节;下一帧自愈。

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
2026-07-24-web-session-model-selector.md: 8f727b6659cf76255ef22aed2e4bb0fcccc9d571
2026-07-24-web-session-model-selector.zh.md: 5cd37a354ffbded4b320c17207709fbbbb18f9f1

View File

@@ -0,0 +1,39 @@
# Agent Note: Session model selection in the Web composer
Status: implemented
English | [中文](2026-07-24-web-session-model-selector.zh.md)
## Problem
The Web conversation displayed and sent through the Host's fixed provider/model route without exposing that route or letting a user change it. The TUI already had a session-local route target, but copying its presentation or hardcoding DeepSeek models in the browser would split model discovery and step-boundary semantics across front doors. A switch made while a response is running also needs one atomic boundary: prompt variables and request routing cannot observe different targets.
## Decision
The Web Host reuses `installAgentLlmTarget` for every created or resumed agent. The target starts from the latest `request/header` when the session has used a model, otherwise from the Host default. `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 route 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 `session.history`'s current `modelTarget`, a `session.models` directory, and `session.selectModel`. The directory is built dynamically from the LLM registry and grouped by provider. Provider catalogs load concurrently 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, and selecting an unlisted model under a registered provider remains valid.
The browser `Session` object owns the current target, grouped catalog, provider failures, operation error, and `idle`/`loading`/`ready`/`selecting`/`error` state. Selector mount primes the directory so the compact trigger can resolve a catalog name, and each menu open refreshes it. Directory and selection calls share a generation counter so older responses cannot replace a newer result; failures retain the previous current target and usable groups.
`@deepseek-ai/dsh-client-ui-conversation` declares the session-scoped single slot `conversation.composer.control` immediately before its primary button. `@deepseek-ai/dsh-client-ui-model-selector` occupies that slot only in an existing conversation. Its compact trigger and radio rows display the catalog name, falling back to the model id for an unlisted current target, while the upward menu displays provider headings once with keyboard navigation, dismissal, retry states, and current selection marking.
## Alternatives considered
**Use separate provider and model dropdowns.** The model list depends on the provider and repeats a two-stage interaction for every change. One grouped menu keeps the provider visible as organization without lengthening the trigger or each row.
**Hardcode the current DeepSeek catalog in the Web client.** This would drift from registered adapters and exclude deployment-owned providers. The LLM registry remains the source of provider and model metadata, including partial lookup failures.
**Make the selection a global default.** A global mutation would unexpectedly redirect other open conversations. The target belongs to one live session, while Host configuration remains the default for sessions without a logged request.
**Reject changes while an agent is running.** The shared atomic target already separates the assembled step from the next selection. Keeping the selector available lets the user prepare the following step without altering the in-flight request.
**Persist every click as a new session event.** A choice is not model-visible until prompt assembly consumes it. Persisting unused UI intent would add a durable event that does not reconstruct a model request; the existing `request/header` records the first request that actually uses the route.
## Consequences
An existing Web conversation can switch among dynamically discovered provider groups without displaying duplicated `provider/model` labels, and the current used route survives resume and reconnect. Catalog names remain presentation-only; selection and persistence continue to use provider/model ids. A provider catalog outage degrades only that group. Selection changes can reduce provider-side cache reuse when the route changes, but the selector adds no prompt content and does not disturb the in-flight step. The empty new-session composer still uses the Host default because it has no session identity or selector slot.
## Testing
Host tests pin grouped discovery, duplicate-catalog isolation, partial provider failure, logged restoration, unlisted current targets, unavailable-provider rejection, and next-assembly switching. Client tests pin state transitions, failure preservation, transport errors, stale-response fencing, history restoration, and snapshot reference stability. UI tests pin slot lifecycle, catalog-name labels with id fallback, provider grouping, radio semantics, retry/error states, successful and failed selection, outside dismissal, and Arrow/Home/End/Escape navigation. The keyless Web fixture exposes two provider groups and reports the selected route in the next generated response.

View File

@@ -0,0 +1,39 @@
# Agent Note: Web 对话输入区的会话模型选择
Status: implemented
[English](2026-07-24-web-session-model-selector.md) | 中文
## 问题
Web 对话原本通过 Host 固定的提供方与模型路由显示并发送消息既不呈现该路由也不允许用户更改。TUI 已经具备会话级的路由目标,但如果照搬其呈现方式,或在浏览器中硬编码 DeepSeek 模型,就会让模型发现逻辑和步骤边界语义分散到不同前门中。响应运行期间发生的切换还需要一个原子边界:提示词变量与请求路由不能观测到不同的目标。
## 决策
Web Host 为每个新建或恢复的 agent智能体复用 `installAgentLlmTarget`。如果会话已经使用过模型,目标从最新的 `request/header` 开始;否则采用 Host 默认值。`session.selectModel` 会更改会话级可变目标,提示词组装则将该目标与请求路由一并捕获,因此运行中步骤发生的切换会应用于下一个组装步骤。下一条实际采用的路由通过现有的完整 `request/header` 快照持久化;尚未进入请求的选择则仅保存在当前进程中。
会话 RPC 领域公开 `session.history` 的当前 `modelTarget``session.models` 模型目录与 `session.selectModel`。该目录从 LLM大语言模型注册表动态构建并按提供方分组。各提供方目录会并发加载且彼此独立失败因此成功加载的分组仍可与可重试的失败记录一同使用。模型是否位于目录仅供参考如果当前模型的已注册提供方没有列出该模型系统会将其作为未列出行插入在已注册提供方下选择未列出的模型仍然有效。
浏览器中的 `Session` 对象持有当前目标、分组目录、提供方失败记录、操作错误,以及 `idle``loading``ready``selecting``error` 状态。选择器挂载时会预加载目录,使紧凑型触发器能够解析目录名称;此后每次打开菜单都会刷新目录。目录与选择调用共用一个代次计数器,防止较早响应覆盖较新结果;失败时保留先前的当前目标和可用分组。
`@deepseek-ai/dsh-client-ui-conversation` 在主按钮之前紧邻位置声明会话作用域的单实例 slot `conversation.composer.control``@deepseek-ai/dsh-client-ui-model-selector` 仅在已有对话中占用该 slot。其紧凑型触发器和单选菜单项显示目录名称当前目标未列出时则回退到模型 ID。向上展开的菜单只显示一次提供方标题同时提供键盘导航、关闭操作、重试状态和当前选择标记。
## 考虑过的替代方案
**分别使用提供方与模型下拉框。** 模型列表依赖提供方,每次更改都需要经过两阶段交互。单个分组菜单仍以提供方组织模型,同时不会增加触发器或各行的显示长度。
**在 Web 客户端中硬编码当前 DeepSeek 目录。** 该目录会与已注册适配器发生偏离也会排除部署自有的提供方。LLM 注册表继续作为提供方与模型元数据的真源,也负责呈现部分查询失败。
**将选择设为全局默认值。** 全局变更会意外改道其他已打开的对话。目标仅属于一个实时会话对于没有已记录请求的会话Host 配置仍是默认值。
**agent 运行期间拒绝更改。** 共享原子目标已经将当前组装步骤与下一次选择分离。保持选择器可用,可以让用户为下一个步骤预先选择模型,而不会改变正在执行的请求。
**将每次点击作为新的会话事件持久化。** 只有提示词组装采用某项选择后,该选择才对模型可见。持久化尚未使用的 UI 意图,会增加一个无法重建模型请求的持久事件;现有 `request/header` 会记录首次实际使用该路由的请求。
## 影响
已有 Web 对话可以在动态发现的提供方分组之间切换,而无需显示重复的 `provider/model` 标签;当前实际使用的路由会在恢复和重连后保留。目录名称仅用于呈现;选择和持久化仍然使用提供方/模型 ID。某个提供方的目录不可用时只有相应分组会降级。路由变更可能降低提供方侧的缓存复用率但选择器不会添加任何提示词内容也不会干扰正在执行的步骤。新的空会话输入区仍使用 Host 默认值,因为它没有会话标识或选择器 slot。
## 测试
Host 测试固定分组发现、重复目录项隔离、部分提供方失败、已记录目标恢复、当前未列出目标、不可用提供方拒绝以及切换仅影响下一次组装。客户端测试固定状态转换、失败时保留原状态、传输错误、过时响应栅栏、从历史记录恢复以及快照引用稳定性。UI 测试固定 slot 生命周期、显示目录名称并回退到 ID、提供方分组、单选语义、重试与错误状态、选择成功与失败、点击外部关闭以及 Arrow/Home/End/Escape 键盘导航。无密钥 Web fixture测试前置数据公开两个提供方分组并在下一条生成的响应中报告所选路由。

View File

@@ -20,6 +20,7 @@
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
"@deepseek-ai/dsh-client-ui-layout": "workspace:^",
"@deepseek-ai/dsh-client-ui-model-selector": "workspace:^",
"@deepseek-ai/dsh-client-ui-question": "workspace:^",
"@deepseek-ai/dsh-client-ui-sidebar": "workspace:^",
"@deepseek-ai/dsh-client-ui-theme": "workspace:^",

View File

@@ -37,6 +37,7 @@ const CLIENT_PACKAGES = [
'@deepseek-ai/dsh-client-ui-layout',
'@deepseek-ai/dsh-client-ui-sidebar',
'@deepseek-ai/dsh-client-ui-conversation',
'@deepseek-ai/dsh-client-ui-model-selector',
'@deepseek-ai/dsh-client-ui-question',
'@deepseek-ai/dsh-client-ui-trajectory',
] as const

View File

@@ -53,6 +53,9 @@
{
"path": "../../packages/client/ui-conversation"
},
{
"path": "../../packages/client/ui-model-selector"
},
{
"path": "../../packages/client/ui-trajectory"
},

View File

@@ -14,6 +14,7 @@ const PLUGINS: readonly (WebBootEntry & { dir: string })[] = [
{ id: '@deepseek-ai/dsh-client-ui-layout', dir: 'ui-layout', url: '/plugins/ui-layout.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime'] },
{ id: '@deepseek-ai/dsh-client-ui-sidebar', dir: 'ui-sidebar', url: '/plugins/ui-sidebar.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] },
{ id: '@deepseek-ai/dsh-client-ui-conversation', dir: 'ui-conversation', url: '/plugins/ui-conversation.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] },
{ id: '@deepseek-ai/dsh-client-ui-model-selector', dir: 'ui-model-selector', url: '/plugins/ui-model-selector.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-conversation'] },
{ id: '@deepseek-ai/dsh-client-ui-trajectory', dir: 'ui-trajectory', url: '/plugins/ui-trajectory.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-conversation'] },
]
@@ -77,7 +78,7 @@ function titleSurfaces(label: string): { sidebar: string; breadcrumb: string; do
return { sidebar, breadcrumb, documentTitle: document.title }
}
it('projects initial and revised durable titles through the built eight-plugin fixture app', async () => {
it('projects initial and revised durable titles through the built nine-plugin fixture app', async () => {
const root = document.querySelector<HTMLElement>('#root')
if (root === null) throw new Error('snapshot root missing')
act(() => {

View File

@@ -1,7 +1,7 @@
// Keyless boot-chain smoke over the REAL carrier: startWebServer + entry
// graph (__DSH_BOOT__ web2 shape) injection + built shell dist in a real
// chromium. First describe: graph injection + the fail-loud half. Second
// describe: the settled success pass — all nine REAL tsdown bundles load
// describe: the settled success pass — all ten REAL tsdown bundles load
// through the module system + vendored Loader chain in ?fixture mode (the
// infrastructure four ride the immediately prefetch tier, the UI rows fetch
// on demand), the three-column frame appears in one flip, and the resident
@@ -31,6 +31,7 @@ const REAL_PLUGINS: { id: string; dir: string; inject?: string[]; immediately?:
{ id: LAYOUT_ID, dir: 'ui-layout', inject: ['@deepseek-ai/dsh-client-runtime'] },
{ id: SIDEBAR_ID, dir: 'ui-sidebar', inject: [LAYOUT_ID] },
{ id: '@deepseek-ai/dsh-client-ui-conversation', dir: 'ui-conversation', inject: [LAYOUT_ID] },
{ id: '@deepseek-ai/dsh-client-ui-model-selector', dir: 'ui-model-selector', inject: ['@deepseek-ai/dsh-client-ui-conversation'] },
{ id: '@deepseek-ai/dsh-client-ui-question', dir: 'ui-question', inject: ['@deepseek-ai/dsh-client-ui-conversation'] },
{ id: '@deepseek-ai/dsh-client-ui-trajectory', dir: 'ui-trajectory', inject: ['@deepseek-ai/dsh-client-ui-conversation'] },
]
@@ -118,7 +119,7 @@ describe('web boot chain (keyless, real carrier)', () => {
})
})
describe('web boot chain success pass (keyless, nine real bundles, ?fixture)', () => {
describe('web boot chain success pass (keyless, ten real bundles, ?fixture)', () => {
let server: Awaited<ReturnType<typeof startWebServer>>
let browser: Browser
let page: Page
@@ -246,6 +247,37 @@ describe('web boot chain success pass (keyless, nine real bundles, ?fixture)', (
expect(await external.getAttribute('rel')).toBe('noopener noreferrer')
})
it('selects a model from a provider group and uses it for the next fixture request', async () => {
onTestFailed(() => saveFailureShot(page, 'smoke-model-selector'))
await page.getByRole('button', { name: 'New session', exact: true }).click()
const emptyInput = page.locator('textarea[placeholder]')
await emptyInput.fill('selector seed')
await page.getByRole('button', { name: '发送' }).click()
const selector = page.getByRole('button', { name: '选择模型,当前 DeepSeek-V4-Flash' })
await selector.waitFor({ state: 'visible', timeout: 15_000 })
expect(await selector.innerText()).toBe('DeepSeek-V4-Flash')
await selector.click()
const menu = page.getByRole('menu', { name: '模型' })
await menu.waitFor()
const triggerBox = await selector.boundingBox()
const menuBox = await menu.boundingBox()
expect(triggerBox).not.toBeNull()
expect(menuBox).not.toBeNull()
expect((menuBox?.y ?? 0) + (menuBox?.height ?? 0)).toBeLessThanOrEqual((triggerBox?.y ?? 0) + 1)
expect(await page.getByRole('group', { name: 'DeepSeek' }).getByRole('menuitemradio').allTextContents())
.toEqual(expect.arrayContaining(['DeepSeek-V4-Flash快速响应', 'DeepSeek-V4-Pro复杂任务']))
await page.getByRole('group', { name: 'OpenAI' }).getByRole('menuitemradio', { name: 'GPT-5' }).click()
await menu.waitFor({ state: 'detached' })
expect(await page.getByRole('button', { name: '选择模型,当前 GPT-5' }).innerText()).toBe('GPT-5')
const residentInput = page.locator('textarea[placeholder]')
await expect.poll(() => residentInput.isEnabled(), { timeout: 15_000 }).toBe(true)
await residentInput.fill('report model')
await page.getByRole('button', { name: '发送' }).click()
await page.getByText('当前模型openai/gpt-5', { exact: true }).waitFor({ timeout: 15_000 })
})
it('renders and completes the resident question through the composer slot', async () => {
onTestFailed(() => saveFailureShot(page, 'smoke-question-composer'))
const sessionTree = page.getByRole('tree', { name: 'Sessions' })

View File

@@ -144,10 +144,13 @@ async function detailsTrack(page: Page): Promise<number> {
return Number(cols.split(' ').pop()!.replace('px', ''))
}
// Readiness gate: `dsh web` serves ALL nine manifest plugins; until every UI
// Readiness gate: `dsh web` serves all ten production manifest plugins; until every UI
// plugin's client bundle exists and exports apply, the loader fail-louds and
// the frame never appears.
const UI_PLUGIN_DIRS = ['connection', 'runtime', 'ui-theme', 'i18n', 'ui-layout', 'ui-sidebar', 'ui-conversation', 'ui-question', 'ui-trajectory']
const UI_PLUGIN_DIRS = [
'connection', 'runtime', 'ui-theme', 'i18n', 'ui-layout', 'ui-sidebar',
'ui-conversation', 'ui-model-selector', 'ui-question', 'ui-trajectory',
]
const ROUND_DONE_MARKER = 'WEB_ROUND_DONE'
const notReady = UI_PLUGIN_DIRS.filter((dir) => {
const bundle = join(REPO_ROOT, 'packages/client', dir, 'lib/client.js')

View File

@@ -1898,6 +1898,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co
- `@deepseek-ai/dsh-client-runtime` ([`packages/client/runtime/src/index.ts`](../packages/client/runtime/src/index.ts))
- `@deepseek-ai/dsh-client-ui-conversation` ([`packages/client/ui-conversation/src/index.ts`](../packages/client/ui-conversation/src/index.ts))
- `@deepseek-ai/dsh-client-ui-layout` ([`packages/client/ui-layout/src/index.ts`](../packages/client/ui-layout/src/index.ts))
- `@deepseek-ai/dsh-client-ui-model-selector` ([`packages/client/ui-model-selector/src/index.ts`](../packages/client/ui-model-selector/src/index.ts))
- `@deepseek-ai/dsh-client-ui-question` — requires `tools` · `userInteraction` ([`packages/client/ui-question/src/index.ts`](../packages/client/ui-question/src/index.ts))
- `@deepseek-ai/dsh-client-ui-sidebar` ([`packages/client/ui-sidebar/src/index.ts`](../packages/client/ui-sidebar/src/index.ts))
- `@deepseek-ai/dsh-client-ui-theme` ([`packages/client/ui-theme/src/index.ts`](../packages/client/ui-theme/src/index.ts))

View File

@@ -7,7 +7,8 @@
export type {
ApiProxy, SessionsApi, SessionSummary, HostApi, EventsApi, MuxFrame, HostFrame,
ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, ToolEventView,
ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, ModelCatalogFailure,
ModelCatalogModel, ModelProviderGroup, ModelTarget, SessionModels, ToolEventView,
} from '@deepseek-ai/dsh-host-apiproxy/api'
export type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-tools/presentation'
export type {

View File

@@ -9,7 +9,7 @@ import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session/types'
import type {
ApiProxy, ClientRequest, ClientResponse, HistoryEntry, HostFrame, MuxFrame, RpcReceipt,
RpcRequest, RpcResponse, RpcResult, ServerRequest, ServerResponse, SessionSummary,
ModelTarget, RpcRequest, RpcResponse, RpcResult, ServerRequest, ServerResponse, SessionSummary,
ToolCallView, ToolEventView, ToolResultView,
} from './api.ts'
import type { RequestPayload, ResponseValue, RpcMethodMap } from '@deepseek-ai/dsh-host-apiproxy/api'
@@ -295,6 +295,10 @@ export function createFixtureApi(): ApiProxy {
{ sessionId: sid('fx-gamma'), updatedAt: Date.now() - 120_000, running: false, cwd: '/tmp/fixture' },
]
const logs = new Map<SessionId, SessionEvent[]>([[sid('fx-alpha'), buildAlphaLog()]])
const modelTargets = new Map<SessionId, ModelTarget>(sessions.map(session => [
session.sessionId,
{ provider: 'deepseek', model: 'deepseek-v4-flash' },
]))
const nextTurn = new Map<SessionId, number>([[sid('fx-alpha'), 60]])
let nextSession = 1
let nextRpc = 1
@@ -469,6 +473,7 @@ export function createFixtureApi(): ApiProxy {
sessionId: sid(`fx-${nextSession++}`), updatedAt: Date.now(), running: false, cwd: '/tmp/fixture',
}
sessions.push(created)
modelTargets.set(created.sessionId, { provider: 'deepseek', model: 'deepseek-v4-flash' })
emitHost({ type: 'host/session-added', sessionId: created.sessionId })
return ok(request, { sessionId: created.sessionId })
},
@@ -481,7 +486,36 @@ export function createFixtureApi(): ApiProxy {
const delay = historyDelayMs
if (delay > 0) await new Promise(resolve => setTimeout(resolve, delay))
if (doomed) throw new Error('fixture: simulated history transport failure')
return ok(request, page)
return ok(request, {
...page,
modelTarget: modelTargets.get(request.payload.sessionId)
?? { provider: 'deepseek', model: 'deepseek-v4-flash' },
})
},
models: request => ok(request, {
current: modelTargets.get(request.payload.sessionId)
?? { provider: 'deepseek', model: 'deepseek-v4-flash' },
groups: [
{
id: 'deepseek',
name: 'DeepSeek',
models: [
{ id: 'deepseek-v4-flash', name: 'DeepSeek-V4-Flash', description: '快速响应' },
{ id: 'deepseek-v4-pro', name: 'DeepSeek-V4-Pro', description: '复杂任务' },
],
},
{
id: 'openai',
name: 'OpenAI',
models: [{ id: 'gpt-5', name: 'GPT-5' }],
},
],
failures: [],
}),
selectModel: (request) => {
const selected = { provider: request.payload.provider, model: request.payload.model }
modelTargets.set(request.payload.sessionId, selected)
return ok(request, { selected })
},
prompt: (request) => {
const { sessionId: id, mode, content } = request.payload
@@ -508,7 +542,9 @@ export function createFixtureApi(): ApiProxy {
turn,
userText === 'render markdown'
? MARKDOWN_FIXTURE
: `回声:${userText}。这是 fixture 的流式回复,用于验证打字机增长与定稿切换。`,
: userText === 'report model'
? `当前模型:${modelTargets.get(id)?.provider ?? 'unknown'}/${modelTargets.get(id)?.model ?? 'unknown'}`
: `回声:${userText}。这是 fixture 的流式回复,用于验证打字机增长与定稿切换。`,
)
return ok(request, { accepted: true as const })
},
@@ -631,6 +667,8 @@ export class FixtureApiClient extends AbstractApiClient {
case 'session.list': return this.api.sessions.list(request)
case 'session.create': return this.api.sessions.create(request)
case 'session.history': return this.api.sessions.history(request)
case 'session.models': return this.api.sessions.models(request)
case 'session.selectModel': return this.api.sessions.selectModel(request)
case 'session.prompt': return this.api.sessions.prompt(request)
case 'session.cancel': return this.api.sessions.cancel(request)
case 'host.describe': return this.api.host.describe(request)

View File

@@ -15,7 +15,8 @@ import { WebApiClient } from './web-api-client.ts'
// ---- Contract re-exports (browser-safe apiproxy channels + core types) ----
export type {
ApiProxy, SessionsApi, SessionSummary, HostApi, EventsApi, MuxFrame, HostFrame,
ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, ToolEventView,
ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, ModelCatalogFailure,
ModelCatalogModel, ModelProviderGroup, ModelTarget, SessionModels, ToolEventView,
ToolCallView, ToolResultView,
RpcRequest, RpcResponse, RpcResult, RpcError, RpcErrorCode,
ClientRequest, ServerResponse, ServerRequest, ClientResponse, RpcMessage, RpcReceipt,

View File

@@ -2,7 +2,7 @@
// data source on a real clock; behavior tests need per-case responses and
// deferred-controlled timing). Streams are hand pumps: pushMux/pushHost.
import type {
HostFrame, IApiClient, MuxFrame, RpcRequest, RpcResponse, SessionId,
HostFrame, IApiClient, ModelTarget, MuxFrame, RpcRequest, RpcResponse, SessionId, SessionModels,
} from '../src/client/api.ts'
import { RpcId } from '../src/client/api.ts'
@@ -44,9 +44,21 @@ export class FakeApiClient implements IApiClient {
onList: (payload: unknown) => Promise<RpcResponse<{ items: never[] }>> = () => Promise.resolve(ok({ items: [] }))
onCreate: (payload: unknown) => Promise<RpcResponse<{ sessionId: SessionId }>> = () => Promise.resolve(ok({ sessionId: 'fk-new' as SessionId }))
onHistory: (payload: { sessionId: SessionId; beforeSeq?: number; maxMessages?: number })
=> Promise<RpcResponse<{ events: never[]; hasMore: boolean }>> =
() => Promise.resolve(ok({ events: [], hasMore: false }))
=> Promise<RpcResponse<{ events: never[]; hasMore: boolean; modelTarget: ModelTarget }>> =
() => Promise.resolve(ok({
events: [],
hasMore: false,
modelTarget: { provider: 'deepseek', model: 'deepseek-chat' },
}))
onModels: (payload: unknown) => Promise<RpcResponse<SessionModels>> = () => Promise.resolve(ok({
current: { provider: 'deepseek', model: 'deepseek-chat' },
groups: [],
failures: [],
}))
onSelectModel: (payload: ModelTarget & { sessionId: SessionId })
=> Promise<RpcResponse<{ selected: ModelTarget }>> =
payload => Promise.resolve(ok({ selected: { provider: payload.provider, model: payload.model } }))
onPrompt: (payload: unknown) => Promise<RpcResponse<{ accepted: true }>> = () => Promise.resolve(ok({ accepted: true as const }))
onCancel: (payload: unknown) => Promise<RpcResponse<{ accepted: true }>> = () => Promise.resolve(ok({ accepted: true as const }))
onDescribe: (payload: unknown) => Promise<RpcResponse<{ version: string; cwd: string; attachedSessions: number }>> =
@@ -63,6 +75,9 @@ export class FakeApiClient implements IApiClient {
create: (payload: unknown) => this.record('session.create', payload, this.onCreate(payload)),
history: (payload: { sessionId: SessionId; beforeSeq?: number; maxMessages?: number }) =>
this.record('session.history', payload, this.onHistory(payload)),
models: (payload: unknown) => this.record('session.models', payload, this.onModels(payload)),
selectModel: (payload: ModelTarget & { sessionId: SessionId }) =>
this.record('session.selectModel', payload, this.onSelectModel(payload)),
prompt: (payload: unknown) => this.record('session.prompt', payload, this.onPrompt(payload)),
cancel: (payload: unknown) => this.record('session.cancel', payload, this.onCancel(payload)),
}

View File

@@ -68,7 +68,43 @@ describe('createFixtureApi', () => {
// Unknown session: empty page, not an error (history of a bare id).
const empty = await api.sessions.history(req({ sessionId: sid('no-such'), maxMessages: 10 }))
if (!empty.result.ok) throw new Error('empty failed')
expect(empty.result.value).toEqual({ events: [], hasMore: false })
expect(empty.result.value).toEqual({
events: [],
hasMore: false,
modelTarget: { provider: 'deepseek', model: 'deepseek-v4-flash' },
})
})
it('serves grouped models and keeps a selected target for later history and fixture requests', async () => {
const api = createFixtureApi()
const sessionId = sid('fx-alpha')
const catalog = await api.sessions.models(req({ sessionId }))
if (!catalog.result.ok) throw new Error('models failed')
expect(catalog.result.value.groups.map(group => group.name)).toEqual(['DeepSeek', 'OpenAI'])
expect(catalog.result.value.groups[0]?.models.map(model => model.id))
.toEqual(['deepseek-v4-flash', 'deepseek-v4-pro'])
const selected = await api.sessions.selectModel(req({
sessionId,
provider: 'openai',
model: 'gpt-5',
}))
if (!selected.result.ok) throw new Error('selection failed')
expect(selected.result.value.selected).toEqual({ provider: 'openai', model: 'gpt-5' })
const history = await api.sessions.history(req({ sessionId }))
if (!history.result.ok) throw new Error('history failed')
expect(history.result.value.modelTarget).toEqual({ provider: 'openai', model: 'gpt-5' })
const prompt = await api.sessions.prompt(req({
sessionId,
mode: 'queue',
content: [{ type: 'text', text: 'report model' }],
}))
expect(prompt.result.ok).toBe(true)
await new Promise(resolve => setTimeout(resolve, 600))
const after = await api.sessions.history(req({ sessionId }))
if (!after.result.ok) throw new Error('history failed')
expect(JSON.stringify(after.result.value.events)).toContain('openai/gpt-5')
})
it('create adds a session and pushes host/session-added to open host streams', async () => {

View File

@@ -5,7 +5,7 @@
* `rebuilt` frame it re-fetches the entry's bundle and swaps the cordis
* fiber in place. Every graph entry is a plugin bundle under the web2 model
* — `immediately` rows differ only in stage-one prefetch (a boot
* optimization), so all nine plugin packages share these reload semantics;
* optimization), so all rostered plugin packages share these reload semantics;
* normal packages (react family, cordis, shell, pure libs) are not entries
* and shell changes still mean a page reload. Cascade is zero-touch:
* downstream fibers key their activation epoch on provider fiber uids

View File

@@ -6,13 +6,17 @@ Client cordis boot + core services: SlotsService (Service wrapper over SlotCore
`SessionManager` retains the latest validated `session/title` control snapshot independently of list and session-instance arrival. Newer event seqs replace older snapshots, title timestamps contribute to list recency, and a subscription baseline discards any retained title beyond its `lastSeq` before the optional folded title arrives. Explicit session removal also clears the retained title. The client-facing `SessionSummary.title` is therefore only the actual durable title; `displayTitle` is always present and falls back through the cwd basename and session id. A cold persisted session keeps that fallback until opening or resuming it causes the host to fold and project its log-backed title.
## Session model selection
Each resident `Session` owns a `modelSelection` snapshot containing the current provider/model target, provider-grouped directory, provider-local failures, and the `idle`/`loading`/`ready`/`selecting`/`error` state. History establishes or refreshes the current target, opening a selector refreshes the directory, and selection failures preserve the last target and usable groups. Directory and selection operations share a monotonically increasing generation so an older response cannot overwrite a newer selection. A reconnect rebuild restores the target reported by the Host without replacing unchanged selection substructure.
## Model Experience
None, as the client runtime hosts browser-side services and the session object layer; nothing here reaches a model request.
None, as the session object layer selects the provider/model route used by a later Host request but adds no model-visible content.
#### KV Cache effect
None; this package neither assembles nor sends a provider request.
Changing the target can change or invalidate provider-side cache reuse; this package does not alter the prompt prefix itself.
## Known Limitations and Deferred Work

View File

@@ -32,7 +32,7 @@ export type {
} from './contract/store.ts'
export type {
AssistantBlock, AssistantMessageNode, ContextMessageNode, ConversationNode, ConversationSnapshot,
RunningToolCall, SteeringMessageNode,
ModelSelectionSnapshot, ModelSelectionStatus, RunningToolCall, SteeringMessageNode,
ToolResultNode, UnknownSurfaceNode, UserMessageNode,
} from './sessions/conversation.ts'
// PendingWait is a value export: tests construct fixture waits directly.

View File

@@ -4,7 +4,10 @@
// string here (narrow to real brands when convenient).
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
import type { RpcError, SessionId, ToolCallView, ToolResultView } from '@deepseek-ai/dsh-client-connection/client'
import type {
ModelCatalogFailure, ModelProviderGroup, ModelTarget, RpcError, SessionId,
ToolCallView, ToolResultView,
} from '@deepseek-ai/dsh-client-connection/client'
import type { PendingInteraction } from './pending.ts'
/** Assistant content blocks sorted by what the UI cares about
@@ -139,6 +142,23 @@ export interface PromptError {
error: RpcError
}
/** Lifecycle of the session-local model directory and selection requests. */
export type ModelSelectionStatus = 'idle' | 'loading' | 'ready' | 'selecting' | 'error'
/** Immutable model-selector state owned by the Session object layer. */
export interface ModelSelectionSnapshot {
/** Target selected for the next assembled step, or null before history opens. */
current: ModelTarget | null
/** Last successfully loaded provider groups. */
groups: readonly ModelProviderGroup[]
/** Provider-local failures from the last successful directory response. */
failures: readonly ModelCatalogFailure[]
/** Current directory or selection operation state. */
status: ModelSelectionStatus
/** Whole-request or selection failure; partial provider failures use {@link failures}. */
error: RpcError | null
}
/** The immutable snapshot contract Session hands to uSES (see the web client architecture RFC). */
export interface ConversationSnapshot {
sessionId: SessionId
@@ -158,4 +178,6 @@ export interface ConversationSnapshot {
loadingOlder: boolean
promptError: PromptError | null
lastAgentError: string | null
/** Session-local model target and advisory directory state. */
modelSelection: ModelSelectionSnapshot
}

View File

@@ -6,15 +6,16 @@
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
import type {
HistoryEntry, IApiClient, MuxFrame, RpcError, RpcId, RpcResult,
SessionId, ToolEventView,
HistoryEntry, IApiClient, ModelTarget, MuxFrame, RpcError, RpcId, RpcResult,
SessionId, SessionModels, ToolEventView,
} from '@deepseek-ai/dsh-client-connection/client'
// Value import from the inline-safe wire layer (not the connection plugin):
// plugin-to-plugin value imports are a bundle purity error.
import { transportError } from '@deepseek-ai/dsh-host-apiproxy/api'
import type { ObservableSnapshot } from '../contract/store.ts'
import type {
ConversationNode, ConversationSnapshot, OpenState, PromptError, RunningToolCall,
ConversationNode, ConversationSnapshot, ModelSelectionSnapshot, OpenState, PromptError,
RunningToolCall,
} from './conversation.ts'
import type { PendingInteraction } from './pending.ts'
import { PendingWait } from './pending.ts'
@@ -69,6 +70,17 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
private removed = false
private promptError: PromptError | null = null
private lastAgentError: string | null = null
private modelSelection: ModelSelectionSnapshot = {
current: null,
groups: [],
failures: [],
status: 'idle',
error: null,
}
/** Latest model-directory/selection operation; stale responses drop all writes. */
private modelGeneration = 0
/** Failed selection target; null means the retryable operation is a directory refresh. */
private modelRetryTarget: ModelTarget | null = null
/** Buffer for live events arriving while open/resync is in flight (stitched by seq once history lands, §D.3). */
private liveBuffer: { event: SessionEvent; view: ToolEventView | undefined }[] = []
/** Gap-repair (resync-lite) in flight: acceptLiveEvent detours to liveBuffer until the tail page lands (audit S3). */
@@ -128,6 +140,102 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
return result
}
/**
* Refresh the advisory provider/model directory. Independent provider
* failures remain in a successful snapshot; whole-request failures preserve
* the last usable groups and current target.
* @returns the model-directory RPC result.
*/
async refreshModels(): Promise<RpcResult<SessionModels>> {
const generation = ++this.modelGeneration
this.modelRetryTarget = null
this.modelSelection = {
...this.modelSelection,
status: 'loading',
error: null,
}
this.notifier.markDirty()
let result: RpcResult<SessionModels>
try {
result = (await this.api.sessions.models({ sessionId: this.sessionId })).result
} catch (error: unknown) {
result = transportError(error)
}
if (generation !== this.modelGeneration) return result
this.modelSelection = result.ok
? {
current: result.value.current,
groups: result.value.groups,
failures: result.value.failures,
status: 'ready',
error: null,
}
: {
...this.modelSelection,
status: 'error',
error: result.error,
}
this.notifier.markDirty()
return result
}
/**
* Select the complete route for this session. The host snapshots it at the
* next prompt-assembly boundary, so running work keeps its assembled target.
* @param target - Provider and provider-owned model id.
* @returns the selection RPC result.
*/
async selectModel(target: ModelTarget): Promise<RpcResult<{ selected: ModelTarget }>> {
const generation = ++this.modelGeneration
this.modelRetryTarget = target
this.modelSelection = {
...this.modelSelection,
status: 'selecting',
error: null,
}
this.notifier.markDirty()
let result: RpcResult<{ selected: ModelTarget }>
try {
result = (await this.api.sessions.selectModel({
sessionId: this.sessionId,
provider: target.provider,
model: target.model,
})).result
} catch (error: unknown) {
result = transportError(error)
}
if (generation !== this.modelGeneration) return result
if (result.ok) this.modelRetryTarget = null
this.modelSelection = result.ok
? {
...this.modelSelection,
current: result.value.selected,
status: 'ready',
error: null,
}
: {
...this.modelSelection,
status: 'error',
error: result.error,
}
this.notifier.markDirty()
return result
}
/**
* Repeat the operation that produced the visible model error.
* A failed selection retains its exact target; directory failures refresh.
* @returns Whether a model selection succeeded; directory retries return false.
*/
async retryModelOperation(): Promise<boolean> {
const target = this.modelRetryTarget
if (target === null) {
await this.refreshModels()
return false
}
return (await this.selectModel(target)).ok
}
/** First open: pull the tail page (idempotent — in-flight/already-open returns the existing promise). */
open(): Promise<void> {
if (this.openState === 'open') return Promise.resolve()
@@ -318,6 +426,7 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
this.openError = null
this.notifier.markDirty()
try {
let modelGeneration = this.modelGeneration
let { result } = await this.api.sessions.history({ sessionId: this.sessionId, maxMessages: PAGE_MESSAGES })
if (generation !== this.openGeneration) return
if (!result.ok) {
@@ -325,13 +434,24 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
this.openError = result.error
return
}
this.installWindow(result.value.events, result.value.hasMore)
this.installWindow(
result.value.events,
result.value.hasMore,
modelGeneration === this.modelGeneration ? result.value.modelTarget : undefined,
)
// Gap detection (§D.3-4): baseline past the window tail and liveBuffer did not cover it -> pull the tail page once more.
const tailSeq = this.windowTailSeq()
if (this.subscribedLastSeq !== null && tailSeq !== null && this.subscribedLastSeq > tailSeq) {
modelGeneration = this.modelGeneration
result = (await this.api.sessions.history({ sessionId: this.sessionId, maxMessages: PAGE_MESSAGES })).result
if (generation !== this.openGeneration) return
if (result.ok) this.installWindow(result.value.events, result.value.hasMore)
if (result.ok) {
this.installWindow(
result.value.events,
result.value.hasMore,
modelGeneration === this.modelGeneration ? result.value.modelTarget : undefined,
)
}
}
this.openState = 'open'
} catch (error) {
@@ -349,13 +469,30 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
* Stitching MUST NOT route through acceptLiveEvent: openState is still 'loading' here
* (doOpen flips it after install), so recursing would push every buffered event straight
* back into liveBuffer where nothing ever drains it — a silent drop loop (audit S1). */
private installWindow(entries: HistoryEntry[], hasMore: boolean): void {
private installWindow(entries: HistoryEntry[], hasMore: boolean, modelTarget?: ModelTarget): void {
this.events = entries.map(e => e.event)
this.views = entries.map(e => e.view)
this.baseSeq = this.events[0]?.seq ?? 0
this.hasMore = hasMore
this.foldAdapter.reset(this.events, this.baseSeq, this.views)
this.rebuildDerivedFromWindow()
if (modelTarget !== undefined) {
const current = this.modelSelection.current
if (
current === null
|| current.provider !== modelTarget.provider
|| current.model !== modelTarget.model
|| this.modelSelection.error !== null
) {
this.modelRetryTarget = null
this.modelSelection = {
...this.modelSelection,
current: modelTarget,
status: this.modelSelection.groups.length > 0 ? 'ready' : 'idle',
error: null,
}
}
}
const buffered = this.liveBuffer
this.liveBuffer = []
for (const item of buffered) this.appendLive(item.event, item.view)
@@ -400,11 +537,16 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
if (this.stitching) return
this.stitching = true
const generation = this.openGeneration
const modelGeneration = this.modelGeneration
try {
const { result } = await this.api.sessions.history({ sessionId: this.sessionId, maxMessages: PAGE_MESSAGES })
// Failure or superseded by a full resync: drop — the resync path rebuilds and clears the buffer itself.
if (result.ok && generation === this.openGeneration && this.openState === 'open') {
this.installWindow(result.value.events, result.value.hasMore)
this.installWindow(
result.value.events,
result.value.hasMore,
modelGeneration === this.modelGeneration ? result.value.modelTarget : undefined,
)
}
} catch (error) {
console.error('[web-runtime] gap repair failed:', error)
@@ -539,6 +681,7 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
loadingOlder: this.loadingOlder,
promptError: this.promptError,
lastAgentError: this.lastAgentError,
modelSelection: this.modelSelection,
}
}
}

View File

@@ -2,7 +2,8 @@
// data source on a real clock; behavior tests need per-case responses and
// deferred-controlled timing). Streams are hand pumps: pushMux/pushHost.
import type {
ClientResponse, HostFrame, IApiClient, MuxFrame, RpcError, RpcReceipt, RpcRequest, RpcResponse, SessionId,
ClientResponse, HostFrame, IApiClient, ModelTarget, MuxFrame, RpcError, RpcReceipt,
RpcRequest, RpcResponse, SessionId, SessionModels,
} from '@deepseek-ai/dsh-client-connection/client'
import { RpcId } from '@deepseek-ai/dsh-client-connection/client'
@@ -46,10 +47,23 @@ export class FakeApiClient implements IApiClient {
// Programmable slots (defaults answer OK-empty); reassign per case.
onList: (payload: unknown) => Promise<RpcResponse<{ items: never[] }>> = () => Promise.resolve(ok({ items: [] }))
onCreate: (payload: unknown) => Promise<RpcResponse<{ sessionId: SessionId }>> = () => Promise.resolve(ok({ sessionId: 'fk-new' as SessionId }))
readonly defaultModel: ModelTarget = { provider: 'deepseek', model: 'deepseek-v4-flash' }
onHistory: (payload: { sessionId: SessionId; beforeSeq?: number; maxMessages?: number })
=> Promise<RpcResponse<{ events: never[]; hasMore: boolean }>> =
() => Promise.resolve(ok({ events: [], hasMore: false }))
=> Promise<RpcResponse<{ events: never[]; hasMore: boolean; modelTarget: ModelTarget }>> =
() => Promise.resolve(ok({ events: [], hasMore: false, modelTarget: this.defaultModel }))
onModels: (payload: unknown) => Promise<RpcResponse<SessionModels>> = () => Promise.resolve(ok({
current: this.defaultModel,
groups: [{
id: 'deepseek',
name: 'DeepSeek',
models: [{ id: 'deepseek-v4-flash', name: 'DeepSeek V4 Flash' }],
}],
failures: [],
}))
onSelectModel: (payload: { provider: string; model: string }) =>
Promise<RpcResponse<{ selected: ModelTarget }>> =
payload => Promise.resolve(ok({ selected: { provider: payload.provider, model: payload.model } }))
onPrompt: (payload: unknown) => Promise<RpcResponse<{ accepted: true }>> = () => Promise.resolve(ok({ accepted: true as const }))
onCancel: (payload: unknown) => Promise<RpcResponse<{ accepted: true }>> = () => Promise.resolve(ok({ accepted: true as const }))
onDescribe: (payload: unknown) => Promise<RpcResponse<{ version: string; cwd: string; attachedSessions: number }>> =
@@ -66,6 +80,9 @@ export class FakeApiClient implements IApiClient {
create: (payload: unknown) => this.record('session.create', payload, this.onCreate(payload)),
history: (payload: { sessionId: SessionId; beforeSeq?: number; maxMessages?: number }) =>
this.record('session.history', payload, this.onHistory(payload)),
models: (payload: unknown) => this.record('session.models', payload, this.onModels(payload)),
selectModel: (payload: { provider: string; model: string }) =>
this.record('session.selectModel', payload, this.onSelectModel(payload)),
prompt: (payload: unknown) => this.record('session.prompt', payload, this.onPrompt(payload)),
cancel: (payload: unknown) => this.record('session.cancel', payload, this.onCancel(payload)),
}

View File

@@ -267,7 +267,11 @@ describe('remaining branches', () => {
describe('connected generation', () => {
it('refreshes the list and resyncs only opened instances', async () => {
const api = new FakeApiClient()
api.onHistory = () => Promise.resolve(ok({ events: entries(plainTurn(0, 0, 'a', 'b')) as never[], hasMore: false }))
api.onHistory = () => Promise.resolve(ok({
events: entries(plainTurn(0, 0, 'a', 'b')) as never[],
hasMore: false,
modelTarget: { provider: 'deepseek', model: 'deepseek-chat' },
}))
const manager = new SessionManager(api)
const openedSession = manager.get(S1)
await openedSession.open()

View File

@@ -24,7 +24,11 @@ function makeSession(api = new FakeApiClient()): { api: FakeApiClient; session:
function histResponse(events: SessionEvent[], hasMore = false) {
// history now returns HistoryEntry[] ({event, view?}); these tests are view-less.
return Promise.resolve(ok({ events: entries(events) as never[], hasMore }))
return Promise.resolve(ok({
events: entries(events) as never[],
hasMore,
modelTarget: { provider: 'deepseek', model: 'deepseek-v4-flash' },
}))
}
describe('open', () => {
@@ -75,7 +79,11 @@ describe('open', () => {
const page = plainTurn(10, 0, '早', '安')
session.handleMuxEnvelope('r1' as never, { type: 'session/event', sessionId: SID, event: ev.turnStart(15, 1) })
session.handleMuxEnvelope('r2' as never, { type: 'session/event', sessionId: SID, event: ev.user(16, '插进来的') })
gate.resolve(ok({ events: entries(page) as never[], hasMore: false }))
gate.resolve(ok({
events: entries(page) as never[],
hasMore: false,
modelTarget: { provider: 'deepseek', model: 'deepseek-v4-flash' },
}))
await opening
const seqs = session.getSnapshot().nodes.map(n => n.seq)
// Overlapping seq-15 frame (== page tail turn/end) was dropped; 16 appended once.
@@ -83,6 +91,165 @@ describe('open', () => {
})
})
describe('model selection', () => {
it('restores the current target from history, then refreshes grouped models with partial failures', async () => {
const { api, session } = makeSession()
api.onHistory = () => histResponse([])
api.onModels = () => Promise.resolve(ok({
current: { provider: 'deepseek', model: 'deepseek-v4-flash' },
groups: [{
id: 'deepseek',
name: 'DeepSeek',
models: [{ id: 'deepseek-v4-flash', name: 'DeepSeek V4 Flash' }],
}],
failures: [{ id: 'offline', name: 'Offline', message: 'catalog down' }],
}))
await session.open()
expect(session.getSnapshot().modelSelection).toEqual({
current: { provider: 'deepseek', model: 'deepseek-v4-flash' },
groups: [],
failures: [],
status: 'idle',
error: null,
})
const refreshing = session.refreshModels()
expect(session.getSnapshot().modelSelection.status).toBe('loading')
await refreshing
expect(session.getSnapshot().modelSelection).toEqual({
current: { provider: 'deepseek', model: 'deepseek-v4-flash' },
groups: [{
id: 'deepseek',
name: 'DeepSeek',
models: [{ id: 'deepseek-v4-flash', name: 'DeepSeek V4 Flash' }],
}],
failures: [{ id: 'offline', name: 'Offline', message: 'catalog down' }],
status: 'ready',
error: null,
})
})
it('keeps the previous target and directory when selection fails, then accepts a retry', async () => {
const { api, session } = makeSession()
await session.open()
await session.refreshModels()
const before = session.getSnapshot().modelSelection
api.onSelectModel = () => Promise.resolve(err({
code: 'model-unavailable',
message: 'gone',
details: { provider: 'deepseek', model: 'deepseek-v4-pro' },
}))
const failed = session.selectModel({ provider: 'deepseek', model: 'deepseek-v4-pro' })
expect(session.getSnapshot().modelSelection.status).toBe('selecting')
await failed
const errored = session.getSnapshot().modelSelection
expect(errored.current).toBe(before.current)
expect(errored.groups).toBe(before.groups)
expect(errored).toMatchObject({ status: 'error', error: { code: 'model-unavailable' } })
api.onSelectModel = payload => Promise.resolve(ok({
selected: { provider: payload.provider, model: payload.model },
}))
await session.retryModelOperation()
expect(api.callsOf('session.selectModel').at(-1)).toEqual({
sessionId: SID,
provider: 'deepseek',
model: 'deepseek-v4-pro',
})
expect(session.getSnapshot().modelSelection).toMatchObject({
current: { provider: 'deepseek', model: 'deepseek-v4-pro' },
status: 'ready',
error: null,
})
})
it('drops stale directory responses after a newer selection operation wins', async () => {
const { api, session } = makeSession()
await session.open()
const stale = deferred<Awaited<ReturnType<FakeApiClient['onModels']>>>()
api.onModels = () => stale.promise
const refreshing = session.refreshModels()
await session.selectModel({ provider: 'deepseek', model: 'deepseek-v4-pro' })
const selected = session.getSnapshot().modelSelection
stale.resolve(ok({
current: { provider: 'deepseek', model: 'stale' },
groups: [{ id: 'deepseek', name: 'Old', models: [{ id: 'stale', name: 'Stale' }] }],
failures: [],
}))
await refreshing
expect(session.getSnapshot().modelSelection).toBe(selected)
expect(session.getSnapshot().modelSelection.current)
.toEqual({ provider: 'deepseek', model: 'deepseek-v4-pro' })
})
it('does not let an older history response overwrite a newer selected target', async () => {
const { api, session } = makeSession()
const history = deferred<Awaited<ReturnType<FakeApiClient['onHistory']>>>()
api.onHistory = () => history.promise
const opening = session.open()
await session.selectModel({ provider: 'deepseek', model: 'deepseek-v4-pro' })
history.resolve(ok({
events: [],
hasMore: false,
modelTarget: { provider: 'deepseek', model: 'deepseek-v4-flash' },
}))
await opening
expect(session.getSnapshot().modelSelection.current)
.toEqual({ provider: 'deepseek', model: 'deepseek-v4-pro' })
})
it('folds directory and selection transport failures without discarding usable state', async () => {
const { api, session } = makeSession()
await session.open()
await session.refreshModels()
const groups = session.getSnapshot().modelSelection.groups
api.onModels = () => Promise.reject(new Error('directory transport down'))
await session.refreshModels()
expect(session.getSnapshot().modelSelection).toMatchObject({
groups,
status: 'error',
error: { code: 'internal', message: 'directory transport down' },
})
api.onModels = () => Promise.resolve(ok({
current: { provider: 'deepseek', model: 'deepseek-v4-flash' },
groups: [...groups],
failures: [],
}))
await session.retryModelOperation()
expect(session.getSnapshot().modelSelection.status).toBe('ready')
api.onSelectModel = () => Promise.reject(new Error('selection transport down'))
await session.selectModel({ provider: 'deepseek', model: 'deepseek-v4-pro' })
expect(session.getSnapshot().modelSelection).toMatchObject({
current: { provider: 'deepseek', model: 'deepseek-v4-flash' },
groups,
status: 'error',
error: { code: 'internal', message: 'selection transport down' },
})
})
it('reconciles a failed selection from authoritative history on reconnect', async () => {
const { api, session } = makeSession()
await session.open()
api.onSelectModel = () => Promise.reject(new Error('lost response'))
await session.selectModel({ provider: 'deepseek', model: 'deepseek-v4-pro' })
expect(session.getSnapshot().modelSelection.status).toBe('error')
api.onHistory = () => Promise.resolve(ok({
events: [],
hasMore: false,
modelTarget: { provider: 'deepseek', model: 'deepseek-v4-pro' },
}))
await session.resync()
expect(session.getSnapshot().modelSelection).toMatchObject({
current: { provider: 'deepseek', model: 'deepseek-v4-pro' },
status: 'idle',
error: null,
})
})
})
describe('live event path', () => {
async function opened(events: SessionEvent[] = plainTurn(0, 0, 'a', 'b')) {
const { api, session } = makeSession()
@@ -210,7 +377,11 @@ describe('paging', () => {
api.onHistory = () => gate.promise
const first = session.loadOlder()
const second = session.loadOlder()
gate.resolve(ok({ events: entries(plainTurn(0, 0, 'a', 'b')) as never[], hasMore: false }))
gate.resolve(ok({
events: entries(plainTurn(0, 0, 'a', 'b')) as never[],
hasMore: false,
modelTarget: { provider: 'deepseek', model: 'deepseek-v4-flash' },
}))
await Promise.all([first, second])
expect(api.callsOf('session.history')).toHaveLength(2) // open + one page, not two
})
@@ -482,7 +653,11 @@ describe('remaining branches', () => {
const opening = session.open()
api.onHistory = () => histResponse(plainTurn(6, 1, '新', '代'))
const resynced = session.resync()
stale.resolve(ok({ events: entries(plainTurn(0, 0, '旧', '代')) as never[], hasMore: false })) // success, but its generation is gone
stale.resolve(ok({
events: entries(plainTurn(0, 0, '旧', '代')) as never[],
hasMore: false,
modelTarget: { provider: 'deepseek', model: 'stale' },
})) // success, but its generation is gone
await Promise.all([opening, resynced])
expect(session.getSnapshot().nodes.map(n => n.seq)).toEqual([7, 9]) // only the fresh generation's window
})
@@ -501,7 +676,11 @@ describe('remaining branches', () => {
const opening = session.open() // triggers the second pull, which parks
await vi.waitFor(() => { expect(call).toBe(2) })
const resynced = session.resync()
secondPull.resolve(ok({ events: entries([...plainTurn(0, 0, 'a', 'b'), ...plainTurn(6, 1, 'c', 'd')]) as never[], hasMore: false }))
secondPull.resolve(ok({
events: entries([...plainTurn(0, 0, 'a', 'b'), ...plainTurn(6, 1, 'c', 'd')]) as never[],
hasMore: false,
modelTarget: { provider: 'deepseek', model: 'stale' },
}))
await Promise.all([opening, resynced])
expect(session.getSnapshot().openState).toBe('open')
})
@@ -515,7 +694,11 @@ describe('remaining branches', () => {
session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event: ev.user(9, '洞') }) // starts repairGap
api.onHistory = () => histResponse(plainTurn(6, 1, 'c', 'd'))
const resynced = session.resync() // bumps the generation
repairPull.resolve(ok({ events: entries(plainTurn(0, 0, '旧', '页')) as never[], hasMore: false })) // repair result: stale, dropped
repairPull.resolve(ok({
events: entries(plainTurn(0, 0, '旧', '页')) as never[],
hasMore: false,
modelTarget: { provider: 'deepseek', model: 'stale' },
})) // repair result: stale, dropped
await resynced
expect(session.getSnapshot().nodes.map(n => n.seq)).toEqual([7, 9])
})
@@ -559,6 +742,7 @@ describe('remaining branches', () => {
{ event: ev.toolResult(7, 1, 'h1', 'done'), view: { for: 'result', view: { card: 'generic', title: '历史果' } } },
] as never[],
hasMore: false,
modelTarget: { provider: 'deepseek', model: 'deepseek-v4-flash' },
}))
await session.open()
expect(session.getSnapshot().nodes.at(-1)).toMatchObject({
@@ -662,6 +846,7 @@ describe('reference stability (the memo contract)', () => {
expect(after).not.toBe(before)
expect(after.runningCalls).toBe(before.runningCalls)
expect(after.pending).toBe(before.pending)
expect(after.modelSelection).toBe(before.modelSelection)
// And a mutation on the tracked domain swaps that array.
feed(ev.toolResult(10, 1, 'c1', 'ECHO'))
const resolved = session.getSnapshot()

View File

@@ -10,6 +10,8 @@ Tool rows are slots too — the standalone tool ring (`ToolViewRegistry`/`ctx.to
Per-session UI state (selection, composer draft, active view) lives in the declared chat store (`stores.ts` `createChatStore`): apply constructs one handle and passes it to the conversation, chat-view, and details registrations, so the session slots share one instance per session (selection written by the chat view, read by details) and the framework owns instance lifecycle and draft persistence. Components are pure — the framework standard kit (`useSession`/`sessionId`/`useSessions`) and the store faces (`useStore`/`actions`) arrive automatically from the registration declaration; the inject factories contribute plain data and callbacks only (send/stop choreography, tab read face, details/paging callbacks, startSession chain).
The resident composer declares the session-scoped single slot `'conversation.composer.control'` and renders its occupant immediately before the send/stop button. Feature packages own the control and its state; ui-conversation supplies only the placement and standard slot shares. The new-session empty-state composer deliberately has no corresponding control slot.
`src/client/` is organized for the future package split: `contract/` is the sole inter-domain shared face (`slots.ts` slot declarations + composed slot props including the tool-row contract, `views.ts` shared primitives, `tool-call-model.ts`); the `skeleton/`, `chat/`, and `toolviews/` (sample registrants) domain directories import contract files and never each other; `apply.ts` is the only assembly point allowed to import all three domains. The `/client` export surface is the contract only — `apply`/`inject`, the two service classes, and the `contract/` type families; implementation components (skeleton, chat rows) and the store factory stay internal and reach the page exclusively through apply's slot registrations (tests take them via the `./src/*` subpath).
## Model Experience

View File

@@ -74,6 +74,7 @@ export function apply(ctx: Context): void {
children: {
'conversation.view': { kind: 'list', scope: 'session' },
'conversation.composer': { kind: 'chain', scope: 'session' },
'conversation.composer.control': { kind: 'single', scope: 'session' },
},
store: chatStore,
inject: (sessionId: SessionId, actions: BoundActions<typeof chatStore>): ConversationInjected => {

View File

@@ -41,6 +41,16 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
* zero owner changes.
*/
'conversation.composer': { kind: 'chain'; scope: 'session'; owner: ComposerChainProps }
/**
* Optional controls rendered in the resident InputBar immediately before
* its primary send/stop button. The conversation entry declares and owns
* the render site; feature plugins contribute the single occupant.
*/
'conversation.composer.control': {
kind: 'single'
scope: 'session'
owner: ComposerControlOwnerProps
}
}
}
@@ -127,9 +137,13 @@ export interface ComposerChainProps {
interactions: readonly PendingInteraction[]
}
/** Full conversation-slot component props: runtime & child-render (view ring + composer chain) & store & injected shares. */
/** Composer-control owner share; session state and actions arrive through the standard and injected shares. */
export interface ComposerControlOwnerProps {}
/** Full conversation-slot component props: runtime & child-render shares & store & injected shares. */
export type ConversationSlotProps =
PropsRuntime<'conversation'> & PropsRenderSlots<'conversation.view' | 'conversation.composer'>
PropsRuntime<'conversation'>
& PropsRenderSlots<'conversation.view' | 'conversation.composer' | 'conversation.composer.control'>
& PropsStore<ChatStore> & ConversationInjected
/**

View File

@@ -18,8 +18,8 @@ export type {
} from './contract/views.ts'
export type { ToolCallBlock } from './contract/tool-call-model.ts'
export type {
ChatStore, ChatViewInjected, ChatViewSlotProps, ComposerChainProps, ConversationInjected,
ConversationSlotProps, ConvViewOwnerProps, ConvViewProps, DetailsInjected, DetailsSlotProps,
ChatStore, ChatViewInjected, ChatViewSlotProps, ComposerChainProps, ComposerControlOwnerProps,
ConversationInjected, ConversationSlotProps, ConvViewOwnerProps, ConvViewProps, DetailsInjected, DetailsSlotProps,
EmptyStateInjected, EmptyStateSlotProps, ToolRowOwnerProps, ToolRowProps,
} from './contract/slots.ts'
// Export discipline: packages/client/AGENTS.md.

View File

@@ -68,6 +68,7 @@ export function ConversationRoot({
disabled={removed}
error={error}
variant="composer"
control={renderSlot('conversation.composer.control', {})}
onDraftChange={actions.setDraft}
onSend={(mode) => { send(draft, mode) }}
onStop={stop}

View File

@@ -124,9 +124,14 @@
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
padding: 0 10px 10px 12px;
}
.control {
min-width: 0;
}
/* Primary send (figma IconButton 34:10465): 34px circle, #3964FE light /
#679EFE dark — the info-fill pair (500→400), NOT button-primary (ink);
white glyph; empty text = 0.4 opacity. */

View File

@@ -26,13 +26,16 @@ export interface InputBarProps {
placeholder?: string
/** Optional leading accessory row content (the empty state mounts its cwd picker here). */
accessory?: ReactNode
/** Optional resident composer control rendered immediately before the primary button. */
control?: ReactNode
onDraftChange: (text: string) => void
onSend: (mode: 'queue' | 'steer') => void
onStop: () => void
}
export function InputBar({
draft, running, disabled, error, variant, placeholder, accessory, onDraftChange, onSend, onStop,
draft, running, disabled, error, variant, placeholder, accessory, control,
onDraftChange, onSend, onStop,
}: InputBarProps) {
const empty = draft.trim() === ''
const inputRef = useRef<HTMLTextAreaElement | null>(null)
@@ -116,6 +119,7 @@ export function InputBar({
<div aria-hidden className={css.mirror}>{`${draft}\n`}</div>
</div>
<div className={css.row}>
{control !== undefined && <div className={css.control}>{control}</div>}
<button
type="button"
className={clsx(css.primary, running && css.stopping)}

View File

@@ -29,6 +29,7 @@ function snapshotBase(): ConversationSnapshot {
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [],
pending: [], running: false, removed: false, openState: 'open', openError: null,
hasMore: false, loadingOlder: false, promptError: null, lastAgentError: null,
modelSelection: { current: null, groups: [], failures: [], status: 'idle', error: null },
}
}

View File

@@ -41,7 +41,8 @@ function snapshotWith(nodes: ToolResultNode[]): ConversationSnapshot {
sessionId: SID, nodes, foldDegraded: false, partial: null, runningCalls: [],
pending: [], running: false, removed: false, openState: 'open', openError: null,
hasMore: false, loadingOlder: false, promptError: null, lastAgentError: null,
} as ConversationSnapshot
modelSelection: { current: null, groups: [], failures: [], status: 'idle', error: null },
}
}
/** Test-owned AppFrame role: declares the layout-owned children and renders the conversation area under the framework session provider. */

View File

@@ -31,6 +31,7 @@ function snapshotBase(): ConversationSnapshot {
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [],
pending: [], running: false, removed: false, openState: 'open', openError: null,
hasMore: false, loadingOlder: false, promptError: null, lastAgentError: null,
modelSelection: { current: null, groups: [], failures: [], status: 'idle', error: null },
}
}

View File

@@ -26,7 +26,8 @@ function snapshotBase(): ConversationSnapshot {
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [],
pending: [], running: false, removed: false, openState: 'open', openError: null,
hasMore: false, loadingOlder: false, promptError: null, lastAgentError: null,
} as ConversationSnapshot
modelSelection: { current: null, groups: [], failures: [], status: 'idle', error: null },
}
}
describe('render branch tails', () => {

View File

@@ -128,4 +128,11 @@ describe('error strip and variants', () => {
expect(view.getByTestId('acc')).toBeTruthy()
expect(view.container.querySelector('[class*="hero"]')).not.toBeNull()
})
it('renders the optional composer control immediately before the primary button', () => {
const { view } = setup({ control: <button type="button">model</button> })
const buttons = view.getAllByRole('button')
expect(buttons.map(button => button.textContent)).toEqual(['model', ''])
expect(buttons[1]?.getAttribute('aria-label')).toBe('发送')
})
})

View File

@@ -30,7 +30,8 @@ function snapshotBase(): ConversationSnapshot {
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [],
pending: [], running: false, removed: false, openState: 'open', openError: null,
hasMore: false, loadingOlder: false, promptError: null, lastAgentError: null,
} as ConversationSnapshot
modelSelection: { current: null, groups: [], failures: [], status: 'idle', error: null },
}
}
function sessionSource(over?: Partial<ConversationSnapshot>) {
@@ -58,7 +59,9 @@ function listHook(rows: { id: string; title: string; cwd?: string; parentId?: st
describe('ConversationRoot branches', () => {
const chatTab: ViewTab = { id: 'chat', label: 'Chat' }
/** renderSlot stub in the outlet's baked shape (ring key + only filter marker). */
const stubRenderSlot = (() => <div data-testid="view-body" />) as unknown as ConversationRootProps['renderSlot']
const stubRenderSlot = ((key: string) =>
key === 'conversation.view' ? <div data-testid="view-body" /> : null
) as unknown as ConversationRootProps['renderSlot']
/** SessionProvider seat stub (render-prop pass-through; ConversationRoot never invokes it). */
const SessionProviderStub: ConversationRootProps['SessionProvider'] = ({ children }) => <>{children(SID)}</>

View File

@@ -177,8 +177,11 @@ describe('ConversationRoot', () => {
})
it('hides the tab strip with a single view; composer writes the store draft and sends it', () => {
const { chat, send } = bench([tab('chat', 'Chat')])
const { chat, send, renderSlot } = bench([tab('chat', 'Chat')])
expect(screen.queryByRole('tablist')).toBeNull()
expect(renderSlot).toHaveBeenCalledWith('conversation.composer.control', {})
expect(screen.getByTestId('view-(all)').getAttribute('data-slot'))
.toBe('conversation.composer.control')
const box = screen.getByPlaceholderText(/输入消息/)
fireEvent.change(box, { target: { value: 'hi' } })
// Typing goes through actions.setDraft into the shared store.

View File

@@ -0,0 +1,20 @@
# @deepseek-ai/dsh-client-ui-model-selector
Session-scoped Web model selector. Its browser half occupies `conversation.composer.control`, shows the current catalog name beside the send button, and opens an upward provider-grouped menu. Provider names appear once as group headings; model rows and the trigger show catalog names without repeating the provider route, with the model id as the fallback for an unlisted current target.
The selector primes the advisory directory when it mounts so the trigger can resolve the catalog name, then refreshes it whenever the menu opens. The Session object layer owns loading, selection, partial-provider-failure, and stale-response state. A selection updates only that live session and takes effect at the next prompt-assembly boundary, including while the current step is running. The latest consumed target remains durable through the existing `request/header`; an unused choice is process-local.
Catalog membership is not request validation. The current target is included as an unlisted row when its registered provider omits it, while a target whose provider is unavailable remains visible on the trigger with a warning in the menu.
## Model Experience
None, as the browser selector changes subsequent request routing but adds no model-visible content.
#### KV Cache effect
Switching routes may invalidate provider-side cache reuse according to the selected adapter. The selector itself adds no prompt content.
## Known Limitations and Deferred Work
- **The new-session composer has no selector** — a session starts with the host default and exposes the selector after creation.
- **Unused selections are not durable** — reload restores the last route consumed by a request, not a choice made without sending.

View File

@@ -0,0 +1,62 @@
{
"name": "@deepseek-ai/dsh-client-ui-model-selector",
"description": "Session-scoped provider/model selector for the Web conversation composer",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./invariant": {
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./client": {
"types": "./lib/types/client/index.d.ts",
"default": "./lib/client.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"dshClient": {
"inject": [
"@deepseek-ai/dsh-client-ui-conversation"
],
"platform": "web"
},
"scripts": {
"bundle": "tsdown",
"watch": "tsdown --watch"
},
"license": "BSD-3-Clause",
"dependencies": {
"@deepseek-ai/dsh-client-connection": "workspace:^",
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"clsx": "^2.0.0",
"react": "^18.2.0"
},
"peerDependencies": {
"@deepseek-ai/dsh-invariants": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"devDependencies": {
"@deepseek-ai/dsh-invariants": "workspace:^",
"@types/react": "~18.3.1",
"cordis": "^4.0.0-rc.7"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/client.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
]
}

View File

@@ -0,0 +1,197 @@
.root {
position: relative;
min-width: 0;
}
.trigger {
display: flex;
align-items: center;
gap: 4px;
min-width: 0;
max-width: 220px;
height: 34px;
padding: 0 6px;
border: none;
border-radius: 8px;
outline: none;
background: transparent;
color: var(--dsw-alias-label-secondary);
font-size: 14px;
line-height: 22px;
font-weight: 500;
cursor: pointer;
}
.trigger:hover:not(:disabled) {
background: var(--dsw-alias-interactive-bg-hover);
color: var(--dsw-alias-label-primary);
}
.trigger:focus-visible {
box-shadow: 0 0 0 2px var(--dsw-alias-border-l3);
}
.trigger:disabled {
color: var(--dsw-alias-label-dimmed);
cursor: default;
}
.triggerLabel {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.chevron {
flex: 0 0 auto;
color: var(--dsw-alias-label-caption);
transition: transform 120ms ease;
}
.chevronOpen {
transform: rotate(180deg);
}
.menu {
position: absolute;
right: 0;
bottom: calc(100% + 8px);
z-index: 20;
display: flex;
flex-direction: column;
width: min(320px, calc(100vw - 32px));
max-height: min(360px, calc(100vh - 96px));
overflow: hidden;
padding: 6px;
border: 1px solid var(--dsw-alias-border-l2-darkmode-thin);
border-radius: 14px;
background: var(--dsw-specific-input-major);
box-shadow: var(--dsw-shadow-lv3);
color: var(--dsw-alias-label-primary);
}
.status,
.empty {
padding: 10px;
color: var(--dsw-alias-label-tertiary);
font-size: 13px;
line-height: 20px;
}
.error,
.warning {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 8px;
margin-bottom: 4px;
padding: 7px 8px;
border-radius: 8px;
background: var(--dsw-alias-interactive-bg-hover-danger);
color: var(--dsw-alias-state-error-primary);
font-size: 12px;
line-height: 18px;
}
.warning {
background: var(--dsw-alias-bg-module-platform);
color: var(--dsw-alias-state-warn-label);
}
.retry {
flex: 0 0 auto;
padding: 0;
border: none;
background: transparent;
color: inherit;
font: inherit;
font-weight: 600;
cursor: pointer;
}
.groups {
min-height: 0;
overflow-y: auto;
}
.group + .group {
margin-top: 4px;
}
.groupTitle {
position: sticky;
top: 0;
z-index: 1;
padding: 5px 8px 3px;
background: var(--dsw-specific-input-major);
color: var(--dsw-alias-label-tertiary);
font-size: 12px;
line-height: 18px;
font-weight: 500;
}
.option {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
min-height: 38px;
padding: 6px 8px;
border: none;
border-radius: 10px;
outline: none;
background: transparent;
color: inherit;
text-align: left;
cursor: pointer;
}
.option:hover:not(:disabled),
.option:focus-visible,
.selected {
background: var(--dsw-alias-interactive-bg-hover);
}
.option:disabled {
color: var(--dsw-alias-label-dimmed);
cursor: default;
}
.optionCopy {
display: flex;
flex: 1;
flex-direction: column;
min-width: 0;
}
.modelName {
overflow: hidden;
color: inherit;
font-size: 14px;
line-height: 20px;
font-weight: 500;
text-overflow: ellipsis;
white-space: nowrap;
}
.description,
.unlisted {
overflow: hidden;
color: var(--dsw-alias-label-tertiary);
font-size: 12px;
line-height: 18px;
text-overflow: ellipsis;
white-space: nowrap;
}
.unlisted {
color: var(--dsw-alias-state-warn-label);
}
.check {
display: grid;
place-items: center;
flex: 0 0 18px;
color: var(--dsw-alias-state-business-primary);
}

View File

@@ -0,0 +1,261 @@
import {
useEffect, useId, useMemo, useRef, useState,
type FocusEvent, type KeyboardEvent,
} from 'react'
import clsx from 'clsx'
import type { ModelTarget } from '@deepseek-ai/dsh-client-connection/client'
import {
IconCheckOutline16, IconChevronDownOutline14,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { ModelSelectorProps } from './contract.ts'
import css from './ModelSelector.module.css'
type FocusPreference = 'current' | 'first' | 'last'
/** Session-scoped provider-grouped model selector for the composer action row. */
export function ModelSelector({
useSession, refreshModels, retryModelOperation, selectModel,
}: ModelSelectorProps) {
const selection = useSession(snapshot => snapshot.modelSelection)
const removed = useSession(snapshot => snapshot.removed)
const [open, setOpen] = useState(false)
const rootRef = useRef<HTMLDivElement | null>(null)
const triggerRef = useRef<HTMLButtonElement | null>(null)
const itemRefs = useRef<(HTMLButtonElement | null)[]>([])
const pendingFocus = useRef<FocusPreference | null>(null)
const id = useId()
const { choices, choiceIndices } = useMemo(() => {
const nextChoices = selection.groups.flatMap(group =>
group.models.map(model => ({
group,
model,
target: { provider: group.id, model: model.id } satisfies ModelTarget,
})))
return {
choices: nextChoices,
choiceIndices: new Map(nextChoices.map((choice, index) => [
JSON.stringify([choice.target.provider, choice.target.model]),
index,
])),
}
}, [selection.groups])
const selectedIndex = selection.current === null
? -1
: choiceIndices.get(JSON.stringify([
selection.current.provider,
selection.current.model,
])) ?? -1
const busy = selection.status === 'selecting'
useEffect(() => {
refreshModels()
}, [refreshModels])
useEffect(() => {
if (!open) return
const closeOutside = (event: MouseEvent): void => {
if (!rootRef.current?.contains(event.target as Node)) setOpen(false)
}
document.addEventListener('mousedown', closeOutside)
return () => { document.removeEventListener('mousedown', closeOutside) }
}, [open])
useEffect(() => {
const preference = pendingFocus.current
if (!open || preference === null || choices.length === 0) return
const index = preference === 'first'
? 0
: preference === 'last'
? choices.length - 1
: selectedIndex >= 0 ? selectedIndex : 0
itemRefs.current[index]?.focus()
pendingFocus.current = null
}, [choices.length, open, selectedIndex])
const show = (preference: FocusPreference | null = null): void => {
pendingFocus.current = preference
setOpen(true)
refreshModels()
}
const close = (restoreFocus = false): void => {
setOpen(false)
pendingFocus.current = null
if (restoreFocus) queueMicrotask(() => { triggerRef.current?.focus() })
}
const moveFocus = (offset: number): void => {
if (choices.length === 0) return
const active = itemRefs.current.findIndex(item => item === document.activeElement)
const origin = active >= 0 ? active : selectedIndex >= 0 ? selectedIndex : 0
const next = (origin + offset + choices.length) % choices.length
itemRefs.current[next]?.focus()
}
const onRootKeyDown = (event: KeyboardEvent<HTMLDivElement>): void => {
if (event.key === 'Escape' && open) {
event.preventDefault()
close(true)
return
}
if (!open) return
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
event.preventDefault()
moveFocus(event.key === 'ArrowDown' ? 1 : -1)
return
}
if (event.key === 'Home' || event.key === 'End') {
event.preventDefault()
itemRefs.current[event.key === 'Home' ? 0 : choices.length - 1]?.focus()
}
}
const onTriggerKeyDown = (event: KeyboardEvent<HTMLButtonElement>): void => {
if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp') return
event.preventDefault()
if (!open) {
show(event.key === 'ArrowDown' ? 'first' : 'last')
return
}
pendingFocus.current = 'current'
const index = selectedIndex >= 0 ? selectedIndex : 0
itemRefs.current[index]?.focus()
}
const onBlur = (event: FocusEvent<HTMLDivElement>): void => {
if (event.relatedTarget instanceof Node && rootRef.current?.contains(event.relatedTarget)) return
close()
}
const choose = (target: ModelTarget): void => {
if (
selection.current?.provider === target.provider
&& selection.current.model === target.model
) {
close(true)
return
}
void selectModel(target).then((accepted) => {
if (accepted && rootRef.current !== null) close(true)
})
}
const retry = (): void => {
void retryModelOperation().then((selected) => {
if (selected && rootRef.current !== null) close(true)
})
}
const currentProviderKnown = selection.current === null
|| selection.groups.some(group => group.id === selection.current?.provider)
|| selection.failures.some(failure => failure.id === selection.current?.provider)
const label = choices[selectedIndex]?.model.name ?? selection.current?.model ?? '选择模型'
return (
<div
ref={rootRef}
className={css.root}
onKeyDown={onRootKeyDown}
onBlur={onBlur}
>
<button
ref={triggerRef}
type="button"
className={css.trigger}
aria-label={`选择模型,当前 ${label}`}
aria-haspopup="menu"
aria-expanded={open}
aria-controls={open ? `${id}-menu` : undefined}
title={label}
disabled={removed}
onClick={() => { open ? close() : show() }}
onKeyDown={onTriggerKeyDown}
>
<span className={css.triggerLabel}>{label}</span>
<IconChevronDownOutline14 className={clsx(css.chevron, open && css.chevronOpen)} />
</button>
{open && (
<div
id={`${id}-menu`}
className={css.menu}
role="menu"
aria-label="模型"
aria-busy={selection.status === 'loading' || busy}
>
{selection.status === 'loading' && (
<div className={css.status}></div>
)}
{selection.error !== null && (
<div className={css.error}>
<span>{selection.error.message}</span>
<button type="button" className={css.retry} onClick={retry}></button>
</div>
)}
{selection.failures.map(failure => (
<div className={css.warning} key={failure.id}>
<span>{failure.name} {failure.message}</span>
<button type="button" className={css.retry} onClick={refreshModels}></button>
</div>
))}
{selection.status !== 'loading' && !currentProviderKnown && selection.current !== null && (
<div className={css.warning}>
{selection.current.provider}
</div>
)}
<div className={clsx(css.groups, 'scrollable')}>
{selection.groups.map((group) => {
const headingId = `${id}-${group.id}`
return (
<section
role="group"
aria-labelledby={headingId}
className={css.group}
key={group.id}
>
<div className={css.groupTitle} id={headingId}>{group.name}</div>
{group.models.map((model) => {
const index = choiceIndices.get(JSON.stringify([group.id, model.id])) ?? -1
const selected = selection.current?.provider === group.id
&& selection.current.model === model.id
return (
<button
ref={(node) => { itemRefs.current[index] = node }}
type="button"
role="menuitemradio"
aria-checked={selected}
className={clsx(css.option, selected && css.selected)}
key={model.id}
title={model.name}
disabled={busy}
onClick={() => { choose({ provider: group.id, model: model.id }) }}
>
<span className={css.optionCopy}>
<span className={css.modelName}>{model.name}</span>
{model.description !== undefined && (
<span className={css.description}>{model.description}</span>
)}
{model.unlisted === true && (
<span className={css.unlisted}> · </span>
)}
</span>
<span className={css.check}>
{selected ? <IconCheckOutline16 /> : null}
</span>
</button>
)
})}
</section>
)
})}
</div>
{selection.status === 'ready' && choices.length === 0 && (
<div className={css.empty}></div>
)}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,28 @@
/**
* Model-selector slot contract: standard session props plus the plain
* object-layer actions injected by this package's registration.
*/
import type { ModelTarget } from '@deepseek-ai/dsh-client-connection/client'
import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
/** Plain callbacks contributed by the selector registration. */
export interface ModelSelectorInjected {
/** Refresh the session's advisory model directory. */
refreshModels(): void
/**
* Retry the directory refresh or exact selection that produced the visible operation error.
* @returns Whether a model selection succeeded and the menu should close.
*/
retryModelOperation(): Promise<boolean>
/**
* Select a complete provider/model target.
* @param target - Target selected from one provider group.
* @returns Whether the host accepted the selection.
*/
selectModel(target: ModelTarget): Promise<boolean>
}
/** Full props of the conversation composer-control occupant. */
export type ModelSelectorProps =
PropsRuntime<'conversation.composer.control'> & ModelSelectorInjected

View File

@@ -0,0 +1,33 @@
/**
* Browser model-selector plugin: registers one session-scoped occupant in the
* conversation composer-control slot. The Session object owns all catalog and
* selection state; the component receives only the standard snapshot hook and
* injected callbacks.
*/
import type { ClientContext, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import type { ModelSelectorInjected } from './contract.ts'
import { ModelSelector } from './ModelSelector.tsx'
export type { ModelSelectorInjected, ModelSelectorProps } from './contract.ts'
/** Required services; conversation is the slot-declaration ordering edge. */
export const inject = ['slots', 'sessions', 'conversation']
/**
* Register the model selector in the resident conversation composer.
* @param ctx - Client root context.
*/
export function apply(ctx: ClientContext): void {
const sessions = ctx.sessions
ctx.slots.register({
name: 'conversation.composer.control',
inject: (sessionId: SessionId): ModelSelectorInjected => {
const session = sessions.manager.get(sessionId)
return {
refreshModels: () => { void session.refreshModels() },
retryModelOperation: () => session.retryModelOperation(),
selectModel: async target => (await session.selectModel(target)).ok,
}
},
}, ModelSelector)
}

View File

@@ -0,0 +1,4 @@
declare module '*.module.css' {
const classes: Record<string, string>
export default classes
}

View File

@@ -0,0 +1,14 @@
/**
* Web model-selector plugin, node half. Model routing and catalog RPCs belong
* to the host runtime, so this package contributes no host registration.
*/
import type { Context } from 'cordis'
/** No host services are required. */
export const inject: string[] = []
/**
* Empty host half for the browser-only selector feature.
* @param _ctx - Host plugin context.
*/
export function apply(_ctx: Context): void {}

View File

@@ -0,0 +1,30 @@
/**
* Package-owned invariant companion for `@deepseek-ai/dsh-client-ui-model-selector`.
* @module @deepseek-ai/dsh-client-ui-model-selector/invariant
*/
/* jscpd:ignore-start */
import type { Context } from 'cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-model-selector'
/** Cordis companion plugin name. */
export const name = 'client-ui-model-selector-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/**
* No runtime invariant: the slot registry owns selector registration
* lifecycle, and the wire/object-layer tests own model-target consistency.
*/
const install: InvariantInstaller = () => {}
/**
* Register this package's invariant companion.
* @param ctx - Cordis context carrying the invariant service.
* @returns The installed registration's disposer after setup succeeds.
*/
export const apply = (ctx: Context): Promise<() => void> =>
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
/* jscpd:ignore-end */

View File

@@ -0,0 +1,83 @@
/**
* Browser-plugin assembly: the selector occupies the conversation-declared
* composer-control slot, injects only Session object actions, fails loud when
* the slot is undeclared, and unregisters with its plugin fiber.
*/
import { Context } from 'cordis'
import { describe, expect, it, vi } from 'vitest'
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import { ModelSelector } from '../src/client/ModelSelector.tsx'
import { apply, inject } from '../src/client/index.ts'
const SID = 'selector-session' as SessionId
async function bench() {
const ctx = new Context()
await ctx.plugin(SlotsService).await()
const slots = ctx.get('slots') as SlotsService
slots.register({
name: 'root',
children: {
'conversation.composer.control': { kind: 'single', scope: 'session' },
},
} as never, (_props: { renderSlot?: unknown }) => null)
const session = {
refreshModels: vi.fn(() => Promise.resolve({ ok: true })),
retryModelOperation: vi.fn(() => Promise.resolve(true)),
selectModel: vi.fn((target: { provider: string; model: string }) => Promise.resolve({
ok: target.model !== 'rejected',
value: { selected: target },
})),
}
ctx.provide('sessions', { manager: { get: () => session } })
ctx.provide('conversation', {})
return { ctx, slots, session }
}
describe('model-selector browser plugin', () => {
it('declares its ordering and service dependencies', () => {
expect(inject).toEqual(['slots', 'sessions', 'conversation'])
})
it('fails loud when the conversation control slot is not declared', async () => {
const ctx = new Context()
await ctx.plugin(SlotsService).await()
ctx.provide('sessions', { manager: { get: vi.fn() } })
ctx.provide('conversation', {})
await expect(ctx.plugin({ inject: [...inject], apply }))
.rejects.toThrow(/slot "conversation\.composer\.control" is not declared/)
})
it('registers the singleton and injects Session-owned refresh/select actions', async () => {
const { ctx, slots, session } = await bench()
await ctx.plugin({ inject: [...inject], apply }).await()
const entries = slots.entries('conversation.composer.control')
expect(entries).toHaveLength(1)
expect(entries[0]?.component).toBe(ModelSelector)
const injected = (entries[0]?.inject as (sessionId: SessionId) => {
refreshModels(): void
retryModelOperation(): Promise<boolean>
selectModel(target: { provider: string; model: string }): Promise<boolean>
})(SID)
injected.refreshModels()
expect(session.refreshModels).toHaveBeenCalledTimes(1)
await expect(injected.retryModelOperation()).resolves.toBe(true)
expect(session.retryModelOperation).toHaveBeenCalledTimes(1)
await expect(injected.selectModel({ provider: 'deepseek', model: 'deepseek-chat' }))
.resolves.toBe(true)
await expect(injected.selectModel({ provider: 'deepseek', model: 'rejected' }))
.resolves.toBe(false)
})
it('unregisters the occupant when its plugin fiber is disposed', async () => {
const { ctx, slots } = await bench()
const fiber = ctx.plugin({ inject: [...inject], apply })
await fiber.await()
expect(slots.entries('conversation.composer.control')).toHaveLength(1)
await fiber.dispose()
expect(slots.entries('conversation.composer.control')).toHaveLength(0)
})
})

View File

@@ -0,0 +1,193 @@
// @vitest-environment jsdom
/**
* Provider-grouped selector behavior: compact model-only trigger, grouped
* radio menu, retry/error states, successful and failed selection, outside
* dismissal, and keyboard focus navigation.
*/
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type {
ConversationSnapshot, ModelSelectionSnapshot,
} from '@deepseek-ai/dsh-client-runtime/client'
import type { ModelTarget } from '@deepseek-ai/dsh-client-connection/client'
import type { ModelSelectorProps } from '../src/client/contract.ts'
import { ModelSelector } from '../src/client/ModelSelector.tsx'
afterEach(cleanup)
const ready: ModelSelectionSnapshot = {
current: { provider: 'deepseek', model: 'deepseek-v4-flash' },
groups: [
{
id: 'deepseek',
name: 'DeepSeek',
models: [
{ id: 'deepseek-v4-flash', name: 'DeepSeek-V4-Flash', description: '快速响应' },
{ id: 'deepseek-v4-pro', name: 'DeepSeek-V4-Pro', description: '复杂任务' },
],
},
{
id: 'openai',
name: 'OpenAI',
models: [{ id: 'gpt-5', name: 'GPT-5' }],
},
],
failures: [],
status: 'ready',
error: null,
}
function setup(selection: ModelSelectionSnapshot = ready, removed = false) {
let current = { modelSelection: selection, removed } as unknown as ConversationSnapshot
const useSession = ((selector: (snapshot: ConversationSnapshot) => unknown) =>
selector(current)) as ModelSelectorProps['useSession']
const refreshModels = vi.fn()
const retryModelOperation = vi.fn(() => Promise.resolve(false))
const selectModel = vi.fn((_target: ModelTarget) => Promise.resolve(true))
const props: ModelSelectorProps = {
sessionId: 'selector-session' as never,
useSession,
useSessions: ((selector: (snapshot: never) => unknown) =>
selector({} as never)) as ModelSelectorProps['useSessions'],
refreshModels,
retryModelOperation,
selectModel,
}
const view = render(<ModelSelector {...props} />)
return {
view,
refreshModels,
retryModelOperation,
selectModel,
update(next: ModelSelectionSnapshot, nextRemoved = removed) {
current = { modelSelection: next, removed: nextRemoved } as unknown as ConversationSnapshot
view.rerender(<ModelSelector {...props} />)
},
}
}
function trigger(): HTMLButtonElement {
return screen.getByRole('button', { name: /选择模型,当前/ })
}
describe('model selector', () => {
it('shows the catalog name, opens upward into provider groups, and marks the current radio item', () => {
const { refreshModels } = setup()
expect(refreshModels).toHaveBeenCalledTimes(1)
expect(trigger().textContent).toBe('DeepSeek-V4-Flash')
expect(trigger().textContent).not.toContain('deepseek/')
expect(trigger().title).toBe('DeepSeek-V4-Flash')
fireEvent.click(trigger())
expect(refreshModels).toHaveBeenCalledTimes(2)
expect(screen.getByRole('menu', { name: '模型' })).toBeTruthy()
expect(screen.getAllByRole('group')).toHaveLength(2)
expect(screen.getByText('DeepSeek')).toBeTruthy()
expect(screen.getByText('OpenAI')).toBeTruthy()
const rows = screen.getAllByRole('menuitemradio')
expect(rows.map(row => row.querySelector('[class*="modelName"]')?.textContent))
.toEqual(['DeepSeek-V4-Flash', 'DeepSeek-V4-Pro', 'GPT-5'])
expect(rows[0]?.getAttribute('aria-checked')).toBe('true')
expect(rows[1]?.getAttribute('aria-checked')).toBe('false')
expect(rows.some(row => row.textContent?.includes('deepseek/deepseek'))).toBe(false)
})
it('keeps the menu open on failure, closes after success, and closes current selection without an RPC', async () => {
const { selectModel } = setup()
selectModel.mockResolvedValueOnce(false)
fireEvent.click(trigger())
fireEvent.click(screen.getByRole('menuitemradio', { name: /DeepSeek-V4-Pro/ }))
await waitFor(() => { expect(selectModel).toHaveBeenCalledWith({ provider: 'deepseek', model: 'deepseek-v4-pro' }) })
expect(screen.getByRole('menu')).toBeTruthy()
selectModel.mockResolvedValueOnce(true)
fireEvent.click(screen.getByRole('menuitemradio', { name: /GPT-5/ }))
await waitFor(() => { expect(screen.queryByRole('menu')).toBeNull() })
fireEvent.click(trigger())
fireEvent.click(screen.getByRole('menuitemradio', { name: /DeepSeek-V4-Flash/ }))
await waitFor(() => { expect(screen.queryByRole('menu')).toBeNull() })
expect(selectModel).toHaveBeenCalledTimes(2)
})
it('renders loading, empty, partial-provider, operation-error, and unavailable-current states with retries', () => {
const error = { code: 'internal' as const, message: 'wire down', details: {} }
const { refreshModels, retryModelOperation, update } = setup({
...ready,
current: { provider: 'missing', model: 'private-preview-with-a-very-long-name' },
failures: [{ id: 'offline', name: 'Offline', message: 'catalog down' }],
status: 'error',
error,
})
expect(trigger().textContent).toBe('private-preview-with-a-very-long-name')
fireEvent.click(trigger())
expect(screen.getByText(/模型操作失败wire down/)).toBeTruthy()
expect(screen.getByText(/Offline 加载失败catalog down/)).toBeTruthy()
expect(screen.getByText(/当前提供方 missing 未注册/)).toBeTruthy()
fireEvent.click(screen.getAllByRole('button', { name: '重试' })[0]!)
expect(retryModelOperation).toHaveBeenCalledTimes(1)
fireEvent.click(screen.getAllByRole('button', { name: '重试' })[1]!)
expect(refreshModels).toHaveBeenCalledTimes(3)
update({ current: null, groups: [], failures: [], status: 'loading', error: null })
expect(screen.getByText('正在刷新模型列表…')).toBeTruthy()
update({ current: null, groups: [], failures: [], status: 'ready', error: null })
expect(screen.getByText('没有可用的模型。')).toBeTruthy()
})
it('closes after retrying a failed selection successfully', async () => {
const { retryModelOperation } = setup({
...ready,
status: 'error',
error: {
code: 'model-unavailable',
message: 'temporary failure',
details: { provider: 'deepseek', model: 'deepseek-v4-pro' },
},
})
retryModelOperation.mockResolvedValueOnce(true)
fireEvent.click(trigger())
fireEvent.click(screen.getByRole('button', { name: '重试' }))
await waitFor(() => { expect(screen.queryByRole('menu')).toBeNull() })
})
it('supports Arrow/Home/End/Escape navigation and restores focus to the trigger', async () => {
setup()
fireEvent.keyDown(trigger(), { key: 'ArrowDown' })
await waitFor(() => {
expect(document.activeElement).toBe(screen.getAllByRole('menuitemradio')[0])
})
fireEvent.keyDown(document.activeElement as HTMLElement, { key: 'ArrowDown' })
expect(document.activeElement).toBe(screen.getAllByRole('menuitemradio')[1])
fireEvent.keyDown(document.activeElement as HTMLElement, { key: 'End' })
expect(document.activeElement).toBe(screen.getAllByRole('menuitemradio')[2])
fireEvent.keyDown(document.activeElement as HTMLElement, { key: 'Home' })
expect(document.activeElement).toBe(screen.getAllByRole('menuitemradio')[0])
fireEvent.keyDown(document.activeElement as HTMLElement, { key: 'ArrowUp' })
expect(document.activeElement).toBe(screen.getAllByRole('menuitemradio')[2])
fireEvent.keyDown(document.activeElement as HTMLElement, { key: 'Escape' })
await waitFor(() => {
expect(screen.queryByRole('menu')).toBeNull()
expect(document.activeElement).toBe(trigger())
})
})
it('opens ArrowUp on the last option, disables rows while selecting, and dismisses outside', async () => {
const { update } = setup()
fireEvent.keyDown(trigger(), { key: 'ArrowUp' })
await waitFor(() => {
expect(document.activeElement).toBe(screen.getAllByRole('menuitemradio')[2])
})
update({ ...ready, status: 'selecting' })
expect(screen.getByRole('menu').getAttribute('aria-busy')).toBe('true')
expect(screen.getAllByRole('menuitemradio').every(row => (row as HTMLButtonElement).disabled)).toBe(true)
fireEvent.mouseDown(document.body)
expect(screen.queryByRole('menu')).toBeNull()
})
it('disables the trigger only when the session is removed', () => {
setup(ready, true)
expect(trigger().disabled).toBe(true)
})
})

View File

@@ -0,0 +1,28 @@
/** Host-half placeholder and package invariant companion. */
import { describe, expect, it, vi } from 'vitest'
import { apply as nodeApply } from '../src/index.ts'
import * as invariant from '../src/invariant.ts'
describe('model-selector node half and invariant companion', () => {
it('keeps the host half as an intentional no-op', () => {
nodeApply(undefined as never)
expect(true).toBe(true)
})
it('registers the package-owned empty invariant installer', async () => {
const register = vi.fn().mockReturnValue(() => {})
const ctx = { invariants: { register } } as never
const dispose = await invariant.apply(ctx)
expect(invariant.name).toBe('client-ui-model-selector-invariant')
expect(invariant.inject).toEqual(['invariants'])
expect(register).toHaveBeenCalledWith(
'@deepseek-ai/dsh-client-ui-model-selector',
expect.any(Function),
)
expect(() => {
(register.mock.calls[0]![1] as (inner: never) => void)(undefined as never)
}).not.toThrow()
expect(dispose).toBeTypeOf('function')
})
})

View File

@@ -0,0 +1,33 @@
{
"extends": "../../../tsconfig.base.client.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": [
"src"
],
"references": [
{
"path": "../../../vendor/cordis"
},
{
"path": "../connection"
},
{
"path": "../runtime"
},
{
"path": "../ui-conversation"
},
{
"path": "../ui-primitives"
},
{
"path": "../ui-slots"
},
{
"path": "../../support/invariants"
}
]
}

View File

@@ -0,0 +1,3 @@
import { clientBundle } from '../tsdown.client.ts'
export default clientBundle('@deepseek-ai/dsh-client-ui-model-selector', ['lib/types/index.js', 'lib/types/invariant.js'])

View File

@@ -10,6 +10,8 @@ The layering/protocol decisions are recorded in the [GUI layering and RPC protoc
The mux stream projects the latest log-backed title as a validated `session/title` control frame after each attached-session subscription baseline and immediately after the corresponding live raw title event. This projection does not add titles to `session.list`; cold sessions remain metadata-only there until opening or resuming attaches their logs.
Session model routing is a session-domain contract. `session.history` returns the selected `modelTarget`, `session.models` returns that target with provider-grouped advisory model metadata and provider-local lookup failures, and `session.selectModel` replaces the target selected for the next prompt-assembly boundary. Catalog membership is not validation: a registered provider may accept an unlisted model, while an unregistered provider returns `model-unavailable`.
## Carrier layer (`/client` + root)
`AbstractApiClient` holds every protocol invariant — rpcId minting, envelope wrap/unwrap, zod parsing, SSE frame decoding, unary timeout, microtask-batched envelope observation (`subscribeEnvelopes`) — while platform subclasses supply only the `doFetch` transport aspect. `InProcessApiClient` over `toFetchHandler(api)` is the isomorphic point: the full wire serialization/validation path with no network, used by `dsh -p` headless.

View File

@@ -19,7 +19,10 @@ export interface ApiProxy {
}
// ---- Domain interfaces and payload entities ----
export type { HistoryEntry, SessionsApi, SessionSummary } from './sessions.ts'
export type {
HistoryEntry, ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelTarget,
SessionModels, SessionsApi, SessionSummary,
} from './sessions.ts'
export type { HostApi } from './host.ts'
export type { EventsApi, MuxFrame, HostFrame, ToolCallView, ToolEventView, ToolResultView } from './events.ts'
export type { ApprovalResponsePayload } from './approvals.ts'

View File

@@ -13,6 +13,8 @@ export interface RpcMethodMap {
'session.list': SessionsApi['list']
'session.create': SessionsApi['create']
'session.history': SessionsApi['history']
'session.models': SessionsApi['models']
'session.selectModel': SessionsApi['selectModel']
'session.prompt': SessionsApi['prompt']
'session.cancel': SessionsApi['cancel']
'host.describe': HostApi['describe']

View File

@@ -35,6 +35,7 @@ export const rpcErrorSchema: z.ZodType<RpcError> = z.discriminatedUnion('code',
z.object({ code: z.literal('bad-request'), message: z.string(), details: z.object({ issues: z.array(z.custom<ZodIssue>()) }) }),
z.object({ code: z.literal('cancelled'), message: z.string(), details: z.object({}) }),
z.object({ code: z.literal('session-not-found'), message: z.string(), details: z.object({ sessionId: z.string() }) }),
z.object({ code: z.literal('model-unavailable'), message: z.string(), details: z.object({ provider: z.string(), model: z.string() }) }),
z.object({ code: z.literal('agent-busy'), message: z.string(), details: z.object({ reason: z.string() }) }),
z.object({ code: z.literal('internal'), message: z.string(), details: z.object({}) }),
]) as unknown as z.ZodType<RpcError>

View File

@@ -32,6 +32,7 @@ export interface RpcErrorDetailsMap {
'bad-request': { issues: ZodIssue[] }
'cancelled': {}
'session-not-found': { sessionId: SessionId }
'model-unavailable': { provider: string; model: string }
'agent-busy': { reason: string }
'internal': {}
}

View File

@@ -9,7 +9,10 @@ import { z } from 'zod'
import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session/types'
import type { RequestPayload, ResponseValue } from './rpc-map.ts'
import type { Wire } from './rpc.schema.ts'
import type { HistoryEntry, SessionSummary } from './sessions.ts'
import type {
HistoryEntry, ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelTarget,
SessionSummary,
} from './sessions.ts'
import type { ToolEventView } from './events.ts'
/** SessionId: one brand cast after shape validation (the only cast point in this domain). */
@@ -61,6 +64,34 @@ export const sessionHistoryRequestSchema = z.object({
maxMessages: z.number().int().positive().optional(),
}) satisfies z.ZodType<Wire<RequestPayload<'session.history'>>>
/** Complete provider/model target. */
export const modelTargetSchema = z.object({
provider: z.string().min(1),
model: z.string().min(1),
}) satisfies z.ZodType<Wire<ModelTarget>>
/** One advisory model entry inside a provider group. */
export const modelCatalogModelSchema = z.object({
id: z.string().min(1),
name: z.string().min(1),
description: z.string().optional(),
unlisted: z.literal(true).optional(),
}) satisfies z.ZodType<Wire<ModelCatalogModel>>
/** One successfully loaded provider group. */
export const modelProviderGroupSchema = z.object({
id: z.string().min(1),
name: z.string().min(1),
models: z.array(modelCatalogModelSchema),
}) satisfies z.ZodType<Wire<ModelProviderGroup>>
/** One provider-local catalog failure. */
export const modelCatalogFailureSchema = z.object({
id: z.string().min(1),
name: z.string().min(1),
message: z.string(),
}) satisfies z.ZodType<Wire<ModelCatalogFailure>>
/**
* ToolEventView passthrough: lock only the `for` discriminant and the presence
* of a card-tagged `view` object. The view interior is a host-computed product
@@ -82,8 +113,33 @@ export const historyEntrySchema = z.object({
export const sessionHistoryValueSchema = z.object({
events: z.array(historyEntrySchema),
hasMore: z.boolean(),
modelTarget: modelTargetSchema,
}) satisfies z.ZodType<Wire<ResponseValue<'session.history'>>>
/** session.models request payload. */
export const sessionModelsRequestSchema = z.object({
sessionId: sessionIdSchema,
}) satisfies z.ZodType<Wire<RequestPayload<'session.models'>>>
/** session.models response value. */
export const sessionModelsValueSchema = z.object({
current: modelTargetSchema,
groups: z.array(modelProviderGroupSchema),
failures: z.array(modelCatalogFailureSchema),
}) satisfies z.ZodType<Wire<ResponseValue<'session.models'>>>
/** session.selectModel request payload. */
export const sessionSelectModelRequestSchema = z.object({
sessionId: sessionIdSchema,
provider: z.string().min(1),
model: z.string().min(1),
}) satisfies z.ZodType<Wire<RequestPayload<'session.selectModel'>>>
/** session.selectModel response value. */
export const sessionSelectModelValueSchema = z.object({
selected: modelTargetSchema,
}) satisfies z.ZodType<Wire<ResponseValue<'session.selectModel'>>>
/** ContentBlock passthrough: core is merge-extensible — the type discriminant envelope is strict, the rest stays wide. */
export const contentBlockSchema = z.looseObject({ type: z.string() })

View File

@@ -31,6 +31,56 @@ export interface HistoryEntry {
view?: ToolEventView
}
/** Complete provider/model route selected for one session. */
export interface ModelTarget {
/** Registered provider route. */
provider: string
/** Provider-owned model id. */
model: string
}
/** One model displayed inside its provider group. */
export interface ModelCatalogModel {
/** Provider-owned model id. */
id: string
/** Provider-supplied display name. */
name: string
/** Optional provider-supplied description. */
description?: string
/** The current model was inserted because the advisory catalog omitted it. */
unlisted?: true
}
/** One provider and the models it advertised successfully. */
export interface ModelProviderGroup {
/** Provider route id used for requests. */
id: string
/** Provider display name. */
name: string
/** Models in provider-preferred order. */
models: ModelCatalogModel[]
}
/** A provider whose asynchronous catalog lookup failed. */
export interface ModelCatalogFailure {
/** Provider route id. */
id: string
/** Provider display name. */
name: string
/** Lookup failure diagnostic. */
message: string
}
/** Detached model-directory snapshot for one session. */
export interface SessionModels {
/** Target selected for the session's next assembled step. */
current: ModelTarget
/** Successfully loaded provider groups. */
groups: ModelProviderGroup[]
/** Provider-local failures; successful groups remain usable. */
failures: ModelCatalogFailure[]
}
/** Session list entry (v1 builds no index: list does readdir+stat). */
export interface SessionSummary {
sessionId: SessionId
@@ -62,7 +112,17 @@ export interface SessionsApi {
* rebuilds the surface from the events with the shared fold.
*/
history(request: RpcRequest<{ sessionId: SessionId; beforeSeq?: number; maxMessages?: number }>):
Promise<RpcResponse<{ events: HistoryEntry[]; hasMore: boolean }>>
Promise<RpcResponse<{ events: HistoryEntry[]; hasMore: boolean; modelTarget: ModelTarget }>>
/** Reads a fresh advisory model directory for this session. Provider lookups run independently. */
models(request: RpcRequest<{ sessionId: SessionId }>): Promise<RpcResponse<SessionModels>>
/**
* Selects the complete route for this session. The registered provider is
* validated, while model catalog membership remains advisory.
*/
selectModel(request: RpcRequest<{ sessionId: SessionId; provider: string; model: string }>):
Promise<RpcResponse<{ selected: ModelTarget }>>
/** Sends a message. content is core's ContentBlock[] verbatim; mode maps 1:1 — queue→send, steer→steer. */
prompt(request: RpcRequest<{ sessionId: SessionId; mode: 'queue' | 'steer'; content: ContentBlock[] }>):

View File

@@ -19,7 +19,9 @@ import {
sessionCreateValueSchema,
sessionHistoryValueSchema,
sessionListValueSchema,
sessionModelsValueSchema,
sessionPromptValueSchema,
sessionSelectModelValueSchema,
} from '../api/sessions.schema.ts'
/**
@@ -42,6 +44,8 @@ export interface IApiClient {
list(payload: RequestPayload<'session.list'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.list'>>>
create(payload: RequestPayload<'session.create'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.create'>>>
history(payload: RequestPayload<'session.history'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.history'>>>
models(payload: RequestPayload<'session.models'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.models'>>>
selectModel(payload: RequestPayload<'session.selectModel'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.selectModel'>>>
prompt(payload: RequestPayload<'session.prompt'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.prompt'>>>
cancel(payload: RequestPayload<'session.cancel'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.cancel'>>>
}
@@ -64,6 +68,8 @@ const UNARY_VALUE_SCHEMAS: { [K in keyof RpcMethodMap]: z.ZodType<Wire<ResponseV
'session.list': sessionListValueSchema,
'session.create': sessionCreateValueSchema,
'session.history': sessionHistoryValueSchema,
'session.models': sessionModelsValueSchema,
'session.selectModel': sessionSelectModelValueSchema,
'session.prompt': sessionPromptValueSchema,
'session.cancel': sessionCancelValueSchema,
'host.describe': hostDescribeValueSchema,
@@ -245,6 +251,8 @@ export abstract class AbstractApiClient implements IApiClient {
list: (payload, signal) => this.callUnary('session.list', payload, signal),
create: (payload, signal) => this.callUnary('session.create', payload, signal),
history: (payload, signal) => this.callUnary('session.history', payload, signal),
models: (payload, signal) => this.callUnary('session.models', payload, signal),
selectModel: (payload, signal) => this.callUnary('session.selectModel', payload, signal),
prompt: (payload, signal) => this.callUnary('session.prompt', payload, signal),
cancel: (payload, signal) => this.callUnary('session.cancel', payload, signal),
}

View File

@@ -19,7 +19,9 @@ import {
sessionCreateRequestSchema,
sessionHistoryRequestSchema,
sessionListRequestSchema,
sessionModelsRequestSchema,
sessionPromptRequestSchema,
sessionSelectModelRequestSchema,
} from '../api/sessions.schema.ts'
import { hostDescribeRequestSchema } from '../api/host.schema.ts'
@@ -41,6 +43,8 @@ const UNARY_ROUTES: UnaryRoutes = {
'session.list': { schema: sessionListRequestSchema, invoke: (api, r) => api.sessions.list(r) },
'session.create': { schema: sessionCreateRequestSchema, invoke: (api, r) => api.sessions.create(r) },
'session.history': { schema: sessionHistoryRequestSchema, invoke: (api, r) => api.sessions.history(r) },
'session.models': { schema: sessionModelsRequestSchema, invoke: (api, r) => api.sessions.models(r) },
'session.selectModel': { schema: sessionSelectModelRequestSchema, invoke: (api, r) => api.sessions.selectModel(r) },
'session.prompt': { schema: sessionPromptRequestSchema, invoke: (api, r) => api.sessions.prompt(r) },
'session.cancel': { schema: sessionCancelRequestSchema, invoke: (api, r) => api.sessions.cancel(r) },
'host.describe': { schema: hostDescribeRequestSchema, invoke: (api, r) => api.host.describe(r) },

View File

@@ -28,7 +28,19 @@ function scriptedApi(overrides: {
sessions: {
list: r => ok(r, { items: [] }),
create: r => ok(r, { sessionId: sid('s-new') }),
history: r => ok(r, { events: [], hasMore: false }),
history: r => ok(r, {
events: [],
hasMore: false,
modelTarget: { provider: 'deepseek', model: 'deepseek-v4-flash' },
}),
models: r => ok(r, {
current: { provider: 'deepseek', model: 'deepseek-v4-flash' },
groups: [],
failures: [],
}),
selectModel: r => ok(r, {
selected: { provider: r.payload.provider, model: r.payload.model },
}),
prompt: r => ok(r, { accepted: true as const }),
cancel: r => ok(r, { accepted: true as const }),
...overrides.sessions,

View File

@@ -30,6 +30,28 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra
result: { ok: false, error: { code: 'session-not-found', message: 'nope', details: { sessionId: request.payload.sessionId } } },
}
},
async models(request) {
return {
rpcId: request.rpcId,
result: {
ok: true,
value: {
current: { provider: 'deepseek', model: 'deepseek-v4-flash' },
groups: [],
failures: [],
},
},
}
},
async selectModel(request) {
return {
rpcId: request.rpcId,
result: {
ok: true,
value: { selected: { provider: request.payload.provider, model: request.payload.model } },
},
}
},
async prompt(request) {
return { rpcId: request.rpcId, result: { ok: true, value: { accepted: true as const } } }
},
@@ -78,6 +100,12 @@ describe('unary round trip (handler ⇄ client, no network)', () => {
it('covers create/prompt/cancel/describe passthrough', async () => {
const c = client()
expect((await c.sessions.create({})).result.ok).toBe(true)
expect((await c.sessions.models({ sessionId: 's' as never })).result.ok).toBe(true)
expect((await c.sessions.selectModel({
sessionId: 's' as never,
provider: 'deepseek',
model: 'deepseek-v4-flash',
})).result.ok).toBe(true)
expect((await c.sessions.prompt({ sessionId: 's' as never, mode: 'queue', content: [{ type: 'text', text: 'x' }] })).result.ok).toBe(true)
expect((await c.sessions.cancel({ sessionId: 's' as never })).result.ok).toBe(true)
expect((await c.host.describe({})).result.ok).toBe(true)

View File

@@ -8,8 +8,9 @@ import { z } from 'zod'
import {
contentBlockSchema, sessionCancelRequestSchema, sessionCancelValueSchema, sessionCreateRequestSchema,
sessionCreateValueSchema, sessionEventSchema, sessionHistoryRequestSchema, sessionHistoryValueSchema,
sessionIdSchema, sessionListRequestSchema, sessionListValueSchema, sessionPromptRequestSchema,
sessionPromptValueSchema, sessionSummarySchema,
sessionIdSchema, sessionListRequestSchema, sessionListValueSchema, sessionModelsRequestSchema,
sessionModelsValueSchema, sessionPromptRequestSchema, sessionPromptValueSchema,
sessionSelectModelRequestSchema, sessionSelectModelValueSchema, sessionSummarySchema,
} from '../src/api/sessions.schema.ts'
import { hostDescribeRequestSchema, hostDescribeValueSchema } from '../src/api/host.schema.ts'
import { hostFrameSchema, muxFrameSchema, askUserQuestionItemSchema } from '../src/api/events.schema.ts'
@@ -31,6 +32,11 @@ describe('rpcErrorSchema', () => {
expect(rpcErrorSchema.parse({ code: 'bad-request', message: 'm', details: { issues: [] } }).code).toBe('bad-request')
expect(rpcErrorSchema.parse({ code: 'cancelled', message: 'm', details: {} }).code).toBe('cancelled')
expect(rpcErrorSchema.parse({ code: 'session-not-found', message: 'm', details: { sessionId: 's' } }).code).toBe('session-not-found')
expect(rpcErrorSchema.parse({
code: 'model-unavailable',
message: 'm',
details: { provider: 'p', model: 'm' },
}).code).toBe('model-unavailable')
expect(rpcErrorSchema.parse({ code: 'agent-busy', message: 'm', details: { reason: 'r' } }).code).toBe('agent-busy')
expect(rpcErrorSchema.parse({ code: 'internal', message: 'm', details: {} }).code).toBe('internal')
})
@@ -99,7 +105,39 @@ describe('sessions domain schemas', () => {
expect(sessionCreateValueSchema.parse({ sessionId: 's1' }).sessionId).toBe('s1')
expect(sessionHistoryRequestSchema.parse({ sessionId: 's1', beforeSeq: 3, maxMessages: 5 }).beforeSeq).toBe(3)
expect(() => sessionHistoryRequestSchema.parse({ sessionId: 's1', maxMessages: 0 })).toThrow()
expect(sessionHistoryValueSchema.parse({ events: [], hasMore: false }).hasMore).toBe(false)
expect(sessionHistoryValueSchema.parse({
events: [],
hasMore: false,
modelTarget: { provider: 'deepseek', model: 'deepseek-v4-flash' },
}).hasMore).toBe(false)
expect(sessionModelsRequestSchema.parse({ sessionId: 's1' }).sessionId).toBe('s1')
expect(sessionModelsValueSchema.parse({
current: { provider: 'deepseek', model: 'deepseek-v4-flash' },
groups: [{
id: 'deepseek',
name: 'DeepSeek',
models: [{
id: 'deepseek-v4-flash',
name: 'DeepSeek V4 Flash',
description: 'fast',
unlisted: true,
}],
}],
failures: [{ id: 'broken', name: 'Broken', message: 'offline' }],
}).groups[0]?.models[0]?.id).toBe('deepseek-v4-flash')
expect(sessionSelectModelRequestSchema.parse({
sessionId: 's1',
provider: 'deepseek',
model: 'deepseek-v4-pro',
}).model).toBe('deepseek-v4-pro')
expect(sessionSelectModelValueSchema.parse({
selected: { provider: 'deepseek', model: 'deepseek-v4-pro' },
}).selected.model).toBe('deepseek-v4-pro')
expect(() => sessionSelectModelRequestSchema.parse({
sessionId: 's1',
provider: '',
model: 'm',
})).toThrow()
const prompt = sessionPromptRequestSchema.parse({ sessionId: 's1', mode: 'queue', content: [{ type: 'text', text: 'hi' }] })
expect(prompt.mode).toBe('queue')
expect(() => sessionPromptRequestSchema.parse({ sessionId: 's1', mode: 'inject', content: [] })).toThrow()

View File

@@ -20,6 +20,8 @@ Which plugins mount and with what defaults is decided only here — shells must
Unary methods take the narrow `RpcRequest<P>` and echo `request.rpcId`; a prompt's rpcId rides `MessageSource` into the `user/message` event so clients can promote optimistic echoes. `history`/`prompt` on a cold session implicitly resume it, deduplicating concurrent calls through an in-flight table; `history` paginates backwards on message boundaries (never mid-message). The mux stream replays a `session/subscribed` baseline per attached session and every still-pending question with its original rpcId. Question responses, including blank per-item answers, are validated against the owning session and exact request before an atomic first-wins claim; answer, whole-request cancellation, owner abort, and provider disposal broadcast `question/resolved`. The host stream carries session lifecycle, running flips, and `agent/error` as the only outlet for live failures with no turn position.
The Web front door installs the shared `installAgentLlmTarget` coupling for every created or resumed agent. A session starts from the latest logged `request/header` route when one exists, otherwise from the Host default. `session.models` discovers every registered provider concurrently, keeps successful groups when another provider fails, and inserts the current target as an unlisted row when its provider omits it. A selection changes the mutable session target immediately; prompt assembly snapshots it atomically with request routing, so a change during a running step first applies to the next assembled step.
## Model Experience
Indirectly, through the non-blocking first-message title request owned by [`dsh-session-title-llm`](../../session-title/session-title-llm/README.md) when `sessionTitleLlm` is enabled, the provider/model defaults injected into created and resumed agents, the other model-facing plugins `bootHost` mounts, and the logged [workspace-instruction prefix](../../context/workspace-context/README.md#prompt-shape) when `workspaceContext` is enabled.

View File

@@ -6,13 +6,15 @@
import { randomUUID } from 'node:crypto'
import { stat } from 'node:fs/promises'
import type { Context } from 'cordis'
import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent'
import { installAgentLlmTarget } from '@deepseek-ai/dsh-agent'
import type { Agent, AgentLlmTargetRef, AgentStatus } from '@deepseek-ai/dsh-agent'
import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm'
import type { JsonValue, Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence'
import { foldSessionTitle } from '@deepseek-ai/dsh-session-title'
import type {
ApiProxy, HistoryEntry, HostFrame, MuxFrame, QuestionResponsePayload, SessionSummary, ToolEventView,
ApiProxy, HistoryEntry, HostFrame, ModelCatalogFailure, ModelProviderGroup, ModelTarget,
MuxFrame, QuestionResponsePayload, SessionSummary, ToolEventView,
} from '@deepseek-ai/dsh-host-apiproxy/api'
import { questionResponsePayloadSchema } from '@deepseek-ai/dsh-host-apiproxy/api/questions.schema'
import type { ClientResponse, RpcError, RpcReceipt, RpcRequest, RpcResponse } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
@@ -280,11 +282,36 @@ class SessionNotFound extends Error {}
*/
export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiProxy {
const agentOptions = { provider: defaults.provider, model: defaults.model }
type WebLlmTargetRef = AgentLlmTargetRef & { current: ModelTarget }
const targets = new WeakMap<Agent, WebLlmTargetRef>()
/** Implicit resume of cold sessions, deduplicating concurrent calls (follows the jsonrpc sessionCreations precedent). */
const resumes = new Map<SessionId, Promise<Agent>>()
const pendingQuestions = new Map<RpcId, PendingQuestion>()
const muxQueues = new Set<FrameQueue<RpcRequest<MuxFrame>>>()
/** Install or return the session-local target that prompt assembly snapshots. */
function targetFor(agent: Agent): WebLlmTargetRef {
const installed = targets.get(agent)
if (installed !== undefined) return installed
const logged = agent.session.requestHeader()?.config
const target: WebLlmTargetRef = {
current: logged === undefined
? { provider: defaults.provider, model: defaults.model }
: { provider: logged.provider, model: logged.model },
assembled: undefined,
}
installAgentLlmTarget(agent.ctx, target)
targets.set(agent, target)
return target
}
/** Pre-publication setup used by both fresh and resumed Web agents. */
function installTarget(agentCtx: Context): void {
const agent = agentCtx.agent
if (agent === undefined) throw new Error('api-proxy: agent setup has no scoped agent')
targetFor(agent)
}
/** Send one transient frame to every connected mux consumer. */
function broadcast(payload: MuxFrame): void {
const envelope = frame(payload)
@@ -363,7 +390,11 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
resume = (async () => {
try {
await assertServable(sessionId)
const handle = await ctx.agents.resume({ resumeSessionId: sessionId, agentOptions })
const handle = await ctx.agents.resume({
resumeSessionId: sessionId,
agentOptions,
setup: installTarget,
})
return handle.agent
} finally {
resumes.delete(sessionId)
@@ -409,7 +440,12 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
// one, the default project is the host-level default (the host process
// working directory unless boot overrides it).
const cwd = request.payload.cwd ?? defaults.cwd
const handle = await ctx.agents.create({ sessionId, agentOptions, meta: { cwd } })
const handle = await ctx.agents.create({
sessionId,
agentOptions,
meta: { cwd },
setup: installTarget,
})
return ok(request, { sessionId: handle.agent.id })
},
@@ -425,7 +461,71 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
const view = viewFor(ctx, event, callId => backscanArgs(page.events, callId))
return { event, ...view === undefined ? {} : { view } }
})
return ok(request, { events: entries, hasMore: page.hasMore })
const current = targetFor(found.agent).current
return ok(request, { events: entries, hasMore: page.hasMore, modelTarget: { ...current } })
},
async models(request) {
const { sessionId } = request.payload
const found = await agentFor(sessionId)
if ('error' in found) return err(request, found.error)
const current = targetFor(found.agent).current
const catalog = await Promise.all(ctx.llm.listProviders().map(async (provider) => {
try {
const models = await ctx.llm.listModels(provider.id)
const group: ModelProviderGroup = {
id: provider.id,
name: provider.name,
models: models.map(model => ({
id: model.id,
name: model.name,
...model.description === undefined ? {} : { description: model.description },
})),
}
return { kind: 'group' as const, group }
} catch (error: unknown) {
const failure: ModelCatalogFailure = {
id: provider.id,
name: provider.name,
message: error instanceof Error ? error.message : String(error),
}
return { kind: 'failure' as const, failure }
}
}))
const groups = catalog.flatMap(item => item.kind === 'group' ? [item.group] : [])
const failures = catalog.flatMap(item => item.kind === 'failure' ? [item.failure] : [])
const currentGroup = groups.find(group => group.id === current.provider)
if (
currentGroup !== undefined
&& !currentGroup.models.some(model => model.id === current.model)
) {
currentGroup.models.push({
id: current.model,
name: current.model,
unlisted: true,
})
}
return ok(request, {
current: { ...current },
groups: groups.filter(group => group.models.length > 0),
failures,
})
},
async selectModel(request) {
const { sessionId, provider, model } = request.payload
const found = await agentFor(sessionId)
if ('error' in found) return err(request, found.error)
if (!ctx.llm.listProviders().some(entry => entry.id === provider)) {
return err(request, {
code: 'model-unavailable',
message: `provider "${provider}" is not registered`,
details: { provider, model },
})
}
const selected: ModelTarget = { provider, model }
targetFor(found.agent).current = selected
return ok(request, { selected: { ...selected } })
},
async prompt(request) {

View File

@@ -0,0 +1,165 @@
/**
* 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.
*/
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import AgentRegistry, { agentEvents } from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import LlmService, { LlmAdapter } from '@deepseek-ai/dsh-llm'
import type {
GenerateOptions, LlmCallConfig, LlmModelInfo, LlmProviderInfo, StreamChunk,
} from '@deepseek-ai/dsh-llm'
import SessionStore from '@deepseek-ai/dsh-session'
import type { SessionId } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
import type { RpcRequest } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
import { createApiProxy } from '../src/api-proxy.ts'
let nextRpc = 1
function request<P>(payload: P): RpcRequest<P> {
return { rpcId: RpcId(`models-${String(nextRpc++)}`), payload }
}
class CatalogAdapter extends LlmAdapter {
constructor(
private readonly name: string,
private readonly models: readonly LlmModelInfo[] | Error,
) {
super()
}
override providerInfo(provider: string): LlmProviderInfo {
return { id: provider, name: this.name }
}
override listModels(): Promise<readonly LlmModelInfo[]> {
return this.models instanceof Error
? Promise.reject(this.models)
: Promise.resolve(this.models)
}
override async *stream(_options: GenerateOptions): AsyncIterable<StreamChunk> {
// Catalog tests never enter provider streaming.
}
}
async function harness(logged?: { provider: string; model: string }): Promise<{
ctx: Context
agent: Agent
sessionId: SessionId
}> {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(SystemPrompt, { persona: '' })
await ctx.plugin(LlmService)
await ctx.plugin(UserInteractionService)
await ctx.plugin(AgentRegistry)
ctx.llm.registerAdapter(['deepseek'], new CatalogAdapter('DeepSeek', [
{ provider: 'deepseek', id: 'deepseek-chat', name: 'DeepSeek Chat' },
{ provider: 'deepseek', id: 'deepseek-reasoner', name: 'DeepSeek Reasoner', description: 'Reasoning model' },
]))
ctx.llm.registerAdapter(['broken'], new CatalogAdapter('Broken Provider', new Error('catalog offline')))
ctx.llm.registerAdapter(['empty'], new CatalogAdapter('Empty Provider', []))
ctx.llm.registerAdapter(['duplicate'], new CatalogAdapter('Duplicate Provider', [
{ provider: 'duplicate', id: 'same', name: 'Same' },
{ provider: 'duplicate', id: 'same', name: 'Same Again' },
]))
const session = ctx.sessions.create()
if (logged !== undefined) {
session.append('request/header', { header: { config: logged }, reason: 'initial' })
}
const agent = {
id: session.id,
session,
status: 'running',
ctx,
} as Agent
ctx.agents.register(agent)
return { ctx, agent, sessionId: session.id }
}
function expectValue<T>(response: { result: { ok: true; value: T } | { ok: false } }): T {
if (!response.result.ok) throw new Error('expected successful response')
return response.result.value
}
describe('Web session model selection', () => {
it('groups successful providers, isolates failures, and preserves an unlisted current model', async () => {
const { ctx, sessionId } = await harness({ provider: 'deepseek', model: 'private-preview' })
const api = createApiProxy(ctx, { provider: 'deepseek', model: 'deepseek-chat', cwd: '/tmp' })
const history = expectValue(await api.sessions.history(request({ sessionId })))
expect(history.modelTarget).toEqual({ provider: 'deepseek', model: 'private-preview' })
const catalog = expectValue(await api.sessions.models(request({ sessionId })))
expect(catalog.current).toEqual({ provider: 'deepseek', model: 'private-preview' })
expect(catalog.groups).toEqual([{
id: 'deepseek',
name: 'DeepSeek',
models: [
{ id: 'deepseek-chat', name: 'DeepSeek Chat' },
{ id: 'deepseek-reasoner', name: 'DeepSeek Reasoner', description: 'Reasoning model' },
{ id: 'private-preview', name: 'private-preview', unlisted: true },
],
}])
expect(catalog.failures).toEqual([
{ id: 'broken', name: 'Broken Provider', message: 'catalog offline' },
{
id: 'duplicate',
name: 'Duplicate Provider',
message: 'adapter returned invalid or duplicate model metadata for provider "duplicate"',
},
])
await ctx.fiber.dispose()
})
it('accepts an advisory-unlisted model, rejects an unavailable provider, and switches only after the next assembly', async () => {
const { ctx, agent, sessionId } = await harness()
const api = createApiProxy(ctx, { provider: 'deepseek', model: 'deepseek-chat', cwd: '/tmp' })
const seed: LlmCallConfig = { provider: 'seed', model: 'seed', temperature: 0.2 }
const signal = new AbortController().signal
expect(expectValue(await api.sessions.history(request({ sessionId }))).modelTarget)
.toEqual({ provider: 'deepseek', model: 'deepseek-chat' })
expect((await ctx.systemPrompt.assemble()).variables)
.toMatchObject({ provider: 'deepseek', model: 'deepseek-chat' })
const selected = expectValue(await api.sessions.selectModel(request({
sessionId,
provider: 'deepseek',
model: 'private-preview',
})))
expect(selected.selected).toEqual({ provider: 'deepseek', model: 'private-preview' })
await expect(agentEvents(ctx, agent).waterfall(
'agent/request', 1, 0, seed, signal, () => Promise.resolve(seed),
)).resolves.toMatchObject({ provider: 'deepseek', model: 'deepseek-chat' })
expect((await ctx.systemPrompt.assemble()).variables)
.toMatchObject({ provider: 'deepseek', model: 'private-preview' })
await expect(agentEvents(ctx, agent).waterfall(
'agent/request', 1, 1, seed, signal, () => Promise.resolve(seed),
)).resolves.toMatchObject({ provider: 'deepseek', model: 'private-preview' })
const rejected = await api.sessions.selectModel(request({
sessionId,
provider: 'missing',
model: 'model',
}))
expect(rejected.result).toEqual({
ok: false,
error: {
code: 'model-unavailable',
message: 'provider "missing" is not registered',
details: { provider: 'missing', model: 'model' },
},
})
expect(expectValue(await api.sessions.history(request({ sessionId }))).modelTarget)
.toEqual({ provider: 'deepseek', model: 'private-preview' })
await ctx.fiber.dispose()
})
})

View File

@@ -20,13 +20,13 @@ The package root exposes the Cordis plugin contract and `DeepSeekAdapter`; wire
defaultContextWindow: 256000 # optional positive-integer fallback for models without an exact value
models: # optional; defaults to V4 Flash and V4 Pro
- id: deepseek-v4-flash
name: DeepSeek V4 Flash
name: DeepSeek-V4-Flash
- id: private-reasoner
description: Company-hosted reasoning model
contextWindow: 64000
```
The plugin registers the single provider route `deepseek`. A request selects it with `provider: deepseek`; its `model` is passed through as the wire `model` string, so changing DeepSeek models does not require lifecycle-time registration. Omitting `models` advertises `deepseek-v4-flash` and `deepseek-v4-pro`, each with a 128,000-token context window; an explicit list replaces those defaults, while `models: []` advertises none. Catalog entries are exposed through `ctx.llm.listModels('deepseek')` for clients such as ACP editors, but remain advisory: unlisted model ids still pass through unchanged. An omitted entry name defaults to its id.
The plugin registers the single provider route `deepseek`. A request selects it with `provider: deepseek`; its `model` is passed through as the wire `model` string, so changing DeepSeek models does not require lifecycle-time registration. Omitting `models` advertises `deepseek-v4-flash` as `DeepSeek-V4-Flash` and `deepseek-v4-pro` as `DeepSeek-V4-Pro`, each with a 256,000-token context window; an explicit list replaces those defaults, while `models: []` advertises none. Catalog entries are exposed through `ctx.llm.listModels('deepseek')` for clients such as ACP editors and the Web selector, but remain advisory: unlisted model ids still pass through unchanged. An omitted entry name defaults to its id.
`contextWindow` is optional per configured model and is not exposed through the advisory catalog. `ctx.llm.resolveModelContext('deepseek', model)` returns an exact model value first, then `defaultContextWindow` for an entry without capacity or an unlisted pass-through id. When neither value exists it returns `undefined` without invalidating routing. Pressure-sensitive plugins therefore get deployment-owned capacity without treating the model selector as authoritative. Registering another adapter for `deepseek` throws `LlmError('DUPLICATE_ADAPTER')`.

View File

@@ -21,8 +21,8 @@ export const name = 'llm-deepseek'
export const inject = ['llm']
const DEFAULT_MODELS: DeepSeekCatalogModel[] = [
{ id: 'deepseek-v4-flash', contextWindow: 128_000 },
{ id: 'deepseek-v4-pro', contextWindow: 128_000 },
{ id: 'deepseek-v4-flash', name: 'DeepSeek-V4-Flash', contextWindow: 256_000 },
{ id: 'deepseek-v4-pro', name: 'DeepSeek-V4-Pro', contextWindow: 256_000 },
]
/**

View File

@@ -528,11 +528,11 @@ describe('plugin registration and config', () => {
await ctx.plugin(LlmDeepSeek, { apiKey: 'k', baseURL: 'http://127.0.0.1:1' })
expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek', name: 'DeepSeek' }])
await expect(ctx.llm.listModels('deepseek')).resolves.toEqual([
{ provider: 'deepseek', id: 'deepseek-v4-flash', name: 'deepseek-v4-flash' },
{ provider: 'deepseek', id: 'deepseek-v4-pro', name: 'deepseek-v4-pro' },
{ provider: 'deepseek', id: 'deepseek-v4-flash', name: 'DeepSeek-V4-Flash' },
{ provider: 'deepseek', id: 'deepseek-v4-pro', name: 'DeepSeek-V4-Pro' },
])
await expect(ctx.llm.resolveModelContext('deepseek', 'deepseek-v4-flash'))
.resolves.toEqual({ contextWindow: 128_000 })
.resolves.toEqual({ contextWindow: 256_000 })
})
it('uses the default model catalog when apply is called directly', async () => {
@@ -540,8 +540,8 @@ describe('plugin registration and config', () => {
await ctx.plugin(LlmService)
LlmDeepSeek.apply(ctx, { apiKey: 'k', baseURL: 'http://127.0.0.1:1' })
await expect(ctx.llm.listModels('deepseek')).resolves.toEqual([
{ provider: 'deepseek', id: 'deepseek-v4-flash', name: 'deepseek-v4-flash' },
{ provider: 'deepseek', id: 'deepseek-v4-pro', name: 'deepseek-v4-pro' },
{ provider: 'deepseek', id: 'deepseek-v4-flash', name: 'DeepSeek-V4-Flash' },
{ provider: 'deepseek', id: 'deepseek-v4-pro', name: 'DeepSeek-V4-Pro' },
])
})

37
pnpm-lock.yaml generated
View File

@@ -119,6 +119,9 @@ importers:
'@deepseek-ai/dsh-client-ui-layout':
specifier: workspace:^
version: link:../../packages/client/ui-layout
'@deepseek-ai/dsh-client-ui-model-selector':
specifier: workspace:^
version: link:../../packages/client/ui-model-selector
'@deepseek-ai/dsh-client-ui-question':
specifier: workspace:^
version: link:../../packages/client/ui-question
@@ -665,6 +668,40 @@ importers:
specifier: ^18.2.0
version: 18.3.1
packages/client/ui-model-selector:
dependencies:
'@deepseek-ai/dsh-client-connection':
specifier: workspace:^
version: link:../connection
'@deepseek-ai/dsh-client-runtime':
specifier: workspace:^
version: link:../runtime
'@deepseek-ai/dsh-client-ui-conversation':
specifier: workspace:^
version: link:../ui-conversation
'@deepseek-ai/dsh-client-ui-primitives':
specifier: workspace:^
version: link:../ui-primitives
'@deepseek-ai/dsh-client-ui-slots':
specifier: workspace:^
version: link:../ui-slots
clsx:
specifier: ^2.0.0
version: 2.1.1
react:
specifier: ^18.2.0
version: 18.3.1
devDependencies:
'@deepseek-ai/dsh-invariants':
specifier: workspace:^
version: link:../../support/invariants
'@types/react':
specifier: ~18.3.1
version: 18.3.31
cordis:
specifier: ^4.0.0-rc.7
version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5)
packages/client/ui-primitives:
dependencies:
clsx:

View File

@@ -7,6 +7,7 @@
".agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md",
".agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md",
".agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md",
".agents/notes/implemented/feature/2026-07-24-web-session-model-selector.md",
".agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.md",
".agents/notes/implemented/process/2026-07-19-web-styling-system.md",
"README.md",

View File

@@ -55,6 +55,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly<Record<string, SentenceContract>> = {
'packages/client/ui-layout': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },
'packages/client/ui-sidebar': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },
'packages/client/ui-conversation': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },
'packages/client/ui-model-selector': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },
'packages/client/ui-question': { kind: 'indirect', reason: 'The package mounts dsh-tool-ask-user; that tool owns the model-visible schema and answer rendering.' },
'packages/client/ui-trajectory': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },
'packages/client/ui-theme': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },

View File

@@ -109,6 +109,7 @@
"@deepseek-ai/dsh-client-ui-layout": ["./packages/client/ui-layout/src"],
"@deepseek-ai/dsh-client-ui-sidebar": ["./packages/client/ui-sidebar/src"],
"@deepseek-ai/dsh-client-ui-conversation": ["./packages/client/ui-conversation/src"],
"@deepseek-ai/dsh-client-ui-model-selector": ["./packages/client/ui-model-selector/src"],
"@deepseek-ai/dsh-client-ui-question": ["./packages/client/ui-question/src"],
"@deepseek-ai/dsh-client-ui-trajectory": ["./packages/client/ui-trajectory/src"],
"@deepseek-ai/dsh-client-ui-theme": ["./packages/client/ui-theme/src"],

View File

@@ -34,6 +34,7 @@
{ "path": "./packages/client/ui-layout" },
{ "path": "./packages/client/ui-sidebar" },
{ "path": "./packages/client/ui-conversation" },
{ "path": "./packages/client/ui-model-selector" },
{ "path": "./packages/client/ui-question" },
{ "path": "./packages/client/ui-trajectory" },
{ "path": "./packages/client/ui-theme" },

View File

@@ -97,6 +97,7 @@ export default defineConfig({
// yet. TODO(gui): cover and remove as the client test lane matures.
'packages/client/ui-trajectory/src/*',
'packages/client/ui-question/src/client/QuestionComposer.tsx',
'packages/client/ui-model-selector/src/client/ModelSelector.tsx',
'packages/client/web-react/src/*',
'packages/client/runtime/src/*',
'packages/client/ui-conversation/src/*',