feat(web): support reasoning effort selection

This commit is contained in:
Yichen Jiang
2026-07-27 16:12:40 +08:00
parent 39c60d5d64
commit 0d084ab6ff
26 changed files with 628 additions and 180 deletions
@@ -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
+11 -3
View File
@@ -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')
+3 -9
View File
@@ -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
}