From b20bda87f1fde4c2466568175e21fe2223935b04 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=B4=E7=9B=9B=E5=8D=8E?= <736139669@qq.com> Date: Mon, 3 Aug 2026 18:05:49 +0800 Subject: [PATCH] fix(web): hide the voice button when no voice backend is configured (#1317) * chore: hide voice button when no voice backend configured (not deployed) * fix(hub,web): handle unavailable voice backends via [HAPI](https://hapi.run) Co-Authored-By: HAPI --------- Co-authored-by: HAPI --- hub/src/web/routes/voice.test.ts | 15 +++++++++ shared/src/voice.backends.test.ts | 12 +++++-- shared/src/voice.ts | 22 ++++++++----- web/src/api/client.test.ts | 10 ++++++ web/src/api/client.ts | 2 +- web/src/api/voice.ts | 8 ++--- web/src/lib/voicePickerPreferences.ts | 4 +-- web/src/realtime/VoiceBackendSession.test.tsx | 31 ++++++++++++++++--- web/src/routes/settings/useVoiceSettings.ts | 2 +- 9 files changed, 84 insertions(+), 22 deletions(-) diff --git a/hub/src/web/routes/voice.test.ts b/hub/src/web/routes/voice.test.ts index 88fd344d..0969ff70 100644 --- a/hub/src/web/routes/voice.test.ts +++ b/hub/src/web/routes/voice.test.ts @@ -488,6 +488,21 @@ describe('GET /api/voice/backend', () => { } }) + test('returns no backend when no voice credentials are configured', async () => { + delete process.env.VOICE_BACKEND + delete process.env.ELEVENLABS_API_KEY + delete process.env.GEMINI_API_KEY + delete process.env.GOOGLE_API_KEY + delete process.env.DASHSCOPE_API_KEY + delete process.env.QWEN_API_KEY + const app = createApp() + const headers = await authHeaders() + const res = await app.request('/api/voice/backend', { headers }) + expect(res.status).toBe(200) + const body = await res.json() as { backend: string | null; backends: string[] } + expect(body).toEqual({ backend: null, backends: [] }) + }) + test('returns elevenlabs by default with backends list', async () => { delete process.env.VOICE_BACKEND delete process.env.GEMINI_API_KEY diff --git a/shared/src/voice.backends.test.ts b/shared/src/voice.backends.test.ts index 9de033c2..b336206e 100644 --- a/shared/src/voice.backends.test.ts +++ b/shared/src/voice.backends.test.ts @@ -38,8 +38,8 @@ describe('listConfiguredVoiceBackends', () => { expect(backends).toEqual(['elevenlabs', 'gemini-live', 'qwen-realtime']) }) - test('falls back to elevenlabs when no keys configured', () => { - expect(listConfiguredVoiceBackends({})).toEqual(['elevenlabs']) + test('returns empty when no keys configured', () => { + expect(listConfiguredVoiceBackends({})).toEqual([]) }) }) @@ -67,6 +67,10 @@ describe('resolveHubVoiceBackend', () => { }) expect(backend).toBe('elevenlabs') }) + + test('returns null when no backends configured', () => { + expect(resolveHubVoiceBackend({})).toBeNull() + }) }) describe('resolveEffectiveVoiceBackend', () => { @@ -80,4 +84,8 @@ describe('resolveEffectiveVoiceBackend', () => { expect(resolveEffectiveVoiceBackend(configured, 'gemini-live', null)).toBe('gemini-live') expect(resolveEffectiveVoiceBackend(configured, 'gemini-live', 'qwen-realtime')).toBe('gemini-live') }) + + test('returns null when no backends configured', () => { + expect(resolveEffectiveVoiceBackend([], null, null)).toBeNull() + }) }) diff --git a/shared/src/voice.ts b/shared/src/voice.ts index 5f32bf39..9a81d0bb 100644 --- a/shared/src/voice.ts +++ b/shared/src/voice.ts @@ -310,25 +310,31 @@ export function listConfiguredVoiceBackends(env: VoiceBackendEnv): VoiceBackendT if (env.DASHSCOPE_API_KEY?.trim() || env.QWEN_API_KEY?.trim()) { backends.push('qwen-realtime') } - return backends.length > 0 ? backends : [DEFAULT_VOICE_BACKEND] + return backends } -/** Hub default from VOICE_BACKEND when configured, else first available backend. */ -export function resolveHubVoiceBackend(env: VoiceBackendEnv): VoiceBackendType { +/** Hub default from VOICE_BACKEND when configured, else first available backend. null when none configured. */ +export function resolveHubVoiceBackend(env: VoiceBackendEnv): VoiceBackendType | null { const configured = listConfiguredVoiceBackends(env) + if (configured.length === 0) { + return null + } const raw = env.VOICE_BACKEND const fromEnv = VOICE_BACKEND_VALUES.includes(raw as VoiceBackendType) ? (raw as VoiceBackendType) : DEFAULT_VOICE_BACKEND - return configured.includes(fromEnv) ? fromEnv : (configured[0] ?? DEFAULT_VOICE_BACKEND) + return configured.includes(fromEnv) ? fromEnv : configured[0]! } /** User preference wins when valid; otherwise hub default. */ export function resolveEffectiveVoiceBackend( configured: readonly VoiceBackendType[], - hubDefault: VoiceBackendType, + hubDefault: VoiceBackendType | null, storedPreference: string | null | undefined -): VoiceBackendType { +): VoiceBackendType | null { + if (configured.length === 0) { + return null + } if ( storedPreference && VOICE_BACKEND_VALUES.includes(storedPreference as VoiceBackendType) @@ -336,10 +342,10 @@ export function resolveEffectiveVoiceBackend( ) { return storedPreference as VoiceBackendType } - if (configured.includes(hubDefault)) { + if (hubDefault && configured.includes(hubDefault)) { return hubDefault } - return configured[0] ?? hubDefault + return configured[0] ?? null } export const GEMINI_LIVE_MODEL = 'gemini-2.5-flash-native-audio-latest' diff --git a/web/src/api/client.test.ts b/web/src/api/client.test.ts index b7b1339d..027a654b 100644 --- a/web/src/api/client.test.ts +++ b/web/src/api/client.test.ts @@ -138,4 +138,14 @@ describe('ApiClient error mapping', () => { expect(init?.body).toBeInstanceOf(FormData) expect(new Headers(init?.headers).has('content-type')).toBe(false) }) + + it('preserves an unavailable voice backend response', async () => { + fetchMock.mockResolvedValueOnce( + new Response(JSON.stringify({ backend: null, backends: [] }), { status: 200 }) + ) + + const api = new ApiClient('test-token') + await expect(api.fetchVoiceBackend()).resolves.toEqual({ backend: null, backends: [] }) + expect(fetchMock.mock.calls[0]?.[0]).toBe('/api/voice/backend') + }) }) diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 4c454435..9b4e692e 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -963,7 +963,7 @@ export class ApiClient { return this.getToken ? this.getToken() : this.token } - async fetchVoiceBackend(): Promise<{ backend: string; backends: string[] }> { + async fetchVoiceBackend(): Promise<{ backend: string | null; backends: string[] }> { return await this.request('/api/voice/backend') } diff --git a/web/src/api/voice.ts b/web/src/api/voice.ts index 9df55841..4b8679d5 100644 --- a/web/src/api/voice.ts +++ b/web/src/api/voice.ts @@ -205,7 +205,7 @@ export async function fetchQwenToken(api: ApiClient): Promise export interface VoiceBackendResponse { /** Hub default (VOICE_BACKEND env, validated against configured backends). */ - backend: VoiceBackendType + backend: VoiceBackendType | null /** Backends with API keys configured on the hub. */ backends: VoiceBackendType[] } @@ -229,12 +229,12 @@ function isVoiceBackendType(value: string): value is VoiceBackendType { export async function fetchVoiceBackend(api: ApiClient): Promise { const result = await api.fetchVoiceBackend() const { backend } = result - if (!isVoiceBackendType(backend)) { + if (backend !== null && !isVoiceBackendType(backend)) { throw new Error(`Unrecognised voice backend: ${backend}`) } - const rawBackends = Array.isArray(result.backends) ? result.backends : [backend] + const rawBackends = Array.isArray(result.backends) ? result.backends : backend !== null ? [backend] : [] const backends = rawBackends.filter(isVoiceBackendType) - if (backends.length === 0) { + if (backend !== null && backends.length === 0) { backends.push(backend) } return { backend, backends } diff --git a/web/src/lib/voicePickerPreferences.ts b/web/src/lib/voicePickerPreferences.ts index d0ccc3d9..5b78581a 100644 --- a/web/src/lib/voicePickerPreferences.ts +++ b/web/src/lib/voicePickerPreferences.ts @@ -58,8 +58,8 @@ export function writeStoredVoiceBackendPreference(backend: VoiceBackendType): vo export function resolveSelectedVoiceBackend( configured: readonly VoiceBackendType[], - hubDefault: VoiceBackendType -): VoiceBackendType { + hubDefault: VoiceBackendType | null +): VoiceBackendType | null { return resolveEffectiveVoiceBackend( configured, hubDefault, diff --git a/web/src/realtime/VoiceBackendSession.test.tsx b/web/src/realtime/VoiceBackendSession.test.tsx index 8c592da1..131c597b 100644 --- a/web/src/realtime/VoiceBackendSession.test.tsx +++ b/web/src/realtime/VoiceBackendSession.test.tsx @@ -1,4 +1,4 @@ -import { cleanup, render, waitFor } from '@testing-library/react' +import { act, cleanup, render, waitFor } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { VoiceBackendSession } from '@/realtime/VoiceBackendSession' import type { ApiClient } from '@/api/client' @@ -10,15 +10,15 @@ vi.mock('@/api/voice', () => ({ })) vi.mock('@/realtime/RealtimeVoiceSession', () => ({ - RealtimeVoiceSession: () => null, + RealtimeVoiceSession: () =>
, })) vi.mock('@/realtime/GeminiLiveVoiceSession', () => ({ - GeminiLiveVoiceSession: () => null, + GeminiLiveVoiceSession: () =>
, })) vi.mock('@/realtime/QwenVoiceSession', () => ({ - QwenVoiceSession: () => null, + QwenVoiceSession: () =>
, })) const api = {} as ApiClient @@ -48,6 +48,29 @@ describe('VoiceBackendSession', () => { consoleError.mockRestore() }) + it('does not mount a voice session when no backend is configured', async () => { + fetchVoiceBackendMock.mockResolvedValue({ backend: null, backends: [] }) + const onReadyChange = vi.fn() + + const view = render( + + ) + await act(async () => { + await Promise.resolve() + }) + + expect(fetchVoiceBackendMock).toHaveBeenCalledWith(api) + expect(view.queryByTestId('elevenlabs-session')).toBeNull() + expect(view.queryByTestId('gemini-session')).toBeNull() + expect(view.queryByTestId('qwen-session')).toBeNull() + expect(onReadyChange).not.toHaveBeenCalled() + }) + it('reports the detected backend as ready', async () => { fetchVoiceBackendMock.mockResolvedValue({ backend: 'elevenlabs', backends: ['elevenlabs'] }) const onStatusChange = vi.fn() diff --git a/web/src/routes/settings/useVoiceSettings.ts b/web/src/routes/settings/useVoiceSettings.ts index 0b02781c..5a9481cf 100644 --- a/web/src/routes/settings/useVoiceSettings.ts +++ b/web/src/routes/settings/useVoiceSettings.ts @@ -33,7 +33,7 @@ export function useVoiceSettings() { setConfiguredBackends(response.backends) const selected = resolveSelectedVoiceBackend(response.backends, response.backend) setBackendState(selected) - setVoiceIdState(readStoredVoiceSelection(selected)) + setVoiceIdState(selected ? readStoredVoiceSelection(selected) : null) }).catch(() => { if (cancelled) return setConfiguredBackends(['elevenlabs'])