From e58534f9d7ce84846da2c1318a6943ab0bffb03e Mon Sep 17 00:00:00 2001 From: laurinaitis <20582029+laurinaitis@users.noreply.github.com> Date: Sat, 11 Jul 2026 13:15:16 +0900 Subject: [PATCH] feat(desktop): list config-defined command TTS/STT providers in settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 `
.providers.` location plus the back-compat top-level `
.` 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 --- apps/desktop/src/app/settings/helpers.test.ts | 93 +++++++++++++++ apps/desktop/src/app/settings/helpers.ts | 110 +++++++++++++++++- 2 files changed, 201 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/app/settings/helpers.test.ts b/apps/desktop/src/app/settings/helpers.test.ts index 7b618822e836..616876a2f268 100644 --- a/apps/desktop/src/app/settings/helpers.test.ts +++ b/apps/desktop/src/app/settings/helpers.test.ts @@ -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. + 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. 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. + expect(opts).toContain('indextts2') // canonical providers. + expect(opts).toContain('typeless') // command block with no type: still surfaced + expect(opts).toContain('mylegacy') // legacy top-level tts. + 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', () => { diff --git a/apps/desktop/src/app/settings/helpers.ts b/apps/desktop/src/app/settings/helpers.ts index 2c3b8e9b01d1..8882f98e63ad 100644 --- a/apps/desktop/src/app/settings/helpers.ts +++ b/apps/desktop/src/app/settings/helpers.ts @@ -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 + 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.`` / ``stt.providers.`` — with a back-compat +// fallback to a top-level ``tts.`` / ``stt.`` 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() + + 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)) { + 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