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

@@ -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(测试前置数据)公开两个提供方分组,并在下一条生成的响应中报告所选路由。