import { useStore } from '@nanostores/react' import { DropdownMenuItem, DropdownMenuLabel, DropdownMenuRadioGroup, DropdownMenuRadioItem, dropdownMenuRow, dropdownMenuSectionLabel, DropdownMenuSeparator, DropdownMenuSubContent } from '@/components/ui/dropdown-menu' import { Switch } from '@/components/ui/switch' import { useI18n } from '@/i18n' import { notifyError } from '@/store/notifications' import { $activeSessionId, $currentReasoningEffort, setCurrentFastMode, setCurrentReasoningEffort } from '@/store/session' // Hermes' real reasoning levels (see VALID_REASONING_EFFORTS); `none` is owned // by the Thinking toggle, not the radio. const EFFORT_OPTIONS = [ { value: 'minimal', labelKey: 'minimal' }, { value: 'low', labelKey: 'low' }, { value: 'medium', labelKey: 'medium' }, { value: 'high', labelKey: 'high' }, { value: 'xhigh', labelKey: 'max' } ] as const /** How "fast" is achieved for a given model — two different mechanisms: * - `param`: the Anthropic/OpenAI `speed=fast` request parameter. * - `variant`: a separate `…-fast` sibling model selected via the model field. */ export type FastControl = | { kind: 'none' } | { kind: 'param'; on: boolean } | { kind: 'variant'; baseId: string; fastId: string; on: boolean } /** Resolve the fast mechanism for a model: prefer the speed=fast parameter * when the backend supports it, else fall back to a `…-fast` sibling model. */ export function resolveFastControl( model: string, providerModels: readonly string[], paramSupported: boolean, currentFastMode: boolean ): FastControl { if (paramSupported) { return { kind: 'param', on: currentFastMode } } if (/-fast$/i.test(model)) { const baseId = model.replace(/-fast$/i, '') // Only a toggle if there's a base to switch back to; otherwise it's a // standalone fast model with no "off" state. return providerModels.includes(baseId) ? { kind: 'variant', baseId, fastId: model, on: true } : { kind: 'none' } } const fastId = `${model}-fast` if (providerModels.includes(fastId)) { return { kind: 'variant', baseId: model, fastId, on: false } } // Fast isn't natively offered here, but if the session still has the speed // param on (carried over from a previous model), expose the toggle so it can // be turned off rather than stranded. if (currentFastMode) { return { kind: 'param', on: true } } return { kind: 'none' } } interface ModelEditSubmenuProps { /** How fast mode is offered for this model (param toggle vs. variant swap). */ fastControl: FastControl /** Whether this row's model is the active one. */ isActive: boolean /** Switch to this model (resolves false on failure). Awaited before applying * edits when not active so a failed switch doesn't write to the old model. */ onActivate: () => Promise | void /** Switch to a specific model id (used to swap base ⇄ -fast variant). */ onSelectModel: (model: string) => Promise | void /** Whether this model supports reasoning effort. */ reasoning: boolean requestGateway: (method: string, params?: Record) => Promise } export function ModelEditSubmenu({ fastControl, isActive, onActivate, onSelectModel, reasoning, requestGateway }: ModelEditSubmenuProps) { const { t } = useI18n() const copy = t.shell.modelOptions // Reactive session state comes straight from the stores rather than being // drilled through the panel, so editing it re-renders only this submenu. const activeSessionId = useStore($activeSessionId) const currentReasoningEffort = useStore($currentReasoningEffort) const effort = normalizeEffort(currentReasoningEffort) const thinkingOn = isThinkingEnabled(currentReasoningEffort) // Reasoning/fast are session-scoped (they apply to the active model), so // editing a non-active model first switches to it. Returns false if the // switch failed, so callers skip applying to the wrong (previous) model. const ensureActive = async (): Promise => { if (isActive) { return true } return (await onActivate()) !== false } const patchReasoning = async (next: string, rollback: string) => { setCurrentReasoningEffort(next) try { if (!(await ensureActive())) { setCurrentReasoningEffort(rollback) return } await requestGateway('config.set', { key: 'reasoning', session_id: activeSessionId ?? '', value: next }) } catch (err) { setCurrentReasoningEffort(rollback) notifyError(err, copy.updateFailed) } } const toggleFast = (enabled: boolean) => { if (fastControl.kind === 'variant') { // Fast is a separate model id — swap to it (or back to the base). void onSelectModel(enabled ? fastControl.fastId : fastControl.baseId) return } if (fastControl.kind === 'param') { setCurrentFastMode(enabled) void (async () => { try { if (!(await ensureActive())) { setCurrentFastMode(!enabled) return } await requestGateway('config.set', { key: 'fast', session_id: activeSessionId ?? '', value: enabled ? 'fast' : 'normal' }) } catch (err) { setCurrentFastMode(!enabled) notifyError(err, copy.fastFailed) } })() } } const hasFast = fastControl.kind !== 'none' const fastOn = fastControl.kind === 'none' ? false : fastControl.on return ( {!hasFast && !reasoning ? (
{copy.noOptions}
) : ( <> {copy.options} {reasoning ? ( event.preventDefault()}> {copy.thinking} void patchReasoning(checked ? effort || 'medium' : 'none', currentReasoningEffort) } size="xs" /> ) : null} {hasFast ? ( event.preventDefault()}> {copy.fast} ) : null} {reasoning ? ( <> {copy.effort} void patchReasoning(value, currentReasoningEffort)} value={effort} > {EFFORT_OPTIONS.map(option => ( event.preventDefault()} value={option.value} > {copy[option.labelKey]} ))} ) : null} )}
) } function isThinkingEnabled(effort: string): boolean { // Empty = Hermes default (medium) = on; only an explicit "none" is off. return (effort || 'medium').trim().toLowerCase() !== 'none' } function normalizeEffort(effort: string): string { const value = (effort || 'medium').trim().toLowerCase() // Thinking off → no effort selected in the radio group. if (value === 'none') { return '' } return EFFORT_OPTIONS.some(option => option.value === value) ? value : 'medium' }