@convai/web-sdk 1.3.2-beta.0 → 1.4.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,5 @@
1
- import { Room, RoomEvent, DefaultReconnectPolicy, } from "livekit-client";
1
+ import { Room, RoomEvent, DefaultReconnectPolicy, setLogLevel, } from "livekit-client";
2
+ setLogLevel("warn");
2
3
  import { AudioManager } from "./AudioManager";
3
4
  import { VideoManager } from "./VideoManager";
4
5
  import { ScreenShareManager } from "./ScreenShareManager";
@@ -6,6 +7,7 @@ import { MessageHandler } from "./MessageHandler";
6
7
  import { EventEmitter } from "./EventEmitter";
7
8
  import { MemoryManager } from "./MemoryManager";
8
9
  import { ConnectionStateHandler } from "./ConnectionStateHandler";
10
+ import { WebSocketSession } from "./WebSocketSession";
9
11
  /**
10
12
  * Custom reconnect policy that disables automatic reconnection
11
13
  */
@@ -64,6 +66,13 @@ export class ConvaiClient extends EventEmitter {
64
66
  this._storedConfig = null;
65
67
  this._endUserId = null;
66
68
  this._endUserMetadata = null;
69
+ this._wsSession = null;
70
+ this._activeTransport = null;
71
+ this._logRtviMessages = true;
72
+ // RTVI client-ready handshake (race-proof bot-ready delivery)
73
+ this._clientReadyRetryTimer = null;
74
+ this._clientReadyTimeoutTimer = null;
75
+ this._clientReadyMsgId = null;
67
76
  // Conversation session tracking
68
77
  this._conversationSessionId = 0;
69
78
  this._conversationStartTime = 0;
@@ -72,6 +81,7 @@ export class ConvaiClient extends EventEmitter {
72
81
  // Store config if provided
73
82
  if (config) {
74
83
  this._storedConfig = config;
84
+ this._logRtviMessages = config.logRtviMessages !== false;
75
85
  }
76
86
  // Initialize room with no reconnect policy
77
87
  this._room = new Room({
@@ -104,6 +114,7 @@ export class ConvaiClient extends EventEmitter {
104
114
  this._videoManager = new VideoManager(this._room);
105
115
  this._screenShareManager = new ScreenShareManager(this._room);
106
116
  this._messageHandler = new MessageHandler(this._room);
117
+ this._messageHandler.setRtviMessageLogging(this._logRtviMessages);
107
118
  this._connectionStateHandler = new ConnectionStateHandler(this.updateState.bind(this), (ready) => (this._isBotReady = ready), () => {
108
119
  this._messageHandler.reset();
109
120
  this._endUserId = null;
@@ -177,7 +188,13 @@ export class ConvaiClient extends EventEmitter {
177
188
  this._room.on(RoomEvent.ConnectionStateChanged, this._connectionStateHandler.handleConnectionStateChanged.bind(this._connectionStateHandler));
178
189
  // Message handler events
179
190
  this._messageHandler.on("botReady", () => {
191
+ // Server may send multiple bot-ready frames (one per client-ready
192
+ // we retried). Only surface the first to consumers per connect.
193
+ if (this._isBotReady) {
194
+ return;
195
+ }
180
196
  this._isBotReady = true;
197
+ this._stopClientReadyHandshake();
181
198
  this.updateState({ isConnected: true });
182
199
  this.emit("botReady");
183
200
  });
@@ -280,6 +297,15 @@ export class ConvaiClient extends EventEmitter {
280
297
  this._messageHandler.on("userMuteStopped", () => {
281
298
  this.emit("userMuteStopped");
282
299
  });
300
+ this._messageHandler.on("actionResponse", (data) => {
301
+ this.emit("actionResponse", data);
302
+ });
303
+ this._messageHandler.on("serverResponse", (response) => {
304
+ this.emit("serverResponse", response);
305
+ });
306
+ this._messageHandler.on("interactionCreated", (data) => {
307
+ this.emit("interactionCreated", data);
308
+ });
283
309
  }
284
310
  /**
285
311
  * Setup MessageHandler to respond to conversation start events
@@ -318,6 +344,31 @@ export class ConvaiClient extends EventEmitter {
318
344
  this.emit("stateChange", this._state);
319
345
  }
320
346
  }
347
+ /**
348
+ * Send a data message via the active transport.
349
+ * For LiveKit: publishes via the data channel.
350
+ * For WebSocket: sends via PipecatClient.sendClientMessage (wrapped in {t,d} envelope).
351
+ */
352
+ publishMessage(type, data) {
353
+ const message = { type, data };
354
+ if (this._activeTransport === "websocket" && this._wsSession?.isConnected) {
355
+ this._wsSession.sendMessage(type, data);
356
+ }
357
+ else if (this._room?.localParticipant &&
358
+ this._room.state !== "disconnected") {
359
+ const encodedData = new TextEncoder().encode(JSON.stringify(message));
360
+ this._room.localParticipant.publishData(encodedData, { reliable: true });
361
+ }
362
+ }
363
+ /**
364
+ * Returns true when a message can be sent on the active transport.
365
+ */
366
+ isTransportReady() {
367
+ if (this._activeTransport === "websocket") {
368
+ return this._wsSession?.isConnected ?? false;
369
+ }
370
+ return (!!this._room?.localParticipant && this._room.state !== "disconnected");
371
+ }
321
372
  /**
322
373
  * Connect to a Convai character
323
374
  */
@@ -336,9 +387,11 @@ export class ConvaiClient extends EventEmitter {
336
387
  }
337
388
  // Add default URL if not provided
338
389
  const configWithDefaults = {
339
- url: "https://realtime-api-preview.convai.com",
390
+ url: "https://realtime-api.convai.com",
340
391
  ...finalConfig,
341
392
  };
393
+ this._logRtviMessages = configWithDefaults.logRtviMessages !== false;
394
+ this._messageHandler.setRtviMessageLogging(this._logRtviMessages);
342
395
  const hasApiKey = Boolean(configWithDefaults.apiKey);
343
396
  const hasAuthToken = Boolean(configWithDefaults.authToken);
344
397
  if ((!hasApiKey && !hasAuthToken) || !configWithDefaults.characterId) {
@@ -354,6 +407,8 @@ export class ConvaiClient extends EventEmitter {
354
407
  // Determine connection type based on enableVideo
355
408
  const connType = configWithDefaults.enableVideo ? "video" : "audio";
356
409
  this._connectionType = connType;
410
+ const transportType = configWithDefaults.transport ?? "livekit";
411
+ this._activeTransport = transportType;
357
412
  // Prepare request body with required parameters
358
413
  const characterSessionIdToSend = configWithDefaults.characterSessionId ?? this._characterSessionId;
359
414
  const requestBody = {
@@ -364,7 +419,7 @@ export class ConvaiClient extends EventEmitter {
364
419
  ...(configWithDefaults.endUserMetadata != null && {
365
420
  end_user_metadata: configWithDefaults.endUserMetadata,
366
421
  }),
367
- transport: "livekit",
422
+ transport: transportType,
368
423
  connection_type: connType,
369
424
  blendshape_provider: configWithDefaults.enableLipsync
370
425
  ? "neurosync"
@@ -377,7 +432,7 @@ export class ConvaiClient extends EventEmitter {
377
432
  output_fps: 90,
378
433
  },
379
434
  llm_provider: "dynamic",
380
- ...(configWithDefaults.enableEmotion !== false && {
435
+ ...(configWithDefaults.enableEmotion === true && {
381
436
  emotion_config: {
382
437
  provider: configWithDefaults.emotionConfig?.provider ?? "llm",
383
438
  min_word_threshold: configWithDefaults.emotionConfig?.min_word_threshold ?? 3,
@@ -395,19 +450,14 @@ export class ConvaiClient extends EventEmitter {
395
450
  character_session_id: characterSessionIdToSend,
396
451
  }),
397
452
  ...(configWithDefaults.actionConfig && {
398
- action_config: configWithDefaults.actionConfig,
399
- }),
400
- ...(configWithDefaults.characters && {
401
- characters: configWithDefaults.characters,
402
- }),
403
- ...(configWithDefaults.objects && {
404
- objects: configWithDefaults.objects,
405
- }),
406
- ...(configWithDefaults.currentAttentionObject && {
407
- current_attention_object: configWithDefaults.currentAttentionObject,
408
- }),
409
- ...(configWithDefaults.sceneDescription && {
410
- scene_description: configWithDefaults.sceneDescription,
453
+ action_config: {
454
+ actions: configWithDefaults.actionConfig.actions,
455
+ objects: configWithDefaults.actionConfig.objects,
456
+ characters: configWithDefaults.actionConfig.characters,
457
+ ...(configWithDefaults.actionConfig.current_attention_object && {
458
+ current_attention_object: configWithDefaults.actionConfig.current_attention_object,
459
+ }),
460
+ },
411
461
  }),
412
462
  ...(configWithDefaults.dynamicInfo && {
413
463
  dynamic_info: {
@@ -429,18 +479,19 @@ export class ConvaiClient extends EventEmitter {
429
479
  }),
430
480
  },
431
481
  };
432
- const headers = {
482
+ const connectHeaders = {
433
483
  "Content-Type": "application/json",
434
484
  };
435
485
  if (configWithDefaults.authToken) {
436
- headers["API-AUTH-TOKEN"] = configWithDefaults.authToken;
486
+ connectHeaders["API-AUTH-TOKEN"] = configWithDefaults.authToken;
437
487
  }
438
488
  else if (configWithDefaults.apiKey) {
439
- headers["X-API-Key"] = configWithDefaults.apiKey;
489
+ connectHeaders["X-API-Key"] = configWithDefaults.apiKey;
440
490
  }
491
+ // HTTP POST /connect — same request for both transports
441
492
  const response = await fetch(`${configWithDefaults.url}/connect`, {
442
493
  method: "POST",
443
- headers,
494
+ headers: connectHeaders,
444
495
  body: JSON.stringify(requestBody),
445
496
  });
446
497
  if (!response.ok) {
@@ -466,7 +517,7 @@ export class ConvaiClient extends EventEmitter {
466
517
  throw new Error(errorMessage);
467
518
  }
468
519
  const connectionData = await response.json();
469
- // Capture character_session_id from connection response and persist in stored config for reconnection
520
+ // Capture session identifiers from /connect response
470
521
  if (connectionData.character_session_id) {
471
522
  this._characterSessionId = connectionData.character_session_id;
472
523
  this._storedConfig = {
@@ -474,39 +525,87 @@ export class ConvaiClient extends EventEmitter {
474
525
  characterSessionId: connectionData.character_session_id,
475
526
  };
476
527
  }
477
- // Capture end_user_id and end_user_metadata from connection response
478
528
  if (connectionData.end_user_id) {
479
529
  this._endUserId = connectionData.end_user_id;
480
530
  }
481
531
  if (connectionData.end_user_metadata) {
482
532
  this._endUserMetadata = connectionData.end_user_metadata;
483
533
  }
484
- // Connect to LiveKit room
485
- await this._room.connect(connectionData.room_url, connectionData.token, {
486
- rtcConfig: {
487
- iceTransportPolicy: "relay",
488
- },
489
- });
490
- // Enable microphone only if startWithAudioOn is true (default: false)
491
- // If false, microphone stays off until user enables it via audioControls
492
- if (configWithDefaults.startWithAudioOn) {
493
- await this._room.localParticipant.setMicrophoneEnabled(true, {
494
- echoCancellation: this._audioSettings.echoCancellation,
495
- noiseSuppression: this._audioSettings.noiseSuppression,
496
- autoGainControl: this._audioSettings.autoGainControl,
497
- sampleRate: this._audioSettings.sampleRate,
498
- channelCount: this._audioSettings.channelCount,
534
+ if (transportType === "websocket") {
535
+ // ── WebSocket (Pipecat) transport path ────────────────────────────
536
+ // Server returns the WebSocket URL in room_url (same field as LiveKit).
537
+ const wsUrl = connectionData.room_url;
538
+ if (!wsUrl) {
539
+ throw new Error("No WebSocket URL returned from /connect endpoint (expected room_url)");
540
+ }
541
+ // Always enable mic at construction so WavMediaManager initializes the
542
+ // audio stream during connect(). If startWithAudioOn is false, we mute
543
+ // after the connection is established (enableMic(false) works once the
544
+ // stream exists, but not before it is initialized).
545
+ this._wsSession = new WebSocketSession((payload) => this._messageHandler.handleDataReceivedPublic(payload), true);
546
+ this._wsSession.on("botAudioTrack", (track) => {
547
+ this.emit("botAudioTrack", track);
499
548
  });
549
+ this._wsSession.on("disconnected", () => {
550
+ this._connectionStateHandler.handleDisconnected();
551
+ });
552
+ this._audioManager.setWebSocketSession(this._wsSession);
553
+ // Wait for WebSocket transport to connect (fires before bot-ready)
554
+ await new Promise((resolve, reject) => {
555
+ const onConnected = () => {
556
+ this._wsSession.off("connected", onConnected);
557
+ this._wsSession.off("error", onError);
558
+ resolve();
559
+ };
560
+ const onError = (err) => {
561
+ this._wsSession.off("connected", onConnected);
562
+ this._wsSession.off("error", onError);
563
+ reject(err instanceof Error ? err : new Error(String(err)));
564
+ };
565
+ this._wsSession.on("connected", onConnected);
566
+ this._wsSession.on("error", onError);
567
+ // connectWithUrl calls initDevices() then connect({ wsUrl }) — matches sandbox flow
568
+ this._wsSession.connectWithUrl(wsUrl).catch((err) => {
569
+ this._wsSession.off("connected", onConnected);
570
+ this._wsSession.off("error", onError);
571
+ reject(err instanceof Error ? err : new Error(String(err)));
572
+ });
573
+ });
574
+ // For WebSocket, mic is already streaming after connect.
575
+ // Only mute if the caller explicitly sets startWithAudioOn: false.
576
+ // Defaulting to on matches the sandbox behavior and ensures the bot
577
+ // sees audio when it initializes (avoids server "tap mic to talk" prompt).
578
+ if (configWithDefaults.startWithAudioOn === false) {
579
+ this._wsSession.enableMic(false);
580
+ this._audioManager.syncWsAudioState(false);
581
+ }
582
+ else {
583
+ this._audioManager.syncWsAudioState(true);
584
+ }
500
585
  }
501
- // Enable camera only if enableVideo is true AND startWithVideoOn is true
502
- if (configWithDefaults.enableVideo &&
503
- configWithDefaults.startWithVideoOn) {
504
- await this._room.localParticipant.setCameraEnabled(true);
586
+ else {
587
+ // ── LiveKit transport path (default) ─────────────────────────────
588
+ await this._room.connect(connectionData.room_url, connectionData.token, {
589
+ rtcConfig: {
590
+ iceTransportPolicy: "relay",
591
+ },
592
+ });
593
+ if (configWithDefaults.startWithAudioOn) {
594
+ await this._room.localParticipant.setMicrophoneEnabled(true, {
595
+ echoCancellation: this._audioSettings.echoCancellation,
596
+ noiseSuppression: this._audioSettings.noiseSuppression,
597
+ autoGainControl: this._audioSettings.autoGainControl,
598
+ sampleRate: this._audioSettings.sampleRate,
599
+ channelCount: this._audioSettings.channelCount,
600
+ });
601
+ }
602
+ if (configWithDefaults.enableVideo &&
603
+ configWithDefaults.startWithVideoOn) {
604
+ await this._room.localParticipant.setCameraEnabled(true);
605
+ }
606
+ this._audioManager.syncStateFromRoom({ emit: true });
607
+ this._participantSid = this._room.localParticipant.sid;
505
608
  }
506
- // Ensure audio manager mirrors the actual microphone permission state
507
- this._audioManager.syncStateFromRoom({ emit: true });
508
- // Capture participant SID
509
- this._participantSid = this._room.localParticipant.sid;
510
609
  // Apply custom mapper to blendshape queue if provided
511
610
  if (configWithDefaults.blendshapeConfig?.customMapper) {
512
611
  this.blendshapeQueue.setMapper(configWithDefaults.blendshapeConfig.customMapper);
@@ -531,6 +630,7 @@ export class ConvaiClient extends EventEmitter {
531
630
  }
532
631
  }
533
632
  this.emit("connect");
633
+ this._startClientReadyHandshake();
534
634
  }
535
635
  catch (error) {
536
636
  this.updateState({
@@ -541,11 +641,82 @@ export class ConvaiClient extends EventEmitter {
541
641
  throw error;
542
642
  }
543
643
  }
644
+ /**
645
+ * RTVI client-ready handshake. Server-side gates bot-ready emission on
646
+ * receipt of client-ready (and re-emits bot-ready on every client-ready),
647
+ * so we can retry until we hear bot-ready back. Closes the race where the
648
+ * bot publishes bot-ready before the client data-channel is subscribed.
649
+ */
650
+ _startClientReadyHandshake() {
651
+ // RTVI client-ready handshake is LiveKit-only. WebSocket transport
652
+ // uses its own pipecat ready signalling.
653
+ if (this._activeTransport !== "livekit") {
654
+ return;
655
+ }
656
+ this._stopClientReadyHandshake();
657
+ this._clientReadyMsgId =
658
+ typeof crypto !== "undefined" && "randomUUID" in crypto
659
+ ? crypto.randomUUID()
660
+ : `client-ready-${Date.now()}-${Math.random()}`;
661
+ const send = () => {
662
+ if (this._isBotReady)
663
+ return;
664
+ if (!this._room || this._room.state !== "connected")
665
+ return;
666
+ try {
667
+ const msg = {
668
+ id: this._clientReadyMsgId,
669
+ label: "rtvi-ai",
670
+ type: "client-ready",
671
+ data: {
672
+ version: "1.0.0",
673
+ about: { library: "convai-web-sdk" },
674
+ },
675
+ };
676
+ const encoded = new TextEncoder().encode(JSON.stringify(msg));
677
+ this._room.localParticipant.publishData(encoded, { reliable: true });
678
+ }
679
+ catch {
680
+ // swallow; next retry will try again
681
+ }
682
+ this._clientReadyRetryTimer = setTimeout(send, ConvaiClient.CLIENT_READY_RETRY_MS);
683
+ };
684
+ send();
685
+ this._clientReadyTimeoutTimer = setTimeout(() => {
686
+ if (this._isBotReady)
687
+ return;
688
+ this._stopClientReadyHandshake();
689
+ const err = new Error(`bot-ready not received within ${ConvaiClient.CLIENT_READY_TIMEOUT_MS}ms`);
690
+ this.emit("error", err);
691
+ }, ConvaiClient.CLIENT_READY_TIMEOUT_MS);
692
+ }
693
+ _stopClientReadyHandshake() {
694
+ if (this._clientReadyRetryTimer) {
695
+ clearTimeout(this._clientReadyRetryTimer);
696
+ this._clientReadyRetryTimer = null;
697
+ }
698
+ if (this._clientReadyTimeoutTimer) {
699
+ clearTimeout(this._clientReadyTimeoutTimer);
700
+ this._clientReadyTimeoutTimer = null;
701
+ }
702
+ }
544
703
  /**
545
704
  * Disconnect from the current character session
546
705
  */
547
706
  async disconnect() {
548
- if (this._room && this._room.state !== "disconnected") {
707
+ this._stopClientReadyHandshake();
708
+ if (this._activeTransport === "websocket" && this._wsSession) {
709
+ try {
710
+ await this._wsSession.disconnect();
711
+ }
712
+ catch {
713
+ // ignore disconnect errors
714
+ }
715
+ this._wsSession = null;
716
+ this._connectionStateHandler.resetConnectionState();
717
+ this.clearAllManagers();
718
+ }
719
+ else if (this._room && this._room.state !== "disconnected") {
549
720
  try {
550
721
  await this._room.disconnect();
551
722
  this._connectionStateHandler.resetConnectionState();
@@ -563,6 +734,8 @@ export class ConvaiClient extends EventEmitter {
563
734
  */
564
735
  clearAllManagers() {
565
736
  this._connectionType = null;
737
+ this._activeTransport = null;
738
+ this._wsSession = null;
566
739
  this._apiKey = null;
567
740
  this._authToken = null;
568
741
  this._characterId = null;
@@ -570,6 +743,7 @@ export class ConvaiClient extends EventEmitter {
570
743
  this._endUserId = null;
571
744
  this._endUserMetadata = null;
572
745
  this._memoryManager = null; // Clear memory manager on disconnect
746
+ this._audioManager.setWebSocketSession(null);
573
747
  this._audioManager.reset();
574
748
  this._videoManager.reset();
575
749
  this._screenShareManager.reset();
@@ -596,14 +770,10 @@ export class ConvaiClient extends EventEmitter {
596
770
  * Send a text message to the character
597
771
  */
598
772
  sendUserTextMessage(text) {
599
- if (!this._room ||
600
- this._room.state === "disconnected" ||
601
- !this._room.localParticipant) {
773
+ if (!this.isTransportReady())
602
774
  return;
603
- }
604
- if (!text || !text.trim()) {
775
+ if (!text || !text.trim())
605
776
  return;
606
- }
607
777
  try {
608
778
  // Reset blendshape queue if mid-conversation, but don't interrupt the server
609
779
  if (this.blendshapeQueue.isConversationActive() ||
@@ -619,16 +789,9 @@ export class ConvaiClient extends EventEmitter {
619
789
  userMessage: text.trim(),
620
790
  timestamp: this._conversationStartTime,
621
791
  });
622
- const message = {
623
- type: "user_text_message",
624
- data: {
625
- text: text.trim(),
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,
792
+ this.publishMessage("user_text_message", {
793
+ text: text.trim(),
794
+ participant_sid: this._participantSid || this._room.localParticipant?.sid,
632
795
  });
633
796
  }
634
797
  catch (error) {
@@ -639,32 +802,24 @@ export class ConvaiClient extends EventEmitter {
639
802
  * Send a trigger message to invoke specific character actions
640
803
  */
641
804
  sendTriggerMessage(triggerName, triggerMessage) {
642
- if (this._room && this._room.localParticipant) {
643
- // Reset blendshape queue if mid-conversation, but don't interrupt the server
644
- if (this.blendshapeQueue.isConversationActive() ||
645
- this._state.isSpeaking) {
646
- this.blendshapeQueue.interrupt();
647
- }
648
- this._conversationSessionId++;
649
- this._conversationStartTime = Date.now();
650
- this.emit("conversationStart", {
651
- sessionId: this._conversationSessionId,
652
- userMessage: `[trigger:${triggerName ?? ""}]`,
653
- timestamp: this._conversationStartTime,
654
- });
655
- const message = {
656
- type: "trigger-message",
657
- data: {
658
- ...(triggerName && { trigger_name: triggerName }),
659
- ...(triggerMessage && { trigger_message: triggerMessage }),
660
- participant_sid: this._participantSid || this._room.localParticipant.sid,
661
- },
662
- };
663
- const encodedData = new TextEncoder().encode(JSON.stringify(message));
664
- this._room.localParticipant.publishData(encodedData, {
665
- reliable: true,
666
- });
667
- }
805
+ if (!this.isTransportReady())
806
+ return;
807
+ // Reset blendshape queue if mid-conversation, but don't interrupt the server
808
+ if (this.blendshapeQueue.isConversationActive() || this._state.isSpeaking) {
809
+ this.blendshapeQueue.interrupt();
810
+ }
811
+ this._conversationSessionId++;
812
+ this._conversationStartTime = Date.now();
813
+ this.emit("conversationStart", {
814
+ sessionId: this._conversationSessionId,
815
+ userMessage: `[trigger:${triggerName ?? ""}]`,
816
+ timestamp: this._conversationStartTime,
817
+ });
818
+ this.publishMessage("trigger-message", {
819
+ ...(triggerName && { trigger_name: triggerName }),
820
+ ...(triggerMessage && { trigger_message: triggerMessage }),
821
+ participant_sid: this._participantSid || this._room.localParticipant?.sid,
822
+ });
668
823
  }
669
824
  /**
670
825
  * If the bot is currently speaking or a conversation is active, send an
@@ -676,17 +831,10 @@ export class ConvaiClient extends EventEmitter {
676
831
  const botSpeaking = this._state.isSpeaking;
677
832
  if (!queueActive && !botSpeaking)
678
833
  return;
679
- // Tell the server to stop the current turn
680
- if (this._room &&
681
- this._room.state !== "disconnected" &&
682
- this._room.localParticipant) {
683
- const message = {
684
- type: "interrupt-bot",
685
- data: {
686
- participant_sid: this._participantSid || this._room.localParticipant.sid,
687
- },
688
- };
689
- this._room.localParticipant.publishData(new TextEncoder().encode(JSON.stringify(message)), { reliable: true });
834
+ if (this.isTransportReady()) {
835
+ this.publishMessage("interrupt-bot", {
836
+ participant_sid: this._participantSid || this._room.localParticipant?.sid,
837
+ });
690
838
  }
691
839
  // End the blendshape queue so the lipsync player finishes cleanly
692
840
  this.blendshapeQueue.interrupt();
@@ -695,26 +843,20 @@ export class ConvaiClient extends EventEmitter {
695
843
  * Send an interrupt message to stop the bot's current response
696
844
  */
697
845
  sendInterruptMessage() {
698
- if (!this._room ||
699
- this._room.state === "disconnected" ||
700
- !this._room.localParticipant) {
846
+ if (!this.isTransportReady())
701
847
  return;
702
- }
703
848
  try {
704
- const message = {
705
- type: "interrupt-bot",
706
- data: {
707
- participant_sid: this._participantSid || this._room.localParticipant.sid,
708
- },
709
- };
710
- const encodedData = new TextEncoder().encode(JSON.stringify(message));
711
- this._room.localParticipant.publishData(encodedData, {
712
- reliable: true,
849
+ this.publishMessage("interrupt-bot", {
850
+ participant_sid: this._participantSid || this._room.localParticipant?.sid,
713
851
  });
714
- // Trigger fade out for interruption - keeps last 10 frames
715
- if (this.blendshapeQueue.isConversationActive() ||
716
- this._state.isSpeaking) {
717
- this.blendshapeQueue.interrupt();
852
+ // Blendshape fade-out only for LiveKit (WebSocket transport has no lipsync;
853
+ // calling interrupt() would set conversationEnded=true and block the next
854
+ // bot-started-speaking from ever firing isSpeaking).
855
+ if (this._activeTransport !== "websocket") {
856
+ if (this.blendshapeQueue.isConversationActive() ||
857
+ this._state.isSpeaking) {
858
+ this.blendshapeQueue.interrupt();
859
+ }
718
860
  }
719
861
  }
720
862
  catch (error) {
@@ -725,39 +867,23 @@ export class ConvaiClient extends EventEmitter {
725
867
  * Update template keys in the character's context
726
868
  */
727
869
  updateTemplateKeys(templateKeys) {
728
- if (this._room &&
729
- this._room.localParticipant &&
730
- Object.keys(templateKeys).length > 0) {
731
- const message = {
732
- type: "update-template-keys",
733
- data: {
734
- template_keys: templateKeys,
735
- participant_sid: this._participantSid || this._room.localParticipant.sid,
736
- },
737
- };
738
- const encodedData = new TextEncoder().encode(JSON.stringify(message));
739
- this._room.localParticipant.publishData(encodedData, {
740
- reliable: true,
741
- });
742
- }
870
+ if (!this.isTransportReady() || Object.keys(templateKeys).length === 0)
871
+ return;
872
+ this.publishMessage("update-template-keys", {
873
+ template_keys: templateKeys,
874
+ participant_sid: this._participantSid || this._room.localParticipant?.sid,
875
+ });
743
876
  }
744
877
  /**
745
878
  * Update dynamic information about the current context
746
879
  */
747
880
  updateDynamicInfo(dynamicInfo) {
748
- if (this._room && this._room.localParticipant && dynamicInfo?.trim()) {
749
- const message = {
750
- type: "update-dynamic-info",
751
- data: {
752
- dynamic_info: { text: dynamicInfo },
753
- participant_sid: this._participantSid || this._room.localParticipant.sid,
754
- },
755
- };
756
- const encodedData = new TextEncoder().encode(JSON.stringify(message));
757
- this._room.localParticipant.publishData(encodedData, {
758
- reliable: true,
759
- });
760
- }
881
+ if (!this.isTransportReady() || !dynamicInfo?.trim())
882
+ return;
883
+ this.publishMessage("update-dynamic-info", {
884
+ dynamic_info: { text: dynamicInfo },
885
+ participant_sid: this._participantSid || this._room.localParticipant?.sid,
886
+ });
761
887
  }
762
888
  /**
763
889
  * Update the bot's temporary runtime context in a unified way.
@@ -786,19 +912,15 @@ export class ConvaiClient extends EventEmitter {
786
912
  * ```
787
913
  */
788
914
  updateContext(options) {
789
- if (!this._room || !this._room.localParticipant) {
915
+ if (!this.isTransportReady())
790
916
  return;
791
- }
792
- // Validate: text is required unless mode is "reset"
793
- if (options.mode !== "reset" && !options.text?.trim()) {
794
- console.warn("[ConvaiClient] updateContext: text is required unless mode is 'reset'");
917
+ const hasText = !!options.text?.trim();
918
+ const hasAttention = options.current_attention_object !== undefined;
919
+ // text is required unless mode is "reset" or only updating attention
920
+ if (options.mode !== "reset" && !hasText && !hasAttention) {
921
+ console.warn("[ConvaiClient] updateContext: text or current_attention_object is required unless mode is 'reset'");
795
922
  return;
796
923
  }
797
- // When run_llm is "false" the server won't respond, so no conversation
798
- // lifecycle is needed. For "true", "auto", or unset the server may respond,
799
- // so we treat it the same as sendUserTextMessage: interrupt any active turn
800
- // and start a new conversation so the blendshape queue is ready to accept
801
- // the incoming frames.
802
924
  const willTriggerLlm = options.run_llm !== "false";
803
925
  if (willTriggerLlm) {
804
926
  if (this.blendshapeQueue.isConversationActive() ||
@@ -813,70 +935,38 @@ export class ConvaiClient extends EventEmitter {
813
935
  timestamp: this._conversationStartTime,
814
936
  });
815
937
  }
816
- const message = {
817
- type: "context-update",
818
- data: {
819
- ...(options.text && { text: options.text.trim() }),
820
- ...(options.mode && { mode: options.mode }),
821
- ...(options.run_llm && { run_llm: options.run_llm }),
822
- participant_sid: this._participantSid || this._room.localParticipant.sid,
823
- },
824
- };
825
- const encodedData = new TextEncoder().encode(JSON.stringify(message));
826
- this._room.localParticipant.publishData(encodedData, {
827
- reliable: true,
938
+ this.publishMessage("context-update", {
939
+ ...(hasText && { text: options.text.trim() }),
940
+ ...(options.mode && { mode: options.mode }),
941
+ ...(options.run_llm && { run_llm: options.run_llm }),
942
+ ...(hasAttention && { current_attention_object: options.current_attention_object }),
943
+ participant_sid: this._participantSid || this._room.localParticipant?.sid,
828
944
  });
829
945
  }
946
+ /**
947
+ * Send descriptive scene updates. Use when environment description changes
948
+ * and the bot should know what is visible or nearby. Does not modify action_config.
949
+ */
950
+ updateSceneMetadata(items) {
951
+ if (!this.isTransportReady())
952
+ return;
953
+ this.publishMessage("update-scene-metadata", { scene_metadata: items });
954
+ }
830
955
  /**
831
956
  * Toggle text-to-speech on or off
832
957
  */
833
958
  toggleTts(enabled) {
834
- if (!this._room ||
835
- this._room.state === "disconnected" ||
836
- !this._room.localParticipant) {
959
+ if (!this.isTransportReady())
837
960
  return;
838
- }
839
- try {
840
- const message = {
841
- type: "tts-toggle",
842
- data: {
843
- enabled: enabled,
844
- },
845
- };
846
- const encodedData = new TextEncoder().encode(JSON.stringify(message));
847
- this._room.localParticipant.publishData(encodedData, {
848
- reliable: true,
849
- });
850
- }
851
- catch (error) {
852
- throw error;
853
- }
961
+ this.publishMessage("tts-toggle", { enabled });
854
962
  }
855
963
  /**
856
964
  * Toggle speech-to-text on or off
857
965
  */
858
966
  toggleStt(enabled) {
859
- if (!this._room ||
860
- this._room.state === "disconnected" ||
861
- !this._room.localParticipant) {
967
+ if (!this.isTransportReady())
862
968
  return;
863
- }
864
- try {
865
- const message = {
866
- lable: "rtvi-ai",
867
- type: "stt-toggle",
868
- data: {
869
- muted: !enabled,
870
- },
871
- };
872
- const encodedData = new TextEncoder().encode(JSON.stringify(message));
873
- this._room.localParticipant.publishData(encodedData, {
874
- reliable: true,
875
- });
876
- }
877
- catch (error) {
878
- throw error;
879
- }
969
+ this.publishMessage("stt-toggle", { muted: !enabled });
880
970
  }
881
971
  /**
882
972
  * Reset the server-side idle timer.
@@ -884,26 +974,13 @@ export class ConvaiClient extends EventEmitter {
884
974
  * the session from being disconnected due to inactivity.
885
975
  */
886
976
  resetIdleTimer() {
887
- if (!this._room ||
888
- this._room.state === "disconnected" ||
889
- !this._room.localParticipant) {
977
+ if (!this.isTransportReady())
890
978
  return;
891
- }
892
- try {
893
- const message = {
894
- type: "reset-idle-timer",
895
- data: {
896
- participant_sid: this._participantSid || this._room.localParticipant.sid,
897
- },
898
- };
899
- const encodedData = new TextEncoder().encode(JSON.stringify(message));
900
- this._room.localParticipant.publishData(encodedData, {
901
- reliable: true,
902
- });
903
- }
904
- catch (error) {
905
- throw error;
906
- }
979
+ this.publishMessage("reset-idle-timer", {
980
+ participant_sid: this._participantSid || this._room.localParticipant?.sid,
981
+ });
907
982
  }
908
983
  }
984
+ ConvaiClient.CLIENT_READY_RETRY_MS = 500;
985
+ ConvaiClient.CLIENT_READY_TIMEOUT_MS = 45000;
909
986
  //# sourceMappingURL=ConvaiClient.js.map