PluginProbe
MxChat – AI Chatbot & Content Generation for WordPress / 3.2.2
MxChat – AI Chatbot & Content Generation for WordPress v3.2.2
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 +93 -1161 3.2.133.2.2 View file →
@@ -1,163 +1,20 @@
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);
37 - }
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 - });
45 - }
46 -
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') {
3 + // Nonce refresh is deferred until first user interaction (ensureSession)
4 + // to avoid admin-ajax calls on passive page loads.
5 + var nonceRefreshed = false;
6 + function refreshNonceIfNeeded(callback) {
7 + if (nonceRefreshed || typeof mxchatChat === 'undefined' || !mxchatChat.ajax_url) {
55 8 if (callback) callback();
56 9 return;
57 10 }
58 - var now = Date.now();
59 - if (cachedFreshNonce && (now - cachedFreshNonceFetchedAt) < NONCE_TTL_MS) {
60 - mxchatChat.nonce = cachedFreshNonce;
11 + nonceRefreshed = true;
12 + $.post(mxchatChat.ajax_url, { action: 'mxchat_refresh_nonce' }, function(res) {
13 + if (res && res.success && res.data && res.data.nonce) {
14 + mxchatChat.nonce = res.data.nonce;
15 + }
61 16 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 17 });
161 18 }
162 19
163 20 // ====================================
@@ -203,10 +60,10 @@
203 60 return Object.keys(this.instances);
204 61 },
205 62
206 63 // 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.
64 + // Returns existing session ID from cookie or localStorage, or null if none exists.
65 + // Does NOT create a new session — use ensureSession() for that.
209 66 getChatSession: function(botId) {
210 67 var cookieName = 'mxchat_session_id_' + botId;
211 68 var storageKey = 'mxchat_session_id_' + botId;
212 69 var sessionId = getCookie(cookieName);
@@ -215,21 +72,8 @@
215 72 if (!sessionId) {
216 73 try { sessionId = localStorage.getItem(storageKey); } catch (e) {}
217 74 }
218 75
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 76 // Re-sync cookie from localStorage if cookie was lost
233 77 if (sessionId && !getCookie(cookieName)) {
234 78 document.cookie = cookieName + "=" + sessionId + "; path=/; max-age=86400; SameSite=Lax";
235 79 }
@@ -343,20 +187,8 @@
343 187 var id = $floating.attr('id') || '';
344 188 var match = id.match(/floating-chatbot-(.+)/);
345 189 if (match) return match[1];
346 190 }
347 - // Fallback: the pre-chat teaser bubble (#pre-chat-message-{bot_id}) is a SIBLING
348 - // outside .mxchat-chatbot-wrapper / .floating-chatbot, so its children — e.g. the
349 - // .close-pre-chat-message button, which carries only a class and no id — miss both
350 - // branches above. Walk to the nearest ancestor whose id is pre-chat-message-{bot_id}
351 - // and read the suffix. (closest() includes the element itself, so a click directly on
352 - // #pre-chat-message-{bot_id} resolves here too.)
353 - var $preChat = $(element).closest('[id^="pre-chat-message-"]');
354 - if ($preChat.length) {
355 - var preId = $preChat.attr('id') || '';
356 - var preMatch = preId.match(/^pre-chat-message-(.+)$/);
357 - if (preMatch) return preMatch[1];
358 - }
359 191 // Fallback: check if element itself has an ID with bot suffix (e.g., floating-chatbot-button-{bot_id})
360 192 var elementId = $(element).attr('id') || '';
361 193 if (elementId) {
362 194 // Match patterns like: floating-chatbot-button-{bot_id}, pre-chat-message-{bot_id}
@@ -615,102 +447,10 @@
615 447 sendButton.disabled = false;
616 448 sendButton.style.opacity = '1';
617 449 sendButton.style.pointerEvents = 'auto';
618 450 }
619 - // Every completion path re-enables input, so this is the single restore
620 - // point for the streaming Stop affordance (no-op when not in stop mode).
621 - mxchatRestoreSendButton(botId);
622 451 }
623 452
624 -// --- Streaming Stop control -------------------------------------------------
625 -// One live stream handle per bot instance, so Stop on one widget never aborts
626 -// another bot on the same page.
627 -var mxchatActiveStreams = {};
628 -// Original send-button markup, captured once per bot the first time the Stop
629 -// state is shown (never captured while already in stop mode, so a rapid
630 -// stop-then-resend can't save the stop glyph as the "original").
631 -var mxchatSendMarkup = {};
632 -
633 -function mxchatShowStopButton(botId) {
634 - var btn = getElementDOM(botId, 'send-button');
635 - if (!btn) return;
636 - if (!btn.classList.contains('mxchat-stop-mode')) {
637 - mxchatSendMarkup[botId] = {
638 - html: btn.innerHTML,
639 - label: btn.getAttribute('aria-label')
640 - };
641 - }
642 -
643 - // Mirror the send icon's rendered size + color so the stop glyph looks
644 - // native, including custom send images/colors and theme overrides.
645 - var child = btn.querySelector('svg, img');
646 - var size = 25;
647 - var color = '';
648 - if (child) {
649 - var rect = child.getBoundingClientRect();
650 - if (rect.width) {
651 - size = Math.round(Math.min(rect.width, rect.height));
652 - }
653 - var cs = window.getComputedStyle(child);
654 - color = (child.tagName.toLowerCase() === 'svg' ? cs.fill : cs.color) || '';
655 - }
656 - var stopLabel = (typeof mxchatChat !== 'undefined' && mxchatChat.stop_button_label) || 'Stop response';
657 - 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>';
658 - // An add-on's DIRECT send-button handler (e.g. mxchat-vision's rebind) can
659 - // start a stream synchronously while the originating click is still
660 - // bubbling up to our delegated handler. Without this guard, that handler
661 - // reads the just-added stop-mode class as a user Stop press and aborts the
662 - // brand-new stream — the user's message renders but no reply ever fires
663 - // (plan-4bba64 silent message loss). The flag only spans the current event
664 - // dispatch: cleared on the next macrotask, long before a real Stop click.
665 - btn.__mxchatStopJustShown = true;
666 - setTimeout(function () { btn.__mxchatStopJustShown = false; }, 0);
667 - btn.classList.add('mxchat-stop-mode');
668 - btn.setAttribute('aria-label', stopLabel);
669 - btn.setAttribute('title', stopLabel);
670 - // disableChatInput() ran when the turn was sent; the Stop control itself
671 - // must stay clickable while the textarea remains disabled.
672 - btn.disabled = false;
673 - btn.style.opacity = '1';
674 - btn.style.pointerEvents = 'auto';
675 -}
676 -
677 -function mxchatRestoreSendButton(botId) {
678 - var btn = getElementDOM(botId, 'send-button');
679 - var saved = mxchatSendMarkup[botId];
680 - if (!btn || !btn.classList.contains('mxchat-stop-mode') || !saved) return;
681 - btn.innerHTML = saved.html;
682 - btn.classList.remove('mxchat-stop-mode');
683 - btn.removeAttribute('title');
684 - if (saved.label) {
685 - btn.setAttribute('aria-label', saved.label);
686 - }
687 -}
688 -
689 -function mxchatStopStreaming(botId) {
690 - var entry = mxchatActiveStreams[botId];
691 - if (!entry || !entry.controller) return;
692 - entry.aborted = true;
693 - try { entry.controller.abort(); } catch (e) {}
694 -}
695 -
696 -// Returns true when a stream rejection came from an intentional Stop click:
697 -// keep the partial text as the turn's answer — no error UI, no fallback resend.
698 -function mxchatHandleStreamAbort(botId, accumulatedContent, callback) {
699 - var entry = mxchatActiveStreams[botId];
700 - if (!entry || !entry.aborted) return false;
701 - delete mxchatActiveStreams[botId];
702 - if (!accumulatedContent) {
703 - // Stopped before the first chunk: drop the thinking bubble, no orphan message.
704 - getElement(botId, 'chat-box').find('.bot-message.temporary-message').remove();
705 - }
706 - enableChatInput(botId); // also restores the send icon
707 - if (callback) {
708 - callback(accumulatedContent || '');
709 - }
710 - return true;
711 -}
712 -
713 453 // Update your existing sendMessage function
714 454 function sendMessage(botId) {
715 455 botId = botId || 'default';
716 456 MxChatInstances.ensureSession(botId);
@@ -731,9 +471,8 @@
731 471 }
732 472
733 473 appendMessage("user", message, '', [], false, botId);
734 474 $chatInput.val('');
735 - mxchatUpdateCharCounter($chatInput[0]); // reset the char counter after send (plan 7091a2)
736 475 $chatInput.css('height', 'auto');
737 476
738 477 if (hasQuickQuestions(botId)) {
739 478 collapseQuickQuestions(botId);
@@ -851,15 +590,8 @@
851 590
852 591 function callMxChat(message, callback, botId) {
853 592 botId = botId || getMxChatBotId();
854 593
855 - // Streaming fallbacks land here: drop any leftover stream handle and
856 - // return the button to its send state (no-op for plain non-stream turns).
857 - if (mxchatActiveStreams[botId]) {
858 - delete mxchatActiveStreams[botId];
859 - }
860 - mxchatRestoreSendButton(botId);
861 -
862 594 // Store the message in case we need to retry after session reset
863 595 getElement(botId, 'mxchat-chatbot-wrapper').find('.mxchat-input-holder textarea').data('pending-message', message);
864 596
865 597 // Get page context if contextual awareness is enabled
@@ -867,28 +599,13 @@
867 599
868 600 // Get instance for session start timestamp (used when persistence is OFF)
869 601 var instance = MxChatInstances.get(botId);
870 602
871 - // Guarantee a non-null session_id before the AJAX leaves. ensureSession() is idempotent
872 - // and returns the guaranteed-present session id from the in-memory instance even when
873 - // cookie/localStorage writes are silently blocked by the browser.
874 - var sessionId = MxChatInstances.ensureSession(botId);
875 - if (!sessionId || sessionId === 'null' || sessionId === 'undefined') {
876 - // Last-resort generation to ensure we never POST a null marker.
877 - sessionId = generateSessionId();
878 - MxChatInstances.setChatSession(botId, sessionId);
879 - }
880 -
881 - // Wait for the page-cache nonce refresh to complete before firing the
882 - // chat-send AJAX. On cached pages the inline mxchatChat.nonce is stale
883 - // until refreshNonceIfNeeded() returns; constructing ajaxData inside the
884 - // callback guarantees we read the fresh value. See plan-c5457f.
885 - refreshNonceIfNeeded(function() {
886 603 // Prepare AJAX data
887 604 const ajaxData = {
888 605 action: 'mxchat_handle_chat_request',
889 606 message: message,
890 - session_id: sessionId,
607 + session_id: getChatSession(botId),
891 608 nonce: mxchatChat.nonce,
892 609 current_page_url: window.location.href,
893 610 current_page_title: document.title,
894 611 bot_id: botId,
@@ -894,14 +611,14 @@
894 611 bot_id: botId,
895 612 // Pass session start timestamp so AI context matches what user sees
896 613 session_start_timestamp: instance.sessionStartTimestamp || 0
897 614 };
898 -
615 +
899 616 // Add page context if available
900 617 if (pageContext) {
901 618 ajaxData.page_context = JSON.stringify(pageContext);
902 619 }
903 -
620 +
904 621 // CHECK FOR VISION FLAGS AND ADD THEM
905 622 if (window.mxchatVisionProcessed) {
906 623 ajaxData.vision_processed = true;
907 624 ajaxData.original_user_message = window.mxchatOriginalMessage || message;
@@ -910,9 +627,9 @@
910 627 window.mxchatVisionProcessed = false;
911 628 window.mxchatOriginalMessage = null;
912 629 window.mxchatVisionImagesCount = 0;
913 630 }
914 -
631 +
915 632 $.ajax({
916 633 url: mxchatChat.ajax_url,
917 634 type: 'POST',
918 635 dataType: 'json',
@@ -1101,9 +818,8 @@
1101 818
1102 819 replaceLastMessage("bot", errorMessage, '', [], botId);
1103 820 }
1104 821 });
1105 - }); // refreshNonceIfNeeded
1106 822 }
1107 823
1108 824 function callMxChatStream(message, callback, botId) {
1109 825 botId = botId || getMxChatBotId();
@@ -1122,25 +838,12 @@
1122 838
1123 839 // Get instance for session start timestamp (used when persistence is OFF)
1124 840 var instance = MxChatInstances.get(botId);
1125 841
1126 - // Guarantee a non-null session_id before the fetch. FormData.append() stringifies any
1127 - // non-string value via String(), so passing `null` would POST the literal string "null"
1128 - // and land in the transcripts table as a ghost session. ensureSession() always returns
1129 - // a real string even when cookies/localStorage are blocked.
1130 - var streamSessionId = MxChatInstances.ensureSession(botId);
1131 - if (!streamSessionId || streamSessionId === 'null' || streamSessionId === 'undefined') {
1132 - streamSessionId = generateSessionId();
1133 - MxChatInstances.setChatSession(botId, streamSessionId);
1134 - }
1135 -
1136 - // Wait for the page-cache nonce refresh before constructing formData (which
1137 - // captures mxchatChat.nonce by value). Mirrors callMxChat's wrapping. See plan-c5457f.
1138 - refreshNonceIfNeeded(function() {
1139 842 const formData = new FormData();
1140 843 formData.append('action', 'mxchat_stream_chat');
1141 844 formData.append('message', message);
1142 - formData.append('session_id', streamSessionId);
845 + formData.append('session_id', getChatSession(botId));
1143 846 formData.append('nonce', mxchatChat.nonce);
1144 847 formData.append('current_page_url', window.location.href);
1145 848 formData.append('current_page_title', document.title);
1146 849 formData.append('bot_id', botId);
@@ -1166,20 +869,12 @@
1166 869 let accumulatedContent = '';
1167 870 let testingDataReceived = false;
1168 871 let streamingStarted = false;
1169 872
1170 - // Abortable stream: a fresh controller per turn, keyed by bot instance.
1171 - // The Stop control (send button swapped in place) aborts both the read
1172 - // loop and the underlying request.
1173 - var streamControl = { controller: new AbortController(), aborted: false };
1174 - mxchatActiveStreams[botId] = streamControl;
1175 - mxchatShowStopButton(botId);
1176 -
1177 873 fetch(mxchatChat.ajax_url, {
1178 874 method: 'POST',
1179 875 body: formData,
1180 - credentials: 'same-origin',
1181 - signal: streamControl.controller.signal
876 + credentials: 'same-origin'
1182 877 })
1183 878 .then(response => {
1184 879 // Store the response for potential fallback handling
1185 880 const responseClone = response.clone();
@@ -1248,16 +943,8 @@
1248 943
1249 944 // Re-enable chat input when stream ends with content
1250 945 enableChatInput(botId);
1251 946
1252 - // Scroll the user's last message to the top now that the
1253 - // bot's full reply has rendered (gives max reading room).
1254 - var $chatBoxDone = getElement(botId, 'chat-box');
1255 - var $lastUserMsgDone = $chatBoxDone.find('.user-message').last();
1256 - if ($lastUserMsgDone.length) {
1257 - scrollElementToTop($lastUserMsgDone, botId);
1258 - }
1259 -
1260 947 if (callback) {
1261 948 callback(accumulatedContent);
1262 949 }
1263 950 return;
@@ -1280,16 +967,8 @@
1280 967
1281 968 // Re-enable chat input after streaming completes
1282 969 enableChatInput(botId);
1283 970
1284 - // Scroll the user's last message to the top now
1285 - // that the bot's full reply has rendered.
1286 - var $chatBoxStreamDone = getElement(botId, 'chat-box');
1287 - var $lastUserMsgStreamDone = $chatBoxStreamDone.find('.user-message').last();
1288 - if ($lastUserMsgStreamDone.length) {
1289 - scrollElementToTop($lastUserMsgStreamDone, botId);
1290 - }
1291 -
1292 971 if (callback) {
1293 972 callback(accumulatedContent);
1294 973 }
1295 974 return;
@@ -1346,9 +1025,8 @@
1346 1025 }
1347 1026
1348 1027 processStream();
1349 1028 }).catch(streamError => {
1350 - if (mxchatHandleStreamAbort(botId, accumulatedContent, callback)) return;
1351 1029 getElement(botId, 'chat-box').find('.bot-message.temporary-message').remove();
1352 1030 callMxChat(message, callback, botId);
1353 1031 });
1354 1032 }
@@ -1355,9 +1033,8 @@
1355 1033
1356 1034 processStream();
1357 1035 })
1358 1036 .catch(error => {
1359 - if (mxchatHandleStreamAbort(botId, accumulatedContent, callback)) return;
1360 1037 // Check if we have server error data with chat mode
1361 1038 if (error && error.isServerError && error.data) {
1362 1039 // Check for chat mode in error data
1363 1040 if (error.data.chat_mode) {
@@ -1370,9 +1047,8 @@
1370 1047 getElement(botId, 'chat-box').find('.bot-message.temporary-message').remove();
1371 1048 callMxChat(message, callback, botId);
1372 1049 }
1373 1050 });
1374 - }); // refreshNonceIfNeeded
1375 1051 }
1376 1052
1377 1053 // Helper function to handle non-streaming responses
1378 1054 function handleNonStreamResponse(data, callback, botId) {
@@ -1581,17 +1257,8 @@
1581 1257 // Update the event handlers to use the correct function names (using event delegation)
1582 1258 // Use class-based selectors for multi-instance support
1583 1259 $(document).on('click', '.send-button', function() {
1584 1260 var botId = getBotIdFromElement(this);
1585 - // While a response is streaming the button is a Stop control.
1586 - if (this.classList.contains('mxchat-stop-mode')) {
1587 - // Same click that just started this stream (an add-on's direct handler
1588 - // ran before this delegated one) — not a Stop press. See
1589 - // mxchatShowStopButton for the full story (plan-4bba64).
1590 - if (this.__mxchatStopJustShown) return;
1591 - mxchatStopStreaming(botId);
1592 - return;
1593 - }
1594 1261 var modeIndicator = getElementDOM(botId, 'chat-mode-indicator');
1595 1262 if (!(modeIndicator && modeIndicator.textContent === 'Live Agent')) {
1596 1263 disableChatInput(botId);
1597 1264 }
@@ -1610,370 +1277,9 @@
1610 1277 sendMessage(botId);
1611 1278 }
1612 1279 });
1613 1280
1614 -// Chat input character counter + soft limit feedback (plan 7091a2).
1615 -// Language-neutral: numbers + color only, no translatable strings. The counter
1616 -// reveals near the cap and ramps neutral -> amber -> red; an over-limit keystroke
1617 -// or trimmed paste produces a brief border-flash/shake so the maxlength cap (plan
1618 -// a3fae2) is never a silent "input jumps back". Per-bot scoped via .input-container.
1619 -function mxchatUpdateCharCounter(inputEl) {
1620 - if (!inputEl || !inputEl.closest) return;
1621 - var max = parseInt(inputEl.getAttribute('maxlength'), 10);
1622 - var container = inputEl.closest('.input-container');
1623 - if (!container || !max || max <= 0) return;
1624 - var counter = container.querySelector('.mxchat-char-counter');
1625 - if (!counter) return;
1626 - var len = inputEl.value.length;
1627 - var ratio = len / max;
1628 - var nearThreshold = 0.8; // start surfacing the counter at 80% of the cap
1629 - var cur = counter.querySelector('.mxchat-char-counter-current');
1630 - if (cur) cur.textContent = len;
1631 - var warn = ratio >= nearThreshold && len < max;
1632 - var full = len >= max;
1633 - counter.classList.toggle('is-visible', ratio >= nearThreshold);
1634 - counter.classList.toggle('is-warn', warn);
1635 - counter.classList.toggle('is-full', full);
1636 - container.classList.toggle('mxchat-input-near-limit', warn);
1637 - container.classList.toggle('mxchat-input-at-limit', full);
1638 -}
1639 -
1640 -function mxchatBumpInput(inputEl) {
1641 - var container = inputEl && inputEl.closest ? inputEl.closest('.input-container') : null;
1642 - if (!container) return;
1643 - container.classList.remove('mxchat-input-bump');
1644 - void container.offsetWidth; // reflow so a rapid second hit retriggers the animation
1645 - container.classList.add('mxchat-input-bump');
1646 - clearTimeout($(container).data('mxchatBumpTimeout'));
1647 - var t = setTimeout(function() { container.classList.remove('mxchat-input-bump'); }, 220);
1648 - $(container).data('mxchatBumpTimeout', t);
1649 -}
1650 -
1651 -// Live counter update on every input.
1652 -$(document).on('input', '.chat-input', function() {
1653 - mxchatUpdateCharCounter(this);
1654 -});
1655 -
1656 -// Visible "you've hit the edge" feedback when a printable keystroke is about to be
1657 -// rejected at the cap (maxlength silently swallows it otherwise).
1658 -$(document).on('keydown', '.chat-input', function(e) {
1659 - var max = parseInt(this.getAttribute('maxlength'), 10);
1660 - if (!max || max <= 0 || this.value.length < max) return;
1661 - if (e.ctrlKey || e.metaKey || e.altKey) return;
1662 - // A single printable char with no selection to overwrite WILL be rejected.
1663 - if (e.key && e.key.length === 1 && this.selectionStart === this.selectionEnd) {
1664 - mxchatBumpInput(this);
1665 - }
1666 -});
1667 -
1668 -// A paste that gets trimmed to the cap also bumps, so truncation is never silent.
1669 -$(document).on('paste', '.chat-input', function() {
1670 - var el = this;
1671 - var max = parseInt(el.getAttribute('maxlength'), 10);
1672 - if (!max || max <= 0) return;
1673 - setTimeout(function() {
1674 - mxchatUpdateCharCounter(el);
1675 - if (el.value.length >= max) mxchatBumpInput(el);
1676 - }, 0);
1677 -});
1678 -
1679 -// Builds the list of overflow-menu items for a given bot.
1680 -// Adding a future item is one push to this array — do NOT hardcode "only download."
1681 -function mxchatGetHeaderMenuItems(botId) {
1682 - var items = [];
1683 - var settings = (typeof mxchatChat !== 'undefined') ? mxchatChat : {};
1684 -
1685 - // The `print_button_*` keys still gate this item for back-compat with
1686 - // existing user options. The action is now a transcript download, not print.
1687 - if (settings.print_button_enabled === 'on') {
1688 - items.push({
1689 - id: 'download-transcript',
1690 - label: settings.print_button_label || 'Download Transcript',
1691 - 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>',
1692 - action: function() {
1693 - mxchatDownloadTranscript(botId);
1694 - }
1695 - });
1696 - }
1697 -
1698 - // "Start new chat" — surfaces the EXISTING per-conversation reset
1699 - // (MxChatInstances.resetChatSession) so a visitor can start a fresh thread
1700 - // without the site owner disabling chat persistence globally. Default OFF;
1701 - // gated by the reset_chat_enabled option. plan ac2e81.
1702 - if (settings.reset_chat_enabled === 'on') {
1703 - items.push({
1704 - id: 'reset-chat',
1705 - label: settings.reset_chat_label || 'Start new chat',
1706 - 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"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/></svg>',
1707 - action: function() {
1708 - var confirmMsg = settings.reset_chat_confirm || 'Start a new chat? This clears the current conversation.';
1709 - if (window.confirm(confirmMsg)) {
1710 - MxChatInstances.resetChatSession(botId);
1711 - }
1712 - }
1713 - });
1714 - }
1715 -
1716 - return items;
1717 -}
1718 -
1719 -// Builds a clean markdown transcript of the current conversation and triggers
1720 -// a file download. Used by the "Download Transcript" menu item.
1721 -function mxchatDownloadTranscript(botId) {
1722 - var $chatBox = getElement(botId, 'chat-box');
1723 - if (!$chatBox || !$chatBox.length) return;
1724 -
1725 - var settings = (typeof mxchatChat !== 'undefined') ? mxchatChat : {};
1726 - var headerTitle = settings.print_header_title || 'Chat transcript';
1727 - var now = new Date();
1728 - var stamp = now.toLocaleString();
1729 -
1730 - var lines = [];
1731 - lines.push('# ' + headerTitle);
1732 - lines.push('');
1733 - lines.push('Exported: ' + stamp);
1734 - lines.push('');
1735 - lines.push('---');
1736 - lines.push('');
1737 -
1738 - $chatBox.find('.user-message, .bot-message, .agent-message').each(function() {
1739 - var $msg = $(this);
1740 - // Skip thinking placeholders and any in-flight temporary messages.
1741 - if ($msg.find('.thinking-dots').length) return;
1742 - if ($msg.hasClass('temporary-message')) return;
1743 -
1744 - var sender;
1745 - if ($msg.hasClass('user-message')) sender = 'User';
1746 - else if ($msg.hasClass('agent-message')) sender = 'Live Agent';
1747 - else sender = 'AI Agent';
1748 -
1749 - // Strip interactive UI from the cloned message so we get the conversation text.
1750 - var $clone = $msg.clone();
1751 - $clone.find('.copy-button, .message-toolbar, .mxchat-copy, button, script, style').remove();
1752 - var text = $clone.text().replace(/ /g, ' ').replace(/[ \t]+\n/g, '\n').replace(/\n{3,}/g, '\n\n').trim();
1753 - if (!text) return;
1754 -
1755 - lines.push('**' + sender + '**');
1756 - lines.push('');
1757 - lines.push(text);
1758 - lines.push('');
1759 - });
1760 -
1761 - var content = lines.join('\n');
1762 - var iso = now.toISOString().replace(/[:.]/g, '-').slice(0, 19);
1763 - var fname = 'mxchat-transcript-' + iso + '.md';
1764 - var blob = new Blob([content], { type: 'text/markdown;charset=utf-8' });
1765 - var url = URL.createObjectURL(blob);
1766 - var a = document.createElement('a');
1767 - a.href = url;
1768 - a.download = fname;
1769 - a.style.display = 'none';
1770 - document.body.appendChild(a);
1771 - a.click();
1772 - setTimeout(function() {
1773 - if (a.parentNode) a.parentNode.removeChild(a);
1774 - URL.revokeObjectURL(url);
1775 - }, 100);
1776 -}
1777 -
1778 -// Reads the bot bubble's actual computed bg+fg and writes them as CSS vars
1779 -// on the menu wrap, so the dropdown matches whatever paints the bubble —
1780 -// saved options, AI theme CSS, or the mxchat-theme add-on.
1781 -function mxchatSyncMenuColors(botId, $wrap) {
1782 - if (!$wrap || !$wrap.length) return;
1783 - var $bot = $wrap.closest('.mxchat-chatbot-wrapper').find('.bot-message').not('.temporary-message').first();
1784 - if (!$bot.length) return;
1785 - var cs = window.getComputedStyle($bot[0]);
1786 - if (cs.backgroundColor && cs.backgroundColor !== 'rgba(0, 0, 0, 0)' && cs.backgroundColor !== 'transparent') {
1787 - $wrap[0].style.setProperty('--mxchat-menu-bg', cs.backgroundColor);
1788 - }
1789 - // Bot text color usually lives on a child div, not .bot-message itself.
1790 - var $textChild = $bot.find('[style*="color"]').first();
1791 - var fg = ($textChild.length ? window.getComputedStyle($textChild[0]).color : cs.color);
1792 - if (fg) $wrap[0].style.setProperty('--mxchat-menu-fg', fg);
1793 -}
1794 -
1795 -// Renders (or re-renders) the item list for one menu wrap. Split out of
1796 -// mxchatInitHeaderMenu so the dynamic-settings merge (plan-32db95) can
1797 -// rebuild items + trigger visibility WITHOUT re-binding the one-time
1798 -// open/close/keyboard wiring. closeMenu is passed in by the init closure;
1799 -// a rebuild before init (never happens, but harmless) just skips it.
1800 -function mxchatRenderHeaderMenuItems(botId, $wrap, closeMenuFn) {
1801 - var $trigger = $wrap.find('.mxchat-menu-trigger');
1802 - var $menu = $wrap.find('.mxchat-header-menu');
1803 - var items = mxchatGetHeaderMenuItems(botId);
1804 -
1805 - $menu.empty();
1806 -
1807 - if (!items.length) {
1808 - $trigger.hide();
1809 - $menu.hide();
1810 - return;
1811 - }
1812 -
1813 - // Clear any inline display:none a previous zero-item render left behind —
1814 - // open/close visibility is governed by the hidden prop + is-open class.
1815 - $trigger.css('display', '');
1816 - $menu.css('display', '');
1817 -
1818 - items.forEach(function(item, idx) {
1819 - var $btn = $('<button>', {
1820 - type: 'button',
1821 - 'class': 'mxchat-menu-item',
1822 - 'role': 'menuitem',
1823 - 'tabindex': '-1',
1824 - 'data-menu-id': item.id,
1825 - html: '<span class="mxchat-menu-item-icon">' + item.icon + '</span>' +
1826 - '<span class="mxchat-menu-item-label"></span>'
1827 - });
1828 - $btn.find('.mxchat-menu-item-label').text(item.label);
1829 - $btn.on('click', function(e) {
1830 - e.preventDefault();
1831 - e.stopPropagation();
1832 - if (closeMenuFn) closeMenuFn();
1833 - try { item.action(); } catch (err) { /* no-op */ }
1834 - });
1835 - $menu.append($btn);
1836 - });
1837 -}
1838 -
1839 -// Re-render every menu on the page after a dynamic-settings merge
1840 -// (multi-bot: each wrap re-reads its items). An OPEN menu is left alone —
1841 -// swapping items under the user mid-interaction yanks focus — and the
1842 -// rebuild runs when it closes instead (closeMenu checks the pending flag).
1843 -function mxchatRebuildHeaderMenus() {
1844 - $('.mxchat-header-menu-wrap').each(function() {
1845 - var $wrap = $(this);
1846 - var botId = $wrap.data('bot-id');
1847 - if (!botId) return;
1848 - if (!$wrap.data('mxchatMenuReady')) {
1849 - mxchatInitHeaderMenu(botId);
1850 - return;
1851 - }
1852 - if ($wrap.find('.mxchat-header-menu').hasClass('is-open')) {
1853 - $wrap.data('mxchatMenuRebuildPending', true);
1854 - return;
1855 - }
1856 - mxchatRenderHeaderMenuItems(botId, $wrap, $wrap.data('mxchatMenuClose'));
1857 - });
1858 -}
1859 -
1860 -// One-time per-widget init: renders menu items, wires open/close,
1861 -// outside-click, Escape, and arrow-key navigation. If no items, hides the
1862 -// trigger. Wiring happens even when there are zero items at init, so a
1863 -// later dynamic-settings rebuild that adds items has a working trigger.
1864 -function mxchatInitHeaderMenu(botId) {
1865 - var $wrap = $('.mxchat-header-menu-wrap[data-bot-id="' + botId + '"]').first();
1866 - if (!$wrap.length || $wrap.data('mxchatMenuReady')) return;
1867 -
1868 - var $trigger = $wrap.find('.mxchat-menu-trigger');
1869 - var $menu = $wrap.find('.mxchat-header-menu');
1870 -
1871 - // Initial color sync — covers normal page load.
1872 - mxchatSyncMenuColors(botId, $wrap);
1873 -
1874 - function openMenu() {
1875 - // Re-sync each open in case the active theme changed since init.
1876 - mxchatSyncMenuColors(botId, $wrap);
1877 - $menu.prop('hidden', false).attr('aria-hidden', 'false').addClass('is-open');
1878 - $trigger.attr('aria-expanded', 'true');
1879 - // Focus the first item for keyboard users
1880 - setTimeout(function() {
1881 - $menu.find('.mxchat-menu-item').first().attr('tabindex', '0').trigger('focus');
1882 - }, 0);
1883 - }
1884 - function closeMenu(returnFocus) {
1885 - $menu.prop('hidden', true).attr('aria-hidden', 'true').removeClass('is-open');
1886 - $trigger.attr('aria-expanded', 'false');
1887 - $menu.find('.mxchat-menu-item').attr('tabindex', '-1');
1888 - if (returnFocus) $trigger.trigger('focus');
1889 - // A dynamic-settings rebuild that arrived while the menu was open
1890 - // was deferred (mxchatRebuildHeaderMenus) — run it now.
1891 - if ($wrap.data('mxchatMenuRebuildPending')) {
1892 - $wrap.removeData('mxchatMenuRebuildPending');
1893 - mxchatRenderHeaderMenuItems(botId, $wrap, closeMenu);
1894 - }
1895 - }
1896 -
1897 - // Toggle on trigger click — stop propagation so the .chatbot-top-bar
1898 - // click-to-collapse handler does not fire.
1899 - $trigger.on('click', function(e) {
1900 - e.preventDefault();
1901 - e.stopPropagation();
1902 - if ($menu.hasClass('is-open')) closeMenu();
1903 - else openMenu();
1904 - });
1905 -
1906 - // Don't let clicks inside the menu bubble to the top-bar collapse handler.
1907 - $menu.on('click', function(e) {
1908 - e.stopPropagation();
1909 - });
1910 -
1911 - // Outside click closes the menu.
1912 - $(document).on('click.mxchatMenu-' + botId, function(e) {
1913 - if (!$menu.hasClass('is-open')) return;
1914 - if ($wrap.has(e.target).length || $wrap.is(e.target)) return;
1915 - closeMenu();
1916 - });
1917 -
1918 - // Keyboard: Escape closes and returns focus; arrow keys move focus; Enter activates.
1919 - $menu.on('keydown', '.mxchat-menu-item', function(e) {
1920 - var $items = $menu.find('.mxchat-menu-item');
1921 - var idx = $items.index(this);
1922 - if (e.key === 'Escape') {
1923 - e.preventDefault();
1924 - closeMenu(true);
1925 - } else if (e.key === 'ArrowDown') {
1926 - e.preventDefault();
1927 - var $next = $items.eq((idx + 1) % $items.length);
1928 - $items.attr('tabindex', '-1');
1929 - $next.attr('tabindex', '0').trigger('focus');
1930 - } else if (e.key === 'ArrowUp') {
1931 - e.preventDefault();
1932 - var $prev = $items.eq((idx - 1 + $items.length) % $items.length);
1933 - $items.attr('tabindex', '-1');
1934 - $prev.attr('tabindex', '0').trigger('focus');
1935 - } else if (e.key === 'Enter' || e.key === ' ') {
1936 - e.preventDefault();
1937 - $(this).trigger('click');
1938 - }
1939 - });
1940 - $trigger.on('keydown', function(e) {
1941 - if (e.key === 'Escape' && $menu.hasClass('is-open')) {
1942 - e.preventDefault();
1943 - closeMenu(true);
1944 - } else if ((e.key === 'ArrowDown' || e.key === 'Enter' || e.key === ' ') && !$menu.hasClass('is-open')) {
1945 - e.preventDefault();
1946 - openMenu();
1947 - }
1948 - });
1949 -
1950 - // Expose closeMenu for out-of-closure re-renders (mxchatRebuildHeaderMenus),
1951 - // then do the initial item render.
1952 - $wrap.data('mxchatMenuClose', closeMenu);
1953 - mxchatRenderHeaderMenuItems(botId, $wrap, closeMenu);
1954 -
1955 - $wrap.data('mxchatMenuReady', true);
1956 -}
1957 -
1958 -// Initialize header menus for every rendered widget on DOM ready.
1959 -$(function() {
1960 - $('.mxchat-header-menu-wrap').each(function() {
1961 - var botId = $(this).data('bot-id');
1962 - if (botId) mxchatInitHeaderMenu(botId);
1963 - });
1964 -
1965 - // Embedded (non-floating) widgets are open from the moment the page
1966 - // renders — refresh dynamic settings at init (plan-32db95). Floating
1967 - // widgets refresh on first launcher open instead.
1968 - var hasEmbeddedWidget = $('.mxchat-chatbot-wrapper').filter(function() {
1969 - return !$(this).closest('.floating-chatbot').length;
1970 - }).length > 0;
1971 - if (hasEmbeddedWidget) {
1972 - mxchatRefreshDynamicSettings();
1973 - }
1974 -});
1975 -
1281 +
1976 1282 function appendMessage(sender, messageText = '', messageHtml = '', images = [], isTemporary = false, botId = 'default') {
1977 1283 try {
1978 1284 // Determine styles based on sender type
1979 1285 let messageClass, bgColor, fontColor;
@@ -2065,12 +1371,8 @@
2065 1371 if (lastUserMessage.length) {
2066 1372 scrollElementToTop(lastUserMessage, botId);
2067 1373 }
2068 1374 }
2069 -
2070 - if ((sender === "bot" || sender === "agent") && !isTemporary) {
2071 - if (typeof mxchatInitHeaderMenu === 'function') mxchatInitHeaderMenu(botId);
2072 - }
2073 1375 });
2074 1376
2075 1377 if (messageText.id) {
2076 1378 var instance = MxChatInstances.get(botId);
@@ -2217,12 +1519,8 @@
2217 1519 }
2218 1520
2219 1521 // Re-enable chat input after response is displayed
2220 1522 enableChatInput(botId);
2221 -
2222 - if (sender === "bot" || sender === "agent") {
2223 - if (typeof mxchatInitHeaderMenu === 'function') mxchatInitHeaderMenu(botId);
2224 - }
2225 1523 } else {
2226 1524 appendMessage(sender, responseText, responseHtml, images, false, botId);
2227 1525 // Re-enable chat input after response is displayed
2228 1526 enableChatInput(botId);
@@ -2675,14 +1973,13 @@
2675 1973 requestAnimationFrame(smoothScroll);
2676 1974 }
2677 1975 }
2678 1976
2679 - function scrollElementToTop(element, botId, topOffset) {
1977 + function scrollElementToTop(element, botId) {
2680 1978 botId = botId || 'default';
2681 - topOffset = (typeof topOffset === 'number') ? topOffset : 2;
2682 1979 var chatBox = getElement(botId, 'chat-box');
2683 1980 var elementTop = element.position().top + chatBox.scrollTop();
2684 - chatBox.animate({ scrollTop: Math.max(0, elementTop - topOffset) }, 500);
1981 + chatBox.animate({ scrollTop: elementTop }, 500);
2685 1982 }
2686 1983
2687 1984 function showChatWidget(botId) {
2688 1985 botId = botId || 'default';
@@ -3119,10 +2416,10 @@
3119 2416 .then(data => {
3120 2417 if (data.success) {
3121 2418 container.style.display = 'none';
3122 2419 nameElement.textContent = '';
3123 - instance.activePdfFile = null;
3124 - appendMessage('bot', 'PDF removed.', '', [], false, botId);
2420 + activePdfFile = null;
2421 + appendMessage('bot', 'PDF removed.');
3125 2422 }
3126 2423 })
3127 2424 .catch(error => {
3128 2425 // Error removing PDF - silently continue
@@ -3128,16 +2425,14 @@
3128 2425 // Error removing PDF - silently continue
3129 2426 });
3130 2427 }
3131 2428
3132 - function removeActiveWord(botId) {
3133 - botId = botId || 'default';
3134 - var instance = MxChatInstances.get(botId);
3135 - const container = getElementDOM(botId, 'active-word-container');
3136 - const nameElement = getElementDOM(botId, 'active-word-name');
3137 -
3138 - if (!container || !nameElement || !instance.activeWordFile) return;
3139 -
2429 + function removeActiveWord() {
2430 + const container = document.getElementById('active-word-container');
2431 + const nameElement = document.getElementById('active-word-name');
2432 +
2433 + if (!container || !nameElement || !activeWordFile) return;
2434 +
3140 2435 fetch(mxchatChat.ajax_url, {
3141 2436 method: 'POST',
3142 2437 headers: {
3143 2438 'Content-Type': 'application/x-www-form-urlencoded',
@@ -3143,9 +2438,9 @@
3143 2438 'Content-Type': 'application/x-www-form-urlencoded',
3144 2439 },
3145 2440 body: new URLSearchParams({
3146 2441 'action': 'mxchat_remove_word',
3147 - 'session_id': getChatSession(botId),
2442 + 'session_id': sessionId,
3148 2443 'nonce': mxchatChat.nonce
3149 2444 })
3150 2445 })
3151 2446 .then(response => response.json())
@@ -3152,10 +2447,10 @@
3152 2447 .then(data => {
3153 2448 if (data.success) {
3154 2449 container.style.display = 'none';
3155 2450 nameElement.textContent = '';
3156 - instance.activeWordFile = null;
3157 - appendMessage('bot', 'Word document removed.', '', [], false, botId);
2451 + activeWordFile = null;
2452 + appendMessage('bot', 'Word document removed.');
3158 2453 }
3159 2454 })
3160 2455 .catch(error => {
3161 2456 // Error removing Word document - silently continue
@@ -3316,14 +2611,9 @@
3316 2611 collapseQuickQuestions(botId);
3317 2612 });
3318 2613
3319 2614 // Chatbot visibility toggle handlers - use class selector for multi-instance support
3320 - // Handles click + Enter/Space keypresses for keyboard accessibility (WCAG 2.1 SC 2.1.1).
3321 - $(document).on('click keydown', '.floating-chatbot-button', function(e) {
3322 - if (e.type === 'keydown') {
3323 - if (e.key !== 'Enter' && e.key !== ' ' && e.key !== 'Spacebar') return;
3324 - e.preventDefault();
3325 - }
2615 + $(document).on('click', '.floating-chatbot-button', function() {
3326 2616 var botId = getBotIdFromElement(this);
3327 2617 var $chatbot = getElement(botId, 'floating-chatbot');
3328 2618 var $badge = getElement(botId, 'chat-notification-badge');
3329 2619 var $preChat = getElement(botId, 'pre-chat-message');
@@ -3328,20 +2618,14 @@
3328 2618 var $badge = getElement(botId, 'chat-notification-badge');
3329 2619 var $preChat = getElement(botId, 'pre-chat-message');
3330 2620
3331 2621 if ($chatbot.hasClass('hidden')) {
3332 - $chatbot.removeClass('hidden').addClass('visible')
3333 - .attr('aria-modal', 'true').attr('role', 'dialog');
3334 - $(this).addClass('hidden').attr('aria-expanded', 'true');
2622 + $chatbot.removeClass('hidden').addClass('visible');
2623 + $(this).addClass('hidden');
3335 2624 $badge.hide(); // Hide notification when opening chat
3336 2625 disableScroll();
3337 2626 $preChat.fadeOut(250);
3338 2627
3339 - // First open per page load: re-fetch behavior settings in case
3340 - // this page's inline values came from a stale full-page cache
3341 - // (plan-32db95). Idempotent — later opens are a no-op.
3342 - mxchatRefreshDynamicSettings();
3343 -
3344 2628 // Load chat history for returning visitors (persistence)
3345 2629 var chatPersistenceEnabled = typeof mxchatChat !== 'undefined' && mxchatChat.chat_persistence_toggle === 'on';
3346 2630 if (chatPersistenceEnabled) {
3347 2631 MxChatInstances.ensureSession(botId);
@@ -3357,55 +2641,24 @@
3357 2641 // No email collection — still route through showChatContainerForBot
3358 2642 // so the loader is shown while chat history loads
3359 2643 showChatContainerForBot(botId);
3360 2644 }
3361 -
3362 - // Move keyboard focus into the message input after the open transition.
3363 - setTimeout(function() {
3364 - var chatInput = getElementDOM(botId, 'chat-input');
3365 - if (chatInput && !chatInput.disabled) {
3366 - try { chatInput.focus({ preventScroll: true }); } catch (err) { chatInput.focus(); }
3367 - }
3368 - }, 300);
3369 2645 } else {
3370 - $chatbot.removeClass('visible').addClass('hidden').removeAttr('aria-modal');
3371 - $(this).removeClass('hidden').attr('aria-expanded', 'false');
2646 + $chatbot.removeClass('visible').addClass('hidden');
2647 + $(this).removeClass('hidden');
3372 2648 enableScroll();
3373 2649 checkPreChatDismissal(botId);
3374 2650 }
3375 2651 });
3376 2652
3377 - // Allow clicking anywhere on the title bar to close the chatbot.
3378 - // Returns keyboard focus to the launcher so keyboard users don't get
3379 - // stranded at <body> (WCAG SC 2.4.3 Focus Order). :focus-visible is
3380 - // heuristic-based so mouse-triggered close won't show a focus ring.
2653 + // Allow clicking anywhere on the title bar to close the chatbot
3381 2654 $(document).on('click', '.chatbot-top-bar', function() {
3382 2655 var botId = getBotIdFromElement(this);
3383 - getElement(botId, 'floating-chatbot').addClass('hidden').removeClass('visible').removeAttr('aria-modal');
3384 - var $launcher = getElement(botId, 'floating-chatbot-button');
3385 - $launcher.removeClass('hidden').attr('aria-expanded', 'false');
2656 + getElement(botId, 'floating-chatbot').addClass('hidden').removeClass('visible');
2657 + getElement(botId, 'floating-chatbot-button').removeClass('hidden');
3386 2658 enableScroll();
3387 - try { $launcher.trigger('focus'); } catch (err) { /* no-op */ }
3388 2659 });
3389 2660
3390 - // Global Escape-key handler — closes any visible chat widget and
3391 - // returns focus to its launcher. Standard modal-dismissal pattern;
3392 - // pairs with aria-modal="true" set on the widget when it opens.
3393 - $(document).on('keydown', function(e) {
3394 - if (e.key !== 'Escape' && e.key !== 'Esc') return;
3395 - var $visible = $('.floating-chatbot.visible');
3396 - if (!$visible.length) return;
3397 - e.preventDefault();
3398 - $visible.each(function() {
3399 - var botId = getBotIdFromElement(this);
3400 - $(this).addClass('hidden').removeClass('visible').removeAttr('aria-modal');
3401 - var $launcher = getElement(botId, 'floating-chatbot-button');
3402 - $launcher.removeClass('hidden').attr('aria-expanded', 'false');
3403 - try { $launcher.trigger('focus'); } catch (err) { /* no-op */ }
3404 - });
3405 - enableScroll();
3406 - });
3407 -
3408 2661 $(document).on('click', '.close-pre-chat-message', function(e) {
3409 2662 e.stopPropagation(); // Prevent triggering the parent .pre-chat-message click
3410 2663 var botId = getBotIdFromElement(this);
3411 2664 handlePreChatDismissal(botId);
@@ -3425,20 +2678,17 @@
3425 2678 var wordInput = getElementDOM(botId, 'word-upload');
3426 2679 if (wordInput) wordInput.click();
3427 2680 });
3428 2681
3429 - // PDF file input change handler - delegated, bot-aware (was bound to stale un-suffixed id 'pdf-upload')
3430 - $(document).on('change', '.pdf-upload', async function(e) {
3431 - var botId = getBotIdFromElement(this);
3432 - var instance = MxChatInstances.get(botId);
3433 - const file = this.files[0];
3434 - const sessionId = MxChatInstances.ensureSession(botId);
3435 -
2682 + // PDF file input change handler
2683 + addSafeEventListener('pdf-upload', 'change', async function(e) {
2684 + const file = e.target.files[0];
2685 +
3436 2686 if (!file || file.type !== 'application/pdf') {
3437 2687 alert('Please select a valid PDF file.');
3438 2688 return;
3439 2689 }
3440 -
2690 +
3441 2691 if (!sessionId) {
3442 2692 alert('Error: No session ID found');
3443 2693 return;
3444 2694 }
@@ -3446,49 +2696,47 @@
3446 2696 if (!mxchatChat || !mxchatChat.ajax_url || !mxchatChat.nonce) {
3447 2697 alert('Error: Ajax configuration missing');
3448 2698 return;
3449 2699 }
3450 -
2700 +
3451 2701 // Disable buttons and show loading state
3452 - const uploadBtn = getElementDOM(botId, 'pdf-upload-btn');
3453 - const sendBtn = getElementDOM(botId, 'send-button');
3454 - if (!uploadBtn) return;
2702 + const uploadBtn = document.getElementById('pdf-upload-btn');
2703 + const sendBtn = document.getElementById('send-button');
3455 2704 const originalBtnContent = uploadBtn.innerHTML;
3456 -
2705 +
3457 2706 try {
3458 - // Wait for page-cache nonce refresh before reading mxchatChat.nonce. See plan-c5457f.
3459 - await new Promise(function(resolve) { refreshNonceIfNeeded(resolve); });
3460 2707 const formData = new FormData();
3461 2708 formData.append('action', 'mxchat_upload_pdf');
3462 2709 formData.append('pdf_file', file);
3463 2710 formData.append('session_id', sessionId);
3464 2711 formData.append('nonce', mxchatChat.nonce);
3465 -
2712 +
3466 2713 uploadBtn.disabled = true;
3467 - if (sendBtn) sendBtn.disabled = true;
2714 + sendBtn.disabled = true;
3468 2715 uploadBtn.innerHTML = `<svg class="spinner" viewBox="0 0 50 50">
3469 2716 <circle cx="25" cy="25" r="20" fill="none" stroke-width="5"></circle>
3470 2717 </svg>`;
3471 -
2718 +
3472 2719 const response = await fetch(mxchatChat.ajax_url, {
3473 2720 method: 'POST',
3474 2721 body: formData
3475 2722 });
3476 -
2723 +
3477 2724 const data = await response.json();
3478 -
2725 +
3479 2726 if (data.success) {
3480 2727 // Hide popular questions if they exist
3481 - if (hasQuickQuestions(botId)) {
3482 - collapseQuickQuestions(botId);
2728 + const popularQuestionsContainer = document.getElementById('mxchat-popular-questions');
2729 + if (hasQuickQuestions()) {
2730 + collapseQuickQuestions();
3483 2731 }
3484 -
2732 +
3485 2733 // Show the active PDF name
3486 - showActivePdf(data.data.filename, botId);
3487 -
3488 - appendMessage('bot', data.data.message, '', [], false, botId);
3489 - scrollToBottom(botId);
3490 - instance.activePdfFile = data.data.filename;
2734 + showActivePdf(data.data.filename);
2735 +
2736 + appendMessage('bot', data.data.message);
2737 + scrollToBottom();
2738 + activePdfFile = data.data.filename;
3491 2739 } else {
3492 2740 alert('Failed to upload PDF. Please try again.');
3493 2741 }
3494 2742 } catch (error) {
@@ -3494,76 +2742,66 @@
3494 2742 } catch (error) {
3495 2743 alert('Error uploading file. Please try again.');
3496 2744 } finally {
3497 2745 uploadBtn.disabled = false;
3498 - if (sendBtn) sendBtn.disabled = false;
2746 + sendBtn.disabled = false;
3499 2747 uploadBtn.innerHTML = originalBtnContent;
3500 2748 this.value = ''; // Reset file input
3501 2749 }
3502 2750 });
3503 2751
3504 - // Word file input change handler - delegated, bot-aware (was bound to stale un-suffixed id 'word-upload')
3505 - $(document).on('change', '.word-upload', async function(e) {
3506 - var botId = getBotIdFromElement(this);
3507 - var instance = MxChatInstances.get(botId);
3508 - const file = this.files[0];
3509 - const sessionId = MxChatInstances.ensureSession(botId);
3510 -
2752 + // Word file input change handler
2753 + addSafeEventListener('word-upload', 'change', async function(e) {
2754 + const file = e.target.files[0];
2755 +
3511 2756 if (!file || file.type !== 'application/vnd.openxmlformats-officedocument.wordprocessingml.document') {
3512 2757 alert('Please select a valid Word document (.docx).');
3513 2758 return;
3514 2759 }
3515 -
2760 +
3516 2761 if (!sessionId) {
3517 2762 alert('Error: No session ID found');
3518 2763 return;
3519 2764 }
3520 2765
3521 - if (!mxchatChat || !mxchatChat.ajax_url || !mxchatChat.nonce) {
3522 - alert('Error: Ajax configuration missing');
3523 - return;
3524 - }
3525 -
3526 2766 // Disable buttons and show loading state
3527 - const uploadBtn = getElementDOM(botId, 'word-upload-btn');
3528 - const sendBtn = getElementDOM(botId, 'send-button');
3529 - if (!uploadBtn) return;
2767 + const uploadBtn = document.getElementById('word-upload-btn');
2768 + const sendBtn = document.getElementById('send-button');
3530 2769 const originalBtnContent = uploadBtn.innerHTML;
3531 -
2770 +
3532 2771 try {
3533 - // Wait for page-cache nonce refresh before reading mxchatChat.nonce. See plan-c5457f.
3534 - await new Promise(function(resolve) { refreshNonceIfNeeded(resolve); });
3535 2772 const formData = new FormData();
3536 2773 formData.append('action', 'mxchat_upload_word');
3537 2774 formData.append('word_file', file);
3538 2775 formData.append('session_id', sessionId);
3539 2776 formData.append('nonce', mxchatChat.nonce);
3540 -
2777 +
3541 2778 uploadBtn.disabled = true;
3542 - if (sendBtn) sendBtn.disabled = true;
2779 + sendBtn.disabled = true;
3543 2780 uploadBtn.innerHTML = `<svg class="spinner" viewBox="0 0 50 50">
3544 2781 <circle cx="25" cy="25" r="20" fill="none" stroke-width="5"></circle>
3545 2782 </svg>`;
3546 -
2783 +
3547 2784 const response = await fetch(mxchatChat.ajax_url, {
3548 2785 method: 'POST',
3549 2786 body: formData
3550 2787 });
3551 -
2788 +
3552 2789 const data = await response.json();
3553 -
2790 +
3554 2791 if (data.success) {
3555 2792 // Hide popular questions if they exist
3556 - if (hasQuickQuestions(botId)) {
3557 - collapseQuickQuestions(botId);
2793 + const popularQuestionsContainer = document.getElementById('mxchat-popular-questions');
2794 + if (hasQuickQuestions()) {
2795 + collapseQuickQuestions();
3558 2796 }
3559 -
2797 +
3560 2798 // Show the active Word document name
3561 - showActiveWord(data.data.filename, botId);
3562 -
3563 - appendMessage('bot', data.data.message, '', [], false, botId);
3564 - scrollToBottom(botId);
3565 - instance.activeWordFile = data.data.filename;
2799 + showActiveWord(data.data.filename);
2800 +
2801 + appendMessage('bot', data.data.message);
2802 + scrollToBottom();
2803 + activeWordFile = data.data.filename;
3566 2804 } else {
3567 2805 alert('Failed to upload Word document. Please try again.');
3568 2806 }
3569 2807 } catch (error) {
@@ -3569,25 +2807,25 @@
3569 2807 } catch (error) {
3570 2808 alert('Error uploading file. Please try again.');
3571 2809 } finally {
3572 2810 uploadBtn.disabled = false;
3573 - if (sendBtn) sendBtn.disabled = false;
2811 + sendBtn.disabled = false;
3574 2812 uploadBtn.innerHTML = originalBtnContent;
3575 2813 this.value = ''; // Reset file input
3576 2814 }
3577 2815 });
3578 2816
3579 - // Remove button click handlers - delegated, bot-aware (were bound to stale un-suffixed ids)
3580 - $(document).on('click', '.remove-pdf-btn', function(e) {
2817 + // Remove button click handlers
2818 + document.getElementById('remove-pdf-btn')?.addEventListener('click', function(e) {
3581 2819 e.preventDefault();
3582 2820 e.stopPropagation();
3583 - removeActivePdf(getBotIdFromElement(this));
2821 + removeActivePdf();
3584 2822 });
3585 -
3586 - $(document).on('click', '.remove-word-btn', function(e) {
2823 +
2824 + document.getElementById('remove-word-btn')?.addEventListener('click', function(e) {
3587 2825 e.preventDefault();
3588 2826 e.stopPropagation();
3589 - removeActiveWord(getBotIdFromElement(this));
2827 + removeActiveWord();
3590 2828 });
3591 2829
3592 2830 // Window resize handlers
3593 2831 $(window).on('resize orientationchange', function() {
@@ -4281,312 +3519,6 @@
4281 3519 }, 2000);
4282 3520 });
4283 3521 }
4284 3522 }
4285 -});
4286 -
4287 -// ============================================================================
4288 -// SATISFACTION RATING (v3.2.6)
4289 -// ============================================================================
4290 -// Per-session 👍/👎 prompt that appears in the chat-box after 60s of user
4291 -// inactivity following a bot reply. One prompt per session, deduped via
4292 -// localStorage. Runs ONLY when the satisfaction_rating_enabled option is on —
4293 -// the option (default off) is authoritative.
4294 -jQuery(function($) {
4295 - if (typeof mxchatChat === 'undefined') return;
4296 - // wp_localize_script stringifies scalars: a PHP boolean false arrives as
4297 - // '' and true as '1', so this must be an explicit-enable allowlist — the
4298 - // old "disabled when exactly false/'off'" check let '' through and the
4299 - // bubble rendered on sites with the option off/unset (plan-4bba64). PHP
4300 - // now emits 'on'/'off' strings; true/'1'/1 keep cached pre-fix HTML
4301 - // (boolean-true localizations) working.
4302 - // NOTE (plan-32db95): this gate reads the INLINE value at DOM ready and is
4303 - // deliberately NOT re-evaluated after the widget's dynamic-settings refresh
4304 - // merges fresh values over mxchatChat (that merge fires on first widget
4305 - // open, after this module has already decided). Re-evaluating would mean
4306 - // restructuring the whole module to late-bind its listeners — not worth it
4307 - // for a prompt that is at worst stale for one page load on a cached page.
4308 - var sre = mxchatChat.satisfaction_rating_enabled;
4309 - if (sre !== 'on' && sre !== true && sre !== '1' && sre !== 1) return;
4310 -
4311 - // wp_localize_script stringifies ints, so accept both number and numeric string.
4312 - var idleRaw = mxchatChat.satisfaction_rating_idle_seconds;
4313 - var idleSeconds = (typeof idleRaw === 'number') ? idleRaw : parseInt(idleRaw, 10);
4314 - if (!isFinite(idleSeconds)) idleSeconds = 60;
4315 - if (idleSeconds < 5) idleSeconds = 5;
4316 - if (idleSeconds > 600) idleSeconds = 600;
4317 - var IDLE_MS = idleSeconds * 1000;
4318 - var MIN_BOT_REPLIES = 2;
4319 - var ratingState = {};
4320 -
4321 - function getState(botId) {
4322 - if (!ratingState[botId]) {
4323 - ratingState[botId] = { idleTimer: null, botReplies: 0, promptShown: false, dismissed: false };
4324 - }
4325 - return ratingState[botId];
4326 - }
4327 -
4328 - function getSessionId(botId) {
4329 - if (typeof MxChatInstances !== 'undefined' && MxChatInstances.getChatSession) {
4330 - return MxChatInstances.getChatSession(botId);
4331 - }
4332 - return null;
4333 - }
4334 -
4335 - function isAlreadyRated(sessionId) {
4336 - if (!sessionId) return false;
4337 - try { return localStorage.getItem('mxchat_rated:' + sessionId) === '1'; } catch (e) { return false; }
4338 - }
4339 -
4340 - function markRated(sessionId) {
4341 - if (!sessionId) return;
4342 - try { localStorage.setItem('mxchat_rated:' + sessionId, '1'); } catch (e) {}
4343 - }
4344 -
4345 - function esc(s) {
4346 - return String(s == null ? '' : s)
4347 - .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
4348 - .replace(/"/g, '&quot;').replace(/'/g, '&#039;');
4349 - }
4350 -
4351 - // Mirror shouldSkipInlineColors so rating bubbles defer to AI-theme CSS.
4352 - function ratingSkipInlineColors(botId) {
4353 - if (mxchatChat.skip_inline_colors) return true;
4354 - var botAssignments = mxchatChat.bot_theme_assignments || {};
4355 - return botAssignments.hasOwnProperty(botId);
4356 - }
4357 -
4358 - function botBubbleStyleAttr(botId) {
4359 - if (ratingSkipInlineColors(botId)) return '';
4360 - var bg = mxchatChat.bot_message_bg_color;
4361 - var fg = mxchatChat.bot_message_font_color;
4362 - if (!bg && !fg) return '';
4363 - return ' style="background-color: ' + esc(bg || '') + '; color: ' + esc(fg || '') + ';"';
4364 - }
4365 -
4366 - // Reads the rating bubble's actual computed fg+bg (whatever paints it —
4367 - // the inline color pickers OR the mxchat-theme AI customizer's injected CSS)
4368 - // and paints the filled "Send" pill so it fills with the bot font color and
4369 - // labels in the bubble bg. Mirrors mxchatSyncMenuColors(~:1512) for the read.
4370 - // We paint the submit button DIRECTLY (inline longhand) rather than relying
4371 - // on the CSS rule's var()s: Chromium resolves an INHERITED custom property
4372 - // unreliably inside a descendant's `background`, so a bubble-level var would
4373 - // silently fall back to the literal (white-block bug all over again). Inline
4374 - // longhand always wins. Same transparent-guard as the menu so we never paint
4375 - // a see-through value — in that case the CSS literal fallbacks keep it legible.
4376 - function syncRatingBubbleColors(botId) {
4377 - var $chatBox = getChatBoxByBotId(botId);
4378 - if (!$chatBox || !$chatBox.length) return;
4379 - var bubbleEl = $chatBox.find('.mxchat-rating-bot-bubble').last()[0];
4380 - if (!bubbleEl) return;
4381 - var cs = window.getComputedStyle(bubbleEl);
4382 - var fg = cs.color;
4383 - var bg = cs.backgroundColor;
4384 - var hasFg = fg && fg !== 'rgba(0, 0, 0, 0)' && fg !== 'transparent';
4385 - var hasBg = bg && bg !== 'rgba(0, 0, 0, 0)' && bg !== 'transparent';
4386 - // Expose on the bubble too, for any inheriting styles / future use.
4387 - if (hasFg) bubbleEl.style.setProperty('--mxchat-bot-fg', fg);
4388 - if (hasBg) bubbleEl.style.setProperty('--mxchat-bot-bg', bg);
4389 - // Paint the Send pill directly — the part that actually fixes the bug.
4390 - var submitEl = bubbleEl.querySelector('.mxchat-rating-submit');
4391 - if (submitEl) {
4392 - if (hasFg) submitEl.style.backgroundColor = fg; // fill = bot font color
4393 - if (hasBg) submitEl.style.color = bg; // label = bubble background
4394 - }
4395 - }
4396 -
4397 - function copy(key) {
4398 - var c = mxchatChat.satisfaction_rating_copy || {};
4399 - var d = {
4400 - question: 'Was this helpful?',
4401 - helpful: 'Helpful',
4402 - not_helpful: 'Not helpful',
4403 - dismiss: 'Dismiss',
4404 - thanks: 'Thanks! Anything we should improve? (optional)',
4405 - placeholder: 'Tell us what could be better…',
4406 - send: 'Send',
4407 - skip: 'Skip',
4408 - saved: 'Thanks for the feedback.'
4409 - };
4410 - return c[key] || d[key];
4411 - }
4412 -
4413 - function thumbUpSvg() {
4414 - 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>';
4415 - }
4416 - function thumbDownSvg() {
4417 - 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>';
4418 - }
4419 -
4420 - function buildPromptHtml(botId) {
4421 - var styleAttr = botBubbleStyleAttr(botId);
4422 - return ''
4423 - + '<div class="bot-message mxchat-rating-bot-bubble"' + styleAttr + '>'
4424 - + '<div class="mxchat-rating-prompt" data-bot-id="' + esc(botId) + '" role="group" aria-label="' + esc(copy('question')) + '">'
4425 - + '<div class="mxchat-rating-question">' + esc(copy('question')) + '</div>'
4426 - + '<div class="mxchat-rating-actions">'
4427 - + '<span class="mxchat-rating-buttons">'
4428 - + '<button type="button" class="mxchat-rating-btn" data-rating="1" aria-label="' + esc(copy('helpful')) + '">' + thumbUpSvg() + '</button>'
4429 - + '<button type="button" class="mxchat-rating-btn" data-rating="-1" aria-label="' + esc(copy('not_helpful')) + '">' + thumbDownSvg() + '</button>'
4430 - + '</span>'
4431 - + '<button type="button" class="mxchat-rating-dismiss" aria-label="' + esc(copy('dismiss')) + '">×</button>'
4432 - + '</div>'
4433 - + '</div>'
4434 - + '</div>';
4435 - }
4436 -
4437 - function buildFeedbackHtml(botId, rating) {
4438 - var styleAttr = botBubbleStyleAttr(botId);
4439 - return ''
4440 - + '<div class="bot-message mxchat-rating-bot-bubble"' + styleAttr + '>'
4441 - + '<div class="mxchat-rating-feedback" data-bot-id="' + esc(botId) + '" data-rating="' + esc(String(rating)) + '">'
4442 - + '<div class="mxchat-rating-feedback-label">' + esc(copy('thanks')) + '</div>'
4443 - + '<textarea class="mxchat-rating-feedback-input" maxlength="500" placeholder="' + esc(copy('placeholder')) + '" rows="2"></textarea>'
4444 - + '<div class="mxchat-rating-feedback-actions">'
4445 - + '<button type="button" class="mxchat-rating-skip">' + esc(copy('skip')) + '</button>'
4446 - + '<button type="button" class="mxchat-rating-submit">' + esc(copy('send')) + '</button>'
4447 - + '</div>'
4448 - + '</div>'
4449 - + '</div>';
4450 - }
4451 -
4452 - function buildSavedHtml(botId) {
4453 - var styleAttr = botBubbleStyleAttr(botId);
4454 - return ''
4455 - + '<div class="bot-message mxchat-rating-bot-bubble"' + styleAttr + '>'
4456 - + '<div class="mxchat-rating-saved">' + esc(copy('saved')) + '</div>'
4457 - + '</div>';
4458 - }
4459 -
4460 - function getChatBoxByBotId(botId) {
4461 - var $byId = $('#chat-box-' + botId);
4462 - if ($byId.length) return $byId.first();
4463 - return $('.chat-box').first();
4464 - }
4465 -
4466 - function scrollChatBoxToBottom($chatBox) {
4467 - if (!$chatBox || !$chatBox.length) return;
4468 - $chatBox.scrollTop($chatBox[0].scrollHeight);
4469 - }
4470 -
4471 - function showPrompt(botId) {
4472 - var s = getState(botId);
4473 - if (s.promptShown || s.dismissed) return;
4474 - var sessionId = getSessionId(botId);
4475 - if (!sessionId) return;
4476 - if (isAlreadyRated(sessionId)) { s.promptShown = true; return; }
4477 - var $chatBox = getChatBoxByBotId(botId);
4478 - if (!$chatBox.length) return;
4479 - if ($chatBox.find('.mxchat-rating-prompt').length) { s.promptShown = true; return; }
4480 - $chatBox.append(buildPromptHtml(botId));
4481 - syncRatingBubbleColors(botId);
4482 - s.promptShown = true;
4483 - scrollChatBoxToBottom($chatBox);
4484 - }
4485 -
4486 - function submitRating(botId, rating, feedback) {
4487 - var sessionId = getSessionId(botId);
4488 - if (!sessionId) return;
4489 - $.post(mxchatChat.ajax_url, {
4490 - action: 'mxchat_save_rating',
4491 - session_id: sessionId,
4492 - bot_id: botId,
4493 - rating: rating,
4494 - feedback: feedback || ''
4495 - });
4496 - markRated(sessionId);
4497 - }
4498 -
4499 - function onBotReply(botId) {
4500 - var s = getState(botId);
4501 - s.botReplies += 1;
4502 - if (s.promptShown || s.dismissed) return;
4503 - var sessionId = getSessionId(botId);
4504 - if (sessionId && isAlreadyRated(sessionId)) { s.promptShown = true; return; }
4505 - if (s.botReplies < MIN_BOT_REPLIES) return;
4506 - if (s.idleTimer) clearTimeout(s.idleTimer);
4507 - s.idleTimer = setTimeout(function() { showPrompt(botId); }, IDLE_MS);
4508 - }
4509 -
4510 - function onUserMessage(botId) {
4511 - var s = getState(botId);
4512 - if (s.idleTimer) { clearTimeout(s.idleTimer); s.idleTimer = null; }
4513 - }
4514 -
4515 - function botIdFromChatBox(el) {
4516 - var id = el && el.id ? el.id : '';
4517 - return id.indexOf('chat-box-') === 0 ? id.substring('chat-box-'.length) : 'default';
4518 - }
4519 -
4520 - function setupObserver(chatBox) {
4521 - var botId = botIdFromChatBox(chatBox);
4522 - try {
4523 - var observer = new MutationObserver(function(mutations) {
4524 - mutations.forEach(function(m) {
4525 - for (var i = 0; i < m.addedNodes.length; i++) {
4526 - var node = m.addedNodes[i];
4527 - if (!node || node.nodeType !== 1) continue;
4528 - var $n = $(node);
4529 - if ($n.hasClass('mxchat-rating-bot-bubble') || $n.hasClass('mxchat-rating-prompt') || $n.hasClass('mxchat-rating-feedback') || $n.hasClass('mxchat-rating-saved')) continue;
4530 - 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)
4531 - else if ($n.hasClass('user-message')) onUserMessage(botId);
4532 - }
4533 - });
4534 - });
4535 - observer.observe(chatBox, { childList: true });
4536 - } catch (e) { /* noop */ }
4537 - }
4538 -
4539 - $('.chat-box').each(function() { setupObserver(this); });
4540 -
4541 - $(document).on('click', '.mxchat-rating-btn', function(e) {
4542 - e.preventDefault();
4543 - var $btn = $(this);
4544 - var $prompt = $btn.closest('.mxchat-rating-prompt');
4545 - var $wrap = $btn.closest('.mxchat-rating-bot-bubble');
4546 - var botId = $prompt.data('bot-id') || 'default';
4547 - var rating = parseInt($btn.attr('data-rating'), 10);
4548 - if (rating !== 1 && rating !== -1) return;
4549 - submitRating(botId, rating, '');
4550 - ($wrap.length ? $wrap : $prompt).replaceWith(buildFeedbackHtml(botId, rating));
4551 - syncRatingBubbleColors(botId);
4552 - scrollChatBoxToBottom(getChatBoxByBotId(botId));
4553 - });
4554 -
4555 - $(document).on('click', '.mxchat-rating-dismiss', function(e) {
4556 - e.preventDefault();
4557 - var $prompt = $(this).closest('.mxchat-rating-prompt');
4558 - var $wrap = $(this).closest('.mxchat-rating-bot-bubble');
4559 - var botId = $prompt.data('bot-id') || 'default';
4560 - var s = getState(botId);
4561 - s.dismissed = true;
4562 - markRated(getSessionId(botId));
4563 - ($wrap.length ? $wrap : $prompt).remove();
4564 - });
4565 -
4566 - function closeFeedback($fb) {
4567 - var botId = $fb.data('bot-id') || 'default';
4568 - var $wrap = $fb.closest('.mxchat-rating-bot-bubble');
4569 - ($wrap.length ? $wrap : $fb).replaceWith(buildSavedHtml(botId));
4570 - syncRatingBubbleColors(botId);
4571 - scrollChatBoxToBottom(getChatBoxByBotId(botId));
4572 - }
4573 -
4574 - $(document).on('click', '.mxchat-rating-skip', function(e) {
4575 - e.preventDefault();
4576 - closeFeedback($(this).closest('.mxchat-rating-feedback'));
4577 - });
4578 -
4579 - $(document).on('click', '.mxchat-rating-submit', function(e) {
4580 - e.preventDefault();
4581 - var $fb = $(this).closest('.mxchat-rating-feedback');
4582 - var botId = $fb.data('bot-id') || 'default';
4583 - var rating = parseInt($fb.attr('data-rating'), 10);
4584 - if (rating !== 1 && rating !== -1) { closeFeedback($fb); return; }
4585 - var text = String($fb.find('.mxchat-rating-feedback-input').val() || '').trim();
4586 - if (text !== '') {
4587 - submitRating(botId, rating, text);
4588 - }
4589 - closeFeedback($fb);
4590 - });
4591 3523 });
4592 3524