@pouchy_ai/companion-sdk 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +86 -0
- package/LICENSE +41 -0
- package/README.md +136 -0
- package/dist/call.d.ts +59 -0
- package/dist/call.js +478 -0
- package/dist/client.d.ts +366 -0
- package/dist/client.js +716 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +17 -0
- package/dist/protocol.d.ts +124 -0
- package/dist/protocol.js +35 -0
- package/dist/sse.d.ts +12 -0
- package/dist/sse.js +38 -0
- package/dist/ws-transport.d.ts +21 -0
- package/dist/ws-transport.js +62 -0
- package/package.json +47 -0
package/dist/call.js
ADDED
|
@@ -0,0 +1,478 @@
|
|
|
1
|
+
// SDK-native voice call — opens a live WebRTC voice session directly to the
|
|
2
|
+
// provider using the credentials from startCall(), with NO dependency on Pouchy's
|
|
3
|
+
// first-party connection wrappers (which are coupled to app stores / genui). This
|
|
4
|
+
// is what lets a pure third-party embed do voice.
|
|
5
|
+
//
|
|
6
|
+
// - OpenAI Realtime: fully self-contained WebRTC (no deps).
|
|
7
|
+
// - ElevenLabs Convai: dynamically imports '@elevenlabs/client' (an OPTIONAL peer
|
|
8
|
+
// dependency) only when that provider is selected; if it's not installed the
|
|
9
|
+
// call rejects with a clear message.
|
|
10
|
+
//
|
|
11
|
+
// Browser-only (needs WebRTC + getUserMedia). Returns a handle to hang up and to
|
|
12
|
+
// inject live context mid-call (the bridge the server uses for voiceRelevant).
|
|
13
|
+
/** Universal "host control" verbs the companion can call on ANY surface (web /
|
|
14
|
+
* game / app / hardware), regardless of provider. They're generic on purpose —
|
|
15
|
+
* the SPECIFIC intent rides in the string params, which YOUR surface interprets
|
|
16
|
+
* — so a single fixed schema set serves every embed AND can be pre-registered on
|
|
17
|
+
* the (otherwise static) ElevenLabs Convai agent. `invoke_action` is the
|
|
18
|
+
* catch-all trigger; the SDK unwraps it to the named action so it reaches your
|
|
19
|
+
* onToolCall just like a declared tool. The others pass through by name. */
|
|
20
|
+
export const HOST_CONTROL_TOOLS = [
|
|
21
|
+
{
|
|
22
|
+
name: 'invoke_action',
|
|
23
|
+
description: 'Trigger a named action the current surface supports — e.g. pause, resume, start, stop, restart, next, previous, submit, confirm, cancel, refresh. Put the action name in "action" and any extra args in "params".',
|
|
24
|
+
parameters: {
|
|
25
|
+
type: 'object',
|
|
26
|
+
properties: {
|
|
27
|
+
action: { type: 'string', description: 'the action, e.g. "pause" / "resume" / "next"' },
|
|
28
|
+
params: { type: 'object', description: 'optional extra arguments for the action' }
|
|
29
|
+
},
|
|
30
|
+
required: ['action']
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
name: 'set_feature',
|
|
35
|
+
description: 'Turn a named feature/capability on or off (e.g. hints, captions, dark mode, a light, notifications, a mode).',
|
|
36
|
+
parameters: {
|
|
37
|
+
type: 'object',
|
|
38
|
+
properties: { feature: { type: 'string' }, on: { type: 'boolean' } },
|
|
39
|
+
required: ['feature', 'on']
|
|
40
|
+
}
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
name: 'set_value',
|
|
44
|
+
description: 'Set a named control to a value — volume, brightness, difficulty, speed, zoom, temperature, etc.',
|
|
45
|
+
parameters: {
|
|
46
|
+
type: 'object',
|
|
47
|
+
properties: {
|
|
48
|
+
name: { type: 'string' },
|
|
49
|
+
value: { description: 'the value: a number, string, or boolean' }
|
|
50
|
+
},
|
|
51
|
+
required: ['name', 'value']
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
name: 'navigate',
|
|
56
|
+
description: 'Go to a named view / screen / page / section / mode / level in the surface.',
|
|
57
|
+
parameters: {
|
|
58
|
+
type: 'object',
|
|
59
|
+
properties: { to: { type: 'string' }, params: { type: 'object' } },
|
|
60
|
+
required: ['to']
|
|
61
|
+
}
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
name: 'highlight',
|
|
65
|
+
description: "Draw the user's attention to a named element (guidance / onboarding / a tutorial step).",
|
|
66
|
+
parameters: {
|
|
67
|
+
type: 'object',
|
|
68
|
+
properties: { target: { type: 'string' }, note: { type: 'string' } },
|
|
69
|
+
required: ['target']
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
];
|
|
73
|
+
/** Names of the host-control verbs (for routing). */
|
|
74
|
+
export const HOST_CONTROL_TOOL_NAMES = HOST_CONTROL_TOOLS.map((t) => t.name);
|
|
75
|
+
/** Avatar gesture/expression tools the embodied ("形象") companion calls to emote
|
|
76
|
+
* — drive a VRM/3D body's gestures and facial expression. These are offered on
|
|
77
|
+
* EVERY embed (like the host verbs) so the companion can express itself without
|
|
78
|
+
* each third party re-declaring them, and — crucially — so the SDK can register
|
|
79
|
+
* a no-op handler for them on the ElevenLabs Convai path, where the shared agent
|
|
80
|
+
* is pre-configured to call play_gesture / play_expression regardless of what the
|
|
81
|
+
* embed declares. An integrator that actually renders an avatar opts in by
|
|
82
|
+
* declaring (or handling) the same tool name and the SDK routes the call to them;
|
|
83
|
+
* otherwise it answers with a silent no-op success (see invokeVoiceTool / emit),
|
|
84
|
+
* so a pure audio or 2D embed can ignore avatars entirely and the companion's
|
|
85
|
+
* emote calls never error. Enum values mirror the canonical GESTURE_CATEGORIES /
|
|
86
|
+
* EXPRESSION_INTENTS the avatar runtime understands (kept inline so the SDK stays
|
|
87
|
+
* self-contained). */
|
|
88
|
+
export const AVATAR_VISUAL_TOOLS = [
|
|
89
|
+
{
|
|
90
|
+
name: 'play_gesture',
|
|
91
|
+
description: 'Play a full-body avatar gesture to express yourself physically (greeting, agreement, thinking, celebration, dance, …). No effect on surfaces without an avatar.',
|
|
92
|
+
parameters: {
|
|
93
|
+
type: 'object',
|
|
94
|
+
properties: {
|
|
95
|
+
category: {
|
|
96
|
+
type: 'string',
|
|
97
|
+
description: 'the kind of gesture',
|
|
98
|
+
enum: [
|
|
99
|
+
'greeting',
|
|
100
|
+
'agreement',
|
|
101
|
+
'disagreement',
|
|
102
|
+
'thinking',
|
|
103
|
+
'celebration',
|
|
104
|
+
'affection',
|
|
105
|
+
'shy',
|
|
106
|
+
'surprise',
|
|
107
|
+
'dance',
|
|
108
|
+
'point',
|
|
109
|
+
'pose'
|
|
110
|
+
]
|
|
111
|
+
}
|
|
112
|
+
},
|
|
113
|
+
required: ['category']
|
|
114
|
+
}
|
|
115
|
+
},
|
|
116
|
+
{
|
|
117
|
+
name: 'play_expression',
|
|
118
|
+
description: "Set the avatar's facial expression to convey emotion (smile, laugh, wink, sad, surprised, …). No effect on surfaces without an avatar.",
|
|
119
|
+
parameters: {
|
|
120
|
+
type: 'object',
|
|
121
|
+
properties: {
|
|
122
|
+
intent: {
|
|
123
|
+
type: 'string',
|
|
124
|
+
description: 'the facial expression',
|
|
125
|
+
enum: [
|
|
126
|
+
'smile',
|
|
127
|
+
'laugh',
|
|
128
|
+
'wink',
|
|
129
|
+
'shy',
|
|
130
|
+
'pout',
|
|
131
|
+
'sad',
|
|
132
|
+
'angry',
|
|
133
|
+
'surprised',
|
|
134
|
+
'relaxed',
|
|
135
|
+
'smug',
|
|
136
|
+
'neutral'
|
|
137
|
+
]
|
|
138
|
+
}
|
|
139
|
+
},
|
|
140
|
+
required: ['intent']
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
];
|
|
144
|
+
/** Names of the avatar visual tools (for routing + the no-op fallback). */
|
|
145
|
+
export const AVATAR_VISUAL_TOOL_NAMES = AVATAR_VISUAL_TOOLS.map((t) => t.name);
|
|
146
|
+
const OPENAI_REALTIME_CALLS = 'https://api.openai.com/v1/realtime/calls';
|
|
147
|
+
/** Open a live call from pre-minted credentials. */
|
|
148
|
+
export async function openCompanionCall(creds, opts = {}, bridge) {
|
|
149
|
+
if (typeof navigator === 'undefined' || !navigator.mediaDevices) {
|
|
150
|
+
throw new Error('connectCall is browser-only (needs WebRTC + microphone access)');
|
|
151
|
+
}
|
|
152
|
+
return creds.provider === 'openai-realtime'
|
|
153
|
+
? openOpenAICall(creds, opts, bridge)
|
|
154
|
+
: openConvaiCall(creds, opts, bridge);
|
|
155
|
+
}
|
|
156
|
+
// ── OpenAI Realtime — self-contained WebRTC ─────────────────────────────────
|
|
157
|
+
async function openOpenAICall(creds, opts, bridge) {
|
|
158
|
+
const pc = new RTCPeerConnection();
|
|
159
|
+
const audioEl = opts.audioElement ?? new Audio();
|
|
160
|
+
audioEl.autoplay = true;
|
|
161
|
+
pc.ontrack = (e) => {
|
|
162
|
+
audioEl.srcObject = e.streams[0];
|
|
163
|
+
void audioEl.play().catch(() => { });
|
|
164
|
+
};
|
|
165
|
+
const mic = await navigator.mediaDevices.getUserMedia({ audio: true });
|
|
166
|
+
for (const track of mic.getAudioTracks())
|
|
167
|
+
pc.addTrack(track, mic);
|
|
168
|
+
// Half-duplex mic (mirrors the first-party realtime path, #1524): on a phone
|
|
169
|
+
// SPEAKER browser AEC doesn't fully cancel the companion's own voice, so it
|
|
170
|
+
// leaks into the mic, is transcribed as a USER turn, and barges in on itself —
|
|
171
|
+
// the avatar cuts off mid-sentence after one line (field report: SDK voice on
|
|
172
|
+
// agentpoker "说一句就断了"). Mute the owned mic track WHILE the model speaks
|
|
173
|
+
// (sends silence, keeps the track + transport alive), re-open after a short tail
|
|
174
|
+
// when the turn ends. Tradeoff: no voice barge-in mid-utterance — far better than
|
|
175
|
+
// the companion interrupting itself.
|
|
176
|
+
const MIC_UNMUTE_TAIL_MS = 350;
|
|
177
|
+
let micUnmuteTimer = null;
|
|
178
|
+
const setMicEnabled = (enabled) => {
|
|
179
|
+
try {
|
|
180
|
+
for (const tr of mic.getAudioTracks())
|
|
181
|
+
tr.enabled = enabled;
|
|
182
|
+
}
|
|
183
|
+
catch {
|
|
184
|
+
/* track gone — ignore */
|
|
185
|
+
}
|
|
186
|
+
};
|
|
187
|
+
const muteMicForSpeech = () => {
|
|
188
|
+
if (micUnmuteTimer) {
|
|
189
|
+
clearTimeout(micUnmuteTimer);
|
|
190
|
+
micUnmuteTimer = null;
|
|
191
|
+
}
|
|
192
|
+
setMicEnabled(false);
|
|
193
|
+
};
|
|
194
|
+
const scheduleMicUnmute = () => {
|
|
195
|
+
if (micUnmuteTimer)
|
|
196
|
+
clearTimeout(micUnmuteTimer);
|
|
197
|
+
micUnmuteTimer = setTimeout(() => {
|
|
198
|
+
micUnmuteTimer = null;
|
|
199
|
+
if (!closed)
|
|
200
|
+
setMicEnabled(true);
|
|
201
|
+
}, MIC_UNMUTE_TAIL_MS);
|
|
202
|
+
};
|
|
203
|
+
const dc = pc.createDataChannel('oai-events');
|
|
204
|
+
// `response.function_call_arguments.done` carries the call_id + args but often
|
|
205
|
+
// not the name — the name arrives earlier on `response.output_item.added`; track it.
|
|
206
|
+
const callNames = new Map();
|
|
207
|
+
if (bridge?.tools.length) {
|
|
208
|
+
dc.addEventListener('open', () => {
|
|
209
|
+
try {
|
|
210
|
+
dc.send(JSON.stringify({
|
|
211
|
+
type: 'session.update',
|
|
212
|
+
session: {
|
|
213
|
+
type: 'realtime',
|
|
214
|
+
tool_choice: 'auto',
|
|
215
|
+
tools: bridge.tools.map((t) => ({
|
|
216
|
+
type: 'function',
|
|
217
|
+
name: t.name,
|
|
218
|
+
description: t.description ?? '',
|
|
219
|
+
parameters: t.parameters ?? { type: 'object', properties: {} }
|
|
220
|
+
}))
|
|
221
|
+
}
|
|
222
|
+
}));
|
|
223
|
+
}
|
|
224
|
+
catch {
|
|
225
|
+
/* a rejected tools update must not kill the call */
|
|
226
|
+
}
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
dc.onmessage = (ev) => {
|
|
230
|
+
let msg;
|
|
231
|
+
try {
|
|
232
|
+
msg = JSON.parse(ev.data);
|
|
233
|
+
}
|
|
234
|
+
catch {
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
// App tool-calling over voice: register name at start, dispatch at done.
|
|
238
|
+
if (bridge &&
|
|
239
|
+
msg.type === 'response.output_item.added' &&
|
|
240
|
+
msg.item?.type === 'function_call' &&
|
|
241
|
+
msg.item.call_id &&
|
|
242
|
+
msg.item.name) {
|
|
243
|
+
callNames.set(msg.item.call_id, msg.item.name);
|
|
244
|
+
}
|
|
245
|
+
if (bridge && msg.type === 'response.function_call_arguments.done' && msg.call_id) {
|
|
246
|
+
const callId = msg.call_id;
|
|
247
|
+
const name = (msg.name && msg.name) || callNames.get(callId) || '';
|
|
248
|
+
callNames.delete(callId);
|
|
249
|
+
if (name && bridge.tools.some((t) => t.name === name)) {
|
|
250
|
+
const argsText = typeof msg.arguments === 'string' ? msg.arguments : '{}';
|
|
251
|
+
void bridge.invoke(name, argsText).then((output) => {
|
|
252
|
+
try {
|
|
253
|
+
dc.send(JSON.stringify({
|
|
254
|
+
type: 'conversation.item.create',
|
|
255
|
+
item: { type: 'function_call_output', call_id: callId, output }
|
|
256
|
+
}));
|
|
257
|
+
dc.send(JSON.stringify({ type: 'response.create' }));
|
|
258
|
+
}
|
|
259
|
+
catch {
|
|
260
|
+
/* channel gone */
|
|
261
|
+
}
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
if (msg.type === 'response.audio_transcript.done' && msg.transcript) {
|
|
267
|
+
opts.onTranscript?.({ role: 'assistant', text: msg.transcript });
|
|
268
|
+
}
|
|
269
|
+
else if (msg.type === 'conversation.item.input_audio_transcription.completed' &&
|
|
270
|
+
msg.transcript) {
|
|
271
|
+
opts.onTranscript?.({ role: 'user', text: msg.transcript });
|
|
272
|
+
}
|
|
273
|
+
else if (msg.type === 'output_audio_buffer.started') {
|
|
274
|
+
opts.onSpeakingChange?.(true);
|
|
275
|
+
// Model started speaking → mute the mic so its own audio can't
|
|
276
|
+
// echo-transcribe into a self-barge-in.
|
|
277
|
+
muteMicForSpeech();
|
|
278
|
+
}
|
|
279
|
+
else if (msg.type === 'output_audio_buffer.stopped' ||
|
|
280
|
+
msg.type === 'output_audio_buffer.cleared' ||
|
|
281
|
+
msg.type === 'response.done' ||
|
|
282
|
+
msg.type === 'conversation.interrupted') {
|
|
283
|
+
// Turn ended (or was cancelled / interrupted) — clear the speaking state
|
|
284
|
+
// and re-open the mic after a short tail so the last bit of the model's
|
|
285
|
+
// audio doesn't get captured the instant we unmute. Several event names
|
|
286
|
+
// can end a turn; any of them re-arms the mic.
|
|
287
|
+
opts.onSpeakingChange?.(false);
|
|
288
|
+
scheduleMicUnmute();
|
|
289
|
+
}
|
|
290
|
+
};
|
|
291
|
+
const offer = await pc.createOffer();
|
|
292
|
+
await pc.setLocalDescription(offer);
|
|
293
|
+
const res = await fetch(OPENAI_REALTIME_CALLS, {
|
|
294
|
+
method: 'POST',
|
|
295
|
+
headers: { Authorization: `Bearer ${creds.clientSecret}`, 'Content-Type': 'application/sdp' },
|
|
296
|
+
body: offer.sdp
|
|
297
|
+
});
|
|
298
|
+
if (!res.ok) {
|
|
299
|
+
pc.close();
|
|
300
|
+
mic.getTracks().forEach((t) => t.stop());
|
|
301
|
+
throw new Error(`OpenAI Realtime SDP exchange failed (${res.status})`);
|
|
302
|
+
}
|
|
303
|
+
await pc.setRemoteDescription({ type: 'answer', sdp: await res.text() });
|
|
304
|
+
let closed = false;
|
|
305
|
+
const teardown = () => {
|
|
306
|
+
if (closed)
|
|
307
|
+
return;
|
|
308
|
+
closed = true;
|
|
309
|
+
if (micUnmuteTimer) {
|
|
310
|
+
clearTimeout(micUnmuteTimer);
|
|
311
|
+
micUnmuteTimer = null;
|
|
312
|
+
}
|
|
313
|
+
try {
|
|
314
|
+
dc.close();
|
|
315
|
+
}
|
|
316
|
+
catch {
|
|
317
|
+
/* ignore */
|
|
318
|
+
}
|
|
319
|
+
pc.close();
|
|
320
|
+
mic.getTracks().forEach((t) => t.stop());
|
|
321
|
+
};
|
|
322
|
+
pc.onconnectionstatechange = () => {
|
|
323
|
+
if (['failed', 'disconnected', 'closed'].includes(pc.connectionState)) {
|
|
324
|
+
// Tear down BEFORE surfacing the error. On a silent transport drop
|
|
325
|
+
// (Wi-Fi→cellular, ICE failure, server expiry) the embedder may never
|
|
326
|
+
// call close(), so without this the mic keeps recording indefinitely.
|
|
327
|
+
// Mirrors the first-party realtime/connection.ts fail() ordering.
|
|
328
|
+
teardown();
|
|
329
|
+
opts.onError?.(new Error(`call ${pc.connectionState}`));
|
|
330
|
+
}
|
|
331
|
+
};
|
|
332
|
+
return {
|
|
333
|
+
provider: 'openai-realtime',
|
|
334
|
+
close: teardown,
|
|
335
|
+
injectEvent: (text, speak = false) => {
|
|
336
|
+
if (closed || dc.readyState !== 'open' || !text.trim())
|
|
337
|
+
return;
|
|
338
|
+
dc.send(JSON.stringify({
|
|
339
|
+
type: 'conversation.item.create',
|
|
340
|
+
item: { type: 'message', role: 'user', content: [{ type: 'input_text', text }] }
|
|
341
|
+
}));
|
|
342
|
+
if (speak)
|
|
343
|
+
dc.send(JSON.stringify({ type: 'response.create' }));
|
|
344
|
+
}
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
// ── ElevenLabs Convai — via the optional @elevenlabs/client peer dep ─────────
|
|
348
|
+
async function openConvaiCall(creds, opts, bridge) {
|
|
349
|
+
let mod;
|
|
350
|
+
try {
|
|
351
|
+
mod = await import('@elevenlabs/client');
|
|
352
|
+
}
|
|
353
|
+
catch {
|
|
354
|
+
throw new Error("elevenlabs-convai call needs the optional peer dependency '@elevenlabs/client' — install it (npm i @elevenlabs/client)");
|
|
355
|
+
}
|
|
356
|
+
// App tools → EL client tools. NOTE: ElevenLabs only CALLS a client tool the
|
|
357
|
+
// agent already knows (declared on the agent config); a shared Convai agent
|
|
358
|
+
// can't take arbitrary per-app tools at runtime, so dynamic app tools are
|
|
359
|
+
// reliable on the OpenAI Realtime path. Registering the handlers is harmless
|
|
360
|
+
// and works for any tool the agent does know.
|
|
361
|
+
const clientTools = {};
|
|
362
|
+
if (bridge?.tools.length) {
|
|
363
|
+
for (const t of bridge.tools) {
|
|
364
|
+
clientTools[t.name] = (params) => bridge.invoke(t.name, JSON.stringify(params ?? {}));
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
let closed = false;
|
|
368
|
+
// Half-duplex mic (mirrors the first-party path, #1524): mute while the agent
|
|
369
|
+
// speaks so its own voice can't echo-transcribe into a self-barge-in (the SDK
|
|
370
|
+
// voice "说一句就断了" report), re-open after a short tail. Convai exposes
|
|
371
|
+
// setMicMuted; guard it (older client builds may lack it).
|
|
372
|
+
const MIC_UNMUTE_TAIL_MS = 350;
|
|
373
|
+
let micUnmuteTimer = null;
|
|
374
|
+
const setConvMicMuted = (muted) => {
|
|
375
|
+
try {
|
|
376
|
+
conv.setMicMuted?.(muted);
|
|
377
|
+
}
|
|
378
|
+
catch {
|
|
379
|
+
/* not supported on this client build — ignore */
|
|
380
|
+
}
|
|
381
|
+
};
|
|
382
|
+
const conv = await mod.Conversation.startSession({
|
|
383
|
+
conversationToken: creds.token,
|
|
384
|
+
connectionType: 'webrtc',
|
|
385
|
+
...(Object.keys(clientTools).length ? { clientTools } : {}),
|
|
386
|
+
overrides: {
|
|
387
|
+
agent: {
|
|
388
|
+
// The server-assembled persona + memory snapshot drives the EL agent.
|
|
389
|
+
prompt: { prompt: creds.instructions },
|
|
390
|
+
...(creds.language ? { language: creds.language } : {}),
|
|
391
|
+
...(creds.firstMessage !== undefined ? { firstMessage: creds.firstMessage } : {})
|
|
392
|
+
},
|
|
393
|
+
...(creds.voice ? { tts: { voiceId: creds.voice } } : {})
|
|
394
|
+
},
|
|
395
|
+
onModeChange: ({ mode }) => {
|
|
396
|
+
if (closed)
|
|
397
|
+
return;
|
|
398
|
+
const speaking = mode === 'speaking';
|
|
399
|
+
opts.onSpeakingChange?.(speaking);
|
|
400
|
+
if (speaking) {
|
|
401
|
+
if (micUnmuteTimer) {
|
|
402
|
+
clearTimeout(micUnmuteTimer);
|
|
403
|
+
micUnmuteTimer = null;
|
|
404
|
+
}
|
|
405
|
+
setConvMicMuted(true);
|
|
406
|
+
}
|
|
407
|
+
else {
|
|
408
|
+
if (micUnmuteTimer)
|
|
409
|
+
clearTimeout(micUnmuteTimer);
|
|
410
|
+
micUnmuteTimer = setTimeout(() => {
|
|
411
|
+
micUnmuteTimer = null;
|
|
412
|
+
if (!closed)
|
|
413
|
+
setConvMicMuted(false);
|
|
414
|
+
}, MIC_UNMUTE_TAIL_MS);
|
|
415
|
+
}
|
|
416
|
+
},
|
|
417
|
+
onMessage: ({ message, role }) => {
|
|
418
|
+
const text = (message ?? '').trim();
|
|
419
|
+
if (!closed && text) {
|
|
420
|
+
opts.onTranscript?.({ role: role === 'agent' ? 'assistant' : 'user', text });
|
|
421
|
+
}
|
|
422
|
+
},
|
|
423
|
+
onError: (message) => {
|
|
424
|
+
if (closed)
|
|
425
|
+
return;
|
|
426
|
+
// End the session (releases the EL SDK's mic + hidden <audio>) before
|
|
427
|
+
// surfacing the error — a silent drop otherwise leaves the mic live
|
|
428
|
+
// because close() depends on the embedder calling it. closed=true first
|
|
429
|
+
// prevents re-entry via the endSession→onDisconnect path.
|
|
430
|
+
closed = true;
|
|
431
|
+
try {
|
|
432
|
+
void conv.endSession().catch(() => { });
|
|
433
|
+
}
|
|
434
|
+
catch {
|
|
435
|
+
/* session not yet established */
|
|
436
|
+
}
|
|
437
|
+
opts.onError?.(new Error(message));
|
|
438
|
+
},
|
|
439
|
+
onDisconnect: () => {
|
|
440
|
+
if (closed)
|
|
441
|
+
return;
|
|
442
|
+
closed = true;
|
|
443
|
+
try {
|
|
444
|
+
void conv.endSession().catch(() => { });
|
|
445
|
+
}
|
|
446
|
+
catch {
|
|
447
|
+
/* session not yet established */
|
|
448
|
+
}
|
|
449
|
+
opts.onError?.(new Error('call disconnected'));
|
|
450
|
+
}
|
|
451
|
+
});
|
|
452
|
+
return {
|
|
453
|
+
provider: 'elevenlabs-convai',
|
|
454
|
+
close: () => {
|
|
455
|
+
if (closed)
|
|
456
|
+
return;
|
|
457
|
+
closed = true;
|
|
458
|
+
if (micUnmuteTimer) {
|
|
459
|
+
clearTimeout(micUnmuteTimer);
|
|
460
|
+
micUnmuteTimer = null;
|
|
461
|
+
}
|
|
462
|
+
void conv.endSession().catch(() => { });
|
|
463
|
+
},
|
|
464
|
+
injectEvent: (text, speak = false) => {
|
|
465
|
+
if (closed || !text.trim())
|
|
466
|
+
return;
|
|
467
|
+
try {
|
|
468
|
+
if (speak)
|
|
469
|
+
conv.sendUserMessage(text);
|
|
470
|
+
else
|
|
471
|
+
conv.sendContextualUpdate(text);
|
|
472
|
+
}
|
|
473
|
+
catch {
|
|
474
|
+
/* channel not ready */
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
};
|
|
478
|
+
}
|