import { useStore } from '@nanostores/react' import { useQuery } from '@tanstack/react-query' import type { ChangeEvent } from 'react' import { useEffect, useMemo, useRef, useState } from 'react' import { useSearchParams } from 'react-router' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { getElevenLabsVoices, getHermesConfigSchema, saveHermesConfig } from '@/hermes' import { useI18n } from '@/i18n' import { triggerHaptic } from '@/lib/haptics' import { confirm } from '@/store/confirm' import { $dataUrlReadMaxMb, clampDataUrlReadMaxMb, DATA_URL_READ_DEFAULT_MAX_MB, DATA_URL_READ_MAX_MAX_MB, DATA_URL_READ_MIN_MAX_MB, refreshDataUrlReadMaxMb, setDataUrlReadMaxMb } from '@/store/data-url-read-max' import { $disableF12, setDisableF12 } from '@/store/disable-f12' import { $keepAwake, setKeepAwake } from '@/store/keep-awake' import { notify, notifyError } from '@/store/notifications' import { normalizeProfileKey } from '@/store/profile' import { repoDiscoveryPolicyFromConfig, repoDiscoveryPolicySignature, scanAndRecordRepos } from '@/store/projects' import { $settingsRequestProfile } from '@/store/settings-scope' import type { ConfigFieldSchema, HermesConfigRecord } from '@/types/hermes' import { hermesConfigCacheWriter, useHermesConfigRecord } from '../hooks/use-config-record' import { useOnProfileSwitch } from '../hooks/use-on-profile-switch' import { PanelEmpty } from '../overlays/panel' import { ConfigField } from './config-field' import { clearsEnabledToolsets, diffConfig, enumOptionsFor, getNested, isExternalMemoryProvider, sectionFieldEntries, setNested, voiceFieldVisible } from './helpers' import { MemoryConnect } from './memory/connect' import { ProviderConfigPanel } from './memory/provider-config-panel' import { ModelSettings, ModelSettingsSkeleton } from './model-settings' import { PoolLimitsSetting } from './pool-limits-setting' import { EmptyState, ListRow, SettingsContent, SettingsSkeleton, ToggleRow } from './primitives' import { SettingsProfileScope } from './profile-scope' import { QuickEntrySettings } from './quick-entry-settings' export function ConfigSettings({ activeSectionId, onConfigSaved, onMainModelChanged, importInputRef }: ConfigSettingsProps) { // Shared "Applies to" scope (null → the app's active profile). Remount the // inner page per scope so every draft/seed/autosave ref resets wholesale // when the target profile changes — the same guarantee useOnProfileSwitch // provides for app-wide switches, without hand-clearing each piece. const scopeProfile = useStore($settingsRequestProfile) return ( ) } interface ConfigSettingsProps { activeSectionId: string onConfigSaved?: () => void onMainModelChanged?: (provider: string, model: string) => void importInputRef: React.RefObject } function ConfigSettingsInner({ activeSectionId, onConfigSaved, onMainModelChanged, importInputRef, scopeProfile }: ConfigSettingsProps & { scopeProfile: string | undefined }) { const { t } = useI18n() const c = t.settings.config const keepAwake = useStore($keepAwake) const disableF12 = useStore($disableF12) // The editable draft is local (debounced autosave watches it), but it's seeded // from — and saved back through — the shared config cache, so edits are visible // in the MCP/model surfaces and reopening the page doesn't reload-flash. const [config, setConfig] = useState(null) const { data: loadedConfig, isError: configLoadFailed, refetch: refetchConfig } = useHermesConfigRecord(scopeProfile) // Writes land on the same cache key the query above reads (base key when // following the active profile, suffixed when a scope override is set). const writeConfigCache = useMemo(() => hermesConfigCacheWriter(scopeProfile), [scopeProfile]) const { data: schemaResponse, isError: schemaFailed, refetch: refetchSchema } = useQuery({ // Base key when following the active profile (matches every pre-existing // consumer); suffixed only for an explicit scope override. queryKey: scopeProfile == null ? ['hermes-config-schema'] : ['hermes-config-schema', normalizeProfileKey(scopeProfile)], queryFn: () => getHermesConfigSchema(scopeProfile), staleTime: 5 * 60 * 1000 }) const schema = schemaResponse?.fields ?? null const [elevenLabsVoiceOptions, setElevenLabsVoiceOptions] = useState(null) const [elevenLabsVoiceLabels, setElevenLabsVoiceLabels] = useState>({}) const saveVersionRef = useRef(0) const savedDiscoverySignatureRef = useRef(undefined) const [saveVersion, setSaveVersion] = useState(0) // Seed the local draft once, the first time the shared record lands. // Background refetches thereafter must not clobber in-progress edits. const configSeeded = useRef(false) // Snapshot of the record as it was when the draft was seeded. Autosave // diffs the draft against this (not against disk) so a field the user // never touched — possibly changed out-of-band by `hermes config set` // while this page sat open — is never resent with its stale value. const configBaselineRef = useRef(null) // Serializes autosave requests so an older save that's still in flight can't // resolve after a newer one and re-advance the baseline / cache with stale // data — each save's diff+request only starts once the previous one lands. const saveQueueRef = useRef>(Promise.resolve()) // eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment) useEffect(() => { if (loadedConfig && !configSeeded.current) { configSeeded.current = true configBaselineRef.current = loadedConfig savedDiscoverySignatureRef.current = repoDiscoveryPolicySignature(repoDiscoveryPolicyFromConfig(loadedConfig)) setConfig(loadedConfig) } }, [loadedConfig]) // A profile switch invalidates (but doesn't clear) the shared config query, so // the local draft would otherwise keep profile A's data and autosave it into // B. Drop the seed + draft (re-seeds from B's refetch) and zero saveVersion so // the pending debounced autosave is cancelled by its effect cleanup. useOnProfileSwitch(() => { configSeeded.current = false configBaselineRef.current = null savedDiscoverySignatureRef.current = undefined setConfig(null) saveVersionRef.current = 0 setSaveVersion(0) saveQueueRef.current = Promise.resolve() }) useEffect(() => { let cancelled = false getElevenLabsVoices(scopeProfile) .then(result => { if (cancelled || !result.available) { return } setElevenLabsVoiceOptions(result.voices.map(voice => voice.voice_id)) setElevenLabsVoiceLabels(Object.fromEntries(result.voices.map(voice => [voice.voice_id, voice.label]))) }) .catch(() => { if (!cancelled) { setElevenLabsVoiceOptions(null) setElevenLabsVoiceLabels({}) } }) return () => void (cancelled = true) // scopeProfile is constant per mount (the inner component is keyed on it). }, [scopeProfile]) // eslint-disable-next-line no-restricted-syntax -- autosave bookkeeping refs, not an atom mirror useEffect(() => { if (!config || saveVersion === 0) { return } const v = saveVersion const snapshot = config const t = window.setTimeout(() => { // Chained onto the queue (not fired directly) so an older save that's // still awaiting its response can't land after this one and undo its // baseline advance — each save's diff is computed once its predecessor // has fully resolved. saveQueueRef.current = saveQueueRef.current.then(async () => { try { const patch = diffConfig(configBaselineRef.current ?? {}, snapshot) const result = await saveHermesConfig(patch, scopeProfile) if (!result.ok) { throw new Error(c.autosaveFailed) } // The saved snapshot becomes the new baseline, so the next autosave // diffs against what's actually on disk instead of the page-load // (or last-baseline) copy — otherwise reverting a field to its // pre-save value diffs to nothing and the revert never reaches disk. configBaselineRef.current = snapshot // Mirror the saved record into the shared cache so MCP/model surfaces // reflect the edit without their own refetch. writeConfigCache(snapshot) if (saveVersionRef.current === v) { // The repo-discovery scan reads the ACTIVE profile's workspace // policy; skip it when this page is editing another profile. if (scopeProfile == null) { const discoverySignature = repoDiscoveryPolicySignature(repoDiscoveryPolicyFromConfig(snapshot)) if (savedDiscoverySignatureRef.current !== discoverySignature) { savedDiscoverySignatureRef.current = discoverySignature await scanAndRecordRepos(true) } } onConfigSaved?.() } } catch (err) { if (saveVersionRef.current === v) { notifyError(err, c.autosaveFailed) } } }) }, 550) return () => window.clearTimeout(t) // eslint-disable-next-line react-hooks/exhaustive-deps -- copy is stable; avoid re-scheduling autosave on locale change }, [config, onConfigSaved, saveVersion]) const applyConfig = (next: HermesConfigRecord) => { saveVersionRef.current += 1 setConfig(next) setSaveVersion(saveVersionRef.current) } const updateConfig = (next: HermesConfigRecord) => { // Guard the single most destructive config edit: clearing the entire // "Enabled Toolsets" list silently disables memory, terminal, web search, // delegation, and most tools, and a stray select-all + Backspace can do it. // Auto-save is debounced with no undo, so confirm a non-empty → empty // transition before applying it. Every other edit passes through untouched. if (config && clearsEnabledToolsets(config, next)) { void confirm({ destructive: true, title: c.toolsetsWipeConfirm }).then(ok => { if (ok) { applyConfig(next) } }) return } applyConfig(next) } const sectionFields = useMemo(() => { if (!schema || !config) { return new Map() } return sectionFieldEntries(schema, config) }, [schema, config]) const fields = sectionFields.get(activeSectionId) ?? [] // Deep-link target from the command palette (?field=): scroll the row // into view and flash it, then drop the param so it doesn't re-fire. const [searchParams, setSearchParams] = useSearchParams() const targetField = searchParams.get('field') useEffect(() => { if (!targetField || !config || !schema) { return } const element = document.getElementById(`setting-field-${targetField}`) if (!element) { return } element.scrollIntoView({ behavior: 'smooth', block: 'center' }) if (!element.hasAttribute('tabindex')) { element.tabIndex = -1 } element.focus({ preventScroll: true }) element.classList.add('setting-field-highlight') const timeout = window.setTimeout(() => element.classList.remove('setting-field-highlight'), 1600) setSearchParams( previous => { const next = new URLSearchParams(previous) next.delete('field') return next }, { replace: true } ) return () => window.clearTimeout(timeout) }, [config, schema, setSearchParams, targetField]) function handleImport(e: ChangeEvent) { const file = e.target.files?.[0] if (!file) { return } const reader = new FileReader() reader.onload = () => { try { updateConfig(JSON.parse(String(reader.result))) notify({ kind: 'success', title: c.imported, message: t.common.saving }) } catch (err) { notifyError(err, c.invalidJson) } } reader.readAsText(file) e.target.value = '' } if (!config || !schema) { // A failed config/schema fetch must surface a retry, not spin forever. if ((configLoadFailed && !config) || (schemaFailed && !schema)) { return (
{ void refetchConfig() void refetchSchema() }} size="sm" > {t.skills.refresh} } icon="error" title={c.failedLoad} />
) } // Every section keeps its shape via a skeleton; model gets its bespoke one // (its catalog fetch is the slow part), the rest the shared field rhythm. if (activeSectionId === 'model') { return (
) } return } const visibleFields = activeSectionId === 'voice' ? fields.filter(([key]) => voiceFieldVisible(key, config)) : fields return ( {/* Which profile's config.yaml this page edits — shared across every config-backed settings page (and hidden for single-profile users). */} {activeSectionId === 'model' && (
)} {/* Device-local desktop prefs (not config.yaml) — they live here since keeping the machine awake and the global Quick Entry chord are both power-user, this-computer-only knobs. */} {activeSectionId === 'advanced' && ( <> )} {/* Device-local attach/preview byte cap (main-process IPC guard). Chat is where image-attachment behavior already lives, so this sits above the schema fields for that section. */} {activeSectionId === 'chat' ? : null} {visibleFields.length === 0 && activeSectionId !== 'chat' ? ( ) : visibleFields.length === 0 ? null : (
{visibleFields.map(([key, field]) => (
) : undefined } enumOptions={ key === 'tts.elevenlabs.voice_id' ? enumOptionsFor(key, getNested(config, key), config, elevenLabsVoiceOptions ?? undefined) : enumOptionsFor(key, getNested(config, key), config) } onChange={value => updateConfig(setNested(config, key, value))} optionLabels={key === 'tts.elevenlabs.voice_id' ? elevenLabsVoiceLabels : undefined} schema={field} schemaKey={key} value={getNested(config, key)} /> {key === 'memory.provider' && isExternalMemoryProvider(getNested(config, key)) ? ( ) : null}
))}
)}
) } /** Free-form MB cap for Desktop's data-URL attach/preview path (main-process). */ function AttachmentSizeSetting() { const { t } = useI18n() const c = t.settings.config const stored = useStore($dataUrlReadMaxMb) const [draft, setDraft] = useState(String(stored)) useEffect(() => { void refreshDataUrlReadMaxMb() }, []) useEffect(() => { setDraft(String(stored)) }, [stored]) const commit = () => { // An empty draft means "reset to the default", not the 1 MB floor // (Number('') === 0 would otherwise clamp down to the floor). const applied = draft.trim() === '' ? DATA_URL_READ_DEFAULT_MAX_MB : clampDataUrlReadMaxMb(draft) // Unchanged: snap the draft back to the stored value and skip the // pointless IPC write + haptic. if (applied === stored) { setDraft(String(stored)) return } void setDataUrlReadMaxMb(applied).then(next => { setDraft(String(next)) // On a bridge write failure the store keeps the old value; only // celebrate when the new cap actually landed. if (next === applied) { triggerHaptic('selection') } }) } return ( setDraft(event.target.value)} onKeyDown={event => { if (event.key === 'Enter') { event.currentTarget.blur() } }} type="number" value={draft} /> {c.attachmentSizeUnit} } description={c.attachmentSizeDesc} title={c.attachmentSizeTitle} /> ) }