diff --git a/apps/desktop/src/app/settings/config-field.tsx b/apps/desktop/src/app/settings/config-field.tsx new file mode 100644 index 0000000000000..aac75a2ee9713 --- /dev/null +++ b/apps/desktop/src/app/settings/config-field.tsx @@ -0,0 +1,224 @@ +import type { ReactNode } from 'react' + +import { Input } from '@/components/ui/input' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' +import { Switch } from '@/components/ui/switch' +import { Textarea } from '@/components/ui/textarea' +import { useI18n } from '@/i18n' +import { prettyName } from '@/lib/text' +import { cn } from '@/lib/utils' +import type { ConfigFieldSchema } from '@/types/hermes' + +import { CONTROL_TEXT, EMPTY_SELECT_VALUE, FIELD_DESCRIPTIONS, FIELD_LABELS, FREE_INPUT_KEYS } from './constants' +import { FallbackModelsField } from './fallback-models-field' +import { fieldCopyForSchemaKey } from './field-copy' +import { ListRow } from './primitives' + +/** + * One generic config row: label + description resolved from the i18n field + * copy (falling back to the schema description), and a control picked from the + * field schema — Switch for booleans, Select for enums, free-input combobox + * (Input + datalist) for FREE_INPUT_KEYS voice/model names, and Input/Textarea + * for the rest. Shared by the Settings config sections and the Capabilities + * TTS provider panel so both surfaces render identical fields. + */ +export function ConfigField({ + schemaKey, + schema, + value, + enumOptions, + optionLabels, + onChange, + descriptionExtra +}: { + schemaKey: string + schema: ConfigFieldSchema + value: unknown + enumOptions?: string[] + optionLabels?: Record + onChange: (value: unknown) => void + descriptionExtra?: ReactNode +}) { + const { t } = useI18n() + const c = t.settings.config + + const label = + fieldCopyForSchemaKey(t.settings.fieldLabels, schemaKey) ?? + fieldCopyForSchemaKey(FIELD_LABELS, schemaKey) ?? + prettyName(schemaKey.split('.').pop() ?? schemaKey) + + const normalize = (v: string) => v.toLowerCase().replace(/[^a-z0-9]+/g, '') + + const rawDescription = ( + fieldCopyForSchemaKey(t.settings.fieldDescriptions, schemaKey) ?? + fieldCopyForSchemaKey(FIELD_DESCRIPTIONS, schemaKey) ?? + schema.description ?? + '' + ).trim() + + const normalizedDesc = normalize(rawDescription) + + const description = + rawDescription && normalizedDesc !== normalize(label) && normalizedDesc !== normalize(schemaKey) + ? rawDescription + : undefined + + const descriptionNode: ReactNode = descriptionExtra ? ( + + {description} + {descriptionExtra} + + ) : ( + description + ) + + const row = (action: ReactNode, wide = false) => ( + + ) + + // `fallback_providers` is a list of {provider, model} objects; the generic + // `list` branch below would stringify them to "[object Object]". Render the + // dedicated structured editor instead. + if (schemaKey === 'fallback_providers') { + return row(, true) + } + + if (schema.type === 'boolean') { + return row( +
+ +
+ ) + } + + const selectOptions = enumOptions ?? (schema.type === 'select' ? (schema.options ?? []).map(String) : undefined) + + // Voice/model name fields are open-world (custom voice IDs, cloned voices, + // brand-new model names) — render a free-input combobox where the known + // options are datalist suggestions instead of a closed Select gate. + if (selectOptions && FREE_INPUT_KEYS.has(schemaKey)) { + const datalistId = `config-field-options-${schemaKey.replace(/\./g, '-')}` + + return row( + <> + onChange(e.target.value)} + placeholder={c.notSet} + value={String(value ?? '')} + /> + + {selectOptions + .filter(option => option !== '') + .map(option => ( + + + ) + } + + if (selectOptions) { + return row( + + ) + } + + if (schema.type === 'number') { + return row( + { + const raw = e.target.value + const n = raw === '' ? 0 : Number(raw) + + if (!Number.isNaN(n)) { + onChange(n) + } + }} + placeholder={c.notSet} + type="number" + value={value === undefined || value === null ? '' : String(value)} + /> + ) + } + + if (schema.type === 'list') { + return row( + + onChange( + e.target.value + .split(',') + .map(s => s.trim()) + .filter(Boolean) + ) + } + placeholder={c.commaSeparated} + value={Array.isArray(value) ? value.join(', ') : String(value ?? '')} + /> + ) + } + + if (typeof value === 'object' && value !== null) { + return row( +