feat(desktop): inline TTS voice/model settings in the Capabilities tab (#68017)

* 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.
This commit is contained in:
Teknium 2026-07-20 05:38:38 -07:00 committed by GitHub
parent 369afc60be
commit a1813c1ef4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 682 additions and 193 deletions

View File

@ -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<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
)
const row = (action: ReactNode, wide = false) => (
<ListRow action={action} 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)
// 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(
<>
<Input
className={CONTROL_TEXT}
list={datalistId}
onChange={e => onChange(e.target.value)}
placeholder={c.notSet}
value={String(value ?? '')}
/>
<datalist id={datalistId}>
{selectOptions
.filter(option => option !== '')
.map(option => (
<option key={option} label={optionLabels?.[option]} value={option} />
))}
</datalist>
</>
)
}
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] ?? 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
)
}

View File

@ -1,16 +1,11 @@
import { useQuery } from '@tanstack/react-query'
import type { ChangeEvent, ReactNode } from 'react'
import type { ChangeEvent } from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useSearchParams } from 'react-router-dom'
import { Button } from '@/components/ui/button'
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 { getElevenLabsVoices, getHermesConfigSchema, saveHermesConfig } from '@/hermes'
import { useI18n } from '@/i18n'
import { cn } from '@/lib/utils'
import { notify, notifyError } from '@/store/notifications'
import type { ConfigFieldSchema, HermesConfigRecord } from '@/types/hermes'
@ -18,21 +13,18 @@ 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 } from './constants'
import { FallbackModelsField } from './fallback-models-field'
import { fieldCopyForSchemaKey } from './field-copy'
import { ConfigField } from './config-field'
import {
enumOptionsFor,
getNested,
isExternalMemoryProvider,
prettyName,
sectionFieldEntries,
setNested
} from './helpers'
import { MemoryConnect } from './memory/connect'
import { ProviderConfigPanel } from './memory/provider-config-panel'
import { ModelSettings, ModelSettingsSkeleton } from './model-settings'
import { EmptyState, ListRow, LoadingState, SettingsContent } from './primitives'
import { EmptyState, LoadingState, SettingsContent } from './primitives'
// On the Voice page, only surface the sub-fields of the *selected* TTS/STT
// provider — otherwise every provider's options render at once (the "totally
@ -54,181 +46,6 @@ export function voiceFieldVisible(key: string, config: HermesConfigRecord): bool
return provider === String(getNested(config, `${domain}.provider`) ?? '')
}
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
)
const row = (action: ReactNode, wide = false) => (
<ListRow action={action} 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)
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] ?? 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
)
}
export function ConfigSettings({
activeSectionId,
onConfigSaved,

View File

@ -260,7 +260,77 @@ export const ENUM_OPTIONS: Record<string, string[]> = {
// Speech-to-text backends — kept in sync with the stt block in
// hermes_cli/config.py (local/groq/openai/mistral/elevenlabs).
'stt.provider': ['local', 'groq', 'openai', 'mistral', 'xai', 'elevenlabs'],
'tts.openai.voice': ['alloy', 'echo', 'fable', 'onyx', 'nova', 'shimmer'],
// gpt-4o-mini-tts voice set (the tts-1 era stopped at shimmer). Free-input
// field — the list is suggestions, not a gate (see FREE_INPUT_KEYS).
'tts.openai.voice': [
'alloy',
'ash',
'ballad',
'cedar',
'coral',
'echo',
'fable',
'marin',
'nova',
'onyx',
'sage',
'shimmer',
'verse'
],
// Popular Edge neural voices (the full catalog is 400+ — free input).
'tts.edge.voice': [
'en-US-AriaNeural',
'en-US-JennyNeural',
'en-US-AndrewNeural',
'en-US-BrianNeural',
'en-US-GuyNeural',
'en-GB-SoniaNeural'
],
'tts.gemini.model': ['gemini-2.5-flash-preview-tts', 'gemini-2.5-pro-preview-tts'],
// Gemini TTS prebuilt voice set.
'tts.gemini.voice': [
'Zephyr',
'Puck',
'Charon',
'Kore',
'Fenrir',
'Leda',
'Orus',
'Aoede',
'Callirrhoe',
'Autonoe',
'Enceladus',
'Iapetus',
'Umbriel',
'Algieba',
'Despina',
'Erinome',
'Algenib',
'Rasalgethi',
'Laomedeia',
'Achernar',
'Alnilam',
'Schedar',
'Gacrux',
'Pulcherrima',
'Achird',
'Zubenelgenubi',
'Vindemiatrix',
'Sadachbia',
'Sadaltager',
'Sulafat'
],
'tts.xai.voice_id': ['eve'],
'tts.minimax.model': ['speech-02-hd', 'speech-02-turbo'],
'tts.mistral.model': ['voxtral-mini-tts-2603'],
'tts.kittentts.model': [
'KittenML/kitten-tts-nano-0.8-int8',
'KittenML/kitten-tts-micro-0.8-int8',
'KittenML/kitten-tts-mini-0.8-int8'
],
'tts.kittentts.voice': ['Jasper'],
'tts.piper.voice': ['en_US-lessac-medium', 'en_US-amy-medium', 'en_US-ryan-high', 'en_GB-alan-medium'],
'tts.neutts.model': ['neuphonic/neutts-air-q4-gguf', 'neuphonic/neutts-air-q8-gguf', 'neuphonic/neutts-air'],
// Text-to-speech backends — kept in sync with the built-in source of truth
// (agent/tts_registry.py::_BUILTIN_NAMES / tools/tts_tool.py::
// BUILTIN_TTS_PROVIDERS). 'xai' is Grok TTS.
@ -285,6 +355,31 @@ export const ENUM_OPTIONS: Record<string, string[]> = {
'updates.non_interactive_local_changes': ['stash', 'discard']
}
// Voice/model name fields render as a free-input combobox (Input + datalist)
// instead of a closed Select: providers accept custom voice IDs (ElevenLabs
// cloned voices, xAI custom voices, Edge's 400+ catalog) and ship new model
// names faster than this list updates. The ENUM_OPTIONS above become
// suggestions rather than a gate for these keys.
export const FREE_INPUT_KEYS = new Set([
'tts.edge.voice',
'tts.openai.model',
'tts.openai.voice',
'tts.elevenlabs.voice_id',
'tts.gemini.model',
'tts.gemini.voice',
'tts.xai.voice_id',
'tts.minimax.model',
'tts.minimax.voice_id',
'tts.mistral.model',
'tts.mistral.voice_id',
'tts.neutts.model',
'tts.kittentts.model',
'tts.kittentts.voice',
'tts.piper.voice',
'tts.deepinfra.model',
'tts.deepinfra.voice'
])
export const FIELD_LABELS: Record<string, string> = defineFieldCopy({
model: 'Default Model',
modelContextLength: 'Context Window',
@ -413,6 +508,10 @@ export const FIELD_LABELS: Record<string, string> = defineFieldCopy({
},
piper: {
voice: 'Piper Voice'
},
deepinfra: {
model: 'DeepInfra TTS Model',
voice: 'DeepInfra Voice'
}
},
memory: {
@ -618,6 +717,8 @@ export const SECTIONS: DesktopConfigSection[] = [
'tts.kittentts.model',
'tts.kittentts.voice',
'tts.piper.voice',
'tts.deepinfra.model',
'tts.deepinfra.voice',
'stt.local.model',
'stt.local.language',
'stt.openai.model',

View File

@ -1,3 +1,4 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { cleanup, fireEvent, render as rtlRender, screen, waitFor } from '@testing-library/react'
import type { ReactElement } from 'react'
import { MemoryRouter } from 'react-router-dom'
@ -15,7 +16,17 @@ vi.mock('react-router-dom', async importOriginal => ({
useNavigate: () => navigateSpy
}))
const render = (ui: ReactElement) => rtlRender(ui, { wrapper: MemoryRouter })
// The inline VoiceProviderFields reads the shared config record through React
// Query, so the panel needs a QueryClientProvider (fresh per render — cached
// config from one test must not leak into the next).
const render = (ui: ReactElement) =>
rtlRender(
<MemoryRouter>
<QueryClientProvider client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}>
{ui}
</QueryClientProvider>
</MemoryRouter>
)
const getToolsetConfig = vi.fn()
const getToolsetModels = vi.fn()
@ -28,6 +39,10 @@ const runToolsetPostSetup = vi.fn()
const getActionStatus = vi.fn()
const startOAuthLogin = vi.fn()
const pollOAuthSession = vi.fn()
const getHermesConfigRecord = vi.fn()
const getHermesConfigSchema = vi.fn()
const saveHermesConfig = vi.fn()
const getElevenLabsVoices = vi.fn()
vi.mock('@/hermes', () => ({
getToolsetConfig: (name: string) => getToolsetConfig(name),
@ -43,7 +58,11 @@ vi.mock('@/hermes', () => ({
runToolsetPostSetup: (name: string, key: string) => runToolsetPostSetup(name, key),
getActionStatus: (name: string, lines?: number) => getActionStatus(name, lines),
startOAuthLogin: (providerId: string) => startOAuthLogin(providerId),
pollOAuthSession: (providerId: string, sessionId: string) => pollOAuthSession(providerId, sessionId)
pollOAuthSession: (providerId: string, sessionId: string) => pollOAuthSession(providerId, sessionId),
getHermesConfigRecord: () => getHermesConfigRecord(),
getHermesConfigSchema: () => getHermesConfigSchema(),
saveHermesConfig: (config: unknown) => saveHermesConfig(config),
getElevenLabsVoices: () => getElevenLabsVoices()
}))
vi.mock('@/store/notifications', () => ({
@ -105,6 +124,17 @@ beforeEach(() => {
selectToolsetProvider.mockResolvedValue({ ok: true, name: 'tts', provider: 'ElevenLabs' })
setEnvVar.mockResolvedValue({ ok: true })
deleteEnvVar.mockResolvedValue({ ok: true })
getHermesConfigRecord.mockResolvedValue({
tts: {
provider: 'edge',
edge: { voice: 'en-US-AriaNeural' },
openai: { model: 'gpt-4o-mini-tts', voice: 'alloy' },
elevenlabs: { voice_id: 'pNInz6obpgDQGcFmaJgB', model_id: 'eleven_multilingual_v2' }
}
})
getHermesConfigSchema.mockResolvedValue({ fields: {}, category_order: [] })
saveHermesConfig.mockResolvedValue({ ok: true })
getElevenLabsVoices.mockResolvedValue({ available: false, voices: [] })
})
afterEach(() => {
@ -113,6 +143,55 @@ afterEach(() => {
})
describe('ToolsetConfigPanel', () => {
it('renders inline voice/model fields for a TTS provider row carrying tts_provider', async () => {
// The Capabilities gap: provider rows only showed API keys — voice/model
// settings lived exclusively in Settings → Voice. Rows now carry the
// backend's tts_provider key and the panel renders the same config
// fields inline (here: OpenAI TTS Model + OpenAI Voice).
getToolsetConfig.mockResolvedValue(
config({
active_provider: 'OpenAI TTS',
providers: [
{
name: 'OpenAI TTS',
badge: 'paid',
tag: 'High quality voices',
env_vars: [
{ key: 'VOICE_TOOLS_OPENAI_KEY', prompt: 'OpenAI API key', url: 'https://x', default: null, is_set: true }
],
post_setup: null,
requires_nous_auth: false,
is_active: true,
tts_provider: 'openai'
}
]
})
)
const { ToolsetConfigPanel } = await import('./toolset-config-panel')
render(<ToolsetConfigPanel onConfiguredChange={vi.fn()} toolset="tts" />)
expect(await screen.findByText('OpenAI TTS Model')).toBeTruthy()
expect(screen.getByText('OpenAI Voice')).toBeTruthy()
// Voice/model names are free-input comboboxes seeded with the current
// config value — a custom voice ID must be typeable, not gated by a
// closed Select.
const voiceInput = screen.getByDisplayValue('alloy')
fireEvent.change(voiceInput, { target: { value: 'marin' } })
await waitFor(() => expect(saveHermesConfig).toHaveBeenCalled(), { timeout: 3000 })
const saved = saveHermesConfig.mock.calls.at(-1)?.[0] as Record<string, Record<string, Record<string, string>>>
expect(saved.tts.openai.voice).toBe('marin')
})
it('renders no inline voice fields for rows without tts_provider (older backend)', async () => {
const { ToolsetConfigPanel } = await import('./toolset-config-panel')
render(<ToolsetConfigPanel onConfiguredChange={vi.fn()} toolset="tts" />)
await screen.findByText('Microsoft Edge TTS')
expect(screen.queryByText('Edge Voice')).toBeNull()
expect(screen.queryByText('OpenAI Voice')).toBeNull()
})
it('lists providers from the config endpoint', async () => {
const { ToolsetConfigPanel } = await import('./toolset-config-panel')
render(<ToolsetConfigPanel onConfiguredChange={vi.fn()} toolset="tts" />)
@ -590,8 +669,8 @@ describe('ToolsetConfigPanel', () => {
render(<ToolsetConfigPanel onConfiguredChange={vi.fn()} toolset="browser" />)
await screen.findByText('Local Browser')
expect(screen.getByText('Installed')).toBeTruthy()
expect(screen.getByRole('button', { name: /Re-run setup/ })).toBeTruthy()
expect(await screen.findByText('Installed')).toBeTruthy()
expect(await screen.findByRole('button', { name: /Re-run setup/ })).toBeTruthy()
expect(screen.queryByRole('button', { name: /^Run setup$/ })).toBeNull()
})
@ -655,7 +734,10 @@ describe('ToolsetConfigPanel', () => {
render(<ToolsetConfigPanel onConfiguredChange={vi.fn()} toolset="browser" />)
await screen.findByText('Local Browser')
expect(screen.getByRole('button', { name: /Run setup/ })).toBeTruthy()
// The Run setup CTA renders inside the expanded panel, which appears one
// effect-driven re-render after the row itself — await it (getByRole
// raced the auto-expand effect and flaked under the RQ provider).
expect(await screen.findByRole('button', { name: /Run setup/ })).toBeTruthy()
expect(screen.queryByText('Installed')).toBeNull()
})
})

View File

@ -33,6 +33,7 @@ import type {
import { EnvVarActionsMenu, EnvVarActionsTrigger } from './env-var-actions-menu'
import { Pill } from './primitives'
import { VoiceProviderFields } from './voice-provider-fields'
interface ToolsetConfigPanelProps {
toolset: string
@ -804,6 +805,12 @@ export function ToolsetConfigPanel({ toolset, onConfiguredChange }: ToolsetConfi
toolset={toolset}
/>
)}
{toolset === 'tts' && provider.tts_provider && (
// Voice/model settings for this backend (tts.<key>.*) —
// the same fields Settings → Voice renders, inline so the
// Capabilities panel is a complete setup surface.
<VoiceProviderFields providerKey={provider.tts_provider} section="tts" />
)}
{MODEL_CATALOG_TOOLSETS.has(toolset) && (
<ModelCatalogPicker
isActiveBackend={provider.is_active || cfg?.active_provider === provider.name}

View File

@ -0,0 +1,77 @@
import { describe, expect, it } from 'vitest'
import { ENUM_OPTIONS, FREE_INPUT_KEYS, SECTIONS } from './constants'
import { voiceProviderKeys } from './voice-provider-fields'
const voiceKeys = SECTIONS.find(s => s.id === 'voice')?.keys ?? []
describe('voiceProviderKeys', () => {
it('derives per-provider field keys from the curated Voice section', () => {
expect(voiceProviderKeys('tts', 'openai')).toEqual(['tts.openai.model', 'tts.openai.voice'])
expect(voiceProviderKeys('tts', 'elevenlabs')).toEqual(['tts.elevenlabs.voice_id', 'tts.elevenlabs.model_id'])
expect(voiceProviderKeys('tts', 'edge')).toEqual(['tts.edge.voice'])
})
it('covers every built-in TTS provider the Capabilities picker offers', () => {
// Every provider key the backend TOOL_CATEGORIES["tts"] rows can carry
// (tts_provider values) must resolve to at least one config field, so the
// Capabilities panel never renders a silently-empty settings block.
for (const provider of [
'edge',
'openai',
'xai',
'elevenlabs',
'mistral',
'gemini',
'kittentts',
'piper',
'deepinfra',
'minimax'
]) {
expect(voiceProviderKeys('tts', provider).length, provider).toBeGreaterThan(0)
}
})
it('scopes to the exact provider segment (no prefix bleed)', () => {
expect(voiceProviderKeys('tts', 'mini')).toEqual([])
expect(voiceProviderKeys('stt', 'openai')).toEqual(['stt.openai.model'])
})
})
describe('voice field option coverage', () => {
it('offers the current gpt-4o-mini-tts voice set, not just the tts-1 six', () => {
const voices = ENUM_OPTIONS['tts.openai.voice']
for (const voice of ['alloy', 'ash', 'ballad', 'cedar', 'coral', 'marin', 'sage', 'verse', 'shimmer']) {
expect(voices).toContain(voice)
}
})
it('keeps voice/model name fields free-input so custom IDs are typeable', () => {
for (const key of [
'tts.openai.voice',
'tts.openai.model',
'tts.elevenlabs.voice_id',
'tts.edge.voice',
'tts.xai.voice_id',
'tts.piper.voice'
]) {
expect(FREE_INPUT_KEYS.has(key), key).toBe(true)
}
})
it('keeps closed enums (devices, providers) out of the free-input set', () => {
expect(FREE_INPUT_KEYS.has('tts.provider')).toBe(false)
expect(FREE_INPUT_KEYS.has('tts.neutts.device')).toBe(false)
expect(FREE_INPUT_KEYS.has('stt.provider')).toBe(false)
})
it('every free-input voice key that lives in the Voice section has suggestions or is intentionally bare', () => {
// Free-input keys don't *require* ENUM_OPTIONS (an empty datalist is
// fine), but any that do declare options must be actual Voice-section
// fields — a typo'd key here would silently do nothing.
for (const key of FREE_INPUT_KEYS) {
expect(voiceKeys, key).toContain(key)
}
})
})

View File

@ -0,0 +1,140 @@
import { useQuery } from '@tanstack/react-query'
import { useEffect, useMemo, useRef, useState } from 'react'
import { getElevenLabsVoices, getHermesConfigSchema, saveHermesConfig } from '@/hermes'
import { useI18n } from '@/i18n'
import { notifyError } from '@/store/notifications'
import type { HermesConfigRecord } from '@/types/hermes'
import { setHermesConfigCache, useHermesConfigRecord } from '../hooks/use-config-record'
import { ConfigField } from './config-field'
import { SECTIONS } from './constants'
import { enumOptionsFor, getNested, inferFieldSchema, setNested } from './helpers'
// The curated voice keys (Settings → Voice) are the single source of which
// per-provider fields exist; both the Voice settings page and the
// Capabilities TTS panel derive from it so the two surfaces never drift.
const VOICE_KEYS = SECTIONS.find(s => s.id === 'voice')?.keys ?? []
export function voiceProviderKeys(section: 'tts' | 'stt', providerKey: string): string[] {
const prefix = `${section}.${providerKey}.`
return VOICE_KEYS.filter(key => key.startsWith(prefix))
}
/**
* Inline voice/model settings for one TTS (or STT) provider, rendered inside
* the Capabilities toolset config panel underneath the provider's API-key
* fields. Reads and writes the same `tts.<provider>.*` config keys as
* Settings Voice (shared ConfigField renderer + enum/free-input rules), with
* the same debounced autosave through the shared config cache.
*/
export function VoiceProviderFields({ section, providerKey }: { section: 'tts' | 'stt'; providerKey: string }) {
const { t } = useI18n()
const keys = useMemo(() => voiceProviderKeys(section, providerKey), [section, providerKey])
const { data: loadedConfig } = useHermesConfigRecord()
const { data: schemaResponse } = useQuery({
queryKey: ['hermes-config-schema'],
queryFn: getHermesConfigSchema,
staleTime: 5 * 60 * 1000
})
// Local editable draft, seeded once from the shared cache (background
// refetches must not clobber in-progress edits) — the same shape as
// config-settings.tsx's autosave loop.
const [config, setConfig] = useState<HermesConfigRecord | null>(null)
const seeded = useRef(false)
useEffect(() => {
if (loadedConfig && !seeded.current) {
seeded.current = true
setConfig(loadedConfig)
}
}, [loadedConfig])
const saveVersionRef = useRef(0)
const [saveVersion, setSaveVersion] = useState(0)
useEffect(() => {
if (!config || saveVersion === 0) {
return
}
const timeout = window.setTimeout(() => {
void saveHermesConfig(config)
.then(() => setHermesConfigCache(config))
.catch(err => notifyError(err, t.settings.config.autosaveFailed))
}, 550)
return () => window.clearTimeout(timeout)
// eslint-disable-next-line react-hooks/exhaustive-deps -- copy is stable; avoid re-scheduling autosave on locale change
}, [config, saveVersion])
// ElevenLabs cloned/library voices from the live account, when available —
// mirrors the Settings → Voice dynamic voice list.
const [elVoices, setElVoices] = useState<string[] | null>(null)
const [elVoiceLabels, setElVoiceLabels] = useState<Record<string, string>>({})
const wantsElevenLabs = keys.includes('tts.elevenlabs.voice_id')
useEffect(() => {
if (!wantsElevenLabs) {
return
}
let cancelled = false
getElevenLabsVoices()
.then(result => {
if (cancelled || !result.available) {
return
}
setElVoices(result.voices.map(voice => voice.voice_id))
setElVoiceLabels(Object.fromEntries(result.voices.map(voice => [voice.voice_id, voice.label])))
})
.catch(() => {
if (!cancelled) {
setElVoices(null)
setElVoiceLabels({})
}
})
return () => void (cancelled = true)
}, [wantsElevenLabs])
if (keys.length === 0 || !config) {
return null
}
const schema = schemaResponse?.fields ?? {}
const updateConfig = (next: HermesConfigRecord) => {
saveVersionRef.current += 1
setConfig(next)
setSaveVersion(saveVersionRef.current)
}
return (
<div className="grid gap-0.5 rounded-lg bg-background/55 px-2.5">
{keys.map(key => {
const value = getNested(config, key)
const field = schema[key] ?? inferFieldSchema(value)
const isElVoice = key === 'tts.elevenlabs.voice_id'
return (
<ConfigField
enumOptions={enumOptionsFor(key, value, config, isElVoice ? (elVoices ?? undefined) : undefined)}
key={key}
onChange={next => updateConfig(setNested(config, key, next))}
optionLabels={isElVoice ? elVoiceLabels : undefined}
schema={field}
schemaKey={key}
value={value}
/>
)
})}
</div>
)
}

View File

@ -760,6 +760,11 @@ export interface ToolProvider {
/** Web toolset only: the backend key written to web.*backend config
* (e.g. 'searxng'). Absent on other toolsets and older backends. */
web_backend?: string
/** TTS toolset only: the provider key written to tts.provider when this row
* is selected (e.g. 'openai'). Doubles as the config section that holds the
* provider's voice/model settings (tts.<key>.*). Absent on other toolsets
* and older backends. */
tts_provider?: string
/** Web toolset only: capabilities this backend can serve. Search-only
* providers (ddgs, brave-free) report ['search']. */
capabilities?: WebCapability[]

View File

@ -2181,7 +2181,9 @@ DEFAULT_CONFIG = {
"openai": {
"model": "gpt-4o-mini-tts",
"voice": "alloy",
# Voices: alloy, echo, fable, onyx, nova, shimmer
# Voices: alloy, ash, ballad, cedar, coral, echo, fable, marin,
# nova, onyx, sage, shimmer, verse (gpt-4o-mini-tts; the tts-1
# era stopped at alloy/echo/fable/onyx/nova/shimmer)
},
"gemini": {
"model": "gemini-2.5-flash-preview-tts",
@ -2209,6 +2211,14 @@ DEFAULT_CONFIG = {
"model": "voxtral-mini-tts-2603",
"voice_id": "c69964a6-ab8b-4f8a-9465-ec0925096ec8", # Paul - Neutral
},
"minimax": {
"model": "speech-02-hd",
"voice_id": "English_expressive_narrator",
},
"kittentts": {
"model": "KittenML/kitten-tts-nano-0.8-int8", # nano 25MB; micro 41MB; mini 80MB
"voice": "Jasper",
},
"neutts": {
"ref_audio": "", # Path to reference voice audio (empty = bundled default)
"ref_text": "", # Path to reference voice transcript (empty = bundled default)

View File

@ -15011,6 +15011,12 @@ async def get_toolset_config(name: str, profile: Optional[str] = None):
# the GUI can offer per-capability selection.
row["web_backend"] = prov["web_backend"]
row["capabilities"] = web_provider_capabilities(prov["web_backend"])
if name == "tts" and prov.get("tts_provider"):
# The provider key written to tts.provider on selection.
# Doubles as the config section holding the provider's
# voice/model settings (tts.<key>.*) so the GUI can render
# those fields inline in the Capabilities panel.
row["tts_provider"] = prov["tts_provider"]
providers.append(row)
if name == "web":
# Resolve the per-capability active backends exactly the way the

View File

@ -5599,6 +5599,26 @@ class TestNewEndpoints:
by_name = {p["name"]: p for p in resp.json()["providers"]}
assert by_name["ElevenLabs"]["status"] == "ready"
def test_get_toolset_config_tts_rows_carry_provider_key(self):
"""TTS provider rows surface their tts_provider config key.
The desktop Capabilities panel renders the provider's voice/model
config fields (tts.<key>.*) inline; without the key it can only show
API keys. Every built-in TTS row declares one.
"""
resp = self.client.get("/api/tools/toolsets/tts/config")
assert resp.status_code == 200
providers = resp.json()["providers"]
assert providers
for prov in providers:
assert prov.get("tts_provider"), f"row {prov['name']!r} missing tts_provider"
by_name = {p["name"]: p for p in providers}
assert by_name["OpenAI TTS"]["tts_provider"] == "openai"
assert by_name["Microsoft Edge TTS"]["tts_provider"] == "edge"
# Non-TTS toolsets must not grow the field.
web = self.client.get("/api/tools/toolsets/web/config").json()
assert all("tts_provider" not in p for p in web["providers"])
def test_get_toolset_config_reflects_selected_provider(self):
"""Selecting a provider is reflected in the next /config read.