Each package ships its zh/en dictionaries as satisfies-typed pairs (zh is the key-set source of truth; en is checked complete against it), merges its namespace into LocaleNamespaceMap, and declares locale: NS at register — components read the framework-injected typed t seat instead of a hand-carried inject member. Overlapping verbatim words (retry, submit, submitting) drop out of package dictionaries in favor of the shared common vocabulary; the question composer stores validation feedback as dictionary keys so shown feedback follows a locale switch.
334 lines
13 KiB
TypeScript
334 lines
13 KiB
TypeScript
/**
|
|
* 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.
|
|
*/
|
|
import {
|
|
useEffect, useId, useMemo, useRef, useState, useSyncExternalStore,
|
|
type KeyboardEvent, type FocusEvent,
|
|
} from 'react'
|
|
import clsx from 'clsx'
|
|
import type { ModelReasoningEffort, ModelTarget } from '@deepseek-ai/dsh-client-connection/client'
|
|
import {
|
|
IconCheckOutline16, IconChevronDownOutline14, IconChevronRightOutline14,
|
|
} 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, 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')
|
|
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,
|
|
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
|
|
? 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'
|
|
|
|
// Mount-time load resolves the trigger label; every open refreshes.
|
|
useEffect(() => { load() }, [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])
|
|
|
|
const show = (): void => {
|
|
setPane('root')
|
|
setOpen(true)
|
|
load()
|
|
}
|
|
|
|
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 choose = (target: ModelTarget): void => {
|
|
if (state.current?.provider === target.provider && state.current.model === target.model) {
|
|
close(true)
|
|
return
|
|
}
|
|
void select(target).then((accepted) => {
|
|
if (accepted && rootRef.current !== null) close(true)
|
|
})
|
|
}
|
|
|
|
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 ?? t('trigger.fallback')
|
|
const triggerLabel = effortLabel === undefined ? modelLabel : `${modelLabel} · ${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={effortLabel === undefined
|
|
? t('trigger.aria', { model: modelLabel })
|
|
: t('trigger.ariaEffort', { model: modelLabel, effort: effortLabel })}
|
|
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 && (
|
|
<div className={css.error}>
|
|
<span>{t('error.action', { message: state.error })}</span>
|
|
<button type="button" className={css.retry} onClick={() => { load() }}>{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={() => { load() }}>{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>
|
|
)}
|
|
{model.unlisted === true && (
|
|
<span className={css.unlisted}>{t('option.currentUnlisted')}</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 && (
|
|
<div className={css.error}>
|
|
<span>{t('error.action', { message: state.error })}</span>
|
|
<button type="button" className={css.retry} onClick={() => { load() }}>{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>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|