/** * API functions for voice assistant integration. * * Fetches conversation tokens from the hub for ElevenLabs integration. * The hub handles authentication with ElevenLabs API, keeping credentials secure. * * Supports two modes: * 1. Default: Hub uses its own ElevenLabs credentials (production) * 2. Custom: Client provides their own ElevenLabs agent ID and API key */ import type { ApiClient } from './client' import { ELEVENLABS_API_BASE, VOICE_AGENT_NAME, buildVoiceAgentConfig } from '@hapi/protocol/voice' import type { VoiceBackendType } from '@hapi/protocol/voice' export interface VoiceTokenResponse { allowed: boolean token?: string agentId?: string error?: string } export interface VoiceTokenRequest { customAgentId?: string customApiKey?: string voiceId?: string } /** * Fetch a conversation token from the hub for ElevenLabs voice sessions. * * This uses the private agent flow where: * 1. Hub holds the ELEVENLABS_API_KEY and ELEVENLABS_AGENT_ID (or uses user-provided ones) * 2. Hub fetches a short-lived conversation token from ElevenLabs * 3. Client uses this token to establish WebRTC connection */ export async function fetchVoiceToken( api: ApiClient, options?: VoiceTokenRequest ): Promise { try { return await api.fetchVoiceToken(options) } catch (error) { return { allowed: false, error: error instanceof Error ? error.message : 'Network error' } } } export interface VoiceInfo { id: string name: string previewUrl: string category: string /** Static-catalog hint (Gemini/Qwen); ElevenLabs uses API name only. */ description?: string } export async function fetchVoices(api: ApiClient): Promise { try { const result = await api.fetchVoices() return result.voices } catch { return [] } } export interface ElevenLabsAgent { agent_id: string name: string } export interface FindAgentResult { success: boolean agentId?: string error?: string } export interface CreateAgentResult { success: boolean agentId?: string error?: string created?: boolean } /** * Find an existing "Hapi Voice Assistant" agent using the provided API key. */ export async function findHapiAgent(apiKey: string): Promise { try { const response = await fetch(`${ELEVENLABS_API_BASE}/convai/agents`, { method: 'GET', headers: { 'xi-api-key': apiKey, 'Accept': 'application/json' } }) if (!response.ok) { const errorData = await response.json().catch(() => ({})) as { detail?: { message?: string } | string } const errorMessage = typeof errorData.detail === 'string' ? errorData.detail : errorData.detail?.message || `API error: ${response.status}` return { success: false, error: errorMessage } } const data = await response.json() as { agents?: ElevenLabsAgent[] } const agents: ElevenLabsAgent[] = data.agents || [] const hapiAgent = agents.find(agent => agent.name === VOICE_AGENT_NAME) if (hapiAgent) { return { success: true, agentId: hapiAgent.agent_id } } else { return { success: false, error: `No agent named "${VOICE_AGENT_NAME}" found` } } } catch (e) { return { success: false, error: e instanceof Error ? e.message : 'Network error' } } } /** * Create or update the "Hapi Voice Assistant" agent with our default configuration. */ export async function createOrUpdateHapiAgent(apiKey: string): Promise { try { const findResult = await findHapiAgent(apiKey) const existingAgentId = findResult.success ? findResult.agentId : null const agentConfig = buildVoiceAgentConfig() let response: Response let created = false if (existingAgentId) { response = await fetch(`${ELEVENLABS_API_BASE}/convai/agents/${existingAgentId}`, { method: 'PATCH', headers: { 'xi-api-key': apiKey, 'Content-Type': 'application/json', 'Accept': 'application/json' }, body: JSON.stringify(agentConfig) }) } else { response = await fetch(`${ELEVENLABS_API_BASE}/convai/agents/create`, { method: 'POST', headers: { 'xi-api-key': apiKey, 'Content-Type': 'application/json', 'Accept': 'application/json' }, body: JSON.stringify(agentConfig) }) created = true } if (!response.ok) { const errorData = await response.json().catch(() => ({})) as { detail?: { message?: string } | string } const errorMessage = typeof errorData.detail === 'string' ? errorData.detail : errorData.detail?.message || `API error: ${response.status}` return { success: false, error: errorMessage } } const data = await response.json() as { agent_id?: string } const agentId = existingAgentId || data.agent_id if (!agentId) { return { success: false, error: 'Failed to get agent ID from response' } } return { success: true, agentId, created } } catch (e) { return { success: false, error: e instanceof Error ? e.message : 'Network error' } } } // --- Pluggable voice backend API --- export interface QwenTokenResponse { allowed: boolean wsUrl?: string error?: string } /** * Fetch a DashScope API key from the hub for Qwen Realtime voice sessions. */ export async function fetchQwenToken(api: ApiClient): Promise { try { return await api.fetchQwenToken() } catch (error) { return { allowed: false, error: error instanceof Error ? error.message : 'Network error' } } } export interface VoiceBackendResponse { /** Hub default (VOICE_BACKEND env, validated against configured backends). */ backend: VoiceBackendType | null /** Backends with API keys configured on the hub. */ backends: VoiceBackendType[] } export interface GeminiTokenResponse { allowed: boolean apiKey?: string wsUrl?: string baseUrl?: string error?: string } /** * Discover which voice backend the hub is configured to use. * Throws on network/server error or unrecognised backend value — callers must handle failures explicitly. */ function isVoiceBackendType(value: string): value is VoiceBackendType { return value === 'elevenlabs' || value === 'gemini-live' || value === 'qwen-realtime' } export async function fetchVoiceBackend(api: ApiClient): Promise { const result = await api.fetchVoiceBackend() const { backend } = result if (backend !== null && !isVoiceBackendType(backend)) { throw new Error(`Unrecognised voice backend: ${backend}`) } const rawBackends = Array.isArray(result.backends) ? result.backends : backend !== null ? [backend] : [] const backends = rawBackends.filter(isVoiceBackendType) if (backend !== null && backends.length === 0) { backends.push(backend) } return { backend, backends } } /** * Fetch a Gemini API key from the hub for Gemini Live voice sessions. */ export async function fetchGeminiToken(api: ApiClient): Promise { try { return await api.fetchGeminiToken() } catch (error) { return { allowed: false, error: error instanceof Error ? error.message : 'Network error' } } }