@eshal-bot/chat-widget 0.2.40 → 0.2.42

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 +1 @@
1
- export{S as default}from"./chat-widget-index-BubaEkmK.esm.js";import"react";import"react-dom/client";import"react/jsx-runtime";import"react-dom";
1
+ export{S as default}from"./chat-widget-index-LsKr3paz.esm.js";import"react";import"react-dom/client";import"react/jsx-runtime";import"react-dom";
@@ -8948,8 +8948,9 @@
8948
8948
  * is a small energy-based state machine between the mic and the socket: it
8949
8949
  * forwards audio ONLY inside a confirmed speech turn.
8950
8950
  *
8951
- * - idle -> speech needs sustained voiced energy (`speechStartMs`), so a
8952
- * click, breath or keyboard tap never arms a turn;
8951
+ * - idle -> speech needs `speechStartMs` of voiced energy inside the last
8952
+ * `speechWindowMs`, so a click, breath or keyboard tap never arms a turn,
8953
+ * while a quiet frame inside a short word does not throw the onset away;
8953
8954
  * - on open it fires `onSpeechStart` (barge-in: stop assistant TTS), then
8954
8955
  * flushes `preRollMs` of buffered audio so the first word is not clipped;
8955
8956
  * - while speaking it forwards every frame, including short pauses;
@@ -8965,8 +8966,18 @@
8965
8966
  // RMS energy (0..1) at/above which a frame counts as voiced. Speech is
8966
8967
  // typically 0.05-0.3; quiet room tone sits well under 0.02.
8967
8968
  energyThreshold: 0.025,
8968
- // Continuous voiced audio (ms) required to OPEN a turn - rejects coughs/clicks.
8969
- speechStartMs: 200,
8969
+ // Voiced audio (ms) inside `speechWindowMs` required to OPEN a turn -
8970
+ // rejects coughs/clicks. EV-4952: this was 200 ms of strictly continuous
8971
+ // voiced frames, and on dev (4 Nov 2026) short answers like "yes" and
8972
+ // "hello" often never opened the gate: at 8 ms frames one quiet frame
8973
+ // between syllables reset the run, so the caller had to repeat themselves.
8974
+ speechStartMs: 120,
8975
+ // Sliding window (ms) the voiced audio above is counted over.
8976
+ speechWindowMs: 200,
8977
+ // Continuous quiet (ms) that abandons a half-built onset. A dip shorter
8978
+ // than this (a plosive, the gap inside "hel-lo") is tolerated; anything
8979
+ // longer is a real pause, so isolated pops cannot add up over time.
8980
+ onsetGapMs: 40,
8970
8981
  // Continuous silence (ms) after speech that CLOSES the turn.
8971
8982
  hangoverMs: 1000,
8972
8983
  // Audio (ms) preceding turn-open to retain and flush.
@@ -8998,12 +9009,18 @@
8998
9009
  sampleRate: config.sampleRate,
8999
9010
  energyThreshold: pick(config.energyThreshold, DEFAULT_SPEECH_GATE_CONFIG.energyThreshold),
9000
9011
  speechStartMs: pick(config.speechStartMs, DEFAULT_SPEECH_GATE_CONFIG.speechStartMs),
9012
+ speechWindowMs: pick(config.speechWindowMs, DEFAULT_SPEECH_GATE_CONFIG.speechWindowMs),
9013
+ onsetGapMs: pick(config.onsetGapMs, DEFAULT_SPEECH_GATE_CONFIG.onsetGapMs),
9001
9014
  hangoverMs: pick(config.hangoverMs, DEFAULT_SPEECH_GATE_CONFIG.hangoverMs),
9002
9015
  preRollMs: pick(config.preRollMs, DEFAULT_SPEECH_GATE_CONFIG.preRollMs)
9003
9016
  };
9004
9017
  let speaking = false;
9005
- // While idle: how much consecutive voiced audio has been seen (to open).
9006
- let voicedRunMs = 0;
9018
+ // While idle: recent frames ({ durationMs, voiced }) covering the last
9019
+ // `speechWindowMs`, with their voiced total, plus the current quiet run.
9020
+ let onset = [];
9021
+ let onsetMs = 0;
9022
+ let voicedMs = 0;
9023
+ let quietRunMs = 0;
9007
9024
  // While speaking: how much consecutive silence has been seen (to close).
9008
9025
  let silenceRunMs = 0;
9009
9026
  // Ring of recent frames kept only while idle, for pre-roll on open.
@@ -9014,9 +9031,15 @@
9014
9031
  const frameDurationMs = function (pcm) {
9015
9032
  return pcm.byteLength / 2 / cfg.sampleRate * 1000;
9016
9033
  };
9034
+ const clearOnset = function () {
9035
+ onset = [];
9036
+ onsetMs = 0;
9037
+ voicedMs = 0;
9038
+ quietRunMs = 0;
9039
+ };
9017
9040
  const reset = function () {
9018
9041
  speaking = false;
9019
- voicedRunMs = 0;
9042
+ clearOnset();
9020
9043
  silenceRunMs = 0;
9021
9044
  preRoll = [];
9022
9045
  preRollMsBuffered = 0;
@@ -9036,22 +9059,35 @@
9036
9059
  const dropped = preRoll.shift();
9037
9060
  preRollMsBuffered -= dropped ? dropped.durationMs : 0;
9038
9061
  }
9062
+ onset.push({
9063
+ durationMs: durationMs,
9064
+ voiced: voiced
9065
+ });
9066
+ onsetMs += durationMs;
9067
+ if (voiced) voicedMs += durationMs;
9068
+ while (onsetMs > cfg.speechWindowMs && onset.length > 1) {
9069
+ const old = onset.shift();
9070
+ onsetMs -= old.durationMs;
9071
+ if (old.voiced) voicedMs -= old.durationMs;
9072
+ }
9039
9073
  if (voiced) {
9040
- voicedRunMs += durationMs;
9041
- if (voicedRunMs >= cfg.speechStartMs) {
9074
+ quietRunMs = 0;
9075
+ if (voicedMs >= cfg.speechStartMs) {
9042
9076
  // Confirmed speech: open the turn. onSpeechStart first so the
9043
9077
  // caller can barge in, then flush the pre-roll and go live.
9044
9078
  speaking = true;
9045
9079
  silenceRunMs = 0;
9080
+ clearOnset();
9046
9081
  if (typeof callbacks.onSpeechStart === 'function') callbacks.onSpeechStart();
9047
9082
  for (let i = 0; i < preRoll.length; i += 1) callbacks.onVoiceFrame(preRoll[i].pcm);
9048
9083
  preRoll = [];
9049
9084
  preRollMsBuffered = 0;
9050
9085
  }
9051
9086
  } else {
9052
- // Sustained energy only: any silent frame resets the run so a brief
9053
- // transient cannot accumulate its way over the threshold.
9054
- voicedRunMs = 0;
9087
+ // A brief dip is tolerated; a real pause abandons the onset so a
9088
+ // stray transient cannot accumulate its way over the threshold.
9089
+ quietRunMs += durationMs;
9090
+ if (quietRunMs >= cfg.onsetGapMs) clearOnset();
9055
9091
  }
9056
9092
  return;
9057
9093
  }
@@ -9065,7 +9101,7 @@
9065
9101
  silenceRunMs += durationMs;
9066
9102
  if (silenceRunMs >= cfg.hangoverMs) {
9067
9103
  speaking = false;
9068
- voicedRunMs = 0;
9104
+ clearOnset();
9069
9105
  silenceRunMs = 0;
9070
9106
  callbacks.onSpeechEnd();
9071
9107
  }
@@ -12278,6 +12314,54 @@
12278
12314
  return "voiceUnavailable";
12279
12315
  }
12280
12316
 
12317
+ /**
12318
+ * The spoken greeting voice-runtime sends as soon as a call connects (EV-4952).
12319
+ *
12320
+ * The server sends `{"greeting": {"text": "..."}}` once, before the greeting
12321
+ * audio, then the audio frames as usual, then `turnComplete`. Until now the
12322
+ * widget dropped every bot reply before the user had spoken, so on the dev
12323
+ * call UR-0016 (Aurelia org org_1793698095653_c341070ff1, 4 Nov 2026) the
12324
+ * caller sat in silence until they spoke first. The marker is what lets the
12325
+ * client tell the greeting apart from a stray reply and play it.
12326
+ *
12327
+ * A client that never gets the marker behaves exactly as before. Mirrors
12328
+ * packages/frontend/src/components/chatbot-preview/voiceGreeting.ts.
12329
+ */
12330
+
12331
+ /** The greeting text if the event is the greeting marker, else null. */
12332
+ function voiceGreetingText(event) {
12333
+ const text = event?.greeting?.text;
12334
+ if (typeof text !== "string") return null;
12335
+ const trimmed = text.trim();
12336
+ return trimmed === "" ? null : trimmed;
12337
+ }
12338
+
12339
+ /**
12340
+ * The bubbles after the greeting arrives.
12341
+ *
12342
+ * - The static welcome this call added (`welcomeId`) is replaced by the
12343
+ * greeting, so the call does not show both.
12344
+ * - A bubble already on screen with the same text (a welcome shown before the
12345
+ * call started, in either list) is kept and no duplicate is added.
12346
+ * - Otherwise the greeting is appended as the bot's bubble.
12347
+ */
12348
+ function applyVoiceGreeting(prev, text, welcomeId, others, makeBubble) {
12349
+ if (welcomeId) {
12350
+ const at = prev.findIndex(m => m.id === welcomeId);
12351
+ if (at >= 0) {
12352
+ const next = [...prev];
12353
+ next[at] = {
12354
+ ...next[at],
12355
+ content: text
12356
+ };
12357
+ return next;
12358
+ }
12359
+ }
12360
+ const shown = [...prev, ...(others || [])].some(m => m.role === "assistant" && typeof m.content === "string" && m.content.trim() === text);
12361
+ if (shown) return prev;
12362
+ return [...prev, makeBubble(text)];
12363
+ }
12364
+
12281
12365
  /**
12282
12366
  * Replies the widget never streamed itself.
12283
12367
  *
@@ -13267,6 +13351,14 @@
13267
13351
  const lastProcessedAssistantContentRef = reactExports.useRef(null); // Track last processed content to prevent duplicates
13268
13352
  const userHasSpokenRef = reactExports.useRef(false); // Track if user has sent first message - prevents automatic greetings
13269
13353
  const turnHasTranscriptionRef = reactExports.useRef(false); // True when outputTranscription received this turn - skip content.parts text
13354
+ // EV-4952: true from the server's greeting marker until its turnComplete
13355
+ // (or a barge-in), so the greeting audio plays before the user has spoken.
13356
+ const greetingActiveRef = reactExports.useRef(false);
13357
+ const voiceWelcomeIdRef = reactExports.useRef(null); // the static welcome bubble this voice call added, if any
13358
+ // The text-mode bubbles, read by the greeting de-dup without re-binding the
13359
+ // socket handler on every message.
13360
+ const voiceTextMessagesRef = reactExports.useRef(messages);
13361
+ voiceTextMessagesRef.current = messages;
13270
13362
  const stopVoiceSessionRef = reactExports.useRef(null); // Ref to avoid referencing stopVoiceSession before it is defined in handleBidiEvent
13271
13363
  const pendingVoiceSourcesRef = reactExports.useRef(null); // Hold sources until the assistant bubble for this turn exists
13272
13364
  // Anonymous browser fingerprint from @eshal-tracker/sdk. Resolved
@@ -14998,6 +15090,7 @@
14998
15090
  currentAssistantMessageIdRef.current = null;
14999
15091
  currentInputMessageIdRef.current = null;
15000
15092
  turnHasTranscriptionRef.current = false; // reset for next turn
15093
+ greetingActiveRef.current = false; // the greeting turn, if any, is over
15001
15094
  pendingVoiceSourcesRef.current = null;
15002
15095
  }, []);
15003
15096
  const handleInterrupted = reactExports.useCallback(() => {
@@ -15009,6 +15102,7 @@
15009
15102
  }
15010
15103
  currentAssistantMessageIdRef.current = null;
15011
15104
  currentInputMessageIdRef.current = null;
15105
+ greetingActiveRef.current = false; // a barge-in over the greeting ends it
15012
15106
  pendingVoiceSourcesRef.current = null;
15013
15107
  setBidiMessages(prev => prev.map(message => message.metadata?.source === "bidi" ? {
15014
15108
  ...message,
@@ -15226,6 +15320,35 @@
15226
15320
  return;
15227
15321
  }
15228
15322
 
15323
+ // EV-4952: voice-runtime greets the caller as soon as the call connects
15324
+ // and marks it with {"greeting": {"text"}} before the audio. Show the
15325
+ // text as the bot's bubble now (replacing this call's static welcome,
15326
+ // never duplicating one already on screen) and let the audio that
15327
+ // follows play until turnComplete, even though the user has not spoken.
15328
+ const greetingText = voiceGreetingText(event);
15329
+ if (greetingText) {
15330
+ greetingActiveRef.current = true;
15331
+ setBidiMessages(prev => applyVoiceGreeting(prev, greetingText, voiceWelcomeIdRef.current, voiceTextMessagesRef.current, text => createMessage({
15332
+ id: `bidi-greeting-${Date.now()}`,
15333
+ role: "assistant",
15334
+ content: text,
15335
+ metadata: {
15336
+ source: "bidi"
15337
+ }
15338
+ })));
15339
+ return;
15340
+ }
15341
+ if (greetingActiveRef.current && !userHasSpokenRef.current) {
15342
+ // The greeting's text is already on screen: play its audio only.
15343
+ if (event.content?.parts) {
15344
+ handleContentParts(event.content.parts, Boolean(event.content?.finished || event.finished), true);
15345
+ }
15346
+ if (event.turnComplete) {
15347
+ handleTurnComplete();
15348
+ }
15349
+ return;
15350
+ }
15351
+
15229
15352
  // CRITICAL: Ignore assistant responses until user has spoken
15230
15353
  // This prevents automatic greetings from appearing before user's first message
15231
15354
  if (!userHasSpokenRef.current) {
@@ -15523,6 +15646,8 @@
15523
15646
 
15524
15647
  // Reset userHasSpoken flag when starting new session
15525
15648
  userHasSpokenRef.current = false;
15649
+ greetingActiveRef.current = false;
15650
+ voiceWelcomeIdRef.current = null;
15526
15651
  setBidiMessages(prev => {
15527
15652
  // Don't add welcome message if there are already any messages (either bidi or text)
15528
15653
  // This prevents showing welcome message when turning voice on again after a conversation
@@ -15543,6 +15668,8 @@
15543
15668
  source: "bidi"
15544
15669
  }
15545
15670
  });
15671
+ // Remembered so a spoken greeting (EV-4952) replaces it, not doubles it.
15672
+ voiceWelcomeIdRef.current = welcomeMsg.id;
15546
15673
  return [welcomeMsg];
15547
15674
  });
15548
15675
  connectWebsocket();
@@ -16497,6 +16624,49 @@
16497
16624
  return typeof u === 'string' && /^https?:\/\//i.test(u) ? u : undefined;
16498
16625
  }
16499
16626
 
16627
+ /**
16628
+ * What a Copy button puts on the clipboard (EV-4817, extended to every chat
16629
+ * surface): the words of a message without its card specs.
16630
+ *
16631
+ * A reply can carry ```eshal-ui``` blocks (buttons, forms, cards) that the
16632
+ * chat renders as controls. Copying the raw message put that JSON on the
16633
+ * clipboard. Same fences and tidy-up as the backend's
16634
+ * channels/utils/emailFormat.ts stripA2uiFences, so both sides agree on what
16635
+ * a fence is. Shared by the frontend (Inbox, Testing lab), the widget and
16636
+ * the agent-sdk.
16637
+ *
16638
+ * Plain JavaScript (types in clipboardText.d.ts), conservative syntax: the
16639
+ * widget's Rollup/Babel build compiles neither TypeScript nor this file.
16640
+ */
16641
+
16642
+ const A2UI_FENCE_TAGS = /(?:eshal-ui|eshal-chart|chart)\b/;
16643
+ function a2uiFenceRe() {
16644
+ return /```[ \t]*(?:eshal-ui|eshal-chart|chart)\b[ \t]*\r?\n([\s\S]*?)```/g;
16645
+ }
16646
+ function stripA2uiFences(text) {
16647
+ if (!text || !A2UI_FENCE_TAGS.test(text)) return text;
16648
+ return text.replace(a2uiFenceRe(), '').replace(/\n{3,}/g, '\n\n').trim();
16649
+ }
16650
+
16651
+ /**
16652
+ * The whole-chat "Copy Chat" text: one "Role: words" stanza per message,
16653
+ * blank line between. A message that is only a card has no words, so it is
16654
+ * left out like any other empty message.
16655
+ */
16656
+ function formatChatForClipboard(messages, assistantName) {
16657
+ if (!Array.isArray(messages)) return '';
16658
+ const label = assistantName || 'Assistant';
16659
+ const lines = [];
16660
+ for (let i = 0; i < messages.length; i++) {
16661
+ const msg = messages[i];
16662
+ if (!msg || typeof msg.content !== 'string') continue;
16663
+ const words = stripA2uiFences(msg.content);
16664
+ if (!words || words.trim() === '') continue;
16665
+ lines.push((msg.role === 'user' ? 'You' : label) + ': ' + words);
16666
+ }
16667
+ return lines.join('\n\n');
16668
+ }
16669
+
16500
16670
  /**
16501
16671
  * WidgetCitationContext - the message's `sources` array + download handler,
16502
16672
  * supplied by `<MessageBubble>` and consumed by every `<CitationPill>` that
@@ -17255,6 +17425,9 @@
17255
17425
  // `stripCitationMarkers` call and the separate `<MessageSources>`
17256
17426
  // footer were removed in the same refactor.
17257
17427
  const displayContent = rawContent;
17428
+ // Copy puts the words on the clipboard, not the ```eshal-ui``` card JSON;
17429
+ // a card-only reply has no words, so it gets no Copy button.
17430
+ const copyText = reactExports.useMemo(() => stripA2uiFences(displayContent), [displayContent]);
17258
17431
  const isTypingMessage = message.isProcessing && (!displayContent || displayContent.length === 0);
17259
17432
  // EV-2351: direction must follow THIS message's own text, not the agent's
17260
17433
  // configured default language. The blanket branding `isRtl` forced English
@@ -17476,8 +17649,8 @@
17476
17649
  className: `text-xs leading-none ${isDark ? "text-gray-400" : "text-gray-500"}`,
17477
17650
  dir: "auto",
17478
17651
  children: formatTime(message.timestamp)
17479
- }), !isTypingMessage && !message.isProcessing && displayContent && /*#__PURE__*/jsxRuntimeExports.jsx(CopyMessageButton, {
17480
- content: displayContent,
17652
+ }), !isTypingMessage && !message.isProcessing && copyText && /*#__PURE__*/jsxRuntimeExports.jsx(CopyMessageButton, {
17653
+ content: copyText,
17481
17654
  isDark: isDark,
17482
17655
  t: t
17483
17656
  }), allowFeedback !== false && onFeedback && !isTypingMessage && !message.isProcessing && displayContent && message.type !== 'welcome' && !message.isWelcome && /*#__PURE__*/jsxRuntimeExports.jsxs("div", {
@@ -23239,7 +23412,7 @@
23239
23412
  * value by its own test (that constant sat at 0.1.113 while this package was
23240
23413
  * at 0.2.2 precisely because nothing enforced it - EV-2431).
23241
23414
  */
23242
- const WIDGET_VERSION = '0.2.40';
23415
+ const WIDGET_VERSION = '0.2.42';
23243
23416
 
23244
23417
  /**
23245
23418
  * API utility functions
@@ -23761,10 +23934,8 @@
23761
23934
 
23762
23935
  // Copy chat handler - copy all messages to clipboard
23763
23936
  const handleCopyChat = reactExports.useCallback(() => {
23764
- const text = messages.filter(msg => msg.content && msg.content.trim() !== "").map(msg => {
23765
- const role = msg.role === "user" ? "You" : widgetConfig?.conciergeName || "Assistant";
23766
- return `${role}: ${msg.content}`;
23767
- }).join("\n\n");
23937
+ // Words only: ```eshal-ui``` card JSON stays out of the transcript.
23938
+ const text = formatChatForClipboard(messages, widgetConfig?.conciergeName);
23768
23939
  if (text) {
23769
23940
  navigator.clipboard.writeText(text).catch(() => {});
23770
23941
  }