mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-21 16:18:55 +00:00
feat(desktop): list config-defined command TTS/STT providers in settings
The Settings > Voice provider dropdowns (tts.provider / stt.provider) only offer the built-in providers plus whatever value is currently set. Custom `type: command` providers declared in config.yaml aren't selectable — and once you switch away from one it drops off the list, so you can only return to it by hand-editing config. enumOptionsFor now merges in the names of any `type: command` entries under the tts/stt config sections, so local command-backed engines appear alongside the built-ins and can be switched freely from the UI. Enumeration mirrors the runtime's own resolution so the dropdown can only offer a name the runtime would actually honour: the canonical `<section>.providers.<name>` location plus the back-compat top-level `<section>.<name>` block, the optional `type:` discriminator, and the built-in-name guard. The guard compares against the runtime's built-in sets rather than the ENUM_OPTIONS display list, which is not a substitute — it already omits `deepinfra` (TTS) and `deepinfra`/`local_command` (STT), so a `providers.deepinfra` command block would otherwise be offered as selectable while the runtime dispatches to the native backend instead. - helpers.ts: add commandProviderNames() + the built-in guard; merge for tts.provider + stt.provider - helpers.test.ts: cover both sections, incl. that non-command config blocks aren't offered and that built-ins absent from the display list are never offered as command providers
This commit is contained in:
parent
a729a5d386
commit
e58534f9d7
2 changed files with 201 additions and 2 deletions
|
|
@ -205,6 +205,99 @@ describe('settings helpers', () => {
|
|||
expect(opts).toContain('my-custom-command-tts')
|
||||
expect(opts).toContain('xai')
|
||||
})
|
||||
|
||||
it('surfaces user-defined command-type TTS providers (canonical providers nesting + legacy)', () => {
|
||||
const withCustom: HermesConfigRecord = {
|
||||
tts: {
|
||||
provider: 'neutts',
|
||||
// canonical location the runtime resolves first: tts.providers.<name>
|
||||
providers: {
|
||||
higgs8: { type: 'command', command: 'curl …' },
|
||||
indextts2: { type: 'command', command: 'curl …' },
|
||||
// `type:` is optional at runtime — a bare command block still qualifies
|
||||
typeless: { command: 'curl …' },
|
||||
// misconfigured: type:command but no command → NOT a runtime provider
|
||||
noop: { type: 'command' }
|
||||
},
|
||||
// back-compat: a top-level tts.<name> command block still resolves at runtime
|
||||
mylegacy: { type: 'command', command: 'curl …' },
|
||||
// a non-command block (built-in config) must NOT be offered as a provider
|
||||
edge: { voice: 'en-US-JennyNeural' }
|
||||
}
|
||||
}
|
||||
|
||||
const opts = enumOptionsFor('tts.provider', 'neutts', withCustom)
|
||||
expect(opts).toContain('higgs8') // canonical providers.<name>
|
||||
expect(opts).toContain('indextts2') // canonical providers.<name>
|
||||
expect(opts).toContain('typeless') // command block with no type: still surfaced
|
||||
expect(opts).toContain('mylegacy') // legacy top-level tts.<name>
|
||||
expect(opts).toContain('elevenlabs') // built-ins preserved
|
||||
expect(opts).not.toContain('noop') // type:command with no command is excluded
|
||||
// 'edge' appears once (the built-in), not duplicated by the config block
|
||||
expect(opts!.filter(o => o === 'edge')).toHaveLength(1)
|
||||
// the 'providers' container itself is never offered as a provider name
|
||||
expect(opts).not.toContain('providers')
|
||||
})
|
||||
|
||||
it('surfaces command-type STT providers too (canonical providers nesting)', () => {
|
||||
const withCustom: HermesConfigRecord = {
|
||||
stt: {
|
||||
provider: 'local',
|
||||
providers: { myasr: { type: 'command', command: 'curl …' } }
|
||||
}
|
||||
}
|
||||
|
||||
const opts = enumOptionsFor('stt.provider', 'local', withCustom)
|
||||
expect(opts).toContain('myasr')
|
||||
expect(opts).toContain('local')
|
||||
expect(opts).not.toContain('providers')
|
||||
})
|
||||
|
||||
// The runtime rejects a built-in name as a command provider before any config
|
||||
// lookup, so such a block must never be offered — including the names the
|
||||
// display list omits (`deepinfra` for TTS; `deepinfra`/`local_command` for
|
||||
// STT), where filtering on ENUM_OPTIONS instead of the runtime's built-in set
|
||||
// would wrongly offer a provider that can never dispatch.
|
||||
it('never offers a built-in name as a command provider, even one absent from the dropdown list', () => {
|
||||
const shadowing: HermesConfigRecord = {
|
||||
tts: {
|
||||
provider: 'edge',
|
||||
providers: {
|
||||
// built-in and absent from ENUM_OPTIONS['tts.provider']
|
||||
deepinfra: { type: 'command', command: 'curl …' },
|
||||
// built-in guard is case-insensitive at runtime (provider.lower())
|
||||
EDGE: { type: 'command', command: 'curl …' },
|
||||
// a genuine custom provider alongside them still surfaces
|
||||
higgs8: { type: 'command', command: 'curl …' }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const opts = enumOptionsFor('tts.provider', 'edge', shadowing)
|
||||
expect(opts).not.toContain('deepinfra')
|
||||
expect(opts).not.toContain('EDGE')
|
||||
expect(opts).toContain('higgs8')
|
||||
expect(opts!.filter(o => o === 'edge')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('never offers a built-in STT name absent from the dropdown list as a command provider', () => {
|
||||
const shadowing: HermesConfigRecord = {
|
||||
stt: {
|
||||
provider: 'local',
|
||||
providers: {
|
||||
// both are built-in STT names omitted from ENUM_OPTIONS['stt.provider']
|
||||
local_command: { type: 'command', command: 'curl …' },
|
||||
deepinfra: { type: 'command', command: 'curl …' },
|
||||
myasr: { type: 'command', command: 'curl …' }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const opts = enumOptionsFor('stt.provider', 'local', shadowing)
|
||||
expect(opts).not.toContain('local_command')
|
||||
expect(opts).not.toContain('deepinfra')
|
||||
expect(opts).toContain('myasr')
|
||||
})
|
||||
})
|
||||
|
||||
describe('sectionFieldEntries', () => {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { asText } from '@/lib/text'
|
||||
import { asText, normalize } from '@/lib/text'
|
||||
import type { ConfigFieldSchema, HermesConfigRecord, ToolsetInfo } from '@/types/hermes'
|
||||
|
||||
import { BUILTIN_PERSONALITIES, ENUM_OPTIONS, PROVIDER_GROUPS, SECTIONS } from './constants'
|
||||
|
|
@ -166,13 +166,119 @@ function personalityOptions(config: HermesConfigRecord): string[] {
|
|||
return [...new Set(['', ...BUILTIN_PERSONALITIES, ...customNames])]
|
||||
}
|
||||
|
||||
// Built-in provider names, mirroring `tts_tool.py:BUILTIN_TTS_PROVIDERS` and
|
||||
// `transcription_tools.py:BUILTIN_STT_PROVIDERS`. The runtime rejects a built-in
|
||||
// name as a command provider before any config lookup
|
||||
// (`_resolve_command_provider_config`: `key = provider.lower().strip()`, then
|
||||
// `if key in BUILTIN_*_PROVIDERS: return None`), so a ``providers.edge`` block
|
||||
// declaring ``type: command`` still dispatches to native Edge.
|
||||
//
|
||||
// These are deliberately NOT derived from `ENUM_OPTIONS`, which is a *display*
|
||||
// list and already drifts from the runtime sets: it omits `deepinfra` (TTS) and
|
||||
// `deepinfra`/`local_command` (STT). Filtering on the display list would offer
|
||||
// those names as command providers that the runtime would never honour.
|
||||
const BUILTIN_TTS_PROVIDERS = new Set([
|
||||
'edge',
|
||||
'elevenlabs',
|
||||
'openai',
|
||||
'minimax',
|
||||
'xai',
|
||||
'mistral',
|
||||
'gemini',
|
||||
'neutts',
|
||||
'kittentts',
|
||||
'piper',
|
||||
'deepinfra'
|
||||
])
|
||||
|
||||
const BUILTIN_STT_PROVIDERS = new Set([
|
||||
'local',
|
||||
'local_command',
|
||||
'groq',
|
||||
'openai',
|
||||
'mistral',
|
||||
'xai',
|
||||
'elevenlabs',
|
||||
'deepinfra'
|
||||
])
|
||||
|
||||
// A user-declared command provider, mirroring the runtime discriminator
|
||||
// (`tts_tool.py:_is_command_provider_config` / `transcription_tools.py`): `type`
|
||||
// is OPTIONAL and case/space-insensitive (absent or normalizing to "command"),
|
||||
// and `command` MUST be a non-empty string. So a canonical block written as just
|
||||
// ``{ command: "curl …" }`` with no ``type:`` — a fully valid runtime provider
|
||||
// under ``providers.*`` — qualifies too, while built-in blocks (which carry
|
||||
// ``voice``/``model`` and no ``command``) and the ``providers`` container itself
|
||||
// (no ``command``) are skipped.
|
||||
function isCommandProvider(value: unknown): boolean {
|
||||
if (value == null || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const record = value as Record<string, unknown>
|
||||
const type = normalize(record.type)
|
||||
|
||||
if (type !== '' && type !== 'command') {
|
||||
return false
|
||||
}
|
||||
|
||||
return typeof record.command === 'string' && record.command.trim() !== ''
|
||||
}
|
||||
|
||||
// Names of user-defined command providers, so the settings dropdown can offer
|
||||
// them alongside the built-ins instead of only whichever one is currently active
|
||||
// (otherwise, once you switch away from a custom provider it drops off the list
|
||||
// and can only be reselected by hand-editing config.yaml).
|
||||
//
|
||||
// Mirrors the runtime's dual resolution (`tts_tool.py:_get_named_provider_config`,
|
||||
// `transcription_tools.py`): the CANONICAL location is nested —
|
||||
// ``tts.providers.<name>`` / ``stt.providers.<name>`` — with a back-compat
|
||||
// fallback to a top-level ``tts.<name>`` / ``stt.<name>`` block. We enumerate
|
||||
// both (deduped), keeping only sections that satisfy isCommandProvider and whose
|
||||
// name the runtime would actually resolve as a command provider — built-ins are
|
||||
// excluded case-insensitively, matching the runtime's `provider.lower().strip()`
|
||||
// guard, so a ``providers.EDGE`` command block is not offered.
|
||||
function commandProviderNames(config: HermesConfigRecord, section: 'tts' | 'stt'): string[] {
|
||||
const builtins = section === 'tts' ? BUILTIN_TTS_PROVIDERS : BUILTIN_STT_PROVIDERS
|
||||
const names = new Set<string>()
|
||||
|
||||
for (const path of [`${section}.providers`, section]) {
|
||||
const block = getNested(config, path)
|
||||
|
||||
if (!block || typeof block !== 'object' || Array.isArray(block)) {
|
||||
continue
|
||||
}
|
||||
|
||||
for (const [name, value] of Object.entries(block as Record<string, unknown>)) {
|
||||
if (isCommandProvider(value) && !builtins.has(normalize(name))) {
|
||||
names.add(name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [...names]
|
||||
}
|
||||
|
||||
export function enumOptionsFor(
|
||||
key: string,
|
||||
value: unknown,
|
||||
config: HermesConfigRecord,
|
||||
dynamicOptions?: string[]
|
||||
): string[] | undefined {
|
||||
const opts = dynamicOptions ?? (key === 'display.personality' ? personalityOptions(config) : ENUM_OPTIONS[key])
|
||||
let opts = dynamicOptions ?? (key === 'display.personality' ? personalityOptions(config) : ENUM_OPTIONS[key])
|
||||
|
||||
// Merge in user-defined command-type providers so custom local TTS/STT
|
||||
// backends declared in config.yaml are selectable, not just the built-ins.
|
||||
// The `includes` guard keeps the list duplicate-free should the display list
|
||||
// ever carry a name we also enumerate.
|
||||
if (!dynamicOptions && opts && (key === 'tts.provider' || key === 'stt.provider')) {
|
||||
const section = key.slice(0, 3) as 'tts' | 'stt'
|
||||
const custom = commandProviderNames(config, section).filter(name => !opts!.includes(name))
|
||||
|
||||
if (custom.length > 0) {
|
||||
opts = [...opts, ...custom]
|
||||
}
|
||||
}
|
||||
|
||||
if (!opts) {
|
||||
return undefined
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue