PluginProbe
MxChat – AI Chatbot & Content Generation for WordPress / 3.1.1
MxChat – AI Chatbot & Content Generation for WordPress v3.1.1
3.2.21 3.2.20 3.2.19 3.2.18 3.2.17 3.2.16 3.2.15 3.2.14 3.2.12 3.2.13 3.2.11 3.2.10 3.2.9 3.2.8 3.2.7 3.2.6 3.2.5 3.2.4 3.2.3 3.2.2 3.2.1 2.0.3 2.0.4 2.0.5 2.0.6 All 152 releases
← All changes | js/chat-script.js +269 -1446 3.2.93.1.1 View file →
@@ -1,166 +1,15 @@
1 1 jQuery(document).ready(function($) {
2 2
3 - // Nonce refresh — v2 (plan-6a68c9).
4 - //
5 - // The widget no longer relies on a nonce embedded in inline cached HTML.
6 - // Before each chat-send / stream-send / upload, we call the REST endpoint
7 - // GET /wp-json/mxchat/v1/nonce and use the freshly-issued value. The
8 - // endpoint creates the nonce with action `mxchat_chat_send`; the server-side
9 - // verifier ALSO still accepts the legacy `mxchat_chat_nonce` action for a
10 - // 30-day backwards-compat window so cached pages still in users' browsers
11 - // (which carry the legacy inline-localized nonce) keep working.
12 - //
13 - // Cache: a single module-scoped slot. TTL 12h conservatively (WP nonces are
14 - // 24h but we refetch at half-life so a freshly-cached-page user never sees
15 - // a borderline-stale nonce).
16 - var cachedFreshNonce = null;
17 - var cachedFreshNonceFetchedAt = 0;
18 - var NONCE_TTL_MS = 12 * 60 * 60 * 1000;
19 - var nonceRefreshState = 'idle'; // 'idle' | 'pending' | 'done'
20 - var nonceRefreshCallbacks = [];
21 -
22 - function getRestNonceUrl() {
23 - if (typeof mxchatChat !== 'undefined' && mxchatChat.rest_url) {
24 - return mxchatChat.rest_url.replace(/\/+$/, '') + '/nonce';
25 - }
26 - // Fallback: derive from current origin if mxchatChat.rest_url isn't set.
27 - return window.location.origin + '/wp-json/mxchat/v1/nonce';
28 - }
29 -
30 - function fetchFreshNonceFromRest() {
31 - return fetch(getRestNonceUrl(), {
32 - credentials: 'same-origin',
33 - headers: { 'Accept': 'application/json' }
34 - }).then(function (resp) {
35 - if (!resp.ok) {
36 - throw new Error('REST nonce fetch failed: ' + resp.status);
3 + // Refresh nonce on load — fixes stale nonces from page-cache plugins
4 + if (typeof mxchatChat !== 'undefined' && mxchatChat.ajax_url) {
5 + $.post(mxchatChat.ajax_url, { action: 'mxchat_refresh_nonce' }, function(res) {
6 + if (res && res.success && res.data && res.data.nonce) {
7 + mxchatChat.nonce = res.data.nonce;
37 8 }
38 - return resp.json();
39 - }).then(function (data) {
40 - if (data && data.nonce) {
41 - return data.nonce;
42 - }
43 - throw new Error('REST nonce response had no nonce field.');
44 9 });
45 10 }
46 11
47 - /**
48 - * withFreshNonce(cb) — invoke cb() after ensuring mxchatChat.nonce is fresh.
49 - * Tries REST endpoint first (cache-bypass design); falls back to the legacy
50 - * admin-ajax refresh path if REST is unavailable. Idempotent — concurrent
51 - * calls share the same in-flight refresh.
52 - */
53 - function withFreshNonce(callback) {
54 - if (typeof mxchatChat === 'undefined') {
55 - if (callback) callback();
56 - return;
57 - }
58 - var now = Date.now();
59 - if (cachedFreshNonce && (now - cachedFreshNonceFetchedAt) < NONCE_TTL_MS) {
60 - mxchatChat.nonce = cachedFreshNonce;
61 - if (callback) callback();
62 - return;
63 - }
64 - if (callback) nonceRefreshCallbacks.push(callback);
65 - if (nonceRefreshState === 'pending') return;
66 - nonceRefreshState = 'pending';
67 -
68 - var resolved = function (nonce) {
69 - if (nonce) {
70 - cachedFreshNonce = nonce;
71 - cachedFreshNonceFetchedAt = Date.now();
72 - mxchatChat.nonce = nonce;
73 - }
74 - nonceRefreshState = 'done';
75 - var pending = nonceRefreshCallbacks;
76 - nonceRefreshCallbacks = [];
77 - pending.forEach(function (cb) { try { cb(); } catch (e) {} });
78 - };
79 -
80 - fetchFreshNonceFromRest()
81 - .then(resolved)
82 - .catch(function () {
83 - // Fallback to the legacy admin-ajax refresh path (issued with the
84 - // old action `mxchat_chat_nonce`; the server still accepts both
85 - // during the compat window).
86 - if (mxchatChat.ajax_url) {
87 - $.post(mxchatChat.ajax_url, { action: 'mxchat_refresh_nonce' })
88 - .done(function (res) {
89 - if (res && res.success && res.data && res.data.nonce) {
90 - resolved(res.data.nonce);
91 - return;
92 - }
93 - resolved(null);
94 - })
95 - .fail(function () { resolved(null); });
96 - } else {
97 - resolved(null);
98 - }
99 - });
100 - }
101 -
102 - // Backwards-compat alias — every existing caller in this file (and any
103 - // out-of-tree consumer that hit this internal API) keeps working unchanged.
104 - function refreshNonceIfNeeded(callback) {
105 - return withFreshNonce(callback);
106 - }
107 -
108 - // Dynamic-settings refresh (plan-32db95).
109 - //
110 - // Every widget setting ships inline in cached page HTML, so behind a
111 - // full-page cache the site owner can't purge (host cache, CDN, the
112 - // browser itself) a toggled setting looks broken until the cache turns
113 - // over. Same distrust-cached-HTML reasoning as the per-request nonce:
114 - // on the FIRST widget open per page load we ask the nonce endpoint for
115 - // the current behavior-gate settings (?with_settings=1), merge them over
116 - // mxchatChat, and rebuild the header menu. Colors are NOT refreshed —
117 - // they're server-inline-styled, so a runtime swap would visibly flash.
118 - // On any failure we keep the inline values silently (nonce-fallback
119 - // posture). At most one request per page load, only if a widget opens.
120 - var dynamicSettingsState = 'idle'; // 'idle' | 'pending' | 'done'
121 -
122 - function mxchatRefreshDynamicSettings() {
123 - if (dynamicSettingsState !== 'idle') return;
124 - if (typeof mxchatChat === 'undefined') return;
125 - dynamicSettingsState = 'pending';
126 -
127 - var applied = function (data) {
128 - dynamicSettingsState = 'done';
129 - if (!data) return; // endpoint unavailable — inline values stand.
130 - if (data.nonce) {
131 - // Seed the nonce cache too: saves the first send's REST
132 - // round-trip and keeps us under the endpoint's rate limit.
133 - cachedFreshNonce = data.nonce;
134 - cachedFreshNonceFetchedAt = Date.now();
135 - mxchatChat.nonce = data.nonce;
136 - }
137 - if (data.settings && typeof data.settings === 'object') {
138 - $.extend(mxchatChat, data.settings);
139 - mxchatRebuildHeaderMenus();
140 - }
141 - };
142 -
143 - fetch(getRestNonceUrl() + '?with_settings=1', {
144 - credentials: 'same-origin',
145 - headers: { 'Accept': 'application/json' }
146 - }).then(function (resp) {
147 - if (!resp.ok) throw new Error('settings refresh failed: ' + resp.status);
148 - return resp.json();
149 - }).then(applied).catch(function () {
150 - // Fallback: legacy admin-ajax refresh path, same as withFreshNonce.
151 - if (mxchatChat.ajax_url) {
152 - $.post(mxchatChat.ajax_url, { action: 'mxchat_refresh_nonce', with_settings: 1 })
153 - .done(function (res) {
154 - applied(res && res.success && res.data ? res.data : null);
155 - })
156 - .fail(function () { applied(null); });
157 - } else {
158 - applied(null);
159 - }
160 - });
161 - }
162 -
163 12 // ====================================
164 13 // MULTI-INSTANCE MANAGEMENT SYSTEM
165 14 // ====================================
166 15
@@ -176,9 +25,9 @@
176 25 var chatPersistenceEnabled = typeof mxchatChat !== 'undefined' && mxchatChat.chat_persistence_toggle === 'on';
177 26
178 27 this.instances[botId] = {
179 28 botId: botId,
180 - sessionId: null,
29 + sessionId: this.getChatSession(botId),
181 30 lastSeenMessageId: '',
182 31 notificationCheckInterval: null,
183 32 pollingInterval: null,
184 33 processedMessageIds: new Set(),
@@ -203,77 +52,23 @@
203 52 return Object.keys(this.instances);
204 53 },
205 54
206 55 // Session management per bot
207 - // Returns existing session ID from cookie or localStorage (with in-memory fallback),
208 - // or null if none exists. Does NOT create a new session — use ensureSession() for that.
209 56 getChatSession: function(botId) {
210 57 var cookieName = 'mxchat_session_id_' + botId;
211 - var storageKey = 'mxchat_session_id_' + botId;
212 58 var sessionId = getCookie(cookieName);
213 59
214 - // Fallback to localStorage if cookie is missing (e.g. cleared by browser/consent)
215 60 if (!sessionId) {
216 - try { sessionId = localStorage.getItem(storageKey); } catch (e) {}
61 + sessionId = generateSessionId();
62 + this.setChatSession(botId, sessionId);
217 63 }
218 64
219 - // Fallback to in-memory instance when cookie AND localStorage are both blocked
220 - // (Safari ITP, strict tracking prevention, cross-origin iframes with partitioned
221 - // storage). Without this, ensureSession() can generate and store an ID that
222 - // getChatSession() then can't read back, causing null session_ids on send.
223 - if (!sessionId && this.instances[botId] && this.instances[botId].sessionId) {
224 - sessionId = this.instances[botId].sessionId;
225 - }
226 -
227 - // Guard against stored sentinel values that indicate earlier broken writes.
228 - if (sessionId === 'null' || sessionId === 'undefined') {
229 - sessionId = null;
230 - }
231 -
232 - // Re-sync cookie from localStorage if cookie was lost
233 - if (sessionId && !getCookie(cookieName)) {
234 - document.cookie = cookieName + "=" + sessionId + "; path=/; max-age=86400; SameSite=Lax";
235 - }
236 -
237 - return sessionId || null;
65 + return sessionId;
238 66 },
239 67
240 - // Lazy session initializer — called on first user interaction
241 - ensureSession: function(botId) {
242 - botId = botId || 'default';
243 - var instance = this.instances[botId] || this.init(botId);
244 -
245 - if (instance.sessionId) {
246 - return instance.sessionId;
247 - }
248 -
249 - // Check for existing session from cookie or localStorage
250 - var existingSession = this.getChatSession(botId);
251 -
252 - if (existingSession) {
253 - instance.sessionId = existingSession;
254 - } else {
255 - // Brand new session
256 - var newId = generateSessionId();
257 - this.setChatSession(botId, newId);
258 - instance.sessionId = newId;
259 - }
260 -
261 - // Now that we have a session, do the deferred work
262 - refreshNonceIfNeeded();
263 - trackOriginatingPage();
264 -
265 - // Note: loadChatHistory is handled by showChatContainerForBot with loader UI,
266 - // so we do NOT call it here to avoid a race condition.
267 -
268 - return instance.sessionId;
269 - },
270 -
271 68 setChatSession: function(botId, sessionId) {
272 69 var cookieName = 'mxchat_session_id_' + botId;
273 - var storageKey = 'mxchat_session_id_' + botId;
274 70 document.cookie = cookieName + "=" + sessionId + "; path=/; max-age=86400; SameSite=Lax";
275 - try { localStorage.setItem(storageKey, sessionId); } catch (e) {}
276 71 if (this.instances[botId]) {
277 72 this.instances[botId].sessionId = sessionId;
278 73 }
279 74 },
@@ -278,10 +73,8 @@
278 73 }
279 74 },
280 75
281 76 resetChatSession: function(botId) {
282 - // Clear old session from localStorage before setting new one
283 - try { localStorage.removeItem('mxchat_session_id_' + botId); } catch (e) {}
284 77 var newSessionId = generateSessionId();
285 78 this.setChatSession(botId, newSessionId);
286 79 var $chatBox = getElement(botId, 'chat-box');
287 80 if ($chatBox.length) {
@@ -290,20 +83,8 @@
290 83 if (this.instances[botId]) {
291 84 this.instances[botId].chatHistoryLoaded = false;
292 85 this.instances[botId].processedMessageIds = new Set();
293 86 }
294 - },
295 -
296 - // Silent reset — new session ID without clearing the chat UI
297 - // Used when IP changes mid-conversation so the user doesn't see messages vanish
298 - silentResetSession: function(botId) {
299 - try { localStorage.removeItem('mxchat_session_id_' + botId); } catch (e) {}
300 - var newSessionId = generateSessionId();
301 - this.setChatSession(botId, newSessionId);
302 - if (this.instances[botId]) {
303 - this.instances[botId].sessionId = newSessionId;
304 - }
305 - return newSessionId;
306 87 }
307 88 };
308 89
309 90 // ====================================
@@ -603,106 +384,13 @@
603 384 sendButton.disabled = false;
604 385 sendButton.style.opacity = '1';
605 386 sendButton.style.pointerEvents = 'auto';
606 387 }
607 - // Every completion path re-enables input, so this is the single restore
608 - // point for the streaming Stop affordance (no-op when not in stop mode).
609 - mxchatRestoreSendButton(botId);
610 388 }
611 389
612 -// --- Streaming Stop control -------------------------------------------------
613 -// One live stream handle per bot instance, so Stop on one widget never aborts
614 -// another bot on the same page.
615 -var mxchatActiveStreams = {};
616 -// Original send-button markup, captured once per bot the first time the Stop
617 -// state is shown (never captured while already in stop mode, so a rapid
618 -// stop-then-resend can't save the stop glyph as the "original").
619 -var mxchatSendMarkup = {};
620 -
621 -function mxchatShowStopButton(botId) {
622 - var btn = getElementDOM(botId, 'send-button');
623 - if (!btn) return;
624 - if (!btn.classList.contains('mxchat-stop-mode')) {
625 - mxchatSendMarkup[botId] = {
626 - html: btn.innerHTML,
627 - label: btn.getAttribute('aria-label')
628 - };
629 - }
630 -
631 - // Mirror the send icon's rendered size + color so the stop glyph looks
632 - // native, including custom send images/colors and theme overrides.
633 - var child = btn.querySelector('svg, img');
634 - var size = 25;
635 - var color = '';
636 - if (child) {
637 - var rect = child.getBoundingClientRect();
638 - if (rect.width) {
639 - size = Math.round(Math.min(rect.width, rect.height));
640 - }
641 - var cs = window.getComputedStyle(child);
642 - color = (child.tagName.toLowerCase() === 'svg' ? cs.fill : cs.color) || '';
643 - }
644 - var stopLabel = (typeof mxchatChat !== 'undefined' && mxchatChat.stop_button_label) || 'Stop response';
645 - btn.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" aria-hidden="true" style="width:' + size + 'px;height:' + size + 'px;' + (color ? 'fill:' + color + ';' : '') + '"><rect x="5" y="5" width="14" height="14" rx="3"></rect></svg>';
646 - // An add-on's DIRECT send-button handler (e.g. mxchat-vision's rebind) can
647 - // start a stream synchronously while the originating click is still
648 - // bubbling up to our delegated handler. Without this guard, that handler
649 - // reads the just-added stop-mode class as a user Stop press and aborts the
650 - // brand-new stream — the user's message renders but no reply ever fires
651 - // (plan-4bba64 silent message loss). The flag only spans the current event
652 - // dispatch: cleared on the next macrotask, long before a real Stop click.
653 - btn.__mxchatStopJustShown = true;
654 - setTimeout(function () { btn.__mxchatStopJustShown = false; }, 0);
655 - btn.classList.add('mxchat-stop-mode');
656 - btn.setAttribute('aria-label', stopLabel);
657 - btn.setAttribute('title', stopLabel);
658 - // disableChatInput() ran when the turn was sent; the Stop control itself
659 - // must stay clickable while the textarea remains disabled.
660 - btn.disabled = false;
661 - btn.style.opacity = '1';
662 - btn.style.pointerEvents = 'auto';
663 -}
664 -
665 -function mxchatRestoreSendButton(botId) {
666 - var btn = getElementDOM(botId, 'send-button');
667 - var saved = mxchatSendMarkup[botId];
668 - if (!btn || !btn.classList.contains('mxchat-stop-mode') || !saved) return;
669 - btn.innerHTML = saved.html;
670 - btn.classList.remove('mxchat-stop-mode');
671 - btn.removeAttribute('title');
672 - if (saved.label) {
673 - btn.setAttribute('aria-label', saved.label);
674 - }
675 -}
676 -
677 -function mxchatStopStreaming(botId) {
678 - var entry = mxchatActiveStreams[botId];
679 - if (!entry || !entry.controller) return;
680 - entry.aborted = true;
681 - try { entry.controller.abort(); } catch (e) {}
682 -}
683 -
684 -// Returns true when a stream rejection came from an intentional Stop click:
685 -// keep the partial text as the turn's answer — no error UI, no fallback resend.
686 -function mxchatHandleStreamAbort(botId, accumulatedContent, callback) {
687 - var entry = mxchatActiveStreams[botId];
688 - if (!entry || !entry.aborted) return false;
689 - delete mxchatActiveStreams[botId];
690 - if (!accumulatedContent) {
691 - // Stopped before the first chunk: drop the thinking bubble, no orphan message.
692 - getElement(botId, 'chat-box').find('.bot-message.temporary-message').remove();
693 - }
694 - enableChatInput(botId); // also restores the send icon
695 - if (callback) {
696 - callback(accumulatedContent || '');
697 - }
698 - return true;
699 -}
700 -
701 390 // Update your existing sendMessage function
702 391 function sendMessage(botId) {
703 392 botId = botId || 'default';
704 - MxChatInstances.ensureSession(botId);
705 393 var $chatInput = getElement(botId, 'chat-input');
706 394 var message = $chatInput.val();
707 395
708 396 // ADD PROMPT HOOK HERE
@@ -745,9 +433,8 @@
745 433
746 434 // Update your existing sendMessageToChatbot function
747 435 function sendMessageToChatbot(message, botId) {
748 436 botId = botId || 'default';
749 - MxChatInstances.ensureSession(botId);
750 437
751 438 // ADD PROMPT HOOK HERE
752 439 if (typeof customMxChatFilter === 'function') {
753 440 message = customMxChatFilter(message, "prompt");
@@ -838,15 +525,8 @@
838 525
839 526 function callMxChat(message, callback, botId) {
840 527 botId = botId || getMxChatBotId();
841 528
842 - // Streaming fallbacks land here: drop any leftover stream handle and
843 - // return the button to its send state (no-op for plain non-stream turns).
844 - if (mxchatActiveStreams[botId]) {
845 - delete mxchatActiveStreams[botId];
846 - }
847 - mxchatRestoreSendButton(botId);
848 -
849 529 // Store the message in case we need to retry after session reset
850 530 getElement(botId, 'mxchat-chatbot-wrapper').find('.mxchat-input-holder textarea').data('pending-message', message);
851 531
852 532 // Get page context if contextual awareness is enabled
@@ -854,28 +534,13 @@
854 534
855 535 // Get instance for session start timestamp (used when persistence is OFF)
856 536 var instance = MxChatInstances.get(botId);
857 537
858 - // Guarantee a non-null session_id before the AJAX leaves. ensureSession() is idempotent
859 - // and returns the guaranteed-present session id from the in-memory instance even when
860 - // cookie/localStorage writes are silently blocked by the browser.
861 - var sessionId = MxChatInstances.ensureSession(botId);
862 - if (!sessionId || sessionId === 'null' || sessionId === 'undefined') {
863 - // Last-resort generation to ensure we never POST a null marker.
864 - sessionId = generateSessionId();
865 - MxChatInstances.setChatSession(botId, sessionId);
866 - }
867 -
868 - // Wait for the page-cache nonce refresh to complete before firing the
869 - // chat-send AJAX. On cached pages the inline mxchatChat.nonce is stale
870 - // until refreshNonceIfNeeded() returns; constructing ajaxData inside the
871 - // callback guarantees we read the fresh value. See plan-c5457f.
872 - refreshNonceIfNeeded(function() {
873 538 // Prepare AJAX data
874 539 const ajaxData = {
875 540 action: 'mxchat_handle_chat_request',
876 541 message: message,
877 - session_id: sessionId,
542 + session_id: getChatSession(botId),
878 543 nonce: mxchatChat.nonce,
879 544 current_page_url: window.location.href,
880 545 current_page_title: document.title,
881 546 bot_id: botId,
@@ -881,14 +546,14 @@
881 546 bot_id: botId,
882 547 // Pass session start timestamp so AI context matches what user sees
883 548 session_start_timestamp: instance.sessionStartTimestamp || 0
884 549 };
885 -
550 +
886 551 // Add page context if available
887 552 if (pageContext) {
888 553 ajaxData.page_context = JSON.stringify(pageContext);
889 554 }
890 -
555 +
891 556 // CHECK FOR VISION FLAGS AND ADD THEM
892 557 if (window.mxchatVisionProcessed) {
893 558 ajaxData.vision_processed = true;
894 559 ajaxData.original_user_message = window.mxchatOriginalMessage || message;
@@ -897,9 +562,9 @@
897 562 window.mxchatVisionProcessed = false;
898 563 window.mxchatOriginalMessage = null;
899 564 window.mxchatVisionImagesCount = 0;
900 565 }
901 -
566 +
902 567 $.ajax({
903 568 url: mxchatChat.ajax_url,
904 569 type: 'POST',
905 570 dataType: 'json',
@@ -937,16 +602,23 @@
937 602 errorMessage = "An error occurred. Please try again or contact support.";
938 603 }
939 604
940 605 // Handle session reset action (IP changed, session expired, etc.)
941 - // Silent reset — keep chat UI intact, just get a new session and retry
942 606 if (response.data && response.data.action === 'reset_session') {
943 - MxChatInstances.silentResetSession(botId);
944 - // Re-send the original message with the new session (user message is already displayed)
607 + // Clear the old session and generate a new one
608 + resetChatSession(botId);
609 + // Remove the temporary loading message
610 + getElement(botId, 'chat-box').find('.bot-message.temporary-message').remove();
611 + // Re-send the original message with the new session
945 612 var originalMessage = getElement(botId, 'mxchat-chatbot-wrapper').find('.mxchat-input-holder textarea').data('pending-message');
946 613 if (originalMessage) {
947 614 getElement(botId, 'mxchat-chatbot-wrapper').find('.mxchat-input-holder textarea').data('pending-message', null);
948 - var currentModel = mxchatChat.model || 'gpt-5.1-chat-latest';
615 + // Re-add the user message and thinking indicator
616 + appendMessage("user", originalMessage, '', [], false, botId);
617 + appendThinkingMessage(botId);
618 + scrollToBottom(botId);
619 + // Determine whether to use streaming
620 + const currentModel = mxchatChat.model || 'gpt-5.1-chat-latest';
949 621 if (shouldUseStreaming(currentModel)) {
950 622 callMxChatStream(originalMessage, function(response) {
951 623 getElement(botId, 'chat-box').find('.bot-message.temporary-message').removeClass('temporary-message');
952 624 }, botId);
@@ -1040,13 +712,9 @@
1040 712 $badge.show();
1041 713 }
1042 714 }
1043 715 } else {
1044 - var emptyMsg = "I received an empty response. Please try again or contact support if this persists.";
1045 - if (response.vectorstore_error) {
1046 - emptyMsg = "I received an empty response. Debug info: " + response.vectorstore_error;
1047 - }
1048 - replaceLastMessage("bot", emptyMsg, '', [], botId);
716 + replaceLastMessage("bot", "I received an empty response. Please try again or contact support if this persists.", '', [], botId);
1049 717 }
1050 718
1051 719 if (response.message_id) {
1052 720 var instance = MxChatInstances.get(botId);
@@ -1088,9 +756,8 @@
1088 756
1089 757 replaceLastMessage("bot", errorMessage, '', [], botId);
1090 758 }
1091 759 });
1092 - }); // refreshNonceIfNeeded
1093 760 }
1094 761
1095 762 function callMxChatStream(message, callback, botId) {
1096 763 botId = botId || getMxChatBotId();
@@ -1109,25 +776,12 @@
1109 776
1110 777 // Get instance for session start timestamp (used when persistence is OFF)
1111 778 var instance = MxChatInstances.get(botId);
1112 779
1113 - // Guarantee a non-null session_id before the fetch. FormData.append() stringifies any
1114 - // non-string value via String(), so passing `null` would POST the literal string "null"
1115 - // and land in the transcripts table as a ghost session. ensureSession() always returns
1116 - // a real string even when cookies/localStorage are blocked.
1117 - var streamSessionId = MxChatInstances.ensureSession(botId);
1118 - if (!streamSessionId || streamSessionId === 'null' || streamSessionId === 'undefined') {
1119 - streamSessionId = generateSessionId();
1120 - MxChatInstances.setChatSession(botId, streamSessionId);
1121 - }
1122 -
1123 - // Wait for the page-cache nonce refresh before constructing formData (which
1124 - // captures mxchatChat.nonce by value). Mirrors callMxChat's wrapping. See plan-c5457f.
1125 - refreshNonceIfNeeded(function() {
1126 780 const formData = new FormData();
1127 781 formData.append('action', 'mxchat_stream_chat');
1128 782 formData.append('message', message);
1129 - formData.append('session_id', streamSessionId);
783 + formData.append('session_id', getChatSession(botId));
1130 784 formData.append('nonce', mxchatChat.nonce);
1131 785 formData.append('current_page_url', window.location.href);
1132 786 formData.append('current_page_title', document.title);
1133 787 formData.append('bot_id', botId);
@@ -1153,20 +807,12 @@
1153 807 let accumulatedContent = '';
1154 808 let testingDataReceived = false;
1155 809 let streamingStarted = false;
1156 810
1157 - // Abortable stream: a fresh controller per turn, keyed by bot instance.
1158 - // The Stop control (send button swapped in place) aborts both the read
1159 - // loop and the underlying request.
1160 - var streamControl = { controller: new AbortController(), aborted: false };
1161 - mxchatActiveStreams[botId] = streamControl;
1162 - mxchatShowStopButton(botId);
1163 -
1164 811 fetch(mxchatChat.ajax_url, {
1165 812 method: 'POST',
1166 813 body: formData,
1167 - credentials: 'same-origin',
1168 - signal: streamControl.controller.signal
814 + credentials: 'same-origin'
1169 815 })
1170 816 .then(response => {
1171 817 // Store the response for potential fallback handling
1172 818 const responseClone = response.clone();
@@ -1235,16 +881,8 @@
1235 881
1236 882 // Re-enable chat input when stream ends with content
1237 883 enableChatInput(botId);
1238 884
1239 - // Scroll the user's last message to the top now that the
1240 - // bot's full reply has rendered (gives max reading room).
1241 - var $chatBoxDone = getElement(botId, 'chat-box');
1242 - var $lastUserMsgDone = $chatBoxDone.find('.user-message').last();
1243 - if ($lastUserMsgDone.length) {
1244 - scrollElementToTop($lastUserMsgDone, botId);
1245 - }
1246 -
1247 885 if (callback) {
1248 886 callback(accumulatedContent);
1249 887 }
1250 888 return;
@@ -1267,16 +905,8 @@
1267 905
1268 906 // Re-enable chat input after streaming completes
1269 907 enableChatInput(botId);
1270 908
1271 - // Scroll the user's last message to the top now
1272 - // that the bot's full reply has rendered.
1273 - var $chatBoxStreamDone = getElement(botId, 'chat-box');
1274 - var $lastUserMsgStreamDone = $chatBoxStreamDone.find('.user-message').last();
1275 - if ($lastUserMsgStreamDone.length) {
1276 - scrollElementToTop($lastUserMsgStreamDone, botId);
1277 - }
1278 -
1279 909 if (callback) {
1280 910 callback(accumulatedContent);
1281 911 }
1282 912 return;
@@ -1333,9 +963,8 @@
1333 963 }
1334 964
1335 965 processStream();
1336 966 }).catch(streamError => {
1337 - if (mxchatHandleStreamAbort(botId, accumulatedContent, callback)) return;
1338 967 getElement(botId, 'chat-box').find('.bot-message.temporary-message').remove();
1339 968 callMxChat(message, callback, botId);
1340 969 });
1341 970 }
@@ -1342,9 +971,8 @@
1342 971
1343 972 processStream();
1344 973 })
1345 974 .catch(error => {
1346 - if (mxchatHandleStreamAbort(botId, accumulatedContent, callback)) return;
1347 975 // Check if we have server error data with chat mode
1348 976 if (error && error.isServerError && error.data) {
1349 977 // Check for chat mode in error data
1350 978 if (error.data.chat_mode) {
@@ -1357,9 +985,8 @@
1357 985 getElement(botId, 'chat-box').find('.bot-message.temporary-message').remove();
1358 986 callMxChat(message, callback, botId);
1359 987 }
1360 988 });
1361 - }); // refreshNonceIfNeeded
1362 989 }
1363 990
1364 991 // Helper function to handle non-streaming responses
1365 992 function handleNonStreamResponse(data, callback, botId) {
@@ -1398,16 +1025,21 @@
1398 1025 errorMessage = "An error occurred. Please try again or contact support.";
1399 1026 }
1400 1027
1401 1028 // Handle session reset action (IP changed, session expired, etc.)
1402 - // Silent reset — keep chat UI intact, just get a new session and retry
1403 1029 if (data.data && data.data.action === 'reset_session') {
1404 - MxChatInstances.silentResetSession(botId);
1405 - // Re-send the original message with the new session (user message is already displayed)
1030 + // Clear the old session and generate a new one
1031 + resetChatSession(botId);
1032 + // Re-send the original message with the new session
1406 1033 var originalMessage = getElement(botId, 'mxchat-chatbot-wrapper').find('.mxchat-input-holder textarea').data('pending-message');
1407 1034 if (originalMessage) {
1408 1035 getElement(botId, 'mxchat-chatbot-wrapper').find('.mxchat-input-holder textarea').data('pending-message', null);
1409 - var currentModel = mxchatChat.model || 'gpt-5.1-chat-latest';
1036 + // Re-add the user message and thinking indicator
1037 + appendMessage("user", originalMessage, '', [], false, botId);
1038 + appendThinkingMessage(botId);
1039 + scrollToBottom(botId);
1040 + // Determine whether to use streaming
1041 + const currentModel = mxchatChat.model || 'gpt-5.1-chat-latest';
1410 1042 if (shouldUseStreaming(currentModel)) {
1411 1043 callMxChatStream(originalMessage, callback, botId);
1412 1044 } else {
1413 1045 callMxChat(originalMessage, callback, botId);
@@ -1568,17 +1200,8 @@
1568 1200 // Update the event handlers to use the correct function names (using event delegation)
1569 1201 // Use class-based selectors for multi-instance support
1570 1202 $(document).on('click', '.send-button', function() {
1571 1203 var botId = getBotIdFromElement(this);
1572 - // While a response is streaming the button is a Stop control.
1573 - if (this.classList.contains('mxchat-stop-mode')) {
1574 - // Same click that just started this stream (an add-on's direct handler
1575 - // ran before this delegated one) — not a Stop press. See
1576 - // mxchatShowStopButton for the full story (plan-4bba64).
1577 - if (this.__mxchatStopJustShown) return;
1578 - mxchatStopStreaming(botId);
1579 - return;
1580 - }
1581 1204 var modeIndicator = getElementDOM(botId, 'chat-mode-indicator');
1582 1205 if (!(modeIndicator && modeIndicator.textContent === 'Live Agent')) {
1583 1206 disableChatInput(botId);
1584 1207 }
@@ -1597,287 +1220,9 @@
1597 1220 sendMessage(botId);
1598 1221 }
1599 1222 });
1600 1223
1601 -// Builds the list of overflow-menu items for a given bot.
1602 -// Adding a future item is one push to this array — do NOT hardcode "only download."
1603 -function mxchatGetHeaderMenuItems(botId) {
1604 - var items = [];
1605 - var settings = (typeof mxchatChat !== 'undefined') ? mxchatChat : {};
1606 -
1607 - // The `print_button_*` keys still gate this item for back-compat with
1608 - // existing user options. The action is now a transcript download, not print.
1609 - if (settings.print_button_enabled === 'on') {
1610 - items.push({
1611 - id: 'download-transcript',
1612 - label: settings.print_button_label || 'Download Transcript',
1613 - icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>',
1614 - action: function() {
1615 - mxchatDownloadTranscript(botId);
1616 - }
1617 - });
1618 - }
1619 -
1620 - return items;
1621 -}
1622 -
1623 -// Builds a clean markdown transcript of the current conversation and triggers
1624 -// a file download. Used by the "Download Transcript" menu item.
1625 -function mxchatDownloadTranscript(botId) {
1626 - var $chatBox = getElement(botId, 'chat-box');
1627 - if (!$chatBox || !$chatBox.length) return;
1628 -
1629 - var settings = (typeof mxchatChat !== 'undefined') ? mxchatChat : {};
1630 - var headerTitle = settings.print_header_title || 'Chat transcript';
1631 - var now = new Date();
1632 - var stamp = now.toLocaleString();
1633 -
1634 - var lines = [];
1635 - lines.push('# ' + headerTitle);
1636 - lines.push('');
1637 - lines.push('Exported: ' + stamp);
1638 - lines.push('');
1639 - lines.push('---');
1640 - lines.push('');
1641 -
1642 - $chatBox.find('.user-message, .bot-message, .agent-message').each(function() {
1643 - var $msg = $(this);
1644 - // Skip thinking placeholders and any in-flight temporary messages.
1645 - if ($msg.find('.thinking-dots').length) return;
1646 - if ($msg.hasClass('temporary-message')) return;
1647 -
1648 - var sender;
1649 - if ($msg.hasClass('user-message')) sender = 'User';
1650 - else if ($msg.hasClass('agent-message')) sender = 'Live Agent';
1651 - else sender = 'AI Agent';
1652 -
1653 - // Strip interactive UI from the cloned message so we get the conversation text.
1654 - var $clone = $msg.clone();
1655 - $clone.find('.copy-button, .message-toolbar, .mxchat-copy, button, script, style').remove();
1656 - var text = $clone.text().replace(/ /g, ' ').replace(/[ \t]+\n/g, '\n').replace(/\n{3,}/g, '\n\n').trim();
1657 - if (!text) return;
1658 -
1659 - lines.push('**' + sender + '**');
1660 - lines.push('');
1661 - lines.push(text);
1662 - lines.push('');
1663 - });
1664 -
1665 - var content = lines.join('\n');
1666 - var iso = now.toISOString().replace(/[:.]/g, '-').slice(0, 19);
1667 - var fname = 'mxchat-transcript-' + iso + '.md';
1668 - var blob = new Blob([content], { type: 'text/markdown;charset=utf-8' });
1669 - var url = URL.createObjectURL(blob);
1670 - var a = document.createElement('a');
1671 - a.href = url;
1672 - a.download = fname;
1673 - a.style.display = 'none';
1674 - document.body.appendChild(a);
1675 - a.click();
1676 - setTimeout(function() {
1677 - if (a.parentNode) a.parentNode.removeChild(a);
1678 - URL.revokeObjectURL(url);
1679 - }, 100);
1680 -}
1681 -
1682 -// Reads the bot bubble's actual computed bg+fg and writes them as CSS vars
1683 -// on the menu wrap, so the dropdown matches whatever paints the bubble —
1684 -// saved options, AI theme CSS, or the mxchat-theme add-on.
1685 -function mxchatSyncMenuColors(botId, $wrap) {
1686 - if (!$wrap || !$wrap.length) return;
1687 - var $bot = $wrap.closest('.mxchat-chatbot-wrapper').find('.bot-message').not('.temporary-message').first();
1688 - if (!$bot.length) return;
1689 - var cs = window.getComputedStyle($bot[0]);
1690 - if (cs.backgroundColor && cs.backgroundColor !== 'rgba(0, 0, 0, 0)' && cs.backgroundColor !== 'transparent') {
1691 - $wrap[0].style.setProperty('--mxchat-menu-bg', cs.backgroundColor);
1692 - }
1693 - // Bot text color usually lives on a child div, not .bot-message itself.
1694 - var $textChild = $bot.find('[style*="color"]').first();
1695 - var fg = ($textChild.length ? window.getComputedStyle($textChild[0]).color : cs.color);
1696 - if (fg) $wrap[0].style.setProperty('--mxchat-menu-fg', fg);
1697 -}
1698 -
1699 -// Renders (or re-renders) the item list for one menu wrap. Split out of
1700 -// mxchatInitHeaderMenu so the dynamic-settings merge (plan-32db95) can
1701 -// rebuild items + trigger visibility WITHOUT re-binding the one-time
1702 -// open/close/keyboard wiring. closeMenu is passed in by the init closure;
1703 -// a rebuild before init (never happens, but harmless) just skips it.
1704 -function mxchatRenderHeaderMenuItems(botId, $wrap, closeMenuFn) {
1705 - var $trigger = $wrap.find('.mxchat-menu-trigger');
1706 - var $menu = $wrap.find('.mxchat-header-menu');
1707 - var items = mxchatGetHeaderMenuItems(botId);
1708 -
1709 - $menu.empty();
1710 -
1711 - if (!items.length) {
1712 - $trigger.hide();
1713 - $menu.hide();
1714 - return;
1715 - }
1716 -
1717 - // Clear any inline display:none a previous zero-item render left behind —
1718 - // open/close visibility is governed by the hidden prop + is-open class.
1719 - $trigger.css('display', '');
1720 - $menu.css('display', '');
1721 -
1722 - items.forEach(function(item, idx) {
1723 - var $btn = $('<button>', {
1724 - type: 'button',
1725 - 'class': 'mxchat-menu-item',
1726 - 'role': 'menuitem',
1727 - 'tabindex': '-1',
1728 - 'data-menu-id': item.id,
1729 - html: '<span class="mxchat-menu-item-icon">' + item.icon + '</span>' +
1730 - '<span class="mxchat-menu-item-label"></span>'
1731 - });
1732 - $btn.find('.mxchat-menu-item-label').text(item.label);
1733 - $btn.on('click', function(e) {
1734 - e.preventDefault();
1735 - e.stopPropagation();
1736 - if (closeMenuFn) closeMenuFn();
1737 - try { item.action(); } catch (err) { /* no-op */ }
1738 - });
1739 - $menu.append($btn);
1740 - });
1741 -}
1742 -
1743 -// Re-render every menu on the page after a dynamic-settings merge
1744 -// (multi-bot: each wrap re-reads its items). An OPEN menu is left alone —
1745 -// swapping items under the user mid-interaction yanks focus — and the
1746 -// rebuild runs when it closes instead (closeMenu checks the pending flag).
1747 -function mxchatRebuildHeaderMenus() {
1748 - $('.mxchat-header-menu-wrap').each(function() {
1749 - var $wrap = $(this);
1750 - var botId = $wrap.data('bot-id');
1751 - if (!botId) return;
1752 - if (!$wrap.data('mxchatMenuReady')) {
1753 - mxchatInitHeaderMenu(botId);
1754 - return;
1755 - }
1756 - if ($wrap.find('.mxchat-header-menu').hasClass('is-open')) {
1757 - $wrap.data('mxchatMenuRebuildPending', true);
1758 - return;
1759 - }
1760 - mxchatRenderHeaderMenuItems(botId, $wrap, $wrap.data('mxchatMenuClose'));
1761 - });
1762 -}
1763 -
1764 -// One-time per-widget init: renders menu items, wires open/close,
1765 -// outside-click, Escape, and arrow-key navigation. If no items, hides the
1766 -// trigger. Wiring happens even when there are zero items at init, so a
1767 -// later dynamic-settings rebuild that adds items has a working trigger.
1768 -function mxchatInitHeaderMenu(botId) {
1769 - var $wrap = $('.mxchat-header-menu-wrap[data-bot-id="' + botId + '"]').first();
1770 - if (!$wrap.length || $wrap.data('mxchatMenuReady')) return;
1771 -
1772 - var $trigger = $wrap.find('.mxchat-menu-trigger');
1773 - var $menu = $wrap.find('.mxchat-header-menu');
1774 -
1775 - // Initial color sync — covers normal page load.
1776 - mxchatSyncMenuColors(botId, $wrap);
1777 -
1778 - function openMenu() {
1779 - // Re-sync each open in case the active theme changed since init.
1780 - mxchatSyncMenuColors(botId, $wrap);
1781 - $menu.prop('hidden', false).attr('aria-hidden', 'false').addClass('is-open');
1782 - $trigger.attr('aria-expanded', 'true');
1783 - // Focus the first item for keyboard users
1784 - setTimeout(function() {
1785 - $menu.find('.mxchat-menu-item').first().attr('tabindex', '0').trigger('focus');
1786 - }, 0);
1787 - }
1788 - function closeMenu(returnFocus) {
1789 - $menu.prop('hidden', true).attr('aria-hidden', 'true').removeClass('is-open');
1790 - $trigger.attr('aria-expanded', 'false');
1791 - $menu.find('.mxchat-menu-item').attr('tabindex', '-1');
1792 - if (returnFocus) $trigger.trigger('focus');
1793 - // A dynamic-settings rebuild that arrived while the menu was open
1794 - // was deferred (mxchatRebuildHeaderMenus) — run it now.
1795 - if ($wrap.data('mxchatMenuRebuildPending')) {
1796 - $wrap.removeData('mxchatMenuRebuildPending');
1797 - mxchatRenderHeaderMenuItems(botId, $wrap, closeMenu);
1798 - }
1799 - }
1800 -
1801 - // Toggle on trigger click — stop propagation so the .chatbot-top-bar
1802 - // click-to-collapse handler does not fire.
1803 - $trigger.on('click', function(e) {
1804 - e.preventDefault();
1805 - e.stopPropagation();
1806 - if ($menu.hasClass('is-open')) closeMenu();
1807 - else openMenu();
1808 - });
1809 -
1810 - // Don't let clicks inside the menu bubble to the top-bar collapse handler.
1811 - $menu.on('click', function(e) {
1812 - e.stopPropagation();
1813 - });
1814 -
1815 - // Outside click closes the menu.
1816 - $(document).on('click.mxchatMenu-' + botId, function(e) {
1817 - if (!$menu.hasClass('is-open')) return;
1818 - if ($wrap.has(e.target).length || $wrap.is(e.target)) return;
1819 - closeMenu();
1820 - });
1821 -
1822 - // Keyboard: Escape closes and returns focus; arrow keys move focus; Enter activates.
1823 - $menu.on('keydown', '.mxchat-menu-item', function(e) {
1824 - var $items = $menu.find('.mxchat-menu-item');
1825 - var idx = $items.index(this);
1826 - if (e.key === 'Escape') {
1827 - e.preventDefault();
1828 - closeMenu(true);
1829 - } else if (e.key === 'ArrowDown') {
1830 - e.preventDefault();
1831 - var $next = $items.eq((idx + 1) % $items.length);
1832 - $items.attr('tabindex', '-1');
1833 - $next.attr('tabindex', '0').trigger('focus');
1834 - } else if (e.key === 'ArrowUp') {
1835 - e.preventDefault();
1836 - var $prev = $items.eq((idx - 1 + $items.length) % $items.length);
1837 - $items.attr('tabindex', '-1');
1838 - $prev.attr('tabindex', '0').trigger('focus');
1839 - } else if (e.key === 'Enter' || e.key === ' ') {
1840 - e.preventDefault();
1841 - $(this).trigger('click');
1842 - }
1843 - });
1844 - $trigger.on('keydown', function(e) {
1845 - if (e.key === 'Escape' && $menu.hasClass('is-open')) {
1846 - e.preventDefault();
1847 - closeMenu(true);
1848 - } else if ((e.key === 'ArrowDown' || e.key === 'Enter' || e.key === ' ') && !$menu.hasClass('is-open')) {
1849 - e.preventDefault();
1850 - openMenu();
1851 - }
1852 - });
1853 -
1854 - // Expose closeMenu for out-of-closure re-renders (mxchatRebuildHeaderMenus),
1855 - // then do the initial item render.
1856 - $wrap.data('mxchatMenuClose', closeMenu);
1857 - mxchatRenderHeaderMenuItems(botId, $wrap, closeMenu);
1858 -
1859 - $wrap.data('mxchatMenuReady', true);
1860 -}
1861 -
1862 -// Initialize header menus for every rendered widget on DOM ready.
1863 -$(function() {
1864 - $('.mxchat-header-menu-wrap').each(function() {
1865 - var botId = $(this).data('bot-id');
1866 - if (botId) mxchatInitHeaderMenu(botId);
1867 - });
1868 -
1869 - // Embedded (non-floating) widgets are open from the moment the page
1870 - // renders — refresh dynamic settings at init (plan-32db95). Floating
1871 - // widgets refresh on first launcher open instead.
1872 - var hasEmbeddedWidget = $('.mxchat-chatbot-wrapper').filter(function() {
1873 - return !$(this).closest('.floating-chatbot').length;
1874 - }).length > 0;
1875 - if (hasEmbeddedWidget) {
1876 - mxchatRefreshDynamicSettings();
1877 - }
1878 -});
1879 -
1224 +
1880 1225 function appendMessage(sender, messageText = '', messageHtml = '', images = [], isTemporary = false, botId = 'default') {
1881 1226 try {
1882 1227 // Determine styles based on sender type
1883 1228 let messageClass, bgColor, fontColor;
@@ -1915,12 +1260,17 @@
1915 1260 'margin-bottom': '1em'
1916 1261 });
1917 1262 }
1918 1263
1919 - // Process the message content - always run linkify to convert markdown
1920 - // links and format text. linkify() handles existing HTML safely via
1921 - // negative lookaheads that skip URLs already inside <a> tags.
1922 - let fullMessage = linkify(messageText);
1264 + // Process the message content based on sender
1265 + let fullMessage;
1266 + if (sender === "user") {
1267 + // For user messages, apply linkify after sanitization
1268 + fullMessage = linkify(messageText);
1269 + } else {
1270 + // For bot/agent messages, preserve HTML
1271 + fullMessage = messageText;
1272 + }
1923 1273
1924 1274 // Add images if provided
1925 1275 if (images && images.length > 0) {
1926 1276 fullMessage += '<div class="image-gallery" dir="auto">';
@@ -1969,12 +1319,8 @@
1969 1319 if (lastUserMessage.length) {
1970 1320 scrollElementToTop(lastUserMessage, botId);
1971 1321 }
1972 1322 }
1973 -
1974 - if ((sender === "bot" || sender === "agent") && !isTemporary) {
1975 - if (typeof mxchatInitHeaderMenu === 'function') mxchatInitHeaderMenu(botId);
1976 - }
1977 1323 });
1978 1324
1979 1325 if (messageText.id) {
1980 1326 var instance = MxChatInstances.get(botId);
@@ -2059,12 +1405,26 @@
2059 1405 bgColor = botMessageBgColor;
2060 1406 fontColor = botMessageFontColor;
2061 1407 }
2062 1408
2063 - // Always run linkify to convert markdown links and format text.
2064 - // linkify() already handles existing HTML (its URL patterns use negative lookaheads
2065 - // to avoid double-processing URLs that are already inside <a> tags).
2066 - var fullMessage = linkify(responseText);
1409 + // FIXED: Only linkify if response doesn't already contain HTML links or tags
1410 + // This prevents double-processing of URLs that are already formatted as HTML
1411 + var fullMessage;
1412 + if (sender === "user") {
1413 + // Always linkify user messages (they're plain text)
1414 + fullMessage = linkify(responseText);
1415 + } else {
1416 + // For bot/agent messages, check if HTML already exists
1417 + if (responseText.includes('<a href=') || responseText.includes('</a>') ||
1418 + responseText.includes('<img') || responseText.includes('<div') ||
1419 + responseText.includes('<p>') || responseText.includes('<br>')) {
1420 + // Response already has HTML, don't process it
1421 + fullMessage = responseText;
1422 + } else {
1423 + // Plain text response, apply linkify
1424 + fullMessage = linkify(responseText);
1425 + }
1426 + }
2067 1427
2068 1428 if (responseHtml) {
2069 1429 // Only add line breaks if there's actual text content before the HTML
2070 1430 if (fullMessage && fullMessage.trim()) {
@@ -2121,12 +1481,8 @@
2121 1481 }
2122 1482
2123 1483 // Re-enable chat input after response is displayed
2124 1484 enableChatInput(botId);
2125 -
2126 - if (sender === "bot" || sender === "agent") {
2127 - if (typeof mxchatInitHeaderMenu === 'function') mxchatInitHeaderMenu(botId);
2128 - }
2129 1485 } else {
2130 1486 appendMessage(sender, responseText, responseHtml, images, false, botId);
2131 1487 // Re-enable chat input after response is displayed
2132 1488 enableChatInput(botId);
@@ -2211,12 +1567,9 @@
2211 1567 processedText = formatTextStyling(processedText);
2212 1568
2213 1569 // Process code blocks BEFORE processing links
2214 1570 processedText = formatCodeBlocks(processedText);
2215 -
2216 - // Process markdown tables BEFORE converting newlines to paragraphs
2217 - processedText = formatMarkdownTables(processedText);
2218 -
1571 +
2219 1572 // NOW convert to paragraphs
2220 1573 processedText = convertNewlinesToBreaks(processedText);
2221 1574
2222 1575 // IMPORTANT: Handle citation-style brackets FIRST [URL]
@@ -2229,63 +1582,37 @@
2229 1582 // Return as a proper link without the brackets
2230 1583 return `<a href="${safeUrl}" target="${linkTarget}">${cleanUrl}</a>`;
2231 1584 });
2232 1585
2233 - // Process markdown links: [text](url) and [](url)
2234 - // Uses balanced parenthesis matching to handle URLs containing parens
2235 - // (e.g. PDF filenames with dates like (2025-08-28).pdf)
2236 - processedText = (function(input) {
2237 - var result = '';
2238 - var i = 0;
2239 - while (i < input.length) {
2240 - // Look for [ at current position
2241 - if (input[i] === '[') {
2242 - // Find closing ]
2243 - var closeBracket = input.indexOf(']', i + 1);
2244 - if (closeBracket === -1 || closeBracket + 1 >= input.length || input[closeBracket + 1] !== '(') {
2245 - result += input[i];
2246 - i++;
2247 - continue;
2248 - }
2249 - var linkText = input.substring(i + 1, closeBracket);
2250 - // Check if URL starts with http
2251 - var urlStart = closeBracket + 2;
2252 - if (!input.substring(urlStart).match(/^https?:\/\//)) {
2253 - result += input[i];
2254 - i++;
2255 - continue;
2256 - }
2257 - // Find balanced closing paren
2258 - var depth = 1;
2259 - var j = urlStart;
2260 - while (j < input.length && depth > 0) {
2261 - if (input[j] === '(') depth++;
2262 - else if (input[j] === ')') depth--;
2263 - if (depth > 0) j++;
2264 - }
2265 - if (depth !== 0) {
2266 - result += input[i];
2267 - i++;
2268 - continue;
2269 - }
2270 - var url = input.substring(urlStart, j);
2271 - var cleanUrl = url.replace(/[\].,;!?]+$/, '');
2272 - var encodedUrl = safeEncodeUrl(cleanUrl);
2273 - if (!linkText || !linkText.trim()) {
2274 - result += '<a href="' + encodedUrl + '" target="' + linkTarget + '">' + cleanUrl + '</a>';
2275 - } else {
2276 - var safeText = sanitizeUserInput(linkText);
2277 - result += '<a href="' + encodedUrl + '" target="' + linkTarget + '">' + safeText + '</a>';
2278 - }
2279 - i = j + 1; // Skip past the closing )
2280 - } else {
2281 - result += input[i];
2282 - i++;
2283 - }
1586 + // Process proper markdown links with text: [text](url)
1587 + // This MUST have non-empty text in the first brackets
1588 + const markdownLinkPattern = /\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g;
1589 + processedText = processedText.replace(markdownLinkPattern, (match, text, url) => {
1590 + // Make sure we have actual text (not just whitespace)
1591 + if (!text || !text.trim()) {
1592 + // If no text, treat the URL as the text
1593 + let cleanUrl = url.replace(/[.,;!?]+$/, '');
1594 + const safeUrl = safeEncodeUrl(cleanUrl);
1595 + return `<a href="${safeUrl}" target="${linkTarget}">${cleanUrl}</a>`;
2284 1596 }
2285 - return result;
2286 - })(processedText);
1597 +
1598 + // Clean the URL
1599 + let cleanUrl = url.replace(/[\].,;!?]+$/, '');
1600 + const safeUrl = safeEncodeUrl(cleanUrl);
1601 + const safeText = sanitizeUserInput(text);
1602 + return `<a href="${safeUrl}" target="${linkTarget}">${safeText}</a>`;
1603 + });
2287 1604
1605 + // Handle empty markdown links: [](url)
1606 + // This is a specific case where there's no text
1607 + const emptyMarkdownPattern = /\[\]\((https?:\/\/[^\s)]+)\)/g;
1608 + processedText = processedText.replace(emptyMarkdownPattern, (match, url) => {
1609 + let cleanUrl = url.replace(/[.,;!?]+$/, '');
1610 + const safeUrl = safeEncodeUrl(cleanUrl);
1611 + // Use the URL itself as the link text
1612 + return `<a href="${safeUrl}" target="${linkTarget}">${cleanUrl}</a>`;
1613 + });
1614 +
2288 1615 // Process phone numbers: [text](tel:number)
2289 1616 const phonePattern = /\[([^\]]+)\]\((tel:[\d+\-\s()]+)\)/g;
2290 1617 processedText = processedText.replace(phonePattern, (match, text, phone) => {
2291 1618 const safePhone = safeEncodeUrl(phone);
@@ -2437,78 +1764,9 @@
2437 1764 });
2438 1765
2439 1766 return text;
2440 1767 }
2441 -
2442 - function formatMarkdownTables(text) {
2443 - var lines = text.split('\n');
2444 - var result = [];
2445 - var i = 0;
2446 -
2447 - while (i < lines.length) {
2448 - // Check for a table: current line has pipes AND next line is a separator row
2449 - if (i + 1 < lines.length &&
2450 - lines[i].indexOf('|') !== -1 &&
2451 - /^\s*\|?[\s\-:]+(\|[\s\-:]+)+\|?\s*$/.test(lines[i + 1])) {
2452 -
2453 - var tableLines = [];
2454 - var headerLine = lines[i];
2455 - var separatorLine = lines[i + 1];
2456 - tableLines.push(headerLine);
2457 - tableLines.push(separatorLine);
2458 -
2459 - // Collect remaining table rows
2460 - var j = i + 2;
2461 - while (j < lines.length && lines[j].indexOf('|') !== -1 && lines[j].trim() !== '') {
2462 - tableLines.push(lines[j]);
2463 - j++;
2464 - }
2465 -
2466 - // Parse alignment from separator row
2467 - var sepCells = separatorLine.split('|').filter(function(c) { return c.trim() !== ''; });
2468 - var alignments = sepCells.map(function(cell) {
2469 - var trimmed = cell.trim();
2470 - if (trimmed.charAt(0) === ':' && trimmed.charAt(trimmed.length - 1) === ':') return 'center';
2471 - if (trimmed.charAt(trimmed.length - 1) === ':') return 'right';
2472 - return 'left';
2473 - });
2474 -
2475 - // Build HTML table
2476 - var html = '<div class="mxchat-table-wrapper"><table class="mxchat-table">';
2477 -
2478 - // Header row
2479 - var headerCells = tableLines[0].split('|').filter(function(c) { return c.trim() !== ''; });
2480 - html += '<thead><tr>';
2481 - headerCells.forEach(function(cell, idx) {
2482 - var align = alignments[idx] || 'left';
2483 - html += '<th style="text-align:' + align + '">' + cell.trim() + '</th>';
2484 - });
2485 - html += '</tr></thead>';
2486 -
2487 - // Body rows
2488 - html += '<tbody>';
2489 - for (var r = 2; r < tableLines.length; r++) {
2490 - var rowCells = tableLines[r].split('|').filter(function(c) { return c.trim() !== ''; });
2491 - html += '<tr>';
2492 - rowCells.forEach(function(cell, idx) {
2493 - var align = alignments[idx] || 'left';
2494 - html += '<td style="text-align:' + align + '">' + cell.trim() + '</td>';
2495 - });
2496 - html += '</tr>';
2497 - }
2498 - html += '</tbody></table></div>';
2499 -
2500 - result.push(html);
2501 - i = j;
2502 - } else {
2503 - result.push(lines[i]);
2504 - i++;
2505 - }
2506 - }
2507 -
2508 - return result.join('\n');
2509 - }
2510 -
1768 +
2511 1769 function sanitizeUserInput(text) {
2512 1770 const div = document.createElement('div');
2513 1771 div.textContent = text;
2514 1772 return div.innerHTML;
@@ -2579,14 +1837,13 @@
2579 1837 requestAnimationFrame(smoothScroll);
2580 1838 }
2581 1839 }
2582 1840
2583 - function scrollElementToTop(element, botId, topOffset) {
1841 + function scrollElementToTop(element, botId) {
2584 1842 botId = botId || 'default';
2585 - topOffset = (typeof topOffset === 'number') ? topOffset : 2;
2586 1843 var chatBox = getElement(botId, 'chat-box');
2587 1844 var elementTop = element.position().top + chatBox.scrollTop();
2588 - chatBox.animate({ scrollTop: Math.max(0, elementTop - topOffset) }, 500);
1845 + chatBox.animate({ scrollTop: elementTop }, 500);
2589 1846 }
2590 1847
2591 1848 function showChatWidget(botId) {
2592 1849 botId = botId || 'default';
@@ -2803,29 +2060,20 @@
2803 2060 // ====================================
2804 2061 // CHAT HISTORY & PERSISTENCE
2805 2062 // ====================================
2806 2063
2807 -function loadChatHistory(botId, onComplete) {
2064 +function loadChatHistory(botId) {
2808 2065 botId = botId || 'default';
2809 2066 var instance = MxChatInstances.get(botId);
2810 2067
2811 2068 // Prevent duplicate loading
2812 2069 if (instance.chatHistoryLoaded) {
2813 - if (onComplete) onComplete();
2814 2070 return;
2815 2071 }
2816 2072
2817 - // Use getChatSession which returns null if no session exists (does NOT create one)
2818 2073 var sessionId = getChatSession(botId);
2819 2074 var chatPersistenceEnabled = mxchatChat.chat_persistence_toggle === 'on';
2820 2075
2821 - // No session yet — nothing to load. History will load after first message via ensureSession.
2822 - if (!sessionId) {
2823 - instance.chatHistoryLoaded = true;
2824 - if (onComplete) onComplete();
2825 - return;
2826 - }
2827 -
2828 2076 if (chatPersistenceEnabled && sessionId) {
2829 2077 $.ajax({
2830 2078 url: mxchatChat.ajax_url,
2831 2079 type: 'POST',
@@ -2836,12 +2084,11 @@
2836 2084 },
2837 2085 success: function(response) {
2838 2086 // Handle session reset (IP changed while user was away)
2839 2087 if (response.success === false && response.data && response.data.action === 'reset_session') {
2840 - // Silent reset — new session but don't clear UI
2841 - MxChatInstances.silentResetSession(botId);
2088 + // Silently reset session - user will start fresh
2089 + resetChatSession(botId);
2842 2090 instance.chatHistoryLoaded = true; // Prevent retry loop
2843 - if (onComplete) onComplete();
2844 2091 return;
2845 2092 }
2846 2093
2847 2094 // Check if the response indicates success
@@ -2897,19 +2144,9 @@
2897 2144 var content = message.content;
2898 2145 content = content.replace(/\\'/g, "'").replace(/\\"/g, '"');
2899 2146 content = decodeHTMLEntities(content);
2900 2147
2901 - // Skip linkify for messages containing structured HTML
2902 - // (forms, product cards, galleries, etc.) to avoid
2903 - // markdown formatting corrupting HTML attributes
2904 - // (e.g. underscores in name="field_name" becoming <em> tags)
2905 - if (content.includes("mxchat-product-card") ||
2906 - content.includes("mxchat-image-gallery") ||
2907 - content.includes("mxchat-featured-products") ||
2908 - content.includes("<form") ||
2909 - content.includes("<input") ||
2910 - content.includes("<select") ||
2911 - content.includes("<textarea")) {
2148 + if (content.includes("mxchat-product-card") || content.includes("mxchat-image-gallery")) {
2912 2149 messageElement.html(content);
2913 2150 } else {
2914 2151 var formattedContent = linkify(content);
2915 2152 messageElement.html(formattedContent);
@@ -2949,17 +2186,13 @@
2949 2186 instance.chatHistoryLoaded = true;
2950 2187 }
2951 2188 }
2952 2189 }
2953 - if (onComplete) onComplete();
2954 2190 },
2955 2191 error: function(xhr, status, error) {
2956 2192 // Error loading chat history - silently continue
2957 - if (onComplete) onComplete();
2958 2193 }
2959 2194 });
2960 - } else {
2961 - if (onComplete) onComplete();
2962 2195 }
2963 2196 }
2964 2197
2965 2198
@@ -3023,10 +2256,10 @@
3023 2256 .then(data => {
3024 2257 if (data.success) {
3025 2258 container.style.display = 'none';
3026 2259 nameElement.textContent = '';
3027 - instance.activePdfFile = null;
3028 - appendMessage('bot', 'PDF removed.', '', [], false, botId);
2260 + activePdfFile = null;
2261 + appendMessage('bot', 'PDF removed.');
3029 2262 }
3030 2263 })
3031 2264 .catch(error => {
3032 2265 // Error removing PDF - silently continue
@@ -3032,16 +2265,14 @@
3032 2265 // Error removing PDF - silently continue
3033 2266 });
3034 2267 }
3035 2268
3036 - function removeActiveWord(botId) {
3037 - botId = botId || 'default';
3038 - var instance = MxChatInstances.get(botId);
3039 - const container = getElementDOM(botId, 'active-word-container');
3040 - const nameElement = getElementDOM(botId, 'active-word-name');
3041 -
3042 - if (!container || !nameElement || !instance.activeWordFile) return;
3043 -
2269 + function removeActiveWord() {
2270 + const container = document.getElementById('active-word-container');
2271 + const nameElement = document.getElementById('active-word-name');
2272 +
2273 + if (!container || !nameElement || !activeWordFile) return;
2274 +
3044 2275 fetch(mxchatChat.ajax_url, {
3045 2276 method: 'POST',
3046 2277 headers: {
3047 2278 'Content-Type': 'application/x-www-form-urlencoded',
@@ -3047,9 +2278,9 @@
3047 2278 'Content-Type': 'application/x-www-form-urlencoded',
3048 2279 },
3049 2280 body: new URLSearchParams({
3050 2281 'action': 'mxchat_remove_word',
3051 - 'session_id': getChatSession(botId),
2282 + 'session_id': sessionId,
3052 2283 'nonce': mxchatChat.nonce
3053 2284 })
3054 2285 })
3055 2286 .then(response => response.json())
@@ -3056,10 +2287,10 @@
3056 2287 .then(data => {
3057 2288 if (data.success) {
3058 2289 container.style.display = 'none';
3059 2290 nameElement.textContent = '';
3060 - instance.activeWordFile = null;
3061 - appendMessage('bot', 'Word document removed.', '', [], false, botId);
2291 + activeWordFile = null;
2292 + appendMessage('bot', 'Word document removed.');
3062 2293 }
3063 2294 })
3064 2295 .catch(error => {
3065 2296 // Error removing Word document - silently continue
@@ -3137,35 +2368,45 @@
3137 2368 // ====================================
3138 2369
3139 2370 function checkPreChatDismissal(botId) {
3140 2371 botId = botId || 'default';
3141 - try {
3142 - var dismissedAt = localStorage.getItem('mxchat_pre_chat_dismissed_' + botId);
3143 - if (dismissedAt) {
3144 - // Re-show after 24 hours
3145 - var elapsed = Date.now() - parseInt(dismissedAt, 10);
3146 - if (elapsed < 86400000) {
2372 + $.ajax({
2373 + url: mxchatChat.ajax_url,
2374 + type: 'POST',
2375 + data: {
2376 + action: 'mxchat_check_pre_chat_message_status',
2377 + _ajax_nonce: mxchatChat.nonce
2378 + },
2379 + success: function(response) {
2380 + if (response.success && !response.data.dismissed) {
2381 + getElement(botId, 'pre-chat-message').fadeIn(250);
2382 + } else {
3147 2383 getElement(botId, 'pre-chat-message').hide();
3148 - return;
3149 2384 }
3150 - // Expired — clear and show again
3151 - localStorage.removeItem('mxchat_pre_chat_dismissed_' + botId);
2385 + },
2386 + error: function() {
2387 + // Error checking pre-chat dismissal - silently continue
3152 2388 }
3153 - getElement(botId, 'pre-chat-message').fadeIn(250);
3154 - } catch (e) {
3155 - // localStorage unavailable — show the message
3156 - getElement(botId, 'pre-chat-message').fadeIn(250);
3157 - }
2389 + });
3158 2390 }
3159 2391
3160 2392 function handlePreChatDismissal(botId) {
3161 2393 botId = botId || 'default';
3162 2394 getElement(botId, 'pre-chat-message').fadeOut(200);
3163 - try {
3164 - localStorage.setItem('mxchat_pre_chat_dismissed_' + botId, String(Date.now()));
3165 - } catch (e) {
3166 - // localStorage unavailable — dismissal won't persist
3167 - }
2395 + $.ajax({
2396 + url: mxchatChat.ajax_url,
2397 + type: 'POST',
2398 + data: {
2399 + action: 'mxchat_dismiss_pre_chat_message',
2400 + _ajax_nonce: mxchatChat.nonce
2401 + },
2402 + success: function() {
2403 + $('#pre-chat-message').hide();
2404 + },
2405 + error: function() {
2406 + // Error dismissing pre-chat message - silently continue
2407 + }
2408 + });
3168 2409 }
3169 2410
3170 2411
3171 2412 // ====================================
@@ -3220,14 +2461,9 @@
3220 2461 collapseQuickQuestions(botId);
3221 2462 });
3222 2463
3223 2464 // Chatbot visibility toggle handlers - use class selector for multi-instance support
3224 - // Handles click + Enter/Space keypresses for keyboard accessibility (WCAG 2.1 SC 2.1.1).
3225 - $(document).on('click keydown', '.floating-chatbot-button', function(e) {
3226 - if (e.type === 'keydown') {
3227 - if (e.key !== 'Enter' && e.key !== ' ' && e.key !== 'Spacebar') return;
3228 - e.preventDefault();
3229 - }
2465 + $(document).on('click', '.floating-chatbot-button', function() {
3230 2466 var botId = getBotIdFromElement(this);
3231 2467 var $chatbot = getElement(botId, 'floating-chatbot');
3232 2468 var $badge = getElement(botId, 'chat-notification-badge');
3233 2469 var $preChat = getElement(botId, 'pre-chat-message');
@@ -3232,88 +2468,35 @@
3232 2468 var $badge = getElement(botId, 'chat-notification-badge');
3233 2469 var $preChat = getElement(botId, 'pre-chat-message');
3234 2470
3235 2471 if ($chatbot.hasClass('hidden')) {
3236 - $chatbot.removeClass('hidden').addClass('visible')
3237 - .attr('aria-modal', 'true').attr('role', 'dialog');
3238 - $(this).addClass('hidden').attr('aria-expanded', 'true');
2472 + $chatbot.removeClass('hidden').addClass('visible');
2473 + $(this).addClass('hidden');
3239 2474 $badge.hide(); // Hide notification when opening chat
3240 2475 disableScroll();
3241 2476 $preChat.fadeOut(250);
3242 -
3243 - // First open per page load: re-fetch behavior settings in case
3244 - // this page's inline values came from a stale full-page cache
3245 - // (plan-32db95). Idempotent — later opens are a no-op.
3246 - mxchatRefreshDynamicSettings();
3247 -
3248 - // Load chat history for returning visitors (persistence)
3249 - var chatPersistenceEnabled = typeof mxchatChat !== 'undefined' && mxchatChat.chat_persistence_toggle === 'on';
3250 - if (chatPersistenceEnabled) {
3251 - MxChatInstances.ensureSession(botId);
3252 - }
3253 -
3254 - // Deferred email check — only on first widget open
3255 - var emailBlocker = getElementDOM(botId, 'email-blocker');
3256 - var instance = MxChatInstances.get(botId);
3257 - if (emailBlocker && !instance.emailCheckDone) {
3258 - instance.emailCheckDone = true;
3259 - resolveEmailState(botId);
3260 - } else if (!emailBlocker) {
3261 - // No email collection — still route through showChatContainerForBot
3262 - // so the loader is shown while chat history loads
3263 - showChatContainerForBot(botId);
3264 - }
3265 -
3266 - // Move keyboard focus into the message input after the open transition.
3267 - setTimeout(function() {
3268 - var chatInput = getElementDOM(botId, 'chat-input');
3269 - if (chatInput && !chatInput.disabled) {
3270 - try { chatInput.focus({ preventScroll: true }); } catch (err) { chatInput.focus(); }
3271 - }
3272 - }, 300);
3273 2477 } else {
3274 - $chatbot.removeClass('visible').addClass('hidden').removeAttr('aria-modal');
3275 - $(this).removeClass('hidden').attr('aria-expanded', 'false');
2478 + $chatbot.removeClass('visible').addClass('hidden');
2479 + $(this).removeClass('hidden');
3276 2480 enableScroll();
3277 2481 checkPreChatDismissal(botId);
3278 2482 }
3279 2483 });
3280 2484
3281 - // Allow clicking anywhere on the title bar to close the chatbot.
3282 - // Returns keyboard focus to the launcher so keyboard users don't get
3283 - // stranded at <body> (WCAG SC 2.4.3 Focus Order). :focus-visible is
3284 - // heuristic-based so mouse-triggered close won't show a focus ring.
2485 + // Allow clicking anywhere on the title bar to close the chatbot
3285 2486 $(document).on('click', '.chatbot-top-bar', function() {
3286 2487 var botId = getBotIdFromElement(this);
3287 - getElement(botId, 'floating-chatbot').addClass('hidden').removeClass('visible').removeAttr('aria-modal');
3288 - var $launcher = getElement(botId, 'floating-chatbot-button');
3289 - $launcher.removeClass('hidden').attr('aria-expanded', 'false');
2488 + getElement(botId, 'floating-chatbot').addClass('hidden').removeClass('visible');
2489 + getElement(botId, 'floating-chatbot-button').removeClass('hidden');
3290 2490 enableScroll();
3291 - try { $launcher.trigger('focus'); } catch (err) { /* no-op */ }
3292 2491 });
3293 2492
3294 - // Global Escape-key handler — closes any visible chat widget and
3295 - // returns focus to its launcher. Standard modal-dismissal pattern;
3296 - // pairs with aria-modal="true" set on the widget when it opens.
3297 - $(document).on('keydown', function(e) {
3298 - if (e.key !== 'Escape' && e.key !== 'Esc') return;
3299 - var $visible = $('.floating-chatbot.visible');
3300 - if (!$visible.length) return;
3301 - e.preventDefault();
3302 - $visible.each(function() {
3303 - var botId = getBotIdFromElement(this);
3304 - $(this).addClass('hidden').removeClass('visible').removeAttr('aria-modal');
3305 - var $launcher = getElement(botId, 'floating-chatbot-button');
3306 - $launcher.removeClass('hidden').attr('aria-expanded', 'false');
3307 - try { $launcher.trigger('focus'); } catch (err) { /* no-op */ }
3308 - });
3309 - enableScroll();
3310 - });
3311 -
3312 2493 $(document).on('click', '.close-pre-chat-message', function(e) {
3313 2494 e.stopPropagation(); // Prevent triggering the parent .pre-chat-message click
3314 2495 var botId = getBotIdFromElement(this);
3315 - handlePreChatDismissal(botId);
2496 + getElement(botId, 'pre-chat-message').fadeOut(200, function() {
2497 + $(this).remove();
2498 + });
3316 2499 });
3317 2500
3318 2501
3319 2502 // PDF upload button handlers - use class selector
@@ -3329,20 +2512,17 @@
3329 2512 var wordInput = getElementDOM(botId, 'word-upload');
3330 2513 if (wordInput) wordInput.click();
3331 2514 });
3332 2515
3333 - // PDF file input change handler - delegated, bot-aware (was bound to stale un-suffixed id 'pdf-upload')
3334 - $(document).on('change', '.pdf-upload', async function(e) {
3335 - var botId = getBotIdFromElement(this);
3336 - var instance = MxChatInstances.get(botId);
3337 - const file = this.files[0];
3338 - const sessionId = MxChatInstances.ensureSession(botId);
3339 -
2516 + // PDF file input change handler
2517 + addSafeEventListener('pdf-upload', 'change', async function(e) {
2518 + const file = e.target.files[0];
2519 +
3340 2520 if (!file || file.type !== 'application/pdf') {
3341 2521 alert('Please select a valid PDF file.');
3342 2522 return;
3343 2523 }
3344 -
2524 +
3345 2525 if (!sessionId) {
3346 2526 alert('Error: No session ID found');
3347 2527 return;
3348 2528 }
@@ -3350,49 +2530,47 @@
3350 2530 if (!mxchatChat || !mxchatChat.ajax_url || !mxchatChat.nonce) {
3351 2531 alert('Error: Ajax configuration missing');
3352 2532 return;
3353 2533 }
3354 -
2534 +
3355 2535 // Disable buttons and show loading state
3356 - const uploadBtn = getElementDOM(botId, 'pdf-upload-btn');
3357 - const sendBtn = getElementDOM(botId, 'send-button');
3358 - if (!uploadBtn) return;
2536 + const uploadBtn = document.getElementById('pdf-upload-btn');
2537 + const sendBtn = document.getElementById('send-button');
3359 2538 const originalBtnContent = uploadBtn.innerHTML;
3360 -
2539 +
3361 2540 try {
3362 - // Wait for page-cache nonce refresh before reading mxchatChat.nonce. See plan-c5457f.
3363 - await new Promise(function(resolve) { refreshNonceIfNeeded(resolve); });
3364 2541 const formData = new FormData();
3365 2542 formData.append('action', 'mxchat_upload_pdf');
3366 2543 formData.append('pdf_file', file);
3367 2544 formData.append('session_id', sessionId);
3368 2545 formData.append('nonce', mxchatChat.nonce);
3369 -
2546 +
3370 2547 uploadBtn.disabled = true;
3371 - if (sendBtn) sendBtn.disabled = true;
2548 + sendBtn.disabled = true;
3372 2549 uploadBtn.innerHTML = `<svg class="spinner" viewBox="0 0 50 50">
3373 2550 <circle cx="25" cy="25" r="20" fill="none" stroke-width="5"></circle>
3374 2551 </svg>`;
3375 -
2552 +
3376 2553 const response = await fetch(mxchatChat.ajax_url, {
3377 2554 method: 'POST',
3378 2555 body: formData
3379 2556 });
3380 -
2557 +
3381 2558 const data = await response.json();
3382 -
2559 +
3383 2560 if (data.success) {
3384 2561 // Hide popular questions if they exist
3385 - if (hasQuickQuestions(botId)) {
3386 - collapseQuickQuestions(botId);
2562 + const popularQuestionsContainer = document.getElementById('mxchat-popular-questions');
2563 + if (hasQuickQuestions()) {
2564 + collapseQuickQuestions();
3387 2565 }
3388 -
2566 +
3389 2567 // Show the active PDF name
3390 - showActivePdf(data.data.filename, botId);
3391 -
3392 - appendMessage('bot', data.data.message, '', [], false, botId);
3393 - scrollToBottom(botId);
3394 - instance.activePdfFile = data.data.filename;
2568 + showActivePdf(data.data.filename);
2569 +
2570 + appendMessage('bot', data.data.message);
2571 + scrollToBottom();
2572 + activePdfFile = data.data.filename;
3395 2573 } else {
3396 2574 alert('Failed to upload PDF. Please try again.');
3397 2575 }
3398 2576 } catch (error) {
@@ -3398,76 +2576,66 @@
3398 2576 } catch (error) {
3399 2577 alert('Error uploading file. Please try again.');
3400 2578 } finally {
3401 2579 uploadBtn.disabled = false;
3402 - if (sendBtn) sendBtn.disabled = false;
2580 + sendBtn.disabled = false;
3403 2581 uploadBtn.innerHTML = originalBtnContent;
3404 2582 this.value = ''; // Reset file input
3405 2583 }
3406 2584 });
3407 2585
3408 - // Word file input change handler - delegated, bot-aware (was bound to stale un-suffixed id 'word-upload')
3409 - $(document).on('change', '.word-upload', async function(e) {
3410 - var botId = getBotIdFromElement(this);
3411 - var instance = MxChatInstances.get(botId);
3412 - const file = this.files[0];
3413 - const sessionId = MxChatInstances.ensureSession(botId);
3414 -
2586 + // Word file input change handler
2587 + addSafeEventListener('word-upload', 'change', async function(e) {
2588 + const file = e.target.files[0];
2589 +
3415 2590 if (!file || file.type !== 'application/vnd.openxmlformats-officedocument.wordprocessingml.document') {
3416 2591 alert('Please select a valid Word document (.docx).');
3417 2592 return;
3418 2593 }
3419 -
2594 +
3420 2595 if (!sessionId) {
3421 2596 alert('Error: No session ID found');
3422 2597 return;
3423 2598 }
3424 2599
3425 - if (!mxchatChat || !mxchatChat.ajax_url || !mxchatChat.nonce) {
3426 - alert('Error: Ajax configuration missing');
3427 - return;
3428 - }
3429 -
3430 2600 // Disable buttons and show loading state
3431 - const uploadBtn = getElementDOM(botId, 'word-upload-btn');
3432 - const sendBtn = getElementDOM(botId, 'send-button');
3433 - if (!uploadBtn) return;
2601 + const uploadBtn = document.getElementById('word-upload-btn');
2602 + const sendBtn = document.getElementById('send-button');
3434 2603 const originalBtnContent = uploadBtn.innerHTML;
3435 -
2604 +
3436 2605 try {
3437 - // Wait for page-cache nonce refresh before reading mxchatChat.nonce. See plan-c5457f.
3438 - await new Promise(function(resolve) { refreshNonceIfNeeded(resolve); });
3439 2606 const formData = new FormData();
3440 2607 formData.append('action', 'mxchat_upload_word');
3441 2608 formData.append('word_file', file);
3442 2609 formData.append('session_id', sessionId);
3443 2610 formData.append('nonce', mxchatChat.nonce);
3444 -
2611 +
3445 2612 uploadBtn.disabled = true;
3446 - if (sendBtn) sendBtn.disabled = true;
2613 + sendBtn.disabled = true;
3447 2614 uploadBtn.innerHTML = `<svg class="spinner" viewBox="0 0 50 50">
3448 2615 <circle cx="25" cy="25" r="20" fill="none" stroke-width="5"></circle>
3449 2616 </svg>`;
3450 -
2617 +
3451 2618 const response = await fetch(mxchatChat.ajax_url, {
3452 2619 method: 'POST',
3453 2620 body: formData
3454 2621 });
3455 -
2622 +
3456 2623 const data = await response.json();
3457 -
2624 +
3458 2625 if (data.success) {
3459 2626 // Hide popular questions if they exist
3460 - if (hasQuickQuestions(botId)) {
3461 - collapseQuickQuestions(botId);
2627 + const popularQuestionsContainer = document.getElementById('mxchat-popular-questions');
2628 + if (hasQuickQuestions()) {
2629 + collapseQuickQuestions();
3462 2630 }
3463 -
2631 +
3464 2632 // Show the active Word document name
3465 - showActiveWord(data.data.filename, botId);
3466 -
3467 - appendMessage('bot', data.data.message, '', [], false, botId);
3468 - scrollToBottom(botId);
3469 - instance.activeWordFile = data.data.filename;
2633 + showActiveWord(data.data.filename);
2634 +
2635 + appendMessage('bot', data.data.message);
2636 + scrollToBottom();
2637 + activeWordFile = data.data.filename;
3470 2638 } else {
3471 2639 alert('Failed to upload Word document. Please try again.');
3472 2640 }
3473 2641 } catch (error) {
@@ -3473,25 +2641,25 @@
3473 2641 } catch (error) {
3474 2642 alert('Error uploading file. Please try again.');
3475 2643 } finally {
3476 2644 uploadBtn.disabled = false;
3477 - if (sendBtn) sendBtn.disabled = false;
2645 + sendBtn.disabled = false;
3478 2646 uploadBtn.innerHTML = originalBtnContent;
3479 2647 this.value = ''; // Reset file input
3480 2648 }
3481 2649 });
3482 2650
3483 - // Remove button click handlers - delegated, bot-aware (were bound to stale un-suffixed ids)
3484 - $(document).on('click', '.remove-pdf-btn', function(e) {
2651 + // Remove button click handlers
2652 + document.getElementById('remove-pdf-btn')?.addEventListener('click', function(e) {
3485 2653 e.preventDefault();
3486 2654 e.stopPropagation();
3487 - removeActivePdf(getBotIdFromElement(this));
2655 + removeActivePdf();
3488 2656 });
3489 -
3490 - $(document).on('click', '.remove-word-btn', function(e) {
2657 +
2658 + document.getElementById('remove-word-btn')?.addEventListener('click', function(e) {
3491 2659 e.preventDefault();
3492 2660 e.stopPropagation();
3493 - removeActiveWord(getBotIdFromElement(this));
2661 + removeActiveWord();
3494 2662 });
3495 2663
3496 2664 // Window resize handlers
3497 2665 $(window).on('resize orientationchange', function() {
@@ -3529,59 +2697,8 @@
3529 2697 });
3530 2698
3531 2699
3532 2700 // ====================================
3533 -// INIT LOADER & CHAT CONTAINER HELPERS
3534 -// ====================================
3535 -// These must be outside the email collection block so they're always available
3536 -// (used by persistence loading even when email collection is off)
3537 -
3538 -function showInitLoader(botId) {
3539 - var loader = getElementDOM(botId, 'mxchat-init-loader');
3540 - if (loader) loader.style.display = 'flex';
3541 -}
3542 -
3543 -function hideInitLoader(botId) {
3544 - var loader = getElementDOM(botId, 'mxchat-init-loader');
3545 - if (loader) loader.style.display = 'none';
3546 -}
3547 -
3548 -function showEmailFormForBot(botId) {
3549 - hideInitLoader(botId);
3550 - var emailBlocker = getElementDOM(botId, 'email-blocker');
3551 - var chatContainer = getElementDOM(botId, 'chat-container');
3552 - if (emailBlocker) emailBlocker.style.display = 'flex';
3553 - if (chatContainer) chatContainer.style.display = 'none';
3554 -}
3555 -
3556 -function showChatContainerForBot(botId) {
3557 - var emailBlocker = getElementDOM(botId, 'email-blocker');
3558 - var chatContainer = getElementDOM(botId, 'chat-container');
3559 - if (emailBlocker) emailBlocker.style.display = 'none';
3560 -
3561 - var instance = MxChatInstances.get(botId);
3562 - var chatPersistenceEnabled = mxchatChat.chat_persistence_toggle === 'on';
3563 -
3564 - // If persistence is on and history hasn't loaded yet, show loader
3565 - // while history loads to prevent flash of empty chat
3566 - if (chatPersistenceEnabled && !instance.chatHistoryLoaded) {
3567 - if (chatContainer) chatContainer.style.display = 'none';
3568 - showInitLoader(botId);
3569 - loadChatHistory(botId, function() {
3570 - hideInitLoader(botId);
3571 - if (chatContainer) chatContainer.style.display = 'flex';
3572 - scrollToBottom(botId, true);
3573 - });
3574 - } else {
3575 - hideInitLoader(botId);
3576 - if (chatContainer) chatContainer.style.display = 'flex';
3577 - if (typeof loadChatHistory === 'function') {
3578 - loadChatHistory(botId);
3579 - }
3580 - }
3581 -}
3582 -
3583 -// ====================================
3584 2701 // EMAIL COLLECTION SETUP - MULTI-INSTANCE VERSION
3585 2702 // ====================================
3586 2703 // Only run email collection setup if it's enabled
3587 2704 if (mxchatChat && mxchatChat.email_collection_enabled === 'on') {
@@ -3617,8 +2734,28 @@
3617 2734 `;
3618 2735 document.head.appendChild(style);
3619 2736 }
3620 2737
2738 + // Helper functions for email collection (multi-instance aware)
2739 + function showEmailFormForBot(botId) {
2740 + var emailBlocker = getElementDOM(botId, 'email-blocker');
2741 + var chatContainer = getElementDOM(botId, 'chat-container');
2742 + if (emailBlocker) emailBlocker.style.display = 'flex';
2743 + if (chatContainer) chatContainer.style.display = 'none';
2744 + }
2745 +
2746 + function showChatContainerForBot(botId) {
2747 + var emailBlocker = getElementDOM(botId, 'email-blocker');
2748 + var chatContainer = getElementDOM(botId, 'chat-container');
2749 + if (emailBlocker) emailBlocker.style.display = 'none';
2750 + if (chatContainer) chatContainer.style.display = 'flex';
2751 +
2752 + // Load chat history for this bot
2753 + if (typeof loadChatHistory === 'function') {
2754 + loadChatHistory(botId);
2755 + }
2756 + }
2757 +
3621 2758 function isValidEmailAddress(email) {
3622 2759 const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
3623 2760 return emailRegex.test(email.trim()) && email.length <= 254;
3624 2761 }
@@ -3740,31 +2877,11 @@
3740 2877 existingErrors.forEach(error => error.remove());
3741 2878 }
3742 2879 }
3743 2880
3744 - // Resolve email state using server-side data when available, AJAX fallback otherwise
3745 - function resolveEmailState(botId) {
3746 - if (mxchatChat.skip_email_check && mxchatChat.initial_email_state) {
3747 - if (mxchatChat.initial_email_state.show_email_form) {
3748 - showEmailFormForBot(botId);
3749 - } else {
3750 - showChatContainerForBot(botId);
3751 - }
3752 - } else {
3753 - checkSessionAndEmailForBot(botId);
3754 - }
3755 - }
3756 -
3757 2881 function checkSessionAndEmailForBot(botId) {
3758 - const sessionId = MxChatInstances.ensureSession(botId);
2882 + const sessionId = getChatSession(botId);
3759 2883
3760 - // Hide both panels while we check — show loader instead
3761 - var emailBlocker = getElementDOM(botId, 'email-blocker');
3762 - var chatContainer = getElementDOM(botId, 'chat-container');
3763 - if (emailBlocker) emailBlocker.style.display = 'none';
3764 - if (chatContainer) chatContainer.style.display = 'none';
3765 - showInitLoader(botId);
3766 -
3767 2884 fetch(mxchatChat.ajax_url, {
3768 2885 method: 'POST',
3769 2886 headers: {
3770 2887 'Content-Type': 'application/x-www-form-urlencoded',
@@ -3812,9 +2929,9 @@
3812 2929 var emailInput = getElementDOM(botId, 'user-email');
3813 2930 var nameInput = getElementDOM(botId, 'user-name');
3814 2931 var userEmail = emailInput ? emailInput.value.trim() : '';
3815 2932 var userName = nameInput ? nameInput.value.trim() : '';
3816 - var sessionId = MxChatInstances.ensureSession(botId);
2933 + var sessionId = getChatSession(botId);
3817 2934
3818 2935 // Validate email
3819 2936 if (!userEmail) {
3820 2937 showEmailError(botId, 'Please enter your email address.');
@@ -3937,27 +3054,25 @@
3937 3054 }
3938 3055 });
3939 3056
3940 3057 // Initialize email check for all bot instances
3941 - // For floating bots: defer until widget is opened (zero passive AJAX)
3942 - // For embedded bots: check immediately since the form is visible
3943 3058 $('.mxchat-chatbot-wrapper').each(function() {
3944 3059 var botId = $(this).data('bot-id') || 'default';
3945 3060 var emailBlocker = getElementDOM(botId, 'email-blocker');
3946 3061
3062 + // Only check if email blocker exists for this bot
3947 3063 if (emailBlocker) {
3948 - if (isEmbeddedBot(botId)) {
3949 - // Embedded bots are always visible — check now
3950 - resolveEmailState(botId);
3064 + if (mxchatChat.skip_email_check && mxchatChat.initial_email_state) {
3065 + if (mxchatChat.initial_email_state.show_email_form) {
3066 + showEmailFormForBot(botId);
3067 + } else {
3068 + showChatContainerForBot(botId);
3069 + }
3070 + } else {
3071 + setTimeout(function() {
3072 + checkSessionAndEmailForBot(botId);
3073 + }, 100);
3951 3074 }
3952 - // Floating bots: handled in the widget open handler
3953 - } else if (isEmbeddedBot(botId)) {
3954 - // Embedded bot, no email collection — load history with loader
3955 - var chatPersistenceEnabled = typeof mxchatChat !== 'undefined' && mxchatChat.chat_persistence_toggle === 'on';
3956 - if (chatPersistenceEnabled) {
3957 - MxChatInstances.ensureSession(botId);
3958 - showChatContainerForBot(botId);
3959 - }
3960 3075 }
3961 3076 });
3962 3077 }
3963 3078
@@ -3967,32 +3082,39 @@
3967 3082 var $chatbot = getElement(botId, 'floating-chatbot');
3968 3083 if ($chatbot.hasClass('hidden')) {
3969 3084 $chatbot.removeClass('hidden').addClass('visible');
3970 3085 getElement(botId, 'floating-chatbot-button').addClass('hidden');
3971 - handlePreChatDismissal(botId);
3086 + $(this).fadeOut(250); // Hide pre-chat message
3972 3087 disableScroll(); // Disable scroll when chatbot opens
3088 + }
3089 + });
3973 3090
3974 - // Load chat history for returning visitors (persistence)
3975 - var chatPersistenceEnabled = typeof mxchatChat !== 'undefined' && mxchatChat.chat_persistence_toggle === 'on';
3976 - if (chatPersistenceEnabled) {
3977 - MxChatInstances.ensureSession(botId);
3978 - }
3091 + // Dismiss pre-chat message via close button - handled by event delegation above at line ~2376
3092 + // This is a fallback for legacy support
3093 + $(document).on('click', '.close-pre-chat-message', function() {
3094 + var botId = getBotIdFromElement(this);
3095 + var $preChat = getElement(botId, 'pre-chat-message');
3096 + $preChat.fadeOut(200); // Hide the message
3979 3097
3980 - // Deferred email check — only on first widget open
3981 - var emailBlocker = getElementDOM(botId, 'email-blocker');
3982 - var instance = MxChatInstances.get(botId);
3983 - if (emailBlocker && !instance.emailCheckDone) {
3984 - instance.emailCheckDone = true;
3985 - resolveEmailState(botId);
3986 - } else if (!emailBlocker) {
3987 - showChatContainerForBot(botId);
3098 + // Send an AJAX request to set the transient flag for 24 hours
3099 + $.ajax({
3100 + url: mxchatChat.ajax_url,
3101 + type: 'POST',
3102 + data: {
3103 + action: 'mxchat_dismiss_pre_chat_message',
3104 + _ajax_nonce: mxchatChat.nonce
3105 + },
3106 + success: function() {
3107 + // Ensure the message is hidden after dismissal
3108 + $preChat.hide();
3109 + },
3110 + error: function() {
3111 + // Error dismissing pre-chat message - silently continue
3988 3112 }
3989 - }
3113 + });
3990 3114 });
3991 3115
3992 - // Legacy duplicate close handler removed — handled by single event delegation above
3993 3116
3994 -
3995 3117 function hasQuickQuestions(botId) {
3996 3118 botId = botId || 'default';
3997 3119 var questionsContainer = getElementDOM(botId, 'mxchat-popular-questions');
3998 3120 if (!questionsContainer) return false;
@@ -4128,11 +3250,18 @@
4128 3250 });
4129 3251
4130 3252 // Initialize when document is ready
4131 3253 setFullHeight();
3254 + trackOriginatingPage();
4132 3255
4133 - // Note: trackOriginatingPage() and loadChatHistory() are now deferred
4134 - // until the user's first interaction via MxChatInstances.ensureSession()
3256 + // Only load chat history if email collection is disabled
3257 + if (mxchatChat.email_collection_enabled !== 'on') {
3258 + // Load history for all instances
3259 + $('.mxchat-chatbot-wrapper').each(function() {
3260 + var botId = $(this).data('bot-id') || 'default';
3261 + loadChatHistory(botId);
3262 + });
3263 + }
4135 3264
4136 3265 // Initialize chat visibility for all instances
4137 3266 $('.mxchat-chatbot-wrapper').each(function() {
4138 3267 var botId = $(this).data('bot-id') || 'default';
@@ -4185,312 +3314,6 @@
4185 3314 }, 2000);
4186 3315 });
4187 3316 }
4188 3317 }
4189 -});
4190 -
4191 -// ============================================================================
4192 -// SATISFACTION RATING (v3.2.6)
4193 -// ============================================================================
4194 -// Per-session 👍/👎 prompt that appears in the chat-box after 60s of user
4195 -// inactivity following a bot reply. One prompt per session, deduped via
4196 -// localStorage. Runs ONLY when the satisfaction_rating_enabled option is on —
4197 -// the option (default off) is authoritative.
4198 -jQuery(function($) {
4199 - if (typeof mxchatChat === 'undefined') return;
4200 - // wp_localize_script stringifies scalars: a PHP boolean false arrives as
4201 - // '' and true as '1', so this must be an explicit-enable allowlist — the
4202 - // old "disabled when exactly false/'off'" check let '' through and the
4203 - // bubble rendered on sites with the option off/unset (plan-4bba64). PHP
4204 - // now emits 'on'/'off' strings; true/'1'/1 keep cached pre-fix HTML
4205 - // (boolean-true localizations) working.
4206 - // NOTE (plan-32db95): this gate reads the INLINE value at DOM ready and is
4207 - // deliberately NOT re-evaluated after the widget's dynamic-settings refresh
4208 - // merges fresh values over mxchatChat (that merge fires on first widget
4209 - // open, after this module has already decided). Re-evaluating would mean
4210 - // restructuring the whole module to late-bind its listeners — not worth it
4211 - // for a prompt that is at worst stale for one page load on a cached page.
4212 - var sre = mxchatChat.satisfaction_rating_enabled;
4213 - if (sre !== 'on' && sre !== true && sre !== '1' && sre !== 1) return;
4214 -
4215 - // wp_localize_script stringifies ints, so accept both number and numeric string.
4216 - var idleRaw = mxchatChat.satisfaction_rating_idle_seconds;
4217 - var idleSeconds = (typeof idleRaw === 'number') ? idleRaw : parseInt(idleRaw, 10);
4218 - if (!isFinite(idleSeconds)) idleSeconds = 60;
4219 - if (idleSeconds < 5) idleSeconds = 5;
4220 - if (idleSeconds > 600) idleSeconds = 600;
4221 - var IDLE_MS = idleSeconds * 1000;
4222 - var MIN_BOT_REPLIES = 2;
4223 - var ratingState = {};
4224 -
4225 - function getState(botId) {
4226 - if (!ratingState[botId]) {
4227 - ratingState[botId] = { idleTimer: null, botReplies: 0, promptShown: false, dismissed: false };
4228 - }
4229 - return ratingState[botId];
4230 - }
4231 -
4232 - function getSessionId(botId) {
4233 - if (typeof MxChatInstances !== 'undefined' && MxChatInstances.getChatSession) {
4234 - return MxChatInstances.getChatSession(botId);
4235 - }
4236 - return null;
4237 - }
4238 -
4239 - function isAlreadyRated(sessionId) {
4240 - if (!sessionId) return false;
4241 - try { return localStorage.getItem('mxchat_rated:' + sessionId) === '1'; } catch (e) { return false; }
4242 - }
4243 -
4244 - function markRated(sessionId) {
4245 - if (!sessionId) return;
4246 - try { localStorage.setItem('mxchat_rated:' + sessionId, '1'); } catch (e) {}
4247 - }
4248 -
4249 - function esc(s) {
4250 - return String(s == null ? '' : s)
4251 - .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
4252 - .replace(/"/g, '&quot;').replace(/'/g, '&#039;');
4253 - }
4254 -
4255 - // Mirror shouldSkipInlineColors so rating bubbles defer to AI-theme CSS.
4256 - function ratingSkipInlineColors(botId) {
4257 - if (mxchatChat.skip_inline_colors) return true;
4258 - var botAssignments = mxchatChat.bot_theme_assignments || {};
4259 - return botAssignments.hasOwnProperty(botId);
4260 - }
4261 -
4262 - function botBubbleStyleAttr(botId) {
4263 - if (ratingSkipInlineColors(botId)) return '';
4264 - var bg = mxchatChat.bot_message_bg_color;
4265 - var fg = mxchatChat.bot_message_font_color;
4266 - if (!bg && !fg) return '';
4267 - return ' style="background-color: ' + esc(bg || '') + '; color: ' + esc(fg || '') + ';"';
4268 - }
4269 -
4270 - // Reads the rating bubble's actual computed fg+bg (whatever paints it —
4271 - // the inline color pickers OR the mxchat-theme AI customizer's injected CSS)
4272 - // and paints the filled "Send" pill so it fills with the bot font color and
4273 - // labels in the bubble bg. Mirrors mxchatSyncMenuColors(~:1512) for the read.
4274 - // We paint the submit button DIRECTLY (inline longhand) rather than relying
4275 - // on the CSS rule's var()s: Chromium resolves an INHERITED custom property
4276 - // unreliably inside a descendant's `background`, so a bubble-level var would
4277 - // silently fall back to the literal (white-block bug all over again). Inline
4278 - // longhand always wins. Same transparent-guard as the menu so we never paint
4279 - // a see-through value — in that case the CSS literal fallbacks keep it legible.
4280 - function syncRatingBubbleColors(botId) {
4281 - var $chatBox = getChatBoxByBotId(botId);
4282 - if (!$chatBox || !$chatBox.length) return;
4283 - var bubbleEl = $chatBox.find('.mxchat-rating-bot-bubble').last()[0];
4284 - if (!bubbleEl) return;
4285 - var cs = window.getComputedStyle(bubbleEl);
4286 - var fg = cs.color;
4287 - var bg = cs.backgroundColor;
4288 - var hasFg = fg && fg !== 'rgba(0, 0, 0, 0)' && fg !== 'transparent';
4289 - var hasBg = bg && bg !== 'rgba(0, 0, 0, 0)' && bg !== 'transparent';
4290 - // Expose on the bubble too, for any inheriting styles / future use.
4291 - if (hasFg) bubbleEl.style.setProperty('--mxchat-bot-fg', fg);
4292 - if (hasBg) bubbleEl.style.setProperty('--mxchat-bot-bg', bg);
4293 - // Paint the Send pill directly — the part that actually fixes the bug.
4294 - var submitEl = bubbleEl.querySelector('.mxchat-rating-submit');
4295 - if (submitEl) {
4296 - if (hasFg) submitEl.style.backgroundColor = fg; // fill = bot font color
4297 - if (hasBg) submitEl.style.color = bg; // label = bubble background
4298 - }
4299 - }
4300 -
4301 - function copy(key) {
4302 - var c = mxchatChat.satisfaction_rating_copy || {};
4303 - var d = {
4304 - question: 'Was this helpful?',
4305 - helpful: 'Helpful',
4306 - not_helpful: 'Not helpful',
4307 - dismiss: 'Dismiss',
4308 - thanks: 'Thanks! Anything we should improve? (optional)',
4309 - placeholder: 'Tell us what could be better…',
4310 - send: 'Send',
4311 - skip: 'Skip',
4312 - saved: 'Thanks for the feedback.'
4313 - };
4314 - return c[key] || d[key];
4315 - }
4316 -
4317 - function thumbUpSvg() {
4318 - return '<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor" aria-hidden="true"><path d="M7.493 18.75c-.425 0-.82-.236-.975-.632A7.48 7.48 0 0 1 6 15.375c0-1.75.599-3.358 1.602-4.634.151-.192.373-.309.6-.397.473-.183.89-.514 1.212-.924a9.042 9.042 0 0 1 2.861-2.4c.723-.384 1.35-.956 1.653-1.715a4.498 4.498 0 0 0 .322-1.672V2.75A.75.75 0 0 1 15 2a2.25 2.25 0 0 1 2.25 2.25c0 1.152-.26 2.243-.723 3.218-.266.558.107 1.282.725 1.282h3.126c1.026 0 1.945.694 2.054 1.715.045.422.068.85.068 1.285a11.95 11.95 0 0 1-2.649 7.521c-.388.482-.987.729-1.605.729H14.23c-.483 0-.964-.078-1.423-.23l-3.114-1.04a4.501 4.501 0 0 0-1.423-.23h-.777Z"/><path d="M2.331 10.977a11.969 11.969 0 0 0-.831 4.398 12 12 0 0 0 .52 3.507c.26.85 1.084 1.368 1.973 1.368H4.9c.445 0 .72-.498.523-.898a8.963 8.963 0 0 1-.924-3.977c0-1.708.476-3.305 1.302-4.666.245-.403-.028-.959-.5-.959H4.25c-.832 0-1.612.453-1.918 1.227Z"/></svg>';
4319 - }
4320 - function thumbDownSvg() {
4321 - return '<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor" aria-hidden="true"><path d="M15.73 5.25h1.035A7.465 7.465 0 0 1 18 9.375a7.465 7.465 0 0 1-1.235 4.125h-.148c-.806 0-1.534.446-2.031 1.08a9.04 9.04 0 0 1-2.861 2.4c-.723.384-1.35.956-1.653 1.715a4.498 4.498 0 0 0-.322 1.672V21a.75.75 0 0 1-.75.75 2.25 2.25 0 0 1-2.25-2.25c0-1.152.26-2.243.723-3.218.266-.558-.107-1.282-.725-1.282H3.622c-1.026 0-1.945-.694-2.054-1.715A12.137 12.137 0 0 1 1.5 12c0-2.848.992-5.464 2.649-7.521C4.537 3.997 5.136 3.75 5.754 3.75h4.541c.483 0 .964.078 1.423.23l3.114 1.04c.46.152.94.23 1.423.23Z"/><path d="M21.669 13.023c.536-1.362.831-2.845.831-4.398 0-1.22-.182-2.398-.52-3.507-.26-.85-1.084-1.368-1.973-1.368H19.1c-.445 0-.72.498-.523.898.591 1.2.924 2.55.924 3.977a8.958 8.958 0 0 1-1.302 4.666c-.245.403.028.959.5.959h1.053c.832 0 1.612-.453 1.918-1.227Z"/></svg>';
4322 - }
4323 -
4324 - function buildPromptHtml(botId) {
4325 - var styleAttr = botBubbleStyleAttr(botId);
4326 - return ''
4327 - + '<div class="bot-message mxchat-rating-bot-bubble"' + styleAttr + '>'
4328 - + '<div class="mxchat-rating-prompt" data-bot-id="' + esc(botId) + '" role="group" aria-label="' + esc(copy('question')) + '">'
4329 - + '<div class="mxchat-rating-question">' + esc(copy('question')) + '</div>'
4330 - + '<div class="mxchat-rating-actions">'
4331 - + '<span class="mxchat-rating-buttons">'
4332 - + '<button type="button" class="mxchat-rating-btn" data-rating="1" aria-label="' + esc(copy('helpful')) + '">' + thumbUpSvg() + '</button>'
4333 - + '<button type="button" class="mxchat-rating-btn" data-rating="-1" aria-label="' + esc(copy('not_helpful')) + '">' + thumbDownSvg() + '</button>'
4334 - + '</span>'
4335 - + '<button type="button" class="mxchat-rating-dismiss" aria-label="' + esc(copy('dismiss')) + '">×</button>'
4336 - + '</div>'
4337 - + '</div>'
4338 - + '</div>';
4339 - }
4340 -
4341 - function buildFeedbackHtml(botId, rating) {
4342 - var styleAttr = botBubbleStyleAttr(botId);
4343 - return ''
4344 - + '<div class="bot-message mxchat-rating-bot-bubble"' + styleAttr + '>'
4345 - + '<div class="mxchat-rating-feedback" data-bot-id="' + esc(botId) + '" data-rating="' + esc(String(rating)) + '">'
4346 - + '<div class="mxchat-rating-feedback-label">' + esc(copy('thanks')) + '</div>'
4347 - + '<textarea class="mxchat-rating-feedback-input" maxlength="500" placeholder="' + esc(copy('placeholder')) + '" rows="2"></textarea>'
4348 - + '<div class="mxchat-rating-feedback-actions">'
4349 - + '<button type="button" class="mxchat-rating-skip">' + esc(copy('skip')) + '</button>'
4350 - + '<button type="button" class="mxchat-rating-submit">' + esc(copy('send')) + '</button>'
4351 - + '</div>'
4352 - + '</div>'
4353 - + '</div>';
4354 - }
4355 -
4356 - function buildSavedHtml(botId) {
4357 - var styleAttr = botBubbleStyleAttr(botId);
4358 - return ''
4359 - + '<div class="bot-message mxchat-rating-bot-bubble"' + styleAttr + '>'
4360 - + '<div class="mxchat-rating-saved">' + esc(copy('saved')) + '</div>'
4361 - + '</div>';
4362 - }
4363 -
4364 - function getChatBoxByBotId(botId) {
4365 - var $byId = $('#chat-box-' + botId);
4366 - if ($byId.length) return $byId.first();
4367 - return $('.chat-box').first();
4368 - }
4369 -
4370 - function scrollChatBoxToBottom($chatBox) {
4371 - if (!$chatBox || !$chatBox.length) return;
4372 - $chatBox.scrollTop($chatBox[0].scrollHeight);
4373 - }
4374 -
4375 - function showPrompt(botId) {
4376 - var s = getState(botId);
4377 - if (s.promptShown || s.dismissed) return;
4378 - var sessionId = getSessionId(botId);
4379 - if (!sessionId) return;
4380 - if (isAlreadyRated(sessionId)) { s.promptShown = true; return; }
4381 - var $chatBox = getChatBoxByBotId(botId);
4382 - if (!$chatBox.length) return;
4383 - if ($chatBox.find('.mxchat-rating-prompt').length) { s.promptShown = true; return; }
4384 - $chatBox.append(buildPromptHtml(botId));
4385 - syncRatingBubbleColors(botId);
4386 - s.promptShown = true;
4387 - scrollChatBoxToBottom($chatBox);
4388 - }
4389 -
4390 - function submitRating(botId, rating, feedback) {
4391 - var sessionId = getSessionId(botId);
4392 - if (!sessionId) return;
4393 - $.post(mxchatChat.ajax_url, {
4394 - action: 'mxchat_save_rating',
4395 - session_id: sessionId,
4396 - bot_id: botId,
4397 - rating: rating,
4398 - feedback: feedback || ''
4399 - });
4400 - markRated(sessionId);
4401 - }
4402 -
4403 - function onBotReply(botId) {
4404 - var s = getState(botId);
4405 - s.botReplies += 1;
4406 - if (s.promptShown || s.dismissed) return;
4407 - var sessionId = getSessionId(botId);
4408 - if (sessionId && isAlreadyRated(sessionId)) { s.promptShown = true; return; }
4409 - if (s.botReplies < MIN_BOT_REPLIES) return;
4410 - if (s.idleTimer) clearTimeout(s.idleTimer);
4411 - s.idleTimer = setTimeout(function() { showPrompt(botId); }, IDLE_MS);
4412 - }
4413 -
4414 - function onUserMessage(botId) {
4415 - var s = getState(botId);
4416 - if (s.idleTimer) { clearTimeout(s.idleTimer); s.idleTimer = null; }
4417 - }
4418 -
4419 - function botIdFromChatBox(el) {
4420 - var id = el && el.id ? el.id : '';
4421 - return id.indexOf('chat-box-') === 0 ? id.substring('chat-box-'.length) : 'default';
4422 - }
4423 -
4424 - function setupObserver(chatBox) {
4425 - var botId = botIdFromChatBox(chatBox);
4426 - try {
4427 - var observer = new MutationObserver(function(mutations) {
4428 - mutations.forEach(function(m) {
4429 - for (var i = 0; i < m.addedNodes.length; i++) {
4430 - var node = m.addedNodes[i];
4431 - if (!node || node.nodeType !== 1) continue;
4432 - var $n = $(node);
4433 - if ($n.hasClass('mxchat-rating-bot-bubble') || $n.hasClass('mxchat-rating-prompt') || $n.hasClass('mxchat-rating-feedback') || $n.hasClass('mxchat-rating-saved')) continue;
4434 - if ($n.hasClass('bot-message')) onBotReply(botId); // count at insert time — streaming providers append with .temporary-message first, then remove later (childList observer can't see attr changes)
4435 - else if ($n.hasClass('user-message')) onUserMessage(botId);
4436 - }
4437 - });
4438 - });
4439 - observer.observe(chatBox, { childList: true });
4440 - } catch (e) { /* noop */ }
4441 - }
4442 -
4443 - $('.chat-box').each(function() { setupObserver(this); });
4444 -
4445 - $(document).on('click', '.mxchat-rating-btn', function(e) {
4446 - e.preventDefault();
4447 - var $btn = $(this);
4448 - var $prompt = $btn.closest('.mxchat-rating-prompt');
4449 - var $wrap = $btn.closest('.mxchat-rating-bot-bubble');
4450 - var botId = $prompt.data('bot-id') || 'default';
4451 - var rating = parseInt($btn.attr('data-rating'), 10);
4452 - if (rating !== 1 && rating !== -1) return;
4453 - submitRating(botId, rating, '');
4454 - ($wrap.length ? $wrap : $prompt).replaceWith(buildFeedbackHtml(botId, rating));
4455 - syncRatingBubbleColors(botId);
4456 - scrollChatBoxToBottom(getChatBoxByBotId(botId));
4457 - });
4458 -
4459 - $(document).on('click', '.mxchat-rating-dismiss', function(e) {
4460 - e.preventDefault();
4461 - var $prompt = $(this).closest('.mxchat-rating-prompt');
4462 - var $wrap = $(this).closest('.mxchat-rating-bot-bubble');
4463 - var botId = $prompt.data('bot-id') || 'default';
4464 - var s = getState(botId);
4465 - s.dismissed = true;
4466 - markRated(getSessionId(botId));
4467 - ($wrap.length ? $wrap : $prompt).remove();
4468 - });
4469 -
4470 - function closeFeedback($fb) {
4471 - var botId = $fb.data('bot-id') || 'default';
4472 - var $wrap = $fb.closest('.mxchat-rating-bot-bubble');
4473 - ($wrap.length ? $wrap : $fb).replaceWith(buildSavedHtml(botId));
4474 - syncRatingBubbleColors(botId);
4475 - scrollChatBoxToBottom(getChatBoxByBotId(botId));
4476 - }
4477 -
4478 - $(document).on('click', '.mxchat-rating-skip', function(e) {
4479 - e.preventDefault();
4480 - closeFeedback($(this).closest('.mxchat-rating-feedback'));
4481 - });
4482 -
4483 - $(document).on('click', '.mxchat-rating-submit', function(e) {
4484 - e.preventDefault();
4485 - var $fb = $(this).closest('.mxchat-rating-feedback');
4486 - var botId = $fb.data('bot-id') || 'default';
4487 - var rating = parseInt($fb.attr('data-rating'), 10);
4488 - if (rating !== 1 && rating !== -1) { closeFeedback($fb); return; }
4489 - var text = String($fb.find('.mxchat-rating-feedback-input').val() || '').trim();
4490 - if (text !== '') {
4491 - submitRating(botId, rating, text);
4492 - }
4493 - closeFeedback($fb);
4494 - });
4495 3318 });
4496 3319