File size: 7,426 Bytes
28ad89a | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 | import { DEFAULT_REASONING_EFFORT, type ModelOptionsResult } from '@hermes/shared'
import { useStore } from '@nanostores/react'
import { useQuery } from '@tanstack/react-query'
import { useSessionView } from '@/app/chat/session-view'
import type { HermesGateway } from '@/hermes'
import { useI18n } from '@/i18n'
import { modelOptionsQueryKey, requestModelOptions } from '@/lib/model-options'
import { currentPickerSelection } from '@/lib/model-status-label'
import { $modelPresets, applyModelPreset, modelPresetKey, setModelPreset } from '@/store/model-presets'
import { notifyError } from '@/store/notifications'
import {
$defaultReasoningEffort,
markComposerSelectionManual,
setCurrentFastMode,
setCurrentReasoningEffort
} from '@/store/session'
import { sessionTileDelegate } from '@/store/session-states'
import type { ModelMenuController } from './model-catalog-menu'
export interface ModelSelection {
model: string
provider: string
/** Runtime id of the surface that opened the menu. When set, the switch
* targets that session (a tile) instead of the primary `$activeSessionId`. */
sessionId?: null | string
}
export interface ModelMenuHostProps {
gateway?: HermesGateway
ownerConnectionId?: string
onSelectModel: (selection: ModelSelection) => Promise<boolean> | void
profile?: string
requestGateway: <T>(method: string, params?: Record<string, unknown>) => Promise<T>
}
/**
* The controller that gives a model-menu edit its meaning on a chat surface —
* write through to THIS surface's session, remember the pick as a global
* preset, keep the optimistic stores honest, and roll back on a failed gateway
* write. Shared by the composer's model menu and its reasoning menu so both
* pills edit the same session through one code path.
*/
export function useModelMenuController({
gateway,
onSelectModel,
ownerConnectionId,
profile = 'default',
requestGateway
}: ModelMenuHostProps) {
const { t } = useI18n()
// Bind to THIS surface's SessionView (primary or tile) so each pane's menu
// shows/switches its own model — not the primary-only globals.
const view = useSessionView()
const activeSessionId = useStore(view.$runtimeId)
const currentFastMode = useStore(view.$fast)
const currentModel = useStore(view.$model)
const currentProvider = useStore(view.$provider)
const currentReasoningEffort = useStore(view.$reasoningEffort)
const modelPresets = useStore($modelPresets)
const defaultEffort = useStore($defaultReasoningEffort) || DEFAULT_REASONING_EFFORT
const touchesPrimary = view.kind === 'primary'
// Subscribe to the SAME query the menu runs (identical key ⇒ React Query
// dedupes, no second fetch). It must be a live subscription, not a cache
// peek: with no model in the session store yet, currentPickerSelection falls
// back to the catalog's reported current, and a non-reactive read would
// never repaint that fallback once the catalog resolved.
const modelOptions = useQuery({
queryKey: modelOptionsQueryKey(profile, activeSessionId, ownerConnectionId),
queryFn: (): Promise<ModelOptionsResult> =>
requestModelOptions({ gateway, profile, request: requestGateway, sessionId: activeSessionId })
})
const { model: optionsModel, provider: optionsProvider } = currentPickerSelection(
{ model: currentModel, provider: currentProvider },
modelOptions.data
)
// Push a reasoning change onto the session that owns it, with rollback.
const patchReasoning = async (next: string, previous: string, provider: string, model: string) => {
if (touchesPrimary) {
markComposerSelectionManual()
setCurrentReasoningEffort(next)
} else if (activeSessionId) {
sessionTileDelegate()?.updateSession(activeSessionId, state => ({ ...state, reasoningEffort: next }))
}
// Preset-only without a session: the gateway's `config.set` falls back to
// global config when none matches — so don't reach it (preset + optimistic
// store are the whole effect).
if (!activeSessionId) {
return
}
try {
await requestGateway('config.set', { key: 'reasoning', session_id: activeSessionId, value: next })
} catch (err) {
if (touchesPrimary) {
setCurrentReasoningEffort(previous)
} else {
sessionTileDelegate()?.updateSession(activeSessionId, state => ({ ...state, reasoningEffort: previous }))
}
setModelPreset(provider, model, { effort: previous })
notifyError(err, t.shell.modelOptions.updateFailed)
}
}
const patchFast = async (enabled: boolean, provider: string, model: string) => {
if (touchesPrimary) {
markComposerSelectionManual()
setCurrentFastMode(enabled)
} else if (activeSessionId) {
sessionTileDelegate()?.updateSession(activeSessionId, state => ({ ...state, fast: enabled }))
}
if (!activeSessionId) {
return
}
try {
await requestGateway('config.set', {
key: 'fast',
session_id: activeSessionId,
value: enabled ? 'fast' : 'normal'
})
} catch (err) {
if (touchesPrimary) {
setCurrentFastMode(!enabled)
} else {
sessionTileDelegate()?.updateSession(activeSessionId, state => ({ ...state, fast: !enabled }))
}
setModelPreset(provider, model, { fast: !enabled })
notifyError(err, t.shell.modelOptions.fastFailed)
}
}
const controller: ModelMenuController = {
// Selecting a model row restores that model's remembered preset onto the
// session (effort/fast). applyModelPreset owns the batched gateway write.
applyPreset: (preset, row) => {
setModelPreset(row.provider, row.model, preset)
void applyModelPreset(preset, {
failMessage: t.shell.modelOptions.updateFailed,
primary: touchesPrimary,
request: requestGateway,
sessionId: activeSessionId
})
},
current: {
effort: currentReasoningEffort,
fast: currentFastMode,
model: optionsModel,
provider: optionsProvider
},
presetFor: (provider, model) => modelPresets[modelPresetKey(provider, model)] ?? {},
// The composer picker never persists the profile default. With a session it
// scopes the switch to that session; with none it's UI state shipped on the
// next session.create. Always stamp sessionId from this surface so a tile
// switch never hits the primary (busy) session by accident.
select: (model, provider) => onSelectModel({ model, provider, sessionId: activeSessionId || null }),
setOptions: (patch, row) => {
// Editing always records the model's global preset (keyed by
// provider::model, not per-surface — a tile edit re-applies to that model
// everywhere); the active model also gets it pushed onto its OWN session.
// Non-active edits stay preset-only — no model switch, no session write.
if (patch.effort !== undefined || patch.fast !== undefined) {
setModelPreset(row.provider, row.model, patch)
}
if (!row.isActive) {
return
}
if (patch.effort !== undefined) {
void patchReasoning(patch.effort, currentReasoningEffort, row.provider, row.model)
}
if (patch.fast !== undefined) {
void patchFast(patch.fast, row.provider, row.model)
}
}
}
return { activeSessionId, controller, defaultEffort, modelOptions }
}
|