@eshal-bot/chat-widget 0.1.44 → 0.1.46

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.
@@ -8589,7 +8589,10 @@
8589
8589
  * DEFAULT_SPEECH_GATE_CONFIG.
8590
8590
  *
8591
8591
  * @param {{ sampleRate: number, energyThreshold?: number, speechStartMs?: number, hangoverMs?: number, preRollMs?: number }} config
8592
- * @param {{ onVoiceFrame: (pcm: ArrayBuffer) => void, onSpeechEnd: () => void }} callbacks
8592
+ * @param {{ onSpeechStart?: () => void, onVoiceFrame: (pcm: ArrayBuffer) => void, onSpeechEnd: () => void }} callbacks
8593
+ * onSpeechStart fires the instant a turn opens — use it for barge-in
8594
+ * (stop any assistant TTS still playing). onVoiceFrame emits each voiced
8595
+ * PCM frame; onSpeechEnd fires when the turn closes after the hangover.
8593
8596
  */
8594
8597
  function createSpeechGate(config, callbacks) {
8595
8598
  var _config$energyThresho, _config$speechStartMs, _config$hangoverMs, _config$preRollMs;
@@ -8637,10 +8640,14 @@
8637
8640
  if (voiced) {
8638
8641
  voicedRunMs += durationMs;
8639
8642
  if (voicedRunMs >= cfg.speechStartMs) {
8640
- // Confirmed speech — open the turn and flush the pre-roll (which
8641
- // contains the voiced onset) before going live.
8643
+ var _callbacks$onSpeechSt;
8644
+ // Confirmed speech — open the turn. Fire onSpeechStart FIRST so the
8645
+ // caller can barge in (stop assistant TTS still playing) before we
8646
+ // start streaming this utterance, then flush the pre-roll (which
8647
+ // contains the voiced onset) and go live.
8642
8648
  speaking = true;
8643
8649
  silenceRunMs = 0;
8650
+ (_callbacks$onSpeechSt = callbacks.onSpeechStart) === null || _callbacks$onSpeechSt === void 0 || _callbacks$onSpeechSt.call(callbacks);
8644
8651
  for (const frame of preRoll) callbacks.onVoiceFrame(frame.pcm);
8645
8652
  preRoll = [];
8646
8653
  preRollMsBuffered = 0;
@@ -9055,6 +9062,27 @@
9055
9062
  };
9056
9063
  };
9057
9064
 
9065
+ // Merge the text (SSE) and voice (Bidi) message arrays into one chronological
9066
+ // transcript: tag text messages with a default source, dedupe by id, and sort
9067
+ // by timestamp. Used for BOTH live display (activeMessages) and the
9068
+ // localStorage snapshot — so voice turns persist across reload exactly like
9069
+ // text turns. (Previously the snapshot saved only the text array, so voice
9070
+ // vanished on reload even though it rendered live and was saved server-side
9071
+ // in the Inbox.)
9072
+ const mergeTranscript = (messages, bidiMessages) => {
9073
+ const allMessages = [...messages.map(msg => _objectSpread2(_objectSpread2({}, msg), {}, {
9074
+ metadata: msg.metadata || {
9075
+ source: "sse"
9076
+ }
9077
+ })), ...bidiMessages];
9078
+ const uniqueMessages = Array.from(new Map(allMessages.map(msg => [msg.id, msg])).values());
9079
+ return uniqueMessages.sort((a, b) => {
9080
+ const timeA = a.timestamp instanceof Date ? a.timestamp.getTime() : new Date(a.timestamp).getTime();
9081
+ const timeB = b.timestamp instanceof Date ? b.timestamp.getTime() : new Date(b.timestamp).getTime();
9082
+ return timeA - timeB;
9083
+ });
9084
+ };
9085
+
9058
9086
  // Helper functions for onboarding
9059
9087
  const getOnboardingQuestions = onboardingQuestions => {
9060
9088
  if (onboardingQuestions && onboardingQuestions.length > 0) {
@@ -9450,8 +9478,12 @@
9450
9478
  reactExports.useEffect(() => {
9451
9479
  if (!organizationId || !bidiSessionId) return;
9452
9480
  if (isConversationLoading || isLoading) return;
9453
- saveSessionMessages(organizationId, bidiSessionId, messages, resolvedMessageLimits);
9454
- }, [messages, organizationId, bidiSessionId, isConversationLoading, isLoading, resolvedMessageLimits]);
9481
+ // Persist the MERGED transcript (text + voice). Previously only `messages`
9482
+ // (text) was saved, so bidi/voice turns — which live in `bidiMessages` —
9483
+ // were dropped on reload even though they render live and are saved
9484
+ // server-side (Inbox). Merging here makes voice survive reload like text.
9485
+ saveSessionMessages(organizationId, bidiSessionId, mergeTranscript(messages, bidiMessages), resolvedMessageLimits);
9486
+ }, [messages, bidiMessages, organizationId, bidiSessionId, isConversationLoading, isLoading, resolvedMessageLimits]);
9455
9487
 
9456
9488
  // Show the onboarding form on first load; bypass it if already completed (persisted in session)
9457
9489
  reactExports.useEffect(() => {
@@ -10581,6 +10613,25 @@
10581
10613
  speechGateRef.current = createSpeechGate({
10582
10614
  sampleRate: 16000
10583
10615
  }, {
10616
+ // Barge-in: the instant the user starts talking, stop any assistant
10617
+ // TTS still playing so we don't talk over them. endOfAudio drops the
10618
+ // player's buffered audio immediately (readIndex → writeIndex). No-op
10619
+ // when nothing is playing (e.g. the user's first utterance).
10620
+ onSpeechStart: () => {
10621
+ var _audioPlayerNodeRef$c2;
10622
+ // Barge-in: user started talking → (1) stop assistant TTS already
10623
+ // buffered locally, and (2) tell the backend to cancel the in-flight
10624
+ // reply so its remaining TTS doesn't clobber the next turn's audio.
10625
+ (_audioPlayerNodeRef$c2 = audioPlayerNodeRef.current) === null || _audioPlayerNodeRef$c2 === void 0 || _audioPlayerNodeRef$c2.port.postMessage({
10626
+ command: "endOfAudio"
10627
+ });
10628
+ const ws = websocketRef.current;
10629
+ if (ws && ws.readyState === WebSocket.OPEN) {
10630
+ ws.send(JSON.stringify({
10631
+ type: "interrupt"
10632
+ }));
10633
+ }
10634
+ },
10584
10635
  onVoiceFrame: sendVoicePcm,
10585
10636
  onSpeechEnd: () => {
10586
10637
  const ws = websocketRef.current;
@@ -10753,11 +10804,11 @@
10753
10804
  }, [isVoiceSessionActive]);
10754
10805
  reactExports.useEffect(() => {
10755
10806
  return () => {
10756
- var _audioPlayerNodeRef$c2, _audioPlayerContextRe2, _audioRecorderNodeRef2, _audioRecorderContext2;
10807
+ var _audioPlayerNodeRef$c3, _audioPlayerContextRe2, _audioRecorderNodeRef2, _audioRecorderContext2;
10757
10808
  if (websocketRef.current && websocketRef.current.readyState === WebSocket.OPEN) {
10758
10809
  websocketRef.current.close();
10759
10810
  }
10760
- (_audioPlayerNodeRef$c2 = audioPlayerNodeRef.current) === null || _audioPlayerNodeRef$c2 === void 0 || _audioPlayerNodeRef$c2.disconnect();
10811
+ (_audioPlayerNodeRef$c3 = audioPlayerNodeRef.current) === null || _audioPlayerNodeRef$c3 === void 0 || _audioPlayerNodeRef$c3.disconnect();
10761
10812
  (_audioPlayerContextRe2 = audioPlayerContextRef.current) === null || _audioPlayerContextRe2 === void 0 || _audioPlayerContextRe2.close();
10762
10813
  (_audioRecorderNodeRef2 = audioRecorderNodeRef.current) === null || _audioRecorderNodeRef2 === void 0 || _audioRecorderNodeRef2.disconnect();
10763
10814
  (_audioRecorderContext2 = audioRecorderContextRef.current) === null || _audioRecorderContext2 === void 0 || _audioRecorderContext2.close();
@@ -10924,28 +10975,7 @@
10924
10975
 
10925
10976
  // Merge and sort messages from both sources (text and voice) by timestamp
10926
10977
  // This ensures all messages persist regardless of voice session state
10927
- const activeMessages = reactExports.useMemo(() => {
10928
- // Merge both message arrays
10929
- const allMessages = [
10930
- // Convert regular messages to include metadata if not present
10931
- ...messages.map(msg => _objectSpread2(_objectSpread2({}, msg), {}, {
10932
- metadata: msg.metadata || {
10933
- source: "sse"
10934
- }
10935
- })),
10936
- // Include bidi messages
10937
- ...bidiMessages];
10938
-
10939
- // Remove duplicates based on message ID (in case same message exists in both arrays)
10940
- const uniqueMessages = Array.from(new Map(allMessages.map(msg => [msg.id, msg])).values());
10941
-
10942
- // Sort by timestamp to maintain chronological order
10943
- return uniqueMessages.sort((a, b) => {
10944
- const timeA = a.timestamp instanceof Date ? a.timestamp.getTime() : new Date(a.timestamp).getTime();
10945
- const timeB = b.timestamp instanceof Date ? b.timestamp.getTime() : new Date(b.timestamp).getTime();
10946
- return timeA - timeB;
10947
- });
10948
- }, [bidiMessages, messages]);
10978
+ const activeMessages = reactExports.useMemo(() => mergeTranscript(messages, bidiMessages), [bidiMessages, messages]);
10949
10979
 
10950
10980
  // Log message state changes for debugging
10951
10981
  reactExports.useEffect(() => {