refactor: apply repository naming contract
Apply the accepted pre-release package, service, type, directory, and role renames as one repository-wide change.
This commit is contained in:
6
packages/client/ui-model-selection/README.i18n.yaml
Normal file
6
packages/client/ui-model-selection/README.i18n.yaml
Normal 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 packages/client/ui-model-selection/README.md
|
||||
README.md: 929d23ad4af1fe41e1cc7398ef432e73ca2a7a94
|
||||
README.zh.md: 23e786d9e490b0739498a3e83703b42ebc345cbd
|
||||
29
packages/client/ui-model-selection/README.md
Normal file
29
packages/client/ui-model-selection/README.md
Normal file
@@ -0,0 +1,29 @@
|
||||
# @deepseek-ai/dsh-client-ui-model-selection
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Model selection plugin, browser half: TWO entries over ONE per-session directory owned by `ModelDirectoryResolver` (`ctx.modelDirectories`). For ordinary sessions, the `/model` popupSelect contribution (registered through `ctx.commandUi`) and the composer's named `conversation.input.model` seat both load the session's advisory directory through `session.models` and submit through `session.selectModel` via the same `ModelDirectory` instance. The compact composer trigger opens a two-level Model/Effort menu: models stay provider-grouped, while the selected exact model supplies its adapter-owned effort names, descriptions, and default. `/model` applies the selected model's default effort, and the composer can then choose any advertised effort.
|
||||
|
||||
The Host-reported provider/model/reasoning `ModelSelection` is the single selection fact, but it is echoed only when the exact provider/model pair remains in the advertised groups; an absent catalog row leaves the routable selection intact while the trigger prompts `Select model`, no stale row is synthesized, and no Effort row is shown until the user picks an advertised model. Directory loads and selections share a generation counter so an older response never overwrites a newer one; a connection reset drops every resident projection and repulls the Host-restored selection before display. Provider-local metadata failures list inline while usable groups stay selectable, and selection failures retain the prior selection and directory.
|
||||
|
||||
When the Host reports that no adapter serves the session's route (`session.models.routable`), this plugin raises a composer block through `ctx.conversation.blocks` and the input goes inert with this plugin's own copy; recovering clears it without a reload. It follows `routable` and nothing else: a `null` — before the first load, or after one failed — never blocks, or a slow Host would lock a working composer, and catalog membership never blocks either, because a route serving a model it stopped advertising is missing from the groups yet perfectly usable. The trigger's own `Select model` fallback still covers that case, which is display, not a gate.
|
||||
|
||||
Directories are per-session, resolved lazily through `ctx.modelDirectories.directoryFor(sessionId)`, and disposed with the session scope. Addressed subagent sessions expose neither entry, and their directory rejects loads, selections, and reconnect refreshes, because ordinary Agent-bound model RPCs would activate persisted child history outside the direct-parent continuation path.
|
||||
|
||||
Every resident directory refetches directly on forwarded `llm/adapters-updated` and `settings/document-updated` owner events. Provider topology, provider catalogs, and the default selection therefore converge without the Host or client runtime deriving a separate model-change alias.
|
||||
|
||||
The `/client` exports are the plugin body (`apply`/`inject`), `ModelDirectoryResolver`, `ModelDirectory` with its state fields, and the seat's injected face type.
|
||||
|
||||
## Model Experience
|
||||
|
||||
Indirectly, through the `session.selectModel` RPC available to ordinary sessions, both entries submit the complete `ModelSelection` that the Host snapshots at the next prompt-assembly boundary, so the following request uses the selected provider, model, and effort while a running step keeps its assembled selection; the selection becomes durable only when the existing request header records a request that consumes it, and menu interaction adds no prompt content.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
Switching the route can reduce or invalidate provider-side cache reuse for subsequent requests; the prompt prefix itself is untouched.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **No create-time or addressed-subagent selection** — both entries require an existing ordinary session's Agent; there is no draft-phase model choice to fold into session creation, and subagent continuation deliberately exposes no independent model-selection contract.
|
||||
- **Directory names are presentation-only** — selection and persistence use provider/model/effort ids; a provider whose catalog or exact-model metadata lookup fails lists as an unselectable failure row until reload.
|
||||
- **No arbitrary effort input** — the composer offers only the exact model's adapter-advertised levels; an adapter without reasoning metadata leaves the Effort row absent.
|
||||
29
packages/client/ui-model-selection/README.zh.md
Normal file
29
packages/client/ui-model-selection/README.zh.md
Normal file
@@ -0,0 +1,29 @@
|
||||
# @deepseek-ai/dsh-client-ui-model-selection
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
模型选择插件(浏览器侧):**两个入口共用一份会话级目录**,由 `ModelDirectoryResolver`(`ctx.modelDirectories`)持有。对于普通会话,`/model` popupSelect 贡献项(经 `ctx.commandUi` 注册)与 composer 的具名 `conversation.input.model` slot 都通过同一个 `ModelDirectory` 实例,经 `session.models` 加载会话的建议目录,并经 `session.selectModel` 提交。紧凑型 composer 触发器会打开两级 Model/Effort 菜单:模型仍按提供方分组,所选具体模型则提供由其适配器持有的推理强度名称、说明和默认值。`/model` 应用所选模型的默认推理强度,composer 随后可以选择任一已公布的推理强度。
|
||||
|
||||
Host 报告的 `ModelSelection` 是唯一的选择事实,其中包含提供方、模型与推理(reasoning)强度;但只有当该提供方/模型对仍在已公布分组中时才会回显。目录行缺席时,可路由的选择保持不变,但触发器会提示 `Select model`;系统不会合成陈旧行,且在用户选择已公布的模型之前不会显示 Effort 行。目录加载与选择共享一个代次计数器,旧响应不会覆盖新结果;连接重置会丢弃所有常驻目录投影,并在显示前重新拉取 Host 恢复的选择。各提供方的元数据获取失败会内联列出,同时可用分组仍可选择;选择失败会保留先前的选择和目录。
|
||||
|
||||
当宿主报告没有适配器服务该会话的路由(`session.models.routable`)时,本插件经 `ctx.conversation.blocks` 注册一个 composer 阻塞块,输入框随之停用并显示本插件自己的文案;恢复后无需重新加载即自动清除。它只跟随 `routable`:`null`(首次加载之前,或加载失败之后)绝不阻断,否则一个慢的宿主就会锁死一个本来可用的 composer;目录成员关系同样不阻断,因为一条仍在服务、只是不再公布该模型的路由不在分组里,却完全可用。触发器自己的 `Select model` 回退仍然覆盖那种情形——那是显示,不是闸门。
|
||||
|
||||
目录按会话惰性解析(`ctx.modelDirectories.directoryFor(sessionId)`),随会话作用域一并 dispose(资源释放)。已寻址 subagent 会话不公开任一入口,其目录会拒绝加载、选择与重新连接刷新,因为绑定到 agent(智能体)的普通模型 RPC 会在直接 parent 继续执行路径之外激活持久化 child 历史。
|
||||
|
||||
每一份常驻目录都会直接在转发的 owner 事件 `llm/adapters-updated` 与 `settings/document-updated` 上重拉。因此提供方拓扑、提供方目录与默认选择都能收敛,Host 与 client runtime 无需再派生一个单独的模型变更别名。
|
||||
|
||||
`/client` 导出面为插件本体(`apply`/`inject`)、`ModelDirectoryResolver`、`ModelDirectory` 及其状态形状、slot 注入面类型。
|
||||
|
||||
## 模型体验
|
||||
|
||||
间接影响。两个入口都通过仅供普通会话使用的 `session.selectModel` RPC 提交完整的 `ModelSelection`;Host 会在下一次提示词组装边界对其进行快照,因此后续请求采用所选提供方、模型与推理强度,而运行中的步骤保留已组装选择。只有当现有请求头记录一次实际采用该选择的请求后,选择才会持久化;菜单交互不会添加提示词内容。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
切换路由可能减少提供方侧后续请求的缓存复用,或使其失效;提示词前缀本身不受影响。
|
||||
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **无创建期或已寻址 subagent 选择**——两个入口都要求既有普通会话的 agent;没有可纳入会话创建的草稿阶段模型选择,subagent 继续执行也有意不公开独立的模型选择约定。
|
||||
- **目录名仅供呈现**——选择与持久化使用提供方/模型/推理强度 id;目录查询或确切模型元数据查询失败的提供方以不可选失败行列出,重新加载前保持原样。
|
||||
- **不能任意输入推理强度**——composer 仅提供确切模型由适配器公布的推理强度;适配器没有推理元数据时不显示 Effort 行。
|
||||
86
packages/client/ui-model-selection/package.json
Normal file
86
packages/client/ui-model-selection/package.json
Normal file
@@ -0,0 +1,86 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-client-ui-model-selection",
|
||||
"description": "Model selection: the /model popupSelect over session.models / session.selectModel",
|
||||
"version": "0.0.1-rc.2",
|
||||
"publishConfig": {
|
||||
"access": "restricted"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
|
||||
"directory": "packages/client/ui-model-selection"
|
||||
},
|
||||
"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"
|
||||
},
|
||||
"dsh": {
|
||||
"client": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-locale",
|
||||
"@deepseek-ai/dsh-client-runtime",
|
||||
"@deepseek-ai/dsh-client-ui-commands",
|
||||
"@deepseek-ai/dsh-api-remotes"
|
||||
],
|
||||
"platform": "web"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"bundle": "tsdown",
|
||||
"watch": "tsdown --watch"
|
||||
},
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-api-remotes": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-connection": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-locale": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-commands": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-input-trigger": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"clsx": "^2.1.1",
|
||||
"@deepseek-ai/cordis": "workspace:^",
|
||||
"react": "^18.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-api-remotes": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-connection": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-locale": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-test-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-commands": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-input-trigger": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@types/react": "~18.3.1",
|
||||
"clsx": "^2.1.1",
|
||||
"@deepseek-ai/cordis": "workspace:^",
|
||||
"react": "^18.2.0"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/client.js",
|
||||
"lib/types/**/*.d.ts"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
.root {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Figma 313:14108 ToggleButton: 13/20 medium secondary label, 4px gap,
|
||||
12px caption chevron; 28px chip height matches the sibling Plan /
|
||||
Read-only selects in the same tool row. */
|
||||
.trigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
max-width: 220px;
|
||||
height: 28px;
|
||||
padding: 0 4px 0 8px;
|
||||
border: none;
|
||||
/* Rounded chip chrome, matching the sibling permission trigger. */
|
||||
border-radius: 24px;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.trigger:hover:not(:disabled) {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
/* Effort value beside the model name (mock's 'High': same 13/20/500, caption tone). */
|
||||
.triggerEffort {
|
||||
flex: 0 0 auto;
|
||||
color: var(--dsw-alias-label-caption);
|
||||
}
|
||||
|
||||
.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(240px, calc(100vw - 32px));
|
||||
max-height: min(360px, calc(100vh - 96px));
|
||||
overflow: hidden;
|
||||
padding: 4px;
|
||||
/* Surface tokens match the Menu primitive card (ui-primitives
|
||||
* Menu.module.css) so every dropdown reads as the same material. */
|
||||
border: 1px solid var(--dsw-alias-border-inverted);
|
||||
border-radius: 12px;
|
||||
background: var(--dsw-specific-menu);
|
||||
box-shadow: var(--dsw-shadow-lv3);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
/* Elevated surface: the scrollbar thumb takes the l2 elevation tokens.
|
||||
Declared here rather than on the scrolling `.groups` child so the
|
||||
elevation choice sits with the surface; the custom properties inherit
|
||||
down to whichever descendant actually scrolls (see ui-theme
|
||||
styles/scrollbar.css for the rebinding contract). */
|
||||
--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2);
|
||||
--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2);
|
||||
}
|
||||
|
||||
.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-menu);
|
||||
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 {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
/* Selection marker is the trailing check, not a fill — matches the Menu
|
||||
* primitive's selected treatment. */
|
||||
.selected {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.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 {
|
||||
overflow: hidden;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.check {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
flex: 0 0 18px;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
/* Two-level root cells (figma 496:26454 .Menu_cell): 40px row, 10px side
|
||||
padding, 8px gap, 10px radius; 14/22 label in primary, value in the
|
||||
#81858C tertiary tone, right chevron drilling into the sub-list. */
|
||||
.cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
padding: 0 10px;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
background: transparent;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
font-size: 14px;
|
||||
line-height: 22px;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.cell:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
.cellLabel {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.cellValue {
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.cellChevron {
|
||||
flex: 0 0 auto;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
375
packages/client/ui-model-selection/src/client/ModelSelect.tsx
Normal file
375
packages/client/ui-model-selection/src/client/ModelSelect.tsx
Normal file
@@ -0,0 +1,375 @@
|
||||
/**
|
||||
* ModelSelect: the composer's named model seat (`conversation.input.model`).
|
||||
* Two-level selection per figma 496:26454's MenuDropdown: the root menu is
|
||||
* the Model / Effort row pair (label + current value + a right chevron),
|
||||
* each drilling into its own list — the provider-grouped model list over
|
||||
* the shared directory, and the effort levels. The trigger (313:14108's
|
||||
* ToggleButton) shows both: model name + effort in the caption tone.
|
||||
* Data and submission ride the SAME per-session ModelDirectory as the
|
||||
* /model popup; exact-model reasoning metadata and the selected effort come
|
||||
* from the Host rather than a client-owned vocabulary. A rejected selection
|
||||
* announces through the shared transient Toast anchored to the composer
|
||||
* card; the in-menu strip with Retry remains the catalog-load surface.
|
||||
*/
|
||||
import {
|
||||
useEffect, useId, useMemo, useRef, useState, useSyncExternalStore,
|
||||
type KeyboardEvent, type FocusEvent,
|
||||
} from 'react'
|
||||
import clsx from 'clsx'
|
||||
import type { ModelReasoningEffort, ModelSelection } from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import {
|
||||
IconCheckOutline16, IconChevronDownOutline14, IconChevronRightOutline14,
|
||||
IconWarningOutline16, Toast,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { ModelSelectInjected } from './slots.ts'
|
||||
import css from './ModelSelect.module.css'
|
||||
|
||||
/** Which pane the dropdown shows: the two-row root or one drilled-in list. */
|
||||
type Pane = 'root' | 'model' | 'effort'
|
||||
|
||||
/** One dynamic effort row; undefined means preserve the provider default. */
|
||||
interface EffortChoice {
|
||||
key: string
|
||||
effort: string | undefined
|
||||
label: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the composer model seat.
|
||||
* @param props - owner share (locked) + injected face (shared directory
|
||||
* store/verbs) + the standard locale seat.
|
||||
* @returns the trigger and, while open, the two-level menu.
|
||||
*/
|
||||
export function ModelSelect(
|
||||
{ locked, available, directory, load, select, t }:
|
||||
ModelSelectInjected & { locked: boolean } & PropsLocale<'model'>,
|
||||
) {
|
||||
const state = useSyncExternalStore(
|
||||
fn => directory.subscribe(fn),
|
||||
() => directory.getSnapshot(),
|
||||
)
|
||||
const [open, setOpen] = useState(false)
|
||||
const [pane, setPane] = useState<Pane>('root')
|
||||
// The in-menu error strip serves catalog loads (its Retry re-runs the
|
||||
// load); a rejected SELECTION announces through the transient toast
|
||||
// instead, so the strip renders only while the latest failure-capable
|
||||
// action was a load.
|
||||
const lastActionRef = useRef<'load' | 'select'>('load')
|
||||
const [toast, setToast] = useState<{ seq: number; text: string } | null>(null)
|
||||
const toastSeq = useRef(0)
|
||||
const rootRef = useRef<HTMLDivElement | null>(null)
|
||||
const triggerRef = useRef<HTMLButtonElement | null>(null)
|
||||
const itemRefs = useRef<(HTMLButtonElement | null)[]>([])
|
||||
const id = useId()
|
||||
|
||||
const choices = useMemo(() => state.groups.flatMap(group =>
|
||||
group.models.map(model => ({
|
||||
group,
|
||||
model,
|
||||
selection: {
|
||||
provider: group.id,
|
||||
model: model.id,
|
||||
...model.reasoning?.defaultEffort === undefined
|
||||
? {}
|
||||
: { reasoningEffort: model.reasoning.defaultEffort },
|
||||
} satisfies ModelSelection,
|
||||
}))), [state.groups])
|
||||
const selectedIndex = state.current === null
|
||||
? -1
|
||||
: choices.findIndex(c => c.selection.provider === state.current?.provider && c.selection.model === state.current.model)
|
||||
const currentChoice = choices[selectedIndex]
|
||||
const reasoning = currentChoice?.model.reasoning
|
||||
const effectiveEffort = state.current?.reasoningEffort ?? reasoning?.defaultEffort
|
||||
const effortLabel = reasoning === undefined
|
||||
? undefined
|
||||
: effectiveEffort === undefined
|
||||
? t('effort.providerDefault')
|
||||
: reasoning.efforts.find(level => level.id === effectiveEffort)?.name ?? effectiveEffort
|
||||
const effortChoices = useMemo<readonly EffortChoice[]>(() => reasoning === undefined
|
||||
? []
|
||||
: [
|
||||
...reasoning.defaultEffort === undefined
|
||||
? [{ key: 'provider-default', effort: undefined, label: t('effort.providerDefault') }]
|
||||
: [],
|
||||
...reasoning.efforts.map((effort: ModelReasoningEffort) => ({
|
||||
key: `effort:${effort.id}`,
|
||||
effort: effort.id,
|
||||
label: effort.name,
|
||||
...effort.description === undefined ? {} : { description: effort.description },
|
||||
})),
|
||||
], [reasoning, t])
|
||||
const busy = state.status === 'selecting'
|
||||
|
||||
const reload = (): void => {
|
||||
lastActionRef.current = 'load'
|
||||
load()
|
||||
}
|
||||
|
||||
// Mount-time load resolves the trigger label; every open refreshes.
|
||||
useEffect(() => {
|
||||
if (available) {
|
||||
lastActionRef.current = 'load'
|
||||
load()
|
||||
}
|
||||
}, [available, load])
|
||||
|
||||
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])
|
||||
|
||||
if (!available) return null
|
||||
|
||||
const show = (): void => {
|
||||
setPane('root')
|
||||
setOpen(true)
|
||||
reload()
|
||||
}
|
||||
|
||||
const close = (restoreFocus = false): void => {
|
||||
setOpen(false)
|
||||
setPane('root')
|
||||
if (restoreFocus) queueMicrotask(() => { triggerRef.current?.focus() })
|
||||
}
|
||||
|
||||
const moveFocus = (offset: number): void => {
|
||||
const items = itemRefs.current.filter(item => item !== null)
|
||||
if (items.length === 0) return
|
||||
const active = items.findIndex(item => item === document.activeElement)
|
||||
const next = (Math.max(active, 0) + offset + items.length) % items.length
|
||||
items[next]?.focus()
|
||||
}
|
||||
|
||||
const onRootKeyDown = (event: KeyboardEvent<HTMLDivElement>): void => {
|
||||
if (event.key === 'Escape' && open) {
|
||||
event.preventDefault()
|
||||
// Escape backs out of a drilled pane first, then closes.
|
||||
if (pane !== 'root') setPane('root')
|
||||
else close(true)
|
||||
return
|
||||
}
|
||||
if (!open) return
|
||||
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
||||
event.preventDefault()
|
||||
moveFocus(event.key === 'ArrowDown' ? 1 : -1)
|
||||
}
|
||||
}
|
||||
|
||||
const onBlur = (event: FocusEvent<HTMLDivElement>): void => {
|
||||
if (event.relatedTarget instanceof Node && rootRef.current?.contains(event.relatedTarget)) return
|
||||
close()
|
||||
}
|
||||
|
||||
const settleSelection = (accepted: boolean): void => {
|
||||
if (accepted) {
|
||||
if (rootRef.current !== null) close(true)
|
||||
return
|
||||
}
|
||||
const message = directory.getSnapshot().error
|
||||
if (message !== null) {
|
||||
toastSeq.current += 1
|
||||
setToast({ seq: toastSeq.current, text: t('error.action', { message }) })
|
||||
}
|
||||
}
|
||||
|
||||
const choose = (selection: ModelSelection): void => {
|
||||
if (state.current?.provider === selection.provider && state.current.model === selection.model) {
|
||||
close(true)
|
||||
return
|
||||
}
|
||||
lastActionRef.current = 'select'
|
||||
void select(selection).then(settleSelection)
|
||||
}
|
||||
|
||||
const chooseEffort = (effort: string | undefined): void => {
|
||||
if (state.current === null) return
|
||||
if (effectiveEffort === effort) {
|
||||
close(true)
|
||||
return
|
||||
}
|
||||
const selection: ModelSelection = {
|
||||
provider: state.current.provider,
|
||||
model: state.current.model,
|
||||
...effort === undefined ? {} : { reasoningEffort: effort },
|
||||
}
|
||||
lastActionRef.current = 'select'
|
||||
void select(selection).then(settleSelection)
|
||||
}
|
||||
|
||||
const modelLabel = currentChoice?.model.name ?? t('trigger.fallback')
|
||||
const triggerLabel = effortLabel === undefined ? modelLabel : `${modelLabel} · ${effortLabel}`
|
||||
const triggerAria = currentChoice === undefined
|
||||
? t('trigger.selectAria')
|
||||
: effortLabel === undefined
|
||||
? t('trigger.aria', { model: modelLabel })
|
||||
: t('trigger.ariaEffort', { model: modelLabel, effort: effortLabel })
|
||||
itemRefs.current = []
|
||||
let itemIndex = 0
|
||||
const itemRef = () => {
|
||||
const at = itemIndex++
|
||||
return (node: HTMLButtonElement | null) => { itemRefs.current[at] = node }
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={rootRef} className={css.root} onKeyDown={onRootKeyDown} onBlur={onBlur}>
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
className={css.trigger}
|
||||
aria-label={triggerAria}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open}
|
||||
aria-controls={open ? `${id}-menu` : undefined}
|
||||
title={triggerLabel}
|
||||
disabled={locked}
|
||||
onClick={() => {
|
||||
if (open) {
|
||||
close()
|
||||
} else {
|
||||
show()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span className={css.triggerLabel}>{modelLabel}</span>
|
||||
{effortLabel !== undefined && <span className={css.triggerEffort}>{effortLabel}</span>}
|
||||
<IconChevronDownOutline14 className={clsx(css.chevron, open && css.chevronOpen)} />
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div
|
||||
id={`${id}-menu`}
|
||||
className={css.menu}
|
||||
role="menu"
|
||||
aria-label={t('menu.aria')}
|
||||
aria-busy={state.status === 'loading' || busy}
|
||||
>
|
||||
{pane === 'root' && (
|
||||
<>
|
||||
<button ref={itemRef()} type="button" role="menuitem" className={css.cell} onClick={() => { setPane('model') }}>
|
||||
<span className={css.cellLabel}>{t('menu.model')}</span>
|
||||
<span className={css.cellValue}>{modelLabel}</span>
|
||||
<IconChevronRightOutline14 className={css.cellChevron} />
|
||||
</button>
|
||||
{reasoning !== undefined && (
|
||||
<button ref={itemRef()} type="button" role="menuitem" className={css.cell} onClick={() => { setPane('effort') }}>
|
||||
<span className={css.cellLabel}>{t('menu.effort')}</span>
|
||||
<span className={css.cellValue}>{effortLabel}</span>
|
||||
<IconChevronRightOutline14 className={css.cellChevron} />
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{pane === 'model' && (
|
||||
<>
|
||||
{state.status === 'loading' && (
|
||||
<div className={css.status}>{t('status.loading')}</div>
|
||||
)}
|
||||
{state.error !== null && lastActionRef.current === 'load' && (
|
||||
<div className={css.error}>
|
||||
<span>{t('error.action', { message: state.error })}</span>
|
||||
<button type="button" className={css.retry} onClick={reload}>{t('retry')}</button>
|
||||
</div>
|
||||
)}
|
||||
{state.failures.map(failure => (
|
||||
<div className={css.warning} key={failure.id}>
|
||||
<span>{t('warning.groupLoad', { name: failure.name, message: failure.message })}</span>
|
||||
<button type="button" className={css.retry} onClick={reload}>{t('retry')}</button>
|
||||
</div>
|
||||
))}
|
||||
<div className={clsx(css.groups, 'scrollable')}>
|
||||
{state.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 selected = state.current?.provider === group.id && state.current.model === model.id
|
||||
return (
|
||||
<button
|
||||
ref={itemRef()}
|
||||
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>
|
||||
)}
|
||||
</span>
|
||||
<span className={css.check}>
|
||||
{selected ? <IconCheckOutline16 /> : null}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</section>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{state.status === 'ready' && choices.length === 0 && (
|
||||
<div className={css.empty}>{t('empty.models')}</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{pane === 'effort' && (
|
||||
<>
|
||||
{state.error !== null && lastActionRef.current === 'load' && (
|
||||
<div className={css.error}>
|
||||
<span>{t('error.action', { message: state.error })}</span>
|
||||
<button type="button" className={css.retry} onClick={reload}>{t('action.reload')}</button>
|
||||
</div>
|
||||
)}
|
||||
{effortChoices.length === 0
|
||||
? <div className={css.empty}>{t('empty.efforts')}</div>
|
||||
: effortChoices.map(level => (
|
||||
<button
|
||||
ref={itemRef()}
|
||||
type="button"
|
||||
role="menuitemradio"
|
||||
aria-checked={effectiveEffort === level.effort}
|
||||
className={clsx(css.option, effectiveEffort === level.effort && css.selected)}
|
||||
key={level.key}
|
||||
disabled={busy}
|
||||
onClick={() => { chooseEffort(level.effort) }}
|
||||
>
|
||||
<span className={css.optionCopy}>
|
||||
<span className={css.modelName}>{level.label}</span>
|
||||
{level.description !== undefined && (
|
||||
<span className={css.description}>{level.description}</span>
|
||||
)}
|
||||
</span>
|
||||
<span className={css.check}>
|
||||
{effectiveEffort === level.effort ? <IconCheckOutline16 /> : null}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{toast !== null && (
|
||||
<Toast
|
||||
key={toast.seq}
|
||||
text={toast.text}
|
||||
icon={<IconWarningOutline16 />}
|
||||
anchor={rootRef.current?.closest<HTMLElement>('[data-composer-card]') ?? null}
|
||||
onDone={() => { setToast(null) }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
154
packages/client/ui-model-selection/src/client/directory.ts
Normal file
154
packages/client/ui-model-selection/src/client/directory.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* Per-session model directory: the ONE state both selection entries share.
|
||||
* The /model popup and the composer-seat selector load through the same
|
||||
* controller and submit through the same selectModel call, so the host stays
|
||||
* the single fact source and the store is one shared echo — a switch made in
|
||||
* either entry is what the other shows next.
|
||||
*/
|
||||
import type {
|
||||
IApiClient, ModelCatalogFailure, ModelProviderGroup, ModelSelection, SessionId, SessionModels,
|
||||
} from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
|
||||
/** Directory snapshot both entries render from. */
|
||||
export interface ModelDirectoryState {
|
||||
/** Model selection the host reports for the next assembled step; null before the first load. */
|
||||
current: ModelSelection | null
|
||||
/**
|
||||
* Whether an adapter serves the current selection's provider, as the host reports
|
||||
* it — null before the first load, which is NOT the same as blocked. Read
|
||||
* this rather than "current matches no group": catalog membership is
|
||||
* advisory, so a route serving a model it stopped advertising is missing
|
||||
* from the groups yet perfectly usable.
|
||||
*/
|
||||
routable: boolean | null
|
||||
/** Successfully loaded provider groups (last good load). */
|
||||
groups: readonly ModelProviderGroup[]
|
||||
/** Provider-local failures from the last load; usable groups stay usable. */
|
||||
failures: readonly ModelCatalogFailure[]
|
||||
/** Lifecycle of the in-flight operation. */
|
||||
status: 'idle' | 'loading' | 'ready' | 'selecting' | 'error'
|
||||
/** Whole-request or selection failure text; null when none. */
|
||||
error: string | null
|
||||
}
|
||||
|
||||
/** One session's shared directory controller; disposed with the session scope. */
|
||||
export class ModelDirectory {
|
||||
/** The shared snapshot both entries render from (uSES-safe store). */
|
||||
readonly store: SnapshotStore<ModelDirectoryState> = createSnapshotStore<ModelDirectoryState>({
|
||||
current: null, routable: null, groups: [], failures: [], status: 'idle', error: null,
|
||||
})
|
||||
|
||||
/** Latest operation wins; an older response never overwrites a newer one. */
|
||||
private generation = 0
|
||||
private disposed = false
|
||||
|
||||
/**
|
||||
* @param sessions - the session wire face (captured from the plugin's root connection).
|
||||
* @param sessionId - the owning session.
|
||||
* @param available - whether this session may use Agent-bound model RPCs.
|
||||
*/
|
||||
constructor(
|
||||
private readonly sessions: Pick<IApiClient['sessions'], 'models' | 'selectModel'>,
|
||||
private readonly sessionId: SessionId,
|
||||
private readonly available: () => boolean,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Refresh the advisory directory (both entries call this on open).
|
||||
* Failure preserves the last good groups and current selection.
|
||||
* @returns the fresh directory value.
|
||||
*/
|
||||
async load(): Promise<SessionModels> {
|
||||
this.assertAvailable()
|
||||
const generation = ++this.generation
|
||||
this.store.update((s) => { s.status = 'loading'; s.error = null })
|
||||
const { result } = await this.sessions.models({ sessionId: this.sessionId })
|
||||
if (this.disposed || generation !== this.generation) {
|
||||
if (!result.ok) throw new Error(`${result.error.code}: ${result.error.message}`)
|
||||
return result.value
|
||||
}
|
||||
if (!result.ok) {
|
||||
this.store.update((s) => { s.status = 'error'; s.error = `${result.error.code}: ${result.error.message}` })
|
||||
throw new Error(`session.models failed: ${result.error.code}: ${result.error.message}`)
|
||||
}
|
||||
const { current, routable, groups, failures } = result.value
|
||||
this.store.update((s) => {
|
||||
s.current = current
|
||||
s.routable = routable
|
||||
s.groups = groups
|
||||
s.failures = failures
|
||||
s.status = 'ready'
|
||||
s.error = null
|
||||
})
|
||||
return result.value
|
||||
}
|
||||
|
||||
/**
|
||||
* Select the complete provider/model/reasoning selection (both entries submit through here). Success
|
||||
* updates the shared current; failure surfaces on the store and throws so
|
||||
* each entry's own retry surface engages.
|
||||
* @param selection - provider, provider-owned model id, and optional adapter-owned effort.
|
||||
*/
|
||||
async select(selection: ModelSelection): Promise<void> {
|
||||
this.assertAvailable()
|
||||
const generation = ++this.generation
|
||||
this.store.update((s) => { s.status = 'selecting'; s.error = null })
|
||||
const { result } = await this.sessions.selectModel({
|
||||
sessionId: this.sessionId,
|
||||
provider: selection.provider,
|
||||
model: selection.model,
|
||||
...selection.reasoningEffort === undefined
|
||||
? {}
|
||||
: { reasoningEffort: selection.reasoningEffort },
|
||||
})
|
||||
if (this.disposed || generation !== this.generation) {
|
||||
if (!result.ok) throw new Error(`${result.error.code}: ${result.error.message}`)
|
||||
return
|
||||
}
|
||||
if (!result.ok) {
|
||||
this.store.update((s) => { s.status = 'error'; s.error = `${result.error.code}: ${result.error.message}` })
|
||||
throw new Error(`session.selectModel failed: ${result.error.code}: ${result.error.message}`)
|
||||
}
|
||||
// The Host validated the route before accepting it, so a selection that
|
||||
// landed is by construction one it can serve.
|
||||
this.store.update((s) => {
|
||||
s.current = result.value.selected
|
||||
s.routable = true
|
||||
s.status = 'ready'
|
||||
s.error = null
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop the previous Host generation's projection and repull it. Clearing
|
||||
* first prevents an unconsumed process-local selection from being displayed
|
||||
* while the restarted Host has restored the last logged model selection.
|
||||
*/
|
||||
resetConnected(): void {
|
||||
if (this.disposed) return
|
||||
++this.generation
|
||||
this.store.update((s) => {
|
||||
s.current = null
|
||||
s.routable = null
|
||||
s.groups = []
|
||||
s.failures = []
|
||||
s.status = 'idle'
|
||||
s.error = null
|
||||
})
|
||||
if (!this.available()) return
|
||||
void this.load().catch(() => { /* the next menu open remains the explicit retry surface */ })
|
||||
}
|
||||
|
||||
/** Scope teardown: late settlements lose write access to the store. */
|
||||
dispose(): void {
|
||||
this.disposed = true
|
||||
}
|
||||
|
||||
private assertAvailable(): void {
|
||||
if (!this.available()) {
|
||||
throw new Error('model selection is unavailable for addressed subagent sessions')
|
||||
}
|
||||
}
|
||||
}
|
||||
176
packages/client/ui-model-selection/src/client/index.ts
Normal file
176
packages/client/ui-model-selection/src/client/index.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
/**
|
||||
* Model selection plugin, browser half — TWO entries over ONE per-session
|
||||
* directory owned by ModelDirectoryResolver (`ctx.modelDirectories`). The /model popupSelect
|
||||
* contribution and the composer's named `conversation.input.model` seat both
|
||||
* load the session's provider-grouped advisory directory (`session.models`)
|
||||
* and submit through `session.selectModel` via the same directory instance,
|
||||
* so the host-reported current selection is the single fact both surfaces echo
|
||||
* — a switch made in either entry is what the other shows next. Failures
|
||||
* ride each entry's own retry surface (popup shell error/retry; seat menu
|
||||
* inline error) without forking the state. Addressed subagent sessions expose
|
||||
* neither entry because those Agent-bound RPCs would activate persisted
|
||||
* history outside the direct-parent continuation path.
|
||||
*/
|
||||
// Type-only: the carrier types, the forwarded Host-event face and the ctx.remote merge.
|
||||
import type { ModelSelection, SessionModels } from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { CommandUiContract, SelectOption } from '@deepseek-ai/dsh-client-ui-commands/client'
|
||||
// Type-only: pulls the ui-conversation SlotMap merge (the input.model seat).
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
// Type-only: pulls the locale plugin's Context merge (ctx.locale).
|
||||
import type {} from '@deepseek-ai/dsh-client-locale/client'
|
||||
import type { TranslateNS } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { ModelDirectoryState } from './directory.ts'
|
||||
import { ModelDirectoryResolver } from './service.ts'
|
||||
import type { ModelSelectInjected } from './slots.ts'
|
||||
import { ModelSelect } from './ModelSelect.tsx'
|
||||
import { en, zh, type ModelKey } from './locales.ts'
|
||||
|
||||
export { ModelDirectory } from './directory.ts'
|
||||
export type { ModelDirectoryState } from './directory.ts'
|
||||
export { ModelDirectoryResolver } from './service.ts'
|
||||
export type { ModelSelectInjected } from './slots.ts'
|
||||
export type { ModelKey } from './locales.ts'
|
||||
|
||||
declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
interface LocaleNamespaceMap {
|
||||
/** The model selection surfaces' copy (/model popup + composer seat). */
|
||||
model: ModelKey
|
||||
}
|
||||
}
|
||||
|
||||
/** One selectable row's id: an opaque row key (resolved by lookup, never parsed). */
|
||||
function rowId(providerId: string, modelId: string): string {
|
||||
return `${providerId}/${modelId}`
|
||||
}
|
||||
|
||||
/** Flatten the directory into popup rows; failure rows are listed for visibility but never selectable. */
|
||||
function optionsOf(directory: SessionModels, t: TranslateNS<'model'>): SelectOption[] {
|
||||
const rows: SelectOption[] = []
|
||||
for (const group of directory.groups) {
|
||||
for (const model of group.models) {
|
||||
rows.push({
|
||||
id: rowId(group.id, model.id),
|
||||
label: model.name,
|
||||
detail: model.description !== undefined ? `${group.name} · ${model.description}` : group.name,
|
||||
...(directory.current.provider === group.id && directory.current.model === model.id
|
||||
? { active: true } : {}),
|
||||
})
|
||||
}
|
||||
}
|
||||
for (const failure of directory.failures) {
|
||||
rows.push({
|
||||
id: `failure/${failure.id}`,
|
||||
label: failure.name,
|
||||
detail: t('option.loadError', { message: failure.message }),
|
||||
})
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a picked row back to its model selection by matching against the loaded
|
||||
* groups (the same data the rows were built from — ids stay opaque).
|
||||
* @param state - the session's directory snapshot.
|
||||
* @param id - the picked row id.
|
||||
* @returns the row's model selection, or undefined for failure rows / stale ids.
|
||||
*/
|
||||
function selectionOf(state: ModelDirectoryState, id: string): ModelSelection | undefined {
|
||||
for (const group of state.groups) {
|
||||
for (const model of group.models) {
|
||||
if (rowId(group.id, model.id) !== id) continue
|
||||
const sameRoute = state.current?.provider === group.id && state.current.model === model.id
|
||||
const reasoningEffort = sameRoute
|
||||
? state.current?.reasoningEffort ?? model.reasoning?.defaultEffort
|
||||
: model.reasoning?.defaultEffort
|
||||
return {
|
||||
provider: group.id,
|
||||
model: model.id,
|
||||
...reasoningEffort === undefined ? {} : { reasoningEffort },
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** Dictionary namespace owned by this plugin. */
|
||||
const NS = 'model'
|
||||
|
||||
/** Required services: the contribution registry, the seat's slot registry, locale, and the service's own faces. */
|
||||
export const inject = ['commandUi', 'connection', 'locale', 'sessions', 'slots', 'remote']
|
||||
|
||||
/**
|
||||
* Client plugin body: mount ModelDirectoryResolver, register the `model` dictionaries,
|
||||
* then register the /model popup contribution and the composer model seat
|
||||
* over the service.
|
||||
* @param ctx - client root context.
|
||||
*/
|
||||
export function apply(ctx: ClientContext): void {
|
||||
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-model-selection: dictionaries')
|
||||
|
||||
// Non-slot faces (the command description, the popup option builder) read
|
||||
// through the bound translate; the seat component reads the standard seat.
|
||||
const t = ctx.locale.bind(NS)
|
||||
|
||||
// The composer-block reason is this plugin's own copy, read at raise time so
|
||||
// a locale change reaches the next publish.
|
||||
ctx.plugin(ModelDirectoryResolver, { blockReason: () => t('blocked.composer') })
|
||||
|
||||
// Entry 1: the /model popupSelect over the shared directory. The command
|
||||
// description is registry-held text: it reads t() once at registration and
|
||||
// refreshes only on re-registration, not on locale change.
|
||||
ctx.inject(['commandUi', 'modelDirectories'], (scope: ClientContext) => {
|
||||
const command = scope.get('commandUi') as CommandUiContract
|
||||
const models = scope.modelDirectories
|
||||
const sessions = scope.sessions
|
||||
scope.effect(() => command.register({
|
||||
name: 'model',
|
||||
description: t('command.description'),
|
||||
available: session => sessions.subagentAddress(session.sessionId) === undefined,
|
||||
ui: {
|
||||
kind: 'popupSelect',
|
||||
options: async (session) => {
|
||||
if (sessions.subagentAddress(session.sessionId) !== undefined) {
|
||||
throw new Error('model selection is unavailable for addressed subagent sessions')
|
||||
}
|
||||
return optionsOf(await models.directoryFor(session.sessionId).load(), t)
|
||||
},
|
||||
onSelect: async (option, session) => {
|
||||
if (sessions.subagentAddress(session.sessionId) !== undefined) {
|
||||
throw new Error('model selection is unavailable for addressed subagent sessions')
|
||||
}
|
||||
const directory = models.directoryFor(session.sessionId)
|
||||
const selection = selectionOf(directory.store.getSnapshot(), option.id)
|
||||
if (selection === undefined) {
|
||||
throw new Error('this provider\'s catalog failed to load — pick a model from a loaded group')
|
||||
}
|
||||
await directory.select(selection)
|
||||
},
|
||||
},
|
||||
}), 'ui-model-selection: /model contribution')
|
||||
})
|
||||
|
||||
// Entry 2: the composer's named model seat over the SAME directory.
|
||||
ctx.inject(['slots', 'modelDirectories'], (scope: ClientContext) => {
|
||||
const models = scope.modelDirectories
|
||||
const sessions = scope.sessions
|
||||
scope.slots.inject('conversation.input.model', () => scope.slots.register({
|
||||
name: 'conversation.input.model',
|
||||
locale: NS,
|
||||
inject: (sessionId): ModelSelectInjected => {
|
||||
const directory = models.directoryFor(sessionId)
|
||||
const available = sessions.subagentAddress(sessionId) === undefined
|
||||
return {
|
||||
available,
|
||||
directory: directory.store,
|
||||
load: () => {
|
||||
if (available) directory.load().catch(() => { /* surfaced on the store */ })
|
||||
},
|
||||
select: (selection: ModelSelection) => available
|
||||
? directory.select(selection).then(() => true, () => false)
|
||||
: Promise.resolve(false),
|
||||
}
|
||||
},
|
||||
}, ModelSelect))
|
||||
})
|
||||
}
|
||||
54
packages/client/ui-model-selection/src/client/locales.ts
Normal file
54
packages/client/ui-model-selection/src/client/locales.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* `model` namespace dictionaries.
|
||||
*
|
||||
* `trigger.selectAria` reads identically to `trigger.fallback` today and is
|
||||
* still a separate key: the visible fallback label and the accessible name of
|
||||
* an unset trigger are free to diverge per locale, and folding it into
|
||||
* `trigger.aria` would announce the degenerate "Select model, current Select
|
||||
* model".
|
||||
*/
|
||||
|
||||
/** Simplified Chinese dictionary (the key-set source of truth). */
|
||||
export const zh = {
|
||||
'command.description': '选择本会话使用的模型',
|
||||
'option.loadError': '目录加载失败:{message}',
|
||||
'trigger.fallback': '选择模型',
|
||||
'trigger.selectAria': '选择模型',
|
||||
'trigger.aria': '选择模型,当前 {model}',
|
||||
'trigger.ariaEffort': '选择模型,当前 {model},推理等级 {effort}',
|
||||
'menu.aria': '模型与推理等级',
|
||||
'menu.model': '模型',
|
||||
'menu.effort': '推理等级',
|
||||
'effort.providerDefault': 'Default',
|
||||
'status.loading': '正在刷新模型列表…',
|
||||
'error.action': '模型操作失败:{message}',
|
||||
'action.reload': '重新加载',
|
||||
'warning.groupLoad': '{name} 加载失败:{message}',
|
||||
'empty.models': '没有可用的模型。',
|
||||
'blocked.composer': '当前模型不可用,请先选择模型',
|
||||
'empty.efforts': '当前模型未提供推理等级。',
|
||||
} satisfies Record<string, string>
|
||||
|
||||
/** The model namespace key union. */
|
||||
export type ModelKey = keyof typeof zh
|
||||
|
||||
/** English dictionary, checked complete against the zh key set. */
|
||||
export const en = {
|
||||
'command.description': 'Select the model for this conversation',
|
||||
'option.loadError': 'Catalog failed to load: {message}',
|
||||
'trigger.fallback': 'Select model',
|
||||
'trigger.selectAria': 'Select model',
|
||||
'trigger.aria': 'Select model, current {model}',
|
||||
'trigger.ariaEffort': 'Select model, current {model}, reasoning effort {effort}',
|
||||
'menu.aria': 'Model and reasoning effort',
|
||||
'menu.model': 'Model',
|
||||
'menu.effort': 'Effort',
|
||||
'effort.providerDefault': 'Default',
|
||||
'status.loading': 'Refreshing model list…',
|
||||
'error.action': 'Model operation failed: {message}',
|
||||
'action.reload': 'Reload',
|
||||
'warning.groupLoad': '{name} failed to load: {message}',
|
||||
'empty.models': 'No models available.',
|
||||
'blocked.composer': 'This model is unavailable — select one to continue',
|
||||
'empty.efforts': 'This model provides no reasoning effort levels.',
|
||||
} satisfies Record<ModelKey, string>
|
||||
110
packages/client/ui-model-selection/src/client/service.ts
Normal file
110
packages/client/ui-model-selection/src/client/service.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* ModelDirectoryResolver (`ctx.modelDirectories`): the root owner of per-session
|
||||
* {@link ModelDirectory} instances. Both selection entries (the /model popup
|
||||
* and the composer model seat) resolve their session's directory through
|
||||
* this service, which is what makes the dual entry one shared state.
|
||||
*
|
||||
* Per-session storage follows the client service pattern (InputTriggerService /
|
||||
* CommandUiRuntime): a lazy service-internal map whose entry is deleted by the
|
||||
* owning scope's disposer. The host `dsh-scope` ScopedLayers registry does
|
||||
* does not belong here: it derives scope from the host carrier mechanism
|
||||
* (object-keyed), while client scopes tag contexts with branded SessionId
|
||||
* strings, and it models global+shadow named registries — this is a
|
||||
* per-session singleton with no global layer to merge.
|
||||
*/
|
||||
import { Service } from '@deepseek-ai/cordis'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { ConnectionHandle, SessionId } from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import type { SessionRuntime } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { ModelDirectory } from './directory.ts'
|
||||
|
||||
declare module '@deepseek-ai/cordis' {
|
||||
interface Context {
|
||||
modelDirectories: ModelDirectoryResolver
|
||||
}
|
||||
}
|
||||
|
||||
/** Live mutable state in one holder (service methods run behind the caller-ctx tracker). */
|
||||
interface LiveState {
|
||||
/** Per-session directories; entries are deleted by their scope disposer. */
|
||||
readonly directories: Map<SessionId, ModelDirectory>
|
||||
}
|
||||
|
||||
/** The `ctx.modelDirectories` session model-selection service. */
|
||||
export class ModelDirectoryResolver extends Service {
|
||||
static inject = ['connection', 'sessions', 'remote']
|
||||
|
||||
private readonly live: LiveState = { directories: new Map() }
|
||||
|
||||
/** Localized composer-block copy; this plugin owns the string it raises. */
|
||||
private readonly blockReason: () => string
|
||||
|
||||
/**
|
||||
* @param ctx - owning root context (the service registers itself as `models`).
|
||||
* @param config - the bound translator for this plugin's own dictionary.
|
||||
*/
|
||||
constructor(ctx: Context, config: { blockReason: () => string }) {
|
||||
super(ctx, 'modelDirectories')
|
||||
this.blockReason = config.blockReason
|
||||
ctx.on('connection/reset', () => {
|
||||
for (const directory of this.live.directories.values()) directory.resetConnected()
|
||||
})
|
||||
// Either source can change the directory: registry topology commits and
|
||||
// settings documents that carry provider catalogs or default selection.
|
||||
const refresh = (): void => {
|
||||
for (const directory of this.live.directories.values()) {
|
||||
directory.load().catch(() => undefined)
|
||||
}
|
||||
}
|
||||
ctx.remote.$on('llm/adapters-updated', refresh)
|
||||
ctx.remote.$on('settings/document-updated', refresh)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the per-session shared directory (lazy; the scope disposer
|
||||
* removes and disposes it). Unknown sessions fail loud.
|
||||
* @param sessionId - the owning session.
|
||||
* @returns the resident directory both entries share.
|
||||
*/
|
||||
directoryFor(sessionId: SessionId): ModelDirectory {
|
||||
const { live } = this
|
||||
const existing = live.directories.get(sessionId)
|
||||
if (existing !== undefined) return existing
|
||||
const sessions = this.ctx.get('sessions') as SessionRuntime
|
||||
const actx = sessions.scope(sessionId)
|
||||
if (actx === undefined) throw new Error(`ui-model-selection: session "${String(sessionId)}" resolved no scope`)
|
||||
const connection = this.ctx.get('connection') as ConnectionHandle
|
||||
const directory = new ModelDirectory(
|
||||
connection.api.sessions,
|
||||
sessionId,
|
||||
() => sessions.subagentAddress(sessionId) === undefined,
|
||||
)
|
||||
live.directories.set(sessionId, directory)
|
||||
// The composer cannot read this plugin (the dependency runs one way), so
|
||||
// the block is pushed: the Host says whether an adapter serves the
|
||||
// session's route, and only a definite `false` makes the input inert.
|
||||
// `null` — before the first load, or after one failed — must not, or a
|
||||
// slow or unreachable Host would lock a working composer.
|
||||
const conversation = this.ctx.get('conversation')
|
||||
if (conversation !== undefined) {
|
||||
const publish = (): void => {
|
||||
conversation.blocks.set(sessionId, directory.store.getSnapshot().routable === false
|
||||
? { reason: this.blockReason() }
|
||||
: undefined)
|
||||
}
|
||||
publish()
|
||||
actx.effect(() => {
|
||||
const stop = directory.store.subscribe(publish)
|
||||
return () => {
|
||||
stop()
|
||||
conversation.blocks.set(sessionId, undefined)
|
||||
}
|
||||
}, 'ui-model-selection: composer block')
|
||||
}
|
||||
actx.effect(() => () => {
|
||||
directory.dispose()
|
||||
live.directories.delete(sessionId)
|
||||
}, 'ui-model-selection: session directory')
|
||||
return directory
|
||||
}
|
||||
}
|
||||
25
packages/client/ui-model-selection/src/client/slots.ts
Normal file
25
packages/client/ui-model-selection/src/client/slots.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* ModelSelect's injected face. The target 'conversation.input.model' seat is
|
||||
* declared (children table) and typed by ui-conversation's composer-bar
|
||||
* entry; this package only contributes the single occupant, so no SlotMap
|
||||
* merge lives here.
|
||||
*/
|
||||
import type { ModelSelection } from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ModelDirectoryState } from './directory.ts'
|
||||
|
||||
/** Injected business face of the composer model seat. */
|
||||
export interface ModelSelectInjected {
|
||||
/** Whether this session supports Agent-bound model inspection and selection. */
|
||||
available: boolean
|
||||
/** The session's shared directory store (same instance the /model popup reads). */
|
||||
directory: SnapshotStore<ModelDirectoryState>
|
||||
/** Refresh the advisory directory (fire-and-forget; errors land on the store). */
|
||||
load: () => void
|
||||
/**
|
||||
* Select a complete provider/model/reasoning selection.
|
||||
* @param selection - model selection and optional adapter-owned effort.
|
||||
* @returns whether the host accepted the selection.
|
||||
*/
|
||||
select: (selection: ModelSelection) => Promise<boolean>
|
||||
}
|
||||
6
packages/client/ui-model-selection/src/css-modules.d.ts
vendored
Normal file
6
packages/client/ui-model-selection/src/css-modules.d.ts
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
declare module '*.module.css' {
|
||||
const classes: Record<string, string>
|
||||
export default classes
|
||||
}
|
||||
|
||||
declare module '*.css'
|
||||
9
packages/client/ui-model-selection/src/index.ts
Normal file
9
packages/client/ui-model-selection/src/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Model selection plugin, node half. Pure UI plugin: the empty apply exists
|
||||
* so the plugin appears in the host cordis.yml / Loader; the browser half
|
||||
* ships via exports["./client"], discovered through the package.json
|
||||
* dsh.client declaration.
|
||||
*/
|
||||
|
||||
/** Host plugin body — no host-side behavior for this surface plugin. */
|
||||
export function apply(): void {}
|
||||
31
packages/client/ui-model-selection/src/invariant.ts
Normal file
31
packages/client/ui-model-selection/src/invariant.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-client-ui-model-selection`.
|
||||
* @module @deepseek-ai/dsh-client-ui-model-selection/invariant
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-model-selection'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'client-ui-model-selection-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: a single command contribution registration whose disposal is
|
||||
* proven by the HMR-safety spec — it emits no cordis events and owns no
|
||||
* cross-plugin mutable state.
|
||||
*/
|
||||
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 */
|
||||
@@ -0,0 +1,323 @@
|
||||
/**
|
||||
* ui-model-selection browser half on a real cordis Context with fake command/slots/
|
||||
* connection faces and real session scopes: the plugin mounts ModelDirectoryResolver
|
||||
* as `models`, the /model contribution and the conversation.input.model
|
||||
* seat both register, and BOTH entries resolve the SAME per-session
|
||||
* directory through the service — a selection submitted through the seat's
|
||||
* inject face is the current the popup's next options pass marks active
|
||||
* (and the reverse), the one-shared-state contract of the dual entry.
|
||||
* Scope disposal drops the directory (HMR safety).
|
||||
*/
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createScope } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import { TestRemote } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import type { ModelSelection } from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import type { CommandContribution, SelectOption } from '@deepseek-ai/dsh-client-ui-commands/client'
|
||||
import type { ModelSelectInjected } from '../src/client/slots.ts'
|
||||
import { apply, inject } from '../src/client/index.ts'
|
||||
import { zh } from '../src/client/locales.ts'
|
||||
|
||||
const sid = (k: string): SessionId => k as SessionId
|
||||
|
||||
const GROUPS = [{
|
||||
id: 'deepseek-official',
|
||||
name: 'DeepSeek',
|
||||
models: [
|
||||
{
|
||||
id: 'deepseek-v4-flash',
|
||||
name: 'DeepSeek-V4-Flash',
|
||||
reasoning: {
|
||||
efforts: [
|
||||
{ id: 'off', name: 'Off' },
|
||||
{ id: 'high', name: 'High' },
|
||||
{ id: 'max', name: 'Max' },
|
||||
],
|
||||
defaultEffort: 'high',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'deepseek-v4-pro',
|
||||
name: 'DeepSeek-V4-Pro',
|
||||
reasoning: {
|
||||
efforts: [
|
||||
{ id: 'off', name: 'Off' },
|
||||
{ id: 'high', name: 'High' },
|
||||
{ id: 'max', name: 'Max' },
|
||||
],
|
||||
defaultEffort: 'high',
|
||||
},
|
||||
},
|
||||
],
|
||||
}]
|
||||
|
||||
/** Boot the plugin over fake faces + a stateful fake host (current moves on selectModel). */
|
||||
async function bench() {
|
||||
const ctx = new Context()
|
||||
let current: ModelSelection = { provider: 'deepseek-official', model: 'deepseek-v4-flash' }
|
||||
const calls = { models: 0, select: 0 }
|
||||
ctx.provide('connection', { api: { sessions: {
|
||||
models: () => {
|
||||
calls.models += 1
|
||||
return Promise.resolve({
|
||||
result: { ok: true as const, value: { current, routable, groups: GROUPS, failures: [] } },
|
||||
})
|
||||
},
|
||||
selectModel: (payload: { provider: string; model: string; reasoningEffort?: string }) => {
|
||||
calls.select += 1
|
||||
current = {
|
||||
provider: payload.provider,
|
||||
model: payload.model,
|
||||
...payload.reasoningEffort === undefined
|
||||
? {}
|
||||
: { reasoningEffort: payload.reasoningEffort },
|
||||
}
|
||||
return Promise.resolve({ result: { ok: true as const, value: { selected: current } } })
|
||||
},
|
||||
} } })
|
||||
// Whether the Host reports an adapter for the current route; the composer
|
||||
// block follows this, never catalog membership.
|
||||
let routable = true
|
||||
const blocks = new Map<SessionId, { reason: string } | undefined>()
|
||||
ctx.provide('conversation', {
|
||||
blocks: {
|
||||
set: (id: SessionId, block: { reason: string } | undefined) => { blocks.set(id, block) },
|
||||
},
|
||||
})
|
||||
let contribution: CommandContribution | undefined
|
||||
ctx.provide('commandUi', {
|
||||
register(c: CommandContribution) {
|
||||
contribution = c
|
||||
return () => { contribution = undefined }
|
||||
},
|
||||
})
|
||||
const seats = new Map<string, {
|
||||
inject: ((sessionId: SessionId) => ModelSelectInjected) | undefined
|
||||
locale: string | undefined
|
||||
}>()
|
||||
ctx.provide('slots', {
|
||||
inject(_name: string, callback: () => () => void) { return callback() },
|
||||
register(options: { name: string; locale?: string; inject?: (sessionId: SessionId) => ModelSelectInjected }) {
|
||||
seats.set(options.name, { inject: options.inject, locale: options.locale })
|
||||
return () => { seats.delete(options.name) }
|
||||
},
|
||||
})
|
||||
ctx.provide('locale', new LocaleRuntime(ctx))
|
||||
const scopes = new Map<SessionId, Context>()
|
||||
const addressed = new Set<SessionId>()
|
||||
ctx.provide('sessions', {
|
||||
scope: (id: SessionId) => scopes.get(id),
|
||||
subagentAddress: (id: SessionId) => addressed.has(id)
|
||||
? { parentSessionId: sid('parent'), childSessionId: id, mode: 'continuable' as const }
|
||||
: undefined,
|
||||
})
|
||||
new TestRemote(ctx)
|
||||
const fiber = ctx.plugin({ inject: [...inject], apply })
|
||||
await fiber.await()
|
||||
await ctx.plugin(function probe() {}).await()
|
||||
const mint = (key: string) => {
|
||||
const handle = createScope(ctx, sid(key))
|
||||
scopes.set(sid(key), handle.ctx)
|
||||
return handle
|
||||
}
|
||||
return {
|
||||
ctx, fiber, mint, calls,
|
||||
contribution: () => contribution!,
|
||||
seat: () => seats.get('conversation.input.model')!,
|
||||
hostCurrent: () => current,
|
||||
setHostCurrent: (selection: ModelSelection) => { current = selection },
|
||||
address: (id: SessionId) => { addressed.add(id) },
|
||||
setRoutable: (next: boolean) => { routable = next },
|
||||
blockOf: (key: string) => blocks.get(sid(key)),
|
||||
}
|
||||
}
|
||||
|
||||
const projection = (id: string) => ({ sessionId: sid(id) })
|
||||
|
||||
describe('ui-model-selection dual entry', () => {
|
||||
it('registers the /model contribution and the composer model seat', async () => {
|
||||
const b = await bench()
|
||||
expect(b.contribution().name).toBe('model')
|
||||
expect(b.contribution().ui.kind).toBe('popupSelect')
|
||||
expect(b.seat().inject).toBeTypeOf('function')
|
||||
// Copy rides the standard locale seat.
|
||||
expect(b.seat().locale).toBe('model')
|
||||
})
|
||||
|
||||
it('popup options mark the host current active with the provider group in the detail', async () => {
|
||||
const b = await bench()
|
||||
b.mint('s1')
|
||||
const options = await b.contribution().ui.options(projection('s1'), new AbortController().signal)
|
||||
expect(options.map((o: SelectOption) => o.label)).toEqual(['DeepSeek-V4-Flash', 'DeepSeek-V4-Pro'])
|
||||
expect(options[0]).toMatchObject({ active: true, detail: 'DeepSeek' })
|
||||
expect(options[1]?.active).toBeUndefined()
|
||||
})
|
||||
|
||||
it('a seat selection is the current the popup marks active next — one shared state', async () => {
|
||||
const b = await bench()
|
||||
b.mint('s1')
|
||||
const seatFace = b.seat().inject!(sid('s1'))
|
||||
// Switch through the SEAT entry.
|
||||
expect(await seatFace.select({
|
||||
provider: 'deepseek-official',
|
||||
model: 'deepseek-v4-pro',
|
||||
reasoningEffort: 'max',
|
||||
})).toBe(true)
|
||||
expect(b.hostCurrent()).toEqual({
|
||||
provider: 'deepseek-official',
|
||||
model: 'deepseek-v4-pro',
|
||||
reasoningEffort: 'max',
|
||||
})
|
||||
expect(seatFace.directory.getSnapshot().current).toEqual({
|
||||
provider: 'deepseek-official',
|
||||
model: 'deepseek-v4-pro',
|
||||
reasoningEffort: 'max',
|
||||
})
|
||||
// The POPUP's next options pass reflects it without a seat-side reload.
|
||||
const options = await b.contribution().ui.options(projection('s1'), new AbortController().signal)
|
||||
expect(options.find((o: SelectOption) => o.label === 'DeepSeek-V4-Pro')).toMatchObject({ active: true })
|
||||
})
|
||||
|
||||
it('a popup selection lands on the seat store — the reverse direction of the same state', async () => {
|
||||
const b = await bench()
|
||||
b.mint('s1')
|
||||
const seatFace = b.seat().inject!(sid('s1'))
|
||||
const options = await b.contribution().ui.options(projection('s1'), new AbortController().signal)
|
||||
const pro = options.find((o: SelectOption) => o.label === 'DeepSeek-V4-Pro')!
|
||||
await b.contribution().ui.onSelect(pro, projection('s1'))
|
||||
expect(seatFace.directory.getSnapshot().current).toEqual({
|
||||
provider: 'deepseek-official',
|
||||
model: 'deepseek-v4-pro',
|
||||
reasoningEffort: 'high',
|
||||
})
|
||||
})
|
||||
|
||||
it('both entries share one directory instance per session, isolated across sessions', async () => {
|
||||
const b = await bench()
|
||||
b.mint('a')
|
||||
b.mint('b')
|
||||
const faceA = b.seat().inject!(sid('a'))
|
||||
const faceA2 = b.seat().inject!(sid('a'))
|
||||
const faceB = b.seat().inject!(sid('b'))
|
||||
expect(faceA.directory).toBe(faceA2.directory)
|
||||
expect(faceA.directory).not.toBe(faceB.directory)
|
||||
// The service face resolves the same instance the seat inject handed out.
|
||||
expect(b.ctx.modelDirectories.directoryFor(sid('a')).store).toBe(faceA.directory)
|
||||
})
|
||||
|
||||
it('drops an unconsumed local selection and restores the Host target after reconnect', async () => {
|
||||
const b = await bench()
|
||||
b.mint('s1')
|
||||
const face = b.seat().inject!(sid('s1'))
|
||||
await face.select({ provider: 'deepseek-official', model: 'deepseek-v4-pro' })
|
||||
b.setHostCurrent({ provider: 'deepseek-official', model: 'deepseek-v4-flash' })
|
||||
|
||||
b.ctx.emit('connection/reset')
|
||||
expect(face.directory.getSnapshot()).toMatchObject({ current: null, status: 'loading' })
|
||||
await Promise.resolve()
|
||||
expect(face.directory.getSnapshot()).toMatchObject({
|
||||
current: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
|
||||
status: 'ready',
|
||||
})
|
||||
})
|
||||
|
||||
it('scope disposal drops the directory; a reborn scope gets a fresh one', async () => {
|
||||
const b = await bench()
|
||||
const first = b.mint('s1')
|
||||
const face1 = b.seat().inject!(sid('s1'))
|
||||
await first.fiber.dispose()
|
||||
b.mint('s1')
|
||||
const face2 = b.seat().inject!(sid('s1'))
|
||||
expect(face2.directory).not.toBe(face1.directory)
|
||||
})
|
||||
|
||||
it('blocks the composer only once the Host reports the route unservable', async () => {
|
||||
const b = await bench()
|
||||
b.mint('s1')
|
||||
const face = b.seat().inject!(sid('s1'))
|
||||
|
||||
// Before the first load nothing is known. `null` is not `false`: a slow
|
||||
// or unreachable Host must never lock a working composer.
|
||||
expect(b.blockOf('s1')).toBeUndefined()
|
||||
face.load()
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
expect(b.blockOf('s1')).toBeUndefined()
|
||||
|
||||
b.setRoutable(false)
|
||||
b.ctx.remote.$dispatch('llm/adapters-updated', [])
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
expect(b.blockOf('s1')?.reason).toBe(zh['blocked.composer'])
|
||||
|
||||
// Recovering clears it without a reload of the surface.
|
||||
b.setRoutable(true)
|
||||
b.ctx.remote.$dispatch('settings/document-updated', ['llm-deepseek', 1])
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
expect(b.blockOf('s1')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('never blocks on catalog membership alone', async () => {
|
||||
const b = await bench()
|
||||
b.mint('s1')
|
||||
const face = b.seat().inject!(sid('s1'))
|
||||
// A model the route serves but no longer advertises: the seat prompts for
|
||||
// a selection, the composer stays usable. Blocking here would break a
|
||||
// supported configuration (a narrowed `models` list over a live route).
|
||||
b.setHostCurrent({ provider: 'deepseek-official', model: 'unlisted' })
|
||||
face.load()
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
const snapshot = face.directory.getSnapshot()
|
||||
expect(snapshot.groups.flatMap(group => group.models.map(model => model.id))).not.toContain('unlisted')
|
||||
expect(b.blockOf('s1')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('clears its block when the session scope goes', async () => {
|
||||
const b = await bench()
|
||||
const scope = b.mint('s1')
|
||||
b.setRoutable(false)
|
||||
const face = b.seat().inject!(sid('s1'))
|
||||
face.load()
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
expect(b.blockOf('s1')).toBeDefined()
|
||||
|
||||
await scope.fiber.dispose()
|
||||
expect(b.blockOf('s1')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('an unknown session fails loud at the seat inject', async () => {
|
||||
const b = await bench()
|
||||
expect(() => b.seat().inject!(sid('ghost'))).toThrow(/resolved no scope/)
|
||||
})
|
||||
|
||||
it('withholds both model entries from addressed subagent sessions without Agent-bound RPCs', async () => {
|
||||
const b = await bench()
|
||||
b.mint('child')
|
||||
b.address(sid('child'))
|
||||
|
||||
expect(b.contribution().available(projection('child'))).toBe(false)
|
||||
await expect(b.contribution().ui.options(
|
||||
projection('child'),
|
||||
new AbortController().signal,
|
||||
)).rejects.toThrow(/unavailable for addressed subagent/)
|
||||
|
||||
const face = b.seat().inject!(sid('child'))
|
||||
expect(face.available).toBe(false)
|
||||
face.load()
|
||||
await expect(face.select({ provider: 'deepseek', model: 'deepseek-v4-pro' })).resolves.toBe(false)
|
||||
await expect(b.ctx.modelDirectories.directoryFor(sid('child')).load())
|
||||
.rejects.toThrow(/unavailable for addressed subagent/)
|
||||
await expect(b.ctx.modelDirectories.directoryFor(sid('child')).select({
|
||||
provider: 'deepseek',
|
||||
model: 'deepseek-v4-pro',
|
||||
})).rejects.toThrow(/unavailable for addressed subagent/)
|
||||
b.ctx.emit('connection/reset')
|
||||
await Promise.resolve()
|
||||
expect(b.calls).toEqual({ models: 0, select: 0 })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,184 @@
|
||||
// @vitest-environment jsdom
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { ModelSelection } from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ComponentProps } from 'react'
|
||||
import type { ModelDirectoryState } from '../src/client/directory.ts'
|
||||
import { ModelSelect } from '../src/client/ModelSelect.tsx'
|
||||
import { zh } from '../src/client/locales.ts'
|
||||
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
|
||||
|
||||
// The seat's key domain is model ∪ common; the stub mirrors the real lookup
|
||||
// chain: package dictionary, then common vocabulary, then the key.
|
||||
const t: ComponentProps<typeof ModelSelect>['t'] = (key, params) => {
|
||||
const template = (zh as Record<string, string>)[key]
|
||||
?? (commonZh as Record<string, string>)[key]
|
||||
?? key
|
||||
return params === undefined
|
||||
? template
|
||||
: template.replace(/\{(\w+)\}/g, (match, name: string) => name in params ? String(params[name]) : match)
|
||||
}
|
||||
|
||||
const reasoning = {
|
||||
efforts: [
|
||||
{ id: 'off', name: 'Off' },
|
||||
{ id: 'high', name: 'High' },
|
||||
{ id: 'max', name: 'Max', description: 'Largest budget' },
|
||||
],
|
||||
defaultEffort: 'high',
|
||||
}
|
||||
|
||||
function state(overrides: Partial<ModelDirectoryState> = {}): ModelDirectoryState {
|
||||
return {
|
||||
current: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
|
||||
routable: true,
|
||||
groups: [{
|
||||
id: 'deepseek-official',
|
||||
name: 'DeepSeek',
|
||||
models: [{ id: 'deepseek-v4-flash', name: 'DeepSeek-V4-Flash', reasoning }],
|
||||
}],
|
||||
failures: [],
|
||||
status: 'ready',
|
||||
error: null,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
describe('ModelSelect reasoning effort', () => {
|
||||
it('renders adapter metadata and submits the effort as part of the session selection', async () => {
|
||||
const directory = createSnapshotStore<ModelDirectoryState>(state())
|
||||
const select = vi.fn(async (selection: ModelSelection) => {
|
||||
directory.set(state({ current: selection }))
|
||||
return true
|
||||
})
|
||||
render(<ModelSelect
|
||||
locked={false}
|
||||
available
|
||||
directory={directory}
|
||||
load={vi.fn()}
|
||||
select={select}
|
||||
t={t}
|
||||
/>)
|
||||
|
||||
const trigger = screen.getByRole('button', {
|
||||
name: '选择模型,当前 DeepSeek-V4-Flash,推理等级 High',
|
||||
})
|
||||
fireEvent.click(trigger)
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: /推理等级/ }))
|
||||
expect(screen.getAllByRole('menuitemradio').map(item => item.textContent))
|
||||
.toEqual(['Off', 'High', 'MaxLargest budget'])
|
||||
|
||||
fireEvent.click(screen.getByRole('menuitemradio', { name: /Max/ }))
|
||||
await waitFor(() => {
|
||||
expect(select).toHaveBeenCalledWith({
|
||||
provider: 'deepseek-official',
|
||||
model: 'deepseek-v4-flash',
|
||||
reasoningEffort: 'max',
|
||||
})
|
||||
expect(trigger.getAttribute('aria-label')).toBe('选择模型,当前 DeepSeek-V4-Flash,推理等级 Max')
|
||||
})
|
||||
})
|
||||
|
||||
it('offers provider default only when the adapter does not configure a model default', () => {
|
||||
const directory = createSnapshotStore(state({
|
||||
groups: [{
|
||||
id: 'provider',
|
||||
name: 'Provider',
|
||||
models: [{
|
||||
id: 'model',
|
||||
name: 'Model',
|
||||
reasoning: { efforts: [{ id: 'standard', name: 'Standard' }] },
|
||||
}],
|
||||
}],
|
||||
current: { provider: 'provider', model: 'model' },
|
||||
}))
|
||||
render(<ModelSelect
|
||||
locked={false}
|
||||
available
|
||||
directory={directory}
|
||||
load={vi.fn()}
|
||||
select={vi.fn().mockResolvedValue(true)}
|
||||
t={t}
|
||||
/>)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', {
|
||||
name: '选择模型,当前 Model,推理等级 Default',
|
||||
}))
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: /推理等级/ }))
|
||||
expect(screen.getAllByRole('menuitemradio').map(item => item.textContent))
|
||||
.toEqual(['Default', 'Standard'])
|
||||
})
|
||||
|
||||
it('prompts for a selection when the current model is no longer advertised', () => {
|
||||
const directory = createSnapshotStore(state({
|
||||
current: { provider: 'deepseek-official', model: 'removed-model' },
|
||||
}))
|
||||
const select = vi.fn().mockResolvedValue(true)
|
||||
render(<ModelSelect
|
||||
locked={false}
|
||||
available
|
||||
directory={directory}
|
||||
load={vi.fn()}
|
||||
select={select}
|
||||
t={t}
|
||||
/>)
|
||||
|
||||
const trigger = screen.getByRole('button', { name: '选择模型' })
|
||||
expect(trigger.textContent).toContain('选择模型')
|
||||
fireEvent.click(trigger)
|
||||
expect(screen.queryByRole('menuitem', { name: /推理等级/ })).toBeNull()
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: /模型/ }))
|
||||
expect(screen.queryByText('removed-model')).toBeNull()
|
||||
expect(screen.getByRole('menuitemradio', { name: 'DeepSeek-V4-Flash' })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('announces a rejected selection as a transient toast and keeps the in-menu strip for loads', async () => {
|
||||
const groups = [{
|
||||
id: 'deepseek-official',
|
||||
name: 'DeepSeek',
|
||||
models: [
|
||||
{ id: 'deepseek-v4-flash', name: 'DeepSeek-V4-Flash', reasoning },
|
||||
{ id: 'deepseek-v4-pro', name: 'DeepSeek-V4-Pro' },
|
||||
],
|
||||
}]
|
||||
const directory = createSnapshotStore<ModelDirectoryState>(state({ groups }))
|
||||
const select = vi.fn(async () => {
|
||||
directory.set(state({ groups, status: 'error', error: 'model-unavailable: session already contains images' }))
|
||||
return false
|
||||
})
|
||||
render(<ModelSelect
|
||||
locked={false}
|
||||
available
|
||||
directory={directory}
|
||||
load={vi.fn()}
|
||||
select={select}
|
||||
t={t}
|
||||
/>)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /选择模型|当前/ }))
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: /模型/ }))
|
||||
fireEvent.click(screen.getByRole('menuitemradio', { name: /DeepSeek-V4-Pro/ }))
|
||||
const toast = await screen.findByRole('alert')
|
||||
expect(toast.textContent).toContain('模型操作失败:model-unavailable: session already contains images')
|
||||
// The selection failure does not render the in-menu load strip (no Retry).
|
||||
expect(screen.queryByRole('button', { name: '重试' })).toBeNull()
|
||||
})
|
||||
|
||||
it('renders no Agent-bound control for an addressed subagent session', () => {
|
||||
const load = vi.fn()
|
||||
render(<ModelSelect
|
||||
locked={false}
|
||||
available={false}
|
||||
directory={createSnapshotStore(state())}
|
||||
load={load}
|
||||
select={vi.fn().mockResolvedValue(false)}
|
||||
t={t}
|
||||
/>)
|
||||
|
||||
expect(screen.queryByRole('button')).toBeNull()
|
||||
expect(load).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
45
packages/client/ui-model-selection/tsconfig.json
Normal file
45
packages/client/ui-model-selection/tsconfig.json
Normal file
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.client.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../api/remotes/tsconfig.client.json"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../locale"
|
||||
},
|
||||
{
|
||||
"path": "../runtime"
|
||||
},
|
||||
{
|
||||
"path": "../ui-commands"
|
||||
},
|
||||
{
|
||||
"path": "../ui-conversation"
|
||||
},
|
||||
{
|
||||
"path": "../ui-primitives"
|
||||
},
|
||||
{
|
||||
"path": "../ui-input-trigger"
|
||||
},
|
||||
{
|
||||
"path": "../ui-slots"
|
||||
},
|
||||
{
|
||||
"path": "../../runtime-diagnostics/invariants"
|
||||
},
|
||||
{
|
||||
"path": "../../api/remotes/tsconfig.client.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
3
packages/client/ui-model-selection/tsdown.config.ts
Normal file
3
packages/client/ui-model-selection/tsdown.config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { clientBundle } from '../tsdown.client.ts'
|
||||
|
||||
export default clientBundle('@deepseek-ai/dsh-client-ui-model-selection', ['lib/types/index.js', 'lib/types/invariant.js'])
|
||||
Reference in New Issue
Block a user