1jQuery(document).ready(function($) { 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') { 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 // ==================================== 164 // MULTI-INSTANCE MANAGEMENT SYSTEM 165 // ==================================== 166 167 // Instance registry - tracks all chatbot instances on the page 168 const MxChatInstances = { 169 instances: {}, 170 171 // Lifecycle facts add-ons can read after the fact (plan 65c9b6). The 172 // events below fire once; a script that loads late checks these instead. 173 toolbarReady: false, 174 opened: {}, 175 hasOpened: function(botId) { 176 return !!this.opened[botId || 'default']; 177 }, 178 179 // Initialize an instance for a bot 180 init: function(botId) { 181 if (!this.instances[botId]) { 182 // When persistence is OFF, track when this session started 183 // so the AI only sees messages from this page load 184 var chatPersistenceEnabled = typeof mxchatChat !== 'undefined' && mxchatChat.chat_persistence_toggle === 'on'; 185 186 this.instances[botId] = { 187 botId: botId, 188 sessionId: null, 189 lastSeenMessageId: '', 190 notificationCheckInterval: null, 191 pollingInterval: null, 192 processedMessageIds: new Set(), 193 activePdfFile: null, 194 activeWordFile: null, 195 chatHistoryLoaded: false, 196 isStreaming: false, 197 // Fresh context timestamp - only used when persistence is OFF 198 sessionStartTimestamp: chatPersistenceEnabled ? 0 : Date.now() 199 }; 200 } 201 return this.instances[botId]; 202 }, 203 204 // Get instance by botId 205 get: function(botId) { 206 return this.instances[botId] || this.init(botId); 207 }, 208 209 // Get all active bot IDs 210 getAllBotIds: function() { 211 return Object.keys(this.instances); 212 }, 213 214 // Session management per bot 215 // Returns existing session ID from cookie or localStorage (with in-memory fallback), 216 // or null if none exists. Does NOT create a new session â use ensureSession() for that. 217 getChatSession: function(botId) { 218 var cookieName = 'mxchat_session_id_' + botId; 219 var storageKey = 'mxchat_session_id_' + botId; 220 var sessionId = getCookie(cookieName); 221 222 // Fallback to localStorage if cookie is missing (e.g. cleared by browser/consent) 223 if (!sessionId) { 224 try { sessionId = localStorage.getItem(storageKey); } catch (e) {} 225 } 226
227 // Fallback to in-memory instance when cookie AND localStorage are both blocked 228 // (Safari ITP, strict tracking prevention, cross-origin iframes with partitioned 229 // storage). Without this, ensureSession() can generate and store an ID that 230 // getChatSession() then can't read back, causing null session_ids on send. 231 if (!sessionId && this.instances[botId] && this.instances[botId].sessionId) { 232 sessionId = this.instances[botId].sessionId; 233 } 234 235 // Guard against stored sentinel values that indicate earlier broken writes. 236 if (sessionId === 'null' || sessionId === 'undefined') { 237 sessionId = null; 238 } 239 240 // Re-sync cookie from localStorage if cookie was lost 241 if (sessionId && !getCookie(cookieName)) { 242 document.cookie = cookieName + "=" + sessionId + "; path=/; max-age=86400; SameSite=Lax"; 243 } 244 245 return sessionId || null; 246 }, 247 248 // Lazy session initializer â called on first user interaction 249 ensureSession: function(botId) { 250 botId = botId || 'default'; 251 var instance = this.instances[botId] || this.init(botId); 252 253 if (instance.sessionId) { 254 return instance.sessionId; 255 } 256 257 // Check for existing session from cookie or localStorage 258 var existingSession = this.getChatSession(botId); 259 260 if (existingSession) { 261 instance.sessionId = existingSession; 262 } else { 263 // Brand new session 264 var newId = generateSessionId(); 265 this.setChatSession(botId, newId); 266 instance.sessionId = newId; 267 } 268 269 // Now that we have a session, do the deferred work 270 refreshNonceIfNeeded(); 271 trackOriginatingPage(); 272 273 // Note: loadChatHistory is handled by showChatContainerForBot with loader UI, 274 // so we do NOT call it here to avoid a race condition. 275 276 return instance.sessionId; 277 }, 278 279 setChatSession: function(botId, sessionId) { 280 var cookieName = 'mxchat_session_id_' + botId; 281 var storageKey = 'mxchat_session_id_' + botId; 282 document.cookie = cookieName + "=" + sessionId + "; path=/; max-age=86400; SameSite=Lax"; 283 try { localStorage.setItem(storageKey, sessionId); } catch (e) {} 284 if (this.instances[botId]) { 285 this.instances[botId].sessionId = sessionId; 286 } 287 }, 288 289 resetChatSession: function(botId) { 290 // Clear old session from localStorage before setting new one 291 try { localStorage.removeItem('mxchat_session_id_' + botId); } catch (e) {} 292 var newSessionId = generateSessionId(); 293 this.setChatSession(botId, newSessionId); 294 var $chatBox = getElement(botId, 'chat-box'); 295 if ($chatBox.length) { 296 // Keep the greeting, drop everything else. Identify the greeting 297 // by its marker class, NOT by position (plan a1a79b): after a 298 // chat-persistence restore the first .bot-message is a real 299 // reply, so ":not(:first)" left a stale answer sitting at the 300 // top of an otherwise empty box. The positional fallback only 301 // runs when the marker is absent â a page served from HTML cache 302 // that predates this release â and behaves exactly as before. 303 if ($chatBox.find('.mxchat-intro-message').length) { 304 $chatBox.find('.user-message, .bot-message:not(.mxchat-intro-message), .agent-message').remove(); 305 } else { 306 $chatBox.find('.user-message, .bot-message:not(:first), .agent-message').remove(); 307 } 308 } 309 if (this.instances[botId]) { 310 this.instances[botId].chatHistoryLoaded = false; 311 this.instances[botId].processedMessageIds = new Set(); 312 } 313 }, 314 315 // Silent reset â new session ID without clearing the chat UI 316 // Used when IP changes mid-conversation so the user doesn't see messages vanish 317 silentResetSession: function(botId) { 318 try { localStorage.removeItem('mxchat_session_id_' + botId); } catch (e) {} 319 var newSessionId = generateSessionId(); 320 this.setChatSession(botId, newSessionId); 321 if (this.instances[botId]) { 322 this.instances[botId].sessionId = newSessionId; 323 } 324 return newSessionId; 325 } 326 }; 327 328 // ==================================== 329 // LIFECYCLE EVENTS FOR ADD-ONS (plan 65c9b6) 330 // ==================================== 331 // 332 // Document-level CustomEvents an add-on listens for instead of polling the 333 // DOM or firing requests on page load: 334 // mxchat:toolbar-ready detail { enabled } once, right after the 335 // toolbar show/hide pass; items rendered through the 336 // mxchat_chat_toolbar_items filter can bind now. 337 // mxchat:opened detail { botId, embedded } once per bot per page 338 // load, the first time its window is shown â the 339 // launcher, the pre-chat bubble, an embedded widget 340 // (at load), or any script that reveals it. 341 // Same facts, readable late: MxChatInstances.toolbarReady / hasOpened(botId). 342 function mxchatDispatch(name, detail) { 343 try { 344 document.dispatchEvent(new CustomEvent(name, { detail: detail || {} })); 345 } catch (err) { /* CustomEvent unsupported â nothing to notify */ } 346 } 347 348 function mxchatFireOpened(botId) { 349 botId = botId || 'default'; 350 if (MxChatInstances.opened[botId]) return; 351 MxChatInstances.opened[botId] = true; 352 mxchatDispatch('mxchat:opened', { botId: botId, embedded: isEmbeddedBot(botId) }); 353 } 354 355 // Catch openers this file does not own (the Trigger add-on, theme buttons, 356 // custom scripts): a floating window gaining .visible counts as opened. 357 function mxchatWatchForOpen(botId) { 358 var el = getElementDOM(botId, 'floating-chatbot'); 359 if (!el || typeof MutationObserver === 'undefined') return; 360 if (el.classList.contains('visible')) { mxchatFireOpened(botId); return; } 361 var obs = new MutationObserver(function() { 362 if (el.classList.contains('visible')) { 363 mxchatFireOpened(botId); 364 obs.disconnect(); 365 } 366 }); 367 obs.observe(el, { attributes: true, attributeFilter: ['class'] }); 368 } 369 370 // ==================================== 371 // ELEMENT SELECTOR HELPERS 372 // ==================================== 373 374 // Check if a specific bot has an AI theme assigned (skip inline colors) 375 function shouldSkipInlineColors(botId) { 376 // If global AI theme is active, skip inline colors for all bots 377 if (mxchatChat.skip_inline_colors) { 378 return true; 379 } 380 // Check if this specific bot has a theme assignment 381 var botAssignments = mxchatChat.bot_theme_assignments || {}; 382 return botAssignments.hasOwnProperty(botId); 383 } 384 385 // Get element by ID with bot suffix - returns jQuery object 386 function getElement(botId, elementName) { 387 return $('#' + elementName + '-' + botId); 388 } 389 390 // Get element by ID with bot suffix - returns DOM element 391 function getElementDOM(botId, elementName) { 392 return document.getElementById(elementName + '-' + botId); 393 } 394 395 // Get bot ID from any element within a chatbot instance 396 function getBotIdFromElement(element) { 397 var $wrapper = $(element).closest('.mxchat-chatbot-wrapper'); 398 if ($wrapper.length) { 399 return $wrapper.data('bot-id') || 'default'; 400 } 401 // Fallback: try to find from floating container 402 var $floating = $(element).closest('.floating-chatbot'); 403 if ($floating.length) { 404 var id = $floating.attr('id') || ''; 405 var match = id.match(/floating-chatbot-(.+)/); 406 if (match) return match[1]; 407 } 408 // Fallback: the pre-chat teaser bubble (#pre-chat-message-{bot_id}) is a SIBLING 409 // outside .mxchat-chatbot-wrapper / .floating-chatbot, so its children â e.g. the 410 // .close-pre-chat-message button, which carries only a class and no id â miss both 411 // branches above. Walk to the nearest ancestor whose id is pre-chat-message-{bot_id} 412 // and read the suffix. (closest() includes the element itself, so a click directly on 413 // #pre-chat-message-{bot_id} resolves here too.) 414 var $preChat = $(element).closest('[id^="pre-chat-message-"]'); 415 if ($preChat.length) { 416 var preId = $preChat.attr('id') || ''; 417 var preMatch = preId.match(/^pre-chat-message-(.+)$/); 418 if (preMatch) return preMatch[1]; 419 } 420 // Fallback: check if element itself has an ID with bot suffix (e.g., floating-chatbot-button-{bot_id}) 421 var elementId = $(element).attr('id') || ''; 422 if (elementId) { 423 // Match patterns like: floating-chatbot-button-{bot_id}, pre-chat-message-{bot_id} 424 var idMatch = elementId.match(/^(?:floating-chatbot-button|pre-chat-message|chat-notification-badge)-(.+
424)$/); 425 if (idMatch) return idMatch[1]; 426 } 427 return 'default'; 428 } 429 430 // Get wrapper element for a bot 431 function getWrapper(botId) { 432 return getElement(botId, 'mxchat-chatbot-wrapper'); 433 } 434 435 // ==================================== 436 // GLOBAL VARIABLES & CONFIGURATION 437 // ==================================== 438 const toolbarIconColor = mxchatChat.toolbar_icon_color || '#212121'; 439 440 // Initialize color settings (these are global as they come from PHP) 441 var userMessageBgColor = mxchatChat.user_message_bg_color; 442 var userMessageFontColor = mxchatChat.user_message_font_color; 443 var botMessageBgColor = mxchatChat.bot_message_bg_color; 444 var botMessageFontColor = mxchatChat.bot_message_font_color; 445 var liveAgentMessageBgColor = mxchatChat.live_agent_message_bg_color; 446 var liveAgentMessageFontColor = mxchatChat.live_agent_message_font_color; 447 448 var linkTarget = mxchatChat.link_target_toggle === 'on' ? '_blank' : '_self'; 449 450 // ==================================== 451 // SESSION MANAGEMENT (Legacy compatibility) 452 // ==================================== 453 454 function getCookie(name) { 455 let value = "; " + document.cookie; 456 let parts = value.split("; " + name + "="); 457 if (parts.length == 2) return parts.pop().split(";").shift(); 458 } 459 460 function generateSessionId() { 461 // Session IDs function as the de-facto bearer token for an anonymous 462 // chat, so generate them with a CSPRNG when available. Math.random is a 463 // legacy fallback for ancient/sandboxed environments that lack 464 // window.crypto. The 'mxchat_chat_' prefix is preserved exactly (other 465 // code pattern-matches on it). (plan-0c17b5) 466 var rand; 467 try { 468 if (window.crypto && window.crypto.getRandomValues) { 469 var buf = new Uint8Array(16); // 128 bits 470 window.crypto.getRandomValues(buf); 471 rand = Array.prototype.map.call(buf, function (b) { 472 return ('0' + b.toString(16)).slice(-2); 473 }).join(''); 474 } 475 } catch (e) {} 476 if (!rand) { 477 rand = Math.random().toString(36).substr(2, 9); // legacy fallback 478 } 479 return 'mxchat_chat_' + rand; 480 } 481 482 // Legacy function - now delegates to instance manager 483 function getChatSession(botId) { 484 botId = botId || 'default'; 485 return MxChatInstances.getChatSession(botId); 486 } 487 488 function setChatSession(sessionId, botId) { 489 botId = botId || 'default'; 490 MxChatInstances.setChatSession(botId, sessionId); 491 } 492 493 function resetChatSession(botId) { 494 botId = botId || 'default'; 495 MxChatInstances.resetChatSession(botId); 496 } 497 498 // ==================================== 499 // INITIALIZE ALL CHATBOT INSTANCES 500 // ==================================== 501 502 function initializeAllInstances() { 503 // Find all chatbot wrappers on the page 504 $('.mxchat-chatbot-wrapper').each(function() { 505 var botId = $(this).data('bot-id') || 'default'; 506 MxChatInstances.init(botId); 507 initializeBotInstance(botId); 508 }); 509 } 510 511 function initializeBotInstance(botId) { 512 var instance = MxChatInstances.get(botId); 513 514 // Initialize quick questions state for this bot 515 checkQuickQuestionsState(botId); 516 517 // Note: Event handlers use event delegation with class selectors, 518 // so they work automatically for all instances without per-bot setup 519 } 520 521// ==================================== 522// CONTEXTUAL AWARENESS FUNCTIONALITY 523// ==================================== 524 525// Plan f3e515: the page context is computed once per page, not once per message. 526// Keyed on URL + title + the content element's text length â cheap to check, 527// and enough to notice a navigation or a re-rendered content area. 528var pageContextCache = { key: null, value: null }; 529 530function getPageContext() { 531 // Check if contextual awareness is enabled 532 if (mxchatChat.contextual_awareness_toggle !== 'on') { 533 return null; 534 } 535 536 // Get page URL 537 const pageUrl = window.location.href; 538 539 // Get page title 540 const pageTitle = document.title || ''; 541 542 // Get main content from the page 543 let pageContent = ''; 544 545 // Try to get content from common content areas 546 const contentSelectors = [ 547 'main', 548 '[role="main"]', 549 '.content', 550 '.main-content', 551 '.post-content', 552 '.entry-content', 553 '.page-content', 554 'article', 555 '#content', 556 '#main' 557 ]; 558
559 let contentElement = null; 560 for (const selector of contentSelectors) { 561 contentElement = document.querySelector(selector); 562 if (contentElement) { 563 break; 564 } 565 } 566 567 // If no specific content area found, use body but exclude header, footer, nav, sidebar 568 if (!contentElement) { 569 contentElement = document.body; 570 } 571 572 if (contentElement) { 573 const cacheKey = pageUrl + '\u0000' + pageTitle + '\u0000' + (contentElement.textContent || '').length; 574 if (pageContextCache.key === cacheKey) { 575 return pageContextCache.value; 576 } 577 578 // Copy the element into a document that has no browsing context 579 // (plan f3e515). A live cloneNode(true) constructed real <video> / 580 // <audio> / <img> elements: media with a src runs resource selection 581 // on creation whether or not it is attached, and an autoplay attribute 582 // made the detached copy PLAY â audibly, once the visible player had 583 // been unmuted through the property. Every message spawned another 584 // one. Elements in an inert document never fetch or play, while 585 // querySelectorAll / textContent / dataset all still work on them. 586 const inertDocument = document.implementation.createHTMLDocument(''); 587 const clone = inertDocument.importNode(contentElement, true); 588 589 // Remove unwanted elements 590 const unwantedSelectors = [ 591 'header', 592 'footer', 593 'nav', 594 '.navigation', 595 '.sidebar', 596 '.widget', 597 '.menu', 598 'script', 599 'style', 600 '.comments', 601 '#comments', 602 '.breadcrumb', 603 '.breadcrumbs', 604 '#floating-chatbot', 605 '#floating-chatbot-button', 606 '.mxchat', 607 '[class*="chat"]', 608 '[id*="chat"]', 609 // Media contributes no text and (outside the inert document) fetches. 610 'video', 611 'audio', 612 'iframe', 613 'object', 614 'embed', 615 'picture', 616 'img', 617 'source' 618 ]; 619 620 unwantedSelectors.forEach(selector => { 621 const elements = clone.querySelectorAll(selector); 622 elements.forEach(el => el.remove()); 623 }); 624 625 // Extract MxChat context data attributes before getting text content 626 const contextData = []; 627 clone.querySelectorAll('[data-mxchat-context]').forEach(el => { 628 const contextValue = el.dataset.mxchatContext; 629 if (contextValue && contextValue.trim()) { 630 contextData.push(contextValue); 631 } 632 }); 633 634 // Get text content and clean it up 635 pageContent = clone.textContent || clone.innerText || ''; 636 637 // Add context data to page content if any were found 638 if (contextData.length > 0) { 639 pageContent += '\n\nAdditional Context:\n' + contextData.join('\n'); 640 } 641 642 // Clean up whitespace and limit length 643 pageContent = pageContent 644 .replace(/\s+/g, ' ') 645 .trim() 646 .substring(0, 3000); // Limit to 3000 characters to avoid token limits 647 648 // Only return context if we have meaningful content 649 const result = (!pageContent || pageContent.length < 50) ? null : { 650 url: pageUrl, 651 title: pageTitle, 652 content: pageContent 653 }; 654 pageContextCache = { key: cacheKey, value: result }; 655 return result; 656 } 657 658 return null; 659} 660 661// Track originating page when chat starts 662function trackOriginatingPage() { 663 const sessionId = getChatSession(); 664 const pageUrl = window.location.href; 665 const pageTitle = document.title || 'Untitled Page'; 666 667 // Only track once per session 668 const trackingKey = 'mxchat_originating_tracked_' + sessionId; 669 if (sessionStorage.getItem(trackingKey)) { 670 return; 671 } 672 673 $.ajax({
674 url: mxchatChat.ajax_url, 675 type: 'POST', 676 data: { 677 action: 'mxchat_track_originating_page', 678 session_id: sessionId, 679 page_url: pageUrl, 680 page_title: pageTitle, 681 nonce: mxchatChat.nonce 682 }, 683 success: function(response) { 684 if (response.success) { 685 sessionStorage.setItem(trackingKey, 'true'); 686 } 687 } 688 }); 689} 690 691// ==================================== 692// CORE CHAT FUNCTIONALITY 693// ==================================== 694 695// Helper functions to disable/enable chat input while waiting for response 696function disableChatInput(botId) { 697 botId = botId || 'default'; 698 var chatInput = getElementDOM(botId, 'chat-input'); 699 var sendButton = getElementDOM(botId, 'send-button'); 700 if (chatInput) { 701 chatInput.disabled = true; 702 chatInput.style.opacity = '0.6'; 703 } 704 if (sendButton) { 705 sendButton.disabled = true; 706 sendButton.style.opacity = '0.5'; 707 sendButton.style.pointerEvents = 'none'; 708 } 709} 710 711// Whether the input may grab focus after a completed reply (plan 03799f). 712// On coarse-pointer devices focusing a text input summons the on-screen 713// keyboard over the answer the visitor is trying to read, so 'auto' (the 714// default) focuses only on fine-pointer devices. The site-wide 715// mxchat_autofocus_after_reply PHP filter can force 'on'/'off'. 716// NOT used on widget open (:~3424) â that focus is a deliberate act and is 717// what makes the widget keyboard-accessible. 718function mxchatShouldAutofocusAfterReply() { 719 var pref = (typeof mxchatChat !== 'undefined' && mxchatChat.autofocus_after_reply) || 'auto'; 720 if (pref === 'on') return true; 721 if (pref === 'off') return false; 722 try { 723 return !window.matchMedia('(pointer: coarse)').matches; 724 } catch (err) { 725 return true; 726 } 727} 728 729function enableChatInput(botId) { 730 botId = botId || 'default'; 731 var chatInput = getElementDOM(botId, 'chat-input'); 732 var sendButton = getElementDOM(botId, 'send-button'); 733 if (chatInput) { 734 chatInput.disabled = false; 735 chatInput.style.opacity = '1'; 736 if (mxchatShouldAutofocusAfterReply()) { 737 try { chatInput.focus({ preventScroll: true }); } catch (err) { chatInput.focus(); } 738 } 739 } 740 if (sendButton) { 741 sendButton.disabled = false; 742 sendButton.style.opacity = '1'; 743 sendButton.style.pointerEvents = 'auto'; 744 } 745 // Every completion path re-enables input, so this is the single restore 746 // point for the streaming Stop affordance (no-op when not in stop mode). 747 mxchatRestoreSendButton(botId); 748} 749 750// --- Streaming Stop control ------------------------------------------------- 751// One live stream handle per bot instance, so Stop on one widget never aborts 752// another bot on the same page. 753var mxchatActiveStreams = {}; 754// Original send-button markup, captured once per bot the first time the Stop 755// state is shown (never captured while already in stop mode, so a rapid 756// stop-then-resend can't save the stop glyph as the "original"). 757var mxchatSendMarkup = {}; 758 759function mxchatShowStopButton(botId) { 760 var btn = getElementDOM(botId, 'send-button'); 761 if (!btn) return; 762 if (!btn.classList.contains('mxchat-stop-mode')) { 763 mxchatSendMarkup[botId] = { 764 html: btn.innerHTML, 765 label: btn.getAttribute('aria-label') 766 }; 767 } 768 769 // Mirror the send icon's rendered size + color so the stop glyph looks 770 // native, including custom send images/colors and theme overrides. 771 var child = btn.querySelector('svg, img'); 772 var size = 25; 773 var color = ''; 774 if (child) { 775 var rect = child.getBoundingClientRect(); 776 if (rect.width) { 777 size = Math.round(Math.min(rect.width, rect.height)); 778 } 779 var cs = window.getComputedStyle(child); 780 color = (child.tagName.toLowerCase() === 'svg' ? cs.fill : cs.color) || ''; 781 } 782 var stopLabel = (typeof mxchatChat !== 'undefined' && mxchatChat.stop_button_label) || 'Stop response'; 783 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>';
784 // An add-on's DIRECT send-button handler (e.g. mxchat-vision's rebind) can 785 // start a stream synchronously while the originating click is still 786 // bubbling up to our delegated handler. Without this guard, that handler 787 // reads the just-added stop-mode class as a user Stop press and aborts the 788 // brand-new stream â the user's message renders but no reply ever fires 789 // (plan-4bba64 silent message loss). The flag only spans the current event 790 // dispatch: cleared on the next macrotask, long before a real Stop click. 791 btn.__mxchatStopJustShown = true; 792 setTimeout(function () { btn.__mxchatStopJustShown = false; }, 0); 793 btn.classList.add('mxchat-stop-mode'); 794 btn.setAttribute('aria-label', stopLabel); 795 btn.setAttribute('title', stopLabel); 796 // disableChatInput() ran when the turn was sent; the Stop control itself 797 // must stay clickable while the textarea remains disabled. 798 btn.disabled = false; 799 btn.style.opacity = '1'; 800 btn.style.pointerEvents = 'auto'; 801} 802 803function mxchatRestoreSendButton(botId) { 804 var btn = getElementDOM(botId, 'send-button'); 805 var saved = mxchatSendMarkup[botId]; 806 if (!btn || !btn.classList.contains('mxchat-stop-mode') || !saved) return; 807 btn.innerHTML = saved.html; 808 btn.classList.remove('mxchat-stop-mode'); 809 btn.removeAttribute('title'); 810 if (saved.label) { 811 btn.setAttribute('aria-label', saved.label); 812 } 813} 814 815function mxchatStopStreaming(botId) { 816 var entry = mxchatActiveStreams[botId]; 817 if (!entry || !entry.controller) return; 818 entry.aborted = true; 819 try { entry.controller.abort(); } catch (e) {} 820} 821 822// Returns true when a stream rejection came from an intentional Stop click: 823// keep the partial text as the turn's answer â no error UI, no fallback resend. 824function mxchatHandleStreamAbort(botId, accumulatedContent, callback) { 825 var entry = mxchatActiveStreams[botId]; 826 if (!entry || !entry.aborted) return false; 827 delete mxchatActiveStreams[botId]; 828 if (!accumulatedContent) { 829 // Stopped before the first chunk: drop the thinking bubble, no orphan message. 830 getElement(botId, 'chat-box').find('.bot-message.temporary-message').remove(); 831 } 832 enableChatInput(botId); // also restores the send icon 833 if (callback) { 834 callback(accumulatedContent || ''); 835 } 836 return true; 837} 838 839// Update your existing sendMessage function 840function sendMessage(botId) { 841 botId = botId || 'default'; 842 MxChatInstances.ensureSession(botId); 843 var $chatInput = getElement(botId, 'chat-input'); 844 var message = $chatInput.val(); 845 846 // ADD PROMPT HOOK HERE 847 if (typeof customMxChatFilter === 'function') { 848 message = customMxChatFilter(message, "prompt"); 849 } 850 851 if (message) { 852 // Don't disable input in live agent mode - let users chat freely 853 var modeIndicator = getElementDOM(botId, 'chat-mode-indicator'); 854 var isAgentMode = modeIndicator && modeIndicator.textContent === 'Live Agent'; 855 if (!isAgentMode) { 856 disableChatInput(botId); 857 } 858 859 appendMessage("user", message, '', [], false, botId); 860 $chatInput.val(''); 861 mxchatUpdateCharCounter($chatInput[0]); // reset the char counter after send (plan 7091a2) 862 $chatInput.css('height', 'auto'); 863 864 if (hasQuickQuestions(botId)) { 865 collapseQuickQuestions(botId); 866 } 867 appendThinkingMessage(botId); 868 scrollToBottom(botId); 869 870 const currentModel = mxchatChat.model || 'gpt-5.6-sol'; 871 872 // Check if streaming is enabled AND supported for this model 873 if (shouldUseStreaming(currentModel)) { 874 callMxChatStream(message, function(response) { 875 // Content is final: releasing aria-busy lets the live region 876 // announce the completed reply once (plan 67f126). 877 getElement(botId, 'chat-box').find('.bot-message.temporary-message').removeClass('temporary-message').attr('aria-busy', 'false'); 878 }, botId); 879 } else { 880 callMxChat(message, function(response) { 881 replaceLastMessage("bot", response, '', [], botId); 882 }, botId); 883 } 884 } 885} 886 887// Update your existing sendMessageToChatbot function 888function sendMessageToChatbot(message, botId) { 889 botId = botId || 'default'; 890 MxChatInstances.ensureSession(botId); 891 892 // ADD PROMPT HOOK HERE 893 if (typeof customMxChatFilter === 'function') { 894 message = customMxChatFilter(message, "prompt"); 895 } 896 897 // Don't disable input in live agent mode - let users chat freely 898 var modeIndicator = getElementDOM(botId, 'chat-mode-indicator'); 899 var isAgentMode = modeIndicator && modeIndicator.textContent === 'Live Agent'; 900 if (!isAgentMode) { 901 disableChatInput(botId); 902 } 903 904 var sessionId = getChatSession(botId); 905 906 if (hasQuickQuestions(botId)) { 907 collapseQuickQuestions(botId); 908 } 909 appendThinkingMessage(botId); 910 scrollToBottom(botId); 911 912 const currentModel = mxchatChat.model || 'gpt-5.6-sol'; 913 914 // Check if streaming is enabled AND supported for this model 915 if (shouldUseStreaming(currentModel)) { 916 callMxChatStream(message, function(response) { 917 // Final content â release aria-busy so the reply announces once (67f126). 918 getElement(botId, 'chat-box').find('.bot-message.temporary-message').removeClass('temporary-message').attr('aria-busy', 'false'); 919 }, botId); 920 } else { 921 callMxChat(message, function(response) { 922 getElement(botId, 'chat-box').find('.temporary-message').remove(); 923 replaceLastMessage("bot", response, '', [], botId); 924 }, botId); 925 } 926} 927 928// Updated shouldUseStreaming function with debugging 929function shouldUseStreaming(model) { 930 // Check if streaming is enabled in settings (using your toggle naming pattern) 931 const streamingEnabled = mxchatChat.enable_streaming_toggle === 'on'; 932 933 // Check if model supports streaming 934 const streamingSupported = isStreamingSupported(model); 935 936 937 // Only use streaming if both enabled and supported 938 return streamingEnabled && streamingSupported; 939} 940 941// Helper function to handle chat mode updates 942function handleChatModeUpdates(response, responseText) {
943 // Check for explicit chat mode in response (THIS IS THE KEY FIX) 944 if (response.chat_mode) { 945 updateChatModeIndicator(response.chat_mode); 946 return; // Return early since we found explicit mode 947 } 948 // Check for fallback response chat mode 949 else if (response.fallbackResponse && response.fallbackResponse.chat_mode) { 950 updateChatModeIndicator(response.fallbackResponse.chat_mode); 951 return; // Return early since we found explicit mode 952 } 953 954 // Only do text-based detection if no explicit mode was provided 955 // Check for specific AI chatbot response text 956 if (responseText === 'You are now chatting with the AI chatbot.' || 957 responseText.includes('now chatting with the AI') || 958 responseText.includes('switched to AI mode') || 959 responseText.includes('AI chatbot is now')) { 960 updateChatModeIndicator('ai'); 961 } 962 // Check for agent transfer messages 963 else if (responseText.includes('agent') && 964 (responseText.includes('transfer') || responseText.includes('connected'))) { 965 updateChatModeIndicator('agent'); 966 } 967} 968 969// Function to get bot ID from any element or wrapper 970// If element is provided, finds the bot ID from its wrapper 971// If no element, returns 'default' (for backward compatibility) 972function getMxChatBotId(element) { 973 if (element) { 974 return getBotIdFromElement(element); 975 } 976 // Fallback: find first chatbot wrapper on page 977 const chatbotWrapper = document.querySelector('.mxchat-chatbot-wrapper'); 978 return chatbotWrapper ? chatbotWrapper.getAttribute('data-bot-id') || 'default' : 'default'; 979} 980 981function callMxChat(message, callback, botId) { 982 botId = botId || getMxChatBotId(); 983 984 // Streaming fallbacks land here: drop any leftover stream handle and 985 // return the button to its send state (no-op for plain non-stream turns). 986 if (mxchatActiveStreams[botId]) { 987 delete mxchatActiveStreams[botId]; 988 } 989 mxchatRestoreSendButton(botId); 990 991 // Store the message in case we need to retry after session reset 992 getElement(botId, 'mxchat-chatbot-wrapper').find('.mxchat-input-holder textarea').data('pending-message', message); 993 994 // Get page context if contextual awareness is enabled 995 const pageContext = getPageContext(); 996 997 // Get instance for session start timestamp (used when persistence is OFF) 998 var instance = MxChatInstances.get(botId); 999 1000 // Guarantee a non-null session_id before the AJAX leaves. ensureSession() is idempotent 1001 // and returns the guaranteed-present session id from the in-memory instance even when 1002 // cookie/localStorage writes are silently blocked by the browser. 1003 var sessionId = MxChatInstances.ensureSession(botId); 1004 if (!sessionId || sessionId === 'null' || sessionId === 'undefined') { 1005 // Last-resort generation to ensure we never POST a null marker. 1006 sessionId = generateSessionId(); 1007 MxChatInstances.setChatSession(botId, sessionId); 1008 } 1009 1010 // Wait for the page-cache nonce refresh to complete before firing the 1011 // chat-send AJAX. On cached pages the inline mxchatChat.nonce is stale 1012 // until refreshNonceIfNeeded() returns; constructing ajaxData inside the 1013 // callback guarantees we read the fresh value. See plan-c5457f. 1014 refreshNonceIfNeeded(function() { 1015 // Prepare AJAX data 1016 const ajaxData = { 1017 action: 'mxchat_handle_chat_request', 1018 message: message, 1019 session_id: sessionId, 1020 nonce: mxchatChat.nonce, 1021 current_page_url: window.location.href, 1022 current_page_title: document.title, 1023 bot_id: botId, 1024 // Pass session start timestamp so AI context matches what user sees 1025 session_start_timestamp: instance.sessionStartTimestamp || 0 1026 }; 1027 1028 // Add page context if available 1029 if (pageContext) { 1030 ajaxData.page_context = JSON.stringify(pageContext); 1031 } 1032 1033 // CHECK FOR VISION FLAGS AND ADD THEM 1034 if (window.mxchatVisionProcessed) { 1035 ajaxData.vision_processed = true; 1036 ajaxData.original_user_message = window.mxchatOriginalMessage || message;
1037 ajaxData.vision_images_count = window.mxchatVisionImagesCount || 0; 1038 // Clear the flags after use 1039 window.mxchatVisionProcessed = false; 1040 window.mxchatOriginalMessage = null; 1041 window.mxchatVisionImagesCount = 0; 1042 } 1043 1044 $.ajax({ 1045 url: mxchatChat.ajax_url, 1046 type: 'POST', 1047 dataType: 'json', 1048 data: ajaxData, 1049 success: function(response) { 1050 // IMMEDIATE CHAT MODE UPDATE - This should be FIRST 1051 if (response.chat_mode) { 1052 updateChatModeIndicator(response.chat_mode, botId); 1053 } 1054 1055 // Also check in data property if response is wrapped 1056 if (response.data && response.data.chat_mode) { 1057 updateChatModeIndicator(response.data.chat_mode, botId); 1058 } 1059 1060 // SECURITY FIX: Check for errors FIRST before checking for success 1061 // This ensures API errors (quota exceeded, invalid key, rate limit) are properly displayed 1062 if (response.success === false || (response.data && response.data.error_message)) { 1063 let errorMessage = ""; 1064 let errorCode = ""; 1065 1066 // Check various possible error locations in the response 1067 if (response.data && response.data.error_message) { 1068 errorMessage = response.data.error_message; 1069 errorCode = response.data.error_code || ""; 1070 } else if (response.error_message) { 1071 errorMessage = response.error_message; 1072 errorCode = response.error_code || ""; 1073 } else if (response.message) { 1074 errorMessage = response.message; 1075 } else if (typeof response.data === 'string') { 1076 errorMessage = response.data; 1077 } else { 1078 // Fallback for any other unexpected response format 1079 errorMessage = "An error occurred. Please try again or contact support."; 1080 } 1081 1082 // Handle session reset action (IP changed, session expired, etc.) 1083 // Silent reset â keep chat UI intact, just get a new session and retry 1084 if (response.data && response.data.action === 'reset_session') { 1085 MxChatInstances.silentResetSession(botId); 1086 // Re-send the original message with the new session (user message is already displayed) 1087 var originalMessage = getElement(botId, 'mxchat-chatbot-wrapper').find('.mxchat-input-holder textarea').data('pending-message'); 1088 if (originalMessage) { 1089 getElement(botId, 'mxchat-chatbot-wrapper').find('.mxchat-input-holder textarea').data('pending-message', null); 1090 var currentModel = mxchatChat.model || 'gpt-5.6-sol'; 1091 if (shouldUseStreaming(currentModel)) { 1092 callMxChatStream(originalMessage, function(response) { 1093 // Final content â release aria-busy so the reply announces once (67f126). 1094 getElement(botId, 'chat-box').find('.bot-message.temporary-message').removeClass('temporary-message').attr('aria-busy', 'false'); 1095 }, botId); 1096 } else { 1097 callMxChat(originalMessage, function(response) { 1098 replaceLastMessage("bot", response, '', [], botId); 1099 }, botId); 1100 } 1101 } 1102 return; 1103 } 1104 1105 // Format user-friendly error message 1106 let displayMessage = errorMessage; 1107 1108 // Customize message for admin users 1109 if (mxchatChat.is_admin) { 1110 // For admin users, show more technical details including error code 1111 displayMessage = errorMessage + (errorCode ? " (Error code: " + errorCode + ")" : ""); 1112 } 1113 1114 replaceLastMessage("bot", displayMessage, '', [], botId); 1115 return; // Exit early for errors 1116 } 1117 1118 // NOW check if this is a successful response by looking for text, html, or message fields 1119 // This preserves compatibility with your server response format 1120 if (response.text !== undefined || response.html !== undefined || response.message !== undefined || 1121 (response.success === true && response.data && response.data.status === 'waiting_for_agent')) { 1122 1123 // Handle successful response - this is your original success handling code 1124 1125 // Handle other responses 1126 let responseText = response.text || ''; 1127 let responseHtml = response.html || ''; 1128 let responseMessage = response.message || ''; 1129 1130 // Add PDF filename handling 1131 if (response.data && response.data.filename) { 1132 showActivePdf(response.data.filename, botId);
1133 var instance = MxChatInstances.get(botId); 1134 instance.activePdfFile = response.data.filename; 1135 } 1136 1137 // Add redirect check here 1138 if (response.redirect_url) { 1139 if (responseText) { 1140 replaceLastMessage("bot", responseText, '', [], botId); 1141 } 1142 setTimeout(() => { 1143 window.location.href = response.redirect_url; 1144 }, 1500); 1145 return; 1146 } 1147 1148 // Check for live agent response 1149 if (response.success && response.data && response.data.status === 'waiting_for_agent') { 1150 removeThinkingDots(botId); 1151 updateChatModeIndicator('agent', botId); 1152 enableChatInput(botId); 1153 return; 1154 } 1155 1156 // Handle the message and show notification if chat is hidden 1157 if (responseText || responseHtml || responseMessage) { 1158 1159 // ADD RESPONSE HOOKS HERE - BEFORE DISPLAYING 1160 if (responseText && typeof customMxChatFilter === 'function') { 1161 responseText = customMxChatFilter(responseText, "response"); 1162 } 1163 if (responseMessage && typeof customMxChatFilter === 'function') { 1164 responseMessage = customMxChatFilter(responseMessage, "response"); 1165 } 1166 1167 // Update the messages as before 1168 if (responseText && responseHtml) { 1169 replaceLastMessage("bot", responseText, responseHtml, [], botId); 1170 } else if (responseText) { 1171 replaceLastMessage("bot", responseText, '', [], botId); 1172 } else if (responseHtml) { 1173 replaceLastMessage("bot", "", responseHtml, [], botId); 1174 } else if (responseMessage) { 1175 replaceLastMessage("bot", responseMessage, '', [], botId); 1176 } 1177 1178 // Check if chat is hidden and show notification 1179 var $floatingChatbot = getElement(botId, 'floating-chatbot'); 1180 if ($floatingChatbot.hasClass('hidden')) { 1181 var $badge = getElement(botId, 'chat-notification-badge'); 1182 if ($badge.length) { 1183 $badge.show(); 1184 } 1185 } 1186 } else { 1187 var emptyMsg = "I received an empty response. Please try again or contact support if this persists."; 1188 if (response.vectorstore_error) { 1189 emptyMsg = "I received an empty response. Debug info: " + response.vectorstore_error; 1190 } 1191 replaceLastMessage("bot", emptyMsg, '', [], botId); 1192 } 1193 1194 if (response.message_id) { 1195 var instance = MxChatInstances.get(botId); 1196 instance.lastSeenMessageId = response.message_id; 1197 } 1198 1199 return; 1200 } 1201 1202 // Fallback for truly unexpected response formats 1203 replaceLastMessage("bot", "Unexpected response format. Please try again or contact support.", '', [], botId); 1204 }, 1205 error: function(xhr, status, error) { 1206 let errorMessage = "An unexpected error occurred."; 1207 1208 // Try to parse the response if it's JSON 1209 try { 1210 const responseJson = JSON.parse(xhr.responseText); 1211 1212 if (responseJson.data && responseJson.data.error_message) { 1213 errorMessage = responseJson.data.error_message; 1214 } else if (responseJson.message) { 1215 errorMessage = responseJson.message; 1216 } 1217 } catch (e) { 1218 // Not JSON or parsing failed, use HTTP status based messages 1219 if (xhr.status === 0) { 1220 errorMessage = "Network error: Please check your internet connection."; 1221 } else if (xhr.status === 403) { 1222 errorMessage = "Access denied: Your session may have expired. Please refresh the page."; 1223 } else if (xhr.status === 404) { 1224 errorMessage = "API endpoint not found. Please contact support."; 1225 } else if (xhr.status === 429) { 1226 errorMessage = "Too many requests. Please try again in a moment."; 1227 } else if (xhr.status >= 500) { 1228 errorMessage = "Server error: The server encountered an issue. Please try again later."; 1229 } 1230 } 1231 1232 replaceLastMessage("bot", errorMessage, '', [], botId); 1233 } 1234 });
1235 }); // refreshNonceIfNeeded 1236} 1237 1238function callMxChatStream(message, callback, botId) { 1239 botId = botId || getMxChatBotId(); 1240 1241 // Store the message in case we need to retry after session reset 1242 getElement(botId, 'mxchat-chatbot-wrapper').find('.mxchat-input-holder textarea').data('pending-message', message); 1243 1244 const currentModel = mxchatChat.model || 'gpt-5.6-sol'; 1245 if (!isStreamingSupported(currentModel)) { 1246 callMxChat(message, callback, botId); 1247 return; 1248 } 1249 1250 // Get page context if contextual awareness is enabled 1251 const pageContext = getPageContext(); 1252 1253 // Get instance for session start timestamp (used when persistence is OFF) 1254 var instance = MxChatInstances.get(botId); 1255 1256 // Guarantee a non-null session_id before the fetch. FormData.append() stringifies any 1257 // non-string value via String(), so passing `null` would POST the literal string "null" 1258 // and land in the transcripts table as a ghost session. ensureSession() always returns 1259 // a real string even when cookies/localStorage are blocked. 1260 var streamSessionId = MxChatInstances.ensureSession(botId); 1261 if (!streamSessionId || streamSessionId === 'null' || streamSessionId === 'undefined') { 1262 streamSessionId = generateSessionId(); 1263 MxChatInstances.setChatSession(botId, streamSessionId); 1264 } 1265 1266 // Wait for the page-cache nonce refresh before constructing formData (which 1267 // captures mxchatChat.nonce by value). Mirrors callMxChat's wrapping. See plan-c5457f. 1268 refreshNonceIfNeeded(function() { 1269 const formData = new FormData(); 1270 formData.append('action', 'mxchat_stream_chat'); 1271 formData.append('message', message); 1272 formData.append('session_id', streamSessionId); 1273 formData.append('nonce', mxchatChat.nonce); 1274 formData.append('current_page_url', window.location.href); 1275 formData.append('current_page_title', document.title); 1276 formData.append('bot_id', botId); 1277 // Pass session start timestamp so AI context matches what user sees 1278 formData.append('session_start_timestamp', instance.sessionStartTimestamp || 0); 1279 1280 // Add page context if available 1281 if (pageContext) { 1282 formData.append('page_context', JSON.stringify(pageContext)); 1283 } 1284 1285 // CHECK FOR VISION FLAGS AND ADD THEM 1286 if (window.mxchatVisionProcessed) { 1287 formData.append('vision_processed', 'true'); 1288 formData.append('original_user_message', window.mxchatOriginalMessage || message); 1289 formData.append('vision_images_count', window.mxchatVisionImagesCount || '0'); 1290 // Clear the flags after use 1291 window.mxchatVisionProcessed = false; 1292 window.mxchatOriginalMessage = null; 1293 window.mxchatVisionImagesCount = 0; 1294 } 1295 1296 let accumulatedContent = ''; 1297 let testingDataReceived = false; 1298 let streamingStarted = false; 1299 // Server-pushed html to append as its OWN bot bubble once the stream 1300 // finishes (e.g. the consent-safe YouTube embed, plan 03ba33). Rendering is 1301 // deferred to [DONE] so the embed always lands BELOW the streamed text. 1302 let pendingAppendHtml = ''; 1303 1304 // Abortable stream: a fresh controller per turn, keyed by bot instance. 1305 // The Stop control (send button swapped in place) aborts both the read 1306 // loop and the underlying request. 1307 var streamControl = { controller: new AbortController(), aborted: false }; 1308 mxchatActiveStreams[botId] = streamControl; 1309 mxchatShowStopButton(botId); 1310 1311 fetch(mxchatChat.ajax_url, { 1312 method: 'POST', 1313 body: formData, 1314 credentials: 'same-origin', 1315 signal: streamControl.controller.signal 1316 }) 1317 .then(response => { 1318 // Store the response for potential fallback handling 1319 const responseClone = response.clone(); 1320 1321 if (!response.ok) { 1322 // Try to get error details from response 1323 return responseClone.json().then(errorData => { 1324 throw { isServerError: true, data: errorData }; 1325 }).catch(() => { 1326 throw new Error('Network response was not ok'); 1327 }); 1328 } 1329 1330 // Check if response is JSON instead of streaming 1331 const contentType = response.headers.get('content-type'); 1332 if (contentType && contentType.includes('application/json')) { 1333 return responseClone.json().then(data => { 1334 // IMMEDIATE CHAT MODE UPDATE for JSON response 1335 if (data.chat_mode) { 1336 updateChatModeIndicator(data.chat_mode, botId); 1337 } 1338 1339 // Check for testing panel 1340 if (window.mxchatTestPanelInstance && data.testing_data) { 1341 window.mxchatTestPanelInstance.handleTestingData(data.testing_data); 1342 } 1343 1344 // Handle the JSON response directly 1345 handleNonStreamResponse(data, callback, botId); 1346 return Promise.resolve(); // Prevent further processing 1347 }); 1348 } 1349 1350 // Continue with streaming processing 1351 const reader = response.body.getReader(); 1352 const decoder = new TextDecoder(); 1353 let buffer = ''; 1354 1355 function processStream() { 1356 reader.read().then(({ done, value }) => { 1357 if (done) { 1358 // If streaming completed but no content was received, try to get response as fallback 1359 if (!streamingStarted || !accumulatedContent) { 1360 // Try to read the response as JSON 1361 responseClone.text().then(text => { 1362 try { 1363 const data = JSON.parse(text); 1364 if (data.text || data.message || data.html) { 1365 handleNonStreamResponse(data, callback, botId); 1366 } else { 1367 // No valid data, fall back to regular call 1368 getElement(botId, 'chat-box').find('.bot-message.temporary-message').remove(); 1369 callMxChat(message, callback, botId); 1370 } 1371 } catch (e) { 1372 // Could not parse, fall back to regular call 1373 getElement(botId, 'chat-box').find('.bot-message.temporary-message').remove(); 1374 callMxChat(message, callback, botId); 1375 } 1376 }).catch(() => { 1377 getElement(botId, 'chat-box').find('.bot-message.temporary-message').remove(); 1378 callMxChat(message, callback, botId); 1379 }); 1380 return; 1381 } 1382 1383 // Re-enable chat input when stream ends with content 1384 enableChatInput(botId); 1385 1386 // Scroll the user's last message to the top now that the 1387 // bot's full reply has rendered (gives max reading room). 1388 var $chatBoxDone = getElement(botId, 'chat-box'); 1389 var $lastUserMsgDone = $chatBoxDone.find('.user-message').last(); 1390 if ($lastUserMsgDone.length) { 1391 scrollElementToTop($lastUserMsgDone, botId); 1392 } 1393 1394 if (callback) { 1395 callback(accumulatedContent); 1396 } 1397 return; 1398 } 1399 1400 buffer += decoder.decode(value, { stream: true });
1401 const lines = buffer.split('\n'); 1402 buffer = lines.pop() || ''; 1403 1404 for (const line of lines) { 1405 if (line.startsWith('data: ')) { 1406 const data = line.substring(6); 1407 1408 if (data === '[DONE]') { 1409 if (!accumulatedContent) { 1410 getElement(botId, 'chat-box').find('.bot-message.temporary-message').remove(); 1411 callMxChat(message, callback, botId); 1412 return; 1413 } 1414 1415 // Re-enable chat input after streaming completes 1416 enableChatInput(botId); 1417 1418 // Render any server-pushed appendix html (e.g. the 1419 // YouTube embed) as its own bot bubble below the 1420 // streamed text â mirrors how it is saved in the 1421 // transcript, so history replays identically. 1422 if (pendingAppendHtml) { 1423 appendMessage("bot", "", pendingAppendHtml, [], false, botId); 1424 pendingAppendHtml = ''; 1425 } 1426 1427 // Scroll the user's last message to the top now 1428 // that the bot's full reply has rendered. 1429 var $chatBoxStreamDone = getElement(botId, 'chat-box'); 1430 var $lastUserMsgStreamDone = $chatBoxStreamDone.find('.user-message').last(); 1431 if ($lastUserMsgStreamDone.length) { 1432 scrollElementToTop($lastUserMsgStreamDone, botId); 1433 } 1434 1435 if (callback) { 1436 callback(accumulatedContent); 1437 } 1438 return; 1439 } 1440 1441 try { 1442 const json = JSON.parse(data); 1443 1444 // IMMEDIATE CHAT MODE UPDATE FOR STREAMING 1445 if (json.chat_mode) { 1446 updateChatModeIndicator(json.chat_mode, botId); 1447 } 1448 1449 // Handle testing data 1450 if (json.testing_data && !testingDataReceived) { 1451 if (window.mxchatTestPanelInstance) { 1452 window.mxchatTestPanelInstance.handleTestingData(json.testing_data); 1453 testingDataReceived = true; 1454 } 1455 } 1456 // Handle content streaming 1457 else if (json.content) { 1458 streamingStarted = true; 1459 accumulatedContent += json.content;
1460 updateStreamingMessage(accumulatedContent, botId); 1461 } 1462 // Stash appendix html (e.g. video embed) for [DONE] 1463 else if (json.append_html) { 1464 pendingAppendHtml = json.append_html; 1465 } 1466 // Server-side final pass changed the assembled text 1467 // (ffef6f: dead-link stripping) â swap the rendered 1468 // bubble for the validated version. Arrives at most 1469 // once, just before [DONE]. 1470 else if (json.replace_content) { 1471 streamingStarted = true; 1472 accumulatedContent = json.replace_content; 1473 updateStreamingMessage(accumulatedContent, botId); 1474 } 1475 // Handle complete response in stream (fallback response) 1476 else if (json.text || json.message || json.html) { 1477 handleNonStreamResponse(json, callback, botId); 1478 return; 1479 } 1480 // Handle errors 1481 else if (json.error) { 1482 1483 // Get error message from various possible fields 1484 let errorMessage = json.error_message || json.message || json.text || 1485 (typeof json.error === 'string' ? json.error : 'An error occurred. Please try again.'); 1486 1487 // Re-enable chat input on error 1488 enableChatInput(botId); 1489 1490 // Display the error directly in the chat 1491 replaceLastMessage("bot", errorMessage, '', [], botId); 1492 1493 if (callback) { 1494 callback(errorMessage); 1495 } 1496 return; 1497 } 1498 } catch (e) { 1499 // SSE data parsing error - silently continue 1500 } 1501 } 1502 } 1503 1504 processStream(); 1505 }).catch(streamError => { 1506 if (mxchatHandleStreamAbort(botId, accumulatedContent, callback)) return; 1507 getElement(botId, 'chat-box').find('.bot-message.temporary-message').remove(); 1508 callMxChat(message, callback, botId); 1509 }); 1510 } 1511 1512 processStream(); 1513 }) 1514 .catch(error => { 1515 if (mxchatHandleStreamAbort(botId, accumulatedContent, callback)) return; 1516 // Check if we have server error data with chat mode 1517 if (error && error.isServerError && error.data) { 1518 // Check for chat mode in error data 1519 if (error.data.chat_mode) { 1520 updateChatModeIndicator(error.data.chat_mode, botId); 1521 } 1522 1523 handleNonStreamResponse(error.data, callback, botId); 1524 } else { 1525 // Only fall back to regular call if we don't have any response data 1526 getElement(botId, 'chat-box').find('.bot-message.temporary-message').remove(); 1527 callMxChat(message, callback, botId); 1528 } 1529 }); 1530 }); // refreshNonceIfNeeded 1531} 1532 1533// Helper function to handle non-streaming responses 1534function handleNonStreamResponse(data, callback, botId) { 1535 botId = botId || 'default'; 1536 1537 // IMMEDIATE CHAT MODE UPDATE FOR NON-STREAMING RESPONSES 1538 if (data.chat_mode) { 1539 updateChatModeIndicator(data.chat_mode, botId); 1540 } 1541 1542 // Also check in data property if response is wrapped 1543 if (data.data && data.data.chat_mode) { 1544 updateChatModeIndicator(data.data.chat_mode, botId); 1545 } 1546 1547 // NOTE: Don't remove temporary message here - let replaceLastMessage handle it 1548 // This prevents a visual gap between thinking dots disappearing and content appearing 1549 1550 // SECURITY FIX: Check for errors FIRST 1551 if (data.success === false || (data.data && data.data.error_message)) { 1552 let errorMessage = ""; 1553 let errorCode = ""; 1554 1555 // Check various possible error locations 1556 if (data.data && data.data.error_message) { 1557 errorMessage = data.data.error_message; 1558 errorCode = data.data.error_code || ""; 1559 } else if (data.error_message) { 1560 errorMessage = data.error_message; 1561 errorCode = data.error_code || ""; 1562 } else if (data.message) { 1563 errorMessage = data.message; 1564 } else if (typeof data.data === 'string') { 1565 errorMessage = data.data; 1566 } else { 1567 errorMessage = "An error occurred. Please try again or c
1567ontact support."; 1568 } 1569 1570 // Handle session reset action (IP changed, session expired, etc.) 1571 // Silent reset â keep chat UI intact, just get a new session and retry 1572 if (data.data && data.data.action === 'reset_session') { 1573 MxChatInstances.silentResetSession(botId); 1574 // Re-send the original message with the new session (user message is already displayed) 1575 var originalMessage = getElement(botId, 'mxchat-chatbot-wrapper').find('.mxchat-input-holder textarea').data('pending-message'); 1576 if (originalMessage) { 1577 getElement(botId, 'mxchat-chatbot-wrapper').find('.mxchat-input-holder textarea').data('pending-message', null); 1578 var currentModel = mxchatChat.model || 'gpt-5.6-sol'; 1579 if (shouldUseStreaming(currentModel)) { 1580 callMxChatStream(originalMessage, callback, botId); 1581 } else { 1582 callMxChat(originalMessage, callback, botId); 1583 } 1584 } 1585 return; 1586 } 1587 1588 // Format user-friendly error message 1589 let displayMessage = errorMessage; 1590 if (mxchatChat.is_admin) { 1591 displayMessage = errorMessage + (errorCode ? " (Error code: " + errorCode + ")" : ""); 1592 } 1593 1594 replaceLastMessage("bot", displayMessage, '', [], botId); 1595 1596 if (callback) { 1597 callback(''); 1598 } 1599 return; // Exit early for errors 1600 } 1601 1602 // Check for live agent response 1603 if (data.success && data.data && data.data.status === 'waiting_for_agent') { 1604 removeThinkingDots(botId); 1605 // Also remove any leftover bot-message that lost its temporary-message class 1606 var $chatBox = getElement(botId, 'chat-box'); 1607 $chatBox.find('.bot-message .thinking-dots').closest('.bot-message').remove(); 1608 updateChatModeIndicator('agent', botId); 1609 enableChatInput(botId); 1610 if (callback) { 1611 callback(''); 1612 } 1613 return; 1614 } 1615 1616 // Handle different response formats 1617 if (data.text || data.html || data.message) { 1618 1619 // Apply response hooks 1620 if (data.text && typeof customMxChatFilter === 'function') { 1621 data.text = customMxChatFilter(data.text, "response"); 1622 } 1623 if (data.message && typeof customMxChatFilter === 'function') { 1624 data.message = customMxChatFilter(data.message, "response"); 1625 } 1626 1627 // Display the response 1628 if (data.text && data.html) { 1629 replaceLastMessage("bot", data.text, data.html, [], botId); 1630 } else if (data.text) { 1631 replaceLastMessage("bot", data.text, '', [], botId); 1632 } else if (data.html) { 1633 replaceLastMessage("bot", "", data.html, [], botId); 1634 } else if (data.message) { 1635 replaceLastMessage("bot", data.message, '', [], botId); 1636 } 1637 } 1638 1639 // Handle other response properties 1640 if (data.data && data.data.filename) { 1641 showActivePdf(data.data.filename, botId); 1642 var instance = MxChatInstances.get(botId); 1643 instance.activePdfFile = data.data.filename; 1644 } 1645 1646 if (data.redirect_url) { 1647 setTimeout(() => { 1648 window.location.href = data.redirect_url; 1649 }, 1500); 1650 } 1651 1652 // Ensure chat input is re-enabled (safety net for edge cases) 1653 enableChatInput(botId); 1654 1655 if (callback) { 1656 callback(data.text || data.message || ''); 1657 } 1658} 1659 1660// Enhanced updateChatModeIndicator function for immediate DOM updates 1661function updateChatModeIndicator(mode, botId) { 1662 botId = botId || 'default'; 1663 const indicator = getElementDOM(botId, 'chat-mode-indicator'); 1664 if (indicator) { 1665 const oldText = indicator.textContent; 1666 1667 if (mode === 'agent') { 1668 indicator.textContent = 'Live Agent'; 1669 startPolling(botId); 1670 } else { 1671 // Everything else is AI mode 1672 const customAiText = indicator.getAttribute('data-ai-text') || 'AI Agent'; 1673 indicator.textContent = customAiText; 1674 stopPolling(botId); 1675 } 1676 1677 // Force immediate DOM update and reflow 1678 if (oldText !== indicator.textContent) { 1679 // Force a reflow to ensure the change is visible immediately 1680 indicator.style.display = 'none'; 1681 indicator.offsetHeight; // Trigger reflow 1682 indicator.style.display = ''; 1683 1684 // Double-check after a brief moment to ensure the change stuck 1685 setTimeout(() => { 1686 if (mode === 'agent' && indicator.textContent !== 'Live Agent') { 1687 indicator.textContent = 'Live Agent'; 1688 } else if (mode !== 'agent' && indicator.textContent === 'Live Agent') { 1689 const customAiText = indicator.getAttribute('data-ai-text') || 'AI Agent'; 1690 indicator.textContent = customAiText; 1691 } 1692 }, 50); 1693 } 1694 } 1695} 1696 1697// Function to update message during streaming 1698function updateStreamingMessage(content, botId) { 1699 botId = botId || 'default'; 1700 1701 // ADD RESPONSE HOOK FOR REAL-TIME STREAMING 1702 if (typeof customMxChatFilter === 'function') { 1703 content = customMxChatFilter(content, "response"); 1704 } 1705 1706 const formattedContent = linkify(content); 1707 1708 // Find the temporary message in this bot's chat box 1709 var $chatBox = getElement(botId, 'chat-box'); 1710 const tempMessage = $chatBox.find('.bot-message.temporary-message').last(); 1711 1712 if (tempMessage.length) { 1713 // aria-busy=true for the whole stream: the bubble is rewritten on 1714 // every chunk, and without busy a polite live region announces those 1715 // rewrites continuously. Flipped false once the reply is final, so 1716 // assistive tech announces the completed message ONCE (plan 67f126). 1717 if (tempMessage.attr('aria-busy') !== 'true') { 1718 tempMessage.attr('aria-busy', 'true'); 1719 } 1720 // Update existing message 1721 tempMessage.html(formattedContent); 1722 } else { 1723 // Create new temporary message if it doesn't exist 1724 appendMessage("bot", content, '', [], true, botId); 1725 } 1726} 1727 1728function isStreamingSupported(model) { 1729 if (!model) return false; 1730 1731 const modelPrefix = model.split('-')[0].toLowerCase(); 1732
1733 // Support streaming for OpenAI, Claude, Grok, DeepSeek, and OpenRouter models 1734 const isSupported = modelPrefix === 'gpt' || 1735 modelPrefix === 'o1' || 1736 modelPrefix === 'claude' || 1737 modelPrefix === 'grok' || 1738 modelPrefix === 'deepseek' || 1739 model === 'openrouter'; // Add this line - check full model name for OpenRouter 1740 1741 return isSupported; 1742} 1743 1744// Update the event handlers to use the correct function names (using event delegation) 1745// Use class-based selectors for multi-instance support 1746$(document).on('click', '.send-button', function() { 1747 var botId = getBotIdFromElement(this); 1748 // While a response is streaming the button is a Stop control. 1749 if (this.classList.contains('mxchat-stop-mode')) { 1750 // Same click that just started this stream (an add-on's direct handler 1751 // ran before this delegated one) â not a Stop press. See 1752 // mxchatShowStopButton for the full story (plan-4bba64). 1753 if (this.__mxchatStopJustShown) return; 1754 mxchatStopStreaming(botId); 1755 return; 1756 } 1757 var modeIndicator = getElementDOM(botId, 'chat-mode-indicator'); 1758 if (!(modeIndicator && modeIndicator.textContent === 'Live Agent')) { 1759 disableChatInput(botId); 1760 } 1761 sendMessage(botId); 1762}); 1763 1764// Override enter key handler (using event delegation) 1765$(document).on('keypress', '.chat-input', function(e) { 1766 if (e.which == 13 && !e.shiftKey) { 1767 e.preventDefault(); 1768 var botId = getBotIdFromElement(this); 1769 var modeIndicator = getElementDOM(botId, 'chat-mode-indicator'); 1770 if (!(modeIndicator && modeIndicator.textContent === 'Live Agent')) { 1771 disableChatInput(botId); 1772 } 1773 sendMessage(botId); 1774 } 1775}); 1776 1777// Chat input character counter + soft limit feedback (plan 7091a2). 1778// Language-neutral: numbers + color only, no translatable strings. The counter 1779// reveals near the cap and ramps neutral -> amber -> red; an over-limit keystroke 1780// or trimmed paste produces a brief border-flash/shake so the maxlength cap (plan 1781// a3fae2) is never a silent "input jumps back". Per-bot scoped via .input-container. 1782function mxchatUpdateCharCounter(inputEl) { 1783 if (!inputEl || !inputEl.closest) return; 1784 var max = parseInt(inputEl.getAttribute('maxlength'), 10); 1785 var container = inputEl.closest('.input-container'); 1786 if (!container || !max || max <= 0) return; 1787 var counter = container.querySelector('.mxchat-char-counter'); 1788 if (!counter) return; 1789 var len = inputEl.value.length; 1790 var ratio = len / max; 1791 var nearThreshold = 0.8; // start surfacing the counter at 80% of the cap 1792 var cur = counter.querySelector('.mxchat-char-counter-current'); 1793 if (cur) cur.textContent = len; 1794 var warn = ratio >= nearThreshold && len < max; 1795 var full = len >= max; 1796 counter.classList.toggle('is-visible', ratio >= nearThreshold); 1797 counter.classList.toggle('is-warn', warn); 1798 counter.classList.toggle('is-full', full); 1799 container.classList.toggle('mxchat-input-near-limit', warn); 1800 container.classList.toggle('mxchat-input-at-limit', full); 1801} 1802 1803function mxchatBumpInput(inputEl) { 1804 var container = inputEl && inputEl.closest ? inputEl.closest('.input-container') : null; 1805 if (!container) return; 1806 container.classList.remove('mxchat-input-bump'); 1807 void container.offsetWidth; // reflow so a rapid second hit retriggers the animation 1808 container.classList.add('mxchat-input-bump'); 1809 clearTimeout($(container).data('mxchatBumpTimeout')); 1810 var t = setTimeout(function() { container.classList.remove('mxchat-input-bump'); }, 220); 1811 $(container).data('mxchatBumpTimeout', t); 1812} 1813 1814// Live counter update on every input. 1815$(document).on('input', '.chat-input', function() { 1816 mxchatUpdateCharCounter(this); 1817}); 1818 1819// Visible "you've hit the edge" feedback when a printable keystroke is about to be 1820// rejected at the cap (maxlength silently swallows it otherwise). 1821$(document).on('keydown', '.chat-input', function(e) { 1822 var max = parseInt(this.getAttribute('maxlength'), 10); 1823 if (!max || max <= 0 || this.value.length < max) return; 1824 if (e.ctrlKey || e.metaKey || e.altKey) return; 1825 // A single printable char with no selection to overwrite WILL be rejected. 1826 if (e.key && e.key.length === 1 && this.selectionStart === this.selectionEnd) { 1827 mxchatBumpInput(this); 1828 } 1829}); 1830 1831// A paste that gets trimmed to the cap also bumps, so truncation is never silent.
1832$(document).on('paste', '.chat-input', function() { 1833 var el = this; 1834 var max = parseInt(el.getAttribute('maxlength'), 10); 1835 if (!max || max <= 0) return; 1836 setTimeout(function() { 1837 mxchatUpdateCharCounter(el); 1838 if (el.value.length >= max) mxchatBumpInput(el); 1839 }, 0); 1840}); 1841 1842// Builds the list of overflow-menu items for a given bot. 1843// Adding a future item is one push to this array â do NOT hardcode "only download." 1844function mxchatGetHeaderMenuItems(botId) { 1845 var items = []; 1846 var settings = (typeof mxchatChat !== 'undefined') ? mxchatChat : {}; 1847 1848 // The `print_button_*` keys still gate this item for back-compat with 1849 // existing user options. The action is now a transcript download, not print. 1850 if (settings.print_button_enabled === 'on') { 1851 items.push({ 1852 id: 'download-transcript', 1853 label: settings.print_button_label || 'Download Transcript', 1854 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>', 1855 action: function() { 1856 mxchatDownloadTranscript(botId); 1857 } 1858 }); 1859 } 1860 1861 // "Start new chat" â surfaces the EXISTING per-conversation reset 1862 // (MxChatInstances.resetChatSession) so a visitor can start a fresh thread 1863 // without the site owner disabling chat persistence globally. Default OFF; 1864 // gated by the reset_chat_enabled option. plan ac2e81. 1865 if (settings.reset_chat_enabled === 'on') { 1866 items.push({ 1867 id: 'reset-chat', 1868 label: settings.reset_chat_label || 'Start new chat', 1869 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>', 1870 action: function() { 1871 var confirmMsg = settings.reset_chat_confirm || 'Start a new chat? This clears the current conversation.'; 1872 if (window.confirm(confirmMsg)) { 1873 MxChatInstances.resetChatSession(botId); 1874 } 1875 } 1876 }); 1877 } 1878 1879 return items; 1880} 1881 1882// Builds a clean markdown transcript of the current conversation and triggers 1883// a file download. Used by the "Download Transcript" menu item. 1884function mxchatDownloadTranscript(botId) { 1885 var $chatBox = getElement(botId, 'chat-box'); 1886 if (!$chatBox || !$chatBox.length) return; 1887 1888 var settings = (typeof mxchatChat !== 'undefined') ? mxchatChat : {}; 1889 var headerTitle = settings.print_header_title || 'Chat transcript'; 1890 var now = new Date(); 1891 var stamp = now.toLocaleString(); 1892 1893 var lines = []; 1894 lines.push('# ' + headerTitle); 1895 lines.push(''); 1896 lines.push('Exported: ' + stamp); 1897 lines.push(''); 1898 lines.push('---'); 1899 lines.push(''); 1900 1901 $chatBox.find('.user-message, .bot-message, .agent-message').each(function() { 1902 var $msg = $(this); 1903 // Skip thinking placeholders and any in-flight temporary messages. 1904 if ($msg.find('.thinking-dots').length) return; 1905 if ($msg.hasClass('temporary-message')) return; 1906 1907 var sender; 1908 if ($msg.hasClass('user-message')) sender = 'User'; 1909 else if ($msg.hasClass('agent-message')) sender = 'Live Agent'; 1910 else sender = 'AI Agent'; 1911 1912 // Strip interactive UI from the cloned message so we get the conversation text. 1913 var $clone = $msg.clone(); 1914 $clone.find('.copy-button, .message-toolbar, .mxchat-copy, button, script, style').remove(); 1915 var text = $clone.text().replace(/Â /g, ' ').replace(/[ \t]+\n/g, '\n').replace(/\n{3,}/g, '\n\n').trim(); 1916 if (!text) return; 1917 1918 lines.push('**' + sender + '**'); 1919 lines.push(''); 1920 lines.push(text); 1921 lines.push(''); 1922 });
1923 1924 var content = lines.join('\n'); 1925 var iso = now.toISOString().replace(/[:.]/g, '-').slice(0, 19); 1926 var fname = 'mxchat-transcript-' + iso + '.md'; 1927 var blob = new Blob([content], { type: 'text/markdown;charset=utf-8' }); 1928 var url = URL.createObjectURL(blob); 1929 var a = document.createElement('a'); 1930 a.href = url; 1931 a.download = fname; 1932 a.style.display = 'none'; 1933 document.body.appendChild(a); 1934 a.click(); 1935 setTimeout(function() { 1936 if (a.parentNode) a.parentNode.removeChild(a); 1937 URL.revokeObjectURL(url); 1938 }, 100); 1939} 1940 1941// Reads the bot bubble's actual computed bg+fg and writes them as CSS vars 1942// on the menu wrap, so the dropdown matches whatever paints the bubble â 1943// saved options, AI theme CSS, or the mxchat-theme add-on. 1944function mxchatSyncMenuColors(botId, $wrap) { 1945 if (!$wrap || !$wrap.length) return; 1946 var $bot = $wrap.closest('.mxchat-chatbot-wrapper').find('.bot-message').not('.temporary-message').first(); 1947 if (!$bot.length) return; 1948 var cs = window.getComputedStyle($bot[0]); 1949 if (cs.backgroundColor && cs.backgroundColor !== 'rgba(0, 0, 0, 0)' && cs.backgroundColor !== 'transparent') { 1950 $wrap[0].style.setProperty('--mxchat-menu-bg', cs.backgroundColor); 1951 } 1952 // Bot text color usually lives on a child div, not .bot-message itself. 1953 var $textChild = $bot.find('[style*="color"]').first(); 1954 var fg = ($textChild.length ? window.getComputedStyle($textChild[0]).color : cs.color); 1955 if (fg) $wrap[0].style.setProperty('--mxchat-menu-fg', fg); 1956} 1957 1958// Renders (or re-renders) the item list for one menu wrap. Split out of 1959// mxchatInitHeaderMenu so the dynamic-settings merge (plan-32db95) can 1960// rebuild items + trigger visibility WITHOUT re-binding the one-time 1961// open/close/keyboard wiring. closeMenu is passed in by the init closure; 1962// a rebuild before init (never happens, but harmless) just skips it. 1963function mxchatRenderHeaderMenuItems(botId, $wrap, closeMenuFn) { 1964 var $trigger = $wrap.find('.mxchat-menu-trigger'); 1965 var $menu = $wrap.find('.mxchat-header-menu'); 1966 var items = mxchatGetHeaderMenuItems(botId); 1967 1968 $menu.empty(); 1969 1970 if (!items.length) { 1971 $trigger.hide(); 1972 $menu.hide(); 1973 return; 1974 } 1975 1976 // Clear any inline display:none a previous zero-item render left behind â 1977 // open/close visibility is governed by the hidden prop + is-open class. 1978 $trigger.css('display', ''); 1979 $menu.css('display', ''); 1980 1981 items.forEach(function(item, idx) { 1982 var $btn = $('<button>', { 1983 type: 'button', 1984 'class': 'mxchat-menu-item', 1985 'role': 'menuitem', 1986 'tabindex': '-1', 1987 'data-menu-id': item.id, 1988 html: '<span class="mxchat-menu-item-icon">' + item.icon + '</span>' + 1989 '<span class="mxchat-menu-item-label"></span>' 1990 }); 1991 $btn.find('.mxchat-menu-item-label').text(item.label); 1992 $btn.on('click', function(e) { 1993 e.preventDefault(); 1994 e.stopPropagation(); 1995 if (closeMenuFn) closeMenuFn(); 1996 try { item.action(); } catch (err) { /* no-op */ } 1997 }); 1998 $menu.append($btn); 1999 }); 2000} 2001 2002// Re-render every menu on the page after a dynamic-settings merge 2003// (multi-bot: each wrap re-reads its items). An OPEN menu is left alone â 2004// swapping items under the user mid-interaction yanks focus â and the 2005// rebuild runs when it closes instead (closeMenu checks the pending flag). 2006function mxchatRebuildHeaderMenus() { 2007 $('.mxchat-header-menu-wrap').each(function() { 2008 var $wrap = $(this); 2009 var botId = $wrap.data('bot-id'); 2010 if (!botId) return; 2011 if (!$wrap.data('mxchatMenuReady')) { 2012 mxchatInitHeaderMenu(botId); 2013 return; 2014 } 2015 if ($wrap.find('.mxchat-header-menu').hasClass('is-open')) { 2016 $wrap.data('mxchatMenuRebuildPending', true); 2017 return; 2018 } 2019 mxchatRenderHeaderMenuItems(botId, $wrap, $wrap.data('mxchatMenuClose')); 2020 });
2021} 2022 2023// One-time per-widget init: renders menu items, wires open/close, 2024// outside-click, Escape, and arrow-key navigation. If no items, hides the 2025// trigger. Wiring happens even when there are zero items at init, so a 2026// later dynamic-settings rebuild that adds items has a working trigger. 2027function mxchatInitHeaderMenu(botId) { 2028 var $wrap = $('.mxchat-header-menu-wrap[data-bot-id="' + botId + '"]').first(); 2029 if (!$wrap.length || $wrap.data('mxchatMenuReady')) return; 2030 2031 var $trigger = $wrap.find('.mxchat-menu-trigger'); 2032 var $menu = $wrap.find('.mxchat-header-menu'); 2033 2034 // Initial color sync â covers normal page load. 2035 mxchatSyncMenuColors(botId, $wrap); 2036 2037 function openMenu() { 2038 // Re-sync each open in case the active theme changed since init. 2039 mxchatSyncMenuColors(botId, $wrap); 2040 $menu.prop('hidden', false).attr('aria-hidden', 'false').addClass('is-open'); 2041 $trigger.attr('aria-expanded', 'true'); 2042 // Focus the first item for keyboard users 2043 setTimeout(function() { 2044 $menu.find('.mxchat-menu-item').first().attr('tabindex', '0').trigger('focus'); 2045 }, 0); 2046 } 2047 function closeMenu(returnFocus) { 2048 $menu.prop('hidden', true).attr('aria-hidden', 'true').removeClass('is-open'); 2049 $trigger.attr('aria-expanded', 'false'); 2050 $menu.find('.mxchat-menu-item').attr('tabindex', '-1'); 2051 if (returnFocus) $trigger.trigger('focus'); 2052 // A dynamic-settings rebuild that arrived while the menu was open 2053 // was deferred (mxchatRebuildHeaderMenus) â run it now. 2054 if ($wrap.data('mxchatMenuRebuildPending')) { 2055 $wrap.removeData('mxchatMenuRebuildPending'); 2056 mxchatRenderHeaderMenuItems(botId, $wrap, closeMenu); 2057 } 2058 } 2059 2060 // Toggle on trigger click â stop propagation so the .chatbot-top-bar 2061 // click-to-collapse handler does not fire. 2062 $trigger.on('click', function(e) { 2063 e.preventDefault(); 2064 e.stopPropagation(); 2065 if ($menu.hasClass('is-open')) closeMenu(); 2066 else openMenu(); 2067 }); 2068 2069 // Don't let clicks inside the menu bubble to the top-bar collapse handler. 2070 $menu.on('click', function(e) { 2071 e.stopPropagation(); 2072 }); 2073 2074 // Outside click closes the menu. 2075 $(document).on('click.mxchatMenu-' + botId, function(e) { 2076 if (!$menu.hasClass('is-open')) return; 2077 if ($wrap.has(e.target).length || $wrap.is(e.target)) return; 2078 closeMenu(); 2079 }); 2080 2081 // Keyboard: Escape closes and returns focus; arrow keys move focus; Enter activates. 2082 $menu.on('keydown', '.mxchat-menu-item', function(e) { 2083 var $items = $menu.find('.mxchat-menu-item'); 2084 var idx = $items.index(this); 2085 if (e.key === 'Escape') { 2086 e.preventDefault(); 2087 closeMenu(true); 2088 } else if (e.key === 'ArrowDown') { 2089 e.preventDefault(); 2090 var $next = $items.eq((idx + 1) % $items.length); 2091 $items.attr('tabindex', '-1'); 2092 $next.attr('tabindex', '0').trigger('focus'); 2093 } else if (e.key === 'ArrowUp') { 2094 e.preventDefault(); 2095 var $prev = $items.eq((idx - 1 + $items.length) % $items.length); 2096 $items.attr('tabindex', '-1'); 2097 $prev.attr('tabindex', '0').trigger('focus'); 2098 } else if (e.key === 'Enter' || e.key === ' ') { 2099 e.preventDefault(); 2100 $(this).trigger('click'); 2101 } 2102 }); 2103 $trigger.on('keydown', function(e) { 2104 if (e.key === 'Escape' && $menu.hasClass('is-open')) { 2105 e.preventDefault(); 2106 closeMenu(true); 2107 } else if ((e.key === 'ArrowDown' || e.key === 'Enter' || e.key === ' ') && !$menu.hasClass('is-open')) { 2108 e.preventDefault(); 2109 openMenu(); 2110 } 2111 }); 2112 2113 // Expose closeMenu for out-of-closure re-renders (mxchatRebuildHeaderMenus), 2114 // then do the initial item render. 2115 $wrap.data('mxchatMenuClose', closeMenu); 2116 mxchatRenderHeaderMenuItems(botId, $wrap, closeMenu); 2117 2118 $wrap.data('mxchatMenuReady', true); 2119} 2120 2121// Initialize header menus for every rendered widget on DOM ready. 2122$(function() { 2123 $('.mxchat-header-menu-wrap').each(function() { 2124 var botId = $(this).data('bot-id'); 2125 if (botId) mxchatInitHeaderMenu(botId); 2126 });
2127 2128 // Embedded (non-floating) widgets are open from the moment the page 2129 // renders â refresh dynamic settings at init (plan-32db95). Floating 2130 // widgets refresh on first launcher open instead. 2131 var hasEmbeddedWidget = $('.mxchat-chatbot-wrapper').filter(function() { 2132 return !$(this).closest('.floating-chatbot').length; 2133 }).length > 0; 2134 if (hasEmbeddedWidget) { 2135 mxchatRefreshDynamicSettings(); 2136 } 2137}); 2138 2139function appendMessage(sender, messageText = '', messageHtml = '', images = [], isTemporary = false, botId = 'default') { 2140 try { 2141 // Determine styles based on sender type 2142 let messageClass, bgColor, fontColor; 2143 2144 if (sender === "user") { 2145 messageClass = "user-message"; 2146 bgColor = userMessageBgColor; 2147 fontColor = userMessageFontColor; 2148 // Only sanitize user input 2149 messageText = sanitizeUserInput(messageText); 2150 } else if (sender === "agent") { 2151 messageClass = "agent-message"; 2152 bgColor = liveAgentMessageBgColor; 2153 fontColor = liveAgentMessageFontColor; 2154 } else { 2155 messageClass = "bot-message"; 2156 bgColor = botMessageBgColor; 2157 fontColor = botMessageFontColor; 2158 } 2159 2160 const messageDiv = $('<div>') 2161 .addClass(messageClass) 2162 .attr('dir', 'auto'); 2163 2164 // Only apply inline colors if AI theme is not active (let CSS handle it) 2165 var skipColors = shouldSkipInlineColors(botId); 2166 if (skipColors) { 2167 messageDiv.css({ 2168 'margin-bottom': '1em' 2169 }); 2170 } else { 2171 messageDiv.css({ 2172 'background': bgColor, 2173 'color': fontColor, 2174 'margin-bottom': '1em' 2175 }); 2176 } 2177 2178 // Process the message content - always run linkify to convert markdown 2179 // links and format text. linkify() handles existing HTML safely via 2180 // negative lookaheads that skip URLs already inside <a> tags. 2181 let fullMessage = linkify(messageText); 2182 2183 // Add images if provided 2184 if (images && images.length > 0) { 2185 fullMessage += '<div class="image-gallery" dir="auto">'; 2186 images.forEach(img => { 2187 const safeTitle = sanitizeUserInput(img.title); 2188 const safeUrl = encodeURI(img.image_url); 2189 const safeThumbnail = encodeURI(img.thumbnail_url); 2190 2191 fullMessage += ` 2192 <div style="margin-bottom: 10px;"> 2193 <strong>${safeTitle}</strong><br> 2194 <a href="${safeUrl}" target="_blank"> 2195 <img src="${safeThumbnail}" alt="${safeTitle}" style="max-width: 100px; height: auto; margin: 5px;" /> 2196 </a> 2197 </div>`; 2198 }); 2199 fullMessage += '</div>'; 2200 } 2201 2202 // Append HTML content if provided 2203 if (messageHtml && sender !== "user") { 2204 // Only add line breaks if there's actual text content before the HTML 2205 if (fullMessage && fullMessage.trim()) { 2206 fullMessage += '<br><br>' + messageHtml; 2207 } else { 2208 fullMessage = messageHtml; 2209 } 2210 } 2211 2212 messageDiv.html(fullMessage); 2213 2214 if (isTemporary) { 2215 // In-flight bubble: hold aria-busy so the live region stays quiet 2216 // until the content is finalized (plan 67f126). 2217 messageDiv.addClass('temporary-message').attr('aria-busy', 'true'); 2218 } 2219 2220 // Append to the correct chatbot instance's chat-box 2221 var $chatBox = getElement(botId, 'chat-box'); 2222 messageDiv.hide().appendTo($chatBox).fadeIn(300, function() { 2223 // FIXED: Use event delegation for link tracking 2224 if (sender === "bot" || sender === "agent") { 2225 attachLinkTracking(messageDiv, messageText, botId); 2226 } 2227 2228 if (sender === "bot") { 2229 const lastUserMessage = $chatBox.find('.user-message').last(); 2230 if (lastUserMessage.length) { 2231 scrollElementToTop(lastUserMessage, botId); 2232 } 2233 } 2234 2235 if ((sender === "bot" || sender === "agent") && !isTemporary) { 2236 if (typeof mxchatInitHeaderMenu === 'function') mxchatInitHeaderMenu(botId); 2237 } 2238 }); 2239 2240 if (messageText.id) {
2241 var instance = MxChatInstances.get(botId); 2242 instance.lastSeenMessageId = messageText.id; 2243 hideNotification(botId); 2244 } 2245 } catch (error) { 2246 // Error rendering message - silently continue 2247 } 2248} 2249 2250// Helper function to attach link tracking with proper event handling 2251function attachLinkTracking(messageDiv, messageText, botId) { 2252 botId = botId || 'default'; 2253 // Use a slight delay to ensure DOM is ready 2254 setTimeout(function() { 2255 const links = messageDiv.find('a[href]').not('[data-tracked]'); 2256 2257 links.each(function() { 2258 const $link = $(this); 2259 const originalHref = $link.attr('href'); 2260 2261 // Mark as tracked to avoid duplicate handlers 2262 $link.attr('data-tracked', 'true'); 2263 2264 // Only track external URLs 2265 if (originalHref && (originalHref.startsWith('http://') || originalHref.startsWith('https://'))) { 2266 // Remove any existing click handlers first 2267 $link.off('click.tracking'); 2268 2269 // Add new click handler with namespace 2270 $link.on('click.tracking', function(e) { 2271 e.preventDefault(); 2272 e.stopPropagation(); 2273 2274 const messageContext = typeof messageText === 'string' 2275 ? messageText.substring(0, 200) 2276 : ''; 2277 2278 // Track the click 2279 $.ajax({
2280 url: mxchatChat.ajax_url, 2281 type: 'POST', 2282 data: { 2283 action: 'mxchat_track_url_click', 2284 session_id: getChatSession(botId), 2285 url: originalHref, 2286 message_context: messageContext, 2287 nonce: mxchatChat.nonce 2288 }, 2289 complete: function() { 2290 // Always redirect, even if tracking fails 2291 if ($link.attr('target') === '_blank' || linkTarget === '_blank') { 2292 window.open(originalHref, '_blank'); 2293 } else { 2294 window.location.href = originalHref; 2295 } 2296 } 2297 }); 2298 2299 return false; // Extra insurance to prevent default 2300 }); 2301 } 2302 }); 2303 }, 100); // Small delay to ensure DOM is ready 2304} 2305 2306function replaceLastMessage(sender, responseText, responseHtml = '', images = [], botId = 'default') { 2307 var messageClass = sender === "user" ? "user-message" : sender === "agent" ? "agent-message" : "bot-message"; 2308 var $chatBox = getElement(botId, 'chat-box'); 2309 var lastMessageDiv = $chatBox.find('.bot-message.temporary-message, .agent-message.temporary-message').last(); 2310 2311 // Determine styles 2312 let bgColor, fontColor; 2313 if (sender === "user") { 2314 bgColor = userMessageBgColor; 2315 fontColor = userMessageFontColor; 2316 } else if (sender === "agent") { 2317 bgColor = liveAgentMessageBgColor; 2318 fontColor = liveAgentMessageFontColor; 2319 } else { 2320 bgColor = botMessageBgColor; 2321 fontColor = botMessageFontColor; 2322 } 2323 2324 // Always run linkify to convert markdown links and format text. 2325 // linkify() already handles existing HTML (its URL patterns use negative lookaheads 2326 // to avoid double-processing URLs that are already inside <a> tags). 2327 var fullMessage = linkify(responseText); 2328 2329 if (responseHtml) { 2330 // Only add line breaks if there's actual text content before the HTML 2331 if (fullMessage && fullMessage.trim()) { 2332 fullMessage += '<br><br>' + responseHtml; 2333 } else { 2334 fullMessage = responseHtml; 2335 } 2336 } 2337 2338 if (images.length > 0) { 2339 fullMessage += '<div class="image-gallery" dir="auto">'; 2340 images.forEach(img => { 2341 const safeTitle = sanitizeUserInput(img.title); 2342 const safeUrl = escapeAttr(img.image_url); 2343 const safeThumbnail = escapeAttr(img.thumbnail_url); 2344 fullMessage += ` 2345 <div style="margin-bottom: 10px;"> 2346 <strong>${safeTitle}</strong><br> 2347 <a href="${safeUrl}" target="_blank"> 2348 <img src="${safeThumbnail}" alt="${safeTitle}" style="max-width: 100px; height: auto; margin: 5px;" /> 2349 </a> 2350 </div>`; 2351 }); 2352 fullMessage += '</div>'; 2353 } 2354 2355 if (lastMessageDiv.length) { 2356 // Replace content immediately to prevent visual gap between thinking dots and response 2357 // aria-busy released AFTER the final content is set, so the live region 2358 // announces the finished message once (plan 67f126). 2359 lastMessageDiv 2360 .html(fullMessage) 2361 .removeClass('bot-message user-message temporary-message') 2362 .addClass(messageClass) 2363 .attr('dir', 'auto') 2364 .attr('aria-busy', 'false'); 2365 2366 // Only apply inline colors if AI theme is not active (let CSS handle it) 2367 var skipColors = mxchatChat.skip_inline_colors || shouldSkipInlineColors(botId); 2368 if (!skipColors) { 2369 lastMessageDiv.css({ 2370 'background-color': bgColor, 2371 'color': fontColor, 2372 }); 2373 } 2374 2375 // Handle link tracking and scroll 2376 if (sender === "bot" || sender === "agent") { 2377 attachLinkTracking(lastMessageDiv, responseText, botId); 2378 2379 const lastUserMessage = $chatBox.find('.user-message').last(); 2380 if (lastUserMessage.length) { 2381 scrollElementToTop(lastUserMessage, botId); 2382 } 2383 // Show notification if chat is hidden 2384 var $floatingChatbot = getElement(botId, 'floating-chatbot'); 2385 if ($floatingChatbot.hasClass('hidden')) { 2386 showNotification(botId); 2387 } 2388 } 2389 2390 // Re-enable chat input after response is displayed 2391 enableChatInput(botId); 2392 2393 if (sender === "bot" || sender === "agent") { 2394 if (typeof mxchatInitHeaderMenu === 'function') mxchatInitHeaderMenu(botId); 2395 } 2396 } else { 2397 appendMessage(sender, responseText, responseHtml, images, false, botId); 2398 // Re-enable chat input after response is displayed 2399 enableChatInput(botId); 2400 } 2401} 2402 2403 2404 function appendThinkingMessage(botId) { 2405 botId = botId || 'default'; 2406 2407 // Don't show thinking dots in live agent mode - message is just forwarded to a human 2408 var indicator = getElementDOM(botId, 'chat-mode-indicator'); 2409 if (indicator && indicator.textContent === 'Live Agent') { 2410 return; 2411 } 2412 2413 var $chatBox = getElement(botId, 'chat-box'); 2414 2415 // Remove any existing thinking dots in this bot's chat first 2416 $chatBox.find('.thinking-dots').remove(); 2417 2418 // Check if we should skip inline colors (AI theme is active) 2419 var skipColors = shouldSkipInlineColors(botId); 2420 2421 // Retrieve the bot message font color and background color 2422 var botMessageFontColor = mxchatChat.bot_message_font_color; 2423 var botMessageBgColor = mxchatChat.bot_message_bg_color; 2424 2425 // Build thinking dots HTML - skip inline colors if AI theme is active 2426 // The dots are decorative; the sr-only span is what the live region 2427 // announces for the waiting state (plan 67f126). Server-localized 2428 // string â safe to inject (esc_html__ output, no user content). 2429 var dotStyle = skipColors ? '' : ' style="background-color: ' + botMessageFontColor + ';"'; 2430 var srThinking = mxchatChat.thinking_announcement || 'Assistant is typing'; 2431 var thinkingHtml = '<span class="sr-only">
2431' + srThinking + '</span>' + 2432 '<div class="thinking-dots-container" aria-hidden="true">' + 2433 '<div class="thinking-dots">' + 2434 '<span class="dot"' + dotStyle + '></span>' + 2435 '<span class="dot"' + dotStyle + '></span>' + 2436 '<span class="dot"' + dotStyle + '></span>' + 2437 '</div>' + 2438 '</div>'; 2439 2440 // Append the thinking dots to this bot's chat container - skip inline colors if AI theme is active 2441 var messageStyle = skipColors ? '' : ' style="background-color: ' + botMessageBgColor + '; color: ' + botMessageFontColor + ';"'; 2442 $chatBox.append('<div class="bot-message temporary-message"' + messageStyle + '>' + thinkingHtml + '</div>'); 2443 scrollToBottom(botId); 2444 } 2445 2446 function removeThinkingDots(botId) { 2447 botId = botId || 'default'; 2448 var $chatBox = getElement(botId, 'chat-box'); 2449 // Remove by temporary-message class first, then fall back to any bot-message containing thinking dots 2450 $chatBox.find('.thinking-dots').closest('.temporary-message').remove(); 2451 $chatBox.find('.bot-message .thinking-dots').closest('.bot-message').remove(); 2452 } 2453 2454 // ==================================== 2455 // TEXT FORMATTING & PROCESSING 2456 // ==================================== 2457 2458 function linkify(inputText) { 2459 if (!inputText) { 2460 return ''; 2461 } 2462 2463 // Helper function to check if URL is already encoded 2464 function isUrlEncoded(url) { 2465 // Check for % followed by exactly 2 hex digits 2466 return /%[0-9a-fA-F]{2}/.test(url); 2467 } 2468 2469 // Helper function to safely encode URLs only if needed, then make the 2470 // result attribute-safe. "Don't double-encode an already-encoded URL" 2471 // used to mean "return it untouched" â so a literal quote riding next 2472 // to a %20 reached href="â¦" intact and closed the attribute 2473 // (plan 2db182). Every href this function feeds now goes through 2474 // escapeAttr(), whether or not the URL was already percent-encoded. 2475 function safeEncodeUrl(url) { 2476 const encoded = isUrlEncoded(url) ? url : encodeURI(url); 2477 return escapeAttr(encoded); 2478 } 2479 2480 // Take backslash escapes and code (fenced + inline) out of the text FIRST 2481 // (plan 9dc34f). Styling used to run before formatCodeBlocks(), so a 2482 // backtick could not protect an underscore and \_ was not an escape at 2483 // all; the header rule also ran over fenced code. Each escape becomes a 2484 // placeholder carrying its literal character, each code span a 2485 // placeholder carrying its rendered HTML; both are restored below, after 2486 // the styling rules have run, so nothing downstream changes. 2487 const literals = protectLiterals(inputText); 2488 let processedText = literals.text; 2489 2490 // Process markdown headers 2491 processedText = formatMarkdownHeaders(processedText); 2492 2493 // Process text styling (bold, italic, strikethrough) 2494 processedText = formatTextStyling(processedText); 2495 2496 // Legacy raw <?php ... ?> blocks (fenced + inline code were already 2497 // rendered inside protectLiterals) â still BEFORE processing links 2498 processedText = formatCodeBlocks(processedText); 2499 2500 // Put the rendered code and the escaped characters back 2501 processedText = literals.restore(processedText); 2502 2503 // Process markdown tables BEFORE converting newlines to paragraphs 2504 processedText = formatMarkdownTables(processedText); 2505 2506 // NOW convert to paragraphs 2507 processedText = convertNewlinesToBreaks(processedText); 2508 2509 // IMPORTANT: Handle citation-style brackets FIRST [URL] 2510 // This prevents them from being processed as markdown links 2511 // Match [URL] where URL is a complete URL in square brackets (common in AI citations) 2512 processedText = processedText.replace(/\[(https?:\/\/[^\]]+)\]/g, (match, url) => { 2513 // Clean the URL of any trailing punctuation 2514 let cleanUrl = url.replace(/[.,;!?]+$/, ''); 2515 const safeUrl = safeEncodeUrl(cleanUrl); 2516 // The bracket body is the only link text that can carry angle 2517 // brackets (the standalone matcher excludes them) â keep it text. 2518 const safeText = cleanUrl.replace(/</g, '<').replace(/>/g, '>'); 2519 // Return as a proper link without the brackets 2520 return `<a href="${safeUrl}" target="${linkTarget}">${safeText}</a>`; 2521 });
2522 2523 // Process markdown links: [text](url) and [](url) 2524 // Uses balanced parenthesis matching to handle URLs containing parens 2525 // (e.g. PDF filenames with dates like (2025-08-28).pdf) 2526 processedText = (function(input) { 2527 var result = ''; 2528 var i = 0; 2529 while (i < input.length) { 2530 // Look for [ at current position 2531 if (input[i] === '[') { 2532 // Find closing ] 2533 var closeBracket = input.indexOf(']', i + 1); 2534 if (closeBracket === -1 || closeBracket + 1 >= input.length || input[closeBracket + 1] !== '(') { 2535 result += input[i]; 2536 i++; 2537 continue; 2538 } 2539 var linkText = input.substring(i + 1, closeBracket); 2540 // Check if URL starts with http 2541 var urlStart = closeBracket + 2; 2542 if (!input.substring(urlStart).match(/^https?:\/\//)) { 2543 result += input[i]; 2544 i++; 2545 continue; 2546 } 2547 // Find balanced closing paren 2548 var depth = 1; 2549 var j = urlStart; 2550 while (j < input.length && depth > 0) { 2551 if (input[j] === '(') depth++; 2552 else if (input[j] === ')') depth--; 2553 if (depth > 0) j++; 2554 } 2555 if (depth !== 0) { 2556 result += input[i]; 2557 i++; 2558 continue; 2559 } 2560 var url = input.substring(urlStart, j); 2561 var cleanUrl = url.replace(/[\].,;!?]+$/, ''); 2562 var encodedUrl = safeEncodeUrl(cleanUrl); 2563 if (!linkText || !linkText.trim()) { 2564 result += '<a href="' + encodedUrl + '" target="' + linkTarget + '">' + cleanUrl + '</a>'; 2565 } else { 2566 var safeText = sanitizeUserInput(linkText); 2567 result += '<a href="' + encodedUrl + '" target="' + linkTarget + '">' + safeText + '</a>'; 2568 } 2569 i = j + 1; // Skip past the closing ) 2570 } else { 2571 result += input[i]; 2572 i++; 2573 } 2574 } 2575 return result; 2576 })(processedText); 2577 2578 // Process phone numbers: [text](tel:number) 2579 const phonePattern = /\[([^\]]+)\]\((tel:[\d+\-\s()]+)\)/g; 2580 processedText = processedText.replace(phonePattern, (match, text, phone) => { 2581 const safePhone = safeEncodeUrl(phone); 2582 const safeText = sanitizeUserInput(text); 2583 return `<a href="${safePhone}">${safeText}</a>`; 2584 }); 2585 2586 // Process mailto links: [text](mailto:email) 2587 const mailtoPattern = /\[([^\]]+)\]\((mailto:[^\)]+)\)/g; 2588 processedText = processedText.replace(mailtoPattern, (match, text, mailto) => { 2589 const safeMailto = safeEncodeUrl(mailto); 2590 const safeText = sanitizeUserInput(text); 2591 return `<a href="${safeMailto}">${safeText}</a>`; 2592 }); 2593 2594 // Process standalone URLs - but NOT if they're already in <a> tags or brackets 2595 // Updated pattern to be more careful about what it matches 2596 const urlPattern = /(^|[^">=\[\]])(https?:\/\/[^\s<"\[\]]+)(?![^<]*<\/a>)(?!\])/gim; 2597 processedText = processedText.replace(urlPattern, (match, prefix, url) => { 2598 // Extra check: make sure this isn't already linked 2599 if (match.includes('href=') || match.includes('</a>')) { 2600 return match; 2601 } 2602 2603 // Clean trailing punctuation 2604 let cleanUrl = url.replace(/[.,;!?)]+$/, ''); 2605 const safeUrl = safeEncodeUrl(cleanUrl); 2606 return `${prefix}<a href="${safeUrl}" target="${linkTarget}">${cleanUrl}</a>`; 2607 }); 2608 2609 // Process www. URLs - but NOT if they're already in <a> tags or brackets 2610 const wwwPattern = /(^|[^">/\[\]])(www\.[\S]+)(?![^<]*<\/a>)(?!\])/gim; 2611 processedText = processedText.replace(wwwPattern, (match, prefix, url) => { 2612 // Extra check: make sure this isn't already linked 2613 if (match.includes('href=') || match.includes('</a>')) { 2614 return match; 2615 } 2616 2617 // Clean trailing punctuation 2618 let cleanUrl = url.replace(/[.,;!?)]+$/, ''); 2619 const safeUrl = safeEncodeUrl(`http://${cleanUrl}`); 2620 return `${prefix}<a href="${safeUrl}" target="${linkTarget}">
2620${cleanUrl}</a>`; 2621 }); 2622 2623 return processedText; 2624} 2625 2626 // One left-to-right pass over backslash escapes and code (plan 9dc34f). 2627 // A single alternation keeps the two honest with each other: an escaped 2628 // backtick is consumed as an escape before it can open a code span, and a 2629 // code span is consumed whole so the escapes inside it stay verbatim. 2630 // Placeholders are private-use characters â no underscore, asterisk, 2631 // backtick, hash, pipe or bracket for a later rule to match. 2632 function protectLiterals(text) { 2633 const store = []; 2634 const out = String(text).replace(/\\([\\`*_~])|(```[\s\S]*?```)|(`[^`\n]+`)/g, function(match, escaped) { 2635 store.push(escaped !== undefined ? escaped : formatCodeBlocks(match)); 2636 return '\uE000' + (store.length - 1) + '\uE001'; 2637 }); 2638 return { 2639 text: out, 2640 restore: function(t) { 2641 return String(t).replace(/\uE000(\d+)\uE001/g, function(m, i) { 2642 return store[Number(i)]; 2643 }); 2644 } 2645 }; 2646 } 2647 2648 function formatMarkdownHeaders(text) { 2649 // Handle h1 to h6 headers 2650 return text.replace(/^(#{1,6})\s+(.+)$/gm, function(match, hashes, content) { 2651 const level = hashes.length; 2652 return `<h${level} class="chat-heading chat-heading-${level}">${content.trim()}</h${level}>`; 2653 }); 2654 } 2655 2656function formatTextStyling(text) { 2657 // IMPORTANT: Protect BOTH HTML href and Markdown URLs from formatting 2658 const protectedSegments = []; 2659 let protectedText = text; 2660 2661 // Step 1a: Protect HTML href="..." attributes 2662 protectedText = protectedText.replace(/href\s*=\s*["']([^"']+)["']/gi, function(match) { 2663 const placeholder = `__PROTECTED_${protectedSegments.length}__`; 2664 protectedSegments.push(match); 2665 return placeholder; 2666 }); 2667 2668 // Step 1b: Protect Markdown links [text](url) 2669 // This is crucial - we need to protect the URLs in markdown format 2670 protectedText = protectedText.replace(/\[([^\]]*)\]\(([^)]+)\)/g, function(match) { 2671 const placeholder = `__PROTECTED_${protectedSegments.length}__`; 2672 protectedSegments.push(match); 2673 return placeholder; 2674 }); 2675 2676 // Step 1c: Also protect bare URLs that might exist 2677 protectedText = protectedText.replace(/(https?:\/\/[^\s<>"]+)/gi, function(match) { 2678 const placeholder = `__PROTECTED_${protectedSegments.length}__`; 2679 protectedSegments.push(match); 2680 return placeholder; 2681 }); 2682 2683 // Step 2: Now apply text styling to the protected text 2684 // Handle bold text (**text**) 2685 protectedText = protectedText.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>'); 2686 2687 // Handle italic text (*text* or _text_) - Safari-compatible (no lookbehind) 2688 // Match single asterisks that aren't part of bold (**) by checking they're not followed/preceded by another * 2689 protectedText = protectedText.replace(/(?!\*\*)\*([^*\n]+)\*(?!\*)/g, '<em>$1</em>'); 2690 2691 // Handle underscores for italic - Safari-compatible (no lookbehind), and 2692 // word-bounded (plan 9dc34f): the opener must follow the start of the 2693 // text or a non-word character, the closer must precede the end or a 2694 // non-word character, and the content may not start or end with a 2695 // space. So my_var_name, MXCHAT_API_KEY and snake_case stay as typed 2696 // while "use _this_ word" still italicises. A token whose content is 2697 // entirely upper-case letters and digits (_VALUE_, _ID_) is a placeholder 2698 // convention, not prose emphasis â it is left verbatim too (ticket 440). 2699 // __PROTECTED_N__ placeholders cannot match: their underscores are all 2700 // adjacent to another underscore or a word character. 2701 protectedText = protectedText.replace(/(^|[^\p{L}\p{N}_])_((?!PROTECTED)[^\s_](?:[^_\n]*[^\s_])?)_(?![\p{L}\p{N}_])/gu, function(match, before, content) { 2702 if (/^[\p{Lu}\p{N}]+$/u.test(content)) { 2703 return match; 2704 } 2705 return before + '<em>' + content + '</em>'; 2706 });
2707 2708 // Handle strikethrough (~~text~~) 2709 protectedText = protectedText.replace(/~~(.*?)~~/g, '<del>$1</del>'); 2710 2711 // Step 3: Restore all protected segments 2712 protectedSegments.forEach((original, index) => { 2713 const placeholder = `__PROTECTED_${index}__`; 2714 protectedText = protectedText.replace(placeholder, original); 2715 }); 2716 2717 return protectedText; 2718} 2719 function formatBoldText(text) { 2720 // This function is kept for compatibility but now uses formatTextStyling 2721 return formatTextStyling(text); 2722 } 2723 2724function convertNewlinesToBreaks(text) { 2725 // Split the text into paragraphs (marked by double newlines or multiple <br> tags) 2726 const paragraphs = text.split(/(?:\n\n|\<br\>\s*\<br\>)/g); 2727 2728 // Filter out empty paragraphs and wrap each paragraph in <p> tags 2729 return paragraphs 2730 .map(para => para.trim()) 2731 .filter(para => para.length > 0) // Remove empty paragraphs 2732 .map(para => `<p>${para}</p>`) 2733 .join(''); 2734} 2735 function formatCodeBlocks(text) { 2736 // Handle fenced code blocks with language specification (```language) 2737 text = text.replace(/```(\w+)?\n?([\s\S]*?)```/g, (match, language, code) => { 2738 const lang = language || 'text'; 2739 const escapedCode = escapeHtml(code.trim()); 2740 return `<div class="mxchat-code-block-container"> 2741 <div class="mxchat-code-header"> 2742 <span class="mxchat-code-language">${lang}</span> 2743 <button class="mxchat-copy-button" aria-label="Copy to clipboard">Copy</button> 2744 </div> 2745 <pre class="mxchat-code-block"><code class="language-${lang}">${escapedCode}</code></pre> 2746 </div>`; 2747 }); 2748 2749 // Handle inline code with single backticks 2750 text = text.replace(/`([^`\n]+)`/g, '<code class="mxchat-inline-code">$1</code>'); 2751 2752 // Handle raw PHP tags (legacy support) 2753 text = text.replace(/(<\?php[\s\S]*?\?>)/g, (match) => { 2754 const escapedCode = escapeHtml(match); 2755 return `<div class="mxchat-code-block-container"> 2756 <div class="mxchat-code-header"> 2757 <span class="mxchat-code-language">php</span> 2758 <button class="mxchat-copy-button" aria-label="Copy to clipboard">Copy</button> 2759 </div> 2760 <pre class="mxchat-code-block"><code class="language-php">${escapedCode}</code></pre> 2761 </div>`; 2762 }); 2763 2764 return text; 2765 } 2766 2767 function formatMarkdownTables(text) { 2768 var lines = text.split('\n'); 2769 var result = []; 2770 var i = 0; 2771 2772 while (i < lines.length) { 2773 // Check for a table: current line has pipes AND next line is a separator row 2774 if (i + 1 < lines.length && 2775 lines[i].indexOf('|') !== -1 && 2776 /^\s*\|?[\s\-:]+(\|[\s\-:]+)+\|?\s*$/.test(lines[i + 1])) { 2777 2778 var tableLines = []; 2779 var headerLine = lines[i]; 2780 var separatorLine = lines[i + 1]; 2781 tableLines.push(headerLine); 2782 tableLines.push(separatorLine); 2783 2784 // Collect remaining table rows 2785 var j = i + 2; 2786 while (j < lines.length && lines[j].indexOf('|') !== -1 && lines[j].trim() !== '') { 2787 tableLines.push(lines[j]); 2788 j++; 2789 } 2790 2791 // Parse alignment from separator row 2792 var sepCells = separatorLine.split('|').filter(function(c) { return c.trim() !== ''; }); 2793 var alignments = sepCells.map(function(cell) { 2794 var trimmed = cell.trim(); 2795 if (trimmed.charAt(0) === ':' && trimmed.charAt(trimmed.length - 1) === ':') return 'center'; 2796 if (trimmed.charAt(trimmed.length - 1) === ':') return 'right'; 2797 return 'left'; 2798 }); 2799 2800 // Build HTML table 2801 var html = '<div class="mxchat-table-wrapper"><table class="mxchat-table">'; 2802 2803 // Header row 2804 var headerCells = tableLines[0].split('|').filter(function(c) { return c.trim() !== ''; }); 2805 html += '<thead><tr>';
2806 headerCells.forEach(function(cell, idx) { 2807 var align = alignments[idx] || 'left'; 2808 html += '<th style="text-align:' + align + '">' + cell.trim() + '</th>'; 2809 }); 2810 html += '</tr></thead>'; 2811 2812 // Body rows 2813 html += '<tbody>'; 2814 for (var r = 2; r < tableLines.length; r++) { 2815 var rowCells = tableLines[r].split('|').filter(function(c) { return c.trim() !== ''; }); 2816 html += '<tr>'; 2817 rowCells.forEach(function(cell, idx) { 2818 var align = alignments[idx] || 'left'; 2819 html += '<td style="text-align:' + align + '">' + cell.trim() + '</td>'; 2820 }); 2821 html += '</tr>'; 2822 } 2823 html += '</tbody></table></div>'; 2824 2825 result.push(html); 2826 i = j; 2827 } else { 2828 result.push(lines[i]); 2829 i++; 2830 } 2831 } 2832 2833 return result.join('\n'); 2834 } 2835 2836 function sanitizeUserInput(text) { 2837 const div = document.createElement('div'); 2838 div.textContent = text; 2839 return div.innerHTML; 2840 } 2841 2842 // Attribute-safe URL for href="â¦" / src="â¦" interpolation (plan 2db182). 2843 // Percent-encodes the characters that can end or extend an attribute. 2844 // Valid in every scheme the widget emits (http, https, tel, mailto) and 2845 // never changes where a legitimate link resolves â encodeURI() already 2846 // produces most of these, this just guarantees them for URLs that skip 2847 // encodeURI() because they were percent-encoded on arrival. 2848 function escapeAttr(url) { 2849 return String(url) 2850 .replace(/"/g, '%22') 2851 .replace(/'/g, '%27') 2852 .replace(/</g, '%3C') 2853 .replace(/>/g, '%3E') 2854 .replace(/`/g, '%60') 2855 .replace(/ /g, '%20'); 2856 } 2857 2858 function escapeHtml(unsafe) { 2859 // Skip escaping if it's already escaped or contains HTML code block markup 2860 if (unsafe.includes('<') || unsafe.includes('>') || 2861 unsafe.includes('<pre><code') || unsafe.includes('</code></pre>')) { 2862 return unsafe; 2863 } 2864 2865 return unsafe 2866 .replace(/&/g, "&") 2867 .replace(/</g, "<") 2868 .replace(/>/g, ">") 2869 .replace(/"/g, """) 2870 .replace(/'/g, "'"); 2871 } 2872 2873 function decodeHTMLEntities(text) { 2874 var textArea = document.createElement('textarea'); 2875 textArea.innerHTML = text; 2876 return textArea.value; 2877 } 2878 2879 // ==================================== 2880 // UI & SCROLLING CONTROLS 2881 // ==================================== 2882 2883 function scrollToBottom(botIdOrInstant, instant) { 2884 // Handle backward compatibility: scrollToBottom() or scrollToBottom(true/false) 2885 var botId = 'default'; 2886 if (typeof botIdOrInstant === 'string') { 2887 botId = botIdOrInstant; 2888 instant = instant || false; 2889 } else if (typeof botIdOrInstant === 'boolean') { 2890 instant = botIdOrInstant; 2891 } else { 2892 instant = false; 2893 } 2894 2895 var chatBox = getElement(botId, 'chat-box'); 2896 if (instant) { 2897 // Instantly set the scroll position to the bottom 2898 chatBox.scrollTop(chatBox.prop("scrollHeight")); 2899 } else { 2900 // Use requestAnimationFrame for smoother scrolling if needed 2901 let start = null; 2902 const scrollHeight = chatBox.prop("scrollHeight"); 2903 const initialScroll = chatBox.scrollTop(); 2904 const distance = scrollHeight - initialScroll; 2905 const duration = 500; // Duration in ms 2906 2907 function smoothScroll(timestamp) { 2908 if (!start) start = timestamp; 2909 const progress = timestamp - start; 2910 const currentScroll = initialScroll + (distance * (progress / duration)); 2911 chatBox.scrollTop(currentScroll); 2912 2913 if (progress < duration) { 2914 requestAnimationFrame(smoothScroll); 2915 } else { 2916 chatBox.scrollTop(scrollHeight); // Ensure it's exactly at the bottom 2917 } 2918 } 2919 2920 requestAnimationFrame(smoothScroll); 2921 } 2922 } 2923 2924 function scrollElementToTop(element, botId, topOffset) { 2925 botId = botId || 'default'; 2926 topOffset = (typeof topOffset === 'number') ? topOffset : 2; 2927 var chatBox = getElement(botId, 'chat-box'); 2928 var elementTop = element.position().top + chatBox.scrollTop(); 2929 chatBox.animate({ scrollTop: Math.max(0, elementTop - topOffset) }, 500); 2930 } 2931 2932 function showChatWidget(botId) { 2933 botId = botId || 'default'; 2934 var $button = getElement(botId, 'floating-chatbot-button'); 2935 // First ensure display is set 2936 $button.css('display', 'flex'); 2937 // Then handle the fade 2938 $button.fadeTo(500, 1); 2939 // Force visibility 2940 $button.removeClass('hidden'); 2941 } 2942 2943 function hideChatWidget(botId) { 2944 botId = botId || 'default'; 2945 var $button = getElement(botId, 'floating-chatbot-button'); 2946 $button.css('display', 'none'); 2947 $button.addClass('hidden'); 2948 } 2949 2950 function disableScroll() { 2951 if (isMobile()) { 2952 $('body').css('overflow', 'hidden'); 2953 } 2954 } 2955 2956 function enableScroll() { 2957 if (isMobile()) { 2958 $('body').css('overflow', ''); 2959 } 2960 } 2961 2962 function isMobile() { 2963 // This can be a simple check, or more sophisticated detection of mobile devices 2964 return window.innerWidth <= 768; // Example threshold for mobile devices 2965 } 2966 2967 function setFullHeight() { 2968 var vh = $(window).innerHeight() * 0.01; 2969 $(':root').css('--vh', vh + 'px'); 2970 } 2971 2972 2973 // ==================================== 2974 // NOTIFICATION SYSTEM 2975 // ==================================== 2976 2977 function createNotificationBadge() { 2978 const chatButton = document.getElementById('floating-chatbot-button'); 2979 2980 if (!chatButton) return; 2981 2982 // Remove any existing badge first 2983 const existingBadge = chatButton.querySelector('.chat-notification-badge'); 2984 if (existingBadge) { 2985 existingBadge.remove(); 2986 } 2987 2988 notificationBadge = document.createElement('div'); 2989 notificationBadge.className = 'chat-notification-badge'; 2990 notificationBadge.style.cssText = ` 2991 display: none; 2992 position: absolute; 2993 top: -5px; 2994 right: -5px; 2995 background-color: red; 2996 color: white; 2997 border-radius: 50%; 2998 padding: 4px 8px; 2999 font-size: 12px; 3000 font-weight: bold; 3001 z-index: 10001; 3002 `; 3003 chatButton.style.position = 'relative'; 3004 chatButton.appendChild(notificationBadge); 3005 3006 } 3007 3008 function showNotification(botId) { 3009 botId = botId || 'default'; 3010 const badge = getElementDOM(botId, 'chat-notification-badge'); 3011 var $floatingChatbot = getElement(botId, 'floating-chatbot'); 3012 if (badge && $floatingChatbot.hasClass('hidden')) { 3013 badge.style.display = 'block'; 3014 badge.textContent = '1'; 3015 } 3016 } 3017 3018 function hideNotification(botId) { 3019 botId = botId || 'default'; 3020 const badge = getElementDOM(botId, 'chat-notification-badge'); 3021 if (badge) { 3022 badge.style.display = 'none'; 3023 } 3024 } 3025 3026 function startNotificationChecking(botId) { 3027 botId = botId || 'default'; 3028 const chatPersistenceEnabled = mxchatChat.chat_persistence_toggle === 'on'; 3029 if (!chatPersistenceEnabled) return; 3030 3031 createNotificationBadge(botId);
3032 var instance = MxChatInstances.get(botId); 3033 instance.notificationCheckInterval = setInterval(function() { 3034 checkForNewMessages(botId); 3035 }, 30000); // Check every 30 seconds 3036 } 3037 3038 function stopNotificationChecking(botId) { 3039 botId = botId || 'default'; 3040 var instance = MxChatInstances.get(botId); 3041 if (instance.notificationCheckInterval) { 3042 clearInterval(instance.notificationCheckInterval); 3043 } 3044 } 3045 3046 function checkForNewMessages() { 3047 const sessionId = getChatSession(); 3048 const chatPersistenceEnabled = mxchatChat.chat_persistence_toggle === 'on'; 3049 3050 if (!chatPersistenceEnabled) return; 3051 3052 $.ajax({ 3053 url: mxchatChat.ajax_url, 3054 type: 'POST', 3055 data: { 3056 action: 'mxchat_check_new_messages', 3057 session_id: sessionId, 3058 last_seen_id: lastSeenMessageId, 3059 nonce: mxchatChat.nonce 3060 }, 3061 success: function(response) { 3062 if (response.success && response.data.hasNewMessages) { 3063 showNotification(); 3064 } 3065 } 3066 }); 3067 } 3068 3069 3070// ==================================== 3071// LIVE AGENT FUNCTIONALITY 3072// ==================================== 3073 3074function startPolling(botId) { 3075 botId = botId || 'default'; 3076 var instance = MxChatInstances.get(botId); 3077 // Clear any existing interval first 3078 stopPolling(botId); 3079 instance.pollingInterval = setInterval(function() { 3080 checkForAgentMessages(botId); 3081 }, 5000); 3082} 3083 3084function stopPolling(botId) { 3085 botId = botId || 'default'; 3086 var instance = MxChatInstances.get(botId); 3087 if (instance.pollingInterval) { 3088 clearInterval(instance.pollingInterval); 3089 instance.pollingInterval = null; 3090 } 3091} 3092 3093function checkForAgentMessages(botId) { 3094 botId = botId || 'default'; 3095 var instance = MxChatInstances.get(botId); 3096 const sessionId = getChatSession(botId); 3097 $.ajax({
3098 url: mxchatChat.ajax_url, 3099 type: 'POST', 3100 dataType: 'json', 3101 data: { 3102 action: 'mxchat_fetch_new_messages', 3103 session_id: sessionId, 3104 last_seen_id: instance.lastSeenMessageId, 3105 persistence_enabled: 'true', 3106 nonce: mxchatChat.nonce 3107 }, 3108 success: function (response) { 3109 if (response.success && response.data?.new_messages) { 3110 let hasNewMessage = false; 3111 3112 response.data.new_messages.forEach(function (message) { 3113 if (message.role === "agent" && !instance.processedMessageIds.has(message.id)) { 3114 hasNewMessage = true; 3115 appendMessage("agent", message.content, '', [], false, botId); 3116 instance.lastSeenMessageId = message.id; 3117 instance.processedMessageIds.add(message.id); 3118 } 3119 }); 3120 3121 if (hasNewMessage) { 3122 enableChatInput(botId); 3123 } 3124 3125 var $floatingChatbot = getElement(botId, 'floating-chatbot'); 3126 if (hasNewMessage && $floatingChatbot.hasClass('hidden')) { 3127 showNotification(botId); 3128 } 3129 3130 scrollToBottom(botId, true); 3131 } 3132 3133 // Handle chat mode transitions (e.g. agent ended chat via !endchat) 3134 if (response.success && response.data?.chat_mode) { 3135 updateChatModeIndicator(response.data.chat_mode, botId); 3136 } 3137 }, 3138 error: function (xhr, status, error) { 3139 // Polling error - silently continue 3140 } 3141 }); 3142} 3143 3144 // ==================================== 3145 // CHAT HISTORY & PERSISTENCE 3146 // ==================================== 3147 3148function loadChatHistory(botId, onComplete) { 3149 botId = botId || 'default'; 3150 var instance = MxChatInstances.get(botId); 3151 3152 // Prevent duplicate loading 3153 if (instance.chatHistoryLoaded) { 3154 if (onComplete) onComplete(); 3155 return; 3156 } 3157 3158 // Use getChatSession which returns null if no session exists (does NOT create one) 3159 var sessionId = getChatSession(botId); 3160 var chatPersistenceEnabled = mxchatChat.chat_persistence_toggle === 'on'; 3161 3162 // No session yet â nothing to load. History will load after first message via ensureSession. 3163 if (!sessionId) { 3164 instance.chatHistoryLoaded = true; 3165 if (onComplete) onComplete(); 3166 return; 3167 } 3168 3169 if (chatPersistenceEnabled && sessionId) { 3170 $.ajax({
3171 url: mxchatChat.ajax_url, 3172 type: 'POST', 3173 dataType: 'json', 3174 data: { 3175 action: 'mxchat_fetch_conversation_history', 3176 session_id: sessionId 3177 }, 3178 success: function(response) { 3179 // Handle session reset (IP changed while user was away) 3180 if (response.success === false && response.data && response.data.action === 'reset_session') { 3181 // Silent reset â new session but don't clear UI 3182 MxChatInstances.silentResetSession(botId); 3183 instance.chatHistoryLoaded = true; // Prevent retry loop 3184 if (onComplete) onComplete(); 3185 return; 3186 } 3187 3188 // Check if the response indicates success 3189 if (response.success) { 3190 // Handle case where conversation data exists and is an array 3191 if (response.data && Array.isArray(response.data.conversation)) { 3192 var $chatBox = getElement(botId, 'chat-box'); 3193 var $fragment = $(document.createDocumentFragment()); 3194 let highestMessageId = instance.lastSeenMessageId; 3195 3196 // Update chat mode if provided 3197 if (response.data.chat_mode) { 3198 updateChatModeIndicator(response.data.chat_mode, botId); 3199 } 3200 3201 // Only process if there are actual messages 3202 if (response.data.conversation.length > 0) { 3203 // Restored history must be SILENT to screen readers 3204 // (plan 67f126): these are DOM additions inside the 3205 // live region and would otherwise announce as if 3206 // they just arrived. Lift aria-live for the batch 3207 // repopulate, restore it after the browser has 3208 // processed the mutations. 3209 var mxLiveRegionEl = $chatBox.get(0); 3210 var mxSavedAriaLive = mxLiveRegionEl ? mxLiveRegionEl.getAttribute('aria-live') : null; 3211 if (mxLiveRegionEl) { 3212 mxLiveRegionEl.setAttribute('aria-live', 'off'); 3213 } 3214 3215 // IMPORTANT: Clear existing messages before loading history. 3216 // Detach the greeting first and put it back below â 3217 // it is server-rendered and never stored in the 3218 // transcript, so the old unconditional .empty() 3219 // deleted it for the rest of the page life (plan 3220 // a1a79b). Detach rather than rebuild: intro_message 3221 // is not localized to JS, and this node already 3222 // carries the per-bot inline colors and any 3223 // {visitor_name} substitution already applied to it. 3224 var $mxIntro = $chatBox.find('.mxchat-intro-message').first().detach(); 3225 $chatBox.empty(); 3226 3227 $.each(response.data.conversation, function(index, message) { 3228 // Skip agent messages if persistence is off 3229 if (!chatPersistenceEnabled && message.role === 'agent') { 3230 return; 3231 } 3232 3233 var messageClass, messageBgColor, messageFontColor; 3234 3235 switch (message.role) { 3236 case 'user': 3237 messageClass = 'user-message'; 3238 messageBgColor = userMessageBgColor; 3239 messageFontColor = userMessageFontColor; 3240 break; 3241 case 'agent': 3242 messageClass = 'agent-message'; 3243 messageBgColor = liveAgentMessageBgColor; 3244 messageFontColor = liveAgentMessageFontColor; 3245 break; 3246 default: 3247 messageClass = 'bot-message'; 3248 messageBgColor = botMessageBgColor; 3249 messageFontColor = botMessageFontColor; 3250 break; 3251 } 3252 3253 var messageElement = $('<div>').addClass(messageClass) 3254 .css({ 3255 'background': messageBgColor, 3256 'color': messageFontColor 3257 }); 3258 3259 var content = message.content; 3260 content = content.replace(/\\'/g, "'").replace(/\\"/g, '"'); 3261 content = decodeHTMLEntities(content); 3262 3263 // A visitor's own rows get the same treatment as the 3264 // live turn (appendMessage escapes user text before 3265 // linkify). Without this a replayed user message came 3266 // back as live markup after a refresh (plan 2db182). 3267 // Bot/agent rows are trusted HTML and stay as they were. 3268 if (message.role === 'user') { 3269 content = sanitizeUserInput(content); 3270 } 3271 3272 // Skip linkify for messages containing structured HTML 3273 // (forms, product cards, galleries, etc.) to avoid 3274 // markdown formatting corrupting HTML attributes 3275 // (e.g. underscores in name="field_name" becoming <em> tags). 3276 // One family check instead of a per-card literal list: any 3277 // element carrying an mxchat- prefixed class is MxChat-generated 3278 // structured markup and replays raw. The old list drifted every 3279 // time an add-on minted a new card class â the filtered-search
3280 // card ("mxchat-filtered-product-card") missed it and replayed 3281 // through linkify as visible markup. 3282 if (/<[a-z][^>]*class\s*=\s*["'][^"']*\bmxchat-/i.test(content) || 3283 content.includes("<form") || 3284 content.includes("<input") || 3285 content.includes("<select") || 3286 content.includes("<textarea")) { 3287 messageElement.html(content); 3288 } else { 3289 var formattedContent = linkify(content); 3290 messageElement.html(formattedContent); 3291 } 3292 3293 $fragment.append(messageElement); 3294 3295 // Track message IDs 3296 if (message.id) { 3297 highestMessageId = Math.max(highestMessageId, message.id); 3298 instance.processedMessageIds.add(message.id); 3299 } 3300 }); 3301 3302 // Only append messages and scroll if we have content. 3303 // Greeting goes back FIRST, above the restored 3304 // history: "Hello â [earlier conversation]" is the 3305 // natural reading and matches the order a fresh 3306 // visitor sees (plan a1a79b). 3307 if ($mxIntro && $mxIntro.length) { 3308 $chatBox.append($mxIntro); 3309 } 3310 $chatBox.append($fragment); 3311 scrollToBottom(botId, true); 3312 3313 // Re-attach live semantics AFTER the rehydration 3314 // mutations have been processed with the region off 3315 // (plan 67f126). Restoring later announces nothing 3316 // retroactively; new turns announce normally. 3317 if (mxLiveRegionEl) { 3318 setTimeout(function() { 3319 mxLiveRegionEl.setAttribute('aria-live', mxSavedAriaLive || 'polite'); 3320 }, 200); 3321 } 3322 3323 // Collapse quick questions if we have conversation history 3324 // BUT skip auto-collapse for embedded bots (they should stay expanded) 3325 if (hasQuickQuestions(botId) && !isEmbeddedBot(botId)) { 3326 collapseQuickQuestions(botId); 3327 } 3328 3329 // Update lastSeenMessageId after history loads 3330 instance.lastSeenMessageId = highestMessageId; 3331 3332 // Only update chat mode if persistence is enabled and we have messages 3333 if (chatPersistenceEnabled) { 3334 var lastMessage = response.data.conversation[response.data.conversation.length - 1]; 3335 if (lastMessage.role === 'agent') { 3336 updateChatModeIndicator('agent', botId); 3337 } 3338 } 3339 3340 // Mark as loaded ONLY after successful load 3341 instance.chatHistoryLoaded = true; 3342 } 3343 } 3344 } 3345 if (onComplete) onComplete(); 3346 }, 3347 error: function(xhr, status, error) { 3348 // Error loading chat history - silently continue 3349 if (onComplete) onComplete(); 3350 } 3351 }); 3352 } else { 3353 if (onComplete) onComplete(); 3354 } 3355} 3356 3357 3358 // ==================================== 3359 // FILE UPLOAD FUNCTIONALITY 3360 // ==================================== 3361 3362 function addSafeEventListener(elementId, eventType, handler) { 3363 const element = document.getElementById(elementId); 3364 if (element) { 3365 element.addEventListener(eventType, handler); 3366 } 3367 } 3368 3369 function showActivePdf(filename, botId) { 3370 botId = botId || 'default'; 3371 const container = getElementDOM(botId, 'active-pdf-container'); 3372 const nameElement = getElementDOM(botId, 'active-pdf-name'); 3373 3374 if (!container || !nameElement) { 3375 return; 3376 } 3377 3378 nameElement.textContent = filename; 3379 container.style.display = 'flex'; 3380 } 3381 3382 function showActiveWord(filename, botId) { 3383 botId = botId || 'default'; 3384 const container = getElementDOM(botId, 'active-word-container'); 3385 const nameElement = getElementDOM(botId, 'active-word-name'); 3386 3387 if (!container || !nameElement) { 3388 return; 3389 } 3390 3391 nameElement.textContent = filename; 3392 container.style.display = 'flex'; 3393 } 3394 3395 function removeActivePdf(botId) { 3396 botId = botId || 'default';
3397 var instance = MxChatInstances.get(botId); 3398 const container = getElementDOM(botId, 'active-pdf-container'); 3399 const nameElement = getElementDOM(botId, 'active-pdf-name'); 3400 3401 if (!container || !nameElement || !instance.activePdfFile) return; 3402 3403 fetch(mxchatChat.ajax_url, { 3404 method: 'POST', 3405 headers: { 3406 'Content-Type': 'application/x-www-form-urlencoded', 3407 }, 3408 body: new URLSearchParams({ 3409 'action': 'mxchat_remove_pdf', 3410 'session_id': getChatSession(botId), 3411 'nonce': mxchatChat.nonce 3412 }) 3413 }) 3414 .then(response => response.json()) 3415 .then(data => { 3416 if (data.success) { 3417 container.style.display = 'none'; 3418 nameElement.textContent = ''; 3419 instance.activePdfFile = null; 3420 appendMessage('bot', 'PDF removed.', '', [], false, botId); 3421 } 3422 }) 3423 .catch(error => { 3424 // Error removing PDF - silently continue 3425 }); 3426 } 3427 3428 function removeActiveWord(botId) { 3429 botId = botId || 'default'; 3430 var instance = MxChatInstances.get(botId); 3431 const container = getElementDOM(botId, 'active-word-container'); 3432 const nameElement = getElementDOM(botId, 'active-word-name'); 3433 3434 if (!container || !nameElement || !instance.activeWordFile) return; 3435 3436 fetch(mxchatChat.ajax_url, { 3437 method: 'POST', 3438 headers: { 3439 'Content-Type': 'application/x-www-form-urlencoded', 3440 }, 3441 body: new URLSearchParams({ 3442 'action': 'mxchat_remove_word', 3443 'session_id': getChatSession(botId), 3444 'nonce': mxchatChat.nonce 3445 }) 3446 }) 3447 .then(response => response.json()) 3448 .then(data => { 3449 if (data.success) { 3450 container.style.display = 'none'; 3451 nameElement.textContent = ''; 3452 instance.activeWordFile = null; 3453 appendMessage('bot', 'Word document removed.', '', [], false, botId); 3454 } 3455 }) 3456 .catch(error => { 3457 // Error removing Word document - silently continue 3458 }); 3459 } 3460 3461 // ==================================== 3462 // CONSENT & COMPLIANCE (GDPR) 3463 // ==================================== 3464 3465 function initializeChatVisibility(botId) { 3466 botId = botId || 'default'; 3467 const complianzEnabled = mxchatChat.complianz_toggle === 'on' || 3468 mxchatChat.complianz_toggle === '1' || 3469 mxchatChat.complianz_toggle === 1; 3470 3471 if (complianzEnabled && typeof cmplz_has_consent === "function" && typeof complianz !== 'undefined') { 3472 // Initial check 3473 checkConsentAndShowChat(botId); 3474 3475 // Listen for consent changes 3476 $(document).on('cmplz_status_change', function(event) { 3477 checkConsentAndShowChat(botId); 3478 }); 3479 } else { 3480 // If Complianz is not enabled, always show 3481 getElement(botId, 'floating-chatbot-button') 3482 .css('display', 'flex') 3483 .removeClass('hidden no-consent') 3484 .fadeTo(500, 1); 3485 3486 // Also check pre-chat message when Complianz is not enabled 3487 checkPreChatDismissal(botId); 3488 } 3489 } 3490 3491 3492 function checkConsentAndShowChat(botId) { 3493 botId = botId || 'default'; 3494 var consentStatus = cmplz_has_consent('marketing'); 3495 var consentType = complianz.consenttype; 3496 3497 let $widget = getElement(botId, 'floating-chatbot-button'); 3498 let $chatbot = getElement(botId, 'floating-chatbot'); 3499 let $preChat = getElement(botId, 'pre-chat-message'); 3500 3501 if (consentStatus === true) { 3502 $widget 3503 .removeClass('no-consent') 3504 .css('display', 'flex') 3505 .removeClass('hidden') 3506 .fadeTo(500, 1); 3507 $chatbot.removeClass('no-consent'); 3508 3509 // Show pre-chat message if not dismissed 3510 checkPreChatDismissal(botId); 3511 } else { 3512 $widget 3513 .addClass('no-consent') 3514 .fadeTo(500, 0, function() { 3515 $(this) 3516 .css('display', 'none') 3517 .addClass('hidden'); 3518 }); 3519 $chatbot.addClass('no-consent'); 3520 3521 // Hide pre-chat message when no consent
3522 $preChat.hide(); 3523 } 3524 } 3525 3526 3527 // ==================================== 3528 // PRE-CHAT MESSAGE HANDLING 3529 // ==================================== 3530 3531 function checkPreChatDismissal(botId) { 3532 botId = botId || 'default'; 3533 try { 3534 var dismissedAt = localStorage.getItem('mxchat_pre_chat_dismissed_' + botId); 3535 if (dismissedAt) { 3536 // Re-show after 24 hours 3537 var elapsed = Date.now() - parseInt(dismissedAt, 10); 3538 if (elapsed < 86400000) { 3539 getElement(botId, 'pre-chat-message').hide(); 3540 return; 3541 } 3542 // Expired â clear and show again 3543 localStorage.removeItem('mxchat_pre_chat_dismissed_' + botId); 3544 } 3545 getElement(botId, 'pre-chat-message').fadeIn(250); 3546 } catch (e) { 3547 // localStorage unavailable â show the message 3548 getElement(botId, 'pre-chat-message').fadeIn(250); 3549 } 3550 } 3551 3552 function handlePreChatDismissal(botId) { 3553 botId = botId || 'default'; 3554 getElement(botId, 'pre-chat-message').fadeOut(200); 3555 try { 3556 localStorage.setItem('mxchat_pre_chat_dismissed_' + botId, String(Date.now())); 3557 } catch (e) { 3558 // localStorage unavailable â dismissal won't persist 3559 } 3560 } 3561 3562 3563 // ==================================== 3564 // UTILITY FUNCTIONS 3565 // ==================================== 3566 3567 function copyToClipboard(text) { 3568 var tempInput = $('<input>'); 3569 $('body').append(tempInput); 3570 tempInput.val(text).select(); 3571 document.execCommand('copy'); 3572 tempInput.remove(); 3573 } 3574 3575 3576 function isImageHtml(str) { 3577 return str.startsWith('<img') && str.endsWith('>'); 3578 } 3579 3580 3581 // ==================================== 3582 // EVENT HANDLERS & INITIALIZATION 3583 // ==================================== 3584 3585$(document).on('click', '.mxchat-popular-question', function () { 3586 var question = $(this).text(); 3587 var botId = getBotIdFromElement(this); 3588 3589 // Append the question as if the user typed it 3590 appendMessage("user", question, '', [], false, botId); 3591 3592 // Only collapse if there are questions 3593 if (hasQuickQuestions(botId)) { 3594 collapseQuickQuestions(botId); 3595 } 3596 3597 // Send the question to the server 3598 sendMessageToChatbot(question, botId); 3599}); 3600 3601$(document).on('click', '.questions-toggle-btn', function(e) { 3602 e.preventDefault(); 3603 e.stopPropagation(); 3604 var botId = getBotIdFromElement(this); 3605 expandQuickQuestions(botId); 3606}); 3607 3608$(document).on('click', '.questions-collapse-btn', function(e) { 3609 e.preventDefault(); 3610 e.stopPropagation(); 3611 var botId = getBotIdFromElement(this); 3612 collapseQuickQuestions(botId); 3613}); 3614 3615// Consent-safe YouTube embed (plan 03ba33): the server only ever ships a 3616// thumbnail facade â no Google iframe exists until the visitor taps play. 3617// Delegated so it also works for embeds restored from chat history. 3618$(document).on('click', '.mxchat-youtube-embed .mxchat-youtube-facade', function(e) { 3619 e.preventDefault(); 3620 var $wrap = $(this).closest('.mxchat-youtube-embed'); 3621 var videoId = String($wrap.data('video-id') || '').replace(/[^A-Za-z0-9_-]/g, ''); 3622 if (!videoId) { 3623 return; 3624 } 3625 var title = $wrap.find('.mxchat-youtube-title').text() || 'YouTube video'; 3626 var $iframe = $('<iframe>', { 3627 src: 'https://www.youtube-nocookie.com/embed/' + videoId + '?autoplay=1&rel=0', 3628 title: title, 3629 allow: 'accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture', 3630 allowfullscreen: true, 3631 frameborder: 0 3632 }).addClass('mxchat-youtube-iframe'); 3633 $wrap.addClass('mxchat-youtube-playing'); 3634 $(this).replaceWith($iframe); 3635}); 3636 3637 // Chatbot visibility toggle handlers - use class selector for multi-instance support 3638 // Handles click + Enter/Space keypresses for keyboard accessibility (WCAG 2.1 SC 2.1.1). 3639 $(document).on('click keydown', '.floating-chatbot-button', function(e) { 3640 if (e.type === 'keydown') { 3641 if (e.key !== 'Enter' && e.key !== ' ' && e.key !== 'Spacebar') return; 3642 e.preventDefault(); 3643 } 3644 var botId = getBotIdFromElement(this); 3645 var $chatbot = getElement(botId, 'floating-chatbot'); 3646 var $badge = getElement(botId, 'chat-notification-badge'); 3647 var $preChat = getElement(botId, 'pre-chat-message'); 3648 3649 if ($chatbot.hasClass('hidden')) { 3650 $chatbot.removeClass('hidden').addClass('visible') 3651 .attr('aria-modal', 'true').attr('role', 'dialog'); 3652 $(this).addClass('hidden').attr('aria-expanded', 'true'); 3653 $badge.hide(); // Hide notification when opening chat 3654 disableScroll();
3655 $preChat.fadeOut(250); 3656 mxchatFireOpened(botId); 3657 3658 // First open per page load: re-fetch behavior settings in case 3659 // this page's inline values came from a stale full-page cache 3660 // (plan-32db95). Idempotent â later opens are a no-op. 3661 mxchatRefreshDynamicSettings(); 3662 3663 // Load chat history for returning visitors (persistence) 3664 var chatPersistenceEnabled = typeof mxchatChat !== 'undefined' && mxchatChat.chat_persistence_toggle === 'on'; 3665 if (chatPersistenceEnabled) { 3666 MxChatInstances.ensureSession(botId); 3667 } 3668 3669 // Deferred email check â only on first widget open 3670 var emailBlocker = getElementDOM(botId, 'email-blocker'); 3671 var instance = MxChatInstances.get(botId); 3672 if (emailBlocker && !instance.emailCheckDone) { 3673 instance.emailCheckDone = true; 3674 resolveEmailState(botId); 3675 } else if (!emailBlocker) { 3676 // No email collection â still route through showChatContainerForBot 3677 // so the loader is shown while chat history loads 3678 showChatContainerForBot(botId); 3679 } 3680 3681 // Move keyboard focus into the message input after the open transition. 3682 setTimeout(function() { 3683 var chatInput = getElementDOM(botId, 'chat-input'); 3684 if (chatInput && !chatInput.disabled) { 3685 try { chatInput.focus({ preventScroll: true }); } catch (err) { chatInput.focus(); } 3686 } 3687 }, 300); 3688 } else { 3689 $chatbot.removeClass('visible').addClass('hidden').removeAttr('aria-modal'); 3690 $(this).removeClass('hidden').attr('aria-expanded', 'false'); 3691 enableScroll(); 3692 checkPreChatDismissal(botId); 3693 } 3694 }); 3695 3696 // Allow clicking anywhere on the title bar to close the chatbot. 3697 // Returns keyboard focus to the launcher so keyboard users don't get 3698 // stranded at <body> (WCAG SC 2.4.3 Focus Order). :focus-visible is 3699 // heuristic-based so mouse-triggered close won't show a focus ring. 3700 $(document).on('click', '.chatbot-top-bar', function() { 3701 var botId = getBotIdFromElement(this); 3702 getElement(botId, 'floating-chatbot').addClass('hidden').removeClass('visible').removeAttr('aria-modal'); 3703 var $launcher = getElement(botId, 'floating-chatbot-button'); 3704 $launcher.removeClass('hidden').attr('aria-expanded', 'false'); 3705 enableScroll(); 3706 try { $launcher.trigger('focus'); } catch (err) { /* no-op */ } 3707 }); 3708 3709 // Global Escape-key handler â closes any visible chat widget and 3710 // returns focus to its launcher. Standard modal-dismissal pattern; 3711 // pairs with aria-modal="true" set on the widget when it opens. 3712 $(document).on('keydown', function(e) { 3713 if (e.key !== 'Escape' && e.key !== 'Esc') return; 3714 var $visible = $('.floating-chatbot.visible'); 3715 if (!$visible.length) return; 3716 e.preventDefault(); 3717 $visible.each(function() { 3718 var botId = getBotIdFromElement(this); 3719 $(this).addClass('hidden').removeClass('visible').removeAttr('aria-modal'); 3720 var $launcher = getElement(botId, 'floating-chatbot-button'); 3721 $launcher.removeClass('hidden').attr('aria-expanded', 'false'); 3722 try { $launcher.trigger('focus'); } catch (err) { /* no-op */ } 3723 }); 3724 enableScroll(); 3725 }); 3726 3727 $(document).on('click', '.close-pre-chat-message', function(e) { 3728 e.stopPropagation(); // Prevent triggering the parent .pre-chat-message click 3729 var botId = getBotIdFromElement(this); 3730 handlePreChatDismissal(botId); 3731 }); 3732 3733 3734 // PDF upload button handlers - use class selector 3735 $(document).on('click', '.pdf-upload-btn', function() { 3736 var botId = getBotIdFromElement(this); 3737 var pdfInput = getElementDOM(botId, 'pdf-upload'); 3738 if (pdfInput) pdfInput.click(); 3739 });
3740 3741 // Word upload button handlers - use class selector 3742 $(document).on('click', '.word-upload-btn', function() { 3743 var botId = getBotIdFromElement(this); 3744 var wordInput = getElementDOM(botId, 'word-upload'); 3745 if (wordInput) wordInput.click(); 3746 }); 3747 3748 // PDF file input change handler - delegated, bot-aware (was bound to stale un-suffixed id 'pdf-upload') 3749 $(document).on('change', '.pdf-upload', async function(e) { 3750 var botId = getBotIdFromElement(this); 3751 var instance = MxChatInstances.get(botId); 3752 const file = this.files[0]; 3753 const sessionId = MxChatInstances.ensureSession(botId); 3754 3755 if (!file || file.type !== 'application/pdf') { 3756 alert('Please select a valid PDF file.'); 3757 return; 3758 } 3759 3760 if (!sessionId) { 3761 alert('Error: No session ID found'); 3762 return; 3763 } 3764 3765 if (!mxchatChat || !mxchatChat.ajax_url || !mxchatChat.nonce) { 3766 alert('Error: Ajax configuration missing'); 3767 return; 3768 } 3769 3770 // Disable buttons and show loading state 3771 const uploadBtn = getElementDOM(botId, 'pdf-upload-btn'); 3772 const sendBtn = getElementDOM(botId, 'send-button'); 3773 if (!uploadBtn) return; 3774 const originalBtnContent = uploadBtn.innerHTML; 3775 3776 try { 3777 // Wait for page-cache nonce refresh before reading mxchatChat.nonce. See plan-c5457f. 3778 await new Promise(function(resolve) { refreshNonceIfNeeded(resolve); }); 3779 const formData = new FormData(); 3780 formData.append('action', 'mxchat_upload_pdf'); 3781 formData.append('pdf_file', file); 3782 formData.append('session_id', sessionId); 3783 formData.append('nonce', mxchatChat.nonce); 3784 3785 uploadBtn.disabled = true; 3786 if (sendBtn) sendBtn.disabled = true; 3787 uploadBtn.innerHTML = `<svg class="spinner" viewBox="0 0 50 50"> 3788 <circle cx="25" cy="25" r="20" fill="none" stroke-width="5"></circle> 3789 </svg>`; 3790 3791 const response = await fetch(mxchatChat.ajax_url, { 3792 method: 'POST', 3793 body: formData 3794 }); 3795 3796 const data = await response.json(); 3797 3798 if (data.success) { 3799 // Hide popular questions if they exist 3800 if (hasQuickQuestions(botId)) { 3801 collapseQuickQuestions(botId); 3802 } 3803 3804 // Show the active PDF name 3805 showActivePdf(data.data.filename, botId); 3806 3807 appendMessage('bot', data.data.message, '', [], false, botId); 3808 scrollToBottom(botId); 3809 instance.activePdfFile = data.data.filename; 3810 } else { 3811 alert('Failed to upload PDF. Please try again.'); 3812 } 3813 } catch (error) { 3814 alert('Error uploading file. Please try again.'); 3815 } finally { 3816 uploadBtn.disabled = false; 3817 if (sendBtn) sendBtn.disabled = false; 3818 uploadBtn.innerHTML = originalBtnContent; 3819 this.value = ''; // Reset file input 3820 } 3821 }); 3822 3823 // Word file input change handler - delegated, bot-aware (was bound to stale un-suffixed id 'word-upload') 3824 $(document).on('change', '.word-upload', async function(e) { 3825 var botId = getBotIdFromElement(this); 3826 var instance = MxChatInstances.get(botId); 3827 const file = this.files[0]; 3828 const sessionId = MxChatInstances.ensureSession(botId); 3829 3830 if (!file || file.type !== 'application/vnd.openxmlformats-officedocument.wordprocessingml.document') { 3831 alert('Please select a valid Word document (.docx).'); 3832 return; 3833 } 3834 3835 if (!sessionId) { 3836 alert('Error: No session ID found'); 3837 return; 3838 } 3839 3840 if (!mxchatChat || !mxchatChat.ajax_url || !mxchatChat.nonce) { 3841 alert('Error: Ajax configuration missing'); 3842 return; 3843 } 3844 3845 // Disable buttons and show loading state 3846 const uploadBtn = getElementDOM(botId, 'word-upload-btn'); 3847 const sendBtn = getElementDOM(botId, 'send-button'); 3848 if (!uploadBtn) return; 3849 const originalBtnContent = uploadBtn.innerHTML; 3850 3851 try { 3852 // Wait for page-cache nonce refresh before reading mxchatChat.nonce. See plan-c5457f. 3853 await new Promise(function(resolve) { refreshNonceIfNeeded(resolve); }); 3854 const formData = new FormData();
3855 formData.append('action', 'mxchat_upload_word'); 3856 formData.append('word_file', file); 3857 formData.append('session_id', sessionId); 3858 formData.append('nonce', mxchatChat.nonce); 3859 3860 uploadBtn.disabled = true; 3861 if (sendBtn) sendBtn.disabled = true; 3862 uploadBtn.innerHTML = `<svg class="spinner" viewBox="0 0 50 50"> 3863 <circle cx="25" cy="25" r="20" fill="none" stroke-width="5"></circle> 3864 </svg>`; 3865 3866 const response = await fetch(mxchatChat.ajax_url, { 3867 method: 'POST', 3868 body: formData 3869 }); 3870 3871 const data = await response.json(); 3872 3873 if (data.success) { 3874 // Hide popular questions if they exist 3875 if (hasQuickQuestions(botId)) { 3876 collapseQuickQuestions(botId); 3877 } 3878 3879 // Show the active Word document name 3880 showActiveWord(data.data.filename, botId); 3881 3882 appendMessage('bot', data.data.message, '', [], false, botId); 3883 scrollToBottom(botId); 3884 instance.activeWordFile = data.data.filename; 3885 } else { 3886 alert('Failed to upload Word document. Please try again.'); 3887 } 3888 } catch (error) { 3889 alert('Error uploading file. Please try again.'); 3890 } finally { 3891 uploadBtn.disabled = false; 3892 if (sendBtn) sendBtn.disabled = false; 3893 uploadBtn.innerHTML = originalBtnContent; 3894 this.value = ''; // Reset file input 3895 } 3896 }); 3897 3898 // Remove button click handlers - delegated, bot-aware (were bound to stale un-suffixed ids) 3899 $(document).on('click', '.remove-pdf-btn', function(e) { 3900 e.preventDefault(); 3901 e.stopPropagation(); 3902 removeActivePdf(getBotIdFromElement(this)); 3903 }); 3904 3905 $(document).on('click', '.remove-word-btn', function(e) { 3906 e.preventDefault(); 3907 e.stopPropagation(); 3908 removeActiveWord(getBotIdFromElement(this)); 3909 }); 3910 3911 // Window resize handlers 3912 $(window).on('resize orientationchange', function() { 3913 setFullHeight(); 3914 }); 3915 3916 3917 // ==================================== 3918 // TOOLBAR & STYLING SETUP 3919 // ==================================== 3920 3921 // Apply toolbar settings 3922 if (mxchatChat.chat_toolbar_toggle === 'on') { 3923 $('.chat-toolbar').show(); 3924 } else { 3925 $('.chat-toolbar').hide(); 3926 } 3927 // Toolbar pass done â server-rendered add-on items can bind (plan 65c9b6). 3928 MxChatInstances.toolbarReady = true; 3929 mxchatDispatch('mxchat:toolbar-ready', { enabled: mxchatChat.chat_toolbar_toggle === 'on' }); 3930 3931 // Apply toolbar icon colors 3932 const toolbarElements = [ 3933 '#mxchat-chatbot .toolbar-btn svg', 3934 '#mxchat-chatbot .active-pdf-name', 3935 '#mxchat-chatbot .active-word-name', 3936 '#mxchat-chatbot .remove-pdf-btn svg', 3937 '#mxchat-chatbot .remove-word-btn svg', 3938 '#mxchat-chatbot .toolbar-perplexity svg' 3939 ]; 3940 3941 toolbarElements.forEach(selector => { 3942 $(selector).css({ 3943 'fill': toolbarIconColor, 3944 'stroke': toolbarIconColor, 3945 'color': toolbarIconColor 3946 }); 3947 }); 3948 3949 3950// ==================================== 3951// INIT LOADER & CHAT CONTAINER HELPERS 3952// ==================================== 3953// These must be outside the email collection block so they're always available 3954// (used by persistence loading even when email collection is off) 3955 3956function showInitLoader(botId) { 3957 var loader = getElementDOM(botId, 'mxchat-init-loader'); 3958 if (loader) loader.style.display = 'flex'; 3959} 3960 3961function hideInitLoader(botId) { 3962 var loader = getElementDOM(botId, 'mxchat-init-loader'); 3963 if (loader) loader.style.display = 'none'; 3964} 3965 3966function showEmailFormForBot(botId) {
3967 hideInitLoader(botId); 3968 var emailBlocker = getElementDOM(botId, 'email-blocker'); 3969 var chatContainer = getElementDOM(botId, 'chat-container'); 3970 if (emailBlocker) emailBlocker.style.display = 'flex'; 3971 if (chatContainer) chatContainer.style.display = 'none'; 3972} 3973 3974function showChatContainerForBot(botId) { 3975 var emailBlocker = getElementDOM(botId, 'email-blocker'); 3976 var chatContainer = getElementDOM(botId, 'chat-container'); 3977 if (emailBlocker) emailBlocker.style.display = 'none'; 3978 3979 var instance = MxChatInstances.get(botId); 3980 var chatPersistenceEnabled = mxchatChat.chat_persistence_toggle === 'on'; 3981 3982 // If persistence is on and history hasn't loaded yet, show loader 3983 // while history loads to prevent flash of empty chat 3984 if (chatPersistenceEnabled && !instance.chatHistoryLoaded) { 3985 if (chatContainer) chatContainer.style.display = 'none'; 3986 showInitLoader(botId); 3987 loadChatHistory(botId, function() { 3988 hideInitLoader(botId); 3989 if (chatContainer) chatContainer.style.display = 'flex'; 3990 scrollToBottom(botId, true); 3991 }); 3992 } else {
3993 hideInitLoader(botId); 3994 if (chatContainer) chatContainer.style.display = 'flex'; 3995 if (typeof loadChatHistory === 'function') { 3996 loadChatHistory(botId); 3997 } 3998 } 3999} 4000 4001// ==================================== 4002// EMAIL COLLECTION SETUP - MULTI-INSTANCE VERSION 4003// ==================================== 4004// Only run email collection setup if it's enabled 4005if (mxchatChat && mxchatChat.email_collection_enabled === 'on') { 4006 4007 // Track submitting state per bot 4008 const emailSubmittingState = {}; 4009 4010 // Add CSS animations for email form (once globally) 4011 if (!document.getElementById('email-error-styles')) { 4012 const style = document.createElement('style'); 4013 style.id = 'email-error-styles'; 4014 style.textContent = ` 4015 @keyframes fadeInError { 4016 from { opacity: 0; transform: translateY(-5px); } 4017 to { opacity: 1; transform: translateY(0); } 4018 } 4019 .email-input-shake { 4020 animation: shake 0.5s ease-in-out; 4021 } 4022 @keyframes shake { 4023 0%, 100% { transform: translateX(0); } 4024 25% { transform: translateX(-5px); } 4025 75% { transform: translateX(5px); } 4026 } 4027 @keyframes spin { 4028 from { transform: rotate(0deg); } 4029 to { transform: rotate(360deg); } 4030 } 4031 .email-spinner { 4032 display: inline-block; 4033 vertical-align: middle; 4034 } 4035 `; 4036 document.head.appendChild(style); 4037 } 4038 4039 function isValidEmailAddress(email) { 4040 const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; 4041 return emailRegex.test(email.trim()) && email.length <= 254; 4042 } 4043 4044 function isValidNameInput(name) { 4045 return name && name.trim().length >= 2 && name.trim().length <= 100; 4046 } 4047 4048 /** 4049 * Replace {visitor_name} placeholder in intro message with actual visitor name 4050 * @param {string} botId - The bot instance ID 4051 * @param {string} visitorName - The visitor's name to insert 4052 */ 4053 function replaceVisitorNamePlaceholder(botId, visitorName) { 4054 var chatBox = getElementDOM(botId, 'chat-box'); 4055 if (!chatBox) return; 4056 4057 // Find the greeting by its marker, not by position (plan a1a79b) â 4058 // after a persistence restore the first .bot-message is a restored 4059 // reply, and {visitor_name} was being substituted into that instead. 4060 // Positional fallback for HTML cached before this release only. 4061 var introMessage = chatBox.querySelector('.mxchat-intro-message') || chatBox.querySelector('.bot-message'); 4062 if (!introMessage) return; 4063 4064 var messageContent = introMessage.querySelector('div[dir="auto"]'); 4065 if (!messageContent) return; 4066 4067 var html = messageContent.innerHTML; 4068 4069 // Replace {visitor_name} placeholder (case-insensitive) 4070 if (visitorName && visitorName.trim()) { 4071 // Escape HTML to prevent XSS 4072 var safeName = $('<div>').text(visitorName.trim()).html(); 4073 html = html.replace(/\{visitor_name\}/gi, safeName); 4074 } else { 4075 // Remove placeholder and clean up spacing if no name provided 4076 html = html.replace(/\{visitor_name\}/gi, ''); 4077 // Clean up any double spaces that might result 4078 html = html.replace(/\s{2,}/g, ' ').trim(); 4079 } 4080 4081 messageContent.innerHTML = html; 4082 } 4083 4084 function setEmailSubmissionState(botId, loading) { 4085 var submitButton = getElementDOM(botId, 'email-submit-button'); 4086 var emailInput = getElementDOM(botId, 'user-email'); 4087 var nameInput = getElementDOM(botId, 'user-name'); 4088 4089 if (loading) { 4090 emailSubmittingState[botId] = true; 4091 if (submitButton) submitButton.disabled = true; 4092 if (emailInput) emailInput.disabled = true; 4093 if (nameInput) nameInput.disabled = true; 4094 4095 if (submitButton && !submitButton.getAttribute('data-original-html')) { 4096 submitButton.setAttribute('data-original-html', submitButton.innerHTML); 4097 const originalText = submitButton.textContent; 4098 submitButton.innerHTML = ` 4099 <svg class="email-spinner" style="width: 16px; height: 16px; margin-right: 8px; animation: spin 1s linear infinite;" viewBox="0 0 24 24"> 4100 <circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" fill="none" stroke-dasharray="31.416" stroke-dashoffset="31.416"> 4101 <animate attributeName="stroke-dasharray" dur="2s" values="0 31.416;15.708 15.708;
41010 31.416" repeatCount="indefinite"/> 4102 <animate attributeName="stroke-dashoffset" dur="2s" values="0;-15.708;-31.416" repeatCount="indefinite"/> 4103 </circle> 4104 </svg> 4105 ${originalText} 4106 `; 4107 submitButton.style.opacity = '0.8'; 4108 } 4109 } else { 4110 emailSubmittingState[botId] = false; 4111 if (submitButton) submitButton.disabled = false; 4112 if (emailInput) emailInput.disabled = false; 4113 if (nameInput) nameInput.disabled = false; 4114 4115 if (submitButton) { 4116 const originalHtml = submitButton.getAttribute('data-original-html'); 4117 if (originalHtml) { 4118 submitButton.innerHTML = originalHtml; 4119 } 4120 submitButton.style.opacity = '1'; 4121 } 4122 } 4123 } 4124 4125 function showEmailError(botId, message) { 4126 clearEmailError(botId); 4127 4128 var emailForm = getElementDOM(botId, 'email-collection-form'); 4129 if (!emailForm) return; 4130 4131 const errorDiv = document.createElement('div'); 4132 errorDiv.className = 'email-error'; 4133 errorDiv.style.cssText = ` 4134 color: #e74c3c; 4135 font-size: 12px; 4136 margin-top: 8px; 4137 padding: 4px 0; 4138 animation: fadeInError 0.3s ease; 4139 `; 4140 errorDiv.textContent = message; 4141 emailForm.appendChild(errorDiv); 4142 4143 // Add shake animation to inputs 4144 var emailInput = getElementDOM(botId, 'user-email'); 4145 var nameInput = getElementDOM(botId, 'user-name'); 4146 4147 if (emailInput) { 4148 emailInput.classList.add('email-input-shake'); 4149 setTimeout(() => emailInput.classList.remove('email-input-shake'), 500); 4150 } 4151 if (nameInput) { 4152 nameInput.classList.add('email-input-shake'); 4153 setTimeout(() => nameInput.classList.remove('email-input-shake'), 500); 4154 } 4155 } 4156 4157 function clearEmailError(botId) { 4158 var emailForm = getElementDOM(botId, 'email-collection-form'); 4159 if (emailForm) { 4160 const existingErrors = emailForm.querySelectorAll('.email-error'); 4161 existingErrors.forEach(error => error.remove()); 4162 } 4163 } 4164 4165 // Resolve email state using server-side data when available, AJAX fallback otherwise 4166 function resolveEmailState(botId) { 4167 if (mxchatChat.skip_email_check && mxchatChat.initial_email_state) { 4168 if (mxchatChat.initial_email_state.show_email_form) { 4169 showEmailFormForBot(botId); 4170 } else { 4171 showChatContainerForBot(botId); 4172 } 4173 } else { 4174 checkSessionAndEmailForBot(botId); 4175 } 4176 } 4177 4178 function checkSessionAndEmailForBot(botId) { 4179 const sessionId = MxChatInstances.ensureSession(botId); 4180 4181 // Hide both panels while we check â show loader instead 4182 var emailBlocker = getElementDOM(botId, 'email-blocker'); 4183 var chatContainer = getElementDOM(botId, 'chat-container'); 4184 if (emailBlocker) emailBlocker.style.display = 'none'; 4185 if (chatContainer) chatContainer.style.display = 'none'; 4186 showInitLoader(botId); 4187 4188 fetch(mxchatChat.ajax_url, { 4189 method: 'POST', 4190 headers: { 4191 'Content-Type': 'application/x-www-form-urlencoded', 4192 }, 4193 body: new URLSearchParams({ 4194 action: 'mxchat_check_email_provided', 4195 session_id: sessionId, 4196 nonce: mxchatChat.nonce, 4197 }) 4198 }) 4199 .then((response) => { 4200 if (!response.ok) { 4201 throw new Error(`HTTP error! status: ${response.status}`); 4202 } 4203 return response.json(); 4204 }) 4205 .then((data) => { 4206 if (data.success) { 4207 // A success response means the server has already decided the 4208 // required info is on file. With email optional (c0cfaf) that 4209 // info can be a name and no address, so keying on data.email 4210 // alone re-prompted a name-only visitor on every page load. 4211 if (data.data.logged_in || data.data.email || data.data.name) { 4212 showChatContainerForBot(botId); 4213 } else { 4214 showEmailFormForBot(botId); 4215 } 4216 } else { 4217 // Pre-fill for a signed-in visitor being shown the form 4218 // (c0cfaf). It arrives on this nocache'd response rather than 4219 // in the page HTML, which is cacheable. Never overwrite what 4220 // the visitor has already typed. 4221 if (data.data) { 4222 prefillEmailForm(botId, data.data.prefill_name, data.data.prefill_email); 4223 } 4224 showEmailFormForBot(botId); 4225 } 4226 }) 4227 .catch((error) => { 4228 showEmailFormForBot(botId); 4229 }); 4230 } 4231 4232 function prefillEmailForm(botId, name, email) { 4233 var nameInput = getElementDOM(botId, 'user-name'); 4234 var emailInput = getElementDOM(botId, 'user-email'); 4235 if (nameInput && name && !nameInput.value) { 4236 nameInput.value = name; 4237 } 4238 if (emailInput && email && !emailInput.value) { 4239 emailInput.value = email; 4240 } 4241 } 4242 4243 // Event delegation for email form submission 4244 $(document).on('submit', '.email-collection-form', function(e) { 4245 e.preventDefault(); 4246 e.stopPropagation(); 4247 4248 var botId = getBotIdFromElement(this); 4249 4250 // Prevent double submission 4251 if (emailSubmittingState[botId]) { 4252 return false; 4253 } 4254 4255 var emailInput = getElementDOM(botId, 'user-email'); 4256 var nameInput = getElementDOM(botId, 'user-name'); 4257 var consentInput = getElementDOM(botId, 'user-consent'); 4258 var userEmail = emailInput ? emailInput.value.trim() : ''; 4259 var userName = nameInput ? nameInput.value.trim() : ''; 4260 var sessionId = MxChatInstances.ensureSession(botId); 4261 4262 // Validate email. The address is optional when the owner turned 4263 // "Require Email Address" off (c0cfaf) â but anything actually typed 4264 // still has to be a valid address. The server enforces both rules 4265 // independently. 4266 var emailRequired = (mxchatChat.lead_capture_require_email || 'on') !== 'off'; 4267 4268 if (emailRequired && !userEmail) { 4269 showEmailError(botId, 'Please enter your email address.'); 4270 return false; 4271 } 4272 4273 if (userEmail && !isValidEmailAddress(userEmail)) { 4274 showEmailError(botId, 'Please enter a valid email address.'); 4275 return false; 4276 } 4277 4278 if (!emailRequired && !userEmail && !userName) { 4279 showEmailError(botId, 'Please enter your name or email address.'); 4280 return false; 4281 } 4282 4283 // Validate name if field exists and has content 4284 if (nameInput && userName && !isValidNameInput(userName)) { 4285 showEmailError(botId, 'Please enter a valid name (2-100 characters).'); 4286 return false; 4287 } 4288 4289 // Consent checkbox (b062c4): backstop behind the native required 4290 // attribute; the server enforces this independently either way. 4291 if (consentInput && consentInput.required && !consentInput.checked) { 4292 showEmailError(botId, 'Please tick the consent box to continue.'); 4293 return false; 4294 } 4295 4296 clearEmailError(botId); 4297 setEmailSubmissionState(botId, true); 4298 4299 // Prepare form data 4300 const formData = new URLSearchParams({ 4301 action: 'mxchat_handle_save_email_and_response', 4302 email: userEmail, 4303 session_id: sessionId, 4304 nonce: mxchatChat.nonce, 4305 }); 4306 4307 if (userName) { 4308 formData.append('name', userName); 4309 } 4310 4311 // Ticked/unticked both travel when the checkbox is rendered, so an 4312 // optional-consent "no" is recorded as a decision, not an absence. 4313 if (consentInput) { 4314 formData.append('consent', consentInput.checked ? '1' : '0'); 4315 } 4316 4317 fetch(mxchatChat.ajax_url, { 4318 method: 'POST', 4319 headers: { 4320 'Content-Type': 'application/x-www-form-urlencoded', 4321 }, 4322 body: formData 4323 }) 4324 .then((response) => { 4325 if (!response.ok) { 4326 throw new Error(`HTTP error! status: ${response.status}`); 4327 } 4328 return response.json(); 4329 }) 4330 .then((data) => { 4331 setEmailSubmissionState(botId, false); 4332 4333 if (data.success) { 4334 showChatContainerForBot(botId); 4335 4336 // Replace {visitor_name}
4336 placeholder in intro message with actual name 4337 if (userName) { 4338 replaceVisitorNamePlaceholder(botId, userName); 4339 } else { 4340 // Remove placeholder if no name provided 4341 replaceVisitorNamePlaceholder(botId, ''); 4342 } 4343 4344 if (data.message && typeof appendMessage === 'function') { 4345 setTimeout(() => { 4346 appendMessage('bot', data.message, '', [], false, botId); 4347 if (typeof scrollToBottom === 'function') { 4348 scrollToBottom(botId); 4349 } 4350 }, 100); 4351 } 4352 } else { 4353 showEmailError(botId, data.message || 'Failed to save email. Please try again.'); 4354 } 4355 }) 4356 .catch((error) => { 4357 setEmailSubmissionState(botId, false); 4358 showEmailError(botId, 'An error occurred. Please try again.'); 4359 }); 4360 4361 return false; 4362 }); 4363 4364 // Real-time email validation using event delegation 4365 $(document).on('input', '.mxchat-email-input', function() { 4366 var botId = getBotIdFromElement(this); 4367 var $input = $(this); 4368 4369 // Clear previous timeout 4370 clearTimeout($input.data('validationTimeout')); 4371 4372 // Debounce validation 4373 var timeout = setTimeout(() => { 4374 var email = this.value.trim(); 4375 clearEmailError(botId); 4376 4377 if (email && !isValidEmailAddress(email)) { 4378 showEmailError(botId, 'Please enter a valid email address.'); 4379 } 4380 }, 500); 4381 4382 $input.data('validationTimeout', timeout); 4383 }); 4384 4385 // Handle Enter key in email input 4386 $(document).on('keypress', '.mxchat-email-input', function(e) { 4387 if (e.key === 'Enter') { 4388 e.preventDefault(); 4389 var botId = getBotIdFromElement(this); 4390 if (!emailSubmittingState[botId]) { 4391 $(this).closest('.email-collection-form').submit(); 4392 } 4393 } 4394 }); 4395 4396 // Handle Enter key in name input 4397 $(document).on('keypress', '.mxchat-name-input', function(e) { 4398 if (e.key === 'Enter') { 4399 e.preventDefault(); 4400 var botId = getBotIdFromElement(this); 4401 if (!emailSubmittingState[botId]) { 4402 $(this).closest('.email-collection-form').submit(); 4403 } 4404 } 4405 }); 4406 4407 // Initialize email check for all bot instances 4408 // For floating bots: defer until widget is opened (zero passive AJAX) 4409 // For embedded bots: check immediately since the form is visible 4410 $('.mxchat-chatbot-wrapper').each(function() { 4411 var botId = $(this).data('bot-id') || 'default'; 4412 var emailBlocker = getElementDOM(botId, 'email-blocker'); 4413 4414 if (emailBlocker) { 4415 if (isEmbeddedBot(botId)) { 4416 // Embedded bots are always visible â check now 4417 resolveEmailState(botId); 4418 } 4419 // Floating bots: handled in the widget open handler 4420 } else if (isEmbeddedBot(botId)) { 4421 // Embedded bot, no email collection â load history with loader 4422 var chatPersistenceEnabled = typeof mxchatChat !== 'undefined' && mxchatChat.chat_persistence_toggle === 'on'; 4423 if (chatPersistenceEnabled) { 4424 MxChatInstances.ensureSession(botId); 4425 showChatContainerForBot(botId); 4426 } 4427 } 4428 }); 4429} 4430 4431 // Open chatbot when pre-chat message is clicked - use class selector for multi-instance 4432 $(document).on('click', '.pre-chat-message', function() { 4433 var botId = getBotIdFromElement(this); 4434 var $chatbot = getElement(botId, 'floating-chatbot'); 4435 if ($chatbot.hasClass('hidden')) { 4436 $chatbot.removeClass('hidden').addClass('visible'); 4437 getElement(botId, 'floating-chatbot-button').addClass('hidden'); 4438 handlePreChatDismissal(botId); 4439 disableScroll(); // Disable scroll when chatbot opens 4440 mxchatFireOpened(botId); 4441 4442 // Load chat history for returning visitors (persistence) 4443 var chatPersistenceEnabled = typeof mxchatChat !== 'undefined' && mxchatChat.chat_persistence_toggle === 'on'; 4444 if (chatPersistenceEnabled) { 4445 MxChatInstances.ensureSession(botId); 4446 } 4447 4448 // Deferred email check â only on first widget open 4449 var emailBlocker = getElementDOM(botId, 'email-blocker');
4450 var instance = MxChatInstances.get(botId); 4451 if (emailBlocker && !instance.emailCheckDone) { 4452 instance.emailCheckDone = true; 4453 resolveEmailState(botId); 4454 } else if (!emailBlocker) { 4455 showChatContainerForBot(botId); 4456 } 4457 } 4458 }); 4459 4460 // Legacy duplicate close handler removed â handled by single event delegation above 4461 4462 4463function hasQuickQuestions(botId) { 4464 botId = botId || 'default'; 4465 var questionsContainer = getElementDOM(botId, 'mxchat-popular-questions'); 4466 if (!questionsContainer) return false; 4467 const questionButtons = questionsContainer.querySelectorAll('.mxchat-popular-question'); 4468 return questionButtons.length > 0; 4469} 4470 4471/** 4472 * Check if a bot is embedded (not floating) 4473 * Embedded bots don't have a .floating-chatbot wrapper 4474 */ 4475function isEmbeddedBot(botId) { 4476 botId = botId || 'default'; 4477 var floatingWrapper = document.getElementById('floating-chatbot-' + botId); 4478 return !floatingWrapper; 4479} 4480 4481function collapseQuickQuestions(botId) { 4482 botId = botId || 'default'; 4483 const questionsContainer = getElementDOM(botId, 'mxchat-popular-questions'); 4484 if (questionsContainer && hasQuickQuestions(botId)) { 4485 questionsContainer.classList.add('collapsed'); 4486 questionsContainer.classList.add('has-been-collapsed'); 4487 try { 4488 sessionStorage.setItem('mxchat_questions_collapsed_' + botId, 'true'); 4489 sessionStorage.setItem('mxchat_questions_has_been_collapsed_' + botId, 'true'); 4490 } catch (e) { 4491 // Ignore if sessionStorage is not available 4492 } 4493 } 4494} 4495 4496function expandQuickQuestions(botId) { 4497 botId = botId || 'default'; 4498 const questionsContainer = getElementDOM(botId, 'mxchat-popular-questions'); 4499 if (questionsContainer && hasQuickQuestions(botId)) { 4500 questionsContainer.classList.remove('collapsed'); 4501 try { 4502 sessionStorage.setItem('mxchat_questions_collapsed_' + botId, 'false'); 4503 } catch (e) { 4504 // Ignore if sessionStorage is not available 4505 } 4506 } 4507} 4508 4509function checkQuickQuestionsState(botId) { 4510 botId = botId || 'default'; 4511 if (!hasQuickQuestions(botId)) { 4512 return; // Don't do anything if no questions exist 4513 } 4514 4515 // Skip restoring collapsed state for embedded bots - they should always start expanded 4516 if (isEmbeddedBot(botId)) { 4517 return; 4518 } 4519 4520 try { 4521 const isCollapsed = sessionStorage.getItem('mxchat_questions_collapsed_' + botId); 4522 const hasBeenCollapsed = sessionStorage.getItem('mxchat_questions_has_been_collapsed_' + botId); 4523 4524 const questionsContainer = getElementDOM(botId, 'mxchat-popular-questions'); 4525 if (questionsContainer) { 4526 if (hasBeenCollapsed === 'true') { 4527 questionsContainer.classList.add('has-been-collapsed'); 4528 } 4529 if (isCollapsed === 'true') { 4530 questionsContainer.classList.add('collapsed'); 4531 } 4532 } 4533 } catch (e) { 4534 // Ignore if sessionStorage is not available 4535 } 4536} 4537 4538// Global delegation for dynamically added links as fallback 4539// Use class selector for multi-instance support 4540$(document).on('click', '.chat-box a[href]:not([data-tracked])', function(e) { 4541 const $link = $(this); 4542 const messageDiv = $link.closest('.bot-message, .agent-message'); 4543 4544 // Only process bot/agent message links 4545 if (messageDiv.length > 0) { 4546 const originalHref = $link.attr('href'); 4547 4548 if (originalHref && (originalHref.startsWith('http://') || originalHref.startsWith('https://'))) { 4549 e.preventDefault(); 4550 e.stopPropagation(); 4551 4552 // Mark as tracked 4553 $link.attr('data-tracked', 'true'); 4554 4555 // Get bot ID from the chat box context 4556 var botId = getBotIdFromElement(this); 4557 4558 // Get message context from the message div 4559 const messageText = messageDiv.text().substring(0, 200); 4560 4561 $.ajax({
4562 url: mxchatChat.ajax_url, 4563 type: 'POST', 4564 data: { 4565 action: 'mxchat_track_url_click', 4566 session_id: getChatSession(botId), 4567 url: originalHref, 4568 message_context: messageText, 4569 nonce: mxchatChat.nonce 4570 }, 4571 complete: function() { 4572 if ($link.attr('target') === '_blank' || linkTarget === '_blank') { 4573 window.open(originalHref, '_blank'); 4574 } else { 4575 window.location.href = originalHref; 4576 } 4577 } 4578 }); 4579 4580 return false; 4581 } 4582 } 4583}); 4584 4585 // ==================================== 4586 // MAIN INITIALIZATION 4587 // ==================================== 4588 4589 // Initialize all chatbot instances on the page 4590 initializeAllInstances(); 4591 4592 // Legacy initialization for single bot compatibility 4593 $('.floating-chatbot.hidden').each(function() { 4594 var botId = getBotIdFromElement(this); 4595 getElement(botId, 'floating-chatbot-button').removeClass('hidden'); 4596 }); 4597 4598 // Initialize when document is ready 4599 setFullHeight(); 4600 4601 // Note: trackOriginatingPage() and loadChatHistory() are now deferred 4602 // until the user's first interaction via MxChatInstances.ensureSession() 4603 4604 // Initialize chat visibility for all instances 4605 $('.mxchat-chatbot-wrapper').each(function() { 4606 var botId = $(this).data('bot-id') || 'default'; 4607 initializeChatVisibility(botId); 4608 // An embedded widget is on screen from the first paint; a floating one 4609 // reports mxchat:opened when its window is shown (plan 65c9b6). 4610 if (isEmbeddedBot(botId)) { 4611 mxchatFireOpened(botId); 4612 } else { 4613 mxchatWatchForOpen(botId); 4614 } 4615 }); 4616 4617 // Make functions globally available for add-ons 4618 window.hasQuickQuestions = hasQuickQuestions; 4619 window.collapseQuickQuestions = collapseQuickQuestions; 4620 window.appendMessage = appendMessage; 4621 window.appendThinkingMessage = appendThinkingMessage; 4622 window.scrollToBottom = scrollToBottom; 4623 window.scrollElementToTop = scrollElementToTop; 4624 window.replaceLastMessage = replaceLastMessage; 4625 window.callMxChat = callMxChat; 4626 window.callMxChatStream = callMxChatStream; 4627 window.shouldUseStreaming = shouldUseStreaming; 4628 window.getChatSession = getChatSession; 4629 window.getPageContext = getPageContext; 4630 window.updateStreamingMessage = updateStreamingMessage; 4631 window.MxChatInstances = MxChatInstances; 4632 window.getElement = getElement; 4633 window.getElementDOM = getElementDOM; 4634 window.getBotIdFromElement = getBotIdFromElement; 4635 4636}); // End of jQuery ready 4637 4638 4639// ==================================== 4640// GLOBAL EVENT LISTENERS (Outside jQuery) 4641// ==================================== 4642 4643// Event listener for copy button (code blocks) 4644document.addEventListener("click", (e) => { 4645 if (e.target.classList.contains("mxchat-copy-button")) { 4646 const copyButton = e.target; 4647 const codeBlock = copyButton 4648 .closest(".mxchat-code-block-container") 4649 .querySelector(".mxchat-code-block code"); 4650 4651 if (codeBlock) { 4652 // Preserve formatting using innerText 4653 navigator.clipboard.writeText(codeBlock.innerText).then(() => { 4654 copyButton.textContent = "Copied!"; 4655 copyButton.setAttribute("aria-label", "Copied to clipboard"); 4656 4657 setTimeout(() => { 4658 copyButton.textContent = "Copy"; 4659 copyButton.setAttribute("aria-label", "Copy to clipboard"); 4660 }, 2000); 4661 }); 4662 } 4663 } 4664}); 4665 4666// ============================================================================ 4667// SATISFACTION RATING (v3.2.6) 4668// ============================================================================ 4669// Per-session ð/ð prompt that appears in the chat-box after 60s of user 4670// inactivity following a bot reply. One prompt per session, deduped via
4671// localStorage. Runs ONLY when the satisfaction_rating_enabled option is on â 4672// the option (default off) is authoritative. 4673jQuery(function($) { 4674 if (typeof mxchatChat === 'undefined') return; 4675 // wp_localize_script stringifies scalars: a PHP boolean false arrives as 4676 // '' and true as '1', so this must be an explicit-enable allowlist â the 4677 // old "disabled when exactly false/'off'" check let '' through and the 4678 // bubble rendered on sites with the option off/unset (plan-4bba64). PHP 4679 // now emits 'on'/'off' strings; true/'1'/1 keep cached pre-fix HTML 4680 // (boolean-true localizations) working. 4681 // NOTE (plan-32db95): this gate reads the INLINE value at DOM ready and is 4682 // deliberately NOT re-evaluated after the widget's dynamic-settings refresh 4683 // merges fresh values over mxchatChat (that merge fires on first widget 4684 // open, after this module has already decided). Re-evaluating would mean 4685 // restructuring the whole module to late-bind its listeners â not worth it 4686 // for a prompt that is at worst stale for one page load on a cached page. 4687 var sre = mxchatChat.satisfaction_rating_enabled; 4688 if (sre !== 'on' && sre !== true && sre !== '1' && sre !== 1) return; 4689 4690 // wp_localize_script stringifies ints, so accept both number and numeric string. 4691 var idleRaw = mxchatChat.satisfaction_rating_idle_seconds; 4692 var idleSeconds = (typeof idleRaw === 'number') ? idleRaw : parseInt(idleRaw, 10); 4693 if (!isFinite(idleSeconds)) idleSeconds = 60; 4694 if (idleSeconds < 5) idleSeconds = 5; 4695 if (idleSeconds > 600) idleSeconds = 600; 4696 var IDLE_MS = idleSeconds * 1000; 4697 var MIN_BOT_REPLIES = 2; 4698 var ratingState = {}; 4699 4700 function getState(botId) { 4701 if (!ratingState[botId]) { 4702 ratingState[botId] = { idleTimer: null, botReplies: 0, promptShown: false, dismissed: false }; 4703 } 4704 return ratingState[botId]; 4705 } 4706 4707 function getSessionId(botId) { 4708 if (typeof MxChatInstances !== 'undefined' && MxChatInstances.getChatSession) { 4709 return MxChatInstances.getChatSession(botId); 4710 } 4711 return null; 4712 } 4713 4714 function isAlreadyRated(sessionId) { 4715 if (!sessionId) return false; 4716 try { return localStorage.getItem('mxchat_rated:' + sessionId) === '1'; } catch (e) { return false; } 4717 } 4718 4719 function markRated(sessionId) { 4720 if (!sessionId) return; 4721 try { localStorage.setItem('mxchat_rated:' + sessionId, '1'); } catch (e) {} 4722 } 4723 4724 function esc(s) { 4725 return String(s == null ? '' : s) 4726 .replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>') 4727 .replace(/"/g, '"').replace(/'/g, '''); 4728 } 4729 4730 // Mirror shouldSkipInlineColors so rating bubbles defer to AI-theme CSS. 4731 function ratingSkipInlineColors(botId) { 4732 if (mxchatChat.skip_inline_colors) return true; 4733 var botAssignments = mxchatChat.bot_theme_assignments || {}; 4734 return botAssignments.hasOwnProperty(botId); 4735 } 4736 4737 function botBubbleStyleAttr(botId) { 4738 if (ratingSkipInlineColors(botId)) return ''; 4739 var bg = mxchatChat.bot_message_bg_color; 4740 var fg = mxchatChat.bot_message_font_color; 4741 if (!bg && !fg) return ''; 4742 return ' style="background-color: ' + esc(bg || '') + '; color: ' + esc(fg || '') + ';"'; 4743 } 4744 4745 // Reads the rating bubble's actual computed fg+bg (whatever paints it â 4746 // the inline color pickers OR the mxchat-theme AI customizer's injected CSS) 4747 // and paints the filled "Send" pill so it fills with the bot font color and 4748 // labels in the bubble bg. Mirrors mxchatSyncMenuColors(~:1512) for the read. 4749 // We paint the submit button DIRECTLY (inline longhand) rather than relying 4750 // on the CSS rule's var()s: Chromium resolves an INHERITED custom property 4751 // unreliably inside a descendant's `background`, so a bubble-level var would 4752 // silently fall back to the literal (white-block bug all over again). Inline 4753 // longhand always wins. Same transparent-guard as the menu so we never paint 4754 // a see-through value â in that case the CSS literal fallbacks keep it legible. 4755 function syncRatingBubbleColors(botId) { 4756 var $chatBox = getChatBoxByBotId(botId); 4757 if (!$chatBox || !$chatBox.length) return; 4758 var bubbleEl = $chatBox.find('.mxchat-rating-bot-bubble').last()[0]; 4759 if (!bubbleEl) return; 4760 var cs = window.getComputedStyle(bubbleEl); 4761 var fg = cs.color; 4762 var bg = cs.backgroundColor; 4763 var hasFg = fg && fg !== 'rgba(0, 0, 0, 0)' && fg !== 'transparent'; 4764 var hasBg = bg && bg !== 'rgba(0, 0, 0, 0)' && bg !== 'transparent'; 4765 // Expose on the bubble too, for any inheriting styles / future use. 4766 if (hasFg) bubbleEl.style.setProperty('--mxchat-bot-fg', fg); 4767 if (hasBg) bubbleEl.style.setProperty('--mxchat-bot-bg', bg); 4768 // Paint the Send pill directly â the part that actually fixes the bug. 4769 var submitEl = bubbleEl.querySelector('.mxchat-rating-submit'); 4770 if (submitEl) { 4771 if (hasFg) submitEl.style.backgroundColor = fg; // fill = bot font color 4772 if (hasBg) submitEl.style.color = bg; // label = bubble background 4773 } 4774 } 4775 4776 function copy(key) { 4777 var c = mxchatChat.satisfaction_rating_copy || {}; 4778 var d = { 4779 question: 'Was this helpful?', 4780 helpful: 'Helpful',
4781 not_helpful: 'Not helpful', 4782 dismiss: 'Dismiss', 4783 thanks: 'Thanks! Anything we should improve? (optional)', 4784 placeholder: 'Tell us what could be betterâ¦', 4785 send: 'Send', 4786 skip: 'Skip', 4787 saved: 'Thanks for the feedback.' 4788 }; 4789 return c[key] || d[key]; 4790 } 4791 4792 function thumbUpSvg() { 4793 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>'; 4794 } 4795 function thumbDownSvg() { 4796 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>'; 4797 } 4798 4799 function buildPromptHtml(botId) { 4800 var styleAttr = botBubbleStyleAttr(botId); 4801 return '' 4802 + '<div class="bot-message mxchat-rating-bot-bubble"' + styleAttr + '>' 4803 + '<div class="mxchat-rating-prompt" data-bot-id="' + esc(botId) + '" role="group" aria-label="' + esc(copy('question')) + '">' 4804 + '<div class="mxchat-rating-question">' + esc(copy('question')) + '</div>' 4805 + '<div class="mxchat-rating-actions">' 4806 + '<span class="mxchat-rating-buttons">' 4807 + '<button type="button" class="mxchat-rating-btn" data-rating="1" aria-label="' + esc(copy('helpful')) + '">' + thumbUpSvg() + '</button>' 4808 + '<button type="button" class="mxchat-rating-btn" data-rating="-1" aria-label="' + esc(copy('not_helpful')) + '">' + thumbDownSvg() + '</button>' 4809 + '</span>' 4810 + '<button type="button" class="mxchat-rating-dismiss" aria-label="' + esc(copy('dismiss')) + '">Ã</button>' 4811 + '</div>' 4812 + '</div>' 4813 + '</div>'; 4814 } 4815 4816 function buildFeedbackHtml(botId, rating) { 4817 var styleAttr = botBubbleStyleAttr(botId); 4818 return '' 4819 + '<div class="bot-message mxchat-rating-bot-bubble"' + styleAttr + '>' 4820 + '<div class="mxchat-rating-feedback" data-bot-id="' + esc(botId) + '" data-rating="' + esc(String(rating)) + '">' 4821 + '<div class="mxchat-rating-feedback-label">' + esc(copy('thanks')) + '</div>' 4822 + '<textarea class="mxchat-rating-feedback-input" maxlength="500" placeholder="' + esc(copy('placeholder')) + '" rows="2"></textarea>' 4823 + '<div class="mxchat-rating-feedback-actions">' 4824 + '<button type="button" class="mxchat-rating-skip">' + esc(copy('skip')) + '</button>' 4825 + '<button type="button" class="mxchat-rating-submit">' + esc(copy('send')) + '</button>' 4826 + '</div>' 4827 + '</div>' 4828 + '</div>'; 4829 } 4830 4831 function buildSavedHtml(botId) { 4832 var styleAttr = botBubbleStyleAttr(botId); 4833 return '' 4834 + '<div class="bot-message mxchat-rating-bot-bubble"' + styleAttr + '>' 4835 + '<div class="mxchat-rating-saved">' + esc(copy('saved')) + '</div>' 4836 + '</div>'; 4837 } 4838 4839 function getChatBoxByBotId(botId) { 4840 var $byId = $('#chat-box-' + botId); 4841 if ($byId.length) return $byId.first(); 4842 return $('.chat-box').first(); 4843 } 4844 4845 function scrollChatBoxToBottom($chatBox) { 4846 if (!$chatBox || !$chatBox.length) return;
4847 $chatBox.scrollTop($chatBox[0].scrollHeight); 4848 } 4849 4850 function showPrompt(botId) { 4851 var s = getState(botId); 4852 if (s.promptShown || s.dismissed) return; 4853 var sessionId = getSessionId(botId); 4854 if (!sessionId) return; 4855 if (isAlreadyRated(sessionId)) { s.promptShown = true; return; } 4856 var $chatBox = getChatBoxByBotId(botId); 4857 if (!$chatBox.length) return; 4858 if ($chatBox.find('.mxchat-rating-prompt').length) { s.promptShown = true; return; } 4859 $chatBox.append(buildPromptHtml(botId)); 4860 syncRatingBubbleColors(botId); 4861 s.promptShown = true; 4862 scrollChatBoxToBottom($chatBox); 4863 } 4864 4865 function submitRating(botId, rating, feedback) { 4866 var sessionId = getSessionId(botId); 4867 if (!sessionId) return; 4868 $.post(mxchatChat.ajax_url, { 4869 action: 'mxchat_save_rating', 4870 session_id: sessionId, 4871 bot_id: botId, 4872 rating: rating, 4873 feedback: feedback || '' 4874 }); 4875 markRated(sessionId); 4876 } 4877 4878 function onBotReply(botId) { 4879 var s = getState(botId); 4880 s.botReplies += 1; 4881 if (s.promptShown || s.dismissed) return; 4882 var sessionId = getSessionId(botId); 4883 if (sessionId && isAlreadyRated(sessionId)) { s.promptShown = true; return; } 4884 if (s.botReplies < MIN_BOT_REPLIES) return; 4885 if (s.idleTimer) clearTimeout(s.idleTimer); 4886 s.idleTimer = setTimeout(function() { showPrompt(botId); }, IDLE_MS); 4887 } 4888 4889 function onUserMessage(botId) { 4890 var s = getState(botId); 4891 if (s.idleTimer) { clearTimeout(s.idleTimer); s.idleTimer = null; } 4892 } 4893 4894 function botIdFromChatBox(el) { 4895 var id = el && el.id ? el.id : ''; 4896 return id.indexOf('chat-box-') === 0 ? id.substring('chat-box-'.length) : 'default'; 4897 } 4898 4899 function setupObserver(chatBox) { 4900 var botId = botIdFromChatBox(chatBox); 4901 try { 4902 var observer = new MutationObserver(function(mutations) { 4903 mutations.forEach(function(m) { 4904 for (var i = 0; i < m.addedNodes.length; i++) { 4905 var node = m.addedNodes[i]; 4906 if (!node || node.nodeType !== 1) continue; 4907 var $n = $(node); 4908 if ($n.hasClass('mxchat-rating-bot-bubble') || $n.hasClass('mxchat-rating-prompt') || $n.hasClass('mxchat-rating-feedback') || $n.hasClass('mxchat-rating-saved')) continue; 4909 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) 4910 else if ($n.hasClass('user-message')) onUserMessage(botId); 4911 } 4912 }); 4913 }); 4914 observer.observe(chatBox, { childList: true }); 4915 } catch (e) { /* noop */ } 4916 } 4917 4918 $('.chat-box').each(function() { setupObserver(this); }); 4919 4920 $(document).on('click', '.mxchat-rating-btn', function(e) { 4921 e.preventDefault(); 4922 var $btn = $(this); 4923 var $prompt = $btn.closest('.mxchat-rating-prompt'); 4924 var $wrap = $btn.closest('.mxchat-rating-bot-bubble'); 4925 var botId = $prompt.data('bot-id') || 'default'; 4926 var rating = parseInt($btn.attr('data-rating'), 10); 4927 if (rating !== 1 && rating !== -1) return; 4928 submitRating(botId, rating, ''); 4929 ($wrap.length ? $wrap : $prompt).replaceWith(buildFeedbackHtml(botId, rating)); 4930 syncRatingBubbleColors(botId); 4931 scrollChatBoxToBottom(getChatBoxByBotId(botId)); 4932 }); 4933 4934 $(document).on('click', '.mxchat-rating-dismiss', function(e) { 4935 e.preventDefault(); 4936 var $prompt = $(this).closest('.mxchat-rating-prompt'); 4937 var $wrap = $(this).closest('.mxchat-rating-bot-bubble'); 4938 var botId = $prompt.data('bot-id') || 'default'; 4939 var s = getState(botId); 4940 s.dismissed = true; 4941 markRated(getSessionId(botId)); 4942 ($wrap.length ? $wrap : $prompt).remove(); 4943 });
4944 4945 function closeFeedback($fb) { 4946 var botId = $fb.data('bot-id') || 'default'; 4947 var $wrap = $fb.closest('.mxchat-rating-bot-bubble'); 4948 ($wrap.length ? $wrap : $fb).replaceWith(buildSavedHtml(botId)); 4949 syncRatingBubbleColors(botId); 4950 scrollChatBoxToBottom(getChatBoxByBotId(botId)); 4951 } 4952 4953 $(document).on('click', '.mxchat-rating-skip', function(e) { 4954 e.preventDefault(); 4955 closeFeedback($(this).closest('.mxchat-rating-feedback')); 4956 }); 4957 4958 $(document).on('click', '.mxchat-rating-submit', function(e) { 4959 e.preventDefault(); 4960 var $fb = $(this).closest('.mxchat-rating-feedback'); 4961 var botId = $fb.data('bot-id') || 'default'; 4962 var rating = parseInt($fb.attr('data-rating'), 10); 4963 if (rating !== 1 && rating !== -1) { closeFeedback($fb); return; } 4964 var text = String($fb.find('.mxchat-rating-feedback-input').val() || '').trim(); 4965 if (text !== '') { 4966 submitRating(botId, rating, text); 4967 } 4968 closeFeedback($fb); 4969 }); 4970}); 4971
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.