1369 lines
51 KiB
TypeScript
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>
|
|
)
|
|
}
|