Skip to content

Commit 860c045

Browse files
amethystaniclaude
andcommitted
feat: ElevenLabs voice agent KB sync, system prompt injection, and EN/DE auto-language
- New Netlify function /api/elevenlabs-kb-sync: syncs all document chunks to ElevenLabs agent KB (full replace strategy) - New utility buildElevenLabsSystemPrompt: builds voice-optimized system prompt from KnowledgeBaseConfig (persona, categories, priority rules, custom instructions, guidelines + EN/DE auto-detect) - ragService: fires syncKbToElevenLabs after processDocument() and deleteDocument() succeed (non-fatal, fire-and-forget) - UserPhoneInterface: injects KB system prompt and greeting as ElevenLabs session overrides on call start Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2 parents a4aaa63 + 7d18e4e commit 860c045

File tree

3 files changed

+267
-26
lines changed

3 files changed

+267
-26
lines changed
Lines changed: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,228 @@
1+
import type { Context, Config } from "@netlify/functions";
2+
3+
const EL_BASE = "https://api.elevenlabs.io/v1";
4+
5+
const CORS_HEADERS = {
6+
"Access-Control-Allow-Origin": "*",
7+
"Access-Control-Allow-Methods": "POST, OPTIONS",
8+
"Access-Control-Allow-Headers": "Content-Type",
9+
"Content-Type": "application/json",
10+
};
11+
12+
export default async (req: Request, _context: Context) => {
13+
// Handle CORS preflight
14+
if (req.method === "OPTIONS") {
15+
return new Response(null, { status: 204, headers: CORS_HEADERS });
16+
}
17+
18+
if (req.method !== "POST") {
19+
return new Response(JSON.stringify({ error: "Method not allowed" }), {
20+
status: 405,
21+
headers: CORS_HEADERS,
22+
});
23+
}
24+
25+
// Read env vars
26+
const apiKey =
27+
Netlify.env.get("ELEVENLABS_API_KEY") ||
28+
Netlify.env.get("VITE_ELEVENLABS_API_KEY") ||
29+
process.env.ELEVENLABS_API_KEY ||
30+
process.env.VITE_ELEVENLABS_API_KEY;
31+
32+
if (!apiKey) {
33+
return new Response(
34+
JSON.stringify({ error: "ELEVENLABS_API_KEY not configured" }),
35+
{ status: 500, headers: CORS_HEADERS }
36+
);
37+
}
38+
39+
const agentId =
40+
Netlify.env.get("ELEVENLABS_AGENT_ID") ||
41+
Netlify.env.get("VITE_ELEVENLABS_AGENT_ID") ||
42+
process.env.ELEVENLABS_AGENT_ID ||
43+
process.env.VITE_ELEVENLABS_AGENT_ID;
44+
45+
if (!agentId) {
46+
return new Response(
47+
JSON.stringify({
48+
error:
49+
"ELEVENLABS_AGENT_ID not configured. Set it as an env var.",
50+
}),
51+
{ status: 500, headers: CORS_HEADERS }
52+
);
53+
}
54+
55+
const supabaseUrl =
56+
Netlify.env.get("VITE_SUPABASE_URL") ||
57+
Netlify.env.get("SUPABASE_URL") ||
58+
process.env.VITE_SUPABASE_URL ||
59+
process.env.SUPABASE_URL;
60+
61+
if (!supabaseUrl) {
62+
return new Response(
63+
JSON.stringify({ error: "SUPABASE_URL not configured" }),
64+
{ status: 500, headers: CORS_HEADERS }
65+
);
66+
}
67+
68+
// Never use VITE_ prefix for service role key — Vite would inline it into the client bundle
69+
const supabaseServiceKey =
70+
Netlify.env.get("SUPABASE_SERVICE_ROLE_KEY") ||
71+
process.env.SUPABASE_SERVICE_ROLE_KEY;
72+
73+
if (!supabaseServiceKey) {
74+
return new Response(
75+
JSON.stringify({ error: "SUPABASE_SERVICE_ROLE_KEY not configured" }),
76+
{ status: 500, headers: CORS_HEADERS }
77+
);
78+
}
79+
80+
// Parse request body
81+
let kbId: string | undefined;
82+
try {
83+
const body = await req.json();
84+
kbId = body.kbId;
85+
} catch {
86+
return new Response(
87+
JSON.stringify({ error: "Invalid or missing JSON body" }),
88+
{ status: 400, headers: CORS_HEADERS }
89+
);
90+
}
91+
92+
if (!kbId) {
93+
return new Response(
94+
JSON.stringify({ error: "Missing required field: kbId" }),
95+
{ status: 400, headers: CORS_HEADERS }
96+
);
97+
}
98+
99+
try {
100+
// Step 1: Fetch all chunks for this kbId from Supabase
101+
const supabaseRes = await fetch(
102+
`${supabaseUrl}/rest/v1/kb_documents?kb_id=eq.${encodeURIComponent(kbId)}&select=content,chunk_index&order=chunk_index.asc`,
103+
{
104+
headers: {
105+
apikey: supabaseServiceKey,
106+
Authorization: `Bearer ${supabaseServiceKey}`,
107+
"Content-Type": "application/json",
108+
},
109+
}
110+
);
111+
112+
if (!supabaseRes.ok) {
113+
const errorText = await supabaseRes.text();
114+
throw new Error(
115+
`Supabase fetch failed (HTTP ${supabaseRes.status}): ${errorText}`
116+
);
117+
}
118+
119+
const chunks: { content: string; chunk_index: number }[] =
120+
await supabaseRes.json();
121+
122+
// Step 2: Fetch current agent config to find existing KB item IDs
123+
const agentRes = await fetch(`${EL_BASE}/convai/agents/${agentId}`, {
124+
headers: { "xi-api-key": apiKey },
125+
});
126+
127+
if (!agentRes.ok) {
128+
const errorText = await agentRes.text();
129+
throw new Error(
130+
`Failed to fetch ElevenLabs agent config (HTTP ${agentRes.status}): ${errorText}`
131+
);
132+
}
133+
134+
const agentData = await agentRes.json();
135+
const existingKbItems: { id: string }[] =
136+
agentData?.conversation_config?.agent?.prompt?.knowledge_base ?? [];
137+
138+
// Step 3: Delete all existing KB items from the agent
139+
for (const item of existingKbItems) {
140+
const delRes = await fetch(
141+
`${EL_BASE}/convai/agents/${agentId}/knowledge-base/${item.id}`,
142+
{
143+
method: "DELETE",
144+
headers: { "xi-api-key": apiKey },
145+
}
146+
);
147+
if (!delRes.ok) {
148+
const errorText = await delRes.text();
149+
throw new Error(
150+
`Failed to delete KB item ${item.id} (HTTP ${delRes.status}): ${errorText}`
151+
);
152+
}
153+
}
154+
155+
// Step 4: If no chunks, return early
156+
if (chunks.length === 0) {
157+
return new Response(
158+
JSON.stringify({ ok: true, chunks_synced: 0 }),
159+
{ status: 200, headers: CORS_HEADERS }
160+
);
161+
}
162+
163+
// Step 5: Combine all chunks into one text blob
164+
const combinedText = chunks.map((c) => c.content).join("\n\n");
165+
166+
// Step 6: Upload combined text as a new KB document to ElevenLabs
167+
const textBlob = new Blob([combinedText], { type: "text/plain" });
168+
const formData = new FormData();
169+
formData.append("file", textBlob, `kb-${kbId}.txt`);
170+
formData.append("name", `kb-${kbId}`);
171+
172+
const uploadRes = await fetch(`${EL_BASE}/convai/knowledge-base`, {
173+
method: "POST",
174+
headers: { "xi-api-key": apiKey },
175+
body: formData,
176+
});
177+
178+
if (!uploadRes.ok) {
179+
const errorText = await uploadRes.text();
180+
throw new Error(
181+
`Failed to upload KB document to ElevenLabs (HTTP ${uploadRes.status}): ${errorText}`
182+
);
183+
}
184+
185+
const uploadData = await uploadRes.json();
186+
const kbDocId: string = uploadData.id;
187+
188+
// Step 7: Attach the new KB doc to the agent
189+
const patchRes = await fetch(`${EL_BASE}/convai/agents/${agentId}`, {
190+
method: "PATCH",
191+
headers: {
192+
"xi-api-key": apiKey,
193+
"Content-Type": "application/json",
194+
},
195+
body: JSON.stringify({
196+
conversation_config: {
197+
agent: {
198+
prompt: {
199+
knowledge_base: [{ type: "file", id: kbDocId }],
200+
},
201+
},
202+
},
203+
}),
204+
});
205+
206+
if (!patchRes.ok) {
207+
const errorText = await patchRes.text();
208+
throw new Error(
209+
`Failed to attach KB document to ElevenLabs agent (HTTP ${patchRes.status}): ${errorText}`
210+
);
211+
}
212+
213+
return new Response(
214+
JSON.stringify({ ok: true, chunks_synced: chunks.length, kb_doc_id: kbDocId }),
215+
{ status: 200, headers: CORS_HEADERS }
216+
);
217+
} catch (error) {
218+
console.error("elevenlabs-kb-sync error:", error);
219+
return new Response(
220+
JSON.stringify({ error: "Internal server error", details: String(error) }),
221+
{ status: 500, headers: CORS_HEADERS }
222+
);
223+
}
224+
};
225+
226+
export const config: Config = {
227+
path: "/api/elevenlabs-kb-sync",
228+
};

src/components/DemoCall/UserPhoneInterface.tsx

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { useConversation } from '@elevenlabs/react';
55
import { cn } from '../../utils/cn';
66
import { useDemoCall } from '../../contexts/DemoCallContext';
77
import { fetchElevenLabsSignedUrl } from '../../services/elevenlabsSignedUrl';
8+
import { buildElevenLabsSystemPrompt } from '../../services/elevenLabsSystemPrompt';
89

910
interface UserPhoneInterfaceProps {
1011
isDark?: boolean;
@@ -27,6 +28,7 @@ export default function UserPhoneInterface({
2728
startCall,
2829
endCall,
2930
addMessage,
31+
knowledgeBase,
3032
} = useDemoCall();
3133

3234
const [callDuration, setCallDuration] = useState(0);
@@ -130,17 +132,28 @@ export default function UserPhoneInterface({
130132
// Get signed URL from our server (keeps API key safe)
131133
const signedUrl = await getSignedUrl();
132134

133-
// Start ElevenLabs Conversational AI session
134-
// This single WebSocket connection handles STT + LLM + TTS
135+
// Build the system prompt from the current knowledge base config.
136+
// This mirrors what Groq gets, keeping voice and text fully in sync.
137+
const systemPrompt = buildElevenLabsSystemPrompt(knowledgeBase);
138+
135139
await conversation.startSession({
136140
signedUrl,
141+
overrides: {
142+
agent: {
143+
prompt: {
144+
prompt: systemPrompt,
145+
},
146+
// Use the KB greeting as the agent's opening line
147+
first_message: knowledgeBase.greeting || undefined,
148+
},
149+
},
137150
});
138151
} catch (error) {
139152
console.error('📞 Failed to start ElevenLabs session:', error);
140153
setConnectionError(error instanceof Error ? error.message : 'Failed to connect');
141154
setAgentStatus('idle');
142155
}
143-
}, [startCall, getSignedUrl, conversation]);
156+
}, [startCall, getSignedUrl, conversation, knowledgeBase]);
144157

145158
const handleEndCall = useCallback(async () => {
146159
console.log('🔴 End call button pressed');

src/services/ragService.ts

Lines changed: 23 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -476,10 +476,10 @@ class RAGService {
476476

477477
if (error) throw error;
478478

479-
// Sync updated KB to ElevenLabs (fire-and-forget, non-blocking)
480-
if (doc?.kb_id) {
481-
this.syncKbToElevenLabs((doc as any).kb_id).catch(() => {/* already logged inside */});
482-
}
479+
// Sync updated KB to ElevenLabs (fire-and-forget, non-blocking)
480+
if ((doc as any)?.kb_id) {
481+
this.syncKbToElevenLabs((doc as any).kb_id).catch(() => {/* already logged inside */});
482+
}
483483

484484
return true;
485485
} catch (error) {
@@ -488,26 +488,26 @@ class RAGService {
488488
}
489489
}
490490

491-
// ─── Sync documents to ElevenLabs agent KB ───────────────────────
492-
async syncKbToElevenLabs(kbId: string): Promise<void> {
493-
try {
494-
const res = await fetch('/api/elevenlabs-kb-sync', {
495-
method: 'POST',
496-
headers: { 'Content-Type': 'application/json' },
497-
body: JSON.stringify({ kbId }),
498-
});
499-
if (!res.ok) {
500-
const err = await res.text();
501-
console.warn('[ragService] ElevenLabs KB sync failed (non-fatal):', err);
502-
} else {
503-
const data = await res.json();
504-
console.log(`[ragService] ElevenLabs KB synced: ${data.chunks_synced ?? 0} chunks`);
505-
}
506-
} catch (e) {
507-
// Non-fatal — voice KB sync failure should never break the document upload UX
508-
console.warn('[ragService] ElevenLabs KB sync error (non-fatal):', e);
491+
// ─── Sync documents to ElevenLabs agent KB ───────────────────────
492+
async syncKbToElevenLabs(kbId: string): Promise<void> {
493+
try {
494+
const res = await fetch('/api/elevenlabs-kb-sync', {
495+
method: 'POST',
496+
headers: { 'Content-Type': 'application/json' },
497+
body: JSON.stringify({ kbId }),
498+
});
499+
if (!res.ok) {
500+
const err = await res.text();
501+
console.warn('[ragService] ElevenLabs KB sync failed (non-fatal):', err);
502+
} else {
503+
const data = await res.json();
504+
console.log(`[ragService] ElevenLabs KB synced: ${data.chunks_synced ?? 0} chunks`);
505+
}
506+
} catch (e) {
507+
// Non-fatal — voice KB sync failure should never break the document upload UX
508+
console.warn('[ragService] ElevenLabs KB sync error (non-fatal):', e);
509+
}
509510
}
510-
}
511511
}
512512

513513
export const ragService = new RAGService();

0 commit comments

Comments
 (0)