From a1813c1ef4d3d27fc80742a9fcff518e3cd8b3ab Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Mon, 20 Jul 2026 05:38:38 -0700 Subject: [PATCH] feat(desktop): inline TTS voice/model settings in the Capabilities tab (#68017) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(desktop): inline TTS voice/model settings in the Capabilities tab The Capabilities > Tools > Text-to-Speech panel only surfaced API keys per provider — voice and model settings (e.g. tts.openai.voice) lived exclusively in Settings > Voice, so users couldn't select or type a voice/model name where they configure the backend. - web_server: TTS provider rows now carry their tts_provider config key (the section holding that backend's voice/model settings) - desktop: new VoiceProviderFields renders the provider's config fields inline in the toolset panel, deriving the key list from the curated Settings > Voice section so the two surfaces can't drift; shared ConfigField extracted to config-field.tsx - voice/model name fields are now free-input comboboxes (Input + datalist) instead of closed Selects — custom voice IDs (ElevenLabs cloned voices, xAI custom voices, Edge's 400+ catalog) are typeable, known values remain suggestions - refreshed the stale OpenAI voice list (adds ash/ballad/cedar/coral/marin/ sage/verse) and added suggestion lists for edge/gemini/minimax/mistral/ kittentts/piper/neutts models and voices - config.py: added missing tts.minimax and tts.kittentts default blocks and deepinfra model/voice fields to the Voice section so those providers are configurable from the GUI at all * test(desktop): await effect-driven panel content in post-setup CTA tests The auto-expand effect renders the provider's inner panel one re-render after the row; with the QueryClientProvider wrapper the extra provider tick made the synchronous getByRole race it (~10% local flake, failed on CI). Await the panel content with findBy* instead. --- .../desktop/src/app/settings/config-field.tsx | 224 ++++++++++++++++++ .../src/app/settings/config-settings.tsx | 189 +-------------- apps/desktop/src/app/settings/constants.ts | 103 +++++++- .../settings/toolset-config-panel.test.tsx | 92 ++++++- .../src/app/settings/toolset-config-panel.tsx | 7 + .../settings/voice-provider-fields.test.ts | 77 ++++++ .../app/settings/voice-provider-fields.tsx | 140 +++++++++++ apps/desktop/src/types/hermes.ts | 5 + hermes_cli/config.py | 12 +- hermes_cli/web_server.py | 6 + tests/hermes_cli/test_web_server.py | 20 ++ 11 files changed, 682 insertions(+), 193 deletions(-) create mode 100644 apps/desktop/src/app/settings/config-field.tsx create mode 100644 apps/desktop/src/app/settings/voice-provider-fields.test.ts create mode 100644 apps/desktop/src/app/settings/voice-provider-fields.tsx 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( +