fix(desktop): Capabilities TTS voice fields write the scoped profile's config, not the active one

ToolsetConfigPanel threads its profile scope into every fetch but rendered
VoiceProviderFields without it, and the fields were hard-wired unscoped —
configuring profile B's TTS from the Capabilities selector read and
autosaved profile A's whole config record. New capability-scoped
saveHermesConfigRecord (symmetric with getHermesConfigRecord), profile
prop threaded through, per-scope cache write-through. Unscoped callers
(Settings → Voice) unchanged.
This commit is contained in:
Teknium
2026-08-19 21:36:53 -07:00
parent f9da9e8385
commit bdac5c8e2e
3 changed files with 35 additions and 11 deletions

View File

@@ -45,6 +45,7 @@ const pollOAuthSession = vi.fn()
const getHermesConfigRecord = vi.fn()
const getHermesConfigSchema = vi.fn()
const saveHermesConfig = vi.fn()
const saveHermesConfigRecord = vi.fn()
const getElevenLabsVoices = vi.fn()
vi.mock('@/hermes', () => ({
@@ -65,6 +66,7 @@ vi.mock('@/hermes', () => ({
getHermesConfigRecord: () => getHermesConfigRecord(),
getHermesConfigSchema: () => getHermesConfigSchema(),
saveHermesConfig: (config: unknown) => saveHermesConfig(config),
saveHermesConfigRecord: (config: unknown, profile?: unknown) => saveHermesConfigRecord(config, profile),
getElevenLabsVoices: () => getElevenLabsVoices(),
// @/store/profile (pulled in transitively via use-config-record's
// normalizeProfileKey import) calls this at module-init; the full-replacement
@@ -152,6 +154,7 @@ beforeEach(() => {
})
getHermesConfigSchema.mockResolvedValue({ fields: {}, category_order: [] })
saveHermesConfig.mockResolvedValue({ ok: true })
saveHermesConfigRecord.mockResolvedValue({ ok: true })
getElevenLabsVoices.mockResolvedValue({ available: false, voices: [] })
})
@@ -195,9 +198,12 @@ describe('ToolsetConfigPanel', () => {
// 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>>>
await waitFor(() => expect(saveHermesConfigRecord).toHaveBeenCalled(), { timeout: 3000 })
const saved = saveHermesConfigRecord.mock.calls.at(-1)?.[0] as Record<string, Record<string, Record<string, string>>>
expect(saved.tts.openai.voice).toBe('marin')
// Unscoped panel (no Capabilities override) → profile rides as undefined,
// preserving the active-profile default. A scoped panel forwards its scope.
expect(saveHermesConfigRecord.mock.calls.at(-1)?.[1]).toBeUndefined()
})
it('renders no inline voice fields for rows without tts_provider (older backend)', async () => {

View File

@@ -889,8 +889,11 @@ export function ToolsetConfigPanel({ toolset, onConfiguredChange, profile }: Too
{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" />
// Capabilities panel is a complete setup surface. Profile
// threaded like every other fetch in this panel: unscoped,
// these fields read AND autosaved the ACTIVE profile's
// config while the panel claimed to configure another.
<VoiceProviderFields profile={profile} providerKey={provider.tts_provider} section="tts" />
)}
{MODEL_CATALOG_TOOLSETS.has(toolset) && (
<ModelCatalogPicker

View File

@@ -1,12 +1,12 @@
import { useQuery } from '@tanstack/react-query'
import { useEffect, useMemo, useRef, useState } from 'react'
import { getElevenLabsVoices, getHermesConfigSchema, saveHermesConfig } from '@/hermes'
import { getElevenLabsVoices, getHermesConfigSchema, type ProfileScope, saveHermesConfigRecord } 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 { hermesConfigCacheWriter, useHermesConfigRecord } from '../hooks/use-config-record'
import { ConfigField } from './config-field'
import { SECTIONS } from './constants'
@@ -30,10 +30,25 @@ export function voiceProviderKeys(section: 'tts' | 'stt', providerKey: string):
* 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 }) {
export function VoiceProviderFields({
section,
providerKey,
profile
}: {
section: 'tts' | 'stt'
providerKey: string
/** Profile whose config these fields read AND write. The Capabilities panel
* is profile-scoped (its scope selector can target another profile, even on
* another gateway); rendering these fields unscoped read and autosaved the
* ACTIVE profile's whole config record while the UI claimed to configure
* profile B — a silent cross-profile clobber. Omitted = active profile,
* which keeps the Settings → Voice page's behavior unchanged. */
profile?: ProfileScope
}) {
const { t } = useI18n()
const keys = useMemo(() => voiceProviderKeys(section, providerKey), [section, providerKey])
const { data: loadedConfig } = useHermesConfigRecord()
const { data: loadedConfig } = useHermesConfigRecord(profile)
const writeConfigCache = useMemo(() => hermesConfigCacheWriter(profile), [profile])
const { data: schemaResponse } = useQuery({
queryKey: ['hermes-config-schema'],
@@ -70,17 +85,17 @@ export function VoiceProviderFields({ section, providerKey }: { section: 'tts' |
}
const timeout = window.setTimeout(() => {
void saveHermesConfig(diffConfig(baseline ?? {}, config))
void saveHermesConfigRecord(diffConfig(baseline ?? {}, config), profile)
.then(() => {
setBaseline(config)
setHermesConfigCache(config)
writeConfigCache(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])
}, [config, profile, saveVersion, writeConfigCache])
// ElevenLabs cloned/library voices from the live account, when available —
// mirrors the Settings → Voice dynamic voice list.