feat(web): support reasoning effort selection
This commit is contained in:
@@ -10,7 +10,8 @@ export type {
|
||||
ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, ToolEventView,
|
||||
WorkspaceApi, WorkspaceId, WorkspaceView,
|
||||
CommandsApi, CommandDescriptor, CommandExecuteResult, SkillsApi, SkillEntry,
|
||||
ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelTarget, SessionModels,
|
||||
ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning,
|
||||
ModelReasoningEffort, ModelTarget, SessionModels,
|
||||
} from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
export type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-tools/presentation'
|
||||
export type {
|
||||
|
||||
@@ -46,6 +46,25 @@ const MARKDOWN_FIXTURE = [
|
||||
|
||||
const USER_MARKDOWN_LITERAL = '用户字面量:# 不渲染 `code` [link](https://example.com)'
|
||||
|
||||
const DEEPSEEK_REASONING = {
|
||||
efforts: [
|
||||
{ id: 'off', name: 'Off' },
|
||||
{ id: 'high', name: 'High' },
|
||||
{ id: 'max', name: 'Max' },
|
||||
],
|
||||
defaultEffort: 'high',
|
||||
}
|
||||
|
||||
const OPENAI_REASONING = {
|
||||
efforts: [
|
||||
{ id: 'off', name: 'Off' },
|
||||
{ id: 'medium', name: 'Medium' },
|
||||
{ id: 'high', name: 'High' },
|
||||
{ id: 'max', name: 'Max' },
|
||||
],
|
||||
defaultEffort: 'medium',
|
||||
}
|
||||
|
||||
function sid(id: string): SessionId {
|
||||
return id as SessionId
|
||||
}
|
||||
@@ -666,20 +685,36 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
|
||||
id: 'deepseek',
|
||||
name: 'DeepSeek',
|
||||
models: [
|
||||
{ id: 'deepseek-v4-flash', name: 'DeepSeek-V4-Flash', description: '快速响应' },
|
||||
{ id: 'deepseek-v4-pro', name: 'DeepSeek-V4-Pro', description: '复杂任务' },
|
||||
{
|
||||
id: 'deepseek-v4-flash',
|
||||
name: 'DeepSeek-V4-Flash',
|
||||
description: '快速响应',
|
||||
reasoning: DEEPSEEK_REASONING,
|
||||
},
|
||||
{
|
||||
id: 'deepseek-v4-pro',
|
||||
name: 'DeepSeek-V4-Pro',
|
||||
description: '复杂任务',
|
||||
reasoning: DEEPSEEK_REASONING,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'openai',
|
||||
name: 'OpenAI',
|
||||
models: [{ id: 'gpt-5', name: 'GPT-5' }],
|
||||
models: [{ id: 'gpt-5', name: 'GPT-5', reasoning: OPENAI_REASONING }],
|
||||
},
|
||||
],
|
||||
failures: [],
|
||||
}),
|
||||
selectModel: (request) => {
|
||||
const selected = { provider: request.payload.provider, model: request.payload.model }
|
||||
const selected: ModelTarget = {
|
||||
provider: request.payload.provider,
|
||||
model: request.payload.model,
|
||||
...request.payload.reasoningEffort === undefined
|
||||
? {}
|
||||
: { reasoningEffort: request.payload.reasoningEffort },
|
||||
}
|
||||
modelTargets.set(request.payload.sessionId, selected)
|
||||
return ok(request, { selected })
|
||||
},
|
||||
@@ -718,7 +753,11 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
|
||||
userText === 'render markdown'
|
||||
? MARKDOWN_FIXTURE
|
||||
: userText === 'report model'
|
||||
? `当前模型:${modelTargets.get(id)?.provider ?? 'unknown'}/${modelTargets.get(id)?.model ?? 'unknown'}`
|
||||
? (() => {
|
||||
const target = modelTargets.get(id)
|
||||
return `当前模型:${target?.provider ?? 'unknown'}/${target?.model ?? 'unknown'}`
|
||||
+ (target?.reasoningEffort === undefined ? '' : ` · 推理等级:${target.reasoningEffort}`)
|
||||
})()
|
||||
: `回声:${userText}。这是 fixture 的流式回复,用于验证打字机增长与定稿切换。`,
|
||||
)
|
||||
return ok(request, { accepted: true as const })
|
||||
|
||||
@@ -15,7 +15,8 @@ export type {
|
||||
ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, ToolEventView,
|
||||
ToolCallView, ToolResultView, WorkspaceApi, WorkspaceId, WorkspaceView,
|
||||
CommandsApi, CommandDescriptor, CommandExecuteResult, SkillsApi, SkillEntry,
|
||||
ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelTarget, SessionModels,
|
||||
ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning,
|
||||
ModelReasoningEffort, ModelTarget, SessionModels,
|
||||
RpcRequest, RpcResponse, RpcResult, RpcError, RpcErrorCode,
|
||||
ClientRequest, ServerResponse, ServerRequest, ClientResponse, RpcMessage, RpcReceipt,
|
||||
IApiClient, SessionId, SessionEvent, ContentBlock, StreamChunk,
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-model/README.md
|
||||
README.md: 4de0e787a79d28ef8e92ee757d8d366e964c74d3
|
||||
README.zh.md: 0eef4af91c69fdab1e04377244d6f256399caa75
|
||||
README.md: 267717c78434f7a73b1c1eebca0cc0f9d65c3642
|
||||
README.zh.md: 325b1d93d99ed22e0945c26f5a3a9e5b3b209c85
|
||||
|
||||
@@ -2,13 +2,13 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Model selection plugin, browser half: TWO entries over ONE per-session directory owned by `ModelService` (`ctx.models`). The `/model` popupSelect contribution (registered through `ctx.command`) and the composer's named `conversation.input.model` seat (a compact trigger + upward provider-grouped menu, figma 313:14108's ToggleButton chrome) both load the session's advisory directory through `session.models` and submit through `session.selectModel` via the same `ModelDirectory` instance — the host-reported current target is the single fact both surfaces echo, so a switch made in either entry is what the other shows next. Directory loads and selections share a generation counter (an older response never overwrites a newer one); a connection reset drops every resident projection and repulls the Host-restored target before displaying it again. Provider-local catalog failures list inline while usable groups stay selectable; whole-request and selection failures surface on each entry's own retry face (the popup shell's error/retry, the seat menu's inline error) without forking the state. Directories are per-session, resolved lazily through `ctx.models.directoryFor(sessionId)`, and disposed with the session scope.
|
||||
Model selection plugin, browser half: TWO entries over ONE per-session directory owned by `ModelService` (`ctx.models`). The `/model` popupSelect contribution (registered through `ctx.command`) and the composer's named `conversation.input.model` seat both load the session's advisory directory through `session.models` and submit through `session.selectModel` via the same `ModelDirectory` instance. The compact composer trigger opens a two-level Model/Effort menu: models stay provider-grouped, while the selected exact model supplies its adapter-owned effort names, descriptions, and default. The Host-reported provider/model/reasoning target is the single fact both entries echo; `/model` applies the selected model's default effort, and the composer can then choose any advertised effort. Directory loads and selections share a generation counter so an older response never overwrites a newer one; a connection reset drops every resident projection and repulls the Host-restored target before display. Provider-local metadata failures list inline while usable groups stay selectable, and selection failures retain the prior target and directory. Directories are per-session, resolved lazily through `ctx.models.directoryFor(sessionId)`, and disposed with the session scope.
|
||||
|
||||
The `/client` export surface is the plugin body (`apply`/`inject`), `ModelService`, `ModelDirectory` with its state shape, and the seat's injected face type.
|
||||
|
||||
## Model Experience
|
||||
|
||||
Indirectly, through the `session.selectModel` RPC both entries submit: the host snapshots the selected provider/model pair at the next prompt-assembly boundary, so the following request routes (and stamps its prompt variables) with the chosen target while a running step keeps its assembled one — the directory, both menus, and every selection interaction stay client-side and never enter the session log.
|
||||
Indirectly, through the `session.selectModel` RPC both entries submit: the Host snapshots the selected provider/model/reasoning target at the next prompt-assembly boundary, so the following request uses the chosen route and effort while a running step keeps its assembled target. The selection becomes durable only when the existing request header records a request that consumes it; menu interaction adds no prompt content.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
@@ -17,5 +17,5 @@ Switching the route can reduce or invalidate provider-side cache reuse for subse
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **No create-time selection** — both entries address an existing session's agent; there is no draft-phase model choice to fold into session creation (the seed order at the host's `targetFor` documents where such a tier would go).
|
||||
- **Directory names are presentation-only** — selection and persistence use provider/model ids; a provider whose catalog lookup fails lists as an unselectable failure row until reload.
|
||||
- **The seat shows no effort level** — the figma mock's `High` text has no wire concept behind it yet; the trigger renders the model name alone.
|
||||
- **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.
|
||||
|
||||
@@ -2,13 +2,13 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
模型选择插件(浏览器半侧):**两个入口共用一份 per-session 目录**,由 `ModelService`(`ctx.models`)持有。`/model` popupSelect contribution(经 `ctx.command` 注册)与 composer 的具名 `conversation.input.model` 坑位(紧凑触发器 + 向上展开的按提供方分组菜单,视觉取 figma 313:14108 的 ToggleButton)都通过同一个 `ModelDirectory` 实例经 `session.models` 加载会话的建议目录、经 `session.selectModel` 提交——host 报告的 current target 是两个界面共同回显的唯一事实,在任一入口切换,另一入口下次打开显示的就是新值。目录加载与选择共享一个代次计数器(旧响应永不覆盖新结果);连接重置会先丢弃所有常驻目录投影,再重新拉取 Host 恢复的 target 后显示,避免继续呈现未消费的进程内选择。提供方级目录失败内联列出,可用分组保持可选;整体失败与选择失败落各入口自己的重试面(popup 壳的 error/retry、坑位菜单的内联错误),状态不分叉。目录按会话惰性解析(`ctx.models.directoryFor(sessionId)`),随会话 scope 一并释放。
|
||||
模型选择插件(浏览器半侧):**两个入口共用一份 per-session 目录**,由 `ModelService`(`ctx.models`)持有。`/model` popupSelect contribution(经 `ctx.command` 注册)与 composer 的具名 `conversation.input.model` 坑位都通过同一个 `ModelDirectory` 实例,经 `session.models` 加载会话的建议目录,并经 `session.selectModel` 提交。紧凑型 composer 触发器会打开两级 Model/Effort 菜单:模型仍按提供方分组,所选确切模型则提供由其适配器持有的推理强度名称、说明和默认值。Host 报告的提供方/模型/推理(reasoning)目标是两个入口共同回显的唯一事实;`/model` 应用所选模型的默认推理强度,composer 随后可以选择任一已公布的推理强度。目录加载与选择共享一个代次计数器,旧响应不会覆盖新结果;连接重置会丢弃所有常驻目录投影,并在显示前重新拉取 Host 恢复的目标。逐提供方元数据失败会内联列出,同时可用分组仍可选择;选择失败会保留先前的目标和目录。目录按会话惰性解析(`ctx.models.directoryFor(sessionId)`),随会话 scope 一并释放。
|
||||
|
||||
`/client` 导出面为插件本体(`apply`/`inject`)、`ModelService`、`ModelDirectory` 及其状态形状、坑位注入面类型。
|
||||
|
||||
## Model Experience
|
||||
|
||||
间接影响,经两个入口共同提交的 `session.selectModel` RPC:host 在下一次提示词组装边界快照所选提供方/模型对,因此后续请求按所选目标路由(并盖入提示词变量),运行中的步骤保持其已组装目标——目录、两个菜单及全部选择交互都留在 client 侧,永不进入 session log。
|
||||
间接影响,经两个入口共同提交的 `session.selectModel` RPC:Host 在下一次提示词组装边界快照所选提供方/模型/推理强度目标,因此后续请求采用所选路由和推理强度,而运行中的步骤保留已组装目标。只有当现有请求头记录一次实际采用该选择的请求后,选择才会持久化;菜单交互不会添加提示词内容。
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
@@ -17,5 +17,5 @@
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **无创建期选择**——两个入口都寻址既有会话的 agent;没有 Draft 期模型选择折入会话创建的通道(host `targetFor` 处的种子序注释记录了该层未来的落点)。
|
||||
- **目录名仅供呈现**——选择与持久化使用提供方/模型 id;目录查询失败的提供方以不可选失败行列出,重新加载前保持原样。
|
||||
- **坑位不显示 effort 档位**——figma 设计稿中的 `High` 文本尚无对应 wire 概念;触发器只渲染模型名。
|
||||
- **目录名仅供呈现**——选择与持久化使用提供方/模型/推理强度 id;目录查询或确切模型元数据查询失败的提供方以不可选失败行列出,重新加载前保持原样。
|
||||
- **不能任意输入推理强度**——composer 仅提供确切模型由适配器公布的推理强度;适配器没有推理元数据时不显示 Effort 行。
|
||||
|
||||
@@ -6,37 +6,38 @@
|
||||
* 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; effort is a client-local display echo until a wire carries
|
||||
* a per-session override (see the directory's state contract).
|
||||
* /model popup; exact-model reasoning metadata and the selected effort come
|
||||
* from the Host rather than a client-owned vocabulary.
|
||||
*/
|
||||
import {
|
||||
useEffect, useId, useMemo, useRef, useState, useSyncExternalStore,
|
||||
type KeyboardEvent, type FocusEvent,
|
||||
} from 'react'
|
||||
import clsx from 'clsx'
|
||||
import type { ModelTarget } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { ModelReasoningEffort, ModelTarget } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import {
|
||||
IconCheckOutline16, IconChevronDownOutline14, IconChevronRightOutline14,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { ModelEffort } from './directory.ts'
|
||||
import type { ModelSelectInjected } from './slots.ts'
|
||||
import css from './ModelSelect.module.css'
|
||||
|
||||
/** The displayable effort levels (deepseek wire vocabulary, capitalized for the UI). */
|
||||
const EFFORT_LEVELS: readonly { id: ModelEffort; label: string }[] = [
|
||||
{ id: 'high', label: 'High' },
|
||||
{ id: 'max', label: 'Max' },
|
||||
]
|
||||
|
||||
/** 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).
|
||||
* @returns the trigger and, while open, the two-level menu.
|
||||
*/
|
||||
export function ModelSelect({ locked, directory, load, select, setEffort }: ModelSelectInjected & { locked: boolean }) {
|
||||
export function ModelSelect({ locked, directory, load, select }: ModelSelectInjected & { locked: boolean }) {
|
||||
const state = useSyncExternalStore(
|
||||
fn => directory.subscribe(fn),
|
||||
() => directory.getSnapshot(),
|
||||
@@ -52,13 +53,39 @@ export function ModelSelect({ locked, directory, load, select, setEffort }: Mode
|
||||
group.models.map(model => ({
|
||||
group,
|
||||
model,
|
||||
target: { provider: group.id, model: model.id } satisfies ModelTarget,
|
||||
target: {
|
||||
provider: group.id,
|
||||
model: model.id,
|
||||
...model.reasoning?.defaultEffort === undefined
|
||||
? {}
|
||||
: { reasoningEffort: model.reasoning.defaultEffort },
|
||||
} satisfies ModelTarget,
|
||||
}))), [state.groups])
|
||||
const selectedIndex = state.current === null
|
||||
? -1
|
||||
: choices.findIndex(c => c.target.provider === state.current?.provider && c.target.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
|
||||
? 'Provider default'
|
||||
: 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: 'Provider default' }]
|
||||
: [],
|
||||
...reasoning.efforts.map((effort: ModelReasoningEffort) => ({
|
||||
key: `effort:${effort.id}`,
|
||||
effort: effort.id,
|
||||
label: effort.name,
|
||||
...effort.description === undefined ? {} : { description: effort.description },
|
||||
})),
|
||||
], [reasoning])
|
||||
const busy = state.status === 'selecting'
|
||||
const effortLabel = EFFORT_LEVELS.find(l => l.id === state.effort)?.label ?? 'High'
|
||||
|
||||
// Mount-time load resolves the trigger label; every open refreshes.
|
||||
useEffect(() => { load() }, [load])
|
||||
@@ -122,7 +149,24 @@ export function ModelSelect({ locked, directory, load, select, setEffort }: Mode
|
||||
})
|
||||
}
|
||||
|
||||
const chooseEffort = (effort: string | undefined): void => {
|
||||
if (state.current === null) return
|
||||
if (effectiveEffort === effort) {
|
||||
close(true)
|
||||
return
|
||||
}
|
||||
const target: ModelTarget = {
|
||||
provider: state.current.provider,
|
||||
model: state.current.model,
|
||||
...effort === undefined ? {} : { reasoningEffort: effort },
|
||||
}
|
||||
void select(target).then((accepted) => {
|
||||
if (accepted && rootRef.current !== null) close(true)
|
||||
})
|
||||
}
|
||||
|
||||
const modelLabel = choices[selectedIndex]?.model.name ?? state.current?.model ?? '选择模型'
|
||||
const triggerLabel = effortLabel === undefined ? modelLabel : `${modelLabel} · ${effortLabel}`
|
||||
itemRefs.current = []
|
||||
let itemIndex = 0
|
||||
const itemRef = () => {
|
||||
@@ -136,16 +180,16 @@ export function ModelSelect({ locked, directory, load, select, setEffort }: Mode
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
className={css.trigger}
|
||||
aria-label={`选择模型,当前 ${modelLabel},effort ${effortLabel}`}
|
||||
aria-label={`选择模型,当前 ${modelLabel}${effortLabel === undefined ? '' : `,推理等级 ${effortLabel}`}`}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open}
|
||||
aria-controls={open ? `${id}-menu` : undefined}
|
||||
title={`${modelLabel} · ${effortLabel}`}
|
||||
title={triggerLabel}
|
||||
disabled={locked}
|
||||
onClick={() => { open ? close() : show() }}
|
||||
>
|
||||
<span className={css.triggerLabel}>{modelLabel}</span>
|
||||
<span className={css.triggerEffort}>{effortLabel}</span>
|
||||
{effortLabel !== undefined && <span className={css.triggerEffort}>{effortLabel}</span>}
|
||||
<IconChevronDownOutline14 className={clsx(css.chevron, open && css.chevronOpen)} />
|
||||
</button>
|
||||
|
||||
@@ -154,7 +198,7 @@ export function ModelSelect({ locked, directory, load, select, setEffort }: Mode
|
||||
id={`${id}-menu`}
|
||||
className={css.menu}
|
||||
role="menu"
|
||||
aria-label="模型与 effort"
|
||||
aria-label="模型与推理等级"
|
||||
aria-busy={state.status === 'loading' || busy}
|
||||
>
|
||||
{pane === 'root' && (
|
||||
@@ -164,11 +208,13 @@ export function ModelSelect({ locked, directory, load, select, setEffort }: Mode
|
||||
<span className={css.cellValue}>{modelLabel}</span>
|
||||
<IconChevronRightOutline14 className={css.cellChevron} />
|
||||
</button>
|
||||
<button ref={itemRef()} type="button" role="menuitem" className={css.cell} onClick={() => setPane('effort')}>
|
||||
<span className={css.cellLabel}>Effort</span>
|
||||
<span className={css.cellValue}>{effortLabel}</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}>Effort</span>
|
||||
<span className={css.cellValue}>{effortLabel}</span>
|
||||
<IconChevronRightOutline14 className={css.cellChevron} />
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -234,24 +280,40 @@ export function ModelSelect({ locked, directory, load, select, setEffort }: Mode
|
||||
</>
|
||||
)}
|
||||
|
||||
{pane === 'effort' && EFFORT_LEVELS.map(level => (
|
||||
<button
|
||||
ref={itemRef()}
|
||||
type="button"
|
||||
role="menuitemradio"
|
||||
aria-checked={state.effort === level.id}
|
||||
className={clsx(css.option, state.effort === level.id && css.selected)}
|
||||
key={level.id}
|
||||
onClick={() => { setEffort(level.id); close(true) }}
|
||||
>
|
||||
<span className={css.optionCopy}>
|
||||
<span className={css.modelName}>{level.label}</span>
|
||||
</span>
|
||||
<span className={css.check}>
|
||||
{state.effort === level.id ? <IconCheckOutline16 /> : null}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
{pane === 'effort' && (
|
||||
<>
|
||||
{state.error !== null && (
|
||||
<div className={css.error}>
|
||||
<span>模型操作失败:{state.error}</span>
|
||||
<button type="button" className={css.retry} onClick={() => { load() }}>重新加载</button>
|
||||
</div>
|
||||
)}
|
||||
{effortChoices.length === 0
|
||||
? <div className={css.empty}>当前模型未提供推理等级。</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>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -11,19 +11,8 @@ import type {
|
||||
import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
|
||||
/** Thinking-effort display levels (the deepseek wire vocabulary). */
|
||||
export type ModelEffort = 'high' | 'max'
|
||||
|
||||
/** Directory snapshot both entries render from. */
|
||||
export interface ModelDirectoryState {
|
||||
/**
|
||||
* Displayed thinking-effort level. Client-local echo only for now: the
|
||||
* design pairs model and effort as one two-level selection, but no wire
|
||||
* carries a per-session effort override yet (the deepseek adapter's
|
||||
* reasoningEffort is deployment config) — selecting it updates this
|
||||
* display state and nothing else.
|
||||
*/
|
||||
effort: ModelEffort
|
||||
/** Target the host reports for the next assembled step; null before the first load. */
|
||||
current: ModelTarget | null
|
||||
/** Successfully loaded provider groups (last good load). */
|
||||
@@ -40,7 +29,7 @@ export interface ModelDirectoryState {
|
||||
export class ModelDirectory {
|
||||
/** The shared snapshot both entries render from (uSES-safe store). */
|
||||
readonly store: SnapshotStore<ModelDirectoryState> = createSnapshotStore<ModelDirectoryState>({
|
||||
effort: 'high', current: null, groups: [], failures: [], status: 'idle', error: null,
|
||||
current: null, groups: [], failures: [], status: 'idle', error: null,
|
||||
})
|
||||
|
||||
/** Latest operation wins; an older response never overwrites a newer one. */
|
||||
@@ -85,16 +74,21 @@ export class ModelDirectory {
|
||||
}
|
||||
|
||||
/**
|
||||
* Select the complete route (both entries submit through here). Success
|
||||
* Select the complete provider/model/reasoning target (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 target - provider and provider-owned model id.
|
||||
* @param target - provider, provider-owned model id, and optional adapter-owned effort.
|
||||
*/
|
||||
async select(target: ModelTarget): Promise<void> {
|
||||
const generation = ++this.generation
|
||||
this.store.update((s) => { s.status = 'selecting'; s.error = null })
|
||||
const { result } = await this.sessions.selectModel({
|
||||
sessionId: this.sessionId, provider: target.provider, model: target.model,
|
||||
sessionId: this.sessionId,
|
||||
provider: target.provider,
|
||||
model: target.model,
|
||||
...target.reasoningEffort === undefined
|
||||
? {}
|
||||
: { reasoningEffort: target.reasoningEffort },
|
||||
})
|
||||
if (this.disposed || generation !== this.generation) {
|
||||
if (!result.ok) throw new Error(`${result.error.code}: ${result.error.message}`)
|
||||
@@ -107,15 +101,6 @@ export class ModelDirectory {
|
||||
this.store.update((s) => { s.current = result.value.selected; s.status = 'ready'; s.error = null })
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the displayed effort level (client-local; see the state field's contract).
|
||||
* @param effort - the level to display.
|
||||
*/
|
||||
setEffort(effort: ModelEffort): void {
|
||||
if (this.disposed) return
|
||||
this.store.update((s) => { s.effort = effort })
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop the previous Host generation's projection and repull it. Clearing
|
||||
* first prevents an unconsumed process-local selection from being displayed
|
||||
|
||||
@@ -20,7 +20,7 @@ import type { ModelSelectInjected } from './slots.ts'
|
||||
import { ModelSelect } from './ModelSelect.tsx'
|
||||
|
||||
export { ModelDirectory } from './directory.ts'
|
||||
export type { ModelDirectoryState, ModelEffort } from './directory.ts'
|
||||
export type { ModelDirectoryState } from './directory.ts'
|
||||
export { ModelService } from './service.ts'
|
||||
export type { ModelSelectInjected } from './slots.ts'
|
||||
|
||||
@@ -61,7 +61,16 @@ function optionsOf(directory: SessionModels): SelectOption[] {
|
||||
function targetOf(state: ModelDirectoryState, id: string): ModelTarget | undefined {
|
||||
for (const group of state.groups) {
|
||||
for (const model of group.models) {
|
||||
if (rowId(group.id, model.id) === id) return { provider: group.id, model: model.id }
|
||||
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
|
||||
@@ -114,7 +123,6 @@ export function apply(ctx: ClientContext): void {
|
||||
directory: directory.store,
|
||||
load: () => { directory.load().catch(() => { /* surfaced on the store */ }) },
|
||||
select: (target: ModelTarget) => directory.select(target).then(() => true, () => false),
|
||||
setEffort: (effort) => { directory.setEffort(effort) },
|
||||
}
|
||||
},
|
||||
}, ModelSelect), 'ui-model: composer model seat registration')
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
*/
|
||||
import type { ModelTarget } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ModelDirectoryState, ModelEffort } from './directory.ts'
|
||||
import type { ModelDirectoryState } from './directory.ts'
|
||||
|
||||
/** Injected business face of the composer model seat. */
|
||||
export interface ModelSelectInjected {
|
||||
@@ -15,15 +15,9 @@ export interface ModelSelectInjected {
|
||||
/** Refresh the advisory directory (fire-and-forget; errors land on the store). */
|
||||
load(): void
|
||||
/**
|
||||
* Select a complete provider/model target through the shared route.
|
||||
* @param target - target picked from one provider group.
|
||||
* Select a complete provider/model/reasoning target through the shared route.
|
||||
* @param target - model target and optional adapter-owned effort.
|
||||
* @returns whether the host accepted the selection.
|
||||
*/
|
||||
select(target: ModelTarget): Promise<boolean>
|
||||
/**
|
||||
* Set the displayed thinking-effort level (client-local echo; see the
|
||||
* directory state contract).
|
||||
* @param effort - the level to display.
|
||||
*/
|
||||
setEffort(effort: ModelEffort): void
|
||||
}
|
||||
|
||||
@@ -23,8 +23,30 @@ const GROUPS = [{
|
||||
id: 'deepseek',
|
||||
name: 'DeepSeek',
|
||||
models: [
|
||||
{ id: 'deepseek-v4-flash', name: 'DeepSeek-V4-Flash' },
|
||||
{ id: 'deepseek-v4-pro', name: 'DeepSeek-V4-Pro' },
|
||||
{
|
||||
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',
|
||||
},
|
||||
},
|
||||
],
|
||||
}]
|
||||
|
||||
@@ -38,9 +60,15 @@ async function bench() {
|
||||
calls.models += 1
|
||||
return Promise.resolve({ result: { ok: true as const, value: { current, groups: GROUPS, failures: [] } } })
|
||||
},
|
||||
selectModel: (payload: { provider: string; model: string }) => {
|
||||
selectModel: (payload: { provider: string; model: string; reasoningEffort?: string }) => {
|
||||
calls.select += 1
|
||||
current = { provider: payload.provider, model: payload.model }
|
||||
current = {
|
||||
provider: payload.provider,
|
||||
model: payload.model,
|
||||
...payload.reasoningEffort === undefined
|
||||
? {}
|
||||
: { reasoningEffort: payload.reasoningEffort },
|
||||
}
|
||||
return Promise.resolve({ result: { ok: true as const, value: { selected: current } } })
|
||||
},
|
||||
} } })
|
||||
@@ -102,9 +130,21 @@ describe('ui-model dual entry', () => {
|
||||
b.mint('s1')
|
||||
const seatFace = b.seat().inject!(sid('s1'))
|
||||
// Switch through the SEAT entry.
|
||||
expect(await seatFace.select({ provider: 'deepseek', model: 'deepseek-v4-pro' })).toBe(true)
|
||||
expect(b.hostCurrent()).toEqual({ provider: 'deepseek', model: 'deepseek-v4-pro' })
|
||||
expect(seatFace.directory.getSnapshot().current).toEqual({ provider: 'deepseek', model: 'deepseek-v4-pro' })
|
||||
expect(await seatFace.select({
|
||||
provider: 'deepseek',
|
||||
model: 'deepseek-v4-pro',
|
||||
reasoningEffort: 'max',
|
||||
})).toBe(true)
|
||||
expect(b.hostCurrent()).toEqual({
|
||||
provider: 'deepseek',
|
||||
model: 'deepseek-v4-pro',
|
||||
reasoningEffort: 'max',
|
||||
})
|
||||
expect(seatFace.directory.getSnapshot().current).toEqual({
|
||||
provider: 'deepseek',
|
||||
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 })
|
||||
@@ -117,7 +157,11 @@ describe('ui-model dual entry', () => {
|
||||
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', model: 'deepseek-v4-pro' })
|
||||
expect(seatFace.directory.getSnapshot().current).toEqual({
|
||||
provider: 'deepseek',
|
||||
model: 'deepseek-v4-pro',
|
||||
reasoningEffort: 'high',
|
||||
})
|
||||
})
|
||||
|
||||
it('both entries share one directory instance per session, isolated across sessions', async () => {
|
||||
|
||||
95
packages/client/ui-model/tests/model-select.spec.tsx
Normal file
95
packages/client/ui-model/tests/model-select.spec.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
// @vitest-environment jsdom
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { ModelTarget } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ModelDirectoryState } from '../src/client/directory.ts'
|
||||
import { ModelSelect } from '../src/client/ModelSelect.tsx'
|
||||
|
||||
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', model: 'deepseek-v4-flash' },
|
||||
groups: [{
|
||||
id: 'deepseek',
|
||||
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 target', async () => {
|
||||
const directory = createSnapshotStore(state())
|
||||
const select = vi.fn(async (target: ModelTarget) => {
|
||||
directory.update((snapshot) => { snapshot.current = target })
|
||||
return true
|
||||
})
|
||||
render(<ModelSelect
|
||||
locked={false}
|
||||
directory={directory}
|
||||
load={vi.fn()}
|
||||
select={select}
|
||||
/>)
|
||||
|
||||
const trigger = screen.getByRole('button', {
|
||||
name: '选择模型,当前 DeepSeek-V4-Flash,推理等级 High',
|
||||
})
|
||||
fireEvent.click(trigger)
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: /Effort/ }))
|
||||
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',
|
||||
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}
|
||||
directory={directory}
|
||||
load={vi.fn()}
|
||||
select={vi.fn().mockResolvedValue(true)}
|
||||
/>)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', {
|
||||
name: '选择模型,当前 Model,推理等级 Provider default',
|
||||
}))
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: /Effort/ }))
|
||||
expect(screen.getAllByRole('menuitemradio').map(item => item.textContent))
|
||||
.toEqual(['Provider default', 'Standard'])
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user