Files
aiturk-hermes-ide/apps/desktop/src/app/settings/model-settings.tsx
T

1369 lines
51 KiB
TypeScript

import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Skeleton } from '@/components/ui/skeleton'
import { Switch } from '@/components/ui/switch'
import {
getAuxiliaryModels,
getGlobalModelInfo,
getGlobalModelOptions,
getMoaModels,
getRecommendedDefaultModel,
saveHermesConfig,
saveMoaModels,
setEnvVar,
setModelAssignment
} from '@/hermes'
import type {
AuxiliaryModelsResponse,
MoaConfigResponse,
MoaModelSlot,
ModelOptionProvider,
StaleAuxAssignment
} from '@/hermes'
import { useI18n } from '@/i18n'
import { isCodeSkewRestartRequired } from '@/lib/code-skew-error'
import { AlertTriangle, Cpu, Loader2 } from '@/lib/icons'
import { DEFAULT_REASONING_EFFORT, REASONING_EFFORT_VALUES } from '@/lib/reasoning-effort'
import { cn } from '@/lib/utils'
import { setMainModelAssignment } from '@/store/cron-model-impact'
import { notifyError, readableError } from '@/store/notifications'
import { startManualLocalEndpoint, startManualOnboarding, startManualProviderOAuth } from '@/store/onboarding'
import { hermesConfigCacheWriter, invalidateHermesConfig, useHermesConfigRecord } from '../hooks/use-config-record'
import { useOnProfileSwitch } from '../hooks/use-on-profile-switch'
import { CONTROL_TEXT } from './constants'
import { getNested, setNested } from './helpers'
import { ListRow, Pill, SectionHeading } from './primitives'
import { useDeepLinkHighlight } from './use-deep-link-highlight'
// Skeleton mirror of the Model settings DOM so the page keeps its shape while
// the provider/model catalog loads, instead of collapsing to a centered
// spinner. Same containers/rhythm as the real render below.
export function ModelSettingsSkeleton() {
return (
<div className="grid gap-6" data-slot="model-settings-skeleton">
<section>
<Skeleton className="mb-3 h-3 w-72 max-w-full" />
<div className="flex flex-wrap items-center gap-2">
<Skeleton className="h-8 w-40" />
<Skeleton className="h-8 w-60 max-w-full" />
<Skeleton className="h-8 w-16" />
</div>
<div className="mt-3 flex flex-wrap items-center gap-x-6 gap-y-3">
<Skeleton className="h-3 w-16" />
<Skeleton className="h-8 w-28" />
<Skeleton className="h-6 w-20" />
</div>
</section>
<section>
<div className="mb-2.5 flex items-center gap-2 pt-2">
<Skeleton className="size-4" />
<Skeleton className="h-4 w-36" />
</div>
<div className="grid gap-1">
{[0, 1, 2, 3].map(row => (
<div
className="grid gap-3 py-3 @2xl:grid-cols-[minmax(0,1fr)_minmax(15rem,22rem)] @2xl:items-center"
key={row}
>
<div className="min-w-0 space-y-1.5">
<Skeleton className="h-3.5 w-32" />
<Skeleton className="h-3 w-52 max-w-full" />
</div>
<Skeleton className="h-8 w-full @2xl:justify-self-end @2xl:w-56" />
</div>
))}
</div>
</section>
</div>
)
}
// agent.service_tier stores "fast"/"priority"/"on" for fast; anything else is
// normal (mirrors tui_gateway _load_service_tier).
const isFastTier = (tier: unknown): boolean =>
['fast', 'priority', 'on'].includes(
String(tier ?? '')
.trim()
.toLowerCase()
)
// A provider row is "ready" to pick a model from when it reports models. The
// backend now surfaces the full `hermes model` universe (every canonical
// provider), so unconfigured providers come back with `authenticated:false`
// and an empty `models` list — those need a setup step before a model exists.
function isProviderReady(p?: ModelOptionProvider): boolean {
return !!p && (p.authenticated !== false || (p.models?.length ?? 0) > 0)
}
// 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: 'compression' },
{ key: 'skills_hub' },
{ key: 'approval' },
{ key: 'mcp' },
{ key: 'title_generation' },
{ key: 'review' },
{ key: 'curator' }
]
const NO_PROVIDERS: readonly ModelOptionProvider[] = [{ name: '—', slug: '', models: [] }]
// Radix <Select> renders a blank trigger when `value` matches no <SelectItem>.
// A custom model (e.g. one added via config that isn't in the provider's
// curated list) would vanish — surface the active value so it stays selectable.
export const withActive = (models: readonly string[], active: string): readonly string[] =>
active && !models.includes(active) ? [active, ...models] : models
// A slot is complete when both halves are chosen. Changing a slot's provider
// intentionally clears its model (see updateMoaSlot), so every provider change
// passes through an incomplete state while the user picks the new model.
export const moaSlotComplete = (slot: MoaModelSlot): boolean => !!(slot.provider.trim() && slot.model.trim())
// True when every slot in every preset is fully specified — the only state
// that is safe to persist. The backend rejects configs with half-filled slots
// (HTTP 422) instead of silently swapping the preset for hardcoded defaults
// (#64156), so the autosave must simply wait for the edit to finish rather
// than trying to "repair" the payload.
export const moaConfigComplete = (config: MoaConfigResponse): boolean =>
Object.values(config.presets).every(
preset =>
preset.reference_models.length > 0 &&
preset.reference_models.every(moaSlotComplete) &&
moaSlotComplete(preset.aggregator)
)
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) {
const { t } = useI18n()
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 (
<div className="flex flex-wrap items-center gap-2 rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-xs text-amber-200">
<AlertTriangle className="size-3.5 shrink-0" />
<span className="grow">
{t.settings.runtime.auxiliaryWarning(
slots.length,
names,
allSameProvider ? provider : t.settings.runtime.otherProviders
)}
</span>
<Button disabled={applying} onClick={onReset} size="sm" variant="textStrong">
{t.settings.model.resetAllToMain}
</Button>
</div>
)
}
interface ModelSettingsProps {
/** Notified after the main model is applied, so live UI stores can sync. */
onMainModelChanged?: (provider: string, model: string) => void
/** Shared settings "Applies to" scope: a concrete profile to edit instead of
* the app's active one, or undefined to follow the active profile (default).
* Request-shaped on purpose — the API helpers treat `null` as "deliberately
* target the primary/default backend", so this prop never carries null. */
scopeProfile?: string
}
export function ModelSettings({ onMainModelChanged, scopeProfile }: ModelSettingsProps) {
const { t } = useI18n()
const m = t.settings.model
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [skewRestart, setSkewRestart] = useState(false)
const [restartingBackend, setRestartingBackend] = useState(false)
const [mainModel, setMainModel] = useState<{ model: string; provider: string } | null>(null)
const [providers, setProviders] = useState<ModelOptionProvider[]>([])
const [selectedProvider, setSelectedProvider] = useState('')
const [selectedModel, setSelectedModel] = useState('')
const [auxiliary, setAuxiliary] = useState<AuxiliaryModelsResponse | null>(null)
const [moa, setMoa] = useState<MoaConfigResponse | null>(null)
const [selectedMoaPreset, setSelectedMoaPreset] = useState('')
const [newMoaPresetName, setNewMoaPresetName] = useState('')
// agent.* defaults round-trip through the shared config cache (read → write
// back the whole record), so a save here shows in the MCP/model surfaces.
const { data: config } = useHermesConfigRecord(scopeProfile)
const setConfig = useMemo(() => hermesConfigCacheWriter(scopeProfile), [scopeProfile])
const [applying, setApplying] = useState(false)
const [editingAuxTask, setEditingAuxTask] = useState<null | string>(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<StaleAuxAssignment[]>([])
// Inline API-key entry for picking an unconfigured `api_key` provider in
// place — mirrors the onboarding ApiKeyForm but scoped to the model picker.
const [apiKeyDraft, setApiKeyDraft] = useState('')
const [activating, setActivating] = useState(false)
// Deep link from the vision Capabilities detail (?tab=config:model&aux=vision):
// scroll the auxiliary task row into view and flash it once the list loads.
useDeepLinkHighlight({
elementId: task => `aux-task-${task}`,
param: 'aux',
ready: task => AUX_TASKS.some(meta => meta.key === task)
})
// Every profile-scoped async here captures this and bails before writing back,
// so a request in flight when the user switches profiles can't paint profile
// A's models/providers into profile B (or fire onMainModelChanged for A).
const profileEpoch = useRef(0)
const setCaughtError = useCallback(
(err: unknown, fallback: string) => {
const skew = isCodeSkewRestartRequired(err)
setSkewRestart(skew)
setError(skew ? m.restartRequired : readableError(err, fallback).message)
},
[m.restartRequired]
)
const refresh = useCallback(
async ({ replaceSelection = false }: { replaceSelection?: boolean } = {}) => {
const epoch = profileEpoch.current
setLoading(true)
setError('')
setSkewRestart(false)
try {
const [modelInfo, modelOptions, auxiliaryModels, moaModels] = await Promise.all([
getGlobalModelInfo(scopeProfile),
getGlobalModelOptions(undefined, scopeProfile),
getAuxiliaryModels(scopeProfile),
getMoaModels(scopeProfile).catch(() => null)
])
if (profileEpoch.current !== epoch) {
return
}
setMainModel({ model: modelInfo.model, provider: modelInfo.provider })
setProviders(modelOptions.providers || [])
if (replaceSelection) {
setSelectedProvider(modelInfo.provider)
setSelectedModel(modelInfo.model)
} else {
setSelectedProvider(prev => prev || modelInfo.provider)
setSelectedModel(prev => prev || modelInfo.model)
}
setAuxiliary(auxiliaryModels)
setMoa(moaModels)
if (moaModels) {
setSelectedMoaPreset(prev => (prev && moaModels.presets[prev] ? prev : moaModels.default_preset))
}
// The config record loads via its own shared query; a model switch can
// change it server-side (aux slots), so nudge that cache to refetch.
void invalidateHermesConfig(scopeProfile)
} catch (err) {
if (profileEpoch.current === epoch) {
setCaughtError(err, m.loadFailed)
}
} finally {
if (profileEpoch.current === epoch) {
setLoading(false)
}
}
},
[m.loadFailed, scopeProfile, setCaughtError]
)
useEffect(() => {
void refresh()
}, [refresh])
// A profile switch swaps the backend under the mounted panel — reload for the
// new profile (bumping the epoch first so any in-flight A request is discarded).
useOnProfileSwitch(() => {
profileEpoch.current += 1
// The panel stays mounted across profile switches, so clear the previous
// profile's draft selection before loading the new profile's source of
// truth. Ordinary same-profile refreshes still preserve in-progress edits.
setSelectedProvider('')
setSelectedModel('')
setApiKeyDraft('')
void refresh({ replaceSelection: true })
})
const providerOptions = providers.length ? providers : NO_PROVIDERS
// Radix renders a blank trigger when the controlled value has no matching
// item. Keep a missing saved provider visible in the main selector while
// leaving it out of the real inventory used for readiness/setup metadata.
const mainProviderOptions = useMemo(
() =>
selectedProvider && !providers.some(provider => provider.slug === selectedProvider)
? [{ name: selectedProvider, slug: selectedProvider, models: [] }, ...providers]
: providerOptions,
[providerOptions, providers, selectedProvider]
)
// MoA reference/aggregator slots must never be the moa virtual provider —
// that would create a recursive MoA tree (the backend rejects it on save).
// Hide it from the slot selectors so it isn't offered as a dead choice.
const moaSlotProviderOptions = providerOptions.filter(provider => (provider.slug || '').toLowerCase() !== 'moa')
const selectedProviderRow = useMemo(
() => providers.find(provider => provider.slug === selectedProvider),
[providers, selectedProvider]
)
const selectedProviderModels = selectedProviderRow?.models ?? []
// An unconfigured provider was picked: no credentials yet, so there are no
// models to choose. `api_key` providers can be activated inline (paste key);
// OAuth / external flows hand off to the onboarding sign-in.
const needsSetup = !!selectedProvider && !isProviderReady(selectedProviderRow)
const setupIsApiKey = needsSetup && selectedProviderRow?.auth_type === 'api_key' && !!selectedProviderRow?.key_env
// Clear any half-typed key when switching provider so it can't leak across.
useEffect(() => {
setApiKeyDraft('')
}, [selectedProvider])
const auxDraftProviderModels = useMemo(
() => providers.find(provider => provider.slug === auxDraft.provider)?.models ?? [],
[auxDraft.provider, providers]
)
const modelsForProvider = useCallback(
(provider: string) => providers.find(row => row.slug === provider)?.models ?? [],
[providers]
)
const currentMoaPreset = useMemo(() => {
if (!moa) {
return null
}
return moa.presets[selectedMoaPreset] || moa.presets[moa.default_preset] || Object.values(moa.presets)[0] || null
}, [moa, selectedMoaPreset])
// Mirror of `moa` so inline edits compute the next state purely (outside the
// setState updater) and hand it straight to the debounced autosave.
const moaRef = useRef<MoaConfigResponse | null>(null)
// eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment)
useEffect(() => {
moaRef.current = moa
}, [moa])
const moaSaveTimer = useRef<number | null>(null)
useEffect(
() => () => {
if (moaSaveTimer.current) {
window.clearTimeout(moaSaveTimer.current)
}
},
[]
)
// Guard against stale save responses overwriting newer state.
const moaSaveGeneration = useRef(0)
// Quiet debounced persist for inline MoA edits — mirrors the config page's
// autosave so slot/aggregator tweaks save themselves, matching the
// preset-level ops (set default / add / delete) that already persist on
// click. No `applying` spinner, so selecting stays responsive.
//
// While any slot is half-filled (provider picked, model pending) the save is
// HELD, not sent: the previous complete config stays on disk and the next
// edit that completes the slot flushes the whole preset. Every edit bumps
// the generation so an in-flight response from an older save can never
// repaint over the user's mid-edit state.
const scheduleMoaSave = useCallback(
(next: MoaConfigResponse) => {
if (moaSaveTimer.current) {
window.clearTimeout(moaSaveTimer.current)
moaSaveTimer.current = null
}
const generation = moaSaveGeneration.current + 1
moaSaveGeneration.current = generation
if (!moaConfigComplete(next)) {
return
}
moaSaveTimer.current = window.setTimeout(() => {
void saveMoaModels(next, scopeProfile)
.then(saved => {
if (moaSaveGeneration.current === generation) {
setMoa(saved)
}
})
.catch(err => {
if (moaSaveGeneration.current === generation) {
setCaughtError(err, m.loadFailed)
}
})
}, 600)
},
[m.loadFailed, scopeProfile, setCaughtError]
)
const updateMoaPreset = useCallback(
(updater: (preset: NonNullable<typeof currentMoaPreset>) => NonNullable<typeof currentMoaPreset>) => {
const prev = moaRef.current
if (!prev || !selectedMoaPreset || !prev.presets[selectedMoaPreset]) {
return
}
const next: MoaConfigResponse = {
...prev,
presets: {
...prev.presets,
[selectedMoaPreset]: updater(prev.presets[selectedMoaPreset])
}
}
moaRef.current = next
setMoa(next)
scheduleMoaSave(next)
},
[scheduleMoaSave, selectedMoaPreset]
)
const updateMoaSlot = useCallback((slot: MoaModelSlot, patch: Partial<MoaModelSlot>): MoaModelSlot => {
const next = { ...slot, ...patch }
// Picking a new provider invalidates the model choice (models are
// per-provider). A same-provider update must not wipe the model — Radix
// filters same-value changes, but programmatic callers may not.
if (patch.provider && patch.provider !== slot.provider) {
next.model = ''
}
return next
}, [])
const saveMoa = useCallback(
async (next: MoaConfigResponse) => {
const epoch = profileEpoch.current
// Explicit preset ops (set default / add / delete) supersede any pending
// debounced slot autosave — cancel it and invalidate in-flight responses
// so the two writers can't race each other's state.
if (moaSaveTimer.current) {
window.clearTimeout(moaSaveTimer.current)
moaSaveTimer.current = null
}
moaSaveGeneration.current += 1
setApplying(true)
setError('')
try {
const saved = await saveMoaModels(next, scopeProfile)
if (profileEpoch.current !== epoch) {
return
}
setMoa(saved)
} catch (err) {
setCaughtError(err, m.loadFailed)
} finally {
setApplying(false)
}
},
[m.loadFailed, scopeProfile, setCaughtError]
)
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<StaleAuxAssignment[]>(() => {
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])
// Capabilities of the APPLIED main model — gates the profile-default
// reasoning/speed controls the same way the composer picker gates per-model
// edits (reasoning defaults on, fast defaults off when unreported).
const mainCaps = useMemo(() => {
const row = providers.find(provider => provider.slug === mainModel?.provider)
return mainModel ? row?.capabilities?.[mainModel.model] : undefined
}, [providers, mainModel])
const reasoningSupported = mainCaps?.reasoning ?? true
const fastSupported = mainCaps?.fast ?? false
// Hand-written `reasoning_effort: false`/`off` reaches us as boolean false
// ("false" once stringified) — show it as Off, not an empty select.
const rawEffort = String(getNested(config ?? {}, 'agent.reasoning_effort') ?? '')
.trim()
.toLowerCase()
const effortValue = rawEffort === 'false' || rawEffort === 'disabled' ? 'none' : rawEffort || DEFAULT_REASONING_EFFORT
const fastOn = isFastTier(getNested(config ?? {}, 'agent.service_tier'))
// Persist a single agent.* default by round-tripping the whole config record
// (PUT /api/config replaces it) — optimistic, with rollback on failure.
const writeAgentDefault = useCallback(
async (key: string, value: string) => {
if (!config) {
return
}
const prev = config
const next = setNested(config, key, value)
setConfig(next)
try {
await saveHermesConfig(next, scopeProfile)
} catch (err) {
setConfig(prev)
notifyError(err, m.defaultsFailed)
}
},
[config, m.defaultsFailed, scopeProfile, setConfig]
)
// Paste an API key for the selected `api_key` provider, persist it, then
// refresh so the now-authenticated provider's models populate. Auto-selects
// the recommended default model so the user can Apply in one more click.
const activateApiKeyProvider = useCallback(async () => {
const keyEnv = selectedProviderRow?.key_env
const slug = selectedProviderRow?.slug
if (!keyEnv || !slug || !apiKeyDraft.trim()) {
return
}
const epoch = profileEpoch.current
setActivating(true)
setError('')
try {
await setEnvVar(keyEnv, apiKeyDraft.trim(), scopeProfile)
setApiKeyDraft('')
// Pick a sensible default for the freshly-activated provider (mirrors
// `hermes model` curation). Best-effort — fall through to the refreshed
// model list if it fails.
let nextModel = ''
try {
const rec = await getRecommendedDefaultModel(slug, scopeProfile)
nextModel = rec.model || ''
} catch {
nextModel = ''
}
const options = await getGlobalModelOptions(undefined, scopeProfile)
if (profileEpoch.current !== epoch) {
return
}
setProviders(options.providers || [])
const refreshedRow = options.providers?.find(p => p.slug === slug)
const fallbackModel = refreshedRow?.models?.[0] ?? ''
setSelectedModel(nextModel || fallbackModel)
} catch (err) {
setCaughtError(err, m.loadFailed)
} finally {
setActivating(false)
}
}, [apiKeyDraft, m.loadFailed, scopeProfile, selectedProviderRow, setCaughtError])
// OAuth / external providers can't be activated with a pasted key — hand off
// to the shared onboarding flow scoped to this provider's real sign-in. The
// custom / local endpoint is NOT an OAuth provider, so it gets the dedicated
// local-endpoint form (URL + optional API key) instead of being dead-ended
// on the OAuth picker (the original "booted back to the first screen" loop).
const startProviderSetup = useCallback(() => {
const rowSlug = selectedProviderRow?.slug.trim() ?? ''
const slug = rowSlug || selectedProvider.trim()
if (!slug) {
return
}
const lower = slug.toLowerCase()
if (lower === 'custom' || lower === 'local' || lower.startsWith('custom:')) {
startManualLocalEndpoint()
} else if (rowSlug) {
startManualProviderOAuth(rowSlug)
} else {
// An absent row has no trustworthy auth metadata. Open the generic
// provider picker instead of deep-linking an unknown or stale slug.
startManualOnboarding()
}
}, [selectedProvider, selectedProviderRow])
const applyMainModel = useCallback(async () => {
if (!selectedProvider || !selectedModel) {
return
}
const epoch = profileEpoch.current
setApplying(true)
setError('')
try {
const result = await setMainModelAssignment(
{
model: selectedModel,
provider: selectedProvider,
...(selectedProviderRow?.api_url ? { base_url: selectedProviderRow.api_url } : {})
},
scopeProfile
)
if (profileEpoch.current !== epoch) {
return
}
const provider = result.provider || selectedProvider
const model = result.model || selectedModel
setMainModel({ provider, model })
setSwitchStaleAux(result.stale_aux ?? [])
// Live UI stores mirror the ACTIVE profile's model; a scoped apply
// changed a different profile and must not repaint them.
if (scopeProfile == null) {
onMainModelChanged?.(provider, model)
}
await refresh()
} catch (err) {
setCaughtError(err, m.loadFailed)
} finally {
setApplying(false)
}
}, [
m.loadFailed,
onMainModelChanged,
refresh,
scopeProfile,
selectedModel,
selectedProvider,
selectedProviderRow,
setCaughtError
])
// Sibling of the applyMainModel endpoint passthrough (#65254): auxiliary
// assignments targeting a user-defined provider must carry that provider's
// endpoint too, or the backend pins the slot without a base_url and the
// aux resolver falls back to the (possibly different, possibly cleared)
// main endpoint.
const endpointForProvider = useCallback(
(provider: string) => {
const row = providers.find(entry => entry.slug === provider)
return row?.api_url ? { base_url: row.api_url } : {}
},
[providers]
)
const setAuxiliaryToMain = useCallback(
async (task: string) => {
if (!mainModel) {
return
}
setApplying(true)
setError('')
try {
await setModelAssignment(
{
model: mainModel.model,
provider: mainModel.provider,
scope: 'auxiliary',
task,
...endpointForProvider(mainModel.provider)
},
scopeProfile
)
await refresh()
} catch (err) {
setCaughtError(err, m.loadFailed)
} finally {
setApplying(false)
}
},
[endpointForProvider, m.loadFailed, mainModel, refresh, scopeProfile, setCaughtError]
)
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,
...endpointForProvider(auxDraft.provider)
},
scopeProfile
)
setEditingAuxTask(null)
await refresh()
} catch (err) {
setCaughtError(err, m.loadFailed)
} finally {
setApplying(false)
}
},
[auxDraft, endpointForProvider, m.loadFailed, refresh, scopeProfile, setCaughtError]
)
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__'
},
scopeProfile
)
setSwitchStaleAux([])
await refresh()
} catch (err) {
setCaughtError(err, m.loadFailed)
} finally {
setApplying(false)
}
}, [m.loadFailed, mainModel, refresh, scopeProfile, setCaughtError])
const recycleStaleBackend = useCallback(async () => {
setRestartingBackend(true)
setError('')
setSkewRestart(false)
try {
await window.hermesDesktop?.recycleBackend?.(scopeProfile)
await refresh({ replaceSelection: true })
} catch (err) {
setCaughtError(err, m.restartFailed)
} finally {
setRestartingBackend(false)
}
}, [m.restartFailed, refresh, scopeProfile, setCaughtError])
if (loading && !mainModel) {
return <ModelSettingsSkeleton />
}
return (
<div className="grid gap-6">
<section>
<p className="mb-3 text-xs text-muted-foreground">{m.appliesDesc}</p>
<div className="flex flex-wrap items-center gap-2">
<Select onValueChange={setSelectedProvider} value={selectedProvider}>
<SelectTrigger className={cn('min-w-40', CONTROL_TEXT)}>
<SelectValue placeholder={m.provider} />
</SelectTrigger>
<SelectContent>
{mainProviderOptions.map(provider => (
<SelectItem key={provider.slug || 'none'} value={provider.slug || 'none'}>
{provider.name}
</SelectItem>
))}
</SelectContent>
</Select>
{needsSetup ? (
setupIsApiKey ? (
<>
<Input
autoComplete="off"
className={cn('min-w-60 flex-1', CONTROL_TEXT)}
onChange={event => setApiKeyDraft(event.target.value)}
onKeyDown={event => {
if (event.key === 'Enter') {
void activateApiKeyProvider()
}
}}
placeholder={t.settings.runtime.pasteProviderKey(
selectedProviderRow?.key_env ?? t.settings.runtime.apiKey
)}
type="password"
value={apiKeyDraft}
/>
<Button
disabled={!apiKeyDraft.trim() || activating}
onClick={() => void activateApiKeyProvider()}
size="sm"
>
{activating && <Loader2 className="size-3.5 animate-spin" />}
{activating ? t.settings.runtime.activatingProvider : t.settings.runtime.activateProvider}
</Button>
</>
) : (
<Button onClick={startProviderSetup} size="sm" variant="textStrong">
{t.settings.runtime.setupProvider(selectedProviderRow?.name ?? t.settings.runtime.provider)}
</Button>
)
) : (
<>
<Select onValueChange={setSelectedModel} value={selectedModel}>
<SelectTrigger className={cn('min-w-60', CONTROL_TEXT)}>
<SelectValue placeholder={m.model} />
</SelectTrigger>
<SelectContent>
{withActive(selectedProviderModels, selectedModel).map(model => (
<SelectItem key={model} value={model}>
{model}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
disabled={!selectedProvider || !selectedModel || applying}
onClick={() => void applyMainModel()}
size="sm"
>
{applying && <Loader2 className="size-3.5 animate-spin" />}
{applying ? m.applying : t.common.apply}
</Button>
</>
)}
</div>
{needsSetup && !setupIsApiKey && selectedProviderRow && (
<p className="mt-2 text-xs text-muted-foreground">
{selectedProviderRow?.auth_type === 'api_key'
? `${selectedProviderRow?.name} needs an API key — set it up to choose a model.`
: `${selectedProviderRow?.name} signs in through your browser — Hermes runs the flow for you.`}
</p>
)}
{config && mainModel && (reasoningSupported || fastSupported) && (
<div className="mt-3 flex flex-wrap items-center gap-x-6 gap-y-3">
<span className="text-xs text-muted-foreground">{m.defaultsLabel}</span>
{reasoningSupported && (
<div className="flex items-center gap-2 text-xs">
{m.reasoning}
<Select
onValueChange={value => void writeAgentDefault('agent.reasoning_effort', value)}
value={effortValue}
>
<SelectTrigger className={cn('min-w-28', CONTROL_TEXT)}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{REASONING_EFFORT_VALUES.map(value => (
<SelectItem key={value} value={value}>
{value === 'none' ? m.reasoningOff : t.shell.modelOptions[value]}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{fastSupported && (
<label className="flex items-center gap-2 text-xs">
{t.shell.modelOptions.fast}
<Switch
checked={fastOn}
onCheckedChange={checked => void writeAgentDefault('agent.service_tier', checked ? 'fast' : 'normal')}
size="xs"
/>
</label>
)}
</div>
)}
{error && (
<div className="mt-2 flex flex-wrap items-center gap-2 text-xs text-destructive">
<span>{error}</span>
{skewRestart && (
<Button
disabled={restartingBackend}
onClick={() => void recycleStaleBackend()}
size="sm"
variant="textStrong"
>
{restartingBackend && <Loader2 className="size-3.5 animate-spin" />}
{restartingBackend ? m.restartingBackend : m.restartBackend}
</Button>
)}
</div>
)}
{switchStaleAux.length > 0 && (
<div className="mt-2">
<StaleAuxWarning
applying={applying}
onReset={() => void resetAuxiliaryModels()}
slots={switchStaleAux}
taskLabel={auxiliaryTaskLabel}
/>
</div>
)}
</section>
<section>
<div className="mb-2.5 flex items-center justify-between">
<SectionHeading icon={Cpu} title={m.auxiliaryTitle} />
<Button
disabled={!mainModel || applying}
onClick={() => void resetAuxiliaryModels()}
size="sm"
variant="textStrong"
>
{m.resetAllToMain}
</Button>
</div>
<p className="mb-2 text-xs text-muted-foreground">{m.auxiliaryDesc}</p>
{switchStaleAux.length === 0 && persistentStaleAux.length > 0 && (
<div className="mb-2.5">
<StaleAuxWarning
applying={applying}
onReset={() => void resetAuxiliaryModels()}
slots={persistentStaleAux}
taskLabel={auxiliaryTaskLabel}
/>
</div>
)}
<div className="grid gap-1">
{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 (
<div className="scroll-mt-6 rounded-lg" id={`aux-task-${meta.key}`} key={meta.key}>
<ListRow
action={
!isEditing && (
<div className="flex shrink-0 items-center gap-1.5">
<Button
disabled={!mainModel || applying}
onClick={() => void setAuxiliaryToMain(meta.key)}
size="sm"
variant="text"
>
{m.setToMain}
</Button>
<Button
disabled={!providers.length || applying}
onClick={() => beginAuxiliaryEdit(meta.key)}
size="sm"
variant="textStrong"
>
{m.change}
</Button>
</div>
)
}
below={
isEditing && (
<div className="mt-2 flex flex-wrap items-center gap-2 pt-1">
<Select
onValueChange={value => setAuxDraft(prev => ({ ...prev, provider: value, model: '' }))}
value={auxDraft.provider}
>
<SelectTrigger className={cn('min-w-32', CONTROL_TEXT)}>
<SelectValue placeholder={m.provider} />
</SelectTrigger>
<SelectContent>
{providerOptions.map(provider => (
<SelectItem key={provider.slug || 'none'} value={provider.slug || 'none'}>
{provider.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
onValueChange={value => setAuxDraft(prev => ({ ...prev, model: value }))}
value={auxDraft.model}
>
<SelectTrigger className={cn('min-w-48', CONTROL_TEXT)}>
<SelectValue placeholder={m.model} />
</SelectTrigger>
<SelectContent>
{withActive(auxDraftProviderModels, auxDraft.model).map(model => (
<SelectItem key={model} value={model}>
{model}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
disabled={!auxDraft.provider || !auxDraft.model || applying}
onClick={() => void applyAuxiliaryDraft(meta.key)}
size="sm"
>
{applying ? m.applying : t.common.apply}
</Button>
<Button onClick={() => setEditingAuxTask(null)} size="sm" variant="ghost">
{t.common.cancel}
</Button>
</div>
)
}
description={
<span className="font-mono text-[0.68rem]">
{isAuto ? m.autoUseMain : `${current.provider} · ${current.model || m.providerDefault}`}
</span>
}
title={
<span className="flex items-baseline gap-2">
{copy.label}
<Pill>{copy.hint}</Pill>
</span>
}
/>
</div>
)
})}
</div>
</section>
{moa && currentMoaPreset && (
<section>
<SectionHeading icon={Cpu} title={t.settings.runtime.moa.title} />
<p className="mb-2 text-xs text-muted-foreground">{t.settings.runtime.moa.description}</p>
<div className="mb-2 flex flex-wrap items-center gap-2">
<Select onValueChange={setSelectedMoaPreset} value={selectedMoaPreset || moa.default_preset}>
<SelectTrigger className={cn('min-w-40', CONTROL_TEXT)}>
<SelectValue placeholder={t.settings.runtime.moa.preset} />
</SelectTrigger>
<SelectContent>
{Object.keys(moa.presets).map(name => (
<SelectItem key={name} value={name}>
{name}
</SelectItem>
))}
</SelectContent>
</Select>
<label className="flex items-center gap-2 rounded-sm border border-border px-2 py-1 text-xs">
{t.settings.runtime.moa.enabled}
<Switch
checked={currentMoaPreset.enabled !== false}
disabled={applying}
onCheckedChange={checked => updateMoaPreset(prev => ({ ...prev, enabled: checked }))}
size="xs"
/>
</label>
<Button
disabled={applying}
onClick={() => {
const next: MoaConfigResponse = {
...moa,
default_preset: selectedMoaPreset || moa.default_preset
}
void saveMoa(next)
}}
size="sm"
variant="text"
>
{t.settings.runtime.moa.setDefault}
</Button>
<Button
disabled={Object.keys(moa.presets).length <= 1 || applying}
onClick={() => {
if (Object.keys(moa.presets).length <= 1) {
return
}
const presets = { ...moa.presets }
delete presets[selectedMoaPreset]
const fallback = Object.keys(presets)[0]
const next: MoaConfigResponse = {
...moa,
presets,
default_preset: moa.default_preset === selectedMoaPreset ? fallback : moa.default_preset,
active_preset: moa.active_preset === selectedMoaPreset ? '' : moa.active_preset
}
setSelectedMoaPreset(Object.keys(moa.presets).find(name => name !== selectedMoaPreset) || '')
void saveMoa(next)
}}
size="sm"
variant="ghost"
>
{t.common.delete}
</Button>
<Input
className={cn('w-40', CONTROL_TEXT)}
onChange={event => setNewMoaPresetName(event.target.value)}
placeholder={t.settings.runtime.moa.newPreset}
value={newMoaPresetName}
/>
<Button
disabled={!newMoaPresetName.trim() || !!moa.presets[newMoaPresetName.trim()] || applying}
onClick={() => {
const name = newMoaPresetName.trim()
const next: MoaConfigResponse = {
...moa,
presets: {
...moa.presets,
[name]: { ...currentMoaPreset, reference_models: [...currentMoaPreset.reference_models] }
}
}
setSelectedMoaPreset(name)
setNewMoaPresetName('')
void saveMoa(next)
}}
size="sm"
variant="textStrong"
>
{t.settings.runtime.moa.addPreset}
</Button>
</div>
<div className="mb-2 text-xs text-muted-foreground">
{t.settings.runtime.moa.default} <span className="font-mono">{moa.default_preset}</span>
</div>
<div className="grid gap-1">
{currentMoaPreset.reference_models.map((slot, index) => (
<ListRow
action={
<Switch
aria-label={t.settings.runtime.moa.toggleReference(index + 1, slot.enabled === false)}
checked={slot.enabled !== false}
disabled={applying}
onCheckedChange={checked =>
updateMoaPreset(prev => ({
...prev,
reference_models: prev.reference_models.map((s, i) =>
i === index ? { ...s, enabled: checked === true } : s
)
}))
}
/>
}
below={
<div className="mt-2 flex flex-wrap items-center gap-2 pt-1">
<Select
onValueChange={value =>
updateMoaPreset(prev => ({
...prev,
reference_models: prev.reference_models.map((s, i) =>
i === index ? updateMoaSlot(s, { provider: value }) : s
)
}))
}
value={slot.provider}
>
<SelectTrigger className={cn('min-w-32', CONTROL_TEXT)}>
<SelectValue placeholder={m.provider} />
</SelectTrigger>
<SelectContent>
{withActive(
moaSlotProviderOptions.map(p => p.slug || 'none'),
slot.provider
).map(slug => {
const provider = moaSlotProviderOptions.find(p => (p.slug || 'none') === slug)
return (
<SelectItem key={slug} value={slug}>
{provider?.name || slug}
</SelectItem>
)
})}
</SelectContent>
</Select>
<Select
onValueChange={value =>
updateMoaPreset(prev => ({
...prev,
reference_models: prev.reference_models.map((s, i) =>
i === index ? updateMoaSlot(s, { model: value }) : s
)
}))
}
value={slot.model}
>
<SelectTrigger className={cn('min-w-48', CONTROL_TEXT)}>
<SelectValue placeholder={m.model} />
</SelectTrigger>
<SelectContent>
{withActive(modelsForProvider(slot.provider), slot.model).map(model => (
<SelectItem key={model} value={model}>
{model}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
disabled={currentMoaPreset.reference_models.length <= 1 || applying}
onClick={() =>
updateMoaPreset(prev => ({
...prev,
reference_models: prev.reference_models.filter((_, i) => i !== index)
}))
}
size="sm"
variant="ghost"
>
{t.common.remove}
</Button>
</div>
}
className={cn(slot.enabled === false && 'opacity-60')}
description={
<span className="font-mono text-[0.68rem]">
{slot.provider} · {slot.model || m.model}
</span>
}
key={`${selectedMoaPreset}-${index}`}
title={t.settings.runtime.moa.reference(index + 1)}
/>
))}
<Button
disabled={applying}
onClick={() =>
updateMoaPreset(prev => ({
...prev,
reference_models: [...prev.reference_models, { ...prev.aggregator, enabled: true }]
}))
}
size="sm"
variant="textStrong"
>
{t.settings.runtime.moa.addReference}
</Button>
<ListRow
below={
<div className="mt-2 flex flex-wrap items-center gap-2 pt-1">
<Select
onValueChange={value =>
updateMoaPreset(prev => ({
...prev,
aggregator: updateMoaSlot(prev.aggregator, { provider: value })
}))
}
value={currentMoaPreset.aggregator.provider}
>
<SelectTrigger className={cn('min-w-32', CONTROL_TEXT)}>
<SelectValue placeholder={m.provider} />
</SelectTrigger>
<SelectContent>
{withActive(
moaSlotProviderOptions.map(p => p.slug || 'none'),
currentMoaPreset.aggregator.provider
).map(slug => {
const provider = moaSlotProviderOptions.find(p => (p.slug || 'none') === slug)
return (
<SelectItem key={slug} value={slug}>
{provider?.name || slug}
</SelectItem>
)
})}
</SelectContent>
</Select>
<Select
onValueChange={value =>
updateMoaPreset(prev => ({
...prev,
aggregator: updateMoaSlot(prev.aggregator, { model: value })
}))
}
value={currentMoaPreset.aggregator.model}
>
<SelectTrigger className={cn('min-w-48', CONTROL_TEXT)}>
<SelectValue placeholder={m.model} />
</SelectTrigger>
<SelectContent>
{withActive(
modelsForProvider(currentMoaPreset.aggregator.provider),
currentMoaPreset.aggregator.model
).map(model => (
<SelectItem key={model} value={model}>
{model}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
}
description={
<span className="font-mono text-[0.68rem]">
{currentMoaPreset.aggregator.provider} · {currentMoaPreset.aggregator.model}
</span>
}
title={t.settings.runtime.moa.aggregator}
/>
</div>
</section>
)}
</div>
)
}