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

531 lines
19 KiB
TypeScript

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 (
<ConfigSettingsInner
activeSectionId={activeSectionId}
importInputRef={importInputRef}
key={scopeProfile ?? '__active__'}
onConfigSaved={onConfigSaved}
onMainModelChanged={onMainModelChanged}
scopeProfile={scopeProfile}
/>
)
}
interface ConfigSettingsProps {
activeSectionId: string
onConfigSaved?: () => void
onMainModelChanged?: (provider: string, model: string) => void
importInputRef: React.RefObject<HTMLInputElement | null>
}
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<HermesConfigRecord | null>(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<string[] | null>(null)
const [elevenLabsVoiceLabels, setElevenLabsVoiceLabels] = useState<Record<string, string>>({})
const saveVersionRef = useRef(0)
const savedDiscoverySignatureRef = useRef<string | undefined>(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<HermesConfigRecord | null>(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<void>>(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<string, [string, ConfigFieldSchema][]>()
}
return sectionFieldEntries(schema, config)
}, [schema, config])
const fields = sectionFields.get(activeSectionId) ?? []
// Deep-link target from the command palette (?field=<key>): 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<HTMLInputElement>) {
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 (
<div className="flex h-full min-h-0 flex-1">
<PanelEmpty
action={
<Button
onClick={() => {
void refetchConfig()
void refetchSchema()
}}
size="sm"
>
{t.skills.refresh}
</Button>
}
icon="error"
title={c.failedLoad}
/>
</div>
)
}
// 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 (
<SettingsContent>
<SettingsProfileScope className="mb-5" />
<div className="mb-6">
<ModelSettingsSkeleton />
</div>
</SettingsContent>
)
}
return <SettingsSkeleton sections={[{ rows: 6 }]} />
}
const visibleFields = activeSectionId === 'voice' ? fields.filter(([key]) => voiceFieldVisible(key, config)) : fields
return (
<SettingsContent>
{/* Which profile's config.yaml this page edits — shared across every
config-backed settings page (and hidden for single-profile users). */}
<SettingsProfileScope className="mb-5" />
{activeSectionId === 'model' && (
<div className="mb-6">
<ModelSettings onMainModelChanged={onMainModelChanged} scopeProfile={scopeProfile} />
</div>
)}
{/* 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' && (
<>
<ToggleRow
checked={keepAwake}
description={c.keepAwakeDesc}
label={c.keepAwakeTitle}
onChange={setKeepAwake}
/>
<ToggleRow
checked={disableF12}
description={c.disableF12Desc}
label={c.disableF12Title}
onChange={setDisableF12}
/>
<PoolLimitsSetting />
<QuickEntrySettings />
</>
)}
{/* 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' ? <AttachmentSizeSetting /> : null}
{visibleFields.length === 0 && activeSectionId !== 'chat' ? (
<EmptyState description={c.emptyDesc} title={c.emptyTitle} />
) : visibleFields.length === 0 ? null : (
<div className="grid gap-1">
{visibleFields.map(([key, field]) => (
<div className="scroll-mt-6 rounded-lg" id={`setting-field-${key}`} key={key}>
<ConfigField
descriptionExtra={
key === 'memory.provider' && isExternalMemoryProvider(getNested(config, key)) ? (
<MemoryConnect profile={scopeProfile} provider={String(getNested(config, key))} />
) : 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)) ? (
<ProviderConfigPanel
key={String(getNested(config, key))}
profile={scopeProfile}
provider={String(getNested(config, key))}
/>
) : null}
</div>
))}
</div>
)}
<input
accept=".json,application/json"
className="hidden"
onChange={handleImport}
ref={importInputRef}
type="file"
/>
</SettingsContent>
)
}
/** 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 (
<ListRow
action={
<div className="flex items-center gap-2">
<Input
aria-label={c.attachmentSizeLabel}
className="w-20"
inputMode="numeric"
max={DATA_URL_READ_MAX_MAX_MB}
min={DATA_URL_READ_MIN_MAX_MB}
onBlur={commit}
onChange={event => setDraft(event.target.value)}
onKeyDown={event => {
if (event.key === 'Enter') {
event.currentTarget.blur()
}
}}
type="number"
value={draft}
/>
<span className="text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
{c.attachmentSizeUnit}
</span>
</div>
}
description={c.attachmentSizeDesc}
title={c.attachmentSizeTitle}
/>
)
}