diff --git a/apps/desktop/src/app/settings/config-settings.tsx b/apps/desktop/src/app/settings/config-settings.tsx index e7953310adc6..cac0d197f2ce 100644 --- a/apps/desktop/src/app/settings/config-settings.tsx +++ b/apps/desktop/src/app/settings/config-settings.tsx @@ -18,9 +18,9 @@ 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 { 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 { ModelSettings, ModelSettingsSkeleton } from './model-settings' import { EmptyState, ListRow, LoadingState, SettingsContent } from './primitives' @@ -328,14 +328,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) ?? [] diff --git a/apps/desktop/src/app/settings/helpers.test.ts b/apps/desktop/src/app/settings/helpers.test.ts index c04dcb42498c..1c8ac1303d6e 100644 --- a/apps/desktop/src/app/settings/helpers.test.ts +++ b/apps/desktop/src/app/settings/helpers.test.ts @@ -3,7 +3,15 @@ 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 the desktop memory provider options in their declared order', () => { @@ -175,4 +183,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 ff5818129053..16bee76b4ea5 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)