@eshal-bot/chat-widget 0.2.10 → 0.2.12

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-CJ4b6TSc.esm.js";import"react";import"react-dom/client";import"react/jsx-runtime";import"react-dom";
1
+ export{S as default}from"./chat-widget-index-DqpnMEPr.esm.js";import"react";import"react-dom/client";import"react/jsx-runtime";import"react-dom";
@@ -11945,6 +11945,11 @@
11945
11945
  };
11946
11946
  const useChatState = ({
11947
11947
  welcomeMessage,
11948
+ // The org's configured fallback. Shown in place of the generic
11949
+ // t("connectionError") when a turn dies before producing a token, so a
11950
+ // branded assistant fails in its own words. Undefined / empty keeps the
11951
+ // localised connection-error copy, so an unconfigured org is unchanged.
11952
+ fallbackMessage,
11948
11953
  quickQuestions = [],
11949
11954
  apiBaseUrl,
11950
11955
  wsBaseUrl,
@@ -12179,6 +12184,90 @@
12179
12184
  // Null when the SDK's IP/geo resolver is blocked - conversations from
12180
12185
  // those visitors fall into the "Other" bucket on the analytics dashboard.
12181
12186
  const visitorProfileRef = reactExports.useRef(null);
12187
+
12188
+ // EV-4047: high-water mark of the newest agent message we have already
12189
+ // merged, as an ISO timestamp. Seeded from the history fetch and advanced by
12190
+ // every SSE frame and every backfill, so a reconnect asks the server only
12191
+ // for what it actually missed.
12192
+ const lastAgentMessageAtRef = reactExports.useRef(null);
12193
+ const noteAgentMessageAt = reactExports.useCallback(createdAt => {
12194
+ if (!createdAt) return;
12195
+ const t = new Date(createdAt).getTime();
12196
+ if (!Number.isFinite(t)) return;
12197
+ const current = lastAgentMessageAtRef.current ? new Date(lastAgentMessageAtRef.current).getTime() : -Infinity;
12198
+ if (t > current) lastAgentMessageAtRef.current = new Date(t).toISOString();
12199
+ }, []);
12200
+
12201
+ // Merge a batch of server-side agent messages into the transcript, deduped
12202
+ // by id and re-sorted by timestamp. Shared by the mount-time history fetch
12203
+ // and the SSE backfill so both agree on ordering and on what counts as an
12204
+ // agent message.
12205
+ const mergeAgentMessages = reactExports.useCallback(agentMsgs => {
12206
+ if (!Array.isArray(agentMsgs) || agentMsgs.length === 0) return;
12207
+ for (const m of agentMsgs) noteAgentMessageAt(m?.createdAt);
12208
+ setMessages(prev => {
12209
+ const seen = new Set(prev.map(m => m.id));
12210
+ const additions = agentMsgs.filter(m => m && m.id && !seen.has(m.id)).map(m => ({
12211
+ ...createMessage({
12212
+ id: m.id,
12213
+ role: m.role || 'assistant',
12214
+ content: m.content,
12215
+ isProcessing: false,
12216
+ metadata: {
12217
+ senderName: m.senderName
12218
+ }
12219
+ }),
12220
+ timestamp: m.createdAt ? new Date(m.createdAt) : new Date()
12221
+ }));
12222
+ if (additions.length === 0) return prev;
12223
+ const tsMs = m => {
12224
+ const t = m.timestamp instanceof Date ? m.timestamp.getTime() : new Date(m.timestamp).getTime();
12225
+ return Number.isFinite(t) ? t : 0;
12226
+ };
12227
+ const merged = [...prev, ...additions];
12228
+ merged.sort((a, b) => tsMs(a) - tsMs(b));
12229
+ return merged;
12230
+ });
12231
+ }, [noteAgentMessageAt]);
12232
+
12233
+ /**
12234
+ * EV-4047 - backfill agent messages published while we had no live stream.
12235
+ *
12236
+ * `deliverWidgetMessageLocal` (backend) drops a push when the conversation
12237
+ * has no registered SSE connection: "nobody is listening" and "this replica
12238
+ * holds no connection" are the same early return. So any human-agent message
12239
+ * published in the window between the takeover and our EventSource actually
12240
+ * being registered server-side is gone for good - which is exactly the
12241
+ * reported symptom, the agent's FIRST reply missing while the second and
12242
+ * later ones arrive.
12243
+ *
12244
+ * Run on every `onopen`, which covers both the initial subscribe and the
12245
+ * browser's own silent EventSource auto-reconnect (our `onerror` never tore
12246
+ * the stream down, so a dropped stream previously lost everything sent
12247
+ * during the gap with nothing to notice it had happened).
12248
+ */
12249
+ const backfillAgentMessages = reactExports.useCallback(async () => {
12250
+ if (!apiBaseUrl || !bidiSessionId) return;
12251
+ try {
12252
+ const headers = {};
12253
+ if (typeof getAuthToken === 'function') {
12254
+ const token = await getAuthToken();
12255
+ if (token) headers['Authorization'] = `Bearer ${token}`;
12256
+ }
12257
+ const since = lastAgentMessageAtRef.current;
12258
+ const url = `${apiBaseUrl.replace(/\/$/, '')}/api/v1/widget/history/${encodeURIComponent(bidiSessionId)}` + (since ? `?since=${encodeURIComponent(since)}` : '');
12259
+ const res = await fetch(url, {
12260
+ headers,
12261
+ credentials: 'include'
12262
+ });
12263
+ if (!res.ok) return;
12264
+ const data = await res.json();
12265
+ const serverMsgs = Array.isArray(data?.messages) ? data.messages : [];
12266
+ mergeAgentMessages(serverMsgs.filter(m => m?.contentType === 'human_reply' || m?.contentType === 'handoff_ack'));
12267
+ } catch {
12268
+ /* network/parse failure - the live stream still carries new messages */
12269
+ }
12270
+ }, [apiBaseUrl, bidiSessionId, getAuthToken, mergeAgentMessages]);
12182
12271
  const startHandoffEventSource = reactExports.useCallback(async onConnected => {
12183
12272
  if (handoffEventSourceRef.current) {
12184
12273
  if (onConnected) onConnected();
@@ -12208,6 +12297,9 @@
12208
12297
  try {
12209
12298
  const data = JSON.parse(event.data);
12210
12299
  if (data.type === 'message_created' && data.message) {
12300
+ // Advance the backfill watermark so a later reconnect doesn't
12301
+ // re-request everything we have already rendered live.
12302
+ noteAgentMessageAt(data.message.createdAt);
12211
12303
  setMessages(prev => {
12212
12304
  if (prev.some(m => m.id === data.message.id)) return prev;
12213
12305
  return [...prev, createMessage({
@@ -12224,11 +12316,19 @@
12224
12316
  } catch {}
12225
12317
  };
12226
12318
  es.onopen = () => {
12319
+ // EV-4047: close the delivery gap BEFORE anything else. This fires on
12320
+ // the first subscribe and again on every browser-driven reconnect, and
12321
+ // messages published while we were not registered server-side were
12322
+ // dropped outright - the backfill is the only thing that recovers them.
12323
+ void backfillAgentMessages();
12227
12324
  if (onConnected) onConnected();
12228
12325
  };
12326
+ // Left empty deliberately: EventSource reconnects on its own and will fire
12327
+ // `onopen` again, which re-runs the backfill above. Closing the stream here
12328
+ // would disable that recovery.
12229
12329
  es.onerror = () => {};
12230
12330
  handoffEventSourceRef.current = es;
12231
- }, [apiBaseUrl, bidiSessionId, getAuthToken]);
12331
+ }, [apiBaseUrl, bidiSessionId, getAuthToken, backfillAgentMessages]);
12232
12332
  reactExports.useEffect(() => {
12233
12333
  return () => {
12234
12334
  if (handoffEventSourceRef.current) {
@@ -12480,28 +12580,12 @@
12480
12580
  const agentMsgs = serverMsgs.filter(m => m?.contentType === 'human_reply' || m?.contentType === 'handoff_ack');
12481
12581
 
12482
12582
  // Merge past human-agent replies into the transcript (regardless of
12483
- // current responder - they're real history worth showing).
12484
- if (!cancelled && agentMsgs.length > 0) {
12485
- setMessages(prev => {
12486
- const seen = new Set(prev.map(m => m.id));
12487
- const additions = agentMsgs.filter(m => !seen.has(m.id)).map(m => ({
12488
- ...createMessage({
12489
- id: m.id,
12490
- role: m.role || 'assistant',
12491
- content: m.content,
12492
- isProcessing: false
12493
- }),
12494
- timestamp: m.createdAt ? new Date(m.createdAt) : new Date()
12495
- }));
12496
- if (additions.length === 0) return prev;
12497
- const tsMs = m => {
12498
- const t = m.timestamp instanceof Date ? m.timestamp.getTime() : new Date(m.timestamp).getTime();
12499
- return Number.isFinite(t) ? t : 0;
12500
- };
12501
- const merged = [...prev, ...additions];
12502
- merged.sort((a, b) => tsMs(a) - tsMs(b));
12503
- return merged;
12504
- });
12583
+ // current responder - they're real history worth showing). Shares
12584
+ // mergeAgentMessages with the EV-4047 SSE backfill, which also seeds
12585
+ // the `since` watermark from what we just merged so the stream's first
12586
+ // `onopen` only asks for genuinely newer messages.
12587
+ if (!cancelled) {
12588
+ mergeAgentMessages(agentMsgs);
12505
12589
  }
12506
12590
 
12507
12591
  // Suppress the AI loader + open the live SSE ONLY when a human agent
@@ -12519,7 +12603,7 @@
12519
12603
  return () => {
12520
12604
  cancelled = true;
12521
12605
  };
12522
- }, [apiBaseUrl, organizationId, bidiSessionId, getAuthToken, startHandoffEventSource, markHandedOff]);
12606
+ }, [apiBaseUrl, organizationId, bidiSessionId, getAuthToken, startHandoffEventSource, markHandedOff, mergeAgentMessages]);
12523
12607
 
12524
12608
  // Persist prompt suggestions to localStorage so they survive page refresh
12525
12609
  reactExports.useEffect(() => {
@@ -13015,13 +13099,19 @@
13015
13099
  // apology bubble alone did not say WHICH message was lost, and the
13016
13100
  // user bubble kept looking exactly like a delivered one.
13017
13101
  setUserMessageStatus(sentUserMessageId, "failed");
13102
+ // Keep the diagnostic on the console REGARDLESS of what the customer
13103
+ // is shown: the org fallback reads as "I don't know that", so once it
13104
+ // is on screen an outage and a knowledge gap are indistinguishable to
13105
+ // whoever is looking at the widget. Support needs to tell them apart.
13106
+ console.error("[widget] send failed before any token; showing the org fallback. " + "This is a FAILED TURN, not a knowledge gap.");
13018
13107
  setMessages(prev => {
13019
13108
  const filtered = (prev || []).filter(msg => !msg.isProcessing);
13020
13109
  const errorMessage = createMessage({
13021
13110
  id: getNextMessageId(),
13022
13111
  role: "assistant",
13023
- content: t("connectionError"),
13112
+ content: fallbackMessage?.trim() || t("connectionError"),
13024
13113
  isProcessing: false,
13114
+ // Still an error for styling / analytics: only the COPY changes.
13025
13115
  isError: true
13026
13116
  });
13027
13117
  return [...filtered, errorMessage];
@@ -14051,7 +14141,7 @@
14051
14141
  throw error;
14052
14142
  }
14053
14143
  }, [handleRecorderAudio]);
14054
- const connectWebsocket = reactExports.useCallback(() => {
14144
+ const connectWebsocket = reactExports.useCallback(async () => {
14055
14145
  if (typeof window === "undefined" || !websocketUrl) {
14056
14146
  return;
14057
14147
  }
@@ -14060,8 +14150,46 @@
14060
14150
  }
14061
14151
  setVoiceStatus("connecting");
14062
14152
  setVoiceError(null);
14153
+
14154
+ // EV-4119: the voice socket now requires a ticket minted by the backend
14155
+ // behind verified widget auth. Previously the org id in the URL path WAS
14156
+ // the credential - and org ids are public, since this widget ships one
14157
+ // into the host page - so anyone could hold a voice session as any org.
14158
+ //
14159
+ // A browser cannot set headers on a WebSocket handshake (the same reason
14160
+ // the SSE route above takes `?token=`), so the ticket travels in the
14161
+ // query string. It is short-lived and bound to this session id, so it is
14162
+ // fetched per connect rather than cached.
14163
+ let voiceTicket;
14164
+ try {
14165
+ const hdrs = {
14166
+ 'Content-Type': 'application/json'
14167
+ };
14168
+ if (typeof getAuthToken === 'function') {
14169
+ const token = await getAuthToken();
14170
+ if (token) hdrs['Authorization'] = `Bearer ${token}`;
14171
+ }
14172
+ const res = await fetch(`${apiBaseUrl.replace(/\/$/, '')}/api/v1/widget/voice-ticket`, {
14173
+ method: 'POST',
14174
+ headers: hdrs,
14175
+ credentials: 'include',
14176
+ body: JSON.stringify({
14177
+ sessionId: bidiSessionId
14178
+ })
14179
+ });
14180
+ if (!res.ok) throw new Error(`voice-ticket ${res.status}`);
14181
+ voiceTicket = (await res.json())?.ticket;
14182
+ if (!voiceTicket) throw new Error('voice-ticket returned no ticket');
14183
+ } catch (err) {
14184
+ // Surface through the existing voice error taxonomy rather than
14185
+ // half-opening a socket the server is going to refuse anyway.
14186
+ console.error('[BIDI] could not obtain a voice session ticket', err);
14187
+ setVoiceStatus('error');
14188
+ setVoiceError(voiceErrorCode(err));
14189
+ return;
14190
+ }
14063
14191
  try {
14064
- const ws = new WebSocket(websocketUrl);
14192
+ const ws = new WebSocket(`${websocketUrl}?ticket=${encodeURIComponent(voiceTicket)}`);
14065
14193
  websocketRef.current = ws;
14066
14194
  ws.onopen = () => {
14067
14195
  setVoiceStatus("connected");
@@ -14110,7 +14238,7 @@
14110
14238
  setVoiceStatus("error");
14111
14239
  setVoiceError(voiceErrorCode(error));
14112
14240
  }
14113
- }, [handleBidiEvent, websocketUrl]);
14241
+ }, [handleBidiEvent, websocketUrl, apiBaseUrl, bidiSessionId, getAuthToken]);
14114
14242
  const startVoiceSession = reactExports.useCallback(async () => {
14115
14243
  if (!enableVoiceInteraction || !organizationId) {
14116
14244
  return;
@@ -14262,6 +14390,10 @@
14262
14390
  isHandedOffRef.current = false;
14263
14391
  setIsHandedOff(false);
14264
14392
  serverHistoryFetchedRef.current = null;
14393
+ // EV-4047: the backfill watermark belongs to the OLD conversation. Left
14394
+ // set, the new session's first `onopen` would ask for messages after a
14395
+ // timestamp from a conversation it has nothing to do with.
14396
+ lastAgentMessageAtRef.current = null;
14265
14397
  if (handoffEventSourceRef.current) {
14266
14398
  handoffEventSourceRef.current.close();
14267
14399
  handoffEventSourceRef.current = null;
@@ -18270,7 +18402,14 @@
18270
18402
  const handleKeyPress = event => {
18271
18403
  if (event.key === "Enter" && !event.shiftKey) {
18272
18404
  event.preventDefault();
18273
- if (isVoiceSessionActive && onBidiSubmit) {
18405
+ // EV-4179: the voice WS only carries text, so a file attached during a
18406
+ // live voice session can't ride it. When there's a ready attachment we
18407
+ // fall through to the normal text send (sendWithAttachments) instead,
18408
+ // which POSTs to /support with the SAME conversationId as the voice
18409
+ // session - so the file is ingested into the shared session store and
18410
+ // answered, and later spoken turns in the same session see it too. A
18411
+ // plain voice turn (no attachment) still goes over the WS.
18412
+ if (isVoiceSessionActive && onBidiSubmit && !hasReadyAttachment) {
18274
18413
  onBidiSubmit();
18275
18414
  return;
18276
18415
  }
@@ -18652,7 +18791,7 @@
18652
18791
  fill: "currentColor"
18653
18792
  })
18654
18793
  }) : /*#__PURE__*/jsxRuntimeExports.jsx("button", {
18655
- onClick: isVoiceSessionActive && onBidiSubmit ? onBidiSubmit : sendWithAttachments,
18794
+ onClick: isVoiceSessionActive && onBidiSubmit && !hasReadyAttachment ? onBidiSubmit : sendWithAttachments,
18656
18795
  disabled: csatVisible || isDisabled || isVoiceSessionActive && voiceStatus !== "connected",
18657
18796
  className: `h-10 w-10 flex-shrink-0 rounded-xl transition-all duration-200 flex items-center justify-center shadow-md disabled:opacity-40 disabled:cursor-not-allowed disabled:shadow-none ${inputValue.trim() ? "scale-100" : "scale-95"}`,
18658
18797
  style: {
@@ -20990,7 +21129,7 @@
20990
21129
  * value by its own test (that constant sat at 0.1.113 while this package was
20991
21130
  * at 0.2.2 precisely because nothing enforced it - EV-2431).
20992
21131
  */
20993
- const WIDGET_VERSION = '0.2.10';
21132
+ const WIDGET_VERSION = '0.2.12';
20994
21133
 
20995
21134
  /**
20996
21135
  * API utility functions
@@ -21209,6 +21348,12 @@
21209
21348
  const enBundle = messages.bundles?.["en"] ?? {};
21210
21349
  const bundle = {
21211
21350
  welcome: localeBundle.welcome || enBundle.welcome,
21351
+ // Same per-field English fallback as `welcome` above, for the same
21352
+ // reason. Read so useChatState can show the org's own words when a turn
21353
+ // dies before producing a token, instead of the generic connection
21354
+ // error: the customer is looking at a branded assistant, and that
21355
+ // sentence is the one thing on screen that is not theirs.
21356
+ fallback: localeBundle.fallback || enBundle.fallback,
21212
21357
  quickQuestions: Array.isArray(localeBundle.quickQuestions) && localeBundle.quickQuestions.length > 0 ? localeBundle.quickQuestions : enBundle.quickQuestions
21213
21358
  };
21214
21359
 
@@ -21225,6 +21370,10 @@
21225
21370
  chatbotAvatarEnabled: branding.chatbotAvatarEnabled ?? false,
21226
21371
  chatbotAvatar: branding.chatbotAvatar ?? null,
21227
21372
  welcomeMessage: welcomeMessage ?? bundle.welcome ?? "Hi! How can we help?",
21373
+ // No literal default: an org that has not configured a fallback keeps
21374
+ // the widget's localised connection-error copy (t("connectionError")),
21375
+ // which is the pre-existing behaviour.
21376
+ fallbackMessage: bundle.fallback,
21228
21377
  quickQuestions: quickQuestions ?? bundle.quickQuestions ?? [],
21229
21378
  quickQuestionsEnabled: quickQuestionsEnabled ?? messages.quickQuestionsEnabled,
21230
21379
  textColor: textColor ?? "#000000",
@@ -21364,6 +21513,9 @@
21364
21513
  addAssistantMessage
21365
21514
  } = useChatState(widgetConfig ? {
21366
21515
  welcomeMessage: widgetConfig.welcomeMessage,
21516
+ // Per the EV-3813 note below, this hook produces customer-facing
21517
+ // failure copy, so it gets the org's configured fallback too.
21518
+ fallbackMessage: widgetConfig.fallbackMessage,
21367
21519
  quickQuestions: widgetConfig.quickQuestions,
21368
21520
  apiBaseUrl,
21369
21521
  wsBaseUrl: wsBaseUrl ?? agentConfig?.wsBaseUrl,