From ecee2ab4c203efb2eee2640314569079058b85fc Mon Sep 17 00:00:00 2001 From: wusumac <736139669@qq.com> Date: Mon, 3 Aug 2026 15:13:36 +0800 Subject: [PATCH] fix(hub,web): handle unavailable voice backends via [HAPI](https://hapi.run) Co-Authored-By: HAPI --- hub/src/web/routes/voice.test.ts | 15 +++++++++ web/src/api/client.test.ts | 10 ++++++ web/src/api/client.ts | 2 +- web/src/realtime/VoiceBackendSession.test.tsx | 31 ++++++++++++++++--- 4 files changed, 53 insertions(+), 5 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/web/src/api/client.test.ts b/web/src/api/client.test.ts index 2bfa8006..25926e50 100644 --- a/web/src/api/client.test.ts +++ b/web/src/api/client.test.ts @@ -127,4 +127,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 e945c748..73aa6b6a 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -951,7 +951,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/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()