@chanl/widget-sdk 0.2.0-canary.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.
Files changed (128) hide show
  1. package/README.md +257 -0
  2. package/dist/auth.d.ts +26 -0
  3. package/dist/auth.d.ts.map +1 -0
  4. package/dist/auth.js +36 -0
  5. package/dist/auth.js.map +1 -0
  6. package/dist/chat/chat-client.d.ts +81 -0
  7. package/dist/chat/chat-client.d.ts.map +1 -0
  8. package/dist/chat/chat-client.js +192 -0
  9. package/dist/chat/chat-client.js.map +1 -0
  10. package/dist/chat/stream-parser.d.ts +20 -0
  11. package/dist/chat/stream-parser.d.ts.map +1 -0
  12. package/dist/chat/stream-parser.js +134 -0
  13. package/dist/chat/stream-parser.js.map +1 -0
  14. package/dist/chat/widget-config.d.ts +7 -0
  15. package/dist/chat/widget-config.d.ts.map +1 -0
  16. package/dist/chat/widget-config.js +26 -0
  17. package/dist/chat/widget-config.js.map +1 -0
  18. package/dist/client.d.ts +66 -0
  19. package/dist/client.d.ts.map +1 -0
  20. package/dist/client.js +49 -0
  21. package/dist/client.js.map +1 -0
  22. package/dist/defaults.d.ts +12 -0
  23. package/dist/defaults.d.ts.map +1 -0
  24. package/dist/defaults.js +27 -0
  25. package/dist/defaults.js.map +1 -0
  26. package/dist/embed/loader-types.d.ts +119 -0
  27. package/dist/embed/loader-types.d.ts.map +1 -0
  28. package/dist/embed/loader-types.js +20 -0
  29. package/dist/embed/loader-types.js.map +1 -0
  30. package/dist/embed/loader.d.ts +101 -0
  31. package/dist/embed/loader.d.ts.map +1 -0
  32. package/dist/embed/loader.js +439 -0
  33. package/dist/embed/loader.js.map +1 -0
  34. package/dist/embed/v1.global.js +5 -0
  35. package/dist/embed/v1.global.js.map +1 -0
  36. package/dist/events.d.ts +10 -0
  37. package/dist/events.d.ts.map +1 -0
  38. package/dist/events.js +25 -0
  39. package/dist/events.js.map +1 -0
  40. package/dist/index.d.ts +19 -0
  41. package/dist/index.d.ts.map +1 -0
  42. package/dist/index.js +29 -0
  43. package/dist/index.js.map +1 -0
  44. package/dist/logger.d.ts +14 -0
  45. package/dist/logger.d.ts.map +1 -0
  46. package/dist/logger.js +3 -0
  47. package/dist/logger.js.map +1 -0
  48. package/dist/next/index.d.ts +58 -0
  49. package/dist/next/index.d.ts.map +1 -0
  50. package/dist/next/index.js +83 -0
  51. package/dist/next/index.js.map +1 -0
  52. package/dist/react/index.d.ts +16 -0
  53. package/dist/react/index.d.ts.map +1 -0
  54. package/dist/react/index.js +20 -0
  55. package/dist/react/index.js.map +1 -0
  56. package/dist/react/types.d.ts +27 -0
  57. package/dist/react/types.d.ts.map +1 -0
  58. package/dist/react/types.js +8 -0
  59. package/dist/react/types.js.map +1 -0
  60. package/dist/react/use-chanl.d.ts +27 -0
  61. package/dist/react/use-chanl.d.ts.map +1 -0
  62. package/dist/react/use-chanl.js +57 -0
  63. package/dist/react/use-chanl.js.map +1 -0
  64. package/dist/react/use-chat.d.ts +32 -0
  65. package/dist/react/use-chat.d.ts.map +1 -0
  66. package/dist/react/use-chat.js +224 -0
  67. package/dist/react/use-chat.js.map +1 -0
  68. package/dist/react/use-voice.d.ts +37 -0
  69. package/dist/react/use-voice.d.ts.map +1 -0
  70. package/dist/react/use-voice.js +268 -0
  71. package/dist/react/use-voice.js.map +1 -0
  72. package/dist/react/widget.d.ts +43 -0
  73. package/dist/react/widget.d.ts.map +1 -0
  74. package/dist/react/widget.js +188 -0
  75. package/dist/react/widget.js.map +1 -0
  76. package/dist/storage/session-storage.d.ts +48 -0
  77. package/dist/storage/session-storage.d.ts.map +1 -0
  78. package/dist/storage/session-storage.js +84 -0
  79. package/dist/storage/session-storage.js.map +1 -0
  80. package/dist/types.d.ts +140 -0
  81. package/dist/types.d.ts.map +1 -0
  82. package/dist/types.js +7 -0
  83. package/dist/types.js.map +1 -0
  84. package/dist/voice/audio-recorder.d.ts +43 -0
  85. package/dist/voice/audio-recorder.d.ts.map +1 -0
  86. package/dist/voice/audio-recorder.js +127 -0
  87. package/dist/voice/audio-recorder.js.map +1 -0
  88. package/dist/voice/index.d.ts +13 -0
  89. package/dist/voice/index.d.ts.map +1 -0
  90. package/dist/voice/index.js +16 -0
  91. package/dist/voice/index.js.map +1 -0
  92. package/dist/voice/mock-mode.d.ts +93 -0
  93. package/dist/voice/mock-mode.d.ts.map +1 -0
  94. package/dist/voice/mock-mode.js +375 -0
  95. package/dist/voice/mock-mode.js.map +1 -0
  96. package/dist/voice/transports/index.d.ts +5 -0
  97. package/dist/voice/transports/index.d.ts.map +1 -0
  98. package/dist/voice/transports/index.js +10 -0
  99. package/dist/voice/transports/index.js.map +1 -0
  100. package/dist/voice/transports/transport.d.ts +70 -0
  101. package/dist/voice/transports/transport.d.ts.map +1 -0
  102. package/dist/voice/transports/transport.js +12 -0
  103. package/dist/voice/transports/transport.js.map +1 -0
  104. package/dist/voice/transports/vapi.d.ts +147 -0
  105. package/dist/voice/transports/vapi.d.ts.map +1 -0
  106. package/dist/voice/transports/vapi.js +337 -0
  107. package/dist/voice/transports/vapi.js.map +1 -0
  108. package/dist/voice/transports/webrtc.d.ts +58 -0
  109. package/dist/voice/transports/webrtc.d.ts.map +1 -0
  110. package/dist/voice/transports/webrtc.js +318 -0
  111. package/dist/voice/transports/webrtc.js.map +1 -0
  112. package/dist/voice/transports/websocket.d.ts +39 -0
  113. package/dist/voice/transports/websocket.d.ts.map +1 -0
  114. package/dist/voice/transports/websocket.js +280 -0
  115. package/dist/voice/transports/websocket.js.map +1 -0
  116. package/dist/voice/types.d.ts +323 -0
  117. package/dist/voice/types.d.ts.map +1 -0
  118. package/dist/voice/types.js +41 -0
  119. package/dist/voice/types.js.map +1 -0
  120. package/dist/voice/utils.d.ts +22 -0
  121. package/dist/voice/utils.d.ts.map +1 -0
  122. package/dist/voice/utils.js +44 -0
  123. package/dist/voice/utils.js.map +1 -0
  124. package/dist/voice/voice-client.d.ts +231 -0
  125. package/dist/voice/voice-client.d.ts.map +1 -0
  126. package/dist/voice/voice-client.js +1187 -0
  127. package/dist/voice/voice-client.js.map +1 -0
  128. package/package.json +91 -0
@@ -0,0 +1,1187 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.VoiceClient = void 0;
4
+ const eventemitter3_1 = require("eventemitter3");
5
+ const types_1 = require("./types");
6
+ const websocket_1 = require("./transports/websocket");
7
+ const webrtc_1 = require("./transports/webrtc");
8
+ const vapi_1 = require("./transports/vapi");
9
+ class VoiceClient extends eventemitter3_1.EventEmitter {
10
+ constructor(publicKey, config = {}) {
11
+ super();
12
+ this.log = {
13
+ log: console.debug.bind(console),
14
+ info: console.info.bind(console),
15
+ debug: console.debug.bind(console),
16
+ warn: console.warn.bind(console),
17
+ error: console.error.bind(console),
18
+ };
19
+ // =========================
20
+ // 2. Connection State & Status
21
+ // =========================
22
+ this.state = types_1.AudialState.Idle;
23
+ /**
24
+ * Active transport — WebSocket or WebRTC.
25
+ * Created on start(), destroyed on stop().
26
+ */
27
+ this.transport = null;
28
+ /**
29
+ * Which transport is actually in use for the current call.
30
+ */
31
+ this.activeTransportType = null;
32
+ /**
33
+ * Room connection state
34
+ */
35
+ this.roomState = null;
36
+ /**
37
+ * The current node ID
38
+ */
39
+ this.currentNodeId = null;
40
+ // =========================
41
+ // 3. Audio State (tracked by Audial, delegated to transport)
42
+ // =========================
43
+ this.audioDeafened = false;
44
+ this.micMuted = false;
45
+ // =========================
46
+ // 4. Retry & Reconnection Logic
47
+ // =========================
48
+ this.retryPolicy = { maxRetries: 3, backoffMs: 1000 };
49
+ this.retryCount = 0;
50
+ this.reconnectTimer = null;
51
+ this.connectionTimeoutTimer = null;
52
+ // Per-call-intent Idempotency-Key (IETF draft + Stripe pattern).
53
+ // Generated on user-initiated start, reused across retries within the
54
+ // same intent, cleared on successful connection (call is now
55
+ // "consumed") or on stop (next start is a new intent).
56
+ // Persisted to sessionStorage so a reload in the same tab during a
57
+ // pending connect reuses the same key → server returns the existing
58
+ // queued interaction instead of leaking a new one.
59
+ this.idempotencyKey = null;
60
+ // =========================
61
+ // 6. Chat Session State
62
+ // =========================
63
+ this.chatState = types_1.ChatState.Idle;
64
+ this.chatSession = null;
65
+ this.chatMessages = [];
66
+ this.publicKey = publicKey;
67
+ this.config = {
68
+ baseUrl: this.getBaseUrl(),
69
+ debug: true, // TODO URGENT: TESTING ONLY
70
+ initialMuted: false,
71
+ // autoReconnect defaults to FALSE. The old default of true produced
72
+ // zombie interactions: every retry calls start() → createRoom() which
73
+ // hits POST /interactions/websocket and creates a brand-new
74
+ // interaction. A transient WebRTC blip could leave 10+ "queued"
75
+ // interactions in the DB, none of which ever connected or produced
76
+ // a transcript. Users opting into reconnect must set it explicitly.
77
+ autoReconnect: false,
78
+ ...config
79
+ };
80
+ // Validate: authToken requires workspaceId
81
+ if (this.config.authToken && !this.config.workspaceId) {
82
+ throw new Error('Audial: workspaceId is required when using authToken');
83
+ }
84
+ if (this.config.debug) {
85
+ const authMode = this.config.authToken ? 'jwt' : 'publicKey';
86
+ console.debug("🎤 Audial SDK initialized:", {
87
+ authMode,
88
+ publicKey: this.publicKey ? this.publicKey.substring(0, 10) + "..." : '(none)',
89
+ baseUrl: this.config.baseUrl,
90
+ transport: this.config.transport ?? 'auto',
91
+ });
92
+ }
93
+ this.audioDeafened = this.config.initialMuted;
94
+ }
95
+ // ── Auth helpers (unchanged) ───────────────────────────────────────────
96
+ getAuthHeaders() {
97
+ if (this.config.authToken) {
98
+ const headers = {
99
+ 'Authorization': `Bearer ${this.config.authToken}`,
100
+ };
101
+ if (this.config.workspaceId) {
102
+ headers['x-workspace-id'] = this.config.workspaceId;
103
+ }
104
+ return headers;
105
+ }
106
+ return {};
107
+ }
108
+ applyPublicKeyAuth(url) {
109
+ if (!this.config.authToken && this.publicKey) {
110
+ url.searchParams.set('publicKey', this.publicKey);
111
+ }
112
+ }
113
+ start(agentIdOrPayload, overrides) {
114
+ if (this.state !== types_1.AudialState.Idle) {
115
+ throw new Error("Audial: call already active");
116
+ }
117
+ this.currentNodeId = null;
118
+ let payload;
119
+ if (typeof agentIdOrPayload === 'string') {
120
+ payload = { agentId: agentIdOrPayload };
121
+ }
122
+ else {
123
+ payload = agentIdOrPayload;
124
+ }
125
+ this.state = types_1.AudialState.CreatingRoom;
126
+ this.retryCount = 0;
127
+ this.lastCallPayload = payload;
128
+ this.lastOverrides = overrides;
129
+ // Mint or restore the Idempotency-Key for this call intent.
130
+ // Same key is reused on every retry path inside this call attempt —
131
+ // the server dedups against it, so a retry loop can't leak zombie
132
+ // interactions. Cleared in stop() and on successful connection.
133
+ if (!this.idempotencyKey) {
134
+ this.idempotencyKey = this.restoreOrMintIdempotencyKey();
135
+ }
136
+ // Connection timeout — if 'ready' not received within limit, emit error
137
+ if (this.connectionTimeoutTimer) {
138
+ clearTimeout(this.connectionTimeoutTimer);
139
+ }
140
+ const timeoutMs = this.config.connectionTimeoutMs ?? 30000;
141
+ this.connectionTimeoutTimer = setTimeout(() => {
142
+ this.connectionTimeoutTimer = null;
143
+ this.emit('error', new Error('Connection timeout - voice service did not respond'));
144
+ this.stop(4000, 'connection-timeout');
145
+ }, timeoutMs);
146
+ if (this.config.debug) {
147
+ console.debug('🎤 Starting call:', { payload, overrides, timeoutMs });
148
+ }
149
+ this.createRoomAndConnect(payload, overrides);
150
+ }
151
+ /**
152
+ * Minimal description of a real-time session result returned by a
153
+ * `createSession` function. Mirrors the shape of `RealtimeSessionResult`
154
+ * from `@chanl/sdk` so consumers can pass `sdk.realtime.createSession`'s
155
+ * return value (after unwrapping `{ data }`) directly, without importing
156
+ * that type here (which would create a cross-package dependency).
157
+ *
158
+ * Only the fields needed for wiring are required; everything else is optional
159
+ * so this interface is forward-compatible as new credential types are added.
160
+ */
161
+ /**
162
+ * High-level orchestration method: fetch a provider session via an injected
163
+ * `createSession` function, then wire the credential into `connectProviderSession`.
164
+ *
165
+ * This keeps widget-sdk free of a dependency on `@chanl/sdk`. The consumer
166
+ * (e.g. chanl-admin) injects `sdk.realtime.createSession` as a plain function.
167
+ *
168
+ * Phase 1 only supports `credentialType === 'join-url'` (VAPI Daily rooms).
169
+ * Attempting to start a session with `token` or `ws-url` credentials will
170
+ * throw immediately with a clear "not yet supported" message.
171
+ *
172
+ * @param createSession - Async function that, given an agentId, resolves with
173
+ * a session result containing `{ provider, credentialType, joinUrl?, ... }`.
174
+ * @param agentId - The agent ID to pass to `createSession`.
175
+ * @param opts.overrides - Optional assistant overrides forwarded to the transport.
176
+ *
177
+ * @throws Error if credentialType is not 'join-url' (Phase 1 only).
178
+ * @throws Error if a call is already active.
179
+ * @throws Error (async, via 'error' event) if the transport connect fails.
180
+ */
181
+ async startProviderSession(createSession, agentId, opts) {
182
+ if (this.state !== types_1.AudialState.Idle) {
183
+ throw new Error('Audial: call already active');
184
+ }
185
+ if (this.config.debug) {
186
+ console.debug('🎤 startProviderSession: fetching session for agent', agentId);
187
+ }
188
+ const session = await createSession(agentId);
189
+ if (session.credentialType !== 'join-url') {
190
+ throw new Error(`Audial: credentialType "${session.credentialType}" is not yet supported in Phase 1 — only 'join-url' is implemented`);
191
+ }
192
+ if (!session.joinUrl) {
193
+ throw new Error('Audial: createSession returned credentialType "join-url" but joinUrl is missing');
194
+ }
195
+ // Delegate to connectProviderSession which manages state transitions,
196
+ // timeout, and transport instantiation — no duplication.
197
+ this.connectProviderSession({
198
+ provider: session.provider,
199
+ joinUrl: session.joinUrl,
200
+ overrides: opts?.overrides,
201
+ });
202
+ }
203
+ /**
204
+ * Connect to a provider-managed voice session via a direct join URL.
205
+ *
206
+ * Unlike `start()`, this method does NOT call POST /interactions/websocket
207
+ * to create a room. Instead it instantiates the appropriate provider
208
+ * transport (currently only 'vapi' is supported) and connects directly
209
+ * to the provider's session URL (e.g. a VAPI Daily.co web-call URL).
210
+ *
211
+ * The public event API (ready, closed, transcript, speech-start, etc.)
212
+ * is identical to the room-based flow.
213
+ *
214
+ * @param params.provider - Provider identifier. Currently supports 'vapi'.
215
+ * @param params.joinUrl - The provider's session join URL.
216
+ * @param params.overrides - Optional assistant overrides forwarded to the transport.
217
+ *
218
+ * @throws Error if provider is unsupported or if already in an active call.
219
+ */
220
+ connectProviderSession({ provider, joinUrl, overrides, }) {
221
+ if (this.state !== types_1.AudialState.Idle) {
222
+ throw new Error('Audial: call already active');
223
+ }
224
+ if (provider !== 'vapi') {
225
+ throw new Error(`Audial: unsupported provider "${provider}" — only 'vapi' is supported`);
226
+ }
227
+ if (!joinUrl) {
228
+ throw new Error('Audial: joinUrl is required for provider sessions');
229
+ }
230
+ this.state = types_1.AudialState.Connecting;
231
+ this.currentNodeId = null;
232
+ // Connection timeout — mirrors the room-based flow
233
+ if (this.connectionTimeoutTimer) {
234
+ clearTimeout(this.connectionTimeoutTimer);
235
+ }
236
+ const timeoutMs = this.config.connectionTimeoutMs ?? 30000;
237
+ this.connectionTimeoutTimer = setTimeout(() => {
238
+ this.connectionTimeoutTimer = null;
239
+ this.emit('error', new Error('Connection timeout - provider session did not respond'));
240
+ this.stop(4000, 'connection-timeout');
241
+ }, timeoutMs);
242
+ if (this.config.debug) {
243
+ console.debug('🎤 Connecting provider session:', { provider, joinUrl, timeoutMs });
244
+ }
245
+ this._connectVapiProviderSession(joinUrl, overrides);
246
+ }
247
+ /**
248
+ * Internal async implementation of connectProviderSession.
249
+ * Separated so the public method can remain synchronous (matching start()).
250
+ */
251
+ async _connectVapiProviderSession(joinUrl, overrides) {
252
+ try {
253
+ const callbacks = this.createTransportCallbacks();
254
+ this.transport = new vapi_1.VapiTransport(callbacks);
255
+ this.activeTransportType = 'vapi';
256
+ if (this.config.debug) {
257
+ console.debug('🚀 Using vapi transport for provider session');
258
+ }
259
+ await this.transport.connect({
260
+ joinUrl,
261
+ overrides,
262
+ debug: this.config.debug,
263
+ // roomState is intentionally omitted — provider sessions bypass room creation
264
+ });
265
+ }
266
+ catch (error) {
267
+ this.handleConnectionError(error instanceof Error ? error : new Error(String(error)));
268
+ }
269
+ }
270
+ /**
271
+ * Stop the current call
272
+ */
273
+ stop(code = 1000, reason = "client-stop") {
274
+ if (this.config.debug) {
275
+ console.debug("🛑 Stopping call:", { code, reason });
276
+ }
277
+ // Clear all timers
278
+ if (this.reconnectTimer) {
279
+ clearTimeout(this.reconnectTimer);
280
+ this.reconnectTimer = null;
281
+ }
282
+ if (this.connectionTimeoutTimer) {
283
+ clearTimeout(this.connectionTimeoutTimer);
284
+ this.connectionTimeoutTimer = null;
285
+ }
286
+ // Stop ends this call intent — clear the Idempotency-Key so the next
287
+ // start() mints a fresh one (new intent → new interaction).
288
+ this.clearIdempotencyKey();
289
+ this.roomState = null;
290
+ if (this.transport) {
291
+ this.transport.disconnect(code, reason);
292
+ }
293
+ else {
294
+ this.emit("closed", code, reason);
295
+ this.cleanup();
296
+ }
297
+ }
298
+ /**
299
+ * Send a message to the server
300
+ */
301
+ send(message, shouldLog = true) {
302
+ if (!this.transport || !this.transport.isConnected()) {
303
+ if (this.config.debug) {
304
+ console.warn("⚠️ Cannot send message (type: " + message.type + "): not connected");
305
+ }
306
+ return;
307
+ }
308
+ // Block unsupported message types
309
+ if (message.type === "assistant-overrides" ||
310
+ message.type === "set-context" ||
311
+ message.type === "clear-context") {
312
+ if (this.config.debug) {
313
+ console.warn(`⚠️ Message type '${message.type}' not supported by current backend`);
314
+ }
315
+ return;
316
+ }
317
+ try {
318
+ this.transport.sendMessage(message);
319
+ if (this.config.debug && shouldLog) {
320
+ console.debug("📤 Sent message:", message);
321
+ }
322
+ }
323
+ catch (error) {
324
+ if (this.config.debug) {
325
+ console.error('❌ Error sending message:', error);
326
+ }
327
+ this.emit('error', error instanceof Error ? error : new Error(String(error)));
328
+ }
329
+ }
330
+ // ── Audio controls (delegated to transport) ────────────────────────────
331
+ deafen() {
332
+ if (this.audioDeafened)
333
+ return;
334
+ this.audioDeafened = true;
335
+ this.transport?.deafen();
336
+ this.emit("audio-deafened", true);
337
+ }
338
+ undeafen() {
339
+ if (!this.audioDeafened)
340
+ return;
341
+ this.audioDeafened = false;
342
+ this.transport?.undeafen();
343
+ this.emit("audio-undeafened", false);
344
+ }
345
+ toggleDeafen() {
346
+ this.audioDeafened ? this.undeafen() : this.deafen();
347
+ }
348
+ setVolume(volume) {
349
+ this.transport?.setVolume(volume);
350
+ }
351
+ muteMic() {
352
+ if (this.micMuted)
353
+ return;
354
+ this.micMuted = true;
355
+ this.emit("mic-muted");
356
+ this.transport?.muteMic();
357
+ }
358
+ async unmuteMic() {
359
+ if (!this.micMuted)
360
+ return;
361
+ this.micMuted = false;
362
+ this.emit("mic-unmuted");
363
+ try {
364
+ await this.transport?.unmuteMic();
365
+ }
366
+ catch (error) {
367
+ this.emit("error", error instanceof Error ? error : new Error(String(error)));
368
+ }
369
+ }
370
+ toggleMicMute() {
371
+ this.micMuted ? this.unmuteMic() : this.muteMic();
372
+ }
373
+ // ── Connection management ──────────────────────────────────────────────
374
+ /**
375
+ * Create room and connect via the appropriate transport.
376
+ */
377
+ async createRoomAndConnect(payload, overrides) {
378
+ try {
379
+ if (this.config.debug) {
380
+ console.debug('🏠 Creating websocket room...');
381
+ }
382
+ const room = await this.createRoom(payload);
383
+ this.roomState = {
384
+ roomId: room.id,
385
+ // The WebRTC /offer relay resolves the room by interactionId, not the
386
+ // room's own id. Prefer interactionId; fall back to id for safety.
387
+ interactionId: room.interactionId ?? room.id,
388
+ status: room.status,
389
+ websocketUrl: room.websocketUrl,
390
+ connectionParameters: room.connectionParameters,
391
+ };
392
+ if (room.status === types_1.WebsocketRoomStatus.Failed || room.status === types_1.WebsocketRoomStatus.Expired) {
393
+ throw new Error(`Room ${room.status}: ${room.id}`);
394
+ }
395
+ if (!room.websocketUrl) {
396
+ throw new Error('Room created but no websocket URL available');
397
+ }
398
+ this.state = types_1.AudialState.Connecting;
399
+ // Determine and create transport
400
+ const transportType = this.resolveTransportType();
401
+ const callbacks = this.createTransportCallbacks();
402
+ if (transportType === 'webrtc') {
403
+ const signalingUrl = this.resolveSignalingUrl();
404
+ this.transport = new webrtc_1.WebRTCTransport(callbacks, signalingUrl);
405
+ this.activeTransportType = 'webrtc';
406
+ }
407
+ else {
408
+ this.transport = new websocket_1.WebSocketTransport(callbacks);
409
+ this.activeTransportType = 'websocket';
410
+ }
411
+ if (this.config.debug) {
412
+ console.debug(`🚀 Using ${this.activeTransportType} transport`);
413
+ }
414
+ // Connect transport
415
+ try {
416
+ await this.transport.connect({
417
+ roomState: this.roomState,
418
+ overrides,
419
+ debug: this.config.debug,
420
+ });
421
+ }
422
+ catch (transportError) {
423
+ // WebRTC failed — try falling back to WebSocket
424
+ if (this.activeTransportType === 'webrtc') {
425
+ if (this.config.debug) {
426
+ console.warn('⚠️ WebRTC failed, falling back to WebSocket:', transportError);
427
+ }
428
+ this.transport.destroy();
429
+ this.transport = new websocket_1.WebSocketTransport(callbacks);
430
+ this.activeTransportType = 'websocket';
431
+ await this.transport.connect({
432
+ roomState: this.roomState,
433
+ overrides,
434
+ debug: this.config.debug,
435
+ });
436
+ }
437
+ else {
438
+ throw transportError;
439
+ }
440
+ }
441
+ }
442
+ catch (error) {
443
+ this.roomState = null;
444
+ this.handleConnectionError(error instanceof Error ? error : new Error(String(error)));
445
+ }
446
+ }
447
+ /**
448
+ * Determine which transport to use based on config + browser capabilities.
449
+ *
450
+ * Default is 'auto': uses WebRTC if browser supports RTCPeerConnection,
451
+ * otherwise falls back to WebSocket. Signaling goes through the platform's
452
+ * /voice-ws/ proxy — no separate bot URL needed.
453
+ */
454
+ resolveTransportType() {
455
+ const requested = this.config.transport;
456
+ if (requested === 'websocket')
457
+ return 'websocket';
458
+ if (requested === 'webrtc')
459
+ return 'webrtc';
460
+ // Auto-detect: use WebRTC if browser supports it
461
+ if (typeof RTCPeerConnection !== 'undefined') {
462
+ return 'webrtc';
463
+ }
464
+ return 'websocket';
465
+ }
466
+ /**
467
+ * Get the WebRTC signaling relay URL.
468
+ *
469
+ * Points to the platform's bot provider endpoint which relays signaling
470
+ * to the voice-bot. Same URL in dev, staging, prod — no separate bot URL.
471
+ *
472
+ * POST {url}/offer/{interactionId} → platform → voice-bot /offer
473
+ * POST {url}/candidates → platform → voice-bot /candidates
474
+ */
475
+ resolveSignalingUrl() {
476
+ // Explicit override (testing, direct bot access)
477
+ const explicit = this.config.webrtcSignalingUrl;
478
+ if (explicit)
479
+ return explicit;
480
+ // Default: platform API signaling relay
481
+ return `${this.getHttpUrl()}/api/v1/bot/call/providers/webrtc`;
482
+ }
483
+ /**
484
+ * Create the callbacks object that wires transport events to Audial.
485
+ */
486
+ createTransportCallbacks() {
487
+ return {
488
+ onReady: () => {
489
+ // Cancel connection timeout
490
+ if (this.connectionTimeoutTimer) {
491
+ clearTimeout(this.connectionTimeoutTimer);
492
+ this.connectionTimeoutTimer = null;
493
+ }
494
+ // The call successfully connected — this intent is now
495
+ // "consumed." Any subsequent start() should create a new
496
+ // interaction, so clear the Idempotency-Key.
497
+ this.clearIdempotencyKey();
498
+ this.state = types_1.AudialState.Active;
499
+ this.emit('ready');
500
+ },
501
+ onMessage: (msg) => {
502
+ this.handleTransportMessage(msg);
503
+ },
504
+ onError: (error) => {
505
+ if (this.config.debug) {
506
+ console.error('❌ Transport error:', error);
507
+ }
508
+ this.emit('error', error);
509
+ },
510
+ onClose: (code, reason) => {
511
+ if (this.config.debug) {
512
+ console.debug('🔌 Transport closed:', { code, reason });
513
+ }
514
+ this.emit('closed', code, reason);
515
+ this.cleanup();
516
+ },
517
+ };
518
+ }
519
+ /**
520
+ * Handle messages from the transport. All event routing happens here.
521
+ * Audio handling is done internally by the transport — we only get
522
+ * the message for event emission purposes.
523
+ */
524
+ handleTransportMessage(msg) {
525
+ // Emit generic 'message' event for all types (hook uses this for config_loaded)
526
+ this.emit('message', msg);
527
+ switch (msg.type) {
528
+ case 'ready': {
529
+ // WebSocket sends {type:"ready"} explicitly — trigger ready logic here.
530
+ // WebRTC fires onReady from dc.onopen (no ready message).
531
+ // Safe to call multiple times — timeout clear is idempotent.
532
+ if (this.connectionTimeoutTimer) {
533
+ clearTimeout(this.connectionTimeoutTimer);
534
+ this.connectionTimeoutTimer = null;
535
+ }
536
+ if (this.state !== types_1.AudialState.Active) {
537
+ this.state = types_1.AudialState.Active;
538
+ this.emit('ready');
539
+ }
540
+ if (this.lastOverrides && this.config.debug) {
541
+ console.warn('⚠️ Assistant overrides not supported by current backend');
542
+ }
543
+ break;
544
+ }
545
+ case 'speech-start': {
546
+ // Event name matches the declared AudialEventMap key (kebab-case).
547
+ // Previously emitted 'speechStart' (camelCase) via `as any`, which
548
+ // meant use-voice subscribers (which listen to 'speech-start') never
549
+ // fired — isAgentSpeaking stayed false for the entire call. Fixed
550
+ // while adding hook tests that caught the mismatch.
551
+ this.emit('speech-start');
552
+ break;
553
+ }
554
+ case 'speech-end': {
555
+ this.emit('speech-end');
556
+ break;
557
+ }
558
+ case 'call-end': {
559
+ if (this.config.debug) {
560
+ console.debug("Message call-end received from server");
561
+ }
562
+ this.emit('closed', 1000, 'server-end');
563
+ this.stop();
564
+ break;
565
+ }
566
+ case 'error': {
567
+ this.emit('error', new Error(msg?.message));
568
+ break;
569
+ }
570
+ case 'transcript': {
571
+ this.emit('transcript', msg);
572
+ break;
573
+ }
574
+ case 'audio': {
575
+ // Audio is handled internally by WebSocketTransport — no action needed
576
+ break;
577
+ }
578
+ case 'node-update': {
579
+ if (this.config.debug) {
580
+ console.debug("[Audial] Node update:", msg.node_id);
581
+ }
582
+ this.currentNodeId = msg.node_id;
583
+ this.emit('node-update', this.currentNodeId);
584
+ break;
585
+ }
586
+ case 'interruption': {
587
+ if (this.config.debug) {
588
+ console.debug("[Audial] Interruption received - clearing audio buffer");
589
+ }
590
+ this.transport?.clearAudioBuffer();
591
+ this.emit('interruption');
592
+ break;
593
+ }
594
+ }
595
+ }
596
+ // ── Connection error + retry ──────────────────────────────────────────
597
+ handleConnectionError(error) {
598
+ if (this.config.debug) {
599
+ console.error('❌ Connection error:', error);
600
+ }
601
+ this.state = types_1.AudialState.Idle;
602
+ this.emit('error', error);
603
+ if (this.config.autoReconnect && this.retryCount < this.retryPolicy.maxRetries) {
604
+ this.retryCount++;
605
+ const delay = this.retryPolicy.backoffMs * Math.pow(2, this.retryCount - 1);
606
+ if (this.config.debug) {
607
+ console.debug(`🔄 Retrying connection in ${delay}ms (attempt ${this.retryCount}/${this.retryPolicy.maxRetries})`);
608
+ }
609
+ // If we already have a room from the initial createRoom(), reuse it on
610
+ // retry — only the TRANSPORT connection failed, not the room. Calling
611
+ // start() again would POST /interactions/websocket and leak a new
612
+ // "queued" interaction per retry. This was the zombie-interaction bug.
613
+ const roomToReuse = this.roomState;
614
+ this.reconnectTimer = setTimeout(() => {
615
+ if (roomToReuse) {
616
+ this.retryTransportOnly(roomToReuse);
617
+ }
618
+ else {
619
+ // No room yet (createRoom itself failed) — full start is safe.
620
+ this.start(this.lastCallPayload, this.lastOverrides);
621
+ }
622
+ }, delay);
623
+ }
624
+ else {
625
+ this.cleanup();
626
+ }
627
+ }
628
+ /**
629
+ * Reconnect the transport layer without re-creating the platform room.
630
+ * Used by the retry path to avoid leaking a new "queued" interaction per
631
+ * attempt. The existing roomState is preserved; only a fresh transport
632
+ * is constructed and connected to the same room.
633
+ */
634
+ async retryTransportOnly(roomState) {
635
+ try {
636
+ if (this.transport) {
637
+ try {
638
+ this.transport.destroy();
639
+ }
640
+ catch { /* ignore */ }
641
+ this.transport = null;
642
+ }
643
+ this.state = types_1.AudialState.Connecting;
644
+ this.roomState = roomState;
645
+ const transportType = this.resolveTransportType();
646
+ const callbacks = this.createTransportCallbacks();
647
+ if (transportType === 'webrtc') {
648
+ const signalingUrl = this.resolveSignalingUrl();
649
+ this.transport = new webrtc_1.WebRTCTransport(callbacks, signalingUrl);
650
+ this.activeTransportType = 'webrtc';
651
+ }
652
+ else {
653
+ this.transport = new websocket_1.WebSocketTransport(callbacks);
654
+ this.activeTransportType = 'websocket';
655
+ }
656
+ await this.transport.connect({
657
+ roomState,
658
+ overrides: this.lastOverrides,
659
+ debug: this.config.debug,
660
+ });
661
+ }
662
+ catch (error) {
663
+ this.handleConnectionError(error instanceof Error ? error : new Error(String(error)));
664
+ }
665
+ }
666
+ reconnect() {
667
+ if (this.lastCallPayload) {
668
+ if (this.config.debug) {
669
+ console.debug('🔄 Reconnecting...');
670
+ }
671
+ this.stop();
672
+ setTimeout(() => {
673
+ this.start(this.lastCallPayload, this.lastOverrides);
674
+ }, 100);
675
+ }
676
+ else {
677
+ throw new Error('Audial: no previous call to reconnect to');
678
+ }
679
+ }
680
+ // ── Connection status ─────────────────────────────────────────────────
681
+ getConnectionStatus() {
682
+ const baseStatus = {
683
+ connected: this.transport?.isConnected() ?? false,
684
+ state: this.state,
685
+ readyState: this.transport?.isConnected() ? 1 : null,
686
+ retryCount: this.retryCount,
687
+ };
688
+ if (this.roomState) {
689
+ baseStatus.roomState = this.roomState;
690
+ }
691
+ return baseStatus;
692
+ }
693
+ // ── Cleanup ───────────────────────────────────────────────────────────
694
+ cleanup() {
695
+ if (this.reconnectTimer) {
696
+ clearTimeout(this.reconnectTimer);
697
+ this.reconnectTimer = null;
698
+ }
699
+ if (this.connectionTimeoutTimer) {
700
+ clearTimeout(this.connectionTimeoutTimer);
701
+ this.connectionTimeoutTimer = null;
702
+ }
703
+ this.roomState = null;
704
+ if (this.transport) {
705
+ this.transport.destroy();
706
+ this.transport = null;
707
+ }
708
+ this.activeTransportType = null;
709
+ this.state = types_1.AudialState.Idle;
710
+ this.removeAllListeners();
711
+ }
712
+ // ── Room creation (unchanged) ─────────────────────────────────────────
713
+ getHttpUrl() {
714
+ return this.config.baseUrl.startsWith('http')
715
+ ? this.config.baseUrl
716
+ : this.config.baseUrl.includes('localhost')
717
+ ? `http://${this.config.baseUrl}`
718
+ : `https://${this.config.baseUrl}`;
719
+ }
720
+ getBaseUrl() {
721
+ if (typeof window !== 'undefined' && window.__AUDIAL_CONFIG__) {
722
+ return window.__AUDIAL_CONFIG__.baseUrl;
723
+ }
724
+ if (typeof process !== 'undefined') {
725
+ const env = process.env;
726
+ if (env.AUDIAL_BASE_URL)
727
+ return env.AUDIAL_BASE_URL;
728
+ if (env.NODE_ENV === 'production' && env.AUDIAL_PROD_URL)
729
+ return env.AUDIAL_PROD_URL;
730
+ if (env.NODE_ENV === 'staging' && env.AUDIAL_STAGING_URL)
731
+ return env.AUDIAL_STAGING_URL;
732
+ if (env.NODE_ENV === 'development' && env.AUDIAL_DEV_URL)
733
+ return env.AUDIAL_DEV_URL;
734
+ }
735
+ if (typeof window !== 'undefined' && window.location && window.location.hostname) {
736
+ const hostname = window.location.hostname;
737
+ if (hostname === 'localhost' || hostname === '127.0.0.1') {
738
+ return 'localhost:8000';
739
+ }
740
+ if (hostname.includes('staging')) {
741
+ return 'lavoz-api-staging.fly.dev';
742
+ }
743
+ if (hostname.includes('production') || hostname.includes('audial.co') || hostname.includes('audial.ai')) {
744
+ return 'lavoz-api.fly.dev';
745
+ }
746
+ }
747
+ return 'lavoz-api.fly.dev';
748
+ }
749
+ async createRoom(payload) {
750
+ const baseUrl = `${this.getHttpUrl()}/api/v1/interactions/websocket`;
751
+ const fullPayload = {
752
+ type: 'voice',
753
+ provider: 'websocket',
754
+ direction: 'web',
755
+ agentId: payload.agentId,
756
+ agentSettings: payload.agentSettings
757
+ ? this.expandAgentSettings(payload.agentSettings)
758
+ : undefined,
759
+ languageCode: payload.languageCode,
760
+ customerId: payload.customerId,
761
+ userHash: payload.userHash,
762
+ customer: payload.customer,
763
+ };
764
+ const url = new URL(baseUrl);
765
+ this.applyPublicKeyAuth(url);
766
+ if (payload.agentId) {
767
+ url.searchParams.set('agentId', payload.agentId);
768
+ }
769
+ try {
770
+ const response = await fetch(url.toString(), {
771
+ method: 'POST',
772
+ headers: {
773
+ 'Content-Type': 'application/json',
774
+ ...this.getAuthHeaders(),
775
+ // IETF Idempotency-Key (draft-ietf-httpapi-idempotency-key-header-07).
776
+ // Lets the server recognize retries of the same call intent and
777
+ // return the existing interaction instead of leaking a new one.
778
+ ...(this.idempotencyKey && { 'Idempotency-Key': this.idempotencyKey }),
779
+ },
780
+ body: JSON.stringify(fullPayload),
781
+ });
782
+ if (!response.ok) {
783
+ let errorMessage;
784
+ try {
785
+ const errorText = await response.text();
786
+ errorMessage = errorText || response.statusText || 'Unknown error';
787
+ }
788
+ catch {
789
+ errorMessage = response.statusText || 'Failed to read error response';
790
+ }
791
+ throw new Error(`Room creation failed [${response.status}]: ${errorMessage}`);
792
+ }
793
+ try {
794
+ const result = await response.json();
795
+ const room = result.data?.data || result.data || result;
796
+ if (room.expiresAt instanceof Date)
797
+ room.expiresAt = room.expiresAt.toISOString();
798
+ if (room.createdAt instanceof Date)
799
+ room.createdAt = room.createdAt.toISOString();
800
+ if (room.updatedAt instanceof Date)
801
+ room.updatedAt = room.updatedAt.toISOString();
802
+ return room;
803
+ }
804
+ catch (parseError) {
805
+ throw new Error(`Room creation succeeded but response is not valid JSON: ${parseError instanceof Error ? parseError.message : 'Parse error'}`);
806
+ }
807
+ }
808
+ catch (error) {
809
+ if (error instanceof Error) {
810
+ if (error.message.includes('Room creation failed'))
811
+ throw error;
812
+ throw new Error(`Network error during room creation: ${error.message}`);
813
+ }
814
+ throw new Error(`Unexpected error during room creation: ${String(error)}`);
815
+ }
816
+ }
817
+ // ── Idempotency-Key helpers ───────────────────────────────────────────
818
+ /**
819
+ * Mint a new Idempotency-Key, or restore one from sessionStorage if a
820
+ * recent key exists (within IDEMPOTENCY_MAX_AGE_MS). Restore path
821
+ * handles the "user refreshed the tab during a pending connect" case:
822
+ * the original interaction is still queued on the server, and reusing
823
+ * the key gets us back the same interaction instead of leaking a new
824
+ * one.
825
+ */
826
+ restoreOrMintIdempotencyKey() {
827
+ try {
828
+ if (typeof sessionStorage !== 'undefined') {
829
+ const cached = sessionStorage.getItem(VoiceClient.IDEMPOTENCY_STORAGE_KEY);
830
+ const at = Number(sessionStorage.getItem(VoiceClient.IDEMPOTENCY_STORAGE_AT) || 0);
831
+ if (cached && Date.now() - at < VoiceClient.IDEMPOTENCY_MAX_AGE_MS) {
832
+ if (this.config.debug) {
833
+ console.debug('🔑 Restored Idempotency-Key from sessionStorage:', cached.slice(0, 8) + '…');
834
+ }
835
+ return cached;
836
+ }
837
+ }
838
+ }
839
+ catch { /* private-mode / SSR — fall through to minting */ }
840
+ const fresh = this.mintUuid();
841
+ try {
842
+ if (typeof sessionStorage !== 'undefined') {
843
+ sessionStorage.setItem(VoiceClient.IDEMPOTENCY_STORAGE_KEY, fresh);
844
+ sessionStorage.setItem(VoiceClient.IDEMPOTENCY_STORAGE_AT, String(Date.now()));
845
+ }
846
+ }
847
+ catch { /* ignore */ }
848
+ if (this.config.debug) {
849
+ console.debug('🔑 Minted Idempotency-Key:', fresh.slice(0, 8) + '…');
850
+ }
851
+ return fresh;
852
+ }
853
+ clearIdempotencyKey() {
854
+ this.idempotencyKey = null;
855
+ try {
856
+ if (typeof sessionStorage !== 'undefined') {
857
+ sessionStorage.removeItem(VoiceClient.IDEMPOTENCY_STORAGE_KEY);
858
+ sessionStorage.removeItem(VoiceClient.IDEMPOTENCY_STORAGE_AT);
859
+ }
860
+ }
861
+ catch { /* ignore */ }
862
+ }
863
+ /**
864
+ * Generate a UUID v4. Prefers crypto.randomUUID() (modern browsers +
865
+ * Node 14.17+); falls back to a getRandomValues-backed implementation,
866
+ * and finally Math.random if neither is available (SSR test envs).
867
+ */
868
+ mintUuid() {
869
+ try {
870
+ if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
871
+ return crypto.randomUUID();
872
+ }
873
+ if (typeof crypto !== 'undefined' && typeof crypto.getRandomValues === 'function') {
874
+ const bytes = new Uint8Array(16);
875
+ crypto.getRandomValues(bytes);
876
+ bytes[6] = (bytes[6] & 0x0f) | 0x40; // v4
877
+ bytes[8] = (bytes[8] & 0x3f) | 0x80; // RFC 4122 variant
878
+ const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
879
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
880
+ }
881
+ }
882
+ catch { /* fall through */ }
883
+ // Last resort — low entropy but still unique enough to avoid self-collision
884
+ return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}-${Math.random().toString(36).slice(2, 10)}`;
885
+ }
886
+ expandAgentSettings(simple) {
887
+ return {
888
+ name: simple.name || 'WebSDK Agent',
889
+ languageCode: simple.languageCode || 'en',
890
+ prompt: simple.prompt || 'You are a helpful assistant.',
891
+ model: {
892
+ provider: simple.model?.provider || (typeof simple.model === 'string' ? 'openai' : 'openai'),
893
+ model: simple.model?.model || (typeof simple.model === 'string' ? simple.model : 'gpt-4'),
894
+ temperature: simple.model?.temperature ?? 0.7,
895
+ storeCompletions: simple.model?.storeCompletions ?? true,
896
+ },
897
+ voice: {
898
+ provider: 'elevenlabs',
899
+ voiceId: simple.voiceId || '21m00Tcm4TlvDq8ikWAM',
900
+ stability: 0.6,
901
+ similarityBoost: 0.9,
902
+ },
903
+ transcriber: {
904
+ provider: 'deepgram',
905
+ model: 'nova-2',
906
+ },
907
+ tools: simple.tools || [],
908
+ routings: simple.routings || [],
909
+ maxDurationSeconds: simple.maxDurationSeconds || 600,
910
+ checkHumanPresence: simple.checkHumanPresence ?? true,
911
+ checkHumanPresenceCount: simple.checkHumanPresenceCount || 2,
912
+ allowedIdleTime: simple.allowedIdleTime || 30,
913
+ allowInterruptions: simple.allowInterruptions ?? true,
914
+ interruptionSensitivity: simple.interruptionSensitivity || 'high',
915
+ enableCutoffResponses: simple.enableCutoffResponses ?? false,
916
+ cutoffResponses: simple.cutoffResponses || [],
917
+ };
918
+ }
919
+ // ═══════════════════════════════════════════════════════════════════════
920
+ // CHAT API METHODS (unchanged — purely HTTP, no transport dependency)
921
+ // ═══════════════════════════════════════════════════════════════════════
922
+ async createChatSession(agentId, options = {}) {
923
+ if (this.chatState !== types_1.ChatState.Idle && this.chatState !== types_1.ChatState.Ended) {
924
+ throw new Error('Chat session already active. End current session first.');
925
+ }
926
+ this.setChatState(types_1.ChatState.Creating);
927
+ const url = new URL(`${this.getHttpUrl()}/api/v1/interactions/chat`);
928
+ this.applyPublicKeyAuth(url);
929
+ url.searchParams.set('agentId', agentId);
930
+ try {
931
+ const response = await fetch(url.toString(), {
932
+ method: 'POST',
933
+ headers: { 'Content-Type': 'application/json', ...this.getAuthHeaders() },
934
+ body: JSON.stringify(options),
935
+ });
936
+ if (!response.ok) {
937
+ const errorText = await response.text().catch(() => 'Unknown error');
938
+ throw new Error(`Failed to create chat session [${response.status}]: ${errorText}`);
939
+ }
940
+ const result = await response.json();
941
+ const sessionData = result.data?.data || result.data || result;
942
+ this.chatSession = {
943
+ sessionId: sessionData.sessionId,
944
+ interactionId: sessionData.interactionId || sessionData.sessionId,
945
+ agentId: sessionData.agentId,
946
+ agentName: sessionData.agentName,
947
+ model: sessionData.model,
948
+ messages: [],
949
+ createdAt: sessionData.createdAt,
950
+ };
951
+ this.chatMessages = [];
952
+ this.setChatState(types_1.ChatState.Active);
953
+ this.emit('chat-session-created', sessionData);
954
+ if (this.config.debug) {
955
+ console.debug('💬 Chat session created:', sessionData);
956
+ }
957
+ return sessionData;
958
+ }
959
+ catch (error) {
960
+ this.setChatState(types_1.ChatState.Error);
961
+ this.emit('error', error instanceof Error ? error : new Error(String(error)));
962
+ throw error;
963
+ }
964
+ }
965
+ async sendChatMessage(message) {
966
+ if (!this.chatSession) {
967
+ throw new Error('No active chat session. Create a session first.');
968
+ }
969
+ if (this.chatState === types_1.ChatState.Sending) {
970
+ throw new Error('Message already being sent. Wait for response.');
971
+ }
972
+ if (!message.trim()) {
973
+ throw new Error('Message cannot be empty.');
974
+ }
975
+ const previousState = this.chatState;
976
+ this.setChatState(types_1.ChatState.Sending);
977
+ const userMessage = {
978
+ role: 'user',
979
+ content: message,
980
+ timestamp: new Date().toISOString(),
981
+ };
982
+ this.chatMessages.push(userMessage);
983
+ if (this.chatSession) {
984
+ this.chatSession.messages.push(userMessage);
985
+ }
986
+ const url = new URL(`${this.getHttpUrl()}/api/v1/interactions/${this.chatSession.interactionId}/message`);
987
+ this.applyPublicKeyAuth(url);
988
+ try {
989
+ const response = await fetch(url.toString(), {
990
+ method: 'POST',
991
+ headers: { 'Content-Type': 'application/json', ...this.getAuthHeaders() },
992
+ body: JSON.stringify({ message }),
993
+ });
994
+ if (!response.ok) {
995
+ const errorText = await response.text().catch(() => 'Unknown error');
996
+ throw new Error(`Failed to send message [${response.status}]: ${errorText}`);
997
+ }
998
+ const result = await response.json();
999
+ const messageData = result.data?.data || result.data || result;
1000
+ const assistantMessage = {
1001
+ role: 'assistant',
1002
+ content: messageData.message,
1003
+ timestamp: new Date().toISOString(),
1004
+ };
1005
+ this.chatMessages.push(assistantMessage);
1006
+ if (this.chatSession) {
1007
+ this.chatSession.messages.push(assistantMessage);
1008
+ }
1009
+ this.setChatState(types_1.ChatState.Active);
1010
+ this.emit('chat-message', messageData);
1011
+ if (this.config.debug) {
1012
+ console.debug('💬 Chat message sent:', { sent: message, received: messageData.message });
1013
+ }
1014
+ return messageData;
1015
+ }
1016
+ catch (error) {
1017
+ this.setChatState(previousState);
1018
+ this.emit('error', error instanceof Error ? error : new Error(String(error)));
1019
+ throw error;
1020
+ }
1021
+ }
1022
+ async streamChatMessage(message, onChunk) {
1023
+ if (!this.chatSession) {
1024
+ throw new Error('No active chat session. Create a session first.');
1025
+ }
1026
+ if (this.chatState === types_1.ChatState.Sending) {
1027
+ throw new Error('Message already being sent. Wait for response.');
1028
+ }
1029
+ if (!message.trim()) {
1030
+ throw new Error('Message cannot be empty.');
1031
+ }
1032
+ const previousState = this.chatState;
1033
+ this.setChatState(types_1.ChatState.Sending);
1034
+ const userMessage = {
1035
+ role: 'user',
1036
+ content: message,
1037
+ timestamp: new Date().toISOString(),
1038
+ };
1039
+ this.chatMessages.push(userMessage);
1040
+ if (this.chatSession) {
1041
+ this.chatSession.messages.push(userMessage);
1042
+ }
1043
+ const url = new URL(`${this.getHttpUrl()}/api/v1/interactions/${this.chatSession.interactionId}/message`);
1044
+ this.applyPublicKeyAuth(url);
1045
+ url.searchParams.set('stream', 'true');
1046
+ try {
1047
+ const response = await fetch(url.toString(), {
1048
+ method: 'POST',
1049
+ headers: { 'Content-Type': 'application/json', ...this.getAuthHeaders() },
1050
+ body: JSON.stringify({ message }),
1051
+ });
1052
+ if (!response.ok) {
1053
+ const errorText = await response.text().catch(() => 'Unknown error');
1054
+ throw new Error(`Failed to stream message [${response.status}]: ${errorText}`);
1055
+ }
1056
+ const reader = response.body?.getReader();
1057
+ if (!reader) {
1058
+ throw new Error('Response body is not readable (streaming not supported by environment)');
1059
+ }
1060
+ const decoder = new TextDecoder();
1061
+ let fullText = '';
1062
+ while (true) {
1063
+ const { done, value } = await reader.read();
1064
+ if (done)
1065
+ break;
1066
+ const chunk = decoder.decode(value, { stream: true });
1067
+ fullText += chunk;
1068
+ onChunk(chunk);
1069
+ }
1070
+ const sessionId = this.chatSession?.sessionId || '';
1071
+ const messageData = { sessionId, message: fullText };
1072
+ const assistantMessage = {
1073
+ role: 'assistant',
1074
+ content: fullText,
1075
+ timestamp: new Date().toISOString(),
1076
+ };
1077
+ this.chatMessages.push(assistantMessage);
1078
+ if (this.chatSession) {
1079
+ this.chatSession.messages.push(assistantMessage);
1080
+ }
1081
+ this.setChatState(types_1.ChatState.Active);
1082
+ this.emit('chat-message', messageData);
1083
+ if (this.config.debug) {
1084
+ console.debug('💬 Chat message streamed:', { sent: message, received: fullText.substring(0, 100) + (fullText.length > 100 ? '...' : '') });
1085
+ }
1086
+ return messageData;
1087
+ }
1088
+ catch (error) {
1089
+ this.setChatState(previousState);
1090
+ this.emit('error', error instanceof Error ? error : new Error(String(error)));
1091
+ throw error;
1092
+ }
1093
+ }
1094
+ async getChatHistory() {
1095
+ if (!this.chatSession) {
1096
+ throw new Error('No active chat session.');
1097
+ }
1098
+ const url = new URL(`${this.getHttpUrl()}/api/v1/interactions/${this.chatSession.interactionId}/messages`);
1099
+ this.applyPublicKeyAuth(url);
1100
+ try {
1101
+ const response = await fetch(url.toString(), {
1102
+ headers: { ...this.getAuthHeaders() },
1103
+ });
1104
+ if (!response.ok) {
1105
+ const errorText = await response.text().catch(() => 'Unknown error');
1106
+ throw new Error(`Failed to get chat history [${response.status}]: ${errorText}`);
1107
+ }
1108
+ const result = await response.json();
1109
+ const historyData = result.data?.data || result.data || result;
1110
+ this.chatMessages = historyData.messages;
1111
+ if (this.chatSession) {
1112
+ this.chatSession.messages = historyData.messages;
1113
+ }
1114
+ if (this.config.debug) {
1115
+ console.debug('💬 Chat history retrieved:', historyData.messages.length, 'messages');
1116
+ }
1117
+ return historyData;
1118
+ }
1119
+ catch (error) {
1120
+ this.emit('error', error instanceof Error ? error : new Error(String(error)));
1121
+ throw error;
1122
+ }
1123
+ }
1124
+ async endChatSession() {
1125
+ if (!this.chatSession) {
1126
+ if (this.config.debug) {
1127
+ console.debug('💬 No active chat session to end');
1128
+ }
1129
+ return;
1130
+ }
1131
+ this.setChatState(types_1.ChatState.Ending);
1132
+ const interactionId = this.chatSession.interactionId;
1133
+ const url = new URL(`${this.getHttpUrl()}/api/v1/interactions/${interactionId}/end`);
1134
+ this.applyPublicKeyAuth(url);
1135
+ try {
1136
+ const response = await fetch(url.toString(), {
1137
+ method: 'POST',
1138
+ headers: { 'Content-Type': 'application/json', ...this.getAuthHeaders() },
1139
+ body: JSON.stringify({}),
1140
+ });
1141
+ if (!response.ok) {
1142
+ const errorText = await response.text().catch(() => 'Unknown error');
1143
+ throw new Error(`Failed to end chat session [${response.status}]: ${errorText}`);
1144
+ }
1145
+ this.chatSession = null;
1146
+ this.chatMessages = [];
1147
+ this.setChatState(types_1.ChatState.Ended);
1148
+ this.emit('chat-session-ended', interactionId);
1149
+ if (this.config.debug) {
1150
+ console.debug('💬 Chat session ended:', interactionId);
1151
+ }
1152
+ }
1153
+ catch (error) {
1154
+ this.setChatState(types_1.ChatState.Error);
1155
+ this.emit('error', error instanceof Error ? error : new Error(String(error)));
1156
+ throw error;
1157
+ }
1158
+ }
1159
+ getChatState() {
1160
+ return this.chatState;
1161
+ }
1162
+ getChatSession() {
1163
+ return this.chatSession;
1164
+ }
1165
+ getChatMessages() {
1166
+ return [...this.chatMessages];
1167
+ }
1168
+ hasChatSession() {
1169
+ return this.chatSession !== null && this.chatState === types_1.ChatState.Active;
1170
+ }
1171
+ setChatState(state) {
1172
+ if (this.chatState !== state) {
1173
+ this.chatState = state;
1174
+ this.emit('chat-state-change', state);
1175
+ }
1176
+ }
1177
+ resetChatState() {
1178
+ this.chatState = types_1.ChatState.Idle;
1179
+ this.chatSession = null;
1180
+ this.chatMessages = [];
1181
+ }
1182
+ }
1183
+ exports.VoiceClient = VoiceClient;
1184
+ VoiceClient.IDEMPOTENCY_STORAGE_KEY = 'chanl:voice-idem-key';
1185
+ VoiceClient.IDEMPOTENCY_STORAGE_AT = 'chanl:voice-idem-at';
1186
+ VoiceClient.IDEMPOTENCY_MAX_AGE_MS = 120000; // 2 min
1187
+ //# sourceMappingURL=voice-client.js.map