import { useCallback, useEffect, useMemo, useState } from 'react' import { Button } from '@/components/ui/button' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' import { getAuxiliaryModels, getGlobalModelInfo, getGlobalModelOptions, setModelAssignment } from '@/hermes' import type { AuxiliaryModelsResponse, ModelOptionProvider, StaleAuxAssignment } from '@/hermes' import { useI18n } from '@/i18n' import { AlertTriangle, Cpu, Loader2 } from '@/lib/icons' import { cn } from '@/lib/utils' import { CONTROL_TEXT } from './constants' import { ListRow, LoadingState, Pill, SectionHeading } from './primitives' // Mirrors `_AUX_TASK_SLOTS` in hermes_cli/web_server.py. Friendly labels and // hints make the assignments readable; raw task keys (vision, mcp, …) are // opaque to most users. interface AuxTaskMeta { key: string } const AUX_TASKS: readonly AuxTaskMeta[] = [ { key: 'vision' }, { key: 'web_extract' }, { key: 'compression' }, { key: 'skills_hub' }, { key: 'approval' }, { key: 'mcp' }, { key: 'title_generation' }, { key: 'curator' } ] const NO_PROVIDERS: readonly ModelOptionProvider[] = [{ name: '—', slug: '', models: [] }] interface StaleAuxWarningProps { applying: boolean onReset: () => void slots: readonly StaleAuxAssignment[] taskLabel: (key: string) => string } // Shared notice: auxiliary tasks still pinned to a provider that isn't the // current main. Surfaces the silent credit-burn path (e.g. aux pinned to a // $0-balance provider after switching main away from it) and offers the // existing one-click reset rather than auto-clearing legitimate pins. function StaleAuxWarning({ applying, onReset, slots, taskLabel }: StaleAuxWarningProps) { if (!slots.length) { return null } const provider = slots[0].provider const allSameProvider = slots.every(slot => slot.provider === provider) const names = slots.map(slot => taskLabel(slot.task)).join(', ') return (
{slots.length} auxiliary task{slots.length === 1 ? '' : 's'} ({names}) still run on{' '} {allSameProvider ? provider : 'other providers'}, not your main model.
) } interface ModelSettingsProps { /** Notified after the main model is applied, so live UI stores can sync. */ onMainModelChanged?: (provider: string, model: string) => void } export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) { const { t } = useI18n() const m = t.settings.model const [loading, setLoading] = useState(true) const [error, setError] = useState('') const [mainModel, setMainModel] = useState<{ model: string; provider: string } | null>(null) const [providers, setProviders] = useState([]) const [selectedProvider, setSelectedProvider] = useState('') const [selectedModel, setSelectedModel] = useState('') const [auxiliary, setAuxiliary] = useState(null) const [applying, setApplying] = useState(false) const [editingAuxTask, setEditingAuxTask] = useState(null) const [auxDraft, setAuxDraft] = useState<{ model: string; provider: string }>({ model: '', provider: '' }) // Aux slots reported stale by the backend immediately after a main-model // switch (provider differs from the new main). Cleared on next switch/reset. const [switchStaleAux, setSwitchStaleAux] = useState([]) const refresh = useCallback(async () => { setLoading(true) setError('') try { const [modelInfo, modelOptions, auxiliaryModels] = await Promise.all([ getGlobalModelInfo(), getGlobalModelOptions(), getAuxiliaryModels() ]) setMainModel({ model: modelInfo.model, provider: modelInfo.provider }) setProviders(modelOptions.providers || []) setSelectedProvider(prev => prev || modelInfo.provider) setSelectedModel(prev => prev || modelInfo.model) setAuxiliary(auxiliaryModels) } catch (err) { setError(err instanceof Error ? err.message : String(err)) } finally { setLoading(false) } }, []) useEffect(() => { void refresh() }, [refresh]) const providerOptions = providers.length ? providers : NO_PROVIDERS const selectedProviderModels = useMemo( () => providers.find(provider => provider.slug === selectedProvider)?.models ?? [], [providers, selectedProvider] ) const auxDraftProviderModels = useMemo( () => providers.find(provider => provider.slug === auxDraft.provider)?.models ?? [], [auxDraft.provider, providers] ) const auxiliaryTaskLabel = useCallback((key: string) => m.tasks[key]?.label ?? key, [m.tasks]) // Persistent mismatch: any aux slot pinned to a provider different from the // current main, regardless of whether the user just switched. Catches the // "I pinned aux months ago and forgot, now it bills a dead provider" case. const persistentStaleAux = useMemo(() => { const mainProvider = (mainModel?.provider ?? '').toLowerCase() if (!mainProvider || !auxiliary) { return [] } return auxiliary.tasks .filter(entry => { const p = (entry.provider ?? '').toLowerCase() return p && p !== 'auto' && p !== mainProvider }) .map(entry => ({ task: entry.task, provider: entry.provider, model: entry.model })) }, [auxiliary, mainModel]) const applyMainModel = useCallback(async () => { if (!selectedProvider || !selectedModel) { return } setApplying(true) setError('') try { const result = await setModelAssignment({ model: selectedModel, provider: selectedProvider, scope: 'main' }) const provider = result.provider || selectedProvider const model = result.model || selectedModel setMainModel({ provider, model }) setSwitchStaleAux(result.stale_aux ?? []) onMainModelChanged?.(provider, model) await refresh() } catch (err) { setError(err instanceof Error ? err.message : String(err)) } finally { setApplying(false) } }, [onMainModelChanged, refresh, selectedModel, selectedProvider]) const setAuxiliaryToMain = useCallback( async (task: string) => { if (!mainModel) { return } setApplying(true) setError('') try { await setModelAssignment({ model: mainModel.model, provider: mainModel.provider, scope: 'auxiliary', task }) await refresh() } catch (err) { setError(err instanceof Error ? err.message : String(err)) } finally { setApplying(false) } }, [mainModel, refresh] ) const applyAuxiliaryDraft = useCallback( async (task: string) => { if (!auxDraft.provider || !auxDraft.model) { return } setApplying(true) setError('') try { await setModelAssignment({ model: auxDraft.model, provider: auxDraft.provider, scope: 'auxiliary', task }) setEditingAuxTask(null) await refresh() } catch (err) { setError(err instanceof Error ? err.message : String(err)) } finally { setApplying(false) } }, [auxDraft, refresh] ) const beginAuxiliaryEdit = useCallback( (task: string) => { const current = auxiliary?.tasks.find(entry => entry.task === task) const initialProvider = current?.provider && current.provider !== 'auto' ? current.provider : (mainModel?.provider ?? '') const initialModel = current?.model || mainModel?.model || '' setAuxDraft({ provider: initialProvider, model: initialModel }) setEditingAuxTask(task) }, [auxiliary, mainModel] ) const resetAuxiliaryModels = useCallback(async () => { if (!mainModel) { return } setApplying(true) setError('') try { await setModelAssignment({ model: mainModel.model, provider: mainModel.provider, scope: 'auxiliary', task: '__reset__' }) setSwitchStaleAux([]) await refresh() } catch (err) { setError(err instanceof Error ? err.message : String(err)) } finally { setApplying(false) } }, [mainModel, refresh]) if (loading && !mainModel) { return } return (

{m.appliesDesc}

{error &&
{error}
} {switchStaleAux.length > 0 && (
void resetAuxiliaryModels()} slots={switchStaleAux} taskLabel={auxiliaryTaskLabel} />
)}

{m.auxiliaryDesc}

{switchStaleAux.length === 0 && persistentStaleAux.length > 0 && (
void resetAuxiliaryModels()} slots={persistentStaleAux} taskLabel={auxiliaryTaskLabel} />
)}
{AUX_TASKS.map(meta => { const copy = m.tasks[meta.key] ?? { label: meta.key, hint: meta.key } const current = auxiliary?.tasks.find(entry => entry.task === meta.key) const isAuto = !current || !current.provider || current.provider === 'auto' const isEditing = editingAuxTask === meta.key return (
) } below={ isEditing && (
) } description={ {isAuto ? m.autoUseMain : `${current.provider} · ${current.model || m.providerDefault}`} } key={meta.key} title={ {copy.label} {copy.hint} } /> ) })}
) }