@eshal-bot/chat-widget 0.1.40 → 0.1.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.
@@ -8582,31 +8582,34 @@
8582
8582
  };
8583
8583
 
8584
8584
  /**
8585
- * Constructs the WebSocket URL.
8586
- * @param {string} apiBaseUrl - The base URL of the API (used when wsBaseUrl is not provided).
8587
- * @param {string} [wsBaseUrl] - Explicit WebSocket base URL (e.g. wss://knowledge-api-prod.eshal.ai/ws).
8588
- * When provided, it is returned directly without any transformation.
8589
- * @returns {string} The WebSocket base URL.
8585
+ * Constructs the voice WebSocket base URL — ALWAYS derived from the API
8586
+ * base URL the widget is embedded with, as `wss://{host}/voice/ws`.
8587
+ *
8588
+ * `/voice/ws/{org}/{session}` is terminated by the frontend custom server
8589
+ * (packages/frontend/server.js), which proxies it in-cluster to
8590
+ * knowledge-api on `/ws`. The widget always talks to the same origin it
8591
+ * loaded its config from, so the WS endpoint is fully determined by
8592
+ * `apiBaseUrl`. There is intentionally NO override parameter: voice can
8593
+ * never be pointed at the (no-longer-public) knowledge-api LoadBalancer by
8594
+ * a stale embed option or backend config value.
8595
+ *
8596
+ * @param {string} apiBaseUrl - Frontend base URL, e.g. https://demo.eshal.ai
8597
+ * @returns {string} Voice WS base, e.g. wss://demo.eshal.ai/voice/ws
8590
8598
  */
8591
- const getWebSocketUrl = (apiBaseUrl, wsBaseUrl) => {
8592
- if (wsBaseUrl) {
8593
- return wsBaseUrl.replace(/\/$/, '');
8594
- }
8599
+ const getWebSocketUrl = apiBaseUrl => {
8595
8600
  if (!apiBaseUrl) {
8596
- // Default fallback if no API base URL is provided
8601
+ // No base URL yet (config still loading) safe public default.
8597
8602
  return 'wss://dev.eshal.ai/voice/ws';
8598
8603
  }
8599
8604
  try {
8600
8605
  const url = new URL(apiBaseUrl);
8601
- // Replace http/https with ws/wss
8606
+ // http→ws, https→wss; point at the frontend voice-proxy path.
8602
8607
  url.protocol = url.protocol.replace(/^http/, 'ws');
8603
- // Append the WebSocket specific path
8604
8608
  url.pathname = '/voice/ws';
8605
- // Ensure it returns a clean URL without extra slashes
8609
+ // Clean URL without a trailing slash.
8606
8610
  return url.toString().replace(/\/$/, '');
8607
8611
  } catch (error) {
8608
8612
  console.error("Invalid API base URL provided for WebSocket:", error);
8609
- // Fallback to default if URL parsing fails
8610
8613
  return 'wss://dev.eshal.ai/voice/ws';
8611
8614
  }
8612
8615
  };
@@ -8664,94 +8667,102 @@
8664
8667
  return (_ONBOARDING_FIELD_ORD = ONBOARDING_FIELD_ORDER[key]) !== null && _ONBOARDING_FIELD_ORD !== void 0 ? _ONBOARDING_FIELD_ORD : 99;
8665
8668
  };
8666
8669
 
8667
- const createSession = async config => {
8668
- // Initialize chat session if needed
8669
- if (config.sessionUrl) {
8670
- try {
8671
- const response = await fetch(config.sessionUrl, {
8672
- method: "POST",
8673
- headers: _objectSpread2({
8674
- "Content-Type": "application/json"
8675
- }, config.apiKey && {
8676
- Authorization: "Bearer ".concat(config.apiKey)
8677
- }),
8678
- body: JSON.stringify({
8679
- userId: config.userId,
8680
- userName: config.userName,
8681
- userEmail: config.userEmail
8682
- }),
8683
- credentials: "include"
8684
- });
8685
- const data = await response.json();
8686
- return data.sessionId;
8687
- } catch (error) {
8688
- console.error("Session creation error:", error);
8689
- return null;
8690
- }
8691
- }
8692
- return null;
8693
- };
8670
+ const SESSION_KEY$1 = 'eshal_chat_session';
8671
+ const SESSION_MSG_KEY = 'eshal_chat_session_messages';
8672
+
8673
+ // Defaults used when the deploy-agent payload hasn't resolved yet (first paint
8674
+ // race) or doesn't include `messageLimits` (older backend). The authoritative
8675
+ // values come from the backend via `WIDGET_CONFIG.MESSAGE_LIMITS`.
8676
+ const DEFAULT_MESSAGE_CAP = 200;
8677
+ const DEFAULT_MESSAGE_TRIM_BATCH = 50;
8694
8678
 
8695
8679
  /**
8696
- * Fetches conversation history for a given org and conversation
8697
- * @param {string} apiBaseUrl - Base URL for the API
8698
- * @param {string} orgId - Organization ID
8699
- * @param {string} conversationId - Conversation ID
8700
- * @returns {Promise<Array>} Array of messages
8701
- */
8702
- const fetchConversationHistory = async (apiBaseUrl, orgId, conversationId) => {
8703
- if (!apiBaseUrl || !orgId || !conversationId) {
8704
- throw new Error("apiBaseUrl, orgId, and conversationId are required");
8705
- }
8706
- const url = "".concat(apiBaseUrl.replace(/\/$/, ''), "/api/v1/conversations/").concat(orgId, "/").concat(conversationId);
8707
- const response = await fetch(url, {
8708
- method: "GET",
8709
- headers: {
8710
- "Content-Type": "application/json"
8711
- },
8712
- credentials: "include"
8680
+ * Sliding-window trim. Once the transcript exceeds `cap`, slice off the
8681
+ * oldest `trimBatch` messages when both surfaces are at steady state the
8682
+ * loop runs at most once; the `while` is defensive against bursts (e.g. a
8683
+ * voice turn that lands several transcription chunks in one tick) that
8684
+ * push length far past the cap in a single render.
8685
+ */
8686
+ function applyMessageCap(messages) {
8687
+ let cap = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : DEFAULT_MESSAGE_CAP;
8688
+ let trimBatch = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : DEFAULT_MESSAGE_TRIM_BATCH;
8689
+ if (!Array.isArray(messages)) return [];
8690
+ if (typeof cap !== 'number' || cap <= 0) cap = DEFAULT_MESSAGE_CAP;
8691
+ if (typeof trimBatch !== 'number' || trimBatch <= 0 || trimBatch >= cap) {
8692
+ trimBatch = Math.min(DEFAULT_MESSAGE_TRIM_BATCH, cap - 1);
8693
+ }
8694
+ let m = messages;
8695
+ while (m.length > cap) m = m.slice(trimBatch);
8696
+ return m;
8697
+ }
8698
+
8699
+ // Strip transient fields (in-flight streaming flags, Date objects) so the
8700
+ // persisted shape is stable across React re-mounts. Keep the bits the UI
8701
+ // needs to render a faithful replay: role + content + timestamp + per-message
8702
+ // metadata (sources, feedback, prompt suggestions, content type).
8703
+ //
8704
+ // The welcome message is INTENTIONALLY excluded. It's client-side display
8705
+ // state generated from `widgetConfig.welcomeMessage` on every mount; the
8706
+ // hydrate path always prepends a fresh welcome above the restored history.
8707
+ // Persisting it would cause a duplicate-welcome render on reload because the
8708
+ // stored copy lacks the `isWelcome` flag (sanitize would strip it) and gets
8709
+ // rendered as a regular assistant message alongside the freshly-added one.
8710
+ function sanitizeMessagesForStorage(messages) {
8711
+ if (!Array.isArray(messages)) return [];
8712
+ return messages.filter(m => m && !m.isWelcome && (m.content || Array.isArray(m.sources) && m.sources.length > 0)).map(m => {
8713
+ var _m$type;
8714
+ return _objectSpread2(_objectSpread2(_objectSpread2({
8715
+ id: m.id,
8716
+ role: m.role,
8717
+ content: m.content,
8718
+ timestamp: m.timestamp instanceof Date ? m.timestamp.toISOString() : typeof m.timestamp === 'string' ? m.timestamp : new Date().toISOString(),
8719
+ type: (_m$type = m.type) !== null && _m$type !== void 0 ? _m$type : null
8720
+ }, Array.isArray(m.sources) && m.sources.length > 0 ? {
8721
+ sources: m.sources
8722
+ } : {}), m.feedback !== undefined ? {
8723
+ feedback: m.feedback
8724
+ } : {}), Array.isArray(m.prompts) && m.prompts.length > 0 ? {
8725
+ prompts: m.prompts
8726
+ } : {});
8713
8727
  });
8714
- if (response.status === 404) {
8715
- return []; // No history for this conversation yet
8716
- }
8717
- if (!response.ok) {
8718
- throw new Error("HTTP error! status: ".concat(response.status));
8728
+ }
8729
+ const saveSessionMessages = (orgId, conversationId, messages, limits) => {
8730
+ if (!orgId || !conversationId) return;
8731
+ try {
8732
+ const cap = limits === null || limits === void 0 ? void 0 : limits.cap;
8733
+ const trimBatch = limits === null || limits === void 0 ? void 0 : limits.trimBatch;
8734
+ const trimmed = applyMessageCap(messages, cap, trimBatch);
8735
+ const sanitized = sanitizeMessagesForStorage(trimmed);
8736
+ localStorage.setItem("".concat(SESSION_MSG_KEY, "_").concat(orgId, "_").concat(conversationId), JSON.stringify(sanitized));
8737
+ } catch (_unused) {
8738
+ // Quota / private-mode / serialization failure — drop silently. The
8739
+ // user-visible chat is unaffected; only the post-reload replay is lost.
8719
8740
  }
8720
- const data = await response.json();
8721
- return data.messages || [];
8722
8741
  };
8723
-
8724
- /**
8725
- * Fetches agent configuration from the deploy-agent endpoint
8726
- * @param {string} apiBaseUrl - Base URL for the API
8727
- * @param {string} orgId - Organization ID
8728
- * @returns {Promise<object>} Agent configuration object
8729
- */
8730
- const fetchAgentConfig = async (apiBaseUrl, orgId) => {
8731
- if (!apiBaseUrl || !orgId) {
8732
- throw new Error("apiBaseUrl and orgId are required");
8742
+ const getSessionMessages = (orgId, conversationId) => {
8743
+ if (!orgId || !conversationId) return [];
8744
+ try {
8745
+ const raw = localStorage.getItem("".concat(SESSION_MSG_KEY, "_").concat(orgId, "_").concat(conversationId));
8746
+ if (!raw) return [];
8747
+ const parsed = JSON.parse(raw);
8748
+ if (!Array.isArray(parsed)) return [];
8749
+ // Restore Date objects so MessageList timestamp formatting works without
8750
+ // every downstream consumer doing the conversion itself.
8751
+ return parsed.map(m => _objectSpread2(_objectSpread2({}, m), {}, {
8752
+ timestamp: m.timestamp ? new Date(m.timestamp) : new Date()
8753
+ }));
8754
+ } catch (_unused2) {
8755
+ return [];
8733
8756
  }
8757
+ };
8758
+ const clearSessionMessages = (orgId, conversationId) => {
8759
+ if (!orgId || !conversationId) return;
8734
8760
  try {
8735
- const url = "".concat(apiBaseUrl.replace(/\/$/, ''), "/api/v1/deploy-agent/").concat(orgId);
8736
- const response = await fetch(url, {
8737
- method: "GET",
8738
- headers: {
8739
- "Content-Type": "application/json"
8740
- },
8741
- credentials: "include"
8742
- });
8743
- if (!response.ok) {
8744
- throw new Error("HTTP error! status: ".concat(response.status));
8745
- }
8746
- const data = await response.json();
8747
- return data;
8748
- } catch (error) {
8749
- console.error("Failed to fetch agent configuration:", error);
8750
- throw error;
8761
+ localStorage.removeItem("".concat(SESSION_MSG_KEY, "_").concat(orgId, "_").concat(conversationId));
8762
+ } catch (_unused3) {
8763
+ // ignore
8751
8764
  }
8752
8765
  };
8753
-
8754
- const SESSION_KEY$1 = 'eshal_chat_session';
8755
8766
  const getTimeoutMs = (value, unit) => {
8756
8767
  if (value === undefined || value === null || !unit) return null;
8757
8768
  const multipliers = {
@@ -8772,7 +8783,7 @@
8772
8783
  const raw = localStorage.getItem("".concat(SESSION_KEY$1, "_").concat(orgId));
8773
8784
  if (!raw) return null;
8774
8785
  return JSON.parse(raw);
8775
- } catch (_unused) {
8786
+ } catch (_unused4) {
8776
8787
  return null;
8777
8788
  }
8778
8789
  };
@@ -8822,7 +8833,7 @@
8822
8833
  createdAt: now,
8823
8834
  firstInteractionAt: null
8824
8835
  }, extra)));
8825
- } catch (_unused2) {}
8836
+ } catch (_unused5) {}
8826
8837
  };
8827
8838
  const updateActivity = orgId => {
8828
8839
  try {
@@ -8836,7 +8847,7 @@
8836
8847
  session.firstInteractionAt = now;
8837
8848
  }
8838
8849
  localStorage.setItem("".concat(SESSION_KEY$1, "_").concat(orgId), JSON.stringify(session));
8839
- } catch (_unused3) {}
8850
+ } catch (_unused6) {}
8840
8851
  };
8841
8852
  const markOnboardingCompleted = orgId => {
8842
8853
  try {
@@ -8844,7 +8855,7 @@
8844
8855
  if (!session) return;
8845
8856
  session.onboardingCompleted = true;
8846
8857
  localStorage.setItem("".concat(SESSION_KEY$1, "_").concat(orgId), JSON.stringify(session));
8847
- } catch (_unused4) {}
8858
+ } catch (_unused7) {}
8848
8859
  };
8849
8860
  const markCsatSubmitted = orgId => {
8850
8861
  try {
@@ -8852,12 +8863,21 @@
8852
8863
  if (!session) return;
8853
8864
  session.csatSubmitted = true;
8854
8865
  localStorage.setItem("".concat(SESSION_KEY$1, "_").concat(orgId), JSON.stringify(session));
8855
- } catch (_unused5) {}
8866
+ } catch (_unused8) {}
8856
8867
  };
8857
8868
  const clearSession$2 = orgId => {
8858
8869
  try {
8870
+ // Read the session BEFORE removing it so we know which messages bucket to
8871
+ // clear. Inactivity sweepers / explicit resets call this path; leaving the
8872
+ // messages bucket behind would resurrect the prior transcript when a new
8873
+ // session happens to land on the same conversationId (extremely rare, but
8874
+ // free to defend against).
8875
+ const existing = getSession(orgId);
8859
8876
  localStorage.removeItem("".concat(SESSION_KEY$1, "_").concat(orgId));
8860
- } catch (_unused6) {}
8877
+ if (existing !== null && existing !== void 0 && existing.conversationId) {
8878
+ clearSessionMessages(orgId, existing.conversationId);
8879
+ }
8880
+ } catch (_unused9) {}
8861
8881
  };
8862
8882
  const savePromptSuggestions = (orgId, prompts) => {
8863
8883
  try {
@@ -8867,14 +8887,14 @@
8867
8887
  return;
8868
8888
  }
8869
8889
  localStorage.setItem("".concat(SESSION_KEY$1, "_prompts_").concat(orgId), JSON.stringify(prompts));
8870
- } catch (_unused7) {}
8890
+ } catch (_unused0) {}
8871
8891
  };
8872
8892
  const getPromptSuggestions = orgId => {
8873
8893
  try {
8874
8894
  const raw = localStorage.getItem("".concat(SESSION_KEY$1, "_prompts_").concat(orgId));
8875
8895
  if (!raw) return [];
8876
8896
  return JSON.parse(raw) || [];
8877
- } catch (_unused8) {
8897
+ } catch (_unused1) {
8878
8898
  return [];
8879
8899
  }
8880
8900
  };
@@ -8909,7 +8929,6 @@
8909
8929
  welcomeMessage,
8910
8930
  quickQuestions = [],
8911
8931
  apiBaseUrl,
8912
- wsBaseUrl,
8913
8932
  apiKey,
8914
8933
  organizationId,
8915
8934
  autoOpen,
@@ -8920,8 +8939,17 @@
8920
8939
  onboardingEnabled = false,
8921
8940
  collectionPrompt,
8922
8941
  inactivityTimeoutValue,
8923
- inactivityTimeoutUnit
8942
+ inactivityTimeoutUnit,
8943
+ // Sliding-window limits for the persisted transcript. Sourced from the
8944
+ // backend's `messageLimits` field on the deploy-agent payload so per-env
8945
+ // configuration ships through one channel. Falls back to module defaults
8946
+ // during the brief mount window before the deploy-agent fetch resolves.
8947
+ messageLimits
8924
8948
  } = _ref2;
8949
+ const resolvedMessageLimits = reactExports.useMemo(() => ({
8950
+ cap: typeof (messageLimits === null || messageLimits === void 0 ? void 0 : messageLimits.cap) === 'number' && messageLimits.cap > 0 ? messageLimits.cap : DEFAULT_MESSAGE_CAP,
8951
+ trimBatch: typeof (messageLimits === null || messageLimits === void 0 ? void 0 : messageLimits.trimBatch) === 'number' && messageLimits.trimBatch > 0 ? messageLimits.trimBatch : DEFAULT_MESSAGE_TRIM_BATCH
8952
+ }), [messageLimits === null || messageLimits === void 0 ? void 0 : messageLimits.cap, messageLimits === null || messageLimits === void 0 ? void 0 : messageLimits.trimBatch]);
8925
8953
  const [isOpen, setIsOpen] = reactExports.useState(autoOpen);
8926
8954
  const [isMinimized, setIsMinimized] = reactExports.useState(false);
8927
8955
  const [isDark, setIsDark] = reactExports.useState(darkMode);
@@ -9051,8 +9079,9 @@
9051
9079
  const visitorProfileRef = reactExports.useRef(null);
9052
9080
  const websocketUrl = reactExports.useMemo(() => {
9053
9081
  if (!organizationId) return null;
9054
- // Construct WebSocket URL from the API base URL
9055
- const resolvedWsBaseUrl = getWebSocketUrl(apiBaseUrl, wsBaseUrl);
9082
+ // Voice WS base is always derived from apiBaseUrl (the frontend origin
9083
+ // the widget is embedded with) → wss://{host}/voice/ws. No override.
9084
+ const resolvedWsBaseUrl = getWebSocketUrl(apiBaseUrl);
9056
9085
  return "".concat(resolvedWsBaseUrl, "/").concat(organizationId, "/").concat(bidiSessionId);
9057
9086
  }, [apiBaseUrl, organizationId, bidiSessionId]);
9058
9087
  const getNextMessageId = reactExports.useCallback(() => {
@@ -9140,91 +9169,62 @@
9140
9169
  return;
9141
9170
  }
9142
9171
 
9143
- // Restore session — fetch conversation history once
9144
- if (!historyLoadedRef.current && apiBaseUrl) {
9172
+ // Restore session — hydrate from localStorage only. We no longer hit
9173
+ // `GET /api/v1/conversations/:orgId/:conversationId` because orgs with
9174
+ // the Eshal Inbox conversation route turned OFF never get rows written
9175
+ // (`isInboxRouteEnabled` gates persistence) and the empty backend reply
9176
+ // was wiping the on-screen transcript on every reload. localStorage is
9177
+ // now the only source of session replay; inbox-on orgs still see their
9178
+ // full transcript because every settled message is mirrored locally.
9179
+ if (!historyLoadedRef.current) {
9145
9180
  historyLoadedRef.current = true;
9146
- setIsConversationLoading(true);
9147
- fetchConversationHistory(apiBaseUrl, organizationId, bidiSessionId).then(msgs => {
9148
- // Filter out onboarding field messages (name/email/phone) so they don't appear as chat bubbles
9149
- const ONBOARDING_TYPES = ['userName', 'email', 'phone', 'fullname', 'mobileno', 'name'];
9150
- const chatMsgs = msgs.filter(msg => {
9151
- const msgType = msg.type || msg.messageType;
9152
- if (msgType && ONBOARDING_TYPES.includes(msgType)) return false;
9153
- return true;
9154
- });
9155
- if (chatMsgs.length > 0) {
9156
- historyHasMessagesRef.current = true;
9157
- const historyMessages = chatMsgs.map((msg, index) => {
9158
- var _msg$Sources;
9159
- // Normalize sources — backend may store them as "Sources" (capital) with
9160
- // "sourceid" (no underscore). Map to the shape MessageSources expects.
9161
- const rawSources = (_msg$Sources = msg.Sources) !== null && _msg$Sources !== void 0 ? _msg$Sources : msg.sources;
9162
- const sources = Array.isArray(rawSources) ? rawSources.map(s => {
9163
- var _ref3, _s$source_type, _s$source_id, _ref4, _s$source_name;
9164
- return {
9165
- source_type: (_ref3 = (_s$source_type = s.source_type) !== null && _s$source_type !== void 0 ? _s$source_type : s.sourceType) !== null && _ref3 !== void 0 ? _ref3 : 'website',
9166
- source_id: (_s$source_id = s.source_id) !== null && _s$source_id !== void 0 ? _s$source_id : s.sourceid,
9167
- source_name: (_ref4 = (_s$source_name = s.source_name) !== null && _s$source_name !== void 0 ? _s$source_name : s.sourceName) !== null && _ref4 !== void 0 ? _ref4 : '',
9168
- url: s.url
9169
- };
9170
- }).filter(s => s.source_id || s.url) : [];
9171
- return _objectSpread2(_objectSpread2(_objectSpread2({}, createMessage({
9172
- id: msg.id || "history-".concat(index, "-").concat(Date.now()),
9173
- role: msg.role || (msg.sender === 'user' ? 'user' : 'assistant'),
9174
- content: msg.message || msg.content || ''
9175
- })), {}, {
9176
- timestamp: msg.time ? new Date(msg.time) : new Date()
9177
- }, Array.isArray(msg.prompts) && msg.prompts.length > 0 ? {
9178
- prompts: msg.prompts
9179
- } : {}), sources.length > 0 ? {
9180
- sources
9181
- } : {});
9181
+ const stored = getSessionMessages(organizationId, bidiSessionId);
9182
+ if (stored.length > 0) {
9183
+ historyHasMessagesRef.current = true;
9184
+ // Prepend the welcome message so reloads still lead with it,
9185
+ // matching the pre-localStorage replay behaviour. The welcome
9186
+ // message is client-only, never written to the messages bucket,
9187
+ // so it needs to be reattached on every hydrate.
9188
+ if (welcomeMessage) {
9189
+ var _stored$;
9190
+ const firstTs = (_stored$ = stored[0]) === null || _stored$ === void 0 ? void 0 : _stored$.timestamp;
9191
+ const firstTsMs = firstTs instanceof Date ? firstTs.getTime() : firstTs ? new Date(firstTs).getTime() : Date.now();
9192
+ const welcomeMsg = _objectSpread2(_objectSpread2({}, createMessage({
9193
+ id: 'welcome-restored',
9194
+ role: 'assistant',
9195
+ content: welcomeMessage
9196
+ })), {}, {
9197
+ isWelcome: true,
9198
+ timestamp: new Date(firstTsMs - 1)
9182
9199
  });
9200
+ setMessages([welcomeMsg, ...stored]);
9201
+ } else {
9202
+ setMessages(stored);
9203
+ }
9183
9204
 
9184
- // If the API didn't return prompts on any message, restore from localStorage
9185
- const hasAnyPrompts = historyMessages.some(m => m.role === 'assistant' && Array.isArray(m.prompts) && m.prompts.length > 0);
9186
- if (!hasAnyPrompts) {
9187
- const savedPrompts = getPromptSuggestions(organizationId);
9188
- if (savedPrompts.length > 0) {
9189
- // Attach saved prompts to the last assistant message
9190
- for (let i = historyMessages.length - 1; i >= 0; i -= 1) {
9191
- if (historyMessages[i].role === 'assistant') {
9192
- historyMessages[i] = _objectSpread2(_objectSpread2({}, historyMessages[i]), {}, {
9205
+ // If no message in the restored transcript carries prompt
9206
+ // suggestions, fall back to the legacy per-org bucket. Preserves
9207
+ // the "prompts survive refresh" UX even when the persistence layer
9208
+ // hasn't seen an assistant turn yet.
9209
+ const hasAnyPrompts = stored.some(m => m.role === 'assistant' && Array.isArray(m.prompts) && m.prompts.length > 0);
9210
+ if (!hasAnyPrompts) {
9211
+ const savedPrompts = getPromptSuggestions(organizationId);
9212
+ if (savedPrompts.length > 0) {
9213
+ setMessages(prev => {
9214
+ const next = [...prev];
9215
+ for (let i = next.length - 1; i >= 0; i -= 1) {
9216
+ if (next[i].role === 'assistant') {
9217
+ next[i] = _objectSpread2(_objectSpread2({}, next[i]), {}, {
9193
9218
  prompts: savedPrompts
9194
9219
  });
9195
9220
  break;
9196
9221
  }
9197
9222
  }
9198
- }
9199
- }
9200
-
9201
- // Always prepend the welcome message so it shows at the top after refresh,
9202
- // matching chatbot-preview behaviour (welcome message is client-side only
9203
- // and is not stored in server history).
9204
- // Use a timestamp before the first history message so it sorts first
9205
- // (activeMessages is sorted by timestamp before being passed to the UI).
9206
- if (welcomeMessage) {
9207
- var _historyMessages$;
9208
- const firstTs = (_historyMessages$ = historyMessages[0]) === null || _historyMessages$ === void 0 ? void 0 : _historyMessages$.timestamp;
9209
- const firstTsMs = firstTs instanceof Date ? firstTs.getTime() : firstTs ? new Date(firstTs).getTime() : Date.now();
9210
- const welcomeMsg = _objectSpread2(_objectSpread2({}, createMessage({
9211
- id: 'welcome-restored',
9212
- role: 'assistant',
9213
- content: welcomeMessage
9214
- })), {}, {
9215
- isWelcome: true,
9216
- timestamp: new Date(firstTsMs - 1)
9223
+ return next;
9217
9224
  });
9218
- setMessages([welcomeMsg, ...historyMessages]);
9219
- } else {
9220
- setMessages(historyMessages);
9221
9225
  }
9222
9226
  }
9223
- }).catch(err => {
9224
- console.error('[Session] History fetch failed:', err);
9225
- }).finally(() => {
9226
- setIsConversationLoading(false);
9227
- });
9227
+ }
9228
9228
  }
9229
9229
  } else {
9230
9230
  // Session is expired, invalid, or non-existent
@@ -9236,9 +9236,27 @@
9236
9236
  if (existing && bidiSessionId === existing.conversationId) {
9237
9237
  const newId = "widget-session-".concat(Math.random().toString(36).slice(2, 9));
9238
9238
  console.warn("[Session] Rotating expired ID ".concat(bidiSessionId, " -> ").concat(newId));
9239
+ // Drop the expired conversation's localStorage transcript before
9240
+ // rotating — otherwise the bucket sticks around forever (the key
9241
+ // includes the conversationId, so it'd never get hit again but
9242
+ // would still occupy origin storage on the customer's site).
9243
+ clearSessionMessages(organizationId, bidiSessionId);
9239
9244
  setBidiSessionId(newId);
9240
9245
  // Note: saveSession will be called on the next run with the newId
9241
9246
  } else {
9247
+ // Cold-start cleanup: the `useState` initializer above generates a
9248
+ // FRESH `bidiSessionId` when it can't reuse the stored session (timeout
9249
+ // expired before reload, settings snapshot mismatched, or the deploy-
9250
+ // agent payload was still loading at first paint so it couldn't run
9251
+ // `isSessionValid`). In that case `existing.conversationId` points to
9252
+ // a now-stale conversation whose messages bucket would otherwise be
9253
+ // orphaned forever — `clearSession` runs in `resetConversation` (which
9254
+ // we never hit here) and the rotate-id branch above only fires when
9255
+ // the IDs match. Mirror the frontend `useSessionManager` hydrate
9256
+ // effect: clean up the stale bucket before we save the new session.
9257
+ if (existing !== null && existing !== void 0 && existing.conversationId && existing.conversationId !== bidiSessionId) {
9258
+ clearSessionMessages(organizationId, existing.conversationId);
9259
+ }
9242
9260
  // We have a fresh ID (either from initializer or rotation), persist it if not already there
9243
9261
  if (!existing || existing.conversationId !== bidiSessionId) {
9244
9262
  saveSession(organizationId, bidiSessionId, {
@@ -9264,6 +9282,29 @@
9264
9282
  }
9265
9283
  }, [messages, organizationId]);
9266
9284
 
9285
+ // ── Sliding-window trim: drop the oldest TRIM_BATCH messages when state
9286
+ // exceeds CAP. Runs only when no message is mid-stream so we never slice
9287
+ // through a partial assistant token. Mutates React state (matches the
9288
+ // intended UX — old scrollback drops in front of the user once the cap is
9289
+ // hit, keeping what's on screen identical to what's in localStorage).
9290
+ reactExports.useEffect(() => {
9291
+ if (isConversationLoading || isLoading) return;
9292
+ if (messages.length <= resolvedMessageLimits.cap) return;
9293
+ setMessages(prev => applyMessageCap(prev, resolvedMessageLimits.cap, resolvedMessageLimits.trimBatch));
9294
+ }, [messages.length, isConversationLoading, isLoading, resolvedMessageLimits.cap, resolvedMessageLimits.trimBatch]);
9295
+
9296
+ // ── Persist a settled snapshot of the transcript. We write only when
9297
+ // streaming/voice traffic has quiesced so a partial assistant message
9298
+ // never lands in localStorage — on reload the next snapshot will reflect
9299
+ // the completed turn. Skipped while history is hydrating (initial mount
9300
+ // race) so the first restore isn't immediately overwritten by an empty
9301
+ // pre-hydrate state.
9302
+ reactExports.useEffect(() => {
9303
+ if (!organizationId || !bidiSessionId) return;
9304
+ if (isConversationLoading || isLoading) return;
9305
+ saveSessionMessages(organizationId, bidiSessionId, messages, resolvedMessageLimits);
9306
+ }, [messages, organizationId, bidiSessionId, isConversationLoading, isLoading, resolvedMessageLimits]);
9307
+
9267
9308
  // Show the onboarding form on first load; bypass it if already completed (persisted in session)
9268
9309
  reactExports.useEffect(() => {
9269
9310
  if (!onboardingEnabled || onboardingCompleted) return;
@@ -10252,11 +10293,11 @@
10252
10293
  const rawSources = (_event$sources = event.sources) !== null && _event$sources !== void 0 ? _event$sources : event.Sources;
10253
10294
  if (Array.isArray(rawSources) && rawSources.length > 0) {
10254
10295
  const normalizedSources = rawSources.map(s => {
10255
- var _ref5, _s$source_type2, _ref6, _s$source_id2, _ref7, _s$source_name2;
10296
+ var _ref3, _s$source_type, _ref4, _s$source_id, _ref5, _s$source_name;
10256
10297
  return {
10257
- source_type: (_ref5 = (_s$source_type2 = s.source_type) !== null && _s$source_type2 !== void 0 ? _s$source_type2 : s.sourceType) !== null && _ref5 !== void 0 ? _ref5 : 'file',
10258
- source_id: (_ref6 = (_s$source_id2 = s.source_id) !== null && _s$source_id2 !== void 0 ? _s$source_id2 : s.sourceid) !== null && _ref6 !== void 0 ? _ref6 : s.sourceId,
10259
- source_name: (_ref7 = (_s$source_name2 = s.source_name) !== null && _s$source_name2 !== void 0 ? _s$source_name2 : s.sourceName) !== null && _ref7 !== void 0 ? _ref7 : '',
10298
+ source_type: (_ref3 = (_s$source_type = s.source_type) !== null && _s$source_type !== void 0 ? _s$source_type : s.sourceType) !== null && _ref3 !== void 0 ? _ref3 : 'file',
10299
+ source_id: (_ref4 = (_s$source_id = s.source_id) !== null && _s$source_id !== void 0 ? _s$source_id : s.sourceid) !== null && _ref4 !== void 0 ? _ref4 : s.sourceId,
10300
+ source_name: (_ref5 = (_s$source_name = s.source_name) !== null && _s$source_name !== void 0 ? _s$source_name : s.sourceName) !== null && _ref5 !== void 0 ? _ref5 : '',
10260
10301
  url: s.url
10261
10302
  };
10262
10303
  }).filter(s => s.source_id || s.url);
@@ -10563,6 +10604,10 @@
10563
10604
  setMessages([]);
10564
10605
  setBidiMessages([]);
10565
10606
 
10607
+ // Drop the localStorage transcript for the conversation we're abandoning
10608
+ // BEFORE we rotate to a fresh sessionId, otherwise the old bucket leaks.
10609
+ clearSessionMessages(organizationId, bidiSessionId);
10610
+
10566
10611
  // Reset onboarding
10567
10612
  setOnboardingActive(false);
10568
10613
  setOnboardingCompleted(false);
@@ -10601,7 +10646,7 @@
10601
10646
  setOnboardingActive(true);
10602
10647
  }
10603
10648
  }
10604
- }, [organizationId, welcomeMessage, onboardingEnabled, onboardingQuestions]);
10649
+ }, [organizationId, bidiSessionId, welcomeMessage, onboardingEnabled, onboardingQuestions, inactivityTimeoutValue, inactivityTimeoutUnit]);
10605
10650
 
10606
10651
  // Keep a stable ref to resetConversation so the timer effect does not need to
10607
10652
  // list it as a dependency (avoids spurious re-runs — and spurious updateActivity
@@ -31781,7 +31826,7 @@
31781
31826
  if (typeof document === 'undefined') {
31782
31827
  return null;
31783
31828
  }
31784
- if ('currentScript' in document && 1 < 2 /* hack to trip TS' flow analysis */) {
31829
+ if (document.currentScript && document.currentScript.tagName === 'SCRIPT' && 1 < 2 /* hack to trip TS' flow analysis */) {
31785
31830
  return /** @type {any} */document.currentScript;
31786
31831
  }
31787
31832
 
@@ -60277,13 +60322,70 @@
60277
60322
  });
60278
60323
  };
60279
60324
 
60325
+ const createSession = async config => {
60326
+ // Initialize chat session if needed
60327
+ if (config.sessionUrl) {
60328
+ try {
60329
+ const response = await fetch(config.sessionUrl, {
60330
+ method: "POST",
60331
+ headers: _objectSpread2({
60332
+ "Content-Type": "application/json"
60333
+ }, config.apiKey && {
60334
+ Authorization: "Bearer ".concat(config.apiKey)
60335
+ }),
60336
+ body: JSON.stringify({
60337
+ userId: config.userId,
60338
+ userName: config.userName,
60339
+ userEmail: config.userEmail
60340
+ }),
60341
+ credentials: "include"
60342
+ });
60343
+ const data = await response.json();
60344
+ return data.sessionId;
60345
+ } catch (error) {
60346
+ console.error("Session creation error:", error);
60347
+ return null;
60348
+ }
60349
+ }
60350
+ return null;
60351
+ };
60352
+
60353
+ /**
60354
+ * Fetches agent configuration from the deploy-agent endpoint
60355
+ * @param {string} apiBaseUrl - Base URL for the API
60356
+ * @param {string} orgId - Organization ID
60357
+ * @returns {Promise<object>} Agent configuration object
60358
+ */
60359
+ const fetchAgentConfig = async (apiBaseUrl, orgId) => {
60360
+ if (!apiBaseUrl || !orgId) {
60361
+ throw new Error("apiBaseUrl and orgId are required");
60362
+ }
60363
+ try {
60364
+ const url = "".concat(apiBaseUrl.replace(/\/$/, ''), "/api/v1/deploy-agent/").concat(orgId);
60365
+ const response = await fetch(url, {
60366
+ method: "GET",
60367
+ headers: {
60368
+ "Content-Type": "application/json"
60369
+ },
60370
+ credentials: "include"
60371
+ });
60372
+ if (!response.ok) {
60373
+ throw new Error("HTTP error! status: ".concat(response.status));
60374
+ }
60375
+ const data = await response.json();
60376
+ return data;
60377
+ } catch (error) {
60378
+ console.error("Failed to fetch agent configuration:", error);
60379
+ throw error;
60380
+ }
60381
+ };
60382
+
60280
60383
  const ChatWidget = _ref => {
60281
60384
  var _agentConfig$concierg;
60282
60385
  let {
60283
60386
  // Required props for dynamic config
60284
60387
  orgId,
60285
60388
  apiBaseUrl,
60286
- wsBaseUrl,
60287
60389
  // Optional override props (will override fetched config if provided)
60288
60390
  darkMode,
60289
60391
  primaryColor,
@@ -60466,7 +60568,6 @@
60466
60568
  welcomeMessage: "Hi! How can we help?",
60467
60569
  quickQuestions: [],
60468
60570
  apiBaseUrl: apiBaseUrl || "",
60469
- wsBaseUrl: agentConfig === null || agentConfig === void 0 ? void 0 : agentConfig.wsBaseUrl,
60470
60571
  apiKey: apiKey || "",
60471
60572
  organizationId: orgId || "",
60472
60573
  autoOpen: false,
@@ -60476,7 +60577,7 @@
60476
60577
  onboardingQuestions: [],
60477
60578
  onboardingEnabled: false,
60478
60579
  collectionPrompt: undefined
60479
- }), [apiBaseUrl, wsBaseUrl, apiKey, orgId]);
60580
+ }), [apiBaseUrl, apiKey, orgId]);
60480
60581
 
60481
60582
  // ALWAYS call hooks before any early returns (Rules of Hooks)
60482
60583
  const {
@@ -60517,7 +60618,11 @@
60517
60618
  welcomeMessage: widgetConfig.welcomeMessage,
60518
60619
  quickQuestions: widgetConfig.quickQuestions,
60519
60620
  apiBaseUrl,
60520
- wsBaseUrl: agentConfig === null || agentConfig === void 0 ? void 0 : agentConfig.wsBaseUrl,
60621
+ // Voice WS endpoint is derived entirely from apiBaseUrl inside
60622
+ // useChatState (→ wss://{host}/voice/ws, the frontend voice proxy).
60623
+ // Nothing is forwarded/overridable here, so a stale backend or embed
60624
+ // wsBaseUrl can't point voice at the old (now non-public)
60625
+ // knowledge-api LoadBalancer.
60521
60626
  apiKey,
60522
60627
  organizationId: widgetConfig.organizationId,
60523
60628
  autoOpen,
@@ -60528,7 +60633,13 @@
60528
60633
  onboardingEnabled: widgetConfig.onboardingEnabled,
60529
60634
  collectionPrompt: widgetConfig.collectionPrompt,
60530
60635
  inactivityTimeoutValue: widgetConfig.inactivityTimeoutValue,
60531
- inactivityTimeoutUnit: widgetConfig.inactivityTimeoutUnit
60636
+ inactivityTimeoutUnit: widgetConfig.inactivityTimeoutUnit,
60637
+ // Sliding-window cap for the localStorage transcript. Comes from
60638
+ // GET /deploy-agent/:orgId → agentConfig.messageLimits (single
60639
+ // source of truth across surfaces). When the field is missing
60640
+ // (older backend / fetch in flight) the hook falls back to its
60641
+ // module-level defaults so chat never blocks on this.
60642
+ messageLimits: agentConfig === null || agentConfig === void 0 ? void 0 : agentConfig.messageLimits
60532
60643
  } : defaultConfig);
60533
60644
 
60534
60645
  // Feedback handler — POST to /api/v1/support/feedback