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 { ComboboxInput } from './combobox-input' 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' import { SearchableSelect } from './searchable-select' /** * 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 ) // Every config row is addressable by its canonical schema key, so a tour can // point at one setting (`[data-tour="field-model"]`) without hunting through // the section for an nth-child path. See lib/tour. 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) // Large closed-world lists (e.g. ~590 IANA timezones) get a searchable // Popover + cmdk combobox instead of a closed Select dropdown. The schema // opt-in via `searchable: true` keeps this deterministic — no field // accidentally triggers based on dynamic option count. if (selectOptions && schema.searchable) { return row( onChange(next)} options={selectOptions.filter(o => o !== '')} placeholder={c.searchPlaceholder} value={String(value ?? '')} /> ) } // 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 dropdown suggestions instead of a closed Select gate. The old // native filtered by the current value, so a field already set // to a valid option showed only that single suggestion. if (selectOptions && FREE_INPUT_KEYS.has(schemaKey)) { return row( o !== '')} placeholder={c.notSet} value={String(value ?? '')} /> ) } 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(