@eshal-bot/chat-widget 0.2.10 → 0.2.11
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.
- package/dist/{chat-widget-MarkdownRenderer-CGbeN7oT.esm.js → chat-widget-MarkdownRenderer-DJIte38L.esm.js} +6 -6
- package/dist/{chat-widget-index-CJ4b6TSc.esm.js → chat-widget-index-BIs09ktJ.esm.js} +3 -3
- package/dist/chat-widget-markdown-chunk.js +63 -3
- package/dist/chat-widget-markdown-chunk.min.js +9 -9
- package/dist/chat-widget.esm.js +1 -1
- package/dist/chat-widget.js +174 -29
- package/dist/chat-widget.min.js +4 -4
- package/dist/chat-widget.umd.js +6 -6
- package/package.json +1 -1
package/dist/chat-widget.esm.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export{S as default}from"./chat-widget-index-
|
|
1
|
+
export{S as default}from"./chat-widget-index-BIs09ktJ.esm.js";import"react";import"react-dom/client";import"react/jsx-runtime";import"react-dom";
|
package/dist/chat-widget.js
CHANGED
|
@@ -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
|
-
|
|
12485
|
-
|
|
12486
|
-
|
|
12487
|
-
|
|
12488
|
-
|
|
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;
|
|
@@ -20990,7 +21122,7 @@
|
|
|
20990
21122
|
* value by its own test (that constant sat at 0.1.113 while this package was
|
|
20991
21123
|
* at 0.2.2 precisely because nothing enforced it - EV-2431).
|
|
20992
21124
|
*/
|
|
20993
|
-
const WIDGET_VERSION = '0.2.
|
|
21125
|
+
const WIDGET_VERSION = '0.2.11';
|
|
20994
21126
|
|
|
20995
21127
|
/**
|
|
20996
21128
|
* API utility functions
|
|
@@ -21209,6 +21341,12 @@
|
|
|
21209
21341
|
const enBundle = messages.bundles?.["en"] ?? {};
|
|
21210
21342
|
const bundle = {
|
|
21211
21343
|
welcome: localeBundle.welcome || enBundle.welcome,
|
|
21344
|
+
// Same per-field English fallback as `welcome` above, for the same
|
|
21345
|
+
// reason. Read so useChatState can show the org's own words when a turn
|
|
21346
|
+
// dies before producing a token, instead of the generic connection
|
|
21347
|
+
// error: the customer is looking at a branded assistant, and that
|
|
21348
|
+
// sentence is the one thing on screen that is not theirs.
|
|
21349
|
+
fallback: localeBundle.fallback || enBundle.fallback,
|
|
21212
21350
|
quickQuestions: Array.isArray(localeBundle.quickQuestions) && localeBundle.quickQuestions.length > 0 ? localeBundle.quickQuestions : enBundle.quickQuestions
|
|
21213
21351
|
};
|
|
21214
21352
|
|
|
@@ -21225,6 +21363,10 @@
|
|
|
21225
21363
|
chatbotAvatarEnabled: branding.chatbotAvatarEnabled ?? false,
|
|
21226
21364
|
chatbotAvatar: branding.chatbotAvatar ?? null,
|
|
21227
21365
|
welcomeMessage: welcomeMessage ?? bundle.welcome ?? "Hi! How can we help?",
|
|
21366
|
+
// No literal default: an org that has not configured a fallback keeps
|
|
21367
|
+
// the widget's localised connection-error copy (t("connectionError")),
|
|
21368
|
+
// which is the pre-existing behaviour.
|
|
21369
|
+
fallbackMessage: bundle.fallback,
|
|
21228
21370
|
quickQuestions: quickQuestions ?? bundle.quickQuestions ?? [],
|
|
21229
21371
|
quickQuestionsEnabled: quickQuestionsEnabled ?? messages.quickQuestionsEnabled,
|
|
21230
21372
|
textColor: textColor ?? "#000000",
|
|
@@ -21364,6 +21506,9 @@
|
|
|
21364
21506
|
addAssistantMessage
|
|
21365
21507
|
} = useChatState(widgetConfig ? {
|
|
21366
21508
|
welcomeMessage: widgetConfig.welcomeMessage,
|
|
21509
|
+
// Per the EV-3813 note below, this hook produces customer-facing
|
|
21510
|
+
// failure copy, so it gets the org's configured fallback too.
|
|
21511
|
+
fallbackMessage: widgetConfig.fallbackMessage,
|
|
21367
21512
|
quickQuestions: widgetConfig.quickQuestions,
|
|
21368
21513
|
apiBaseUrl,
|
|
21369
21514
|
wsBaseUrl: wsBaseUrl ?? agentConfig?.wsBaseUrl,
|