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

239 lines
7.5 KiB
TypeScript

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<string, string>
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 ? (
<span className="inline-flex flex-wrap items-center gap-x-3 gap-y-1">
{description}
{descriptionExtra}
</span>
) : (
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) => (
<ListRow action={action} data-tour={`field-${schemaKey}`} description={descriptionNode} title={label} wide={wide} />
)
// `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(<FallbackModelsField onChange={onChange} value={value} />, true)
}
if (schema.type === 'boolean') {
return row(
<div className="flex items-center justify-end">
<Switch checked={Boolean(value)} onCheckedChange={onChange} />
</div>
)
}
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(
<SearchableSelect
clearLabel={schema.clearable ? c.systemDefault : undefined}
emptyMessage={c.noResults}
onChange={next => 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 <datalist> 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(
<ComboboxInput
className={CONTROL_TEXT}
onChange={onChange}
optionLabels={optionLabels}
options={selectOptions.filter(o => o !== '')}
placeholder={c.notSet}
value={String(value ?? '')}
/>
)
}
if (selectOptions) {
return row(
<Select
onValueChange={next => onChange(next === EMPTY_SELECT_VALUE ? '' : next)}
value={String(value ?? '') || EMPTY_SELECT_VALUE}
>
<SelectTrigger className={CONTROL_TEXT}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{selectOptions.map(option => (
<SelectItem key={option || EMPTY_SELECT_VALUE} value={option || EMPTY_SELECT_VALUE}>
{option
? (optionLabels?.[option] ?? t.settings.fieldOptions?.[schemaKey]?.[option] ?? prettyName(option))
: schemaKey === 'display.personality'
? c.none
: schemaKey === 'memory.provider'
? c.builtinOnly
: c.noneParen}
</SelectItem>
))}
</SelectContent>
</Select>
)
}
if (schema.type === 'number') {
return row(
<Input
className={CONTROL_TEXT}
onChange={e => {
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(
<Input
className={CONTROL_TEXT}
onChange={e =>
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(
<Textarea
className={cn('min-h-28 resize-y bg-background font-mono', CONTROL_TEXT)}
onChange={e => {
try {
onChange(JSON.parse(e.target.value))
} catch {
/* keep last valid */
}
}}
placeholder={c.notSet}
spellCheck={false}
value={JSON.stringify(value, null, 2)}
/>,
true
)
}
const isLong = schema.type === 'text' || String(value ?? '').length > 100
return row(
isLong ? (
<Textarea
className={cn('min-h-24 resize-y bg-background', CONTROL_TEXT)}
onChange={e => onChange(e.target.value)}
placeholder={c.notSet}
value={String(value ?? '')}
/>
) : (
<Input
className={CONTROL_TEXT}
onChange={e => onChange(e.target.value)}
placeholder={c.notSet}
value={String(value ?? '')}
/>
),
isLong
)
}