diff --git a/apps/desktop/src/app/settings/config-settings.tsx b/apps/desktop/src/app/settings/config-settings.tsx index fa9e00eb349..46732d21bd1 100644 --- a/apps/desktop/src/app/settings/config-settings.tsx +++ b/apps/desktop/src/app/settings/config-settings.tsx @@ -18,14 +18,14 @@ import { setHermesConfigCache, useHermesConfigRecord } from '../hooks/use-config import { useOnProfileSwitch } from '../hooks/use-on-profile-switch' import { PanelEmpty } from '../overlays/panel' -import { CONTROL_TEXT, EMPTY_SELECT_VALUE, FIELD_DESCRIPTIONS, FIELD_LABELS, SECTIONS } from './constants' +import { CONTROL_TEXT, EMPTY_SELECT_VALUE, FIELD_DESCRIPTIONS, FIELD_LABELS } from './constants' import { FallbackModelsField } from './fallback-models-field' import { fieldCopyForSchemaKey } from './field-copy' -import { enumOptionsFor, getNested, prettyName, setNested } from './helpers' +import { enumOptionsFor, getNested, prettyName, sectionFieldEntries, setNested } from './helpers' import { MemoryConnect } from './memory/connect' +import { ProviderConfigPanel } from './memory/provider-config-panel' import { ModelSettings, ModelSettingsSkeleton } from './model-settings' import { EmptyState, ListRow, LoadingState, SettingsContent } from './primitives' -import { ProviderConfigPanel } from './provider-config-panel' // On the Voice page, only surface the sub-fields of the *selected* TTS/STT // provider — otherwise every provider's options render at once (the "totally @@ -336,14 +336,12 @@ export function ConfigSettings({ } const sectionFields = useMemo(() => { - if (!schema) { + if (!schema || !config) { return new Map() } - return new Map( - SECTIONS.map(s => [s.id, s.keys.flatMap(k => (schema[k] ? [[k, schema[k]] as [string, ConfigFieldSchema]] : []))]) - ) - }, [schema]) + return sectionFieldEntries(schema, config) + }, [schema, config]) const fields = sectionFields.get(activeSectionId) ?? [] @@ -475,7 +473,7 @@ export function ConfigSettings({ value={getNested(config, key)} /> {key === 'memory.provider' && typeof getNested(config, key) === 'string' && getNested(config, key) ? ( - + ) : null} ))} diff --git a/apps/desktop/src/app/settings/constants.ts b/apps/desktop/src/app/settings/constants.ts index 3bce38af97a..cb87dd2b953 100644 --- a/apps/desktop/src/app/settings/constants.ts +++ b/apps/desktop/src/app/settings/constants.ts @@ -247,7 +247,7 @@ export const ENUM_OPTIONS: Record = { 'code_execution.mode': ['project', 'strict'], 'context.engine': ['compressor', 'default', 'custom'], 'delegation.reasoning_effort': ['', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max', 'ultra'], - 'memory.provider': ['', 'builtin', 'hindsight', 'honcho'], + 'memory.provider': ['', 'builtin', 'honcho', 'hindsight'], // Terminal execution backends — kept in sync with the dispatch ladder in // tools/terminal_tool.py::_create_environment (local/docker/singularity/ // modal/daytona/ssh). Remote backends need extra env (image, tokens, host). diff --git a/apps/desktop/src/app/settings/helpers.test.ts b/apps/desktop/src/app/settings/helpers.test.ts index d29f2a73943..a010076cdff 100644 --- a/apps/desktop/src/app/settings/helpers.test.ts +++ b/apps/desktop/src/app/settings/helpers.test.ts @@ -3,13 +3,21 @@ import { describe, expect, it } from 'vitest' import type { HermesConfigRecord } from '@/types/hermes' import { defineFieldCopy, fieldCopyForSchemaKey, schemaKeyToFieldCopyKey } from './field-copy' -import { enumOptionsFor, getNested, providerGroup, setNested, stripToolsetLabel, toolsetDisplayLabel } from './helpers' +import { + enumOptionsFor, + getNested, + providerGroup, + sectionFieldEntries, + setNested, + stripToolsetLabel, + toolsetDisplayLabel +} from './helpers' describe('settings helpers', () => { - it('lists Hindsight as a built-in desktop memory provider option', () => { + it('lists the desktop memory provider options in their declared order', () => { const options = enumOptionsFor('memory.provider', '', {}) - expect(options).toContain('hindsight') + expect(options).toEqual(['', 'builtin', 'honcho', 'hindsight']) }) describe('defineFieldCopy', () => { @@ -176,4 +184,39 @@ describe('settings helpers', () => { expect(opts).toContain('xai') }) }) + + describe('sectionFieldEntries', () => { + it('renders memory.provider from config even when the backend schema omits it', () => { + const schema = { 'memory.memory_enabled': { type: 'boolean' as const } } + const config: HermesConfigRecord = { memory: { memory_enabled: true, provider: '' } } + + const memoryKeys = (sectionFieldEntries(schema, config).get('memory') ?? []).map(([key]) => key) + + expect(memoryKeys).toContain('memory.provider') + }) + + it('infers the field type from the config value when the schema omits the key', () => { + const config: HermesConfigRecord = { memory: { provider: '', memory_enabled: true, memory_char_limit: 2200 } } + + const fields = new Map(sectionFieldEntries({}, config).get('memory') ?? []) + + expect(fields.get('memory.provider')?.type).toBe('string') + expect(fields.get('memory.memory_enabled')?.type).toBe('boolean') + expect(fields.get('memory.memory_char_limit')?.type).toBe('number') + }) + + it('prefers the backend schema entry over inference when both exist', () => { + const schema = { 'memory.provider': { type: 'select' as const, options: ['honcho'] } } + const config: HermesConfigRecord = { memory: { provider: 'honcho' } } + + const field = new Map(sectionFieldEntries(schema, config).get('memory') ?? []).get('memory.provider') + + expect(field?.type).toBe('select') + expect(field?.options).toEqual(['honcho']) + }) + + it('hides declared keys absent from both schema and config', () => { + expect(sectionFieldEntries({}, {}).get('memory') ?? []).toHaveLength(0) + }) + }) }) diff --git a/apps/desktop/src/app/settings/helpers.ts b/apps/desktop/src/app/settings/helpers.ts index ff581812905..16bee76b4ea 100644 --- a/apps/desktop/src/app/settings/helpers.ts +++ b/apps/desktop/src/app/settings/helpers.ts @@ -1,7 +1,7 @@ import { asText } from '@/lib/text' -import type { HermesConfigRecord, ToolsetInfo } from '@/types/hermes' +import type { ConfigFieldSchema, HermesConfigRecord, ToolsetInfo } from '@/types/hermes' -import { BUILTIN_PERSONALITIES, ENUM_OPTIONS, PROVIDER_GROUPS } from './constants' +import { BUILTIN_PERSONALITIES, ENUM_OPTIONS, PROVIDER_GROUPS, SECTIONS } from './constants' // Canonical implementations live in @/lib/text; re-exported here so the many // settings/capabilities call sites keep their import path. @@ -97,6 +97,40 @@ export function getNested(obj: HermesConfigRecord, path: string): unknown { return cur } +export function inferFieldSchema(value: unknown): ConfigFieldSchema { + if (typeof value === 'boolean') { + return { type: 'boolean' } + } + + if (typeof value === 'number') { + return { type: 'number' } + } + + if (Array.isArray(value)) { + return { type: 'list' } + } + + return { type: 'string' } +} + +// Backend schema omits some declared keys (e.g. memory.provider); config presence is the availability signal. +export function sectionFieldEntries( + schema: Record, + config: HermesConfigRecord +): Map { + return new Map( + SECTIONS.map(s => [ + s.id, + s.keys.flatMap(k => { + const value = getNested(config, k) + const field = schema[k] ?? (value === undefined ? undefined : inferFieldSchema(value)) + + return field ? [[k, field] as [string, ConfigFieldSchema]] : [] + }) + ]) + ) +} + export function setNested(obj: HermesConfigRecord, path: string, value: unknown): HermesConfigRecord { const clone = structuredClone(obj) const parts = configPathParts(path) diff --git a/apps/desktop/src/app/settings/memory/field-control.tsx b/apps/desktop/src/app/settings/memory/field-control.tsx new file mode 100644 index 00000000000..62871be910f --- /dev/null +++ b/apps/desktop/src/app/settings/memory/field-control.tsx @@ -0,0 +1,128 @@ +import { Input } from '@/components/ui/input' +import { Tip } from '@/components/ui/tooltip' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' +import { Switch } from '@/components/ui/switch' +import { Textarea } from '@/components/ui/textarea' +import { Check, Info } from '@/lib/icons' +import type { MemoryProviderField } from '@/types/hermes' + +import { CONTROL_TEXT } from '../constants' + +// Fade the placeholder well below set values so example text never reads as data. +const FIELD_INPUT = `font-mono ${CONTROL_TEXT} placeholder:text-muted-foreground/45` + +// Field label with an optional info tooltip, shared by the panel and modal rows. +export function FieldTitle({ field }: { field: MemoryProviderField }) { + if (!field.info) { + return <>{field.label} + } + + return ( + + {field.label} + + + + + ) +} + +// Values are edited as strings; the backend coerces them to native types. +export function FieldControl({ + field, + value, + onChange, + onCommit +}: { + field: MemoryProviderField + value: string + onChange: (value: string) => void + // Present on autosaving surfaces: discrete controls commit on change, text-like + // controls commit on blur. Absent (the modal), edits stay drafts until Save. + onCommit?: (value: string) => void +}) { + const set = (next: string) => { + onChange(next) + onCommit?.(next) + } + const commitDraft = onCommit ? () => onCommit(value) : undefined + + if (field.kind === 'bool') { + return set(checked ? 'true' : 'false')} /> + } + + if (field.kind === 'number') { + return ( + onChange(event.target.value)} + placeholder={field.placeholder} + type="number" + value={value} + /> + ) + } + + if (field.kind === 'json') { + return ( +