@voxevolv/client 0.1.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 +12 -0
- package/LICENSE +21 -0
- package/README.md +130 -0
- package/dist/browser/index.js +40 -0
- package/dist/browser/index.js.map +7 -0
- package/dist/browser/index.js.sha384 +1 -0
- package/dist/browser/manifest.json +4 -0
- package/dist/generated/protocol-codecs.d.ts +19 -0
- package/dist/generated/protocol-codecs.d.ts.map +1 -0
- package/dist/generated/protocol-codecs.js +819 -0
- package/dist/generated/protocol-codecs.js.map +1 -0
- package/dist/index.d.ts +160 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +796 -0
- package/dist/index.js.map +1 -0
- package/dist/protocol.d.ts +110 -0
- package/dist/protocol.d.ts.map +1 -0
- package/dist/protocol.js +101 -0
- package/dist/protocol.js.map +1 -0
- package/package.json +55 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,796 @@
|
|
|
1
|
+
import { ConnectionState as LiveKitConnectionState, DataPacket_Kind, Room, RoomEvent, RpcError, Track, } from "livekit-client";
|
|
2
|
+
import { createClientMessage, CommandError, EventCursor, isCommandAckPayload, isCommandErrorPayload, isContextRejectedPayload, isContextUpdatePayload, isContextUpdatedPayload, isRealtimeEvent, } from "./protocol.js";
|
|
3
|
+
export { CommandError, EventCursor, createClientMessage, isCommandAckPayload, isCommandErrorPayload, isContextRejectedPayload, isContextUpdatePayload, isContextUpdatedPayload, isRealtimeEvent, } from "./protocol.js";
|
|
4
|
+
export { decodeAgentSnapshotPayload, decodeClientHelloPayload, decodeClientMessage, decodeCommandAckPayload, decodeCommandErrorPayload, decodeContextRejectedPayload, decodeContextUpdatePayload, decodeContextUpdatedPayload, decodeRealtimeEvent, } from "./generated/protocol-codecs.js";
|
|
5
|
+
export const SDK_VERSION = "0.1.0";
|
|
6
|
+
export class RealtimeSessionHttpError extends Error {
|
|
7
|
+
status;
|
|
8
|
+
code;
|
|
9
|
+
requestId;
|
|
10
|
+
details;
|
|
11
|
+
constructor(message, options) {
|
|
12
|
+
super(message);
|
|
13
|
+
this.name = "RealtimeSessionHttpError";
|
|
14
|
+
this.status = options.status;
|
|
15
|
+
this.code = options.code ?? null;
|
|
16
|
+
this.requestId = options.requestId ?? null;
|
|
17
|
+
this.details = options.details ?? [];
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
export class RealtimeSession {
|
|
21
|
+
id;
|
|
22
|
+
room;
|
|
23
|
+
accessToken;
|
|
24
|
+
credentials;
|
|
25
|
+
stateValue = "idle";
|
|
26
|
+
listeners = new Set();
|
|
27
|
+
stateListeners = new Set();
|
|
28
|
+
gapListeners = new Set();
|
|
29
|
+
remoteAudioTrackListeners = new Set();
|
|
30
|
+
remoteAudioTrackUnsubscribedListeners = new Set();
|
|
31
|
+
readyListeners = new Set();
|
|
32
|
+
userTranscriptListeners = new Set();
|
|
33
|
+
agentTranscriptListeners = new Set();
|
|
34
|
+
errorListeners = new Set();
|
|
35
|
+
endedListeners = new Set();
|
|
36
|
+
toolCallListeners = new Set();
|
|
37
|
+
audioPlaybackBlockedListeners = new Set();
|
|
38
|
+
remoteAudioStreams = new WeakMap();
|
|
39
|
+
cursor = new EventCursor();
|
|
40
|
+
connectionId = `conn_${Math.random().toString(36).slice(2)}_${Date.now().toString(36)}`;
|
|
41
|
+
clientSeq = 0;
|
|
42
|
+
lastHelloMessageId = null;
|
|
43
|
+
agentReady = false;
|
|
44
|
+
readyResolve = null;
|
|
45
|
+
replayPromise = null;
|
|
46
|
+
pendingCommands = new Map();
|
|
47
|
+
toolHandlers = new Map();
|
|
48
|
+
apiBase = "/v1";
|
|
49
|
+
useManagedMicrophone = true;
|
|
50
|
+
applicationAudioTrack = null;
|
|
51
|
+
connectDefaults;
|
|
52
|
+
fetcher;
|
|
53
|
+
endEndpoint;
|
|
54
|
+
refreshCredentialsCallback;
|
|
55
|
+
constructor(credentials, options = {}) {
|
|
56
|
+
validateSessionCredentials(credentials);
|
|
57
|
+
this.id = credentials.id;
|
|
58
|
+
this.accessToken = credentials.access_token;
|
|
59
|
+
this.credentials = credentials.connection;
|
|
60
|
+
this.connectDefaults = options.connect?.apiBase === undefined
|
|
61
|
+
? (options.connect ?? {})
|
|
62
|
+
: { ...options.connect, apiBase: normalizeApiBase(options.connect.apiBase) };
|
|
63
|
+
this.apiBase = this.connectDefaults.apiBase ?? this.apiBase;
|
|
64
|
+
this.fetcher = options.fetch ?? globalThis.fetch.bind(globalThis);
|
|
65
|
+
this.endEndpoint = options.endEndpoint ?? null;
|
|
66
|
+
this.refreshCredentialsCallback = options.refreshCredentials ?? null;
|
|
67
|
+
this.room = new Room({ adaptiveStream: true, dynacast: true });
|
|
68
|
+
for (const [name, handler] of Object.entries(options.tools ?? {})) {
|
|
69
|
+
this.registerTool(name, handler);
|
|
70
|
+
}
|
|
71
|
+
this.room.registerRpcMethod("voxevolv.tool.invoke", async (invocation) => {
|
|
72
|
+
let payload;
|
|
73
|
+
try {
|
|
74
|
+
payload = JSON.parse(invocation.payload);
|
|
75
|
+
}
|
|
76
|
+
catch (error) {
|
|
77
|
+
throw new RpcError(1500, "Tool invocation payload was not valid JSON", undefined, { cause: error });
|
|
78
|
+
}
|
|
79
|
+
if (typeof payload.name !== "string") {
|
|
80
|
+
throw new RpcError(1500, "Tool invocation did not include a valid name");
|
|
81
|
+
}
|
|
82
|
+
const handler = this.toolHandlers.get(payload.name);
|
|
83
|
+
if (!handler)
|
|
84
|
+
throw new RpcError(1500, `No browser handler is registered for ${payload.name}`);
|
|
85
|
+
if (!payload.arguments || typeof payload.arguments !== "object" || Array.isArray(payload.arguments)) {
|
|
86
|
+
throw new RpcError(1500, "Tool invocation arguments must be an object");
|
|
87
|
+
}
|
|
88
|
+
const callId = typeof payload.call_id === "string" ? payload.call_id : invocation.requestId;
|
|
89
|
+
const arguments_ = payload.arguments;
|
|
90
|
+
this.notifyListeners(this.toolCallListeners, {
|
|
91
|
+
name: payload.name,
|
|
92
|
+
callId,
|
|
93
|
+
arguments: arguments_,
|
|
94
|
+
});
|
|
95
|
+
try {
|
|
96
|
+
const result = await handler(arguments_, { callId });
|
|
97
|
+
return JSON.stringify(result ?? null);
|
|
98
|
+
}
|
|
99
|
+
catch (error) {
|
|
100
|
+
const message = error instanceof Error ? error.message : "Browser tool handler failed";
|
|
101
|
+
throw new RpcError(1500, message, undefined, { cause: error });
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
this.bindRoomEvents();
|
|
105
|
+
}
|
|
106
|
+
static async create(options) {
|
|
107
|
+
if ("connect" in options) {
|
|
108
|
+
throw new TypeError("RealtimeSession.create() accepts apiBase and media options at the top level, not in connect");
|
|
109
|
+
}
|
|
110
|
+
const { endpoint, request, microphone, audioTrack, readyTimeoutMs, apiBase, ...sessionOptions } = options;
|
|
111
|
+
if (!endpoint)
|
|
112
|
+
throw new TypeError("endpoint is required");
|
|
113
|
+
if (!apiBase) {
|
|
114
|
+
throw new TypeError("apiBase is required so rejoin, replay, and end use the intended VoxEvolv API or application proxy");
|
|
115
|
+
}
|
|
116
|
+
const normalizedApiBase = normalizeApiBase(apiBase);
|
|
117
|
+
const fetcher = sessionOptions.fetch ?? globalThis.fetch.bind(globalThis);
|
|
118
|
+
const response = await fetcher(endpoint, { ...request, method: "POST" });
|
|
119
|
+
if (!response.ok)
|
|
120
|
+
throw await httpError(response, "Could not create realtime session");
|
|
121
|
+
const credentials = await response.json();
|
|
122
|
+
return new RealtimeSession(credentials, {
|
|
123
|
+
...sessionOptions,
|
|
124
|
+
fetch: fetcher,
|
|
125
|
+
connect: { microphone, audioTrack, readyTimeoutMs, apiBase: normalizedApiBase },
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
get state() {
|
|
129
|
+
return this.stateValue;
|
|
130
|
+
}
|
|
131
|
+
onEvent(listener) {
|
|
132
|
+
this.listeners.add(listener);
|
|
133
|
+
return () => this.listeners.delete(listener);
|
|
134
|
+
}
|
|
135
|
+
onStateChange(listener) {
|
|
136
|
+
this.stateListeners.add(listener);
|
|
137
|
+
this.notifyListeners(new Set([listener]), this.stateValue);
|
|
138
|
+
return () => this.stateListeners.delete(listener);
|
|
139
|
+
}
|
|
140
|
+
onProtocolGap(listener) {
|
|
141
|
+
this.gapListeners.add(listener);
|
|
142
|
+
return () => this.gapListeners.delete(listener);
|
|
143
|
+
}
|
|
144
|
+
onRemoteAudioTrack(listener) {
|
|
145
|
+
this.remoteAudioTrackListeners.add(listener);
|
|
146
|
+
return () => this.remoteAudioTrackListeners.delete(listener);
|
|
147
|
+
}
|
|
148
|
+
onRemoteAudioTrackUnsubscribed(listener) {
|
|
149
|
+
this.remoteAudioTrackUnsubscribedListeners.add(listener);
|
|
150
|
+
return () => this.remoteAudioTrackUnsubscribedListeners.delete(listener);
|
|
151
|
+
}
|
|
152
|
+
onReady(listener) {
|
|
153
|
+
this.readyListeners.add(listener);
|
|
154
|
+
return () => this.readyListeners.delete(listener);
|
|
155
|
+
}
|
|
156
|
+
onUserTranscript(listener) {
|
|
157
|
+
this.userTranscriptListeners.add(listener);
|
|
158
|
+
return () => this.userTranscriptListeners.delete(listener);
|
|
159
|
+
}
|
|
160
|
+
onAgentTranscript(listener) {
|
|
161
|
+
this.agentTranscriptListeners.add(listener);
|
|
162
|
+
return () => this.agentTranscriptListeners.delete(listener);
|
|
163
|
+
}
|
|
164
|
+
onError(listener) {
|
|
165
|
+
this.errorListeners.add(listener);
|
|
166
|
+
return () => this.errorListeners.delete(listener);
|
|
167
|
+
}
|
|
168
|
+
onEnded(listener) {
|
|
169
|
+
this.endedListeners.add(listener);
|
|
170
|
+
return () => this.endedListeners.delete(listener);
|
|
171
|
+
}
|
|
172
|
+
onToolCall(listener) {
|
|
173
|
+
this.toolCallListeners.add(listener);
|
|
174
|
+
return () => this.toolCallListeners.delete(listener);
|
|
175
|
+
}
|
|
176
|
+
onAudioPlaybackBlocked(listener) {
|
|
177
|
+
this.audioPlaybackBlockedListeners.add(listener);
|
|
178
|
+
return () => this.audioPlaybackBlockedListeners.delete(listener);
|
|
179
|
+
}
|
|
180
|
+
registerTool(name, handler) {
|
|
181
|
+
if (["connecting", "connected_not_ready", "connected", "reconnecting"].includes(this.stateValue)) {
|
|
182
|
+
throw new Error("Browser tools must be registered before connect()");
|
|
183
|
+
}
|
|
184
|
+
if (!/^[a-z][a-z0-9_]{0,79}$/.test(name)) {
|
|
185
|
+
throw new TypeError("Tool name must be lowercase letters, numbers, and underscores");
|
|
186
|
+
}
|
|
187
|
+
if (this.toolHandlers.has(name))
|
|
188
|
+
throw new TypeError(`Tool ${name} is already registered`);
|
|
189
|
+
this.toolHandlers.set(name, handler);
|
|
190
|
+
return () => {
|
|
191
|
+
if (this.toolHandlers.get(name) === handler)
|
|
192
|
+
this.toolHandlers.delete(name);
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
async connect(options = {}) {
|
|
196
|
+
if (["connecting", "connected_not_ready", "connected", "reconnecting"].includes(this.stateValue)) {
|
|
197
|
+
throw new Error(`Cannot connect while session is ${this.stateValue}`);
|
|
198
|
+
}
|
|
199
|
+
const resolved = { ...this.connectDefaults, ...options };
|
|
200
|
+
const audioTrack = resolved.audioTrack ?? null;
|
|
201
|
+
const microphone = resolved.microphone ?? audioTrack === null;
|
|
202
|
+
const readyTimeoutMs = resolved.readyTimeoutMs ?? 15_000;
|
|
203
|
+
const apiBase = normalizeApiBase(resolved.apiBase ?? this.apiBase);
|
|
204
|
+
this.validateAudioOptions(microphone, audioTrack);
|
|
205
|
+
this.useManagedMicrophone = microphone;
|
|
206
|
+
this.applicationAudioTrack = audioTrack;
|
|
207
|
+
this.setState("connecting");
|
|
208
|
+
this.apiBase = apiBase;
|
|
209
|
+
this.agentReady = false;
|
|
210
|
+
try {
|
|
211
|
+
await this.room.connect(this.credentials.url, this.credentials.token);
|
|
212
|
+
if (audioTrack) {
|
|
213
|
+
await this.publishApplicationAudioTrack(audioTrack);
|
|
214
|
+
}
|
|
215
|
+
else if (microphone) {
|
|
216
|
+
await this.room.localParticipant.setMicrophoneEnabled(true);
|
|
217
|
+
}
|
|
218
|
+
this.setState("connected_not_ready");
|
|
219
|
+
await this.sendHello();
|
|
220
|
+
await this.waitForAgentReady(readyTimeoutMs);
|
|
221
|
+
this.setState("connected");
|
|
222
|
+
}
|
|
223
|
+
catch (error) {
|
|
224
|
+
try {
|
|
225
|
+
await this.room.disconnect();
|
|
226
|
+
}
|
|
227
|
+
catch {
|
|
228
|
+
// Preserve the original connection/readiness failure.
|
|
229
|
+
}
|
|
230
|
+
this.setState("failed");
|
|
231
|
+
throw error;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
async rejoin(apiBase = this.apiBase) {
|
|
235
|
+
if (["connecting", "reconnecting"].includes(this.stateValue)) {
|
|
236
|
+
throw new Error(`Cannot rejoin while session is ${this.stateValue}`);
|
|
237
|
+
}
|
|
238
|
+
const normalizedApiBase = normalizeApiBase(apiBase);
|
|
239
|
+
let response = await this.fetchRoomToken(normalizedApiBase);
|
|
240
|
+
if (response.status === 401 && this.refreshCredentialsCallback) {
|
|
241
|
+
await this.refreshCredentials();
|
|
242
|
+
response = await this.fetchRoomToken(normalizedApiBase);
|
|
243
|
+
}
|
|
244
|
+
if (!response.ok)
|
|
245
|
+
throw await httpError(response, "Could not renew room token");
|
|
246
|
+
const payload = (await response.json());
|
|
247
|
+
this.credentials = payload.connection;
|
|
248
|
+
if (["connected", "connected_not_ready"].includes(this.stateValue)) {
|
|
249
|
+
await this.disconnect();
|
|
250
|
+
}
|
|
251
|
+
await this.connect({
|
|
252
|
+
apiBase: normalizedApiBase,
|
|
253
|
+
microphone: this.useManagedMicrophone,
|
|
254
|
+
...(this.applicationAudioTrack ? { audioTrack: this.applicationAudioTrack } : {}),
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
async setMicrophoneEnabled(enabled) {
|
|
258
|
+
await this.room.localParticipant.setMicrophoneEnabled(enabled);
|
|
259
|
+
}
|
|
260
|
+
async sendCommand(type, payload, timeoutMs = 10_000) {
|
|
261
|
+
const message = createClientMessage(this.connectionId, ++this.clientSeq, type, payload);
|
|
262
|
+
return this.sendTrackedCommand(message, timeoutMs);
|
|
263
|
+
}
|
|
264
|
+
async updateContext(update, timeoutMs = 20_000) {
|
|
265
|
+
const transfer = "image" in update
|
|
266
|
+
? await this.uploadImageContext(update)
|
|
267
|
+
: await this.uploadTextContext(update);
|
|
268
|
+
const { payload } = transfer;
|
|
269
|
+
if (!isContextUpdatePayload(payload)) {
|
|
270
|
+
throw new TypeError("Invalid context update payload");
|
|
271
|
+
}
|
|
272
|
+
const message = createClientMessage(this.connectionId, ++this.clientSeq, "context.update", { ...payload });
|
|
273
|
+
return this.sendTrackedCommand(message, timeoutMs, transfer.resend);
|
|
274
|
+
}
|
|
275
|
+
async uploadTextContext(update) {
|
|
276
|
+
const data = new TextEncoder().encode(update.text);
|
|
277
|
+
if (data.byteLength === 0)
|
|
278
|
+
throw new TypeError("Context text cannot be empty");
|
|
279
|
+
if (data.byteLength > 262_144) {
|
|
280
|
+
throw new TypeError("Context text must be at most 262144 UTF-8 bytes");
|
|
281
|
+
}
|
|
282
|
+
const base = {
|
|
283
|
+
key: update.key,
|
|
284
|
+
mode: update.mode,
|
|
285
|
+
label: update.label ?? "",
|
|
286
|
+
respond: update.respond ?? false,
|
|
287
|
+
};
|
|
288
|
+
if (data.byteLength <= 32_768)
|
|
289
|
+
return { payload: { ...base, text: update.text } };
|
|
290
|
+
const sha256 = await sha256Hex(data);
|
|
291
|
+
const streamId = contextStreamId();
|
|
292
|
+
const send = async () => {
|
|
293
|
+
const writer = await this.room.localParticipant.streamText({
|
|
294
|
+
streamId,
|
|
295
|
+
topic: "voxevolv.context.text",
|
|
296
|
+
totalSize: data.byteLength,
|
|
297
|
+
attributes: { sha256 },
|
|
298
|
+
});
|
|
299
|
+
await writer.write(update.text);
|
|
300
|
+
await writer.close();
|
|
301
|
+
};
|
|
302
|
+
await send();
|
|
303
|
+
return {
|
|
304
|
+
payload: {
|
|
305
|
+
...base,
|
|
306
|
+
text_stream: { stream_id: streamId, bytes: data.byteLength, sha256 },
|
|
307
|
+
},
|
|
308
|
+
resend: send,
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
async uploadImageContext(update) {
|
|
312
|
+
const allowed = new Set(["image/jpeg", "image/png", "image/webp"]);
|
|
313
|
+
if (!allowed.has(update.image.type)) {
|
|
314
|
+
throw new TypeError("Image context must be JPEG, PNG, or WebP");
|
|
315
|
+
}
|
|
316
|
+
const data = new Uint8Array(await update.image.arrayBuffer());
|
|
317
|
+
if (data.byteLength === 0 || data.byteLength > 5_242_880) {
|
|
318
|
+
throw new TypeError("Image context must be between 1 byte and 5 MiB");
|
|
319
|
+
}
|
|
320
|
+
const sha256 = await sha256Hex(data);
|
|
321
|
+
const streamId = contextStreamId();
|
|
322
|
+
const send = async () => {
|
|
323
|
+
const writer = await this.room.localParticipant.streamBytes({
|
|
324
|
+
streamId,
|
|
325
|
+
topic: "voxevolv.context.image",
|
|
326
|
+
name: "context-image",
|
|
327
|
+
mimeType: update.image.type,
|
|
328
|
+
totalSize: data.byteLength,
|
|
329
|
+
attributes: { sha256 },
|
|
330
|
+
});
|
|
331
|
+
await writer.write(data);
|
|
332
|
+
await writer.close();
|
|
333
|
+
};
|
|
334
|
+
await send();
|
|
335
|
+
return {
|
|
336
|
+
payload: {
|
|
337
|
+
key: update.key,
|
|
338
|
+
mode: "replace",
|
|
339
|
+
label: update.label ?? "",
|
|
340
|
+
image: {
|
|
341
|
+
stream_id: streamId,
|
|
342
|
+
mime: update.image.type,
|
|
343
|
+
bytes: data.byteLength,
|
|
344
|
+
sha256,
|
|
345
|
+
...(update.width === undefined ? {} : { width: update.width }),
|
|
346
|
+
...(update.height === undefined ? {} : { height: update.height }),
|
|
347
|
+
detail: update.detail ?? "low",
|
|
348
|
+
},
|
|
349
|
+
respond: update.respond ?? false,
|
|
350
|
+
},
|
|
351
|
+
resend: send,
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
async disconnect() {
|
|
355
|
+
if (this.stateValue === "disconnected")
|
|
356
|
+
return;
|
|
357
|
+
this.rejectPendingCommands(new Error("Session disconnected before command completion"));
|
|
358
|
+
await this.room.disconnect();
|
|
359
|
+
this.setState("disconnected");
|
|
360
|
+
}
|
|
361
|
+
async end(options = {}) {
|
|
362
|
+
const endpoint = options.endpoint ?? this.endEndpoint ?? `${this.apiBase}/sessions/${this.id}`;
|
|
363
|
+
const request = options.request ?? {};
|
|
364
|
+
const headers = new Headers(request.headers);
|
|
365
|
+
const usesScopedSessionEndpoint = !options.endpoint && !this.endEndpoint;
|
|
366
|
+
if (!headers.has("Authorization") && usesScopedSessionEndpoint) {
|
|
367
|
+
headers.set("Authorization", `Bearer ${this.accessToken}`);
|
|
368
|
+
}
|
|
369
|
+
let response = await this.fetcher(endpoint, {
|
|
370
|
+
...request,
|
|
371
|
+
method: request.method ?? "DELETE",
|
|
372
|
+
headers,
|
|
373
|
+
});
|
|
374
|
+
if (response.status === 401 && usesScopedSessionEndpoint && this.refreshCredentialsCallback) {
|
|
375
|
+
await this.refreshCredentials();
|
|
376
|
+
headers.set("Authorization", `Bearer ${this.accessToken}`);
|
|
377
|
+
response = await this.fetcher(endpoint, {
|
|
378
|
+
...request,
|
|
379
|
+
method: request.method ?? "DELETE",
|
|
380
|
+
headers,
|
|
381
|
+
});
|
|
382
|
+
}
|
|
383
|
+
if (!response.ok)
|
|
384
|
+
throw await httpError(response, "Could not end realtime session");
|
|
385
|
+
if (options.disconnect ?? true)
|
|
386
|
+
await this.disconnect();
|
|
387
|
+
}
|
|
388
|
+
async replay(apiBase = this.apiBase, afterSeq = this.cursor.lastServerSeq) {
|
|
389
|
+
if (this.replayPromise)
|
|
390
|
+
return this.replayPromise;
|
|
391
|
+
const normalizedApiBase = normalizeApiBase(apiBase);
|
|
392
|
+
this.replayPromise = this.fetchReplay(normalizedApiBase, afterSeq).finally(() => {
|
|
393
|
+
this.replayPromise = null;
|
|
394
|
+
});
|
|
395
|
+
return this.replayPromise;
|
|
396
|
+
}
|
|
397
|
+
setState(state) {
|
|
398
|
+
if (this.stateValue === state)
|
|
399
|
+
return;
|
|
400
|
+
this.stateValue = state;
|
|
401
|
+
this.notifyListeners(this.stateListeners, state);
|
|
402
|
+
}
|
|
403
|
+
bindRoomEvents() {
|
|
404
|
+
this.room.on(RoomEvent.Reconnecting, () => {
|
|
405
|
+
this.agentReady = false;
|
|
406
|
+
this.rejectPendingCommands(new Error("Connection interrupted before command completion"), true);
|
|
407
|
+
this.setState("reconnecting");
|
|
408
|
+
});
|
|
409
|
+
this.room.on(RoomEvent.Reconnected, () => {
|
|
410
|
+
this.connectionId = `conn_${Math.random().toString(36).slice(2)}_${Date.now().toString(36)}`;
|
|
411
|
+
this.clientSeq = 0;
|
|
412
|
+
void this.sendHello().catch(() => this.setState("failed"));
|
|
413
|
+
this.setState("connected_not_ready");
|
|
414
|
+
});
|
|
415
|
+
this.room.on(RoomEvent.Disconnected, () => {
|
|
416
|
+
this.agentReady = false;
|
|
417
|
+
this.rejectPendingCommands(new Error("Session disconnected before command completion"));
|
|
418
|
+
this.setState("disconnected");
|
|
419
|
+
});
|
|
420
|
+
this.room.on(RoomEvent.ConnectionStateChanged, (state) => {
|
|
421
|
+
if (state === LiveKitConnectionState.Connected) {
|
|
422
|
+
this.setState(this.agentReady ? "connected" : "connected_not_ready");
|
|
423
|
+
}
|
|
424
|
+
});
|
|
425
|
+
this.room.on(RoomEvent.TrackSubscribed, (track) => {
|
|
426
|
+
if (track.kind !== Track.Kind.Audio)
|
|
427
|
+
return;
|
|
428
|
+
const element = track.attach();
|
|
429
|
+
element.dataset.voxevolvRemoteAudio = "true";
|
|
430
|
+
element.style.display = "none";
|
|
431
|
+
document.body.appendChild(element);
|
|
432
|
+
try {
|
|
433
|
+
const playback = element.play();
|
|
434
|
+
void playback.catch((error) => {
|
|
435
|
+
this.notifyListeners(this.audioPlaybackBlockedListeners, {
|
|
436
|
+
error,
|
|
437
|
+
element,
|
|
438
|
+
track: track.mediaStreamTrack,
|
|
439
|
+
});
|
|
440
|
+
});
|
|
441
|
+
}
|
|
442
|
+
catch (error) {
|
|
443
|
+
this.notifyListeners(this.audioPlaybackBlockedListeners, {
|
|
444
|
+
error,
|
|
445
|
+
element,
|
|
446
|
+
track: track.mediaStreamTrack,
|
|
447
|
+
});
|
|
448
|
+
}
|
|
449
|
+
const stream = new MediaStream([track.mediaStreamTrack]);
|
|
450
|
+
this.remoteAudioStreams.set(track, stream);
|
|
451
|
+
this.notifyRemoteAudioListeners(this.remoteAudioTrackListeners, track.mediaStreamTrack, stream);
|
|
452
|
+
});
|
|
453
|
+
this.room.on(RoomEvent.TrackUnsubscribed, (track) => {
|
|
454
|
+
if (track.kind !== Track.Kind.Audio)
|
|
455
|
+
return;
|
|
456
|
+
const stream = this.remoteAudioStreams.get(track) ?? new MediaStream([track.mediaStreamTrack]);
|
|
457
|
+
this.notifyRemoteAudioListeners(this.remoteAudioTrackUnsubscribedListeners, track.mediaStreamTrack, stream);
|
|
458
|
+
this.remoteAudioStreams.delete(track);
|
|
459
|
+
track.detach().forEach((element) => element.remove());
|
|
460
|
+
});
|
|
461
|
+
this.room.on(RoomEvent.DataReceived, (data, _participant, kind, topic) => {
|
|
462
|
+
if (topic !== "voxevolv.events" || kind !== DataPacket_Kind.RELIABLE)
|
|
463
|
+
return;
|
|
464
|
+
let value;
|
|
465
|
+
try {
|
|
466
|
+
value = JSON.parse(new TextDecoder().decode(data));
|
|
467
|
+
}
|
|
468
|
+
catch {
|
|
469
|
+
// Ignore data from other room participants that is not a VoxEvolv envelope.
|
|
470
|
+
return;
|
|
471
|
+
}
|
|
472
|
+
if (!isRealtimeEvent(value))
|
|
473
|
+
return;
|
|
474
|
+
const result = this.cursor.accept(value);
|
|
475
|
+
if (result.gap) {
|
|
476
|
+
this.notifyListeners(this.gapListeners, result.gap);
|
|
477
|
+
void this.replay(this.apiBase, result.gap.after_server_seq);
|
|
478
|
+
}
|
|
479
|
+
if (!result.deliver)
|
|
480
|
+
return;
|
|
481
|
+
this.observeReadiness(value);
|
|
482
|
+
this.observeCommandResult(value);
|
|
483
|
+
this.observeConvenienceEvents(value);
|
|
484
|
+
this.notifyListeners(this.listeners, value);
|
|
485
|
+
});
|
|
486
|
+
}
|
|
487
|
+
notifyListeners(listeners, value) {
|
|
488
|
+
for (const listener of listeners) {
|
|
489
|
+
try {
|
|
490
|
+
listener(value);
|
|
491
|
+
}
|
|
492
|
+
catch (error) {
|
|
493
|
+
console.error("VoxEvolv event listener failed", error);
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
notifyRemoteAudioListeners(listeners, track, stream) {
|
|
498
|
+
for (const listener of listeners) {
|
|
499
|
+
try {
|
|
500
|
+
listener(track, stream);
|
|
501
|
+
}
|
|
502
|
+
catch (error) {
|
|
503
|
+
console.error("VoxEvolv remote audio listener failed", error);
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
validateAudioOptions(microphone, audioTrack) {
|
|
508
|
+
if (microphone && audioTrack) {
|
|
509
|
+
throw new TypeError("microphone and audioTrack cannot both be enabled");
|
|
510
|
+
}
|
|
511
|
+
if (!audioTrack)
|
|
512
|
+
return;
|
|
513
|
+
if (audioTrack.kind !== "audio") {
|
|
514
|
+
throw new TypeError("audioTrack must be an audio MediaStreamTrack");
|
|
515
|
+
}
|
|
516
|
+
if (audioTrack.readyState === "ended") {
|
|
517
|
+
throw new TypeError("audioTrack has already ended");
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
async fetchRoomToken(apiBase) {
|
|
521
|
+
return this.fetcher(`${apiBase}/sessions/${this.id}/room-tokens`, {
|
|
522
|
+
method: "POST",
|
|
523
|
+
headers: { Authorization: `Bearer ${this.accessToken}` },
|
|
524
|
+
});
|
|
525
|
+
}
|
|
526
|
+
async refreshCredentials() {
|
|
527
|
+
if (!this.refreshCredentialsCallback)
|
|
528
|
+
return;
|
|
529
|
+
const refreshed = await this.refreshCredentialsCallback();
|
|
530
|
+
validateSessionCredentials(refreshed);
|
|
531
|
+
if (refreshed.id !== this.id) {
|
|
532
|
+
throw new Error("Refreshed credentials belong to a different session");
|
|
533
|
+
}
|
|
534
|
+
this.accessToken = refreshed.access_token;
|
|
535
|
+
this.credentials = refreshed.connection;
|
|
536
|
+
}
|
|
537
|
+
observeConvenienceEvents(event) {
|
|
538
|
+
if (event.type === "session.ended")
|
|
539
|
+
this.notifyListeners(this.endedListeners, event);
|
|
540
|
+
if (event.type.endsWith(".error"))
|
|
541
|
+
this.notifyListeners(this.errorListeners, event);
|
|
542
|
+
const transcript = transcriptEvent(event);
|
|
543
|
+
if (!transcript)
|
|
544
|
+
return;
|
|
545
|
+
this.notifyListeners(transcript.role === "user" ? this.userTranscriptListeners : this.agentTranscriptListeners, transcript);
|
|
546
|
+
}
|
|
547
|
+
async publishApplicationAudioTrack(audioTrack) {
|
|
548
|
+
const publishedTrack = audioTrack.clone();
|
|
549
|
+
try {
|
|
550
|
+
await this.room.localParticipant.publishTrack(publishedTrack, {
|
|
551
|
+
source: Track.Source.Microphone,
|
|
552
|
+
name: "voxevolv-application-audio",
|
|
553
|
+
});
|
|
554
|
+
}
|
|
555
|
+
catch (error) {
|
|
556
|
+
publishedTrack.stop();
|
|
557
|
+
throw error;
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
async sendHello() {
|
|
561
|
+
const message = createClientMessage(this.connectionId, ++this.clientSeq, "client.hello", {
|
|
562
|
+
last_server_seq: this.cursor.lastServerSeq,
|
|
563
|
+
sdk: `@voxevolv/client@${SDK_VERSION}`,
|
|
564
|
+
tool_handlers: [...this.toolHandlers.keys()].sort(),
|
|
565
|
+
});
|
|
566
|
+
this.lastHelloMessageId = message.message_id;
|
|
567
|
+
await this.sendTrackedCommand(message, 10_000);
|
|
568
|
+
}
|
|
569
|
+
async sendTrackedCommand(message, timeoutMs, retryTransfer) {
|
|
570
|
+
const response = new Promise((resolve, reject) => {
|
|
571
|
+
const timeout = globalThis.setTimeout(() => {
|
|
572
|
+
const pending = this.pendingCommands.get(message.message_id);
|
|
573
|
+
if (pending)
|
|
574
|
+
globalThis.clearInterval(pending.retry);
|
|
575
|
+
this.pendingCommands.delete(message.message_id);
|
|
576
|
+
reject(new Error(`Command ${message.type} timed out after ${timeoutMs}ms`));
|
|
577
|
+
}, timeoutMs);
|
|
578
|
+
const retry = globalThis.setInterval(() => {
|
|
579
|
+
void this.publishCommand(message).catch(() => {
|
|
580
|
+
// A reconnect may temporarily reject writes; retry until timeout.
|
|
581
|
+
});
|
|
582
|
+
}, 500);
|
|
583
|
+
this.pendingCommands.set(message.message_id, {
|
|
584
|
+
commandType: message.type,
|
|
585
|
+
resolve,
|
|
586
|
+
reject,
|
|
587
|
+
timeout,
|
|
588
|
+
retry,
|
|
589
|
+
retryTransfer: retryTransfer
|
|
590
|
+
? async () => {
|
|
591
|
+
await retryTransfer();
|
|
592
|
+
await this.publishCommand(message);
|
|
593
|
+
}
|
|
594
|
+
: undefined,
|
|
595
|
+
transferRetrying: false,
|
|
596
|
+
});
|
|
597
|
+
});
|
|
598
|
+
try {
|
|
599
|
+
await this.publishCommand(message);
|
|
600
|
+
}
|
|
601
|
+
catch (error) {
|
|
602
|
+
const pending = this.pendingCommands.get(message.message_id);
|
|
603
|
+
if (pending) {
|
|
604
|
+
globalThis.clearTimeout(pending.timeout);
|
|
605
|
+
globalThis.clearInterval(pending.retry);
|
|
606
|
+
this.pendingCommands.delete(message.message_id);
|
|
607
|
+
pending.reject(error instanceof Error ? error : new Error(String(error)));
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
return response;
|
|
611
|
+
}
|
|
612
|
+
async publishCommand(message) {
|
|
613
|
+
await this.room.localParticipant.publishData(new TextEncoder().encode(JSON.stringify(message)), { reliable: true, topic: "voxevolv.commands" });
|
|
614
|
+
}
|
|
615
|
+
waitForAgentReady(timeoutMs) {
|
|
616
|
+
if (this.agentReady)
|
|
617
|
+
return Promise.resolve();
|
|
618
|
+
return new Promise((resolve, reject) => {
|
|
619
|
+
const timeout = globalThis.setTimeout(() => {
|
|
620
|
+
this.readyResolve = null;
|
|
621
|
+
reject(new Error(`Agent did not become ready within ${timeoutMs}ms`));
|
|
622
|
+
}, timeoutMs);
|
|
623
|
+
this.readyResolve = () => {
|
|
624
|
+
globalThis.clearTimeout(timeout);
|
|
625
|
+
this.readyResolve = null;
|
|
626
|
+
resolve();
|
|
627
|
+
};
|
|
628
|
+
});
|
|
629
|
+
}
|
|
630
|
+
observeReadiness(event) {
|
|
631
|
+
const payload = event.payload;
|
|
632
|
+
const snapshotReady = event.type === "agent.snapshot"
|
|
633
|
+
&& payload.status === "ready"
|
|
634
|
+
&& payload.connection_id === this.connectionId
|
|
635
|
+
&& payload.ack_message_id === this.lastHelloMessageId;
|
|
636
|
+
if (event.type !== "agent.ready" && !snapshotReady)
|
|
637
|
+
return;
|
|
638
|
+
const wasReady = this.agentReady;
|
|
639
|
+
this.agentReady = true;
|
|
640
|
+
if (this.room.state === LiveKitConnectionState.Connected)
|
|
641
|
+
this.setState("connected");
|
|
642
|
+
this.readyResolve?.();
|
|
643
|
+
if (!wasReady)
|
|
644
|
+
this.notifyListeners(this.readyListeners, event);
|
|
645
|
+
}
|
|
646
|
+
observeCommandResult(event) {
|
|
647
|
+
if (event.type !== "command.ack"
|
|
648
|
+
&& event.type !== "command.error"
|
|
649
|
+
&& event.type !== "context.updated"
|
|
650
|
+
&& event.type !== "context.rejected")
|
|
651
|
+
return;
|
|
652
|
+
const pending = this.pendingCommands.get(String(event.payload.message_id ?? ""));
|
|
653
|
+
if (!pending)
|
|
654
|
+
return;
|
|
655
|
+
if (event.type === "command.ack" && isCommandAckPayload(event.payload)) {
|
|
656
|
+
globalThis.clearTimeout(pending.timeout);
|
|
657
|
+
globalThis.clearInterval(pending.retry);
|
|
658
|
+
this.pendingCommands.delete(event.payload.message_id);
|
|
659
|
+
pending.resolve(event.payload);
|
|
660
|
+
return;
|
|
661
|
+
}
|
|
662
|
+
if (event.type === "context.updated" && isContextUpdatedPayload(event.payload)) {
|
|
663
|
+
globalThis.clearTimeout(pending.timeout);
|
|
664
|
+
globalThis.clearInterval(pending.retry);
|
|
665
|
+
this.pendingCommands.delete(event.payload.message_id);
|
|
666
|
+
pending.resolve(event.payload);
|
|
667
|
+
return;
|
|
668
|
+
}
|
|
669
|
+
if ((event.type === "command.error" && isCommandErrorPayload(event.payload))
|
|
670
|
+
|| (event.type === "context.rejected" && isContextRejectedPayload(event.payload))) {
|
|
671
|
+
if (event.type === "context.rejected" && event.payload.retryable) {
|
|
672
|
+
if (pending.retryTransfer && !pending.transferRetrying) {
|
|
673
|
+
pending.transferRetrying = true;
|
|
674
|
+
void pending.retryTransfer()
|
|
675
|
+
.catch(() => {
|
|
676
|
+
// The command timeout remains authoritative while reconnecting.
|
|
677
|
+
})
|
|
678
|
+
.finally(() => { pending.transferRetrying = false; });
|
|
679
|
+
}
|
|
680
|
+
return;
|
|
681
|
+
}
|
|
682
|
+
globalThis.clearTimeout(pending.timeout);
|
|
683
|
+
globalThis.clearInterval(pending.retry);
|
|
684
|
+
this.pendingCommands.delete(event.payload.message_id);
|
|
685
|
+
pending.reject(new CommandError(event.payload));
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
rejectPendingCommands(error, preserveContext = false) {
|
|
689
|
+
for (const [messageId, pending] of this.pendingCommands) {
|
|
690
|
+
if (preserveContext && pending.commandType === "context.update")
|
|
691
|
+
continue;
|
|
692
|
+
globalThis.clearTimeout(pending.timeout);
|
|
693
|
+
globalThis.clearInterval(pending.retry);
|
|
694
|
+
pending.reject(error);
|
|
695
|
+
this.pendingCommands.delete(messageId);
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
async fetchReplay(apiBase, afterSeq) {
|
|
699
|
+
let nextAfter = afterSeq;
|
|
700
|
+
while (true) {
|
|
701
|
+
let response = await this.fetcher(`${apiBase}/sessions/${this.id}/events?after_seq=${nextAfter}&limit=200`, { headers: { Authorization: `Bearer ${this.accessToken}` } });
|
|
702
|
+
if (response.status === 401 && this.refreshCredentialsCallback) {
|
|
703
|
+
await this.refreshCredentials();
|
|
704
|
+
response = await this.fetcher(`${apiBase}/sessions/${this.id}/events?after_seq=${nextAfter}&limit=200`, { headers: { Authorization: `Bearer ${this.accessToken}` } });
|
|
705
|
+
}
|
|
706
|
+
if (!response.ok)
|
|
707
|
+
throw await httpError(response, "Could not replay session events");
|
|
708
|
+
const page = (await response.json());
|
|
709
|
+
for (const stored of page.items) {
|
|
710
|
+
nextAfter = Math.max(nextAfter, stored.server_seq);
|
|
711
|
+
const event = {
|
|
712
|
+
protocol_version: 1,
|
|
713
|
+
event_id: stored.event_id,
|
|
714
|
+
stream_id: "durable-replay",
|
|
715
|
+
stream_seq: Math.max(this.cursor.lastStreamSeq, 1),
|
|
716
|
+
server_seq: stored.server_seq,
|
|
717
|
+
ts: stored.created_at,
|
|
718
|
+
type: stored.type,
|
|
719
|
+
payload: stored.payload,
|
|
720
|
+
};
|
|
721
|
+
if (!this.cursor.acceptReplay(event))
|
|
722
|
+
continue;
|
|
723
|
+
this.observeReadiness(event);
|
|
724
|
+
this.observeConvenienceEvents(event);
|
|
725
|
+
this.notifyListeners(this.listeners, event);
|
|
726
|
+
}
|
|
727
|
+
nextAfter = Math.max(nextAfter, page.next_after_seq);
|
|
728
|
+
if (!page.has_more)
|
|
729
|
+
return;
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
async function sha256Hex(data) {
|
|
734
|
+
if (!globalThis.crypto?.subtle)
|
|
735
|
+
throw new Error("Web Crypto SHA-256 is unavailable");
|
|
736
|
+
const digest = await globalThis.crypto.subtle.digest("SHA-256", Uint8Array.from(data).buffer);
|
|
737
|
+
return [...new Uint8Array(digest)]
|
|
738
|
+
.map((byte) => byte.toString(16).padStart(2, "0"))
|
|
739
|
+
.join("");
|
|
740
|
+
}
|
|
741
|
+
function contextStreamId() {
|
|
742
|
+
if (globalThis.crypto?.randomUUID)
|
|
743
|
+
return globalThis.crypto.randomUUID();
|
|
744
|
+
return `ctx_${Date.now().toString(36)}_${Math.random().toString(36).slice(2)}`;
|
|
745
|
+
}
|
|
746
|
+
function validateSessionCredentials(value) {
|
|
747
|
+
if (!value || typeof value !== "object") {
|
|
748
|
+
throw new TypeError("Session credentials must be an object");
|
|
749
|
+
}
|
|
750
|
+
if (!value.id || !value.access_token || !value.access_token_expires_at) {
|
|
751
|
+
throw new TypeError("Session credentials are incomplete");
|
|
752
|
+
}
|
|
753
|
+
if (!value.connection
|
|
754
|
+
|| !value.connection.url
|
|
755
|
+
|| !value.connection.token
|
|
756
|
+
|| !value.connection.room_name) {
|
|
757
|
+
throw new TypeError("Session connection credentials are incomplete");
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
function normalizeApiBase(value) {
|
|
761
|
+
const normalized = value.trim().replace(/\/+$/, "");
|
|
762
|
+
if (!normalized) {
|
|
763
|
+
throw new TypeError("apiBase must be a non-root relative path or an absolute URL");
|
|
764
|
+
}
|
|
765
|
+
return normalized;
|
|
766
|
+
}
|
|
767
|
+
function transcriptEvent(event) {
|
|
768
|
+
const match = /^transcript\.(user|agent)\.(interim|final)$/.exec(event.type);
|
|
769
|
+
if (!match)
|
|
770
|
+
return null;
|
|
771
|
+
const text = event.payload.text;
|
|
772
|
+
if (typeof text !== "string")
|
|
773
|
+
return null;
|
|
774
|
+
return {
|
|
775
|
+
role: match[1],
|
|
776
|
+
text,
|
|
777
|
+
final: match[2] === "final",
|
|
778
|
+
event,
|
|
779
|
+
};
|
|
780
|
+
}
|
|
781
|
+
async function httpError(response, fallback) {
|
|
782
|
+
let payload = {};
|
|
783
|
+
try {
|
|
784
|
+
payload = await response.json();
|
|
785
|
+
}
|
|
786
|
+
catch {
|
|
787
|
+
// The HTTP status and fallback message remain actionable without a JSON body.
|
|
788
|
+
}
|
|
789
|
+
return new RealtimeSessionHttpError(typeof payload.message === "string" ? payload.message : `${fallback} (${response.status})`, {
|
|
790
|
+
status: response.status,
|
|
791
|
+
code: typeof payload.error === "string" ? payload.error : null,
|
|
792
|
+
requestId: typeof payload.request_id === "string" ? payload.request_id : null,
|
|
793
|
+
details: Array.isArray(payload.details) ? payload.details : [],
|
|
794
|
+
});
|
|
795
|
+
}
|
|
796
|
+
//# sourceMappingURL=index.js.map
|