@pouchy_ai/companion-sdk 0.30.0 → 0.32.1
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 +93 -1
- package/README.md +51 -2
- package/dist/adapter-core.d.ts +58 -0
- package/dist/adapter-core.d.ts.map +1 -0
- package/dist/adapter-core.js +158 -0
- package/dist/adapter-core.js.map +1 -0
- package/dist/call.d.ts +1 -0
- package/dist/call.d.ts.map +1 -0
- package/dist/call.js +1 -0
- package/dist/call.js.map +1 -0
- package/dist/client.d.ts +48 -1
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +56 -3
- package/dist/client.js.map +1 -0
- package/dist/errors.d.ts +1 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +1 -0
- package/dist/errors.js.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -0
- package/dist/protocol.d.ts +1 -0
- package/dist/protocol.d.ts.map +1 -0
- package/dist/protocol.js +1 -0
- package/dist/protocol.js.map +1 -0
- package/dist/scopes.d.ts +1 -0
- package/dist/scopes.d.ts.map +1 -0
- package/dist/scopes.js +1 -0
- package/dist/scopes.js.map +1 -0
- package/dist/sse.d.ts +1 -0
- package/dist/sse.d.ts.map +1 -0
- package/dist/sse.js +1 -0
- package/dist/sse.js.map +1 -0
- package/dist/svelte.d.ts +16 -0
- package/dist/svelte.d.ts.map +1 -0
- package/dist/svelte.js +25 -0
- package/dist/svelte.js.map +1 -0
- package/dist/vue.d.ts +15 -0
- package/dist/vue.d.ts.map +1 -0
- package/dist/vue.js +27 -0
- package/dist/vue.js.map +1 -0
- package/dist/ws-transport.d.ts +1 -0
- package/dist/ws-transport.d.ts.map +1 -0
- package/dist/ws-transport.js +1 -0
- package/dist/ws-transport.js.map +1 -0
- package/package.json +24 -6
- package/src/adapter-core.ts +227 -0
- package/src/call.ts +699 -0
- package/src/client.ts +1960 -0
- package/src/errors.ts +77 -0
- package/src/index.ts +105 -0
- package/src/protocol.ts +316 -0
- package/src/scopes.ts +94 -0
- package/src/sse.ts +41 -0
- package/src/svelte.ts +47 -0
- package/src/vue.ts +46 -0
- package/src/ws-transport.ts +70 -0
package/src/call.ts
ADDED
|
@@ -0,0 +1,699 @@
|
|
|
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
|
+
|
|
14
|
+
import type { CallCredentials } from './client';
|
|
15
|
+
import { CompanionError } from './errors';
|
|
16
|
+
|
|
17
|
+
/** Lets the live voice call invoke the app's declared tools (pause_game,
|
|
18
|
+
* set_feature, …). The companion's voice LLM calls a tool → invoke() runs the
|
|
19
|
+
* app's onToolCall handler and resolves with a result string fed back to the
|
|
20
|
+
* model. Wired by connectCall from the tools declared at createCompanion(). */
|
|
21
|
+
export interface VoiceToolBridge {
|
|
22
|
+
tools: { name: string; description?: string; parameters?: Record<string, unknown> }[];
|
|
23
|
+
invoke: (name: string, argsJson: string) => Promise<string>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Universal "host control" verbs the companion can call on ANY surface (web /
|
|
27
|
+
* game / app / hardware), regardless of provider. They're generic on purpose —
|
|
28
|
+
* the SPECIFIC intent rides in the string params, which YOUR surface interprets
|
|
29
|
+
* — so a single fixed schema set serves every embed AND can be pre-registered on
|
|
30
|
+
* the (otherwise static) ElevenLabs Convai agent. `invoke_action` is the
|
|
31
|
+
* catch-all trigger; the SDK unwraps it to the named action so it reaches your
|
|
32
|
+
* onToolCall just like a declared tool. The others pass through by name. */
|
|
33
|
+
export const HOST_CONTROL_TOOLS: VoiceToolBridge['tools'] = [
|
|
34
|
+
{
|
|
35
|
+
name: 'invoke_action',
|
|
36
|
+
description:
|
|
37
|
+
'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".',
|
|
38
|
+
parameters: {
|
|
39
|
+
type: 'object',
|
|
40
|
+
properties: {
|
|
41
|
+
action: { type: 'string', description: 'the action, e.g. "pause" / "resume" / "next"' },
|
|
42
|
+
params: { type: 'object', description: 'optional extra arguments for the action' }
|
|
43
|
+
},
|
|
44
|
+
required: ['action']
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
name: 'set_feature',
|
|
49
|
+
description:
|
|
50
|
+
'Turn a named feature/capability on or off (e.g. hints, captions, dark mode, a light, notifications, a mode).',
|
|
51
|
+
parameters: {
|
|
52
|
+
type: 'object',
|
|
53
|
+
properties: { feature: { type: 'string' }, on: { type: 'boolean' } },
|
|
54
|
+
required: ['feature', 'on']
|
|
55
|
+
}
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
name: 'set_value',
|
|
59
|
+
description:
|
|
60
|
+
'Set a named control to a value — volume, brightness, difficulty, speed, zoom, temperature, etc.',
|
|
61
|
+
parameters: {
|
|
62
|
+
type: 'object',
|
|
63
|
+
properties: {
|
|
64
|
+
name: { type: 'string' },
|
|
65
|
+
value: { description: 'the value: a number, string, or boolean' }
|
|
66
|
+
},
|
|
67
|
+
required: ['name', 'value']
|
|
68
|
+
}
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
name: 'navigate',
|
|
72
|
+
description: 'Go to a named view / screen / page / section / mode / level in the surface.',
|
|
73
|
+
parameters: {
|
|
74
|
+
type: 'object',
|
|
75
|
+
properties: { to: { type: 'string' }, params: { type: 'object' } },
|
|
76
|
+
required: ['to']
|
|
77
|
+
}
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
name: 'highlight',
|
|
81
|
+
description: "Draw the user's attention to a named element (guidance / onboarding / a tutorial step).",
|
|
82
|
+
parameters: {
|
|
83
|
+
type: 'object',
|
|
84
|
+
properties: { target: { type: 'string' }, note: { type: 'string' } },
|
|
85
|
+
required: ['target']
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
];
|
|
89
|
+
|
|
90
|
+
/** Names of the host-control verbs (for routing). */
|
|
91
|
+
export const HOST_CONTROL_TOOL_NAMES: readonly string[] = HOST_CONTROL_TOOLS.map((t) => t.name);
|
|
92
|
+
|
|
93
|
+
/** Avatar gesture/expression tools the embodied ("形象") companion calls to emote
|
|
94
|
+
* — drive a VRM/3D body's gestures and facial expression. These are offered on
|
|
95
|
+
* EVERY embed (like the host verbs) so the companion can express itself without
|
|
96
|
+
* each third party re-declaring them, and — crucially — so the SDK can register
|
|
97
|
+
* a no-op handler for them on the ElevenLabs Convai path, where the shared agent
|
|
98
|
+
* is pre-configured to call play_gesture / play_expression regardless of what the
|
|
99
|
+
* embed declares. An integrator that actually renders an avatar opts in by
|
|
100
|
+
* declaring (or handling) the same tool name and the SDK routes the call to them;
|
|
101
|
+
* otherwise it answers with a silent no-op success (see invokeVoiceTool / emit),
|
|
102
|
+
* so a pure audio or 2D embed can ignore avatars entirely and the companion's
|
|
103
|
+
* emote calls never error. Enum values mirror the canonical GESTURE_CATEGORIES /
|
|
104
|
+
* EXPRESSION_INTENTS the avatar runtime understands (kept inline so the SDK stays
|
|
105
|
+
* self-contained). */
|
|
106
|
+
export const AVATAR_VISUAL_TOOLS: VoiceToolBridge['tools'] = [
|
|
107
|
+
{
|
|
108
|
+
name: 'play_gesture',
|
|
109
|
+
description:
|
|
110
|
+
'Play a full-body avatar gesture to express yourself physically (greeting, agreement, thinking, celebration, dance, …). No effect on surfaces without an avatar.',
|
|
111
|
+
parameters: {
|
|
112
|
+
type: 'object',
|
|
113
|
+
properties: {
|
|
114
|
+
category: {
|
|
115
|
+
type: 'string',
|
|
116
|
+
description: 'the kind of gesture',
|
|
117
|
+
enum: [
|
|
118
|
+
'greeting',
|
|
119
|
+
'agreement',
|
|
120
|
+
'disagreement',
|
|
121
|
+
'thinking',
|
|
122
|
+
'celebration',
|
|
123
|
+
'affection',
|
|
124
|
+
'shy',
|
|
125
|
+
'surprise',
|
|
126
|
+
'dance',
|
|
127
|
+
'point',
|
|
128
|
+
'pose'
|
|
129
|
+
]
|
|
130
|
+
}
|
|
131
|
+
},
|
|
132
|
+
required: ['category']
|
|
133
|
+
}
|
|
134
|
+
},
|
|
135
|
+
{
|
|
136
|
+
name: 'play_expression',
|
|
137
|
+
description:
|
|
138
|
+
"Set the avatar's facial expression to convey emotion (smile, laugh, wink, sad, surprised, …). No effect on surfaces without an avatar.",
|
|
139
|
+
parameters: {
|
|
140
|
+
type: 'object',
|
|
141
|
+
properties: {
|
|
142
|
+
intent: {
|
|
143
|
+
type: 'string',
|
|
144
|
+
description: 'the facial expression',
|
|
145
|
+
enum: [
|
|
146
|
+
'smile',
|
|
147
|
+
'laugh',
|
|
148
|
+
'wink',
|
|
149
|
+
'shy',
|
|
150
|
+
'pout',
|
|
151
|
+
'sad',
|
|
152
|
+
'angry',
|
|
153
|
+
'surprised',
|
|
154
|
+
'relaxed',
|
|
155
|
+
'smug',
|
|
156
|
+
'neutral'
|
|
157
|
+
]
|
|
158
|
+
}
|
|
159
|
+
},
|
|
160
|
+
required: ['intent']
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
];
|
|
164
|
+
|
|
165
|
+
/** Names of the avatar visual tools (for routing + the no-op fallback). */
|
|
166
|
+
export const AVATAR_VISUAL_TOOL_NAMES: readonly string[] = AVATAR_VISUAL_TOOLS.map((t) => t.name);
|
|
167
|
+
|
|
168
|
+
export interface CompanionCallOptions {
|
|
169
|
+
/** Element to route remote audio to. One is created if omitted. */
|
|
170
|
+
audioElement?: HTMLAudioElement;
|
|
171
|
+
onTranscript?: (e: { role: 'user' | 'assistant'; text: string }) => void;
|
|
172
|
+
onSpeakingChange?: (speaking: boolean) => void;
|
|
173
|
+
onError?: (err: Error) => void;
|
|
174
|
+
/** OPT-IN full duplex: keep the mic OPEN while the companion speaks so the
|
|
175
|
+
* user can interrupt mid-utterance (both providers' native VAD interruption
|
|
176
|
+
* then works). Default false = half-duplex — the mic is muted during agent
|
|
177
|
+
* speech, because on phone SPEAKERS browser echo cancellation does not fully
|
|
178
|
+
* remove the companion's own voice and it barges in on itself (the SDK-voice
|
|
179
|
+
* "说一句就断了" field report). Enable only where AEC actually holds:
|
|
180
|
+
* headphones, desktop, or devices you have verified. */
|
|
181
|
+
bargeIn?: boolean;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export interface CompanionCall {
|
|
185
|
+
/** Hang up. Idempotent. */
|
|
186
|
+
close: () => void;
|
|
187
|
+
/** Inject an out-of-band context line. `speak: true` makes the companion react
|
|
188
|
+
* now; false stages it for the next turn. */
|
|
189
|
+
injectEvent: (text: string, speak?: boolean) => void;
|
|
190
|
+
provider: CallCredentials['provider'];
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const OPENAI_REALTIME_CALLS = 'https://api.openai.com/v1/realtime/calls';
|
|
194
|
+
|
|
195
|
+
/** Open a live call from pre-minted credentials. */
|
|
196
|
+
export async function openCompanionCall(
|
|
197
|
+
creds: CallCredentials,
|
|
198
|
+
opts: CompanionCallOptions = {},
|
|
199
|
+
bridge?: VoiceToolBridge
|
|
200
|
+
): Promise<CompanionCall> {
|
|
201
|
+
if (typeof navigator === 'undefined' || !navigator.mediaDevices) {
|
|
202
|
+
throw new CompanionError(
|
|
203
|
+
'connectCall is browser-only (needs WebRTC + microphone access)',
|
|
204
|
+
0,
|
|
205
|
+
'call_unsupported'
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
return creds.provider === 'openai-realtime'
|
|
209
|
+
? openOpenAICall(creds, opts, bridge)
|
|
210
|
+
: openConvaiCall(creds, opts, bridge);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Connect-step timeouts (mirror the first-party realtime path). Without them a
|
|
214
|
+
// stuck mic (Android WebView holding the device) or a hung SDP exchange hangs
|
|
215
|
+
// the connect promise forever — and if gUM already resolved, with a hot mic.
|
|
216
|
+
const GUM_TIMEOUT_MS = 10_000;
|
|
217
|
+
const SDP_TIMEOUT_MS = 8_000;
|
|
218
|
+
|
|
219
|
+
/** getUserMedia with a timeout. If the timeout wins, a late-resolving mic is
|
|
220
|
+
* stopped so a slow permission grant can't leave the device recording after
|
|
221
|
+
* we've already given up on the call. */
|
|
222
|
+
async function getMicWithTimeout(): Promise<MediaStream> {
|
|
223
|
+
let timedOut = false;
|
|
224
|
+
const micPromise = navigator.mediaDevices.getUserMedia({
|
|
225
|
+
// Explicit processing constraints: echoCancellation is what makes the
|
|
226
|
+
// opt-in barge-in mode viable at all, and it is harmless in half-duplex.
|
|
227
|
+
audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true }
|
|
228
|
+
});
|
|
229
|
+
micPromise
|
|
230
|
+
.then((m) => {
|
|
231
|
+
if (timedOut) m.getTracks().forEach((t) => t.stop());
|
|
232
|
+
})
|
|
233
|
+
.catch(() => {});
|
|
234
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
235
|
+
const timeout = new Promise<never>((_, reject) => {
|
|
236
|
+
timer = setTimeout(() => {
|
|
237
|
+
timedOut = true;
|
|
238
|
+
reject(new CompanionError('microphone request timed out', 0, 'call_connect_failed'));
|
|
239
|
+
}, GUM_TIMEOUT_MS);
|
|
240
|
+
});
|
|
241
|
+
// Clear the timer when the mic wins the race — it held a live 10s closure.
|
|
242
|
+
return Promise.race([micPromise, timeout]).finally(() => {
|
|
243
|
+
if (timer) clearTimeout(timer);
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// ── OpenAI Realtime — self-contained WebRTC ─────────────────────────────────
|
|
248
|
+
async function openOpenAICall(
|
|
249
|
+
creds: Extract<CallCredentials, { provider: 'openai-realtime' }>,
|
|
250
|
+
opts: CompanionCallOptions,
|
|
251
|
+
bridge?: VoiceToolBridge
|
|
252
|
+
): Promise<CompanionCall> {
|
|
253
|
+
// Acquire the mic FIRST (with a timeout): if gUM fails/times out there is no
|
|
254
|
+
// RTCPeerConnection yet to leak. Creating pc before gUM (the old order) leaked
|
|
255
|
+
// a peer connection on every permission denial.
|
|
256
|
+
const mic = await getMicWithTimeout();
|
|
257
|
+
const pc = new RTCPeerConnection();
|
|
258
|
+
const audioEl = opts.audioElement ?? new Audio();
|
|
259
|
+
audioEl.autoplay = true;
|
|
260
|
+
pc.ontrack = (e) => {
|
|
261
|
+
audioEl.srcObject = e.streams[0];
|
|
262
|
+
void audioEl.play().catch(() => {});
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
for (const track of mic.getAudioTracks()) pc.addTrack(track, mic);
|
|
266
|
+
|
|
267
|
+
// Half-duplex mic (mirrors the first-party realtime path, #1524): on a phone
|
|
268
|
+
// SPEAKER browser AEC doesn't fully cancel the companion's own voice, so it
|
|
269
|
+
// leaks into the mic, is transcribed as a USER turn, and barges in on itself —
|
|
270
|
+
// the avatar cuts off mid-sentence after one line (field report: SDK voice on
|
|
271
|
+
// agentpoker "说一句就断了"). Mute the owned mic track WHILE the model speaks
|
|
272
|
+
// (sends silence, keeps the track + transport alive), re-open after a short tail
|
|
273
|
+
// when the turn ends. Tradeoff: no voice barge-in mid-utterance — far better than
|
|
274
|
+
// the companion interrupting itself.
|
|
275
|
+
const MIC_UNMUTE_TAIL_MS = 350;
|
|
276
|
+
let micUnmuteTimer: ReturnType<typeof setTimeout> | null = null;
|
|
277
|
+
const setMicEnabled = (enabled: boolean) => {
|
|
278
|
+
try {
|
|
279
|
+
for (const tr of mic.getAudioTracks()) tr.enabled = enabled;
|
|
280
|
+
} catch {
|
|
281
|
+
/* track gone — ignore */
|
|
282
|
+
}
|
|
283
|
+
};
|
|
284
|
+
const muteMicForSpeech = () => {
|
|
285
|
+
if (micUnmuteTimer) {
|
|
286
|
+
clearTimeout(micUnmuteTimer);
|
|
287
|
+
micUnmuteTimer = null;
|
|
288
|
+
}
|
|
289
|
+
setMicEnabled(false);
|
|
290
|
+
};
|
|
291
|
+
const scheduleMicUnmute = () => {
|
|
292
|
+
if (micUnmuteTimer) clearTimeout(micUnmuteTimer);
|
|
293
|
+
micUnmuteTimer = setTimeout(() => {
|
|
294
|
+
micUnmuteTimer = null;
|
|
295
|
+
if (!closed) setMicEnabled(true);
|
|
296
|
+
}, MIC_UNMUTE_TAIL_MS);
|
|
297
|
+
};
|
|
298
|
+
|
|
299
|
+
const dc = pc.createDataChannel('oai-events');
|
|
300
|
+
// `response.function_call_arguments.done` carries the call_id + args but often
|
|
301
|
+
// not the name — the name arrives earlier on `response.output_item.added`; track it.
|
|
302
|
+
// Cancelled tool calls never reach `arguments.done`, so their entries were
|
|
303
|
+
// never deleted — cap the map (drop-oldest) so a long call can't grow it.
|
|
304
|
+
const callNames = new Map<string, string>();
|
|
305
|
+
const rememberCallName = (id: string, name: string) => {
|
|
306
|
+
callNames.set(id, name);
|
|
307
|
+
if (callNames.size > 64) {
|
|
308
|
+
const first = callNames.keys().next().value;
|
|
309
|
+
if (first !== undefined) callNames.delete(first);
|
|
310
|
+
}
|
|
311
|
+
};
|
|
312
|
+
if (bridge?.tools.length) {
|
|
313
|
+
dc.addEventListener('open', () => {
|
|
314
|
+
try {
|
|
315
|
+
dc.send(
|
|
316
|
+
JSON.stringify({
|
|
317
|
+
type: 'session.update',
|
|
318
|
+
session: {
|
|
319
|
+
type: 'realtime',
|
|
320
|
+
tool_choice: 'auto',
|
|
321
|
+
tools: bridge.tools.map((t) => ({
|
|
322
|
+
type: 'function',
|
|
323
|
+
name: t.name,
|
|
324
|
+
description: t.description ?? '',
|
|
325
|
+
parameters: t.parameters ?? { type: 'object', properties: {} }
|
|
326
|
+
}))
|
|
327
|
+
}
|
|
328
|
+
})
|
|
329
|
+
);
|
|
330
|
+
} catch {
|
|
331
|
+
/* a rejected tools update must not kill the call */
|
|
332
|
+
}
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
dc.onmessage = (ev) => {
|
|
336
|
+
let msg: {
|
|
337
|
+
type?: string;
|
|
338
|
+
transcript?: string;
|
|
339
|
+
delta?: string;
|
|
340
|
+
call_id?: string;
|
|
341
|
+
name?: string;
|
|
342
|
+
arguments?: string;
|
|
343
|
+
item?: { type?: string; name?: string; call_id?: string };
|
|
344
|
+
};
|
|
345
|
+
try {
|
|
346
|
+
msg = JSON.parse(ev.data);
|
|
347
|
+
} catch {
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
// App tool-calling over voice: register name at start, dispatch at done.
|
|
351
|
+
if (
|
|
352
|
+
bridge &&
|
|
353
|
+
msg.type === 'response.output_item.added' &&
|
|
354
|
+
msg.item?.type === 'function_call' &&
|
|
355
|
+
msg.item.call_id &&
|
|
356
|
+
msg.item.name
|
|
357
|
+
) {
|
|
358
|
+
rememberCallName(msg.item.call_id, msg.item.name);
|
|
359
|
+
}
|
|
360
|
+
if (bridge && msg.type === 'response.function_call_arguments.done' && msg.call_id) {
|
|
361
|
+
const callId = msg.call_id;
|
|
362
|
+
const name = (msg.name && msg.name) || callNames.get(callId) || '';
|
|
363
|
+
callNames.delete(callId);
|
|
364
|
+
if (name && bridge.tools.some((t) => t.name === name)) {
|
|
365
|
+
const argsText = typeof msg.arguments === 'string' ? msg.arguments : '{}';
|
|
366
|
+
// invokeVoiceTool never rejects, but openCompanionCall is a public
|
|
367
|
+
// export (bring-your-own bridge) — a throwing consumer bridge must
|
|
368
|
+
// still answer the model, or the voice turn hangs until provider
|
|
369
|
+
// timeout (plus an unhandled rejection).
|
|
370
|
+
void bridge
|
|
371
|
+
.invoke(name, argsText)
|
|
372
|
+
.catch((e) =>
|
|
373
|
+
JSON.stringify({ ok: false, error: e instanceof Error ? e.message : 'tool failed' })
|
|
374
|
+
)
|
|
375
|
+
.then((output) => {
|
|
376
|
+
try {
|
|
377
|
+
dc.send(
|
|
378
|
+
JSON.stringify({
|
|
379
|
+
type: 'conversation.item.create',
|
|
380
|
+
item: { type: 'function_call_output', call_id: callId, output }
|
|
381
|
+
})
|
|
382
|
+
);
|
|
383
|
+
dc.send(JSON.stringify({ type: 'response.create' }));
|
|
384
|
+
} catch {
|
|
385
|
+
/* channel gone */
|
|
386
|
+
}
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
if (msg.type === 'response.audio_transcript.done' && msg.transcript) {
|
|
392
|
+
opts.onTranscript?.({ role: 'assistant', text: msg.transcript });
|
|
393
|
+
} else if (
|
|
394
|
+
msg.type === 'conversation.item.input_audio_transcription.completed' &&
|
|
395
|
+
msg.transcript
|
|
396
|
+
) {
|
|
397
|
+
opts.onTranscript?.({ role: 'user', text: msg.transcript });
|
|
398
|
+
} else if (msg.type === 'output_audio_buffer.started') {
|
|
399
|
+
opts.onSpeakingChange?.(true);
|
|
400
|
+
// Model started speaking → mute the mic so its own audio can't
|
|
401
|
+
// echo-transcribe into a self-barge-in. bargeIn opts out (B wave):
|
|
402
|
+
// the mic stays open and OpenAI's server VAD handles interruption.
|
|
403
|
+
if (opts.bargeIn !== true) muteMicForSpeech();
|
|
404
|
+
} else if (
|
|
405
|
+
msg.type === 'output_audio_buffer.stopped' ||
|
|
406
|
+
msg.type === 'output_audio_buffer.cleared' ||
|
|
407
|
+
msg.type === 'response.done' ||
|
|
408
|
+
msg.type === 'conversation.interrupted'
|
|
409
|
+
) {
|
|
410
|
+
// Turn ended (or was cancelled / interrupted) — clear the speaking state
|
|
411
|
+
// and re-open the mic after a short tail so the last bit of the model's
|
|
412
|
+
// audio doesn't get captured the instant we unmute. Several event names
|
|
413
|
+
// can end a turn; any of them re-arms the mic.
|
|
414
|
+
opts.onSpeakingChange?.(false);
|
|
415
|
+
if (opts.bargeIn !== true) scheduleMicUnmute();
|
|
416
|
+
}
|
|
417
|
+
};
|
|
418
|
+
|
|
419
|
+
const offer = await pc.createOffer();
|
|
420
|
+
await pc.setLocalDescription(offer);
|
|
421
|
+
// Bound the SDP exchange so a hung network can't leave the pc + hot mic open
|
|
422
|
+
// forever. On any failure, close both before throwing.
|
|
423
|
+
const sdpAbort = new AbortController();
|
|
424
|
+
const sdpTimer = setTimeout(() => sdpAbort.abort(), SDP_TIMEOUT_MS);
|
|
425
|
+
const cleanupPartial = () => {
|
|
426
|
+
pc.close();
|
|
427
|
+
mic.getTracks().forEach((t) => t.stop());
|
|
428
|
+
};
|
|
429
|
+
let res: Response;
|
|
430
|
+
try {
|
|
431
|
+
res = await fetch(OPENAI_REALTIME_CALLS, {
|
|
432
|
+
method: 'POST',
|
|
433
|
+
headers: { Authorization: `Bearer ${creds.clientSecret}`, 'Content-Type': 'application/sdp' },
|
|
434
|
+
body: offer.sdp,
|
|
435
|
+
signal: sdpAbort.signal
|
|
436
|
+
});
|
|
437
|
+
} catch {
|
|
438
|
+
clearTimeout(sdpTimer);
|
|
439
|
+
cleanupPartial();
|
|
440
|
+
throw new CompanionError(
|
|
441
|
+
sdpAbort.signal.aborted
|
|
442
|
+
? 'OpenAI Realtime SDP exchange timed out'
|
|
443
|
+
: 'OpenAI Realtime SDP exchange failed',
|
|
444
|
+
0,
|
|
445
|
+
'call_connect_failed'
|
|
446
|
+
);
|
|
447
|
+
}
|
|
448
|
+
clearTimeout(sdpTimer);
|
|
449
|
+
if (!res.ok) {
|
|
450
|
+
cleanupPartial();
|
|
451
|
+
throw new CompanionError(
|
|
452
|
+
`OpenAI Realtime SDP exchange failed (${res.status})`,
|
|
453
|
+
res.status,
|
|
454
|
+
'call_connect_failed'
|
|
455
|
+
);
|
|
456
|
+
}
|
|
457
|
+
await pc.setRemoteDescription({ type: 'answer', sdp: await res.text() });
|
|
458
|
+
|
|
459
|
+
let closed = false;
|
|
460
|
+
// `disconnected` is TRANSIENT per the WebRTC spec — a brief Wi-Fi hiccup or a
|
|
461
|
+
// cell handoff routinely drops to `disconnected` and recovers to `connected`
|
|
462
|
+
// on its own. Tearing down the instant it appears killed calls that would have
|
|
463
|
+
// healed (field report: SDK voice "动不动就掉线"). Give it a grace window to
|
|
464
|
+
// come back; only if it's STILL disconnected when the timer fires do we tear
|
|
465
|
+
// down — so the mic still can't record forever on a genuine silent drop (the
|
|
466
|
+
// original concern). `failed` / `closed` stay immediately fatal.
|
|
467
|
+
const DISCONNECT_GRACE_MS = 6000;
|
|
468
|
+
let disconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
|
469
|
+
const clearDisconnectTimer = () => {
|
|
470
|
+
if (disconnectTimer) {
|
|
471
|
+
clearTimeout(disconnectTimer);
|
|
472
|
+
disconnectTimer = null;
|
|
473
|
+
}
|
|
474
|
+
};
|
|
475
|
+
const teardown = () => {
|
|
476
|
+
if (closed) return;
|
|
477
|
+
closed = true;
|
|
478
|
+
clearDisconnectTimer();
|
|
479
|
+
if (micUnmuteTimer) {
|
|
480
|
+
clearTimeout(micUnmuteTimer);
|
|
481
|
+
micUnmuteTimer = null;
|
|
482
|
+
}
|
|
483
|
+
try {
|
|
484
|
+
dc.close();
|
|
485
|
+
} catch {
|
|
486
|
+
/* ignore */
|
|
487
|
+
}
|
|
488
|
+
pc.close();
|
|
489
|
+
mic.getTracks().forEach((t) => t.stop());
|
|
490
|
+
};
|
|
491
|
+
|
|
492
|
+
pc.onconnectionstatechange = () => {
|
|
493
|
+
const st = pc.connectionState;
|
|
494
|
+
if (st === 'connected') {
|
|
495
|
+
// (Re)connected — cancel any pending disconnect teardown.
|
|
496
|
+
clearDisconnectTimer();
|
|
497
|
+
return;
|
|
498
|
+
}
|
|
499
|
+
if (st === 'failed' || st === 'closed') {
|
|
500
|
+
// Genuinely dead — tear down immediately, BEFORE surfacing the error, so a
|
|
501
|
+
// silent transport drop can't leave the mic recording (mirrors the
|
|
502
|
+
// first-party realtime/connection.ts fail() ordering).
|
|
503
|
+
clearDisconnectTimer();
|
|
504
|
+
teardown();
|
|
505
|
+
opts.onError?.(new Error(`call ${st}`));
|
|
506
|
+
return;
|
|
507
|
+
}
|
|
508
|
+
if (st === 'disconnected') {
|
|
509
|
+
// Hold a grace window; a recovery to `connected` (handled above) cancels
|
|
510
|
+
// the timer. Still disconnected when it fires → tear down.
|
|
511
|
+
if (disconnectTimer || closed) return;
|
|
512
|
+
disconnectTimer = setTimeout(() => {
|
|
513
|
+
disconnectTimer = null;
|
|
514
|
+
if (closed) return;
|
|
515
|
+
if (pc.connectionState === 'disconnected') {
|
|
516
|
+
teardown();
|
|
517
|
+
opts.onError?.(new Error('call disconnected'));
|
|
518
|
+
}
|
|
519
|
+
}, DISCONNECT_GRACE_MS);
|
|
520
|
+
}
|
|
521
|
+
};
|
|
522
|
+
|
|
523
|
+
// Data-channel liveness (mirrors the first-party dataChannel.onclose/onerror).
|
|
524
|
+
// A dc-only death — the channel closes/errors while pc.connectionState still
|
|
525
|
+
// reads 'connected' — otherwise goes undetected and the mic stays hot.
|
|
526
|
+
// teardown flips `closed` before it calls dc.close(), so a normal teardown's
|
|
527
|
+
// own channel close is guarded out here (no double-fire / spurious error).
|
|
528
|
+
dc.onclose = () => {
|
|
529
|
+
if (closed) return;
|
|
530
|
+
teardown();
|
|
531
|
+
opts.onError?.(new Error('call data channel closed'));
|
|
532
|
+
};
|
|
533
|
+
dc.onerror = () => {
|
|
534
|
+
if (closed) return;
|
|
535
|
+
teardown();
|
|
536
|
+
opts.onError?.(new Error('call data channel error'));
|
|
537
|
+
};
|
|
538
|
+
|
|
539
|
+
return {
|
|
540
|
+
provider: 'openai-realtime',
|
|
541
|
+
close: teardown,
|
|
542
|
+
injectEvent: (text, speak = false) => {
|
|
543
|
+
if (closed || dc.readyState !== 'open' || !text.trim()) return;
|
|
544
|
+
dc.send(
|
|
545
|
+
JSON.stringify({
|
|
546
|
+
type: 'conversation.item.create',
|
|
547
|
+
item: { type: 'message', role: 'user', content: [{ type: 'input_text', text }] }
|
|
548
|
+
})
|
|
549
|
+
);
|
|
550
|
+
if (speak) dc.send(JSON.stringify({ type: 'response.create' }));
|
|
551
|
+
}
|
|
552
|
+
};
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
// ── ElevenLabs Convai — via the optional @elevenlabs/client peer dep ─────────
|
|
556
|
+
async function openConvaiCall(
|
|
557
|
+
creds: Extract<CallCredentials, { provider: 'elevenlabs-convai' }>,
|
|
558
|
+
opts: CompanionCallOptions,
|
|
559
|
+
bridge?: VoiceToolBridge
|
|
560
|
+
): Promise<CompanionCall> {
|
|
561
|
+
let mod: typeof import('@elevenlabs/client');
|
|
562
|
+
try {
|
|
563
|
+
mod = await import('@elevenlabs/client');
|
|
564
|
+
} catch {
|
|
565
|
+
throw new CompanionError(
|
|
566
|
+
"elevenlabs-convai call needs the optional peer dependency '@elevenlabs/client' — install it (npm i @elevenlabs/client)",
|
|
567
|
+
0,
|
|
568
|
+
'call_dependency_missing'
|
|
569
|
+
);
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
// App tools → EL client tools. NOTE: ElevenLabs only CALLS a client tool the
|
|
573
|
+
// agent already knows (declared on the agent config); a shared Convai agent
|
|
574
|
+
// can't take arbitrary per-app tools at runtime, so dynamic app tools are
|
|
575
|
+
// reliable on the OpenAI Realtime path. Registering the handlers is harmless
|
|
576
|
+
// and works for any tool the agent does know.
|
|
577
|
+
const clientTools: Record<string, (params: unknown) => Promise<string>> = {};
|
|
578
|
+
if (bridge?.tools.length) {
|
|
579
|
+
for (const t of bridge.tools) {
|
|
580
|
+
clientTools[t.name] = (params: unknown) =>
|
|
581
|
+
bridge.invoke(t.name, JSON.stringify(params ?? {}));
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
let closed = false;
|
|
586
|
+
// Half-duplex mic (mirrors the first-party path, #1524): mute while the agent
|
|
587
|
+
// speaks so its own voice can't echo-transcribe into a self-barge-in (the SDK
|
|
588
|
+
// voice "说一句就断了" report), re-open after a short tail. Convai exposes
|
|
589
|
+
// setMicMuted; guard it (older client builds may lack it).
|
|
590
|
+
const MIC_UNMUTE_TAIL_MS = 350;
|
|
591
|
+
let micUnmuteTimer: ReturnType<typeof setTimeout> | null = null;
|
|
592
|
+
const setConvMicMuted = (muted: boolean) => {
|
|
593
|
+
try {
|
|
594
|
+
(conv as unknown as { setMicMuted?: (m: boolean) => void }).setMicMuted?.(muted);
|
|
595
|
+
} catch {
|
|
596
|
+
/* not supported on this client build — ignore */
|
|
597
|
+
}
|
|
598
|
+
};
|
|
599
|
+
const conv = await mod.Conversation.startSession({
|
|
600
|
+
conversationToken: creds.token,
|
|
601
|
+
connectionType: 'webrtc',
|
|
602
|
+
...(Object.keys(clientTools).length ? { clientTools } : {}),
|
|
603
|
+
overrides: {
|
|
604
|
+
agent: {
|
|
605
|
+
// The server-assembled persona + memory snapshot drives the EL agent.
|
|
606
|
+
prompt: { prompt: creds.instructions },
|
|
607
|
+
...(creds.language ? { language: creds.language as never } : {}),
|
|
608
|
+
...(creds.firstMessage !== undefined ? { firstMessage: creds.firstMessage } : {})
|
|
609
|
+
},
|
|
610
|
+
...(creds.voice ? { tts: { voiceId: creds.voice } } : {})
|
|
611
|
+
},
|
|
612
|
+
onModeChange: ({ mode }: { mode: string }) => {
|
|
613
|
+
if (closed) return;
|
|
614
|
+
const speaking = mode === 'speaking';
|
|
615
|
+
opts.onSpeakingChange?.(speaking);
|
|
616
|
+
// bargeIn opts out of half-duplex (B wave): the mic stays open and
|
|
617
|
+
// ElevenLabs' native interruption handling takes over.
|
|
618
|
+
if (opts.bargeIn === true) return;
|
|
619
|
+
if (speaking) {
|
|
620
|
+
if (micUnmuteTimer) {
|
|
621
|
+
clearTimeout(micUnmuteTimer);
|
|
622
|
+
micUnmuteTimer = null;
|
|
623
|
+
}
|
|
624
|
+
setConvMicMuted(true);
|
|
625
|
+
} else {
|
|
626
|
+
if (micUnmuteTimer) clearTimeout(micUnmuteTimer);
|
|
627
|
+
micUnmuteTimer = setTimeout(() => {
|
|
628
|
+
micUnmuteTimer = null;
|
|
629
|
+
if (!closed) setConvMicMuted(false);
|
|
630
|
+
}, MIC_UNMUTE_TAIL_MS);
|
|
631
|
+
}
|
|
632
|
+
},
|
|
633
|
+
onMessage: ({ message, role }: { message: string; role: string }) => {
|
|
634
|
+
const text = (message ?? '').trim();
|
|
635
|
+
if (!closed && text) {
|
|
636
|
+
opts.onTranscript?.({ role: role === 'agent' ? 'assistant' : 'user', text });
|
|
637
|
+
}
|
|
638
|
+
},
|
|
639
|
+
onError: (message: string) => {
|
|
640
|
+
if (closed) return;
|
|
641
|
+
// End the session (releases the EL SDK's mic + hidden <audio>) before
|
|
642
|
+
// surfacing the error — a silent drop otherwise leaves the mic live
|
|
643
|
+
// because close() depends on the embedder calling it. closed=true first
|
|
644
|
+
// prevents re-entry via the endSession→onDisconnect path.
|
|
645
|
+
closed = true;
|
|
646
|
+
try {
|
|
647
|
+
void conv.endSession().catch(() => {});
|
|
648
|
+
} catch {
|
|
649
|
+
/* session not yet established */
|
|
650
|
+
}
|
|
651
|
+
opts.onError?.(new Error(message));
|
|
652
|
+
},
|
|
653
|
+
onDisconnect: () => {
|
|
654
|
+
if (closed) return;
|
|
655
|
+
closed = true;
|
|
656
|
+
try {
|
|
657
|
+
void conv.endSession().catch(() => {});
|
|
658
|
+
} catch {
|
|
659
|
+
/* session not yet established */
|
|
660
|
+
}
|
|
661
|
+
opts.onError?.(new Error('call disconnected'));
|
|
662
|
+
},
|
|
663
|
+
// Liveness backstop (first-party F3): a transport wedge can flip status to
|
|
664
|
+
// 'disconnected' WITHOUT firing onDisconnect, leaving "connected" with a hot
|
|
665
|
+
// mic forever. Treat a disconnected status the same as onDisconnect.
|
|
666
|
+
onStatusChange: ({ status }: { status: string }) => {
|
|
667
|
+
if (closed || status !== 'disconnected') return;
|
|
668
|
+
closed = true;
|
|
669
|
+
try {
|
|
670
|
+
void conv.endSession().catch(() => {});
|
|
671
|
+
} catch {
|
|
672
|
+
/* session not yet established */
|
|
673
|
+
}
|
|
674
|
+
opts.onError?.(new Error('call disconnected'));
|
|
675
|
+
}
|
|
676
|
+
});
|
|
677
|
+
|
|
678
|
+
return {
|
|
679
|
+
provider: 'elevenlabs-convai',
|
|
680
|
+
close: () => {
|
|
681
|
+
if (closed) return;
|
|
682
|
+
closed = true;
|
|
683
|
+
if (micUnmuteTimer) {
|
|
684
|
+
clearTimeout(micUnmuteTimer);
|
|
685
|
+
micUnmuteTimer = null;
|
|
686
|
+
}
|
|
687
|
+
void conv.endSession().catch(() => {});
|
|
688
|
+
},
|
|
689
|
+
injectEvent: (text, speak = false) => {
|
|
690
|
+
if (closed || !text.trim()) return;
|
|
691
|
+
try {
|
|
692
|
+
if (speak) conv.sendUserMessage(text);
|
|
693
|
+
else conv.sendContextualUpdate(text);
|
|
694
|
+
} catch {
|
|
695
|
+
/* channel not ready */
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
};
|
|
699
|
+
}
|