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:
parent
369afc60be
commit
a1813c1ef4
|
|
@ -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
|
||||
)
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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[]
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue