Files
hapi/web/src/api/voice.ts
T

256 lines
7.7 KiB
TypeScript

/**
* 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<VoiceTokenResponse> {
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<VoiceInfo[]> {
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<FindAgentResult> {
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<CreateAgentResult> {
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<QwenTokenResponse> {
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<VoiceBackendResponse> {
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<GeminiTokenResponse> {
try {
return await api.fetchGeminiToken()
} catch (error) {
return {
allowed: false,
error: error instanceof Error ? error.message : 'Network error'
}
}
}