mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
feat(voice): dynamic settings voice picker with safe fallback + preview (#690)
* feat(voice): dynamic settings voice picker with safe fallback + preview * fix(voice): honor picker with configured agent and stop preview on unmount * fix(voice): apply PR review feedback for agent selection and preview cleanup
This commit is contained in:
@@ -0,0 +1,190 @@
|
||||
import { describe, expect, it, mock } from 'bun:test'
|
||||
import { Hono } from 'hono'
|
||||
import { SignJWT } from 'jose'
|
||||
import type { WebAppEnv } from '../middleware/auth'
|
||||
import { createAuthMiddleware } from '../middleware/auth'
|
||||
import { createVoiceRoutes } from './voice'
|
||||
|
||||
const JWT_SECRET = new TextEncoder().encode('test-secret')
|
||||
|
||||
async function authHeaders() {
|
||||
const token = await new SignJWT({ uid: 1, ns: 'default' })
|
||||
.setProtectedHeader({ alg: 'HS256' })
|
||||
.setIssuedAt()
|
||||
.setExpirationTime('1h')
|
||||
.sign(JWT_SECRET)
|
||||
return { authorization: `Bearer ${token}` }
|
||||
}
|
||||
|
||||
function createApp() {
|
||||
const app = new Hono<WebAppEnv>()
|
||||
app.use('*', createAuthMiddleware(JWT_SECRET))
|
||||
app.route('/api', createVoiceRoutes())
|
||||
return app
|
||||
}
|
||||
|
||||
describe('GET /api/voice/voices', () => {
|
||||
it('returns 401 without auth', async () => {
|
||||
const app = createApp()
|
||||
const res = await app.request('/api/voice/voices')
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
|
||||
it('returns empty list when ELEVENLABS_API_KEY is not set', async () => {
|
||||
const app = createApp()
|
||||
const headers = await authHeaders()
|
||||
const prev = process.env.ELEVENLABS_API_KEY
|
||||
delete process.env.ELEVENLABS_API_KEY
|
||||
|
||||
const res = await app.request('/api/voice/voices', { headers })
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual({ voices: [] })
|
||||
|
||||
if (prev) process.env.ELEVENLABS_API_KEY = prev
|
||||
})
|
||||
|
||||
it('maps ElevenLabs voice fields correctly', async () => {
|
||||
const app = createApp()
|
||||
const headers = await authHeaders()
|
||||
const prev = process.env.ELEVENLABS_API_KEY
|
||||
process.env.ELEVENLABS_API_KEY = 'test-key'
|
||||
|
||||
const fetchMock = mock(() => Promise.resolve(new Response(JSON.stringify({
|
||||
voices: [
|
||||
{ voice_id: 'v1', name: 'Alice', preview_url: 'https://cdn.example/a.mp3', category: 'premade' },
|
||||
{ voice_id: 'v2', name: 'MyClone', preview_url: 'https://cdn.example/c.mp3', category: 'cloned' },
|
||||
]
|
||||
}), { status: 200 })))
|
||||
|
||||
const originalFetch = global.fetch
|
||||
// @ts-expect-error test override
|
||||
global.fetch = fetchMock
|
||||
|
||||
const res = await app.request('/api/voice/voices', { headers })
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual({
|
||||
voices: [
|
||||
{ id: 'v1', name: 'Alice', previewUrl: 'https://cdn.example/a.mp3', category: 'premade' },
|
||||
{ id: 'v2', name: 'MyClone', previewUrl: 'https://cdn.example/c.mp3', category: 'cloned' },
|
||||
]
|
||||
})
|
||||
|
||||
global.fetch = originalFetch
|
||||
if (prev) process.env.ELEVENLABS_API_KEY = prev
|
||||
else delete process.env.ELEVENLABS_API_KEY
|
||||
})
|
||||
})
|
||||
|
||||
describe('POST /api/voice/token', () => {
|
||||
it('creates/selects voice-specific agent when voiceId is provided', async () => {
|
||||
const app = createApp()
|
||||
const headers = {
|
||||
...(await authHeaders()),
|
||||
'content-type': 'application/json'
|
||||
}
|
||||
|
||||
const prevKey = process.env.ELEVENLABS_API_KEY
|
||||
const prevAgent = process.env.ELEVENLABS_AGENT_ID
|
||||
process.env.ELEVENLABS_API_KEY = 'test-key'
|
||||
delete process.env.ELEVENLABS_AGENT_ID
|
||||
|
||||
const requests: Array<{ url: string; init?: RequestInit }> = []
|
||||
const originalFetch = global.fetch
|
||||
// @ts-expect-error test override
|
||||
global.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = String(input)
|
||||
requests.push({ url, init })
|
||||
|
||||
if (url.endsWith('/convai/agents') && init?.method === 'GET') {
|
||||
return new Response(JSON.stringify({ agents: [] }), { status: 200 })
|
||||
}
|
||||
if (url.endsWith('/convai/agents/create') && init?.method === 'POST') {
|
||||
return new Response(JSON.stringify({ agent_id: 'agent_voice_alice' }), { status: 200 })
|
||||
}
|
||||
if (url.includes('/convai/conversation/token?agent_id=')) {
|
||||
return new Response(JSON.stringify({ token: 'tok_alice' }), { status: 200 })
|
||||
}
|
||||
return new Response('not found', { status: 404 })
|
||||
}) as typeof fetch
|
||||
|
||||
const res = await app.request('/api/voice/token', {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({ voiceId: 'alice-voice-id' })
|
||||
})
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual({
|
||||
allowed: true,
|
||||
token: 'tok_alice',
|
||||
agentId: 'agent_voice_alice'
|
||||
})
|
||||
|
||||
const createCall = requests.find(r => r.url.endsWith('/convai/agents/create'))
|
||||
expect(createCall).toBeTruthy()
|
||||
const createBody = JSON.parse(String(createCall?.init?.body))
|
||||
expect(createBody.name).toContain('[voice:alice-voice-id]')
|
||||
expect(createBody.conversation_config?.tts?.voice_id).toBe('alice-voice-id')
|
||||
|
||||
global.fetch = originalFetch
|
||||
if (prevKey) process.env.ELEVENLABS_API_KEY = prevKey
|
||||
else delete process.env.ELEVENLABS_API_KEY
|
||||
if (prevAgent) process.env.ELEVENLABS_AGENT_ID = prevAgent
|
||||
else delete process.env.ELEVENLABS_AGENT_ID
|
||||
})
|
||||
|
||||
it('prefers voice-specific agent over ELEVENLABS_AGENT_ID when voiceId is provided', async () => {
|
||||
const app = createApp()
|
||||
const headers = {
|
||||
...(await authHeaders()),
|
||||
'content-type': 'application/json'
|
||||
}
|
||||
|
||||
const prevKey = process.env.ELEVENLABS_API_KEY
|
||||
const prevAgent = process.env.ELEVENLABS_AGENT_ID
|
||||
process.env.ELEVENLABS_API_KEY = 'test-key'
|
||||
process.env.ELEVENLABS_AGENT_ID = 'env_default_agent'
|
||||
|
||||
const requests: Array<{ url: string; init?: RequestInit }> = []
|
||||
const originalFetch = global.fetch
|
||||
// @ts-expect-error test override
|
||||
global.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = String(input)
|
||||
requests.push({ url, init })
|
||||
|
||||
if (url.endsWith('/convai/agents') && init?.method === 'GET') {
|
||||
return new Response(JSON.stringify({ agents: [] }), { status: 200 })
|
||||
}
|
||||
if (url.endsWith('/convai/agents/create') && init?.method === 'POST') {
|
||||
return new Response(JSON.stringify({ agent_id: 'agent_voice_jessicax' }), { status: 200 })
|
||||
}
|
||||
if (url.includes('/convai/conversation/token?agent_id=')) {
|
||||
return new Response(JSON.stringify({ token: 'tok_jessicax' }), { status: 200 })
|
||||
}
|
||||
return new Response('not found', { status: 404 })
|
||||
}) as typeof fetch
|
||||
|
||||
const res = await app.request('/api/voice/token', {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({ voiceId: 'jessicax-voice-id' })
|
||||
})
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual({
|
||||
allowed: true,
|
||||
token: 'tok_jessicax',
|
||||
agentId: 'agent_voice_jessicax'
|
||||
})
|
||||
|
||||
const tokenCall = requests.find(r => r.url.includes('/convai/conversation/token?agent_id='))
|
||||
expect(tokenCall?.url).toContain('agent_id=agent_voice_jessicax')
|
||||
expect(tokenCall?.url).not.toContain('agent_id=env_default_agent')
|
||||
|
||||
global.fetch = originalFetch
|
||||
if (prevKey) process.env.ELEVENLABS_API_KEY = prevKey
|
||||
else delete process.env.ELEVENLABS_API_KEY
|
||||
if (prevAgent) process.env.ELEVENLABS_AGENT_ID = prevAgent
|
||||
else delete process.env.ELEVENLABS_AGENT_ID
|
||||
})
|
||||
})
|
||||
+191
-20
@@ -9,7 +9,17 @@ import {
|
||||
|
||||
const tokenRequestSchema = z.object({
|
||||
customAgentId: z.string().optional(),
|
||||
customApiKey: z.string().optional()
|
||||
customApiKey: z.string().optional(),
|
||||
voiceId: z.string().optional()
|
||||
})
|
||||
|
||||
const telemetryEventSchema = z.object({
|
||||
stage: z.string().min(1),
|
||||
message: z.string().min(1),
|
||||
sessionId: z.string().optional(),
|
||||
voiceId: z.string().optional(),
|
||||
language: z.string().optional(),
|
||||
details: z.record(z.string(), z.unknown()).optional()
|
||||
})
|
||||
|
||||
// Cache for auto-created agent IDs (keyed by API key hash)
|
||||
@@ -20,10 +30,26 @@ interface ElevenLabsAgent {
|
||||
name: string
|
||||
}
|
||||
|
||||
function parseVoiceAgentMap(): Record<string, string> {
|
||||
const raw = process.env.ELEVENLABS_VOICE_AGENT_MAP
|
||||
if (!raw) return {}
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as unknown
|
||||
if (!parsed || typeof parsed !== 'object') return {}
|
||||
return Object.fromEntries(
|
||||
Object.entries(parsed as Record<string, unknown>)
|
||||
.filter(([k, v]) => typeof k === 'string' && typeof v === 'string')
|
||||
.map(([k, v]) => [k, v as string])
|
||||
)
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find an existing "Hapi Voice Assistant" agent
|
||||
*/
|
||||
async function findHapiAgent(apiKey: string): Promise<string | null> {
|
||||
async function findHapiAgent(apiKey: string, agentName: string = VOICE_AGENT_NAME): Promise<string | null> {
|
||||
try {
|
||||
const response = await fetch(`${ELEVENLABS_API_BASE}/convai/agents`, {
|
||||
method: 'GET',
|
||||
@@ -39,7 +65,7 @@ async function findHapiAgent(apiKey: string): Promise<string | null> {
|
||||
|
||||
const data = await response.json() as { agents?: ElevenLabsAgent[] }
|
||||
const agents: ElevenLabsAgent[] = data.agents || []
|
||||
const hapiAgent = agents.find(agent => agent.name === VOICE_AGENT_NAME)
|
||||
const hapiAgent = agents.find(agent => agent.name === agentName)
|
||||
|
||||
return hapiAgent?.agent_id || null
|
||||
} catch {
|
||||
@@ -51,7 +77,17 @@ async function findHapiAgent(apiKey: string): Promise<string | null> {
|
||||
* Create a new "Hapi Voice Assistant" agent
|
||||
*/
|
||||
async function createHapiAgent(apiKey: string): Promise<string | null> {
|
||||
return createNamedHapiAgent(apiKey, VOICE_AGENT_NAME)
|
||||
}
|
||||
|
||||
async function createNamedHapiAgent(apiKey: string, agentName: string, voiceId?: string): Promise<string | null> {
|
||||
try {
|
||||
const config = buildVoiceAgentConfig()
|
||||
config.name = agentName
|
||||
if (voiceId) {
|
||||
config.conversation_config.tts.voice_id = voiceId
|
||||
}
|
||||
|
||||
const response = await fetch(`${ELEVENLABS_API_BASE}/convai/agents/create`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
@@ -59,7 +95,7 @@ async function createHapiAgent(apiKey: string): Promise<string | null> {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(buildVoiceAgentConfig())
|
||||
body: JSON.stringify(config)
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -83,23 +119,37 @@ async function createHapiAgent(apiKey: string): Promise<string | null> {
|
||||
* Get or create agent ID - finds existing or creates new "Hapi Voice Assistant" agent
|
||||
*/
|
||||
async function getOrCreateAgentId(apiKey: string): Promise<string | null> {
|
||||
return getOrCreateAgentIdForVoice(apiKey)
|
||||
}
|
||||
|
||||
function getVoiceAgentName(voiceId?: string): string {
|
||||
if (!voiceId || voiceId.trim().length === 0) return VOICE_AGENT_NAME
|
||||
return `${VOICE_AGENT_NAME} [voice:${voiceId}]`
|
||||
}
|
||||
|
||||
async function getOrCreateAgentIdForVoice(apiKey: string, voiceId?: string): Promise<string | null> {
|
||||
// Check cache first (simple hash of first/last chars of API key)
|
||||
const cacheKey = `${apiKey.slice(0, 4)}...${apiKey.slice(-4)}`
|
||||
const cacheKey = `${apiKey.slice(0, 4)}...${apiKey.slice(-4)}::${voiceId ?? 'default'}`
|
||||
const cached = agentIdCache.get(cacheKey)
|
||||
if (cached) {
|
||||
return cached
|
||||
}
|
||||
|
||||
const agentName = getVoiceAgentName(voiceId)
|
||||
|
||||
// Try to find existing agent
|
||||
console.log('[Voice] No agent ID configured, searching for existing agent...')
|
||||
let agentId = await findHapiAgent(apiKey)
|
||||
console.log('[Voice] No agent ID configured, searching for existing agent...', {
|
||||
voiceId,
|
||||
agentName
|
||||
})
|
||||
let agentId = await findHapiAgent(apiKey, agentName)
|
||||
|
||||
if (agentId) {
|
||||
console.log('[Voice] Found existing agent:', agentId)
|
||||
} else {
|
||||
// Create new agent
|
||||
console.log('[Voice] No existing agent found, creating new one...')
|
||||
agentId = await createHapiAgent(apiKey)
|
||||
agentId = await createNamedHapiAgent(apiKey, agentName, voiceId)
|
||||
if (agentId) {
|
||||
console.log('[Voice] Created new agent:', agentId)
|
||||
}
|
||||
@@ -118,37 +168,64 @@ export function createVoiceRoutes(): Hono<WebAppEnv> {
|
||||
|
||||
// Get ElevenLabs ConvAI conversation token
|
||||
app.post('/voice/token', async (c) => {
|
||||
const requestId = crypto.randomUUID()
|
||||
const json = await c.req.json().catch(() => null)
|
||||
const parsed = tokenRequestSchema.safeParse(json ?? {})
|
||||
if (!parsed.success) {
|
||||
console.warn('[Voice][Token] Invalid request body', { requestId })
|
||||
return c.json({ allowed: false, error: 'Invalid request body' }, 400)
|
||||
}
|
||||
|
||||
const { customAgentId, customApiKey } = parsed.data
|
||||
const { customAgentId, customApiKey, voiceId } = parsed.data
|
||||
|
||||
// Use custom credentials if provided, otherwise fall back to env vars
|
||||
const apiKey = customApiKey || process.env.ELEVENLABS_API_KEY
|
||||
let agentId = customAgentId || process.env.ELEVENLABS_AGENT_ID
|
||||
const voiceAgentMap = parseVoiceAgentMap()
|
||||
const mappedAgentId = voiceId ? voiceAgentMap[voiceId] : undefined
|
||||
let agentId = customAgentId || mappedAgentId
|
||||
|
||||
if (!apiKey) {
|
||||
console.warn('[Voice][Token] Missing API key', { requestId })
|
||||
return c.json({
|
||||
allowed: false,
|
||||
error: 'ElevenLabs API key not configured'
|
||||
}, 400)
|
||||
}
|
||||
|
||||
// Auto-create agent if not configured
|
||||
// If a voice was selected and no explicit mapping/custom agent is set,
|
||||
// resolve/create a dedicated per-voice agent so selection always takes effect.
|
||||
if (!agentId && voiceId) {
|
||||
agentId = await getOrCreateAgentIdForVoice(apiKey, voiceId) ?? undefined
|
||||
}
|
||||
|
||||
// Fallback to environment default agent only when no voice-specific route applies.
|
||||
if (!agentId) {
|
||||
agentId = await getOrCreateAgentId(apiKey) ?? undefined
|
||||
if (!agentId) {
|
||||
return c.json({
|
||||
allowed: false,
|
||||
error: 'Failed to create ElevenLabs agent automatically'
|
||||
}, 500)
|
||||
}
|
||||
agentId = process.env.ELEVENLABS_AGENT_ID
|
||||
}
|
||||
|
||||
// Final fallback for setups without configured agent id.
|
||||
if (!agentId) {
|
||||
agentId = await getOrCreateAgentIdForVoice(apiKey, undefined) ?? undefined
|
||||
}
|
||||
|
||||
if (!agentId) {
|
||||
console.error('[Voice][Token] Failed to resolve/create agent ID', { requestId })
|
||||
return c.json({
|
||||
allowed: false,
|
||||
error: 'Failed to create ElevenLabs agent automatically'
|
||||
}, 500)
|
||||
}
|
||||
|
||||
try {
|
||||
console.log('[Voice][Token] Requesting ElevenLabs conversation token', {
|
||||
requestId,
|
||||
agentId,
|
||||
voiceId,
|
||||
hasCustomAgentId: Boolean(customAgentId),
|
||||
hasMappedAgentId: Boolean(mappedAgentId),
|
||||
hasCustomApiKey: Boolean(customApiKey)
|
||||
})
|
||||
|
||||
// Fetch conversation token from ElevenLabs
|
||||
const response = await fetch(
|
||||
`https://api.elevenlabs.io/v1/convai/conversation/token?agent_id=${encodeURIComponent(agentId)}`,
|
||||
@@ -164,7 +241,12 @@ export function createVoiceRoutes(): Hono<WebAppEnv> {
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({})) as { detail?: { message?: string }; error?: string }
|
||||
const errorMessage = errorData.detail?.message || errorData.error || `ElevenLabs API error: ${response.status}`
|
||||
console.error('[Voice] Failed to get token from ElevenLabs:', errorMessage)
|
||||
console.error('[Voice][Token] Failed to get token from ElevenLabs', {
|
||||
requestId,
|
||||
agentId,
|
||||
status: response.status,
|
||||
errorMessage
|
||||
})
|
||||
return c.json({
|
||||
allowed: false,
|
||||
error: errorMessage
|
||||
@@ -173,19 +255,29 @@ export function createVoiceRoutes(): Hono<WebAppEnv> {
|
||||
|
||||
const data = await response.json() as { token?: string }
|
||||
if (!data.token) {
|
||||
console.error('[Voice][Token] Token response missing token field', {
|
||||
requestId,
|
||||
agentId
|
||||
})
|
||||
return c.json({
|
||||
allowed: false,
|
||||
error: 'No token in ElevenLabs response'
|
||||
}, 500)
|
||||
}
|
||||
|
||||
console.log('[Voice][Token] Token issued successfully', { requestId, agentId })
|
||||
|
||||
return c.json({
|
||||
allowed: true,
|
||||
token: data.token,
|
||||
agentId
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('[Voice] Error fetching token:', error)
|
||||
console.error('[Voice][Token] Error fetching token', {
|
||||
requestId,
|
||||
agentId,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
return c.json({
|
||||
allowed: false,
|
||||
error: error instanceof Error ? error.message : 'Network error'
|
||||
@@ -193,5 +285,84 @@ export function createVoiceRoutes(): Hono<WebAppEnv> {
|
||||
}
|
||||
})
|
||||
|
||||
// Get available ElevenLabs voices (includes user's voice clones)
|
||||
app.get('/voice/voices', async (c) => {
|
||||
const requestId = crypto.randomUUID()
|
||||
const apiKey = process.env.ELEVENLABS_API_KEY
|
||||
if (!apiKey) {
|
||||
console.warn('[Voice][Voices] Missing API key, returning empty voices list', { requestId })
|
||||
return c.json({ voices: [] })
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${ELEVENLABS_API_BASE}/voices`, {
|
||||
headers: {
|
||||
'xi-api-key': apiKey,
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
console.error('[Voice][Voices] ElevenLabs voices request failed', {
|
||||
requestId,
|
||||
status: response.status
|
||||
})
|
||||
return c.json({ voices: [] })
|
||||
}
|
||||
|
||||
const data = await response.json() as {
|
||||
voices?: Array<{
|
||||
voice_id: string
|
||||
name: string
|
||||
preview_url: string
|
||||
category: string
|
||||
}>
|
||||
}
|
||||
|
||||
const voices = (data.voices ?? []).map(v => ({
|
||||
id: v.voice_id,
|
||||
name: v.name,
|
||||
previewUrl: v.preview_url,
|
||||
category: v.category
|
||||
}))
|
||||
|
||||
console.log('[Voice][Voices] Voices fetched', {
|
||||
requestId,
|
||||
count: voices.length
|
||||
})
|
||||
|
||||
return c.json({ voices })
|
||||
} catch (error) {
|
||||
console.error('[Voice][Voices] Unexpected error fetching voices', {
|
||||
requestId,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
return c.json({ voices: [] })
|
||||
}
|
||||
})
|
||||
|
||||
app.post('/voice/telemetry', async (c) => {
|
||||
const requestId = crypto.randomUUID()
|
||||
const json = await c.req.json().catch(() => null)
|
||||
const parsed = telemetryEventSchema.safeParse(json ?? {})
|
||||
if (!parsed.success) {
|
||||
console.warn('[Voice][Telemetry] Invalid payload', { requestId })
|
||||
return c.json({ ok: false, error: 'Invalid telemetry payload' }, 400)
|
||||
}
|
||||
|
||||
const { stage, message, sessionId, voiceId, language, details } = parsed.data
|
||||
console.log('[Voice][Telemetry]', {
|
||||
requestId,
|
||||
stage,
|
||||
message,
|
||||
sessionId,
|
||||
voiceId,
|
||||
language,
|
||||
details
|
||||
})
|
||||
|
||||
return c.json({ ok: true })
|
||||
})
|
||||
|
||||
return app
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user