@llmrtc/llmrtc-web-client 1.0.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/LICENSE +201 -0
- package/README.md +43 -0
- package/dist/connection-state.d.ts +89 -0
- package/dist/connection-state.js +158 -0
- package/dist/connection-state.js.map +1 -0
- package/dist/index.d.ts +128 -0
- package/dist/index.js +708 -0
- package/dist/index.js.map +1 -0
- package/dist/native-peer.d.ts +84 -0
- package/dist/native-peer.js +351 -0
- package/dist/native-peer.js.map +1 -0
- package/package.json +43 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,708 @@
|
|
|
1
|
+
import EventEmitter from 'eventemitter3';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { NativePeer } from './native-peer.js';
|
|
4
|
+
import { ConnectionStateMachine, ConnectionState } from './connection-state.js';
|
|
5
|
+
import { PROTOCOL_VERSION } from '@llmrtc/llmrtc-core';
|
|
6
|
+
// Re-export for convenience
|
|
7
|
+
export { ConnectionState } from './connection-state.js';
|
|
8
|
+
export { NativePeer } from './native-peer.js';
|
|
9
|
+
export { PROTOCOL_VERSION } from '@llmrtc/llmrtc-core';
|
|
10
|
+
const MessageSchema = z.object({ type: z.string() }).passthrough();
|
|
11
|
+
const HEARTBEAT_INTERVAL_MS = 15000;
|
|
12
|
+
const HEARTBEAT_TIMEOUT_MS = 10000;
|
|
13
|
+
const MAX_MISSED_HEARTBEATS = 2;
|
|
14
|
+
/**
|
|
15
|
+
* Default ICE servers - Metered STUN server
|
|
16
|
+
* Used when no custom ICE servers configured and server doesn't provide any
|
|
17
|
+
*/
|
|
18
|
+
const DEFAULT_ICE_SERVERS = [
|
|
19
|
+
{ urls: 'stun:stun.metered.ca:80' }
|
|
20
|
+
];
|
|
21
|
+
export class LLMRTCWebClient extends EventEmitter {
|
|
22
|
+
constructor(config) {
|
|
23
|
+
super();
|
|
24
|
+
Object.defineProperty(this, "config", {
|
|
25
|
+
enumerable: true,
|
|
26
|
+
configurable: true,
|
|
27
|
+
writable: true,
|
|
28
|
+
value: config
|
|
29
|
+
});
|
|
30
|
+
Object.defineProperty(this, "ws", {
|
|
31
|
+
enumerable: true,
|
|
32
|
+
configurable: true,
|
|
33
|
+
writable: true,
|
|
34
|
+
value: null
|
|
35
|
+
});
|
|
36
|
+
Object.defineProperty(this, "peer", {
|
|
37
|
+
enumerable: true,
|
|
38
|
+
configurable: true,
|
|
39
|
+
writable: true,
|
|
40
|
+
value: null
|
|
41
|
+
});
|
|
42
|
+
Object.defineProperty(this, "stateMachine", {
|
|
43
|
+
enumerable: true,
|
|
44
|
+
configurable: true,
|
|
45
|
+
writable: true,
|
|
46
|
+
value: void 0
|
|
47
|
+
});
|
|
48
|
+
Object.defineProperty(this, "sessionId", {
|
|
49
|
+
enumerable: true,
|
|
50
|
+
configurable: true,
|
|
51
|
+
writable: true,
|
|
52
|
+
value: null
|
|
53
|
+
});
|
|
54
|
+
Object.defineProperty(this, "heartbeatInterval", {
|
|
55
|
+
enumerable: true,
|
|
56
|
+
configurable: true,
|
|
57
|
+
writable: true,
|
|
58
|
+
value: void 0
|
|
59
|
+
});
|
|
60
|
+
Object.defineProperty(this, "heartbeatTimeout", {
|
|
61
|
+
enumerable: true,
|
|
62
|
+
configurable: true,
|
|
63
|
+
writable: true,
|
|
64
|
+
value: void 0
|
|
65
|
+
});
|
|
66
|
+
Object.defineProperty(this, "missedHeartbeats", {
|
|
67
|
+
enumerable: true,
|
|
68
|
+
configurable: true,
|
|
69
|
+
writable: true,
|
|
70
|
+
value: 0
|
|
71
|
+
});
|
|
72
|
+
Object.defineProperty(this, "reconnectTimeout", {
|
|
73
|
+
enumerable: true,
|
|
74
|
+
configurable: true,
|
|
75
|
+
writable: true,
|
|
76
|
+
value: void 0
|
|
77
|
+
});
|
|
78
|
+
/** ICE servers received from server in ready message */
|
|
79
|
+
Object.defineProperty(this, "serverIceServers", {
|
|
80
|
+
enumerable: true,
|
|
81
|
+
configurable: true,
|
|
82
|
+
writable: true,
|
|
83
|
+
value: null
|
|
84
|
+
});
|
|
85
|
+
// Media state
|
|
86
|
+
Object.defineProperty(this, "audioTrack", {
|
|
87
|
+
enumerable: true,
|
|
88
|
+
configurable: true,
|
|
89
|
+
writable: true,
|
|
90
|
+
value: void 0
|
|
91
|
+
});
|
|
92
|
+
Object.defineProperty(this, "audioStream", {
|
|
93
|
+
enumerable: true,
|
|
94
|
+
configurable: true,
|
|
95
|
+
writable: true,
|
|
96
|
+
value: void 0
|
|
97
|
+
});
|
|
98
|
+
Object.defineProperty(this, "audioSender", {
|
|
99
|
+
enumerable: true,
|
|
100
|
+
configurable: true,
|
|
101
|
+
writable: true,
|
|
102
|
+
value: void 0
|
|
103
|
+
});
|
|
104
|
+
Object.defineProperty(this, "videoCapture", {
|
|
105
|
+
enumerable: true,
|
|
106
|
+
configurable: true,
|
|
107
|
+
writable: true,
|
|
108
|
+
value: void 0
|
|
109
|
+
});
|
|
110
|
+
Object.defineProperty(this, "screenCapture", {
|
|
111
|
+
enumerable: true,
|
|
112
|
+
configurable: true,
|
|
113
|
+
writable: true,
|
|
114
|
+
value: void 0
|
|
115
|
+
});
|
|
116
|
+
// Default reconnection to enabled
|
|
117
|
+
const reconnectionConfig = {
|
|
118
|
+
enabled: true,
|
|
119
|
+
...config.reconnection
|
|
120
|
+
};
|
|
121
|
+
this.stateMachine = new ConnectionStateMachine(reconnectionConfig);
|
|
122
|
+
this.stateMachine.on('stateChange', ({ to }) => {
|
|
123
|
+
this.emit('stateChange', to);
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Get the current connection state.
|
|
128
|
+
*/
|
|
129
|
+
get state() {
|
|
130
|
+
return this.stateMachine.state;
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Get the session ID assigned by the server.
|
|
134
|
+
*/
|
|
135
|
+
get currentSessionId() {
|
|
136
|
+
return this.sessionId;
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Start the connection to the server.
|
|
140
|
+
*/
|
|
141
|
+
async start() {
|
|
142
|
+
if (this.stateMachine.state !== ConnectionState.DISCONNECTED) {
|
|
143
|
+
throw new Error('Client already started or connecting');
|
|
144
|
+
}
|
|
145
|
+
this.stateMachine.transition(ConnectionState.CONNECTING);
|
|
146
|
+
try {
|
|
147
|
+
await this.connect();
|
|
148
|
+
this.stateMachine.transition(ConnectionState.CONNECTED);
|
|
149
|
+
}
|
|
150
|
+
catch (error) {
|
|
151
|
+
this.handleConnectionError(error);
|
|
152
|
+
throw error;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
async connect() {
|
|
156
|
+
// 1. Establish WebSocket
|
|
157
|
+
await this.connectWebSocket();
|
|
158
|
+
// 2. Wait for ready message with session ID and ICE servers
|
|
159
|
+
await this.waitForReady();
|
|
160
|
+
// 3. Resolve ICE servers
|
|
161
|
+
// Priority: client config > server-provided > default STUN
|
|
162
|
+
const iceServers = this.config.iceServers?.length
|
|
163
|
+
? this.config.iceServers
|
|
164
|
+
: this.serverIceServers?.length
|
|
165
|
+
? this.serverIceServers
|
|
166
|
+
: DEFAULT_ICE_SERVERS;
|
|
167
|
+
console.log('[web-client] Using', iceServers.length, 'ICE servers');
|
|
168
|
+
// 4. Create peer connection
|
|
169
|
+
this.peer = new NativePeer({ iceServers, trickle: false }, true);
|
|
170
|
+
this.setupPeerEventHandlers();
|
|
171
|
+
// 5. Create offer (triggers signal event which sends to server)
|
|
172
|
+
await this.peer.createOffer();
|
|
173
|
+
// 6. Wait for peer connection to be established
|
|
174
|
+
await this.waitForPeerConnection();
|
|
175
|
+
// 7. Start heartbeat
|
|
176
|
+
this.startHeartbeat();
|
|
177
|
+
}
|
|
178
|
+
connectWebSocket() {
|
|
179
|
+
return new Promise((resolve, reject) => {
|
|
180
|
+
console.log('[web-client] Connecting to', this.config.signallingUrl);
|
|
181
|
+
this.ws = new WebSocket(this.config.signallingUrl);
|
|
182
|
+
const onOpen = () => {
|
|
183
|
+
console.log('[web-client] WebSocket connected');
|
|
184
|
+
cleanup();
|
|
185
|
+
resolve();
|
|
186
|
+
};
|
|
187
|
+
const onError = (e) => {
|
|
188
|
+
console.error('[web-client] WebSocket error:', e);
|
|
189
|
+
cleanup();
|
|
190
|
+
reject(new Error('WebSocket connection failed'));
|
|
191
|
+
};
|
|
192
|
+
const cleanup = () => {
|
|
193
|
+
this.ws?.removeEventListener('open', onOpen);
|
|
194
|
+
this.ws?.removeEventListener('error', onError);
|
|
195
|
+
};
|
|
196
|
+
this.ws.addEventListener('open', onOpen);
|
|
197
|
+
this.ws.addEventListener('error', onError);
|
|
198
|
+
this.ws.onmessage = (ev) => this.handleSignalingMessage(ev.data);
|
|
199
|
+
this.ws.onclose = () => this.handleWebSocketClose();
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
waitForReady() {
|
|
203
|
+
return new Promise((resolve, reject) => {
|
|
204
|
+
const timeout = setTimeout(() => {
|
|
205
|
+
reject(new Error('Timeout waiting for ready message'));
|
|
206
|
+
}, 10000);
|
|
207
|
+
const handler = (ev) => {
|
|
208
|
+
try {
|
|
209
|
+
const msg = JSON.parse(ev.data);
|
|
210
|
+
if (msg.type === 'ready') {
|
|
211
|
+
clearTimeout(timeout);
|
|
212
|
+
this.sessionId = msg.id;
|
|
213
|
+
// Store server-provided ICE servers
|
|
214
|
+
if (msg.iceServers?.length) {
|
|
215
|
+
this.serverIceServers = msg.iceServers;
|
|
216
|
+
console.log('[web-client] Received', msg.iceServers.length, 'ICE servers from server');
|
|
217
|
+
}
|
|
218
|
+
// Check protocol version
|
|
219
|
+
const serverVersion = msg.protocolVersion ?? 0;
|
|
220
|
+
if (serverVersion !== PROTOCOL_VERSION) {
|
|
221
|
+
console.warn(`[web-client] Protocol version mismatch: client=${PROTOCOL_VERSION}, server=${serverVersion}`);
|
|
222
|
+
}
|
|
223
|
+
console.log('[web-client] Session ID:', this.sessionId, 'Protocol version:', serverVersion);
|
|
224
|
+
this.ws?.removeEventListener('message', handler);
|
|
225
|
+
resolve();
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
catch {
|
|
229
|
+
// Ignore parse errors
|
|
230
|
+
}
|
|
231
|
+
};
|
|
232
|
+
this.ws?.addEventListener('message', handler);
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
setupPeerEventHandlers() {
|
|
236
|
+
if (!this.peer)
|
|
237
|
+
return;
|
|
238
|
+
this.peer.on('signal', (signal) => {
|
|
239
|
+
console.log('[web-client] Sending offer signal');
|
|
240
|
+
this.ws?.send(JSON.stringify({ type: 'offer', signal }));
|
|
241
|
+
});
|
|
242
|
+
this.peer.on('data', (data) => {
|
|
243
|
+
const str = typeof data === 'string' ? data : new TextDecoder().decode(data);
|
|
244
|
+
this.handlePayload(str);
|
|
245
|
+
});
|
|
246
|
+
this.peer.on('connect', () => {
|
|
247
|
+
console.log('[web-client] Peer connected');
|
|
248
|
+
});
|
|
249
|
+
this.peer.on('close', () => {
|
|
250
|
+
console.log('[web-client] Peer closed');
|
|
251
|
+
if (this.stateMachine.state === ConnectionState.CONNECTED) {
|
|
252
|
+
this.scheduleReconnect();
|
|
253
|
+
}
|
|
254
|
+
});
|
|
255
|
+
this.peer.on('error', (err) => {
|
|
256
|
+
console.error('[web-client] Peer error:', err.message);
|
|
257
|
+
this.emit('error', {
|
|
258
|
+
code: 'WEBRTC_ERROR',
|
|
259
|
+
message: err.message,
|
|
260
|
+
recoverable: true
|
|
261
|
+
});
|
|
262
|
+
});
|
|
263
|
+
this.peer.on('track', (track, stream) => {
|
|
264
|
+
if (track.kind === 'audio') {
|
|
265
|
+
console.log('[web-client] Received TTS audio track from server');
|
|
266
|
+
this.emit('ttsTrack', stream);
|
|
267
|
+
}
|
|
268
|
+
});
|
|
269
|
+
this.peer.on('connectionStateChange', (state) => {
|
|
270
|
+
console.log('[web-client] Connection state changed:', state);
|
|
271
|
+
if (state === 'failed' || state === 'disconnected') {
|
|
272
|
+
if (this.stateMachine.state === ConnectionState.CONNECTED) {
|
|
273
|
+
this.scheduleReconnect();
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
waitForPeerConnection() {
|
|
279
|
+
return new Promise((resolve, reject) => {
|
|
280
|
+
if (this.peer?.connected) {
|
|
281
|
+
resolve();
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
const timeout = setTimeout(() => {
|
|
285
|
+
cleanup();
|
|
286
|
+
reject(new Error('Timeout waiting for peer connection'));
|
|
287
|
+
}, 30000);
|
|
288
|
+
const onConnect = () => {
|
|
289
|
+
clearTimeout(timeout);
|
|
290
|
+
cleanup();
|
|
291
|
+
resolve();
|
|
292
|
+
};
|
|
293
|
+
const onError = (err) => {
|
|
294
|
+
clearTimeout(timeout);
|
|
295
|
+
cleanup();
|
|
296
|
+
reject(err);
|
|
297
|
+
};
|
|
298
|
+
const cleanup = () => {
|
|
299
|
+
this.peer?.off('connect', onConnect);
|
|
300
|
+
this.peer?.off('error', onError);
|
|
301
|
+
};
|
|
302
|
+
this.peer?.on('connect', onConnect);
|
|
303
|
+
this.peer?.on('error', onError);
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
handleSignalingMessage(raw) {
|
|
307
|
+
try {
|
|
308
|
+
const parsed = MessageSchema.safeParse(JSON.parse(raw));
|
|
309
|
+
if (!parsed.success)
|
|
310
|
+
return;
|
|
311
|
+
const msg = parsed.data;
|
|
312
|
+
switch (msg.type) {
|
|
313
|
+
case 'signal':
|
|
314
|
+
if (this.peer && !this.peer.destroyed) {
|
|
315
|
+
console.log('[web-client] Received answer signal');
|
|
316
|
+
this.peer.signal(msg.signal);
|
|
317
|
+
}
|
|
318
|
+
break;
|
|
319
|
+
case 'pong':
|
|
320
|
+
// Reset missed heartbeats on pong
|
|
321
|
+
this.missedHeartbeats = 0;
|
|
322
|
+
if (this.heartbeatTimeout) {
|
|
323
|
+
clearTimeout(this.heartbeatTimeout);
|
|
324
|
+
this.heartbeatTimeout = undefined;
|
|
325
|
+
}
|
|
326
|
+
break;
|
|
327
|
+
case 'reconnect-ack':
|
|
328
|
+
console.log('[web-client] Reconnect acknowledged:', msg.historyRecovered ? 'history recovered' : 'new session', 'sessionId:', msg.sessionId);
|
|
329
|
+
// Update session ID to the one confirmed by the server
|
|
330
|
+
// This ensures sessionId is preserved across reconnections when history is recovered
|
|
331
|
+
if (msg.sessionId) {
|
|
332
|
+
this.sessionId = msg.sessionId;
|
|
333
|
+
}
|
|
334
|
+
break;
|
|
335
|
+
default:
|
|
336
|
+
// Only process payload messages from WebSocket if DataChannel is NOT connected
|
|
337
|
+
// This prevents duplicate message handling since backend sends to both channels
|
|
338
|
+
if (!this.peer?.connected) {
|
|
339
|
+
this.handlePayload(raw);
|
|
340
|
+
}
|
|
341
|
+
// When DataChannel is connected, ignore payload messages from WebSocket
|
|
342
|
+
// They will be handled by the DataChannel's 'data' event
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
catch (err) {
|
|
346
|
+
console.error('[web-client] Error handling signal:', err);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
handlePayload(raw) {
|
|
350
|
+
try {
|
|
351
|
+
const msg = JSON.parse(raw);
|
|
352
|
+
switch (msg.type) {
|
|
353
|
+
case 'transcript':
|
|
354
|
+
this.emit('transcript', msg.text);
|
|
355
|
+
break;
|
|
356
|
+
case 'llm-chunk':
|
|
357
|
+
if (msg.content)
|
|
358
|
+
this.emit('llmChunk', msg.content);
|
|
359
|
+
break;
|
|
360
|
+
case 'llm':
|
|
361
|
+
this.emit('llm', msg.text);
|
|
362
|
+
break;
|
|
363
|
+
case 'tts':
|
|
364
|
+
if (msg.data)
|
|
365
|
+
this.emit('tts', base64ToArrayBuffer(msg.data), msg.format ?? 'mp3');
|
|
366
|
+
break;
|
|
367
|
+
case 'tts-start':
|
|
368
|
+
this.emit('ttsStart');
|
|
369
|
+
break;
|
|
370
|
+
case 'tts-complete':
|
|
371
|
+
this.emit('ttsComplete');
|
|
372
|
+
break;
|
|
373
|
+
case 'tts-cancelled':
|
|
374
|
+
this.emit('ttsCancelled');
|
|
375
|
+
break;
|
|
376
|
+
case 'speech-start':
|
|
377
|
+
this.emit('speechStart');
|
|
378
|
+
break;
|
|
379
|
+
case 'speech-end':
|
|
380
|
+
this.sendAttachments();
|
|
381
|
+
this.emit('speechEnd');
|
|
382
|
+
break;
|
|
383
|
+
case 'tool-call-start':
|
|
384
|
+
this.emit('toolCallStart', {
|
|
385
|
+
name: msg.name,
|
|
386
|
+
callId: msg.callId,
|
|
387
|
+
arguments: msg.arguments ?? {}
|
|
388
|
+
});
|
|
389
|
+
break;
|
|
390
|
+
case 'tool-call-end':
|
|
391
|
+
this.emit('toolCallEnd', {
|
|
392
|
+
callId: msg.callId,
|
|
393
|
+
result: msg.result,
|
|
394
|
+
error: msg.error,
|
|
395
|
+
durationMs: msg.durationMs ?? 0
|
|
396
|
+
});
|
|
397
|
+
break;
|
|
398
|
+
case 'stage-change':
|
|
399
|
+
this.emit('stageChange', {
|
|
400
|
+
from: msg.from,
|
|
401
|
+
to: msg.to,
|
|
402
|
+
reason: msg.reason ?? ''
|
|
403
|
+
});
|
|
404
|
+
break;
|
|
405
|
+
case 'error':
|
|
406
|
+
this.emit('error', {
|
|
407
|
+
code: msg.code ?? 'SERVER_ERROR',
|
|
408
|
+
message: msg.message ?? 'Unknown error',
|
|
409
|
+
recoverable: false
|
|
410
|
+
});
|
|
411
|
+
break;
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
catch (err) {
|
|
415
|
+
console.error('[web-client] Error handling payload:', err);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
handleWebSocketClose() {
|
|
419
|
+
console.log('[web-client] WebSocket closed');
|
|
420
|
+
if (this.stateMachine.state === ConnectionState.CONNECTED) {
|
|
421
|
+
this.scheduleReconnect();
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
handleConnectionError(error) {
|
|
425
|
+
console.error('[web-client] Connection error:', error.message);
|
|
426
|
+
if (this.stateMachine.reconnectionEnabled) {
|
|
427
|
+
this.scheduleReconnect();
|
|
428
|
+
}
|
|
429
|
+
else {
|
|
430
|
+
this.stateMachine.transition(ConnectionState.FAILED);
|
|
431
|
+
this.emit('error', {
|
|
432
|
+
code: 'CONNECTION_ERROR',
|
|
433
|
+
message: error.message,
|
|
434
|
+
recoverable: false
|
|
435
|
+
});
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
scheduleReconnect() {
|
|
439
|
+
if (!this.stateMachine.reconnectionEnabled) {
|
|
440
|
+
this.stateMachine.transition(ConnectionState.FAILED);
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
// Clear any existing reconnect timeout
|
|
444
|
+
if (this.reconnectTimeout) {
|
|
445
|
+
clearTimeout(this.reconnectTimeout);
|
|
446
|
+
}
|
|
447
|
+
this.stateMachine.transition(ConnectionState.RECONNECTING);
|
|
448
|
+
const delay = this.stateMachine.getNextRetryDelay();
|
|
449
|
+
if (delay === null) {
|
|
450
|
+
// Max retries exceeded
|
|
451
|
+
this.stateMachine.transition(ConnectionState.FAILED);
|
|
452
|
+
this.emit('error', {
|
|
453
|
+
code: 'RECONNECTION_FAILED',
|
|
454
|
+
message: 'Maximum reconnection attempts exceeded',
|
|
455
|
+
recoverable: false
|
|
456
|
+
});
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
459
|
+
console.log(`[web-client] Reconnecting in ${delay}ms (attempt ${this.stateMachine.retryCount}/${this.stateMachine.maxRetries})`);
|
|
460
|
+
this.emit('reconnecting', this.stateMachine.retryCount, this.stateMachine.maxRetries);
|
|
461
|
+
this.reconnectTimeout = setTimeout(() => this.attemptReconnect(), delay);
|
|
462
|
+
}
|
|
463
|
+
async attemptReconnect() {
|
|
464
|
+
// Save old session ID BEFORE cleanup/connect (connect() will set a new sessionId)
|
|
465
|
+
const oldSessionId = this.sessionId;
|
|
466
|
+
// Clean up existing connections but keep session ID
|
|
467
|
+
this.cleanup(false);
|
|
468
|
+
this.stateMachine.transition(ConnectionState.CONNECTING);
|
|
469
|
+
try {
|
|
470
|
+
await this.connect();
|
|
471
|
+
// Send reconnect message with OLD session ID to recover the previous session
|
|
472
|
+
// Note: connect() sets this.sessionId to a new value from the server's ready message,
|
|
473
|
+
// so we must use oldSessionId here to request session recovery
|
|
474
|
+
if (oldSessionId) {
|
|
475
|
+
console.log('[web-client] Sending reconnect request for session:', oldSessionId);
|
|
476
|
+
this.ws?.send(JSON.stringify({
|
|
477
|
+
type: 'reconnect',
|
|
478
|
+
sessionId: oldSessionId
|
|
479
|
+
}));
|
|
480
|
+
}
|
|
481
|
+
// Re-add audio track if we had one
|
|
482
|
+
if (this.audioStream && this.audioTrack) {
|
|
483
|
+
console.log('[web-client] Re-adding audio track after reconnect');
|
|
484
|
+
this.audioSender = this.peer?.addTrack(this.audioTrack, this.audioStream);
|
|
485
|
+
}
|
|
486
|
+
this.stateMachine.transition(ConnectionState.CONNECTED);
|
|
487
|
+
}
|
|
488
|
+
catch (error) {
|
|
489
|
+
console.error('[web-client] Reconnect attempt failed:', error);
|
|
490
|
+
this.scheduleReconnect();
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
startHeartbeat() {
|
|
494
|
+
this.stopHeartbeat();
|
|
495
|
+
this.heartbeatInterval = setInterval(() => {
|
|
496
|
+
if (this.ws?.readyState === WebSocket.OPEN) {
|
|
497
|
+
this.ws.send(JSON.stringify({ type: 'ping', timestamp: Date.now() }));
|
|
498
|
+
this.heartbeatTimeout = setTimeout(() => {
|
|
499
|
+
this.missedHeartbeats++;
|
|
500
|
+
console.warn(`[web-client] Missed heartbeat (${this.missedHeartbeats}/${MAX_MISSED_HEARTBEATS})`);
|
|
501
|
+
if (this.missedHeartbeats >= MAX_MISSED_HEARTBEATS) {
|
|
502
|
+
console.warn('[web-client] Too many missed heartbeats, reconnecting');
|
|
503
|
+
this.scheduleReconnect();
|
|
504
|
+
}
|
|
505
|
+
}, HEARTBEAT_TIMEOUT_MS);
|
|
506
|
+
}
|
|
507
|
+
}, HEARTBEAT_INTERVAL_MS);
|
|
508
|
+
}
|
|
509
|
+
stopHeartbeat() {
|
|
510
|
+
if (this.heartbeatInterval) {
|
|
511
|
+
clearInterval(this.heartbeatInterval);
|
|
512
|
+
this.heartbeatInterval = undefined;
|
|
513
|
+
}
|
|
514
|
+
if (this.heartbeatTimeout) {
|
|
515
|
+
clearTimeout(this.heartbeatTimeout);
|
|
516
|
+
this.heartbeatTimeout = undefined;
|
|
517
|
+
}
|
|
518
|
+
this.missedHeartbeats = 0;
|
|
519
|
+
}
|
|
520
|
+
/**
|
|
521
|
+
* Share audio with the server.
|
|
522
|
+
* Speech detection is handled server-side using Silero VAD.
|
|
523
|
+
*/
|
|
524
|
+
async shareAudio(stream) {
|
|
525
|
+
if (!this.peer?.connected) {
|
|
526
|
+
throw new Error('Peer not connected');
|
|
527
|
+
}
|
|
528
|
+
this.audioStream = stream;
|
|
529
|
+
this.audioTrack = stream.getAudioTracks()[0];
|
|
530
|
+
if (!this.audioTrack) {
|
|
531
|
+
throw new Error('No audio track in stream');
|
|
532
|
+
}
|
|
533
|
+
console.log('[web-client] Adding audio track to peer connection');
|
|
534
|
+
this.audioSender = this.peer.addTrack(this.audioTrack, stream);
|
|
535
|
+
// Wait for signaling to stabilize
|
|
536
|
+
await this.waitForStableSignaling();
|
|
537
|
+
console.log('[web-client] Audio track added - VAD handled server-side');
|
|
538
|
+
return {
|
|
539
|
+
stop: async () => {
|
|
540
|
+
console.log('[web-client] Stopping audio sharing');
|
|
541
|
+
if (this.audioSender && this.peer) {
|
|
542
|
+
this.peer.removeTrack(this.audioSender);
|
|
543
|
+
this.audioSender = undefined;
|
|
544
|
+
}
|
|
545
|
+
this.audioTrack?.stop();
|
|
546
|
+
stream.getTracks().forEach((t) => t.stop());
|
|
547
|
+
this.audioTrack = undefined;
|
|
548
|
+
this.audioStream = undefined;
|
|
549
|
+
}
|
|
550
|
+
};
|
|
551
|
+
}
|
|
552
|
+
waitForStableSignaling() {
|
|
553
|
+
return new Promise((resolve) => {
|
|
554
|
+
const checkState = () => {
|
|
555
|
+
if (this.peer?.signalingState === 'stable') {
|
|
556
|
+
resolve();
|
|
557
|
+
}
|
|
558
|
+
else {
|
|
559
|
+
setTimeout(checkState, 100);
|
|
560
|
+
}
|
|
561
|
+
};
|
|
562
|
+
setTimeout(checkState, 100);
|
|
563
|
+
});
|
|
564
|
+
}
|
|
565
|
+
/**
|
|
566
|
+
* Send vision attachments to include with next speech segment.
|
|
567
|
+
*/
|
|
568
|
+
sendAttachments() {
|
|
569
|
+
const attachments = this.gatherAttachments();
|
|
570
|
+
if (attachments.length > 0 && this.peer?.connected) {
|
|
571
|
+
this.peer.send(JSON.stringify({ type: 'attachments', attachments }));
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
gatherAttachments() {
|
|
575
|
+
const attachments = [];
|
|
576
|
+
const cam = this.videoCapture?.getLastFrame();
|
|
577
|
+
const screen = this.screenCapture?.getLastFrame();
|
|
578
|
+
if (cam)
|
|
579
|
+
attachments.push({ data: cam, mimeType: 'image/jpeg', alt: 'camera frame' });
|
|
580
|
+
if (screen)
|
|
581
|
+
attachments.push({ data: screen, mimeType: 'image/jpeg', alt: 'screen frame' });
|
|
582
|
+
return attachments;
|
|
583
|
+
}
|
|
584
|
+
shareVideo(stream, intervalMs = 1000) {
|
|
585
|
+
this.videoCapture?.stop();
|
|
586
|
+
const ctrl = startFrameCapture(stream, intervalMs);
|
|
587
|
+
this.videoCapture = ctrl;
|
|
588
|
+
return ctrl;
|
|
589
|
+
}
|
|
590
|
+
shareScreen(stream, intervalMs = 1200) {
|
|
591
|
+
this.screenCapture?.stop();
|
|
592
|
+
const ctrl = startFrameCapture(stream, intervalMs);
|
|
593
|
+
this.screenCapture = ctrl;
|
|
594
|
+
return ctrl;
|
|
595
|
+
}
|
|
596
|
+
/**
|
|
597
|
+
* Close the connection.
|
|
598
|
+
*/
|
|
599
|
+
close() {
|
|
600
|
+
this.stateMachine.transition(ConnectionState.CLOSED);
|
|
601
|
+
this.cleanup(true);
|
|
602
|
+
}
|
|
603
|
+
cleanup(fullCleanup) {
|
|
604
|
+
this.stopHeartbeat();
|
|
605
|
+
if (this.reconnectTimeout) {
|
|
606
|
+
clearTimeout(this.reconnectTimeout);
|
|
607
|
+
this.reconnectTimeout = undefined;
|
|
608
|
+
}
|
|
609
|
+
this.videoCapture?.stop();
|
|
610
|
+
this.screenCapture?.stop();
|
|
611
|
+
this.peer?.destroy();
|
|
612
|
+
this.peer = null;
|
|
613
|
+
if (this.ws) {
|
|
614
|
+
this.ws.onclose = null; // Prevent triggering reconnect
|
|
615
|
+
this.ws.close();
|
|
616
|
+
this.ws = null;
|
|
617
|
+
}
|
|
618
|
+
if (fullCleanup) {
|
|
619
|
+
this.sessionId = null;
|
|
620
|
+
this.serverIceServers = null;
|
|
621
|
+
this.audioTrack = undefined;
|
|
622
|
+
this.audioStream = undefined;
|
|
623
|
+
this.audioSender = undefined;
|
|
624
|
+
this.stateMachine.reset();
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
// Helper functions
|
|
629
|
+
function base64ToArrayBuffer(base64) {
|
|
630
|
+
const binary = atob(base64);
|
|
631
|
+
const bytes = new Uint8Array(binary.length);
|
|
632
|
+
for (let i = 0; i < binary.length; i++) {
|
|
633
|
+
bytes[i] = binary.charCodeAt(i);
|
|
634
|
+
}
|
|
635
|
+
return bytes.buffer;
|
|
636
|
+
}
|
|
637
|
+
export async function recordOnce(durationMs = 4000) {
|
|
638
|
+
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
|
639
|
+
const recorder = new MediaRecorder(stream, { mimeType: 'audio/webm' });
|
|
640
|
+
const chunks = [];
|
|
641
|
+
return new Promise((resolve) => {
|
|
642
|
+
recorder.ondataavailable = (e) => {
|
|
643
|
+
if (e.data.size > 0)
|
|
644
|
+
chunks.push(e.data);
|
|
645
|
+
};
|
|
646
|
+
recorder.onstop = async () => {
|
|
647
|
+
const blob = new Blob(chunks, { type: 'audio/webm' });
|
|
648
|
+
const buf = await blob.arrayBuffer();
|
|
649
|
+
stream.getTracks().forEach((t) => t.stop());
|
|
650
|
+
resolve(buf);
|
|
651
|
+
};
|
|
652
|
+
recorder.start();
|
|
653
|
+
setTimeout(() => recorder.stop(), durationMs);
|
|
654
|
+
});
|
|
655
|
+
}
|
|
656
|
+
export async function captureScreenFrame() {
|
|
657
|
+
const screen = await navigator.mediaDevices.getDisplayMedia({ video: true });
|
|
658
|
+
const track = screen.getVideoTracks()[0];
|
|
659
|
+
const image = await grabFrame(track);
|
|
660
|
+
track.stop();
|
|
661
|
+
return image;
|
|
662
|
+
}
|
|
663
|
+
async function grabFrame(track) {
|
|
664
|
+
const capture = new window.ImageCapture(track);
|
|
665
|
+
const bitmap = await capture.grabFrame();
|
|
666
|
+
const canvas = document.createElement('canvas');
|
|
667
|
+
canvas.width = bitmap.width;
|
|
668
|
+
canvas.height = bitmap.height;
|
|
669
|
+
const ctx = canvas.getContext('2d');
|
|
670
|
+
if (!ctx)
|
|
671
|
+
throw new Error('canvas context missing');
|
|
672
|
+
ctx.drawImage(bitmap, 0, 0);
|
|
673
|
+
const dataUrl = canvas.toDataURL('image/jpeg', 0.6);
|
|
674
|
+
return dataUrl;
|
|
675
|
+
}
|
|
676
|
+
function startFrameCapture(stream, intervalMs) {
|
|
677
|
+
const track = stream.getVideoTracks()[0];
|
|
678
|
+
let stopped = false;
|
|
679
|
+
const canvas = document.createElement('canvas');
|
|
680
|
+
const ctx = canvas.getContext('2d');
|
|
681
|
+
let lastFrame = null;
|
|
682
|
+
const tick = async () => {
|
|
683
|
+
if (stopped)
|
|
684
|
+
return;
|
|
685
|
+
try {
|
|
686
|
+
const capture = new window.ImageCapture(track);
|
|
687
|
+
const bitmap = await capture.grabFrame();
|
|
688
|
+
canvas.width = bitmap.width;
|
|
689
|
+
canvas.height = bitmap.height;
|
|
690
|
+
ctx?.drawImage(bitmap, 0, 0);
|
|
691
|
+
lastFrame = canvas.toDataURL('image/jpeg', 0.6);
|
|
692
|
+
}
|
|
693
|
+
catch (err) {
|
|
694
|
+
// ignore frame errors
|
|
695
|
+
}
|
|
696
|
+
timer = window.setTimeout(tick, intervalMs);
|
|
697
|
+
};
|
|
698
|
+
let timer = window.setTimeout(tick, intervalMs);
|
|
699
|
+
return {
|
|
700
|
+
stop: () => {
|
|
701
|
+
stopped = true;
|
|
702
|
+
window.clearTimeout(timer);
|
|
703
|
+
stream.getTracks().forEach((t) => t.stop());
|
|
704
|
+
},
|
|
705
|
+
getLastFrame: () => lastFrame
|
|
706
|
+
};
|
|
707
|
+
}
|
|
708
|
+
//# sourceMappingURL=index.js.map
|