[codex] Delete voice provider settings on remove (#1782)

* Delete voice provider settings on remove

* fix voice provider delete UI test
This commit is contained in:
ekko
2026-06-25 10:30:16 +08:00
committed by GitHub
parent a2eca7d713
commit 44795ff46c
14 changed files with 241 additions and 15 deletions
@@ -112,6 +112,15 @@ export async function clearSttSecret(
return body as SttProviderSettingsResponse
}
export async function deleteSttProvider(
provider: StoredSttProvider,
): Promise<{ success?: boolean; deleted?: boolean; activeProvider?: SttProvider | null }> {
return request<{ success?: boolean; deleted?: boolean; activeProvider?: SttProvider | null }>(
`/api/hermes/stt/settings/${provider}`,
{ method: 'DELETE' },
)
}
export async function deleteSttBaseUrlPreset(
provider: StoredSttProvider,
url: string,
@@ -107,6 +107,15 @@ export async function clearTtsSecret(
return body as TtsProviderSettingsResponse
}
export async function deleteTtsProvider(
provider: Exclude<StoredTtsProvider, 'edge'>,
): Promise<{ success?: boolean; deleted?: boolean; activeProvider?: StoredTtsProvider | null }> {
return request<{ success?: boolean; deleted?: boolean; activeProvider?: StoredTtsProvider | null }>(
`/api/hermes/tts/settings/${provider}`,
{ method: 'DELETE' },
)
}
export async function deleteTtsBaseUrlPreset(
provider: StoredTtsProvider,
url: string,
@@ -136,7 +136,7 @@ const moreOptions = computed(() => [
{
label: t('settings.voice.remove'),
key: 'remove',
disabled: !props.connection.hasSecret,
disabled: props.connection.isBuiltin,
},
])
@@ -149,7 +149,7 @@ function handleEditAction() {
}
function handleMoreSelect(key: string | number) {
if (key === 'remove' && props.connection.hasSecret) {
if (key === 'remove' && !props.connection.isBuiltin) {
emit('remove', props.connection)
}
}
@@ -1,7 +1,7 @@
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import {
clearTtsSecret,
deleteTtsProvider,
fetchTtsSettings,
saveActiveTtsProvider,
saveTtsSettings,
@@ -10,7 +10,7 @@ import {
type TtsStoredSettings,
} from '@/api/hermes/tts-settings'
import {
clearSttSecret,
deleteSttProvider,
fetchSttSettings,
saveActiveSttProvider,
saveSttSettings,
@@ -276,11 +276,11 @@ export function useVoiceApiConnections() {
async function deleteSecret(kind: VoiceApiKind, provider: VoiceApiProvider) {
if (kind === 'tts') {
if (!isStoredTtsProvider(provider)) return
await clearTtsSecret(provider, 'apiKey')
if (!isStoredTtsProvider(provider) || provider === 'edge') return
await deleteTtsProvider(provider)
} else {
if (!isStoredSttProvider(provider)) return
await clearSttSecret(provider, 'apiKey')
await deleteSttProvider(provider)
}
await refresh()
}
@@ -5,6 +5,7 @@ import {
assertActiveSttProvider,
assertStoredSttProvider,
clearStoredSttSecret,
deleteSttProviderSetting,
getActiveSttProvider,
getSttProviderSetting,
isStoredSttProvider,
@@ -398,6 +399,27 @@ export async function deleteSecret(ctx: Context) {
}
}
export async function deleteProvider(ctx: Context) {
const userId = authUserId(ctx)
if (!userId) return
const provider = ctx.params.provider || ''
try {
const profile = requestedProfile(ctx)
const storedProvider = assertStoredSttProvider(provider)
const deleted = deleteSttProviderSetting(profile, storedProvider)
const currentActiveProvider = getActiveSttProvider(profile)
const activeProvider = currentActiveProvider === storedProvider
? saveActiveSttProvider(profile, 'browser')
: currentActiveProvider
ctx.body = { success: true, deleted, activeProvider }
} catch (error) {
if (handleSettingsError(ctx, error)) return
throw error
}
}
export async function saveActiveProvider(ctx: Context) {
const userId = authUserId(ctx)
if (!userId) return
@@ -10,6 +10,7 @@ import {
assertActiveTtsProvider,
assertStoredTtsProvider,
clearStoredTtsSecret,
deleteTtsProviderSetting,
getActiveTtsProvider,
getTtsProviderSetting,
isStoredTtsProvider,
@@ -194,6 +195,32 @@ export async function deleteSecret(ctx: Context) {
}
}
export async function deleteProvider(ctx: Context) {
const userId = authUserId(ctx)
if (!userId) return
const provider = ctx.params.provider || ''
try {
const profile = requestedProfile(ctx)
const storedProvider = assertStoredTtsProvider(provider)
if (storedProvider === 'edge') {
ctx.status = 400
ctx.body = { error: 'built-in TTS provider cannot be deleted' }
return
}
const deleted = deleteTtsProviderSetting(profile, storedProvider)
const currentActiveProvider = getActiveTtsProvider(profile)
const activeProvider = currentActiveProvider === storedProvider
? saveActiveTtsProvider(profile, 'edge')
: currentActiveProvider
ctx.body = { success: true, deleted, activeProvider }
} catch (error) {
if (handleSettingsError(ctx, error)) return
throw error
}
}
type ProbeKind = 'tts' | 'stt'
type ProbeCompatibility = 'openai-compatible' | 'manual'
@@ -403,3 +403,13 @@ export function clearStoredSttSecret(
return getSttProviderSetting(profileName, storedProvider)
}
export function deleteSttProviderSetting(profile: string, provider: StoredSttProvider): boolean {
const profileName = normalizeProfile(profile)
const storedProvider = assertStoredSttProvider(provider)
const db = requireDb()
const result = db.prepare(
`DELETE FROM ${STT_PROFILE_PROVIDER_SETTINGS_TABLE} WHERE profile = ? AND provider = ?`
).run(profileName, storedProvider)
return result.changes > 0
}
@@ -398,3 +398,13 @@ export function clearStoredTtsSecret(
return getTtsProviderSetting(profileName, storedProvider)
}
export function deleteTtsProviderSetting(profile: string, provider: StoredTtsProvider): boolean {
const profileName = normalizeProfile(profile)
const storedProvider = assertStoredTtsProvider(provider)
const db = requireDb()
const result = db.prepare(
`DELETE FROM ${TTS_PROFILE_PROVIDER_SETTINGS_TABLE} WHERE profile = ? AND provider = ?`
).run(profileName, storedProvider)
return result.changes > 0
}
+1
View File
@@ -9,6 +9,7 @@ sttProtectedRoutes.get('/api/hermes/stt/profile-status/missing-audio', ctrl.miss
sttProtectedRoutes.post('/api/hermes/mcu/voice-turn', ctrl.mcuVoiceTurn)
sttProtectedRoutes.put('/api/hermes/stt/settings/active', ctrl.saveActiveProvider)
sttProtectedRoutes.put('/api/hermes/stt/settings/:provider', ctrl.saveSettings)
sttProtectedRoutes.delete('/api/hermes/stt/settings/:provider', ctrl.deleteProvider)
sttProtectedRoutes.delete('/api/hermes/stt/settings/:provider/base-url-preset', ctrl.deleteBaseUrlPreset)
sttProtectedRoutes.delete('/api/hermes/stt/settings/:provider/secret/:secretName', ctrl.deleteSecret)
sttProtectedRoutes.post('/api/hermes/stt/transcribe', ctrl.transcribe)
+1
View File
@@ -11,6 +11,7 @@ ttsRoutes.get('/api/hermes/mcu/audio/:file', ctrl.mcuAudio)
ttsProtectedRoutes.get('/api/hermes/tts/settings', ctrl.listSettings)
ttsProtectedRoutes.put('/api/hermes/tts/settings/active', ctrl.saveActiveProvider)
ttsProtectedRoutes.put('/api/hermes/tts/settings/:provider', ctrl.saveSettings)
ttsProtectedRoutes.delete('/api/hermes/tts/settings/:provider', ctrl.deleteProvider)
ttsProtectedRoutes.delete('/api/hermes/tts/settings/:provider/base-url-preset', ctrl.deleteBaseUrlPreset)
ttsProtectedRoutes.delete('/api/hermes/tts/settings/:provider/secret/:secretName', ctrl.deleteSecret)
ttsProtectedRoutes.post('/api/voice/providers/probe', ctrl.probeProvider)
+29 -1
View File
@@ -11,7 +11,7 @@ vi.mock('@/router', () => ({
import router from '@/router'
import { hasApiKey } from '../../packages/client/src/api/client'
import { clearSttSecret, deleteSttBaseUrlPreset, fetchSttSettings, saveActiveSttProvider, saveSttSettings } from '../../packages/client/src/api/hermes/stt-settings'
import { clearSttSecret, deleteSttBaseUrlPreset, deleteSttProvider, fetchSttSettings, saveActiveSttProvider, saveSttSettings } from '../../packages/client/src/api/hermes/stt-settings'
import { transcribeSpeech } from '../../packages/client/src/api/hermes/stt'
const mockFetch = vi.fn()
@@ -199,6 +199,34 @@ describe('stt api wrappers', () => {
})
})
it('deletes stored provider settings via the provider endpoint', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({
success: true,
deleted: true,
activeProvider: 'browser',
}),
})
const result = await deleteSttProvider('openai')
expect(mockFetch).toHaveBeenCalledWith(
'https://hermes.example/api/hermes/stt/settings/openai',
expect.objectContaining({
method: 'DELETE',
headers: expect.objectContaining({
Authorization: 'Bearer jwt-token',
}),
}),
)
expect(result).toEqual({
success: true,
deleted: true,
activeProvider: 'browser',
})
})
it('deletes saved base URL presets via the provider preset endpoint', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
+10 -7
View File
@@ -9,11 +9,13 @@ const mockFetchSttSettings = vi.fn()
const mockSaveSttSettings = vi.fn()
const mockSaveActiveSttProvider = vi.fn()
const mockClearSttSecret = vi.fn()
const mockDeleteSttProvider = vi.fn()
const mockDeleteSttBaseUrlPreset = vi.fn()
const mockTranscribeSpeech = vi.fn()
const mockFetchTtsSettings = vi.fn()
const mockSaveTtsSettings = vi.fn()
const mockClearTtsSecret = vi.fn()
const mockDeleteTtsProvider = vi.fn()
const mockDeleteTtsBaseUrlPreset = vi.fn()
const mockFetchProviderModels = vi.fn()
const mockProbeVoiceProvider = vi.fn()
@@ -371,6 +373,7 @@ vi.mock('@/api/hermes/stt-settings', () => ({
saveSttSettings: mockSaveSttSettings,
saveActiveSttProvider: mockSaveActiveSttProvider,
clearSttSecret: mockClearSttSecret,
deleteSttProvider: mockDeleteSttProvider,
deleteSttBaseUrlPreset: mockDeleteSttBaseUrlPreset,
}))
@@ -392,6 +395,7 @@ vi.mock('@/api/hermes/tts-settings', () => ({
fetchTtsSettings: mockFetchTtsSettings,
saveTtsSettings: mockSaveTtsSettings,
clearTtsSecret: mockClearTtsSecret,
deleteTtsProvider: mockDeleteTtsProvider,
deleteTtsBaseUrlPreset: mockDeleteTtsBaseUrlPreset,
}))
@@ -512,6 +516,7 @@ describe('useSttSettings', () => {
mockSaveSttSettings.mockReset()
mockSaveActiveSttProvider.mockReset()
mockClearSttSecret.mockReset()
mockDeleteSttProvider.mockReset()
mockDeleteSttBaseUrlPreset.mockReset()
mockTranscribeSpeech.mockReset()
mockMicStart.mockReset()
@@ -520,6 +525,7 @@ describe('useSttSettings', () => {
mockFetchTtsSettings.mockReset()
mockSaveTtsSettings.mockReset()
mockClearTtsSecret.mockReset()
mockDeleteTtsProvider.mockReset()
mockDeleteTtsBaseUrlPreset.mockReset()
mockFetchProviderModels.mockReset()
mockProbeVoiceProvider.mockReset()
@@ -719,6 +725,7 @@ describe('VoiceSettings STT UI', () => {
mockSaveSttSettings.mockReset()
mockSaveActiveSttProvider.mockReset()
mockClearSttSecret.mockReset()
mockDeleteSttProvider.mockReset()
mockDeleteSttBaseUrlPreset.mockReset()
mockTranscribeSpeech.mockReset()
mockMicStart.mockReset()
@@ -727,6 +734,7 @@ describe('VoiceSettings STT UI', () => {
mockFetchTtsSettings.mockReset()
mockSaveTtsSettings.mockReset()
mockClearTtsSecret.mockReset()
mockDeleteTtsProvider.mockReset()
mockDeleteTtsBaseUrlPreset.mockReset()
mockFetchProviderModels.mockReset()
mockSpeechStop.mockReset()
@@ -966,12 +974,7 @@ describe('VoiceSettings STT UI', () => {
}],
})
.mockResolvedValue({ providers: [] })
mockClearTtsSecret.mockResolvedValue({
provider: 'mimo',
settings: {},
secrets: {},
updatedAt: 6,
})
mockDeleteTtsProvider.mockResolvedValue({ success: true, deleted: true, activeProvider: null })
const wrapper = await mountComponent()
await flushPromises()
@@ -980,7 +983,7 @@ describe('VoiceSettings STT UI', () => {
await wrapper.get('[data-testid="dropdown-option-remove"]').trigger('click')
await flushPromises()
expect(mockClearTtsSecret).toHaveBeenCalledWith('mimo', 'apiKey')
expect(mockDeleteTtsProvider).toHaveBeenCalledWith('mimo')
expect(wrapper.text()).not.toContain('raw-secret')
})
@@ -132,6 +132,32 @@ describe('stt settings controller', () => {
})
})
it('deletes a stored STT provider row and falls back to browser when it was active', async () => {
const ctrl = await initController()
const user = { id: 7, username: 'bob', role: 'admin' }
await ctrl.saveSettings(makeCtx(user, {
settings: { model: 'gpt-4o-transcribe' },
secrets: { apiKey: 'server-secret' },
}, { provider: 'openai' }))
const deleteCtx = makeCtx(user, {}, { provider: 'openai' })
await ctrl.deleteProvider(deleteCtx)
expect(deleteCtx.status).toBe(200)
expect(deleteCtx.body).toEqual({
success: true,
deleted: true,
activeProvider: 'browser',
})
expect(db.prepare(
'SELECT COUNT(*) AS count FROM stt_profile_provider_settings WHERE profile = ? AND provider = ?'
).get('default', 'openai').count).toBe(0)
const activeRow = db.prepare('SELECT active_provider FROM stt_profile_settings WHERE profile = ?').get('default') as { active_provider: string }
expect(activeRow.active_provider).toBe('browser')
})
it('deletes saved custom base URL presets without deleting the current setting or secret', async () => {
const ctrl = await initController()
const user = { id: 13, username: 'dana', role: 'admin' }
@@ -263,6 +289,7 @@ describe('stt routes', () => {
const listSettings = vi.fn(async (ctx: any) => { ctx.body = { route: 'listSettings' } })
const saveActiveProvider = vi.fn(async (ctx: any) => { ctx.body = { route: 'saveActiveProvider' } })
const saveSettings = vi.fn(async (ctx: any) => { ctx.body = { route: 'saveSettings' } })
const deleteProvider = vi.fn(async (ctx: any) => { ctx.body = { route: 'deleteProvider' } })
const deleteSecret = vi.fn(async (ctx: any) => { ctx.body = { route: 'deleteSecret' } })
const deleteBaseUrlPreset = vi.fn(async (ctx: any) => { ctx.body = { route: 'deleteBaseUrlPreset' } })
const profileStatus = vi.fn(async (ctx: any) => { ctx.body = { route: 'profileStatus' } })
@@ -274,6 +301,7 @@ describe('stt routes', () => {
listSettings,
saveActiveProvider,
saveSettings,
deleteProvider,
deleteSecret,
deleteBaseUrlPreset,
profileStatus,
@@ -292,6 +320,7 @@ describe('stt routes', () => {
'/api/hermes/mcu/voice-turn',
'/api/hermes/stt/settings/active',
'/api/hermes/stt/settings/:provider',
'/api/hermes/stt/settings/:provider',
'/api/hermes/stt/settings/:provider/base-url-preset',
'/api/hermes/stt/settings/:provider/secret/:secretName',
'/api/hermes/stt/transcribe',
@@ -158,6 +158,80 @@ describe('tts synthesize controller', () => {
}
})
it('deletes a stored TTS provider row and falls back to Edge when it was active', async () => {
const { DatabaseSync } = await import('node:sqlite')
const db = new DatabaseSync(':memory:')
vi.doMock('../../packages/server/src/db/index', () => ({
getDb: () => db,
getStoragePath: () => ':memory:',
}))
try {
const schemas = await import('../../packages/server/src/db/hermes/schemas')
schemas.initAllHermesTables()
const ctrl = await import('../../packages/server/src/controllers/hermes/tts')
const { ctx: saveCtx } = createMockCtx({
settings: { model: 'tts-1', voice: 'alloy' },
secrets: { apiKey: 'server-secret' },
})
saveCtx.state = { user: { id: 7 } }
saveCtx.params = { provider: 'openai' }
saveCtx.query = {}
saveCtx.get = vi.fn(() => '')
await ctrl.saveSettings(saveCtx)
const { ctx: deleteCtx } = createMockCtx()
deleteCtx.state = { user: { id: 7 } }
deleteCtx.params = { provider: 'openai' }
deleteCtx.query = {}
deleteCtx.get = vi.fn(() => '')
await ctrl.deleteProvider(deleteCtx)
expect(deleteCtx.status).toBe(200)
expect(deleteCtx.body).toEqual({
success: true,
deleted: true,
activeProvider: 'edge',
})
expect(db.prepare(
'SELECT COUNT(*) AS count FROM tts_profile_provider_settings WHERE profile = ? AND provider = ?'
).get('default', 'openai').count).toBe(0)
const activeRow = db.prepare('SELECT active_provider FROM tts_profile_settings WHERE profile = ?').get('default') as { active_provider: string }
expect(activeRow.active_provider).toBe('edge')
} finally {
db.close()
vi.doUnmock('../../packages/server/src/db/index')
}
})
it('rejects deleting the built-in Edge TTS provider', async () => {
const { DatabaseSync } = await import('node:sqlite')
const db = new DatabaseSync(':memory:')
vi.doMock('../../packages/server/src/db/index', () => ({
getDb: () => db,
getStoragePath: () => ':memory:',
}))
try {
const schemas = await import('../../packages/server/src/db/hermes/schemas')
schemas.initAllHermesTables()
const ctrl = await import('../../packages/server/src/controllers/hermes/tts')
const { ctx } = createMockCtx()
ctx.state = { user: { id: 7 } }
ctx.params = { provider: 'edge' }
ctx.query = {}
ctx.get = vi.fn(() => '')
await ctrl.deleteProvider(ctx)
expect(ctx.status).toBe(400)
expect(ctx.body).toEqual({ error: 'built-in TTS provider cannot be deleted' })
} finally {
db.close()
vi.doUnmock('../../packages/server/src/db/index')
}
})
it('preserves numeric Edge TTS rate and pitch settings on save', async () => {
const { DatabaseSync } = await import('node:sqlite')
const db = new DatabaseSync(':memory:')
@@ -804,6 +878,7 @@ describe('tts routes', () => {
const listSettings = vi.fn(async (ctx: any) => { ctx.body = { route: 'listSettings' } })
const saveSettings = vi.fn(async (ctx: any) => { ctx.body = { route: 'saveSettings' } })
const saveActiveProvider = vi.fn(async (ctx: any) => { ctx.body = { route: 'saveActiveProvider' } })
const deleteProvider = vi.fn(async (ctx: any) => { ctx.body = { route: 'deleteProvider' } })
const deleteBaseUrlPreset = vi.fn(async (ctx: any) => { ctx.body = { route: 'deleteBaseUrlPreset' } })
const deleteSecret = vi.fn(async (ctx: any) => { ctx.body = { route: 'deleteSecret' } })
const probeProvider = vi.fn(async (ctx: any) => { ctx.body = { route: 'probeProvider' } })
@@ -817,6 +892,7 @@ describe('tts routes', () => {
listSettings,
saveSettings,
saveActiveProvider,
deleteProvider,
deleteBaseUrlPreset,
deleteSecret,
probeProvider,
@@ -836,6 +912,7 @@ describe('tts routes', () => {
'/api/hermes/tts/settings',
'/api/hermes/tts/settings/active',
'/api/hermes/tts/settings/:provider',
'/api/hermes/tts/settings/:provider',
'/api/hermes/tts/settings/:provider/base-url-preset',
'/api/hermes/tts/settings/:provider/secret/:secretName',
'/api/voice/providers/probe',