@convai/web-sdk 1.3.1-beta.0 → 1.4.0-beta.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 (38) hide show
  1. package/dist/core/AudioManager.d.ts +4 -0
  2. package/dist/core/AudioManager.d.ts.map +1 -1
  3. package/dist/core/AudioManager.js +26 -0
  4. package/dist/core/AudioManager.js.map +1 -1
  5. package/dist/core/BlendshapeQueue.d.ts +13 -1
  6. package/dist/core/BlendshapeQueue.d.ts.map +1 -1
  7. package/dist/core/BlendshapeQueue.js +56 -5
  8. package/dist/core/BlendshapeQueue.js.map +1 -1
  9. package/dist/core/ConvaiClient.d.ts +26 -0
  10. package/dist/core/ConvaiClient.d.ts.map +1 -1
  11. package/dist/core/ConvaiClient.js +287 -196
  12. package/dist/core/ConvaiClient.js.map +1 -1
  13. package/dist/core/MessageHandler.d.ts +6 -0
  14. package/dist/core/MessageHandler.d.ts.map +1 -1
  15. package/dist/core/MessageHandler.js +107 -27
  16. package/dist/core/MessageHandler.js.map +1 -1
  17. package/dist/core/WebSocketSession.d.ts +45 -0
  18. package/dist/core/WebSocketSession.d.ts.map +1 -0
  19. package/dist/core/WebSocketSession.js +133 -0
  20. package/dist/core/WebSocketSession.js.map +1 -0
  21. package/dist/core/types.d.ts +15 -51
  22. package/dist/core/types.d.ts.map +1 -1
  23. package/dist/react/components/ConvaiWidget.d.ts.map +1 -1
  24. package/dist/react/components/ConvaiWidget.js +26 -14
  25. package/dist/react/components/ConvaiWidget.js.map +1 -1
  26. package/dist/react/hooks/useConvaiClient.d.ts.map +1 -1
  27. package/dist/react/hooks/useConvaiClient.js +16 -0
  28. package/dist/react/hooks/useConvaiClient.js.map +1 -1
  29. package/dist/vanilla/AudioRenderer.d.ts +6 -0
  30. package/dist/vanilla/AudioRenderer.d.ts.map +1 -1
  31. package/dist/vanilla/AudioRenderer.js +21 -0
  32. package/dist/vanilla/AudioRenderer.js.map +1 -1
  33. package/dist/vanilla/ConvaiWidget.d.ts.map +1 -1
  34. package/dist/vanilla/ConvaiWidget.js +1 -0
  35. package/dist/vanilla/ConvaiWidget.js.map +1 -1
  36. package/dist/vanilla/types.d.ts +1 -1
  37. package/dist/vanilla/types.d.ts.map +1 -1
  38. package/package.json +3 -1
@@ -6,6 +6,7 @@ import { MessageHandler } from "./MessageHandler";
6
6
  import { EventEmitter } from "./EventEmitter";
7
7
  import { MemoryManager } from "./MemoryManager";
8
8
  import { ConnectionStateHandler } from "./ConnectionStateHandler";
9
+ import { WebSocketSession } from "./WebSocketSession";
9
10
  /**
10
11
  * Custom reconnect policy that disables automatic reconnection
11
12
  */
@@ -64,6 +65,8 @@ export class ConvaiClient extends EventEmitter {
64
65
  this._storedConfig = null;
65
66
  this._endUserId = null;
66
67
  this._endUserMetadata = null;
68
+ this._wsSession = null;
69
+ this._activeTransport = null;
67
70
  // Conversation session tracking
68
71
  this._conversationSessionId = 0;
69
72
  this._conversationStartTime = 0;
@@ -209,7 +212,25 @@ export class ConvaiClient extends EventEmitter {
209
212
  this.updateState({ emotion });
210
213
  });
211
214
  this._messageHandler.on("listeningChange", (isListening) => {
215
+ const wasListening = this._state.isListening;
212
216
  this.updateState({ isListening });
217
+ // Voice input path — activate the blendshape queue as soon as the user
218
+ // starts speaking (user-started-speaking) so conversationActive is true
219
+ // immediately, matching the text-input path where it's true on send.
220
+ if (!wasListening && isListening) {
221
+ // Reset blendshape queue if mid-conversation, but don't interrupt the server
222
+ if (this.blendshapeQueue.isConversationActive() ||
223
+ this._state.isSpeaking) {
224
+ this.blendshapeQueue.interrupt();
225
+ }
226
+ this._conversationSessionId++;
227
+ this._conversationStartTime = Date.now();
228
+ this.emit("conversationStart", {
229
+ sessionId: this._conversationSessionId,
230
+ userMessage: "[voice]",
231
+ timestamp: this._conversationStartTime,
232
+ });
233
+ }
213
234
  });
214
235
  // Handle metrics events
215
236
  this._messageHandler.on("metrics", (metricsData) => {
@@ -240,6 +261,31 @@ export class ConvaiClient extends EventEmitter {
240
261
  this._messageHandler.on("llmNoResponse", () => {
241
262
  this.emit("llmNoResponse");
242
263
  });
264
+ // Forward bot-output event (aggregated output with spoken status)
265
+ this._messageHandler.on("botOutput", (data) => {
266
+ this.emit("botOutput", data);
267
+ });
268
+ // Forward bot TTS text (word-by-word as spoken)
269
+ this._messageHandler.on("botTtsText", (data) => {
270
+ this.emit("botTtsText", data);
271
+ });
272
+ // Forward bot TTS lifecycle events
273
+ this._messageHandler.on("botTtsStarted", () => {
274
+ this.emit("botTtsStarted");
275
+ });
276
+ this._messageHandler.on("botTtsStopped", () => {
277
+ this.emit("botTtsStopped");
278
+ });
279
+ // Forward server-side muting events
280
+ this._messageHandler.on("userMuteStarted", () => {
281
+ this.emit("userMuteStarted");
282
+ });
283
+ this._messageHandler.on("userMuteStopped", () => {
284
+ this.emit("userMuteStopped");
285
+ });
286
+ this._messageHandler.on("actionResponse", (data) => {
287
+ this.emit("actionResponse", data);
288
+ });
243
289
  }
244
290
  /**
245
291
  * Setup MessageHandler to respond to conversation start events
@@ -249,7 +295,6 @@ export class ConvaiClient extends EventEmitter {
249
295
  setupMessageHandlerEvents() {
250
296
  this.on("conversationStart", (data) => {
251
297
  this._messageHandler.getBlendshapeQueue().startConversation();
252
- console.log(`[ConvaiClient] 🎬 Blendshape queue activated for conversation ${data.sessionId}`);
253
298
  });
254
299
  }
255
300
  /**
@@ -279,6 +324,30 @@ export class ConvaiClient extends EventEmitter {
279
324
  this.emit("stateChange", this._state);
280
325
  }
281
326
  }
327
+ /**
328
+ * Send a data message via the active transport.
329
+ * For LiveKit: publishes via the data channel.
330
+ * For WebSocket: sends via PipecatClient.sendClientMessage (wrapped in {t,d} envelope).
331
+ */
332
+ publishMessage(type, data) {
333
+ if (this._activeTransport === "websocket" && this._wsSession?.isConnected) {
334
+ this._wsSession.sendMessage(type, data);
335
+ }
336
+ else if (this._room?.localParticipant &&
337
+ this._room.state !== "disconnected") {
338
+ const encodedData = new TextEncoder().encode(JSON.stringify({ type, data }));
339
+ this._room.localParticipant.publishData(encodedData, { reliable: true });
340
+ }
341
+ }
342
+ /**
343
+ * Returns true when a message can be sent on the active transport.
344
+ */
345
+ isTransportReady() {
346
+ if (this._activeTransport === "websocket") {
347
+ return this._wsSession?.isConnected ?? false;
348
+ }
349
+ return (!!this._room?.localParticipant && this._room.state !== "disconnected");
350
+ }
282
351
  /**
283
352
  * Connect to a Convai character
284
353
  */
@@ -315,6 +384,8 @@ export class ConvaiClient extends EventEmitter {
315
384
  // Determine connection type based on enableVideo
316
385
  const connType = configWithDefaults.enableVideo ? "video" : "audio";
317
386
  this._connectionType = connType;
387
+ const transportType = configWithDefaults.transport ?? "livekit";
388
+ this._activeTransport = transportType;
318
389
  // Prepare request body with required parameters
319
390
  const characterSessionIdToSend = configWithDefaults.characterSessionId ?? this._characterSessionId;
320
391
  const requestBody = {
@@ -325,7 +396,7 @@ export class ConvaiClient extends EventEmitter {
325
396
  ...(configWithDefaults.endUserMetadata != null && {
326
397
  end_user_metadata: configWithDefaults.endUserMetadata,
327
398
  }),
328
- transport: "livekit",
399
+ transport: transportType,
329
400
  connection_type: connType,
330
401
  blendshape_provider: configWithDefaults.enableLipsync
331
402
  ? "neurosync"
@@ -338,7 +409,7 @@ export class ConvaiClient extends EventEmitter {
338
409
  output_fps: 90,
339
410
  },
340
411
  llm_provider: "dynamic",
341
- ...(configWithDefaults.enableEmotion !== false && {
412
+ ...(configWithDefaults.enableEmotion === true && {
342
413
  emotion_config: {
343
414
  provider: configWithDefaults.emotionConfig?.provider ?? "llm",
344
415
  min_word_threshold: configWithDefaults.emotionConfig?.min_word_threshold ?? 3,
@@ -356,19 +427,14 @@ export class ConvaiClient extends EventEmitter {
356
427
  character_session_id: characterSessionIdToSend,
357
428
  }),
358
429
  ...(configWithDefaults.actionConfig && {
359
- action_config: configWithDefaults.actionConfig,
360
- }),
361
- ...(configWithDefaults.characters && {
362
- characters: configWithDefaults.characters,
363
- }),
364
- ...(configWithDefaults.objects && {
365
- objects: configWithDefaults.objects,
366
- }),
367
- ...(configWithDefaults.currentAttentionObject && {
368
- current_attention_object: configWithDefaults.currentAttentionObject,
369
- }),
370
- ...(configWithDefaults.sceneDescription && {
371
- scene_description: configWithDefaults.sceneDescription,
430
+ action_config: {
431
+ actions: configWithDefaults.actionConfig.actions,
432
+ objects: configWithDefaults.actionConfig.objects,
433
+ characters: configWithDefaults.actionConfig.characters,
434
+ ...(configWithDefaults.actionConfig.current_attention_object && {
435
+ current_attention_object: configWithDefaults.actionConfig.current_attention_object,
436
+ }),
437
+ },
372
438
  }),
373
439
  ...(configWithDefaults.dynamicInfo && {
374
440
  dynamic_info: {
@@ -383,24 +449,26 @@ export class ConvaiClient extends EventEmitter {
383
449
  }),
384
450
  invocation_metadata: {
385
451
  source: configWithDefaults.invocationMetadata?.source ?? "web_sdk",
386
- client_version: configWithDefaults.invocationMetadata?.clientVersion ?? "1.2.2-beta.4",
452
+ client_version: configWithDefaults.invocationMetadata?.clientVersion ??
453
+ "1.3.2-beta.0",
387
454
  ...(configWithDefaults.invocationMetadata?.extraMetadata && {
388
455
  extra_metadata: configWithDefaults.invocationMetadata.extraMetadata,
389
456
  }),
390
457
  },
391
458
  };
392
- const headers = {
459
+ const connectHeaders = {
393
460
  "Content-Type": "application/json",
394
461
  };
395
462
  if (configWithDefaults.authToken) {
396
- headers["API-AUTH-TOKEN"] = configWithDefaults.authToken;
463
+ connectHeaders["API-AUTH-TOKEN"] = configWithDefaults.authToken;
397
464
  }
398
465
  else if (configWithDefaults.apiKey) {
399
- headers["X-API-Key"] = configWithDefaults.apiKey;
466
+ connectHeaders["X-API-Key"] = configWithDefaults.apiKey;
400
467
  }
468
+ // HTTP POST /connect — same request for both transports
401
469
  const response = await fetch(`${configWithDefaults.url}/connect`, {
402
470
  method: "POST",
403
- headers,
471
+ headers: connectHeaders,
404
472
  body: JSON.stringify(requestBody),
405
473
  });
406
474
  if (!response.ok) {
@@ -426,7 +494,7 @@ export class ConvaiClient extends EventEmitter {
426
494
  throw new Error(errorMessage);
427
495
  }
428
496
  const connectionData = await response.json();
429
- // Capture character_session_id from connection response and persist in stored config for reconnection
497
+ // Capture session identifiers from /connect response
430
498
  if (connectionData.character_session_id) {
431
499
  this._characterSessionId = connectionData.character_session_id;
432
500
  this._storedConfig = {
@@ -434,39 +502,87 @@ export class ConvaiClient extends EventEmitter {
434
502
  characterSessionId: connectionData.character_session_id,
435
503
  };
436
504
  }
437
- // Capture end_user_id and end_user_metadata from connection response
438
505
  if (connectionData.end_user_id) {
439
506
  this._endUserId = connectionData.end_user_id;
440
507
  }
441
508
  if (connectionData.end_user_metadata) {
442
509
  this._endUserMetadata = connectionData.end_user_metadata;
443
510
  }
444
- // Connect to LiveKit room
445
- await this._room.connect(connectionData.room_url, connectionData.token, {
446
- rtcConfig: {
447
- iceTransportPolicy: "relay",
448
- },
449
- });
450
- // Enable microphone only if startWithAudioOn is true (default: false)
451
- // If false, microphone stays off until user enables it via audioControls
452
- if (configWithDefaults.startWithAudioOn) {
453
- await this._room.localParticipant.setMicrophoneEnabled(true, {
454
- echoCancellation: this._audioSettings.echoCancellation,
455
- noiseSuppression: this._audioSettings.noiseSuppression,
456
- autoGainControl: this._audioSettings.autoGainControl,
457
- sampleRate: this._audioSettings.sampleRate,
458
- channelCount: this._audioSettings.channelCount,
511
+ if (transportType === "websocket") {
512
+ // ── WebSocket (Pipecat) transport path ────────────────────────────
513
+ // Server returns the WebSocket URL in room_url (same field as LiveKit).
514
+ const wsUrl = connectionData.room_url;
515
+ if (!wsUrl) {
516
+ throw new Error("No WebSocket URL returned from /connect endpoint (expected room_url)");
517
+ }
518
+ // Always enable mic at construction so WavMediaManager initializes the
519
+ // audio stream during connect(). If startWithAudioOn is false, we mute
520
+ // after the connection is established (enableMic(false) works once the
521
+ // stream exists, but not before it is initialized).
522
+ this._wsSession = new WebSocketSession((payload) => this._messageHandler.handleDataReceivedPublic(payload), true);
523
+ this._wsSession.on("botAudioTrack", (track) => {
524
+ this.emit("botAudioTrack", track);
459
525
  });
526
+ this._wsSession.on("disconnected", () => {
527
+ this._connectionStateHandler.handleDisconnected();
528
+ });
529
+ this._audioManager.setWebSocketSession(this._wsSession);
530
+ // Wait for WebSocket transport to connect (fires before bot-ready)
531
+ await new Promise((resolve, reject) => {
532
+ const onConnected = () => {
533
+ this._wsSession.off("connected", onConnected);
534
+ this._wsSession.off("error", onError);
535
+ resolve();
536
+ };
537
+ const onError = (err) => {
538
+ this._wsSession.off("connected", onConnected);
539
+ this._wsSession.off("error", onError);
540
+ reject(err instanceof Error ? err : new Error(String(err)));
541
+ };
542
+ this._wsSession.on("connected", onConnected);
543
+ this._wsSession.on("error", onError);
544
+ // connectWithUrl calls initDevices() then connect({ wsUrl }) — matches sandbox flow
545
+ this._wsSession.connectWithUrl(wsUrl).catch((err) => {
546
+ this._wsSession.off("connected", onConnected);
547
+ this._wsSession.off("error", onError);
548
+ reject(err instanceof Error ? err : new Error(String(err)));
549
+ });
550
+ });
551
+ // For WebSocket, mic is already streaming after connect.
552
+ // Only mute if the caller explicitly sets startWithAudioOn: false.
553
+ // Defaulting to on matches the sandbox behavior and ensures the bot
554
+ // sees audio when it initializes (avoids server "tap mic to talk" prompt).
555
+ if (configWithDefaults.startWithAudioOn === false) {
556
+ this._wsSession.enableMic(false);
557
+ this._audioManager.syncWsAudioState(false);
558
+ }
559
+ else {
560
+ this._audioManager.syncWsAudioState(true);
561
+ }
460
562
  }
461
- // Enable camera only if enableVideo is true AND startWithVideoOn is true
462
- if (configWithDefaults.enableVideo &&
463
- configWithDefaults.startWithVideoOn) {
464
- await this._room.localParticipant.setCameraEnabled(true);
563
+ else {
564
+ // ── LiveKit transport path (default) ─────────────────────────────
565
+ await this._room.connect(connectionData.room_url, connectionData.token, {
566
+ rtcConfig: {
567
+ iceTransportPolicy: "relay",
568
+ },
569
+ });
570
+ if (configWithDefaults.startWithAudioOn) {
571
+ await this._room.localParticipant.setMicrophoneEnabled(true, {
572
+ echoCancellation: this._audioSettings.echoCancellation,
573
+ noiseSuppression: this._audioSettings.noiseSuppression,
574
+ autoGainControl: this._audioSettings.autoGainControl,
575
+ sampleRate: this._audioSettings.sampleRate,
576
+ channelCount: this._audioSettings.channelCount,
577
+ });
578
+ }
579
+ if (configWithDefaults.enableVideo &&
580
+ configWithDefaults.startWithVideoOn) {
581
+ await this._room.localParticipant.setCameraEnabled(true);
582
+ }
583
+ this._audioManager.syncStateFromRoom({ emit: true });
584
+ this._participantSid = this._room.localParticipant.sid;
465
585
  }
466
- // Ensure audio manager mirrors the actual microphone permission state
467
- this._audioManager.syncStateFromRoom({ emit: true });
468
- // Capture participant SID
469
- this._participantSid = this._room.localParticipant.sid;
470
586
  // Apply custom mapper to blendshape queue if provided
471
587
  if (configWithDefaults.blendshapeConfig?.customMapper) {
472
588
  this.blendshapeQueue.setMapper(configWithDefaults.blendshapeConfig.customMapper);
@@ -505,7 +621,18 @@ export class ConvaiClient extends EventEmitter {
505
621
  * Disconnect from the current character session
506
622
  */
507
623
  async disconnect() {
508
- if (this._room && this._room.state !== "disconnected") {
624
+ if (this._activeTransport === "websocket" && this._wsSession) {
625
+ try {
626
+ await this._wsSession.disconnect();
627
+ }
628
+ catch {
629
+ // ignore disconnect errors
630
+ }
631
+ this._wsSession = null;
632
+ this._connectionStateHandler.resetConnectionState();
633
+ this.clearAllManagers();
634
+ }
635
+ else if (this._room && this._room.state !== "disconnected") {
509
636
  try {
510
637
  await this._room.disconnect();
511
638
  this._connectionStateHandler.resetConnectionState();
@@ -523,6 +650,8 @@ export class ConvaiClient extends EventEmitter {
523
650
  */
524
651
  clearAllManagers() {
525
652
  this._connectionType = null;
653
+ this._activeTransport = null;
654
+ this._wsSession = null;
526
655
  this._apiKey = null;
527
656
  this._authToken = null;
528
657
  this._characterId = null;
@@ -530,6 +659,7 @@ export class ConvaiClient extends EventEmitter {
530
659
  this._endUserId = null;
531
660
  this._endUserMetadata = null;
532
661
  this._memoryManager = null; // Clear memory manager on disconnect
662
+ this._audioManager.setWebSocketSession(null);
533
663
  this._audioManager.reset();
534
664
  this._videoManager.reset();
535
665
  this._screenShareManager.reset();
@@ -556,35 +686,28 @@ export class ConvaiClient extends EventEmitter {
556
686
  * Send a text message to the character
557
687
  */
558
688
  sendUserTextMessage(text) {
559
- if (!this._room ||
560
- this._room.state === "disconnected" ||
561
- !this._room.localParticipant) {
689
+ if (!this.isTransportReady())
562
690
  return;
563
- }
564
- if (!text || !text.trim()) {
691
+ if (!text || !text.trim())
565
692
  return;
566
- }
567
693
  try {
694
+ // Reset blendshape queue if mid-conversation, but don't interrupt the server
695
+ if (this.blendshapeQueue.isConversationActive() ||
696
+ this._state.isSpeaking) {
697
+ this.blendshapeQueue.interrupt();
698
+ }
568
699
  // Start new conversation session
569
700
  this._conversationSessionId++;
570
701
  this._conversationStartTime = Date.now();
571
- console.log(`------conversation ${this._conversationSessionId}-------`);
572
702
  // Emit conversation start event
573
703
  this.emit("conversationStart", {
574
704
  sessionId: this._conversationSessionId,
575
705
  userMessage: text.trim(),
576
706
  timestamp: this._conversationStartTime,
577
707
  });
578
- const message = {
579
- type: "user_text_message",
580
- data: {
581
- text: text.trim(),
582
- participant_sid: this._participantSid || this._room.localParticipant.sid,
583
- },
584
- };
585
- const encodedData = new TextEncoder().encode(JSON.stringify(message));
586
- this._room.localParticipant.publishData(encodedData, {
587
- reliable: true,
708
+ this.publishMessage("user_text_message", {
709
+ text: text.trim(),
710
+ participant_sid: this._participantSid || this._room.localParticipant?.sid,
588
711
  });
589
712
  }
590
713
  catch (error) {
@@ -595,44 +718,61 @@ export class ConvaiClient extends EventEmitter {
595
718
  * Send a trigger message to invoke specific character actions
596
719
  */
597
720
  sendTriggerMessage(triggerName, triggerMessage) {
598
- if (this._room && this._room.localParticipant) {
599
- const message = {
600
- type: "trigger-message",
601
- data: {
602
- ...(triggerName && { trigger_name: triggerName }),
603
- ...(triggerMessage && { trigger_message: triggerMessage }),
604
- participant_sid: this._participantSid || this._room.localParticipant.sid,
605
- },
606
- };
607
- const encodedData = new TextEncoder().encode(JSON.stringify(message));
608
- this._room.localParticipant.publishData(encodedData, {
609
- reliable: true,
721
+ if (!this.isTransportReady())
722
+ return;
723
+ // Reset blendshape queue if mid-conversation, but don't interrupt the server
724
+ if (this.blendshapeQueue.isConversationActive() || this._state.isSpeaking) {
725
+ this.blendshapeQueue.interrupt();
726
+ }
727
+ this._conversationSessionId++;
728
+ this._conversationStartTime = Date.now();
729
+ this.emit("conversationStart", {
730
+ sessionId: this._conversationSessionId,
731
+ userMessage: `[trigger:${triggerName ?? ""}]`,
732
+ timestamp: this._conversationStartTime,
733
+ });
734
+ this.publishMessage("trigger-message", {
735
+ ...(triggerName && { trigger_name: triggerName }),
736
+ ...(triggerMessage && { trigger_message: triggerMessage }),
737
+ participant_sid: this._participantSid || this._room.localParticipant?.sid,
738
+ });
739
+ }
740
+ /**
741
+ * If the bot is currently speaking or a conversation is active, send an
742
+ * interrupt to the server and end the blendshape conversation so the queue
743
+ * is clean before a new response starts arriving.
744
+ */
745
+ endCurrentConversationIfActive() {
746
+ const queueActive = this.blendshapeQueue.isConversationActive();
747
+ const botSpeaking = this._state.isSpeaking;
748
+ if (!queueActive && !botSpeaking)
749
+ return;
750
+ if (this.isTransportReady()) {
751
+ this.publishMessage("interrupt-bot", {
752
+ participant_sid: this._participantSid || this._room.localParticipant?.sid,
610
753
  });
611
754
  }
755
+ // End the blendshape queue so the lipsync player finishes cleanly
756
+ this.blendshapeQueue.interrupt();
612
757
  }
613
758
  /**
614
759
  * Send an interrupt message to stop the bot's current response
615
760
  */
616
761
  sendInterruptMessage() {
617
- if (!this._room ||
618
- this._room.state === "disconnected" ||
619
- !this._room.localParticipant) {
762
+ if (!this.isTransportReady())
620
763
  return;
621
- }
622
764
  try {
623
- const message = {
624
- type: "interrupt-bot",
625
- data: {
626
- participant_sid: this._participantSid || this._room.localParticipant.sid,
627
- },
628
- };
629
- const encodedData = new TextEncoder().encode(JSON.stringify(message));
630
- this._room.localParticipant.publishData(encodedData, {
631
- reliable: true,
765
+ this.publishMessage("interrupt-bot", {
766
+ participant_sid: this._participantSid || this._room.localParticipant?.sid,
632
767
  });
633
- // Trigger fade out for interruption - keeps last 10 frames
634
- if (this.blendshapeQueue) {
635
- this.blendshapeQueue.interrupt();
768
+ // Blendshape fade-out only for LiveKit (WebSocket transport has no lipsync;
769
+ // calling interrupt() would set conversationEnded=true and block the next
770
+ // bot-started-speaking from ever firing isSpeaking).
771
+ if (this._activeTransport !== "websocket") {
772
+ if (this.blendshapeQueue.isConversationActive() ||
773
+ this._state.isSpeaking) {
774
+ this.blendshapeQueue.interrupt();
775
+ }
636
776
  }
637
777
  }
638
778
  catch (error) {
@@ -643,39 +783,23 @@ export class ConvaiClient extends EventEmitter {
643
783
  * Update template keys in the character's context
644
784
  */
645
785
  updateTemplateKeys(templateKeys) {
646
- if (this._room &&
647
- this._room.localParticipant &&
648
- Object.keys(templateKeys).length > 0) {
649
- const message = {
650
- type: "update-template-keys",
651
- data: {
652
- template_keys: templateKeys,
653
- participant_sid: this._participantSid || this._room.localParticipant.sid,
654
- },
655
- };
656
- const encodedData = new TextEncoder().encode(JSON.stringify(message));
657
- this._room.localParticipant.publishData(encodedData, {
658
- reliable: true,
659
- });
660
- }
786
+ if (!this.isTransportReady() || Object.keys(templateKeys).length === 0)
787
+ return;
788
+ this.publishMessage("update-template-keys", {
789
+ template_keys: templateKeys,
790
+ participant_sid: this._participantSid || this._room.localParticipant?.sid,
791
+ });
661
792
  }
662
793
  /**
663
794
  * Update dynamic information about the current context
664
795
  */
665
796
  updateDynamicInfo(dynamicInfo) {
666
- if (this._room && this._room.localParticipant && dynamicInfo?.trim()) {
667
- const message = {
668
- type: "update-dynamic-info",
669
- data: {
670
- dynamic_info: { text: dynamicInfo },
671
- participant_sid: this._participantSid || this._room.localParticipant.sid,
672
- },
673
- };
674
- const encodedData = new TextEncoder().encode(JSON.stringify(message));
675
- this._room.localParticipant.publishData(encodedData, {
676
- reliable: true,
677
- });
678
- }
797
+ if (!this.isTransportReady() || !dynamicInfo?.trim())
798
+ return;
799
+ this.publishMessage("update-dynamic-info", {
800
+ dynamic_info: { text: dynamicInfo },
801
+ participant_sid: this._participantSid || this._room.localParticipant?.sid,
802
+ });
679
803
  }
680
804
  /**
681
805
  * Update the bot's temporary runtime context in a unified way.
@@ -704,79 +828,61 @@ export class ConvaiClient extends EventEmitter {
704
828
  * ```
705
829
  */
706
830
  updateContext(options) {
707
- if (!this._room || !this._room.localParticipant) {
831
+ if (!this.isTransportReady())
708
832
  return;
709
- }
710
- // Validate: text is required unless mode is "reset"
711
- if (options.mode !== "reset" && !options.text?.trim()) {
712
- console.warn("[ConvaiClient] updateContext: text is required unless mode is 'reset'");
833
+ const hasText = !!options.text?.trim();
834
+ const hasAttention = options.current_attention_object !== undefined;
835
+ // text is required unless mode is "reset" or only updating attention
836
+ if (options.mode !== "reset" && !hasText && !hasAttention) {
837
+ console.warn("[ConvaiClient] updateContext: text or current_attention_object is required unless mode is 'reset'");
713
838
  return;
714
839
  }
715
- const message = {
716
- type: "context-update",
717
- data: {
718
- ...(options.text && { text: options.text.trim() }),
719
- ...(options.mode && { mode: options.mode }),
720
- ...(options.run_llm && { run_llm: options.run_llm }),
721
- participant_sid: this._participantSid || this._room.localParticipant.sid,
722
- },
723
- };
724
- const encodedData = new TextEncoder().encode(JSON.stringify(message));
725
- this._room.localParticipant.publishData(encodedData, {
726
- reliable: true,
840
+ const willTriggerLlm = options.run_llm !== "false";
841
+ if (willTriggerLlm) {
842
+ if (this.blendshapeQueue.isConversationActive() ||
843
+ this._state.isSpeaking) {
844
+ this.blendshapeQueue.interrupt();
845
+ }
846
+ this._conversationSessionId++;
847
+ this._conversationStartTime = Date.now();
848
+ this.emit("conversationStart", {
849
+ sessionId: this._conversationSessionId,
850
+ userMessage: `[context-update:${options.mode ?? "append"}]`,
851
+ timestamp: this._conversationStartTime,
852
+ });
853
+ }
854
+ this.publishMessage("context-update", {
855
+ ...(hasText && { text: options.text.trim() }),
856
+ ...(options.mode && { mode: options.mode }),
857
+ ...(options.run_llm && { run_llm: options.run_llm }),
858
+ ...(hasAttention && { current_attention_object: options.current_attention_object }),
859
+ participant_sid: this._participantSid || this._room.localParticipant?.sid,
727
860
  });
728
861
  }
862
+ /**
863
+ * Send descriptive scene updates. Use when environment description changes
864
+ * and the bot should know what is visible or nearby. Does not modify action_config.
865
+ */
866
+ updateSceneMetadata(items) {
867
+ if (!this.isTransportReady())
868
+ return;
869
+ this.publishMessage("update-scene-metadata", { scene_metadata: items });
870
+ }
729
871
  /**
730
872
  * Toggle text-to-speech on or off
731
873
  */
732
874
  toggleTts(enabled) {
733
- if (!this._room ||
734
- this._room.state === "disconnected" ||
735
- !this._room.localParticipant) {
875
+ if (!this.isTransportReady())
736
876
  return;
737
- }
738
- try {
739
- const message = {
740
- type: "tts-toggle",
741
- data: {
742
- enabled: enabled,
743
- },
744
- };
745
- const encodedData = new TextEncoder().encode(JSON.stringify(message));
746
- this._room.localParticipant.publishData(encodedData, {
747
- reliable: true,
748
- });
749
- }
750
- catch (error) {
751
- throw error;
752
- }
877
+ this.publishMessage("tts-toggle", { enabled });
753
878
  }
754
879
  /**
755
880
  * Toggle speech-to-text on or off
756
881
  */
757
882
  toggleStt(enabled) {
758
- if (!this._room ||
759
- this._room.state === "disconnected" ||
760
- !this._room.localParticipant) {
883
+ if (!this.isTransportReady())
761
884
  return;
762
- }
763
- try {
764
- const message = {
765
- lable: "rtvi-ai",
766
- type: "stt-toggle",
767
- data: {
768
- muted: !enabled,
769
- },
770
- };
771
- const encodedData = new TextEncoder().encode(JSON.stringify(message));
772
- console.log("Toggle Stt: ", enabled, encodedData);
773
- this._room.localParticipant.publishData(encodedData, {
774
- reliable: true,
775
- });
776
- }
777
- catch (error) {
778
- throw error;
779
- }
885
+ this.publishMessage("stt-toggle", { muted: !enabled });
780
886
  }
781
887
  /**
782
888
  * Reset the server-side idle timer.
@@ -784,26 +890,11 @@ export class ConvaiClient extends EventEmitter {
784
890
  * the session from being disconnected due to inactivity.
785
891
  */
786
892
  resetIdleTimer() {
787
- if (!this._room ||
788
- this._room.state === "disconnected" ||
789
- !this._room.localParticipant) {
893
+ if (!this.isTransportReady())
790
894
  return;
791
- }
792
- try {
793
- const message = {
794
- type: "reset-idle-timer",
795
- data: {
796
- participant_sid: this._participantSid || this._room.localParticipant.sid,
797
- },
798
- };
799
- const encodedData = new TextEncoder().encode(JSON.stringify(message));
800
- this._room.localParticipant.publishData(encodedData, {
801
- reliable: true,
802
- });
803
- }
804
- catch (error) {
805
- throw error;
806
- }
895
+ this.publishMessage("reset-idle-timer", {
896
+ participant_sid: this._participantSid || this._room.localParticipant?.sid,
897
+ });
807
898
  }
808
899
  }
809
900
  //# sourceMappingURL=ConvaiClient.js.map