jQuery(document).ready(function($) { // Nonce refresh — v2 (plan-6a68c9). // // The widget no longer relies on a nonce embedded in inline cached HTML. // Before each chat-send / stream-send / upload, we call the REST endpoint // GET /wp-json/mxchat/v1/nonce and use the freshly-issued value. The // endpoint creates the nonce with action `mxchat_chat_send`; the server-side // verifier ALSO still accepts the legacy `mxchat_chat_nonce` action for a // 30-day backwards-compat window so cached pages still in users' browsers // (which carry the legacy inline-localized nonce) keep working. // // Cache: a single module-scoped slot. TTL 12h conservatively (WP nonces are // 24h but we refetch at half-life so a freshly-cached-page user never sees // a borderline-stale nonce). var cachedFreshNonce = null; var cachedFreshNonceFetchedAt = 0; var NONCE_TTL_MS = 12 * 60 * 60 * 1000; var nonceRefreshState = 'idle'; // 'idle' | 'pending' | 'done' var nonceRefreshCallbacks = []; function getRestNonceUrl() { if (typeof mxchatChat !== 'undefined' && mxchatChat.rest_url) { return mxchatChat.rest_url.replace(/\/+$/, '') + '/nonce'; } // Fallback: derive from current origin if mxchatChat.rest_url isn't set. return window.location.origin + '/wp-json/mxchat/v1/nonce'; } function fetchFreshNonceFromRest() { return fetch(getRestNonceUrl(), { credentials: 'same-origin', headers: { 'Accept': 'application/json' } }).then(function (resp) { if (!resp.ok) { throw new Error('REST nonce fetch failed: ' + resp.status); } return resp.json(); }).then(function (data) { if (data && data.nonce) { return data.nonce; } throw new Error('REST nonce response had no nonce field.'); }); } /** * withFreshNonce(cb) — invoke cb() after ensuring mxchatChat.nonce is fresh. * Tries REST endpoint first (cache-bypass design); falls back to the legacy * admin-ajax refresh path if REST is unavailable. Idempotent — concurrent * calls share the same in-flight refresh. */ function withFreshNonce(callback) { if (typeof mxchatChat === 'undefined') { if (callback) callback(); return; } var now = Date.now(); if (cachedFreshNonce && (now - cachedFreshNonceFetchedAt) < NONCE_TTL_MS) { mxchatChat.nonce = cachedFreshNonce; if (callback) callback(); return; } if (callback) nonceRefreshCallbacks.push(callback); if (nonceRefreshState === 'pending') return; nonceRefreshState = 'pending'; var resolved = function (nonce) { if (nonce) { cachedFreshNonce = nonce; cachedFreshNonceFetchedAt = Date.now(); mxchatChat.nonce = nonce; } nonceRefreshState = 'done'; var pending = nonceRefreshCallbacks; nonceRefreshCallbacks = []; pending.forEach(function (cb) { try { cb(); } catch (e) {} }); }; fetchFreshNonceFromRest() .then(resolved) .catch(function () { // Fallback to the legacy admin-ajax refresh path (issued with the // old action `mxchat_chat_nonce`; the server still accepts both // during the compat window). if (mxchatChat.ajax_url) { $.post(mxchatChat.ajax_url, { action: 'mxchat_refresh_nonce' }) .done(function (res) { if (res && res.success && res.data && res.data.nonce) { resolved(res.data.nonce); return; } resolved(null); }) .fail(function () { resolved(null); }); } else { resolved(null); } }); } // Backwards-compat alias — every existing caller in this file (and any // out-of-tree consumer that hit this internal API) keeps working unchanged. function refreshNonceIfNeeded(callback) { return withFreshNonce(callback); } // Dynamic-settings refresh (plan-32db95). // // Every widget setting ships inline in cached page HTML, so behind a // full-page cache the site owner can't purge (host cache, CDN, the // browser itself) a toggled setting looks broken until the cache turns // over. Same distrust-cached-HTML reasoning as the per-request nonce: // on the FIRST widget open per page load we ask the nonce endpoint for // the current behavior-gate settings (?with_settings=1), merge them over // mxchatChat, and rebuild the header menu. Colors are NOT refreshed — // they're server-inline-styled, so a runtime swap would visibly flash. // On any failure we keep the inline values silently (nonce-fallback // posture). At most one request per page load, only if a widget opens. var dynamicSettingsState = 'idle'; // 'idle' | 'pending' | 'done' function mxchatRefreshDynamicSettings() { if (dynamicSettingsState !== 'idle') return; if (typeof mxchatChat === 'undefined') return; dynamicSettingsState = 'pending'; var applied = function (data) { dynamicSettingsState = 'done'; if (!data) return; // endpoint unavailable — inline values stand. if (data.nonce) { // Seed the nonce cache too: saves the first send's REST // round-trip and keeps us under the endpoint's rate limit. cachedFreshNonce = data.nonce; cachedFreshNonceFetchedAt = Date.now(); mxchatChat.nonce = data.nonce; } if (data.settings && typeof data.settings === 'object') { $.extend(mxchatChat, data.settings); mxchatRebuildHeaderMenus(); } }; fetch(getRestNonceUrl() + '?with_settings=1', { credentials: 'same-origin', headers: { 'Accept': 'application/json' } }).then(function (resp) { if (!resp.ok) throw new Error('settings refresh failed: ' + resp.status); return resp.json(); }).then(applied).catch(function () { // Fallback: legacy admin-ajax refresh path, same as withFreshNonce. if (mxchatChat.ajax_url) { $.post(mxchatChat.ajax_url, { action: 'mxchat_refresh_nonce', with_settings: 1 }) .done(function (res) { applied(res && res.success && res.data ? res.data : null); }) .fail(function () { applied(null); }); } else { applied(null); } }); } // ==================================== // MULTI-INSTANCE MANAGEMENT SYSTEM // ==================================== // Instance registry - tracks all chatbot instances on the page const MxChatInstances = { instances: {}, // Initialize an instance for a bot init: function(botId) { if (!this.instances[botId]) { // When persistence is OFF, track when this session started // so the AI only sees messages from this page load var chatPersistenceEnabled = typeof mxchatChat !== 'undefined' && mxchatChat.chat_persistence_toggle === 'on'; this.instances[botId] = { botId: botId, sessionId: null, lastSeenMessageId: '', notificationCheckInterval: null, pollingInterval: null, processedMessageIds: new Set(), activePdfFile: null, activeWordFile: null, chatHistoryLoaded: false, isStreaming: false, // Fresh context timestamp - only used when persistence is OFF sessionStartTimestamp: chatPersistenceEnabled ? 0 : Date.now() }; } return this.instances[botId]; }, // Get instance by botId get: function(botId) { return this.instances[botId] || this.init(botId); }, // Get all active bot IDs getAllBotIds: function() { return Object.keys(this.instances); }, // Session management per bot // Returns existing session ID from cookie or localStorage (with in-memory fallback), // or null if none exists. Does NOT create a new session — use ensureSession() for that. getChatSession: function(botId) { var cookieName = 'mxchat_session_id_' + botId; var storageKey = 'mxchat_session_id_' + botId; var sessionId = getCookie(cookieName); // Fallback to localStorage if cookie is missing (e.g. cleared by browser/consent) if (!sessionId) { try { sessionId = localStorage.getItem(storageKey); } catch (e) {} } // Fallback to in-memory instance when cookie AND localStorage are both blocked // (Safari ITP, strict tracking prevention, cross-origin iframes with partitioned // storage). Without this, ensureSession() can generate and store an ID that // getChatSession() then can't read back, causing null session_ids on send. if (!sessionId && this.instances[botId] && this.instances[botId].sessionId) { sessionId = this.instances[botId].sessionId; } // Guard against stored sentinel values that indicate earlier broken writes. if (sessionId === 'null' || sessionId === 'undefined') { sessionId = null; } // Re-sync cookie from localStorage if cookie was lost if (sessionId && !getCookie(cookieName)) { document.cookie = cookieName + "=" + sessionId + "; path=/; max-age=86400; SameSite=Lax"; } return sessionId || null; }, // Lazy session initializer — called on first user interaction ensureSession: function(botId) { botId = botId || 'default'; var instance = this.instances[botId] || this.init(botId); if (instance.sessionId) { return instance.sessionId; } // Check for existing session from cookie or localStorage var existingSession = this.getChatSession(botId); if (existingSession) { instance.sessionId = existingSession; } else { // Brand new session var newId = generateSessionId(); this.setChatSession(botId, newId); instance.sessionId = newId; } // Now that we have a session, do the deferred work refreshNonceIfNeeded(); trackOriginatingPage(); // Note: loadChatHistory is handled by showChatContainerForBot with loader UI, // so we do NOT call it here to avoid a race condition. return instance.sessionId; }, setChatSession: function(botId, sessionId) { var cookieName = 'mxchat_session_id_' + botId; var storageKey = 'mxchat_session_id_' + botId; document.cookie = cookieName + "=" + sessionId + "; path=/; max-age=86400; SameSite=Lax"; try { localStorage.setItem(storageKey, sessionId); } catch (e) {} if (this.instances[botId]) { this.instances[botId].sessionId = sessionId; } }, resetChatSession: function(botId) { // Clear old session from localStorage before setting new one try { localStorage.removeItem('mxchat_session_id_' + botId); } catch (e) {} var newSessionId = generateSessionId(); this.setChatSession(botId, newSessionId); var $chatBox = getElement(botId, 'chat-box'); if ($chatBox.length) { $chatBox.find('.user-message, .bot-message:not(:first), .agent-message').remove(); } if (this.instances[botId]) { this.instances[botId].chatHistoryLoaded = false; this.instances[botId].processedMessageIds = new Set(); } }, // Silent reset — new session ID without clearing the chat UI // Used when IP changes mid-conversation so the user doesn't see messages vanish silentResetSession: function(botId) { try { localStorage.removeItem('mxchat_session_id_' + botId); } catch (e) {} var newSessionId = generateSessionId(); this.setChatSession(botId, newSessionId); if (this.instances[botId]) { this.instances[botId].sessionId = newSessionId; } return newSessionId; } }; // ==================================== // ELEMENT SELECTOR HELPERS // ==================================== // Check if a specific bot has an AI theme assigned (skip inline colors) function shouldSkipInlineColors(botId) { // If global AI theme is active, skip inline colors for all bots if (mxchatChat.skip_inline_colors) { return true; } // Check if this specific bot has a theme assignment var botAssignments = mxchatChat.bot_theme_assignments || {}; return botAssignments.hasOwnProperty(botId); } // Get element by ID with bot suffix - returns jQuery object function getElement(botId, elementName) { return $('#' + elementName + '-' + botId); } // Get element by ID with bot suffix - returns DOM element function getElementDOM(botId, elementName) { return document.getElementById(elementName + '-' + botId); } // Get bot ID from any element within a chatbot instance function getBotIdFromElement(element) { var $wrapper = $(element).closest('.mxchat-chatbot-wrapper'); if ($wrapper.length) { return $wrapper.data('bot-id') || 'default'; } // Fallback: try to find from floating container var $floating = $(element).closest('.floating-chatbot'); if ($floating.length) { var id = $floating.attr('id') || ''; var match = id.match(/floating-chatbot-(.+)/); if (match) return match[1]; } // Fallback: the pre-chat teaser bubble (#pre-chat-message-{bot_id}) is a SIBLING // outside .mxchat-chatbot-wrapper / .floating-chatbot, so its children — e.g. the // .close-pre-chat-message button, which carries only a class and no id — miss both // branches above. Walk to the nearest ancestor whose id is pre-chat-message-{bot_id} // and read the suffix. (closest() includes the element itself, so a click directly on // #pre-chat-message-{bot_id} resolves here too.) var $preChat = $(element).closest('[id^="pre-chat-message-"]'); if ($preChat.length) { var preId = $preChat.attr('id') || ''; var preMatch = preId.match(/^pre-chat-message-(.+)$/); if (preMatch) return preMatch[1]; } // Fallback: check if element itself has an ID with bot suffix (e.g., floating-chatbot-button-{bot_id}) var elementId = $(element).attr('id') || ''; if (elementId) { // Match patterns like: floating-chatbot-button-{bot_id}, pre-chat-message-{bot_id} var idMatch = elementId.match(/^(?:floating-chatbot-button|pre-chat-message|chat-notification-badge)-(.+)$/); if (idMatch) return idMatch[1]; } return 'default'; } // Get wrapper element for a bot function getWrapper(botId) { return getElement(botId, 'mxchat-chatbot-wrapper'); } // ==================================== // GLOBAL VARIABLES & CONFIGURATION // ==================================== const toolbarIconColor = mxchatChat.toolbar_icon_color || '#212121'; // Initialize color settings (these are global as they come from PHP) var userMessageBgColor = mxchatChat.user_message_bg_color; var userMessageFontColor = mxchatChat.user_message_font_color; var botMessageBgColor = mxchatChat.bot_message_bg_color; var botMessageFontColor = mxchatChat.bot_message_font_color; var liveAgentMessageBgColor = mxchatChat.live_agent_message_bg_color; var liveAgentMessageFontColor = mxchatChat.live_agent_message_font_color; var linkTarget = mxchatChat.link_target_toggle === 'on' ? '_blank' : '_self'; // ==================================== // SESSION MANAGEMENT (Legacy compatibility) // ==================================== function getCookie(name) { let value = "; " + document.cookie; let parts = value.split("; " + name + "="); if (parts.length == 2) return parts.pop().split(";").shift(); } function generateSessionId() { return 'mxchat_chat_' + Math.random().toString(36).substr(2, 9); } // Legacy function - now delegates to instance manager function getChatSession(botId) { botId = botId || 'default'; return MxChatInstances.getChatSession(botId); } function setChatSession(sessionId, botId) { botId = botId || 'default'; MxChatInstances.setChatSession(botId, sessionId); } function resetChatSession(botId) { botId = botId || 'default'; MxChatInstances.resetChatSession(botId); } // ==================================== // INITIALIZE ALL CHATBOT INSTANCES // ==================================== function initializeAllInstances() { // Find all chatbot wrappers on the page $('.mxchat-chatbot-wrapper').each(function() { var botId = $(this).data('bot-id') || 'default'; MxChatInstances.init(botId); initializeBotInstance(botId); }); } function initializeBotInstance(botId) { var instance = MxChatInstances.get(botId); // Initialize quick questions state for this bot checkQuickQuestionsState(botId); // Note: Event handlers use event delegation with class selectors, // so they work automatically for all instances without per-bot setup } // ==================================== // CONTEXTUAL AWARENESS FUNCTIONALITY // ==================================== function getPageContext() { // Check if contextual awareness is enabled if (mxchatChat.contextual_awareness_toggle !== 'on') { return null; } // Get page URL const pageUrl = window.location.href; // Get page title const pageTitle = document.title || ''; // Get main content from the page let pageContent = ''; // Try to get content from common content areas const contentSelectors = [ 'main', '[role="main"]', '.content', '.main-content', '.post-content', '.entry-content', '.page-content', 'article', '#content', '#main' ]; let contentElement = null; for (const selector of contentSelectors) { contentElement = document.querySelector(selector); if (contentElement) { break; } } // If no specific content area found, use body but exclude header, footer, nav, sidebar if (!contentElement) { contentElement = document.body; } if (contentElement) { // Clone the element to avoid modifying the original const clone = contentElement.cloneNode(true); // Remove unwanted elements const unwantedSelectors = [ 'header', 'footer', 'nav', '.navigation', '.sidebar', '.widget', '.menu', 'script', 'style', '.comments', '#comments', '.breadcrumb', '.breadcrumbs', '#floating-chatbot', '#floating-chatbot-button', '.mxchat', '[class*="chat"]', '[id*="chat"]' ]; unwantedSelectors.forEach(selector => { const elements = clone.querySelectorAll(selector); elements.forEach(el => el.remove()); }); // Extract MxChat context data attributes before getting text content const contextData = []; clone.querySelectorAll('[data-mxchat-context]').forEach(el => { const contextValue = el.dataset.mxchatContext; if (contextValue && contextValue.trim()) { contextData.push(contextValue); } }); // Get text content and clean it up pageContent = clone.textContent || clone.innerText || ''; // Add context data to page content if any were found if (contextData.length > 0) { pageContent += '\n\nAdditional Context:\n' + contextData.join('\n'); } // Clean up whitespace and limit length pageContent = pageContent .replace(/\s+/g, ' ') .trim() .substring(0, 3000); // Limit to 3000 characters to avoid token limits } // Only return context if we have meaningful content if (!pageContent || pageContent.length < 50) { return null; } return { url: pageUrl, title: pageTitle, content: pageContent }; } // Track originating page when chat starts function trackOriginatingPage() { const sessionId = getChatSession(); const pageUrl = window.location.href; const pageTitle = document.title || 'Untitled Page'; // Only track once per session const trackingKey = 'mxchat_originating_tracked_' + sessionId; if (sessionStorage.getItem(trackingKey)) { return; } $.ajax({ url: mxchatChat.ajax_url, type: 'POST', data: { action: 'mxchat_track_originating_page', session_id: sessionId, page_url: pageUrl, page_title: pageTitle, nonce: mxchatChat.nonce }, success: function(response) { if (response.success) { sessionStorage.setItem(trackingKey, 'true'); } } }); } // ==================================== // CORE CHAT FUNCTIONALITY // ==================================== // Helper functions to disable/enable chat input while waiting for response function disableChatInput(botId) { botId = botId || 'default'; var chatInput = getElementDOM(botId, 'chat-input'); var sendButton = getElementDOM(botId, 'send-button'); if (chatInput) { chatInput.disabled = true; chatInput.style.opacity = '0.6'; } if (sendButton) { sendButton.disabled = true; sendButton.style.opacity = '0.5'; sendButton.style.pointerEvents = 'none'; } } function enableChatInput(botId) { botId = botId || 'default'; var chatInput = getElementDOM(botId, 'chat-input'); var sendButton = getElementDOM(botId, 'send-button'); if (chatInput) { chatInput.disabled = false; chatInput.style.opacity = '1'; chatInput.focus(); } if (sendButton) { sendButton.disabled = false; sendButton.style.opacity = '1'; sendButton.style.pointerEvents = 'auto'; } // Every completion path re-enables input, so this is the single restore // point for the streaming Stop affordance (no-op when not in stop mode). mxchatRestoreSendButton(botId); } // --- Streaming Stop control ------------------------------------------------- // One live stream handle per bot instance, so Stop on one widget never aborts // another bot on the same page. var mxchatActiveStreams = {}; // Original send-button markup, captured once per bot the first time the Stop // state is shown (never captured while already in stop mode, so a rapid // stop-then-resend can't save the stop glyph as the "original"). var mxchatSendMarkup = {}; function mxchatShowStopButton(botId) { var btn = getElementDOM(botId, 'send-button'); if (!btn) return; if (!btn.classList.contains('mxchat-stop-mode')) { mxchatSendMarkup[botId] = { html: btn.innerHTML, label: btn.getAttribute('aria-label') }; } // Mirror the send icon's rendered size + color so the stop glyph looks // native, including custom send images/colors and theme overrides. var child = btn.querySelector('svg, img'); var size = 25; var color = ''; if (child) { var rect = child.getBoundingClientRect(); if (rect.width) { size = Math.round(Math.min(rect.width, rect.height)); } var cs = window.getComputedStyle(child); color = (child.tagName.toLowerCase() === 'svg' ? cs.fill : cs.color) || ''; } var stopLabel = (typeof mxchatChat !== 'undefined' && mxchatChat.stop_button_label) || 'Stop response'; btn.innerHTML = ''; // An add-on's DIRECT send-button handler (e.g. mxchat-vision's rebind) can // start a stream synchronously while the originating click is still // bubbling up to our delegated handler. Without this guard, that handler // reads the just-added stop-mode class as a user Stop press and aborts the // brand-new stream — the user's message renders but no reply ever fires // (plan-4bba64 silent message loss). The flag only spans the current event // dispatch: cleared on the next macrotask, long before a real Stop click. btn.__mxchatStopJustShown = true; setTimeout(function () { btn.__mxchatStopJustShown = false; }, 0); btn.classList.add('mxchat-stop-mode'); btn.setAttribute('aria-label', stopLabel); btn.setAttribute('title', stopLabel); // disableChatInput() ran when the turn was sent; the Stop control itself // must stay clickable while the textarea remains disabled. btn.disabled = false; btn.style.opacity = '1'; btn.style.pointerEvents = 'auto'; } function mxchatRestoreSendButton(botId) { var btn = getElementDOM(botId, 'send-button'); var saved = mxchatSendMarkup[botId]; if (!btn || !btn.classList.contains('mxchat-stop-mode') || !saved) return; btn.innerHTML = saved.html; btn.classList.remove('mxchat-stop-mode'); btn.removeAttribute('title'); if (saved.label) { btn.setAttribute('aria-label', saved.label); } } function mxchatStopStreaming(botId) { var entry = mxchatActiveStreams[botId]; if (!entry || !entry.controller) return; entry.aborted = true; try { entry.controller.abort(); } catch (e) {} } // Returns true when a stream rejection came from an intentional Stop click: // keep the partial text as the turn's answer — no error UI, no fallback resend. function mxchatHandleStreamAbort(botId, accumulatedContent, callback) { var entry = mxchatActiveStreams[botId]; if (!entry || !entry.aborted) return false; delete mxchatActiveStreams[botId]; if (!accumulatedContent) { // Stopped before the first chunk: drop the thinking bubble, no orphan message. getElement(botId, 'chat-box').find('.bot-message.temporary-message').remove(); } enableChatInput(botId); // also restores the send icon if (callback) { callback(accumulatedContent || ''); } return true; } // Update your existing sendMessage function function sendMessage(botId) { botId = botId || 'default'; MxChatInstances.ensureSession(botId); var $chatInput = getElement(botId, 'chat-input'); var message = $chatInput.val(); // ADD PROMPT HOOK HERE if (typeof customMxChatFilter === 'function') { message = customMxChatFilter(message, "prompt"); } if (message) { // Don't disable input in live agent mode - let users chat freely var modeIndicator = getElementDOM(botId, 'chat-mode-indicator'); var isAgentMode = modeIndicator && modeIndicator.textContent === 'Live Agent'; if (!isAgentMode) { disableChatInput(botId); } appendMessage("user", message, '', [], false, botId); $chatInput.val(''); mxchatUpdateCharCounter($chatInput[0]); // reset the char counter after send (plan 7091a2) $chatInput.css('height', 'auto'); if (hasQuickQuestions(botId)) { collapseQuickQuestions(botId); } appendThinkingMessage(botId); scrollToBottom(botId); const currentModel = mxchatChat.model || 'gpt-5.1-chat-latest'; // Check if streaming is enabled AND supported for this model if (shouldUseStreaming(currentModel)) { callMxChatStream(message, function(response) { getElement(botId, 'chat-box').find('.bot-message.temporary-message').removeClass('temporary-message'); }, botId); } else { callMxChat(message, function(response) { replaceLastMessage("bot", response, '', [], botId); }, botId); } } } // Update your existing sendMessageToChatbot function function sendMessageToChatbot(message, botId) { botId = botId || 'default'; MxChatInstances.ensureSession(botId); // ADD PROMPT HOOK HERE if (typeof customMxChatFilter === 'function') { message = customMxChatFilter(message, "prompt"); } // Don't disable input in live agent mode - let users chat freely var modeIndicator = getElementDOM(botId, 'chat-mode-indicator'); var isAgentMode = modeIndicator && modeIndicator.textContent === 'Live Agent'; if (!isAgentMode) { disableChatInput(botId); } var sessionId = getChatSession(botId); if (hasQuickQuestions(botId)) { collapseQuickQuestions(botId); } appendThinkingMessage(botId); scrollToBottom(botId); const currentModel = mxchatChat.model || 'gpt-5.1-chat-latest'; // Check if streaming is enabled AND supported for this model if (shouldUseStreaming(currentModel)) { callMxChatStream(message, function(response) { getElement(botId, 'chat-box').find('.bot-message.temporary-message').removeClass('temporary-message'); }, botId); } else { callMxChat(message, function(response) { getElement(botId, 'chat-box').find('.temporary-message').remove(); replaceLastMessage("bot", response, '', [], botId); }, botId); } } // Updated shouldUseStreaming function with debugging function shouldUseStreaming(model) { // Check if streaming is enabled in settings (using your toggle naming pattern) const streamingEnabled = mxchatChat.enable_streaming_toggle === 'on'; // Check if model supports streaming const streamingSupported = isStreamingSupported(model); // Only use streaming if both enabled and supported return streamingEnabled && streamingSupported; } // Helper function to handle chat mode updates function handleChatModeUpdates(response, responseText) { // Check for explicit chat mode in response (THIS IS THE KEY FIX) if (response.chat_mode) { updateChatModeIndicator(response.chat_mode); return; // Return early since we found explicit mode } // Check for fallback response chat mode else if (response.fallbackResponse && response.fallbackResponse.chat_mode) { updateChatModeIndicator(response.fallbackResponse.chat_mode); return; // Return early since we found explicit mode } // Only do text-based detection if no explicit mode was provided // Check for specific AI chatbot response text if (responseText === 'You are now chatting with the AI chatbot.' || responseText.includes('now chatting with the AI') || responseText.includes('switched to AI mode') || responseText.includes('AI chatbot is now')) { updateChatModeIndicator('ai'); } // Check for agent transfer messages else if (responseText.includes('agent') && (responseText.includes('transfer') || responseText.includes('connected'))) { updateChatModeIndicator('agent'); } } // Function to get bot ID from any element or wrapper // If element is provided, finds the bot ID from its wrapper // If no element, returns 'default' (for backward compatibility) function getMxChatBotId(element) { if (element) { return getBotIdFromElement(element); } // Fallback: find first chatbot wrapper on page const chatbotWrapper = document.querySelector('.mxchat-chatbot-wrapper'); return chatbotWrapper ? chatbotWrapper.getAttribute('data-bot-id') || 'default' : 'default'; } function callMxChat(message, callback, botId) { botId = botId || getMxChatBotId(); // Streaming fallbacks land here: drop any leftover stream handle and // return the button to its send state (no-op for plain non-stream turns). if (mxchatActiveStreams[botId]) { delete mxchatActiveStreams[botId]; } mxchatRestoreSendButton(botId); // Store the message in case we need to retry after session reset getElement(botId, 'mxchat-chatbot-wrapper').find('.mxchat-input-holder textarea').data('pending-message', message); // Get page context if contextual awareness is enabled const pageContext = getPageContext(); // Get instance for session start timestamp (used when persistence is OFF) var instance = MxChatInstances.get(botId); // Guarantee a non-null session_id before the AJAX leaves. ensureSession() is idempotent // and returns the guaranteed-present session id from the in-memory instance even when // cookie/localStorage writes are silently blocked by the browser. var sessionId = MxChatInstances.ensureSession(botId); if (!sessionId || sessionId === 'null' || sessionId === 'undefined') { // Last-resort generation to ensure we never POST a null marker. sessionId = generateSessionId(); MxChatInstances.setChatSession(botId, sessionId); } // Wait for the page-cache nonce refresh to complete before firing the // chat-send AJAX. On cached pages the inline mxchatChat.nonce is stale // until refreshNonceIfNeeded() returns; constructing ajaxData inside the // callback guarantees we read the fresh value. See plan-c5457f. refreshNonceIfNeeded(function() { // Prepare AJAX data const ajaxData = { action: 'mxchat_handle_chat_request', message: message, session_id: sessionId, nonce: mxchatChat.nonce, current_page_url: window.location.href, current_page_title: document.title, bot_id: botId, // Pass session start timestamp so AI context matches what user sees session_start_timestamp: instance.sessionStartTimestamp || 0 }; // Add page context if available if (pageContext) { ajaxData.page_context = JSON.stringify(pageContext); } // CHECK FOR VISION FLAGS AND ADD THEM if (window.mxchatVisionProcessed) { ajaxData.vision_processed = true; ajaxData.original_user_message = window.mxchatOriginalMessage || message; ajaxData.vision_images_count = window.mxchatVisionImagesCount || 0; // Clear the flags after use window.mxchatVisionProcessed = false; window.mxchatOriginalMessage = null; window.mxchatVisionImagesCount = 0; } $.ajax({ url: mxchatChat.ajax_url, type: 'POST', dataType: 'json', data: ajaxData, success: function(response) { // IMMEDIATE CHAT MODE UPDATE - This should be FIRST if (response.chat_mode) { updateChatModeIndicator(response.chat_mode, botId); } // Also check in data property if response is wrapped if (response.data && response.data.chat_mode) { updateChatModeIndicator(response.data.chat_mode, botId); } // SECURITY FIX: Check for errors FIRST before checking for success // This ensures API errors (quota exceeded, invalid key, rate limit) are properly displayed if (response.success === false || (response.data && response.data.error_message)) { let errorMessage = ""; let errorCode = ""; // Check various possible error locations in the response if (response.data && response.data.error_message) { errorMessage = response.data.error_message; errorCode = response.data.error_code || ""; } else if (response.error_message) { errorMessage = response.error_message; errorCode = response.error_code || ""; } else if (response.message) { errorMessage = response.message; } else if (typeof response.data === 'string') { errorMessage = response.data; } else { // Fallback for any other unexpected response format errorMessage = "An error occurred. Please try again or contact support."; } // Handle session reset action (IP changed, session expired, etc.) // Silent reset — keep chat UI intact, just get a new session and retry if (response.data && response.data.action === 'reset_session') { MxChatInstances.silentResetSession(botId); // Re-send the original message with the new session (user message is already displayed) var originalMessage = getElement(botId, 'mxchat-chatbot-wrapper').find('.mxchat-input-holder textarea').data('pending-message'); if (originalMessage) { getElement(botId, 'mxchat-chatbot-wrapper').find('.mxchat-input-holder textarea').data('pending-message', null); var currentModel = mxchatChat.model || 'gpt-5.1-chat-latest'; if (shouldUseStreaming(currentModel)) { callMxChatStream(originalMessage, function(response) { getElement(botId, 'chat-box').find('.bot-message.temporary-message').removeClass('temporary-message'); }, botId); } else { callMxChat(originalMessage, function(response) { replaceLastMessage("bot", response, '', [], botId); }, botId); } } return; } // Format user-friendly error message let displayMessage = errorMessage; // Customize message for admin users if (mxchatChat.is_admin) { // For admin users, show more technical details including error code displayMessage = errorMessage + (errorCode ? " (Error code: " + errorCode + ")" : ""); } replaceLastMessage("bot", displayMessage, '', [], botId); return; // Exit early for errors } // NOW check if this is a successful response by looking for text, html, or message fields // This preserves compatibility with your server response format if (response.text !== undefined || response.html !== undefined || response.message !== undefined || (response.success === true && response.data && response.data.status === 'waiting_for_agent')) { // Handle successful response - this is your original success handling code // Handle other responses let responseText = response.text || ''; let responseHtml = response.html || ''; let responseMessage = response.message || ''; // Add PDF filename handling if (response.data && response.data.filename) { showActivePdf(response.data.filename, botId); var instance = MxChatInstances.get(botId); instance.activePdfFile = response.data.filename; } // Add redirect check here if (response.redirect_url) { if (responseText) { replaceLastMessage("bot", responseText, '', [], botId); } setTimeout(() => { window.location.href = response.redirect_url; }, 1500); return; } // Check for live agent response if (response.success && response.data && response.data.status === 'waiting_for_agent') { removeThinkingDots(botId); updateChatModeIndicator('agent', botId); enableChatInput(botId); return; } // Handle the message and show notification if chat is hidden if (responseText || responseHtml || responseMessage) { // ADD RESPONSE HOOKS HERE - BEFORE DISPLAYING if (responseText && typeof customMxChatFilter === 'function') { responseText = customMxChatFilter(responseText, "response"); } if (responseMessage && typeof customMxChatFilter === 'function') { responseMessage = customMxChatFilter(responseMessage, "response"); } // Update the messages as before if (responseText && responseHtml) { replaceLastMessage("bot", responseText, responseHtml, [], botId); } else if (responseText) { replaceLastMessage("bot", responseText, '', [], botId); } else if (responseHtml) { replaceLastMessage("bot", "", responseHtml, [], botId); } else if (responseMessage) { replaceLastMessage("bot", responseMessage, '', [], botId); } // Check if chat is hidden and show notification var $floatingChatbot = getElement(botId, 'floating-chatbot'); if ($floatingChatbot.hasClass('hidden')) { var $badge = getElement(botId, 'chat-notification-badge'); if ($badge.length) { $badge.show(); } } } else { var emptyMsg = "I received an empty response. Please try again or contact support if this persists."; if (response.vectorstore_error) { emptyMsg = "I received an empty response. Debug info: " + response.vectorstore_error; } replaceLastMessage("bot", emptyMsg, '', [], botId); } if (response.message_id) { var instance = MxChatInstances.get(botId); instance.lastSeenMessageId = response.message_id; } return; } // Fallback for truly unexpected response formats replaceLastMessage("bot", "Unexpected response format. Please try again or contact support.", '', [], botId); }, error: function(xhr, status, error) { let errorMessage = "An unexpected error occurred."; // Try to parse the response if it's JSON try { const responseJson = JSON.parse(xhr.responseText); if (responseJson.data && responseJson.data.error_message) { errorMessage = responseJson.data.error_message; } else if (responseJson.message) { errorMessage = responseJson.message; } } catch (e) { // Not JSON or parsing failed, use HTTP status based messages if (xhr.status === 0) { errorMessage = "Network error: Please check your internet connection."; } else if (xhr.status === 403) { errorMessage = "Access denied: Your session may have expired. Please refresh the page."; } else if (xhr.status === 404) { errorMessage = "API endpoint not found. Please contact support."; } else if (xhr.status === 429) { errorMessage = "Too many requests. Please try again in a moment."; } else if (xhr.status >= 500) { errorMessage = "Server error: The server encountered an issue. Please try again later."; } } replaceLastMessage("bot", errorMessage, '', [], botId); } }); }); // refreshNonceIfNeeded } function callMxChatStream(message, callback, botId) { botId = botId || getMxChatBotId(); // Store the message in case we need to retry after session reset getElement(botId, 'mxchat-chatbot-wrapper').find('.mxchat-input-holder textarea').data('pending-message', message); const currentModel = mxchatChat.model || 'gpt-5.1-chat-latest'; if (!isStreamingSupported(currentModel)) { callMxChat(message, callback, botId); return; } // Get page context if contextual awareness is enabled const pageContext = getPageContext(); // Get instance for session start timestamp (used when persistence is OFF) var instance = MxChatInstances.get(botId); // Guarantee a non-null session_id before the fetch. FormData.append() stringifies any // non-string value via String(), so passing `null` would POST the literal string "null" // and land in the transcripts table as a ghost session. ensureSession() always returns // a real string even when cookies/localStorage are blocked. var streamSessionId = MxChatInstances.ensureSession(botId); if (!streamSessionId || streamSessionId === 'null' || streamSessionId === 'undefined') { streamSessionId = generateSessionId(); MxChatInstances.setChatSession(botId, streamSessionId); } // Wait for the page-cache nonce refresh before constructing formData (which // captures mxchatChat.nonce by value). Mirrors callMxChat's wrapping. See plan-c5457f. refreshNonceIfNeeded(function() { const formData = new FormData(); formData.append('action', 'mxchat_stream_chat'); formData.append('message', message); formData.append('session_id', streamSessionId); formData.append('nonce', mxchatChat.nonce); formData.append('current_page_url', window.location.href); formData.append('current_page_title', document.title); formData.append('bot_id', botId); // Pass session start timestamp so AI context matches what user sees formData.append('session_start_timestamp', instance.sessionStartTimestamp || 0); // Add page context if available if (pageContext) { formData.append('page_context', JSON.stringify(pageContext)); } // CHECK FOR VISION FLAGS AND ADD THEM if (window.mxchatVisionProcessed) { formData.append('vision_processed', 'true'); formData.append('original_user_message', window.mxchatOriginalMessage || message); formData.append('vision_images_count', window.mxchatVisionImagesCount || '0'); // Clear the flags after use window.mxchatVisionProcessed = false; window.mxchatOriginalMessage = null; window.mxchatVisionImagesCount = 0; } let accumulatedContent = ''; let testingDataReceived = false; let streamingStarted = false; // Abortable stream: a fresh controller per turn, keyed by bot instance. // The Stop control (send button swapped in place) aborts both the read // loop and the underlying request. var streamControl = { controller: new AbortController(), aborted: false }; mxchatActiveStreams[botId] = streamControl; mxchatShowStopButton(botId); fetch(mxchatChat.ajax_url, { method: 'POST', body: formData, credentials: 'same-origin', signal: streamControl.controller.signal }) .then(response => { // Store the response for potential fallback handling const responseClone = response.clone(); if (!response.ok) { // Try to get error details from response return responseClone.json().then(errorData => { throw { isServerError: true, data: errorData }; }).catch(() => { throw new Error('Network response was not ok'); }); } // Check if response is JSON instead of streaming const contentType = response.headers.get('content-type'); if (contentType && contentType.includes('application/json')) { return responseClone.json().then(data => { // IMMEDIATE CHAT MODE UPDATE for JSON response if (data.chat_mode) { updateChatModeIndicator(data.chat_mode, botId); } // Check for testing panel if (window.mxchatTestPanelInstance && data.testing_data) { window.mxchatTestPanelInstance.handleTestingData(data.testing_data); } // Handle the JSON response directly handleNonStreamResponse(data, callback, botId); return Promise.resolve(); // Prevent further processing }); } // Continue with streaming processing const reader = response.body.getReader(); const decoder = new TextDecoder(); let buffer = ''; function processStream() { reader.read().then(({ done, value }) => { if (done) { // If streaming completed but no content was received, try to get response as fallback if (!streamingStarted || !accumulatedContent) { // Try to read the response as JSON responseClone.text().then(text => { try { const data = JSON.parse(text); if (data.text || data.message || data.html) { handleNonStreamResponse(data, callback, botId); } else { // No valid data, fall back to regular call getElement(botId, 'chat-box').find('.bot-message.temporary-message').remove(); callMxChat(message, callback, botId); } } catch (e) { // Could not parse, fall back to regular call getElement(botId, 'chat-box').find('.bot-message.temporary-message').remove(); callMxChat(message, callback, botId); } }).catch(() => { getElement(botId, 'chat-box').find('.bot-message.temporary-message').remove(); callMxChat(message, callback, botId); }); return; } // Re-enable chat input when stream ends with content enableChatInput(botId); // Scroll the user's last message to the top now that the // bot's full reply has rendered (gives max reading room). var $chatBoxDone = getElement(botId, 'chat-box'); var $lastUserMsgDone = $chatBoxDone.find('.user-message').last(); if ($lastUserMsgDone.length) { scrollElementToTop($lastUserMsgDone, botId); } if (callback) { callback(accumulatedContent); } return; } buffer += decoder.decode(value, { stream: true }); const lines = buffer.split('\n'); buffer = lines.pop() || ''; for (const line of lines) { if (line.startsWith('data: ')) { const data = line.substring(6); if (data === '[DONE]') { if (!accumulatedContent) { getElement(botId, 'chat-box').find('.bot-message.temporary-message').remove(); callMxChat(message, callback, botId); return; } // Re-enable chat input after streaming completes enableChatInput(botId); // Scroll the user's last message to the top now // that the bot's full reply has rendered. var $chatBoxStreamDone = getElement(botId, 'chat-box'); var $lastUserMsgStreamDone = $chatBoxStreamDone.find('.user-message').last(); if ($lastUserMsgStreamDone.length) { scrollElementToTop($lastUserMsgStreamDone, botId); } if (callback) { callback(accumulatedContent); } return; } try { const json = JSON.parse(data); // IMMEDIATE CHAT MODE UPDATE FOR STREAMING if (json.chat_mode) { updateChatModeIndicator(json.chat_mode, botId); } // Handle testing data if (json.testing_data && !testingDataReceived) { if (window.mxchatTestPanelInstance) { window.mxchatTestPanelInstance.handleTestingData(json.testing_data); testingDataReceived = true; } } // Handle content streaming else if (json.content) { streamingStarted = true; accumulatedContent += json.content; updateStreamingMessage(accumulatedContent, botId); } // Handle complete response in stream (fallback response) else if (json.text || json.message || json.html) { handleNonStreamResponse(json, callback, botId); return; } // Handle errors else if (json.error) { // Get error message from various possible fields let errorMessage = json.error_message || json.message || json.text || (typeof json.error === 'string' ? json.error : 'An error occurred. Please try again.'); // Re-enable chat input on error enableChatInput(botId); // Display the error directly in the chat replaceLastMessage("bot", errorMessage, '', [], botId); if (callback) { callback(errorMessage); } return; } } catch (e) { // SSE data parsing error - silently continue } } } processStream(); }).catch(streamError => { if (mxchatHandleStreamAbort(botId, accumulatedContent, callback)) return; getElement(botId, 'chat-box').find('.bot-message.temporary-message').remove(); callMxChat(message, callback, botId); }); } processStream(); }) .catch(error => { if (mxchatHandleStreamAbort(botId, accumulatedContent, callback)) return; // Check if we have server error data with chat mode if (error && error.isServerError && error.data) { // Check for chat mode in error data if (error.data.chat_mode) { updateChatModeIndicator(error.data.chat_mode, botId); } handleNonStreamResponse(error.data, callback, botId); } else { // Only fall back to regular call if we don't have any response data getElement(botId, 'chat-box').find('.bot-message.temporary-message').remove(); callMxChat(message, callback, botId); } }); }); // refreshNonceIfNeeded } // Helper function to handle non-streaming responses function handleNonStreamResponse(data, callback, botId) { botId = botId || 'default'; // IMMEDIATE CHAT MODE UPDATE FOR NON-STREAMING RESPONSES if (data.chat_mode) { updateChatModeIndicator(data.chat_mode, botId); } // Also check in data property if response is wrapped if (data.data && data.data.chat_mode) { updateChatModeIndicator(data.data.chat_mode, botId); } // NOTE: Don't remove temporary message here - let replaceLastMessage handle it // This prevents a visual gap between thinking dots disappearing and content appearing // SECURITY FIX: Check for errors FIRST if (data.success === false || (data.data && data.data.error_message)) { let errorMessage = ""; let errorCode = ""; // Check various possible error locations if (data.data && data.data.error_message) { errorMessage = data.data.error_message; errorCode = data.data.error_code || ""; } else if (data.error_message) { errorMessage = data.error_message; errorCode = data.error_code || ""; } else if (data.message) { errorMessage = data.message; } else if (typeof data.data === 'string') { errorMessage = data.data; } else { errorMessage = "An error occurred. Please try again or contact support."; } // Handle session reset action (IP changed, session expired, etc.) // Silent reset — keep chat UI intact, just get a new session and retry if (data.data && data.data.action === 'reset_session') { MxChatInstances.silentResetSession(botId); // Re-send the original message with the new session (user message is already displayed) var originalMessage = getElement(botId, 'mxchat-chatbot-wrapper').find('.mxchat-input-holder textarea').data('pending-message'); if (originalMessage) { getElement(botId, 'mxchat-chatbot-wrapper').find('.mxchat-input-holder textarea').data('pending-message', null); var currentModel = mxchatChat.model || 'gpt-5.1-chat-latest'; if (shouldUseStreaming(currentModel)) { callMxChatStream(originalMessage, callback, botId); } else { callMxChat(originalMessage, callback, botId); } } return; } // Format user-friendly error message let displayMessage = errorMessage; if (mxchatChat.is_admin) { displayMessage = errorMessage + (errorCode ? " (Error code: " + errorCode + ")" : ""); } replaceLastMessage("bot", displayMessage, '', [], botId); if (callback) { callback(''); } return; // Exit early for errors } // Check for live agent response if (data.success && data.data && data.data.status === 'waiting_for_agent') { removeThinkingDots(botId); // Also remove any leftover bot-message that lost its temporary-message class var $chatBox = getElement(botId, 'chat-box'); $chatBox.find('.bot-message .thinking-dots').closest('.bot-message').remove(); updateChatModeIndicator('agent', botId); enableChatInput(botId); if (callback) { callback(''); } return; } // Handle different response formats if (data.text || data.html || data.message) { // Apply response hooks if (data.text && typeof customMxChatFilter === 'function') { data.text = customMxChatFilter(data.text, "response"); } if (data.message && typeof customMxChatFilter === 'function') { data.message = customMxChatFilter(data.message, "response"); } // Display the response if (data.text && data.html) { replaceLastMessage("bot", data.text, data.html, [], botId); } else if (data.text) { replaceLastMessage("bot", data.text, '', [], botId); } else if (data.html) { replaceLastMessage("bot", "", data.html, [], botId); } else if (data.message) { replaceLastMessage("bot", data.message, '', [], botId); } } // Handle other response properties if (data.data && data.data.filename) { showActivePdf(data.data.filename, botId); var instance = MxChatInstances.get(botId); instance.activePdfFile = data.data.filename; } if (data.redirect_url) { setTimeout(() => { window.location.href = data.redirect_url; }, 1500); } // Ensure chat input is re-enabled (safety net for edge cases) enableChatInput(botId); if (callback) { callback(data.text || data.message || ''); } } // Enhanced updateChatModeIndicator function for immediate DOM updates function updateChatModeIndicator(mode, botId) { botId = botId || 'default'; const indicator = getElementDOM(botId, 'chat-mode-indicator'); if (indicator) { const oldText = indicator.textContent; if (mode === 'agent') { indicator.textContent = 'Live Agent'; startPolling(botId); } else { // Everything else is AI mode const customAiText = indicator.getAttribute('data-ai-text') || 'AI Agent'; indicator.textContent = customAiText; stopPolling(botId); } // Force immediate DOM update and reflow if (oldText !== indicator.textContent) { // Force a reflow to ensure the change is visible immediately indicator.style.display = 'none'; indicator.offsetHeight; // Trigger reflow indicator.style.display = ''; // Double-check after a brief moment to ensure the change stuck setTimeout(() => { if (mode === 'agent' && indicator.textContent !== 'Live Agent') { indicator.textContent = 'Live Agent'; } else if (mode !== 'agent' && indicator.textContent === 'Live Agent') { const customAiText = indicator.getAttribute('data-ai-text') || 'AI Agent'; indicator.textContent = customAiText; } }, 50); } } } // Function to update message during streaming function updateStreamingMessage(content, botId) { botId = botId || 'default'; // ADD RESPONSE HOOK FOR REAL-TIME STREAMING if (typeof customMxChatFilter === 'function') { content = customMxChatFilter(content, "response"); } const formattedContent = linkify(content); // Find the temporary message in this bot's chat box var $chatBox = getElement(botId, 'chat-box'); const tempMessage = $chatBox.find('.bot-message.temporary-message').last(); if (tempMessage.length) { // Update existing message tempMessage.html(formattedContent); } else { // Create new temporary message if it doesn't exist appendMessage("bot", content, '', [], true, botId); } } function isStreamingSupported(model) { if (!model) return false; const modelPrefix = model.split('-')[0].toLowerCase(); // Support streaming for OpenAI, Claude, Grok, DeepSeek, and OpenRouter models const isSupported = modelPrefix === 'gpt' || modelPrefix === 'o1' || modelPrefix === 'claude' || modelPrefix === 'grok' || modelPrefix === 'deepseek' || model === 'openrouter'; // Add this line - check full model name for OpenRouter return isSupported; } // Update the event handlers to use the correct function names (using event delegation) // Use class-based selectors for multi-instance support $(document).on('click', '.send-button', function() { var botId = getBotIdFromElement(this); // While a response is streaming the button is a Stop control. if (this.classList.contains('mxchat-stop-mode')) { // Same click that just started this stream (an add-on's direct handler // ran before this delegated one) — not a Stop press. See // mxchatShowStopButton for the full story (plan-4bba64). if (this.__mxchatStopJustShown) return; mxchatStopStreaming(botId); return; } var modeIndicator = getElementDOM(botId, 'chat-mode-indicator'); if (!(modeIndicator && modeIndicator.textContent === 'Live Agent')) { disableChatInput(botId); } sendMessage(botId); }); // Override enter key handler (using event delegation) $(document).on('keypress', '.chat-input', function(e) { if (e.which == 13 && !e.shiftKey) { e.preventDefault(); var botId = getBotIdFromElement(this); var modeIndicator = getElementDOM(botId, 'chat-mode-indicator'); if (!(modeIndicator && modeIndicator.textContent === 'Live Agent')) { disableChatInput(botId); } sendMessage(botId); } }); // Chat input character counter + soft limit feedback (plan 7091a2). // Language-neutral: numbers + color only, no translatable strings. The counter // reveals near the cap and ramps neutral -> amber -> red; an over-limit keystroke // or trimmed paste produces a brief border-flash/shake so the maxlength cap (plan // a3fae2) is never a silent "input jumps back". Per-bot scoped via .input-container. function mxchatUpdateCharCounter(inputEl) { if (!inputEl || !inputEl.closest) return; var max = parseInt(inputEl.getAttribute('maxlength'), 10); var container = inputEl.closest('.input-container'); if (!container || !max || max <= 0) return; var counter = container.querySelector('.mxchat-char-counter'); if (!counter) return; var len = inputEl.value.length; var ratio = len / max; var nearThreshold = 0.8; // start surfacing the counter at 80% of the cap var cur = counter.querySelector('.mxchat-char-counter-current'); if (cur) cur.textContent = len; var warn = ratio >= nearThreshold && len < max; var full = len >= max; counter.classList.toggle('is-visible', ratio >= nearThreshold); counter.classList.toggle('is-warn', warn); counter.classList.toggle('is-full', full); container.classList.toggle('mxchat-input-near-limit', warn); container.classList.toggle('mxchat-input-at-limit', full); } function mxchatBumpInput(inputEl) { var container = inputEl && inputEl.closest ? inputEl.closest('.input-container') : null; if (!container) return; container.classList.remove('mxchat-input-bump'); void container.offsetWidth; // reflow so a rapid second hit retriggers the animation container.classList.add('mxchat-input-bump'); clearTimeout($(container).data('mxchatBumpTimeout')); var t = setTimeout(function() { container.classList.remove('mxchat-input-bump'); }, 220); $(container).data('mxchatBumpTimeout', t); } // Live counter update on every input. $(document).on('input', '.chat-input', function() { mxchatUpdateCharCounter(this); }); // Visible "you've hit the edge" feedback when a printable keystroke is about to be // rejected at the cap (maxlength silently swallows it otherwise). $(document).on('keydown', '.chat-input', function(e) { var max = parseInt(this.getAttribute('maxlength'), 10); if (!max || max <= 0 || this.value.length < max) return; if (e.ctrlKey || e.metaKey || e.altKey) return; // A single printable char with no selection to overwrite WILL be rejected. if (e.key && e.key.length === 1 && this.selectionStart === this.selectionEnd) { mxchatBumpInput(this); } }); // A paste that gets trimmed to the cap also bumps, so truncation is never silent. $(document).on('paste', '.chat-input', function() { var el = this; var max = parseInt(el.getAttribute('maxlength'), 10); if (!max || max <= 0) return; setTimeout(function() { mxchatUpdateCharCounter(el); if (el.value.length >= max) mxchatBumpInput(el); }, 0); }); // Builds the list of overflow-menu items for a given bot. // Adding a future item is one push to this array — do NOT hardcode "only download." function mxchatGetHeaderMenuItems(botId) { var items = []; var settings = (typeof mxchatChat !== 'undefined') ? mxchatChat : {}; // The `print_button_*` keys still gate this item for back-compat with // existing user options. The action is now a transcript download, not print. if (settings.print_button_enabled === 'on') { items.push({ id: 'download-transcript', label: settings.print_button_label || 'Download Transcript', icon: '', action: function() { mxchatDownloadTranscript(botId); } }); } // "Start new chat" — surfaces the EXISTING per-conversation reset // (MxChatInstances.resetChatSession) so a visitor can start a fresh thread // without the site owner disabling chat persistence globally. Default OFF; // gated by the reset_chat_enabled option. plan ac2e81. if (settings.reset_chat_enabled === 'on') { items.push({ id: 'reset-chat', label: settings.reset_chat_label || 'Start new chat', icon: '', action: function() { var confirmMsg = settings.reset_chat_confirm || 'Start a new chat? This clears the current conversation.'; if (window.confirm(confirmMsg)) { MxChatInstances.resetChatSession(botId); } } }); } return items; } // Builds a clean markdown transcript of the current conversation and triggers // a file download. Used by the "Download Transcript" menu item. function mxchatDownloadTranscript(botId) { var $chatBox = getElement(botId, 'chat-box'); if (!$chatBox || !$chatBox.length) return; var settings = (typeof mxchatChat !== 'undefined') ? mxchatChat : {}; var headerTitle = settings.print_header_title || 'Chat transcript'; var now = new Date(); var stamp = now.toLocaleString(); var lines = []; lines.push('# ' + headerTitle); lines.push(''); lines.push('Exported: ' + stamp); lines.push(''); lines.push('---'); lines.push(''); $chatBox.find('.user-message, .bot-message, .agent-message').each(function() { var $msg = $(this); // Skip thinking placeholders and any in-flight temporary messages. if ($msg.find('.thinking-dots').length) return; if ($msg.hasClass('temporary-message')) return; var sender; if ($msg.hasClass('user-message')) sender = 'User'; else if ($msg.hasClass('agent-message')) sender = 'Live Agent'; else sender = 'AI Agent'; // Strip interactive UI from the cloned message so we get the conversation text. var $clone = $msg.clone(); $clone.find('.copy-button, .message-toolbar, .mxchat-copy, button, script, style').remove(); var text = $clone.text().replace(/ /g, ' ').replace(/[ \t]+\n/g, '\n').replace(/\n{3,}/g, '\n\n').trim(); if (!text) return; lines.push('**' + sender + '**'); lines.push(''); lines.push(text); lines.push(''); }); var content = lines.join('\n'); var iso = now.toISOString().replace(/[:.]/g, '-').slice(0, 19); var fname = 'mxchat-transcript-' + iso + '.md'; var blob = new Blob([content], { type: 'text/markdown;charset=utf-8' }); var url = URL.createObjectURL(blob); var a = document.createElement('a'); a.href = url; a.download = fname; a.style.display = 'none'; document.body.appendChild(a); a.click(); setTimeout(function() { if (a.parentNode) a.parentNode.removeChild(a); URL.revokeObjectURL(url); }, 100); } // Reads the bot bubble's actual computed bg+fg and writes them as CSS vars // on the menu wrap, so the dropdown matches whatever paints the bubble — // saved options, AI theme CSS, or the mxchat-theme add-on. function mxchatSyncMenuColors(botId, $wrap) { if (!$wrap || !$wrap.length) return; var $bot = $wrap.closest('.mxchat-chatbot-wrapper').find('.bot-message').not('.temporary-message').first(); if (!$bot.length) return; var cs = window.getComputedStyle($bot[0]); if (cs.backgroundColor && cs.backgroundColor !== 'rgba(0, 0, 0, 0)' && cs.backgroundColor !== 'transparent') { $wrap[0].style.setProperty('--mxchat-menu-bg', cs.backgroundColor); } // Bot text color usually lives on a child div, not .bot-message itself. var $textChild = $bot.find('[style*="color"]').first(); var fg = ($textChild.length ? window.getComputedStyle($textChild[0]).color : cs.color); if (fg) $wrap[0].style.setProperty('--mxchat-menu-fg', fg); } // Renders (or re-renders) the item list for one menu wrap. Split out of // mxchatInitHeaderMenu so the dynamic-settings merge (plan-32db95) can // rebuild items + trigger visibility WITHOUT re-binding the one-time // open/close/keyboard wiring. closeMenu is passed in by the init closure; // a rebuild before init (never happens, but harmless) just skips it. function mxchatRenderHeaderMenuItems(botId, $wrap, closeMenuFn) { var $trigger = $wrap.find('.mxchat-menu-trigger'); var $menu = $wrap.find('.mxchat-header-menu'); var items = mxchatGetHeaderMenuItems(botId); $menu.empty(); if (!items.length) { $trigger.hide(); $menu.hide(); return; } // Clear any inline display:none a previous zero-item render left behind — // open/close visibility is governed by the hidden prop + is-open class. $trigger.css('display', ''); $menu.css('display', ''); items.forEach(function(item, idx) { var $btn = $('
${escapedCode}
`; }); // Handle inline code with single backticks text = text.replace(/`([^`\n]+)`/g, '$1'); // Handle raw PHP tags (legacy support) text = text.replace(/(<\?php[\s\S]*?\?>)/g, (match) => { const escapedCode = escapeHtml(match); return `
php
${escapedCode}
`; }); return text; } function formatMarkdownTables(text) { var lines = text.split('\n'); var result = []; var i = 0; while (i < lines.length) { // Check for a table: current line has pipes AND next line is a separator row if (i + 1 < lines.length && lines[i].indexOf('|') !== -1 && /^\s*\|?[\s\-:]+(\|[\s\-:]+)+\|?\s*$/.test(lines[i + 1])) { var tableLines = []; var headerLine = lines[i]; var separatorLine = lines[i + 1]; tableLines.push(headerLine); tableLines.push(separatorLine); // Collect remaining table rows var j = i + 2; while (j < lines.length && lines[j].indexOf('|') !== -1 && lines[j].trim() !== '') { tableLines.push(lines[j]); j++; } // Parse alignment from separator row var sepCells = separatorLine.split('|').filter(function(c) { return c.trim() !== ''; }); var alignments = sepCells.map(function(cell) { var trimmed = cell.trim(); if (trimmed.charAt(0) === ':' && trimmed.charAt(trimmed.length - 1) === ':') return 'center'; if (trimmed.charAt(trimmed.length - 1) === ':') return 'right'; return 'left'; }); // Build HTML table var html = '
'; // Header row var headerCells = tableLines[0].split('|').filter(function(c) { return c.trim() !== ''; }); html += ''; headerCells.forEach(function(cell, idx) { var align = alignments[idx] || 'left'; html += ''; }); html += ''; // Body rows html += ''; for (var r = 2; r < tableLines.length; r++) { var rowCells = tableLines[r].split('|').filter(function(c) { return c.trim() !== ''; }); html += ''; rowCells.forEach(function(cell, idx) { var align = alignments[idx] || 'left'; html += ''; }); html += ''; } html += '
' + cell.trim() + '
' + cell.trim() + '
'; result.push(html); i = j; } else { result.push(lines[i]); i++; } } return result.join('\n'); } function sanitizeUserInput(text) { const div = document.createElement('div'); div.textContent = text; return div.innerHTML; } function escapeHtml(unsafe) { // Skip escaping if it's already escaped or contains HTML code block markup if (unsafe.includes('<') || unsafe.includes('>') || unsafe.includes('
')) { return unsafe; } return unsafe .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'"); } function decodeHTMLEntities(text) { var textArea = document.createElement('textarea'); textArea.innerHTML = text; return textArea.value; } // ==================================== // UI & SCROLLING CONTROLS // ==================================== function scrollToBottom(botIdOrInstant, instant) { // Handle backward compatibility: scrollToBottom() or scrollToBottom(true/false) var botId = 'default'; if (typeof botIdOrInstant === 'string') { botId = botIdOrInstant; instant = instant || false; } else if (typeof botIdOrInstant === 'boolean') { instant = botIdOrInstant; } else { instant = false; } var chatBox = getElement(botId, 'chat-box'); if (instant) { // Instantly set the scroll position to the bottom chatBox.scrollTop(chatBox.prop("scrollHeight")); } else { // Use requestAnimationFrame for smoother scrolling if needed let start = null; const scrollHeight = chatBox.prop("scrollHeight"); const initialScroll = chatBox.scrollTop(); const distance = scrollHeight - initialScroll; const duration = 500; // Duration in ms function smoothScroll(timestamp) { if (!start) start = timestamp; const progress = timestamp - start; const currentScroll = initialScroll + (distance * (progress / duration)); chatBox.scrollTop(currentScroll); if (progress < duration) { requestAnimationFrame(smoothScroll); } else { chatBox.scrollTop(scrollHeight); // Ensure it's exactly at the bottom } } requestAnimationFrame(smoothScroll); } } function scrollElementToTop(element, botId, topOffset) { botId = botId || 'default'; topOffset = (typeof topOffset === 'number') ? topOffset : 2; var chatBox = getElement(botId, 'chat-box'); var elementTop = element.position().top + chatBox.scrollTop(); chatBox.animate({ scrollTop: Math.max(0, elementTop - topOffset) }, 500); } function showChatWidget(botId) { botId = botId || 'default'; var $button = getElement(botId, 'floating-chatbot-button'); // First ensure display is set $button.css('display', 'flex'); // Then handle the fade $button.fadeTo(500, 1); // Force visibility $button.removeClass('hidden'); } function hideChatWidget(botId) { botId = botId || 'default'; var $button = getElement(botId, 'floating-chatbot-button'); $button.css('display', 'none'); $button.addClass('hidden'); } function disableScroll() { if (isMobile()) { $('body').css('overflow', 'hidden'); } } function enableScroll() { if (isMobile()) { $('body').css('overflow', ''); } } function isMobile() { // This can be a simple check, or more sophisticated detection of mobile devices return window.innerWidth <= 768; // Example threshold for mobile devices } function setFullHeight() { var vh = $(window).innerHeight() * 0.01; $(':root').css('--vh', vh + 'px'); } // ==================================== // NOTIFICATION SYSTEM // ==================================== function createNotificationBadge() { const chatButton = document.getElementById('floating-chatbot-button'); if (!chatButton) return; // Remove any existing badge first const existingBadge = chatButton.querySelector('.chat-notification-badge'); if (existingBadge) { existingBadge.remove(); } notificationBadge = document.createElement('div'); notificationBadge.className = 'chat-notification-badge'; notificationBadge.style.cssText = ` display: none; position: absolute; top: -5px; right: -5px; background-color: red; color: white; border-radius: 50%; padding: 4px 8px; font-size: 12px; font-weight: bold; z-index: 10001; `; chatButton.style.position = 'relative'; chatButton.appendChild(notificationBadge); } function showNotification(botId) { botId = botId || 'default'; const badge = getElementDOM(botId, 'chat-notification-badge'); var $floatingChatbot = getElement(botId, 'floating-chatbot'); if (badge && $floatingChatbot.hasClass('hidden')) { badge.style.display = 'block'; badge.textContent = '1'; } } function hideNotification(botId) { botId = botId || 'default'; const badge = getElementDOM(botId, 'chat-notification-badge'); if (badge) { badge.style.display = 'none'; } } function startNotificationChecking(botId) { botId = botId || 'default'; const chatPersistenceEnabled = mxchatChat.chat_persistence_toggle === 'on'; if (!chatPersistenceEnabled) return; createNotificationBadge(botId); var instance = MxChatInstances.get(botId); instance.notificationCheckInterval = setInterval(function() { checkForNewMessages(botId); }, 30000); // Check every 30 seconds } function stopNotificationChecking(botId) { botId = botId || 'default'; var instance = MxChatInstances.get(botId); if (instance.notificationCheckInterval) { clearInterval(instance.notificationCheckInterval); } } function checkForNewMessages() { const sessionId = getChatSession(); const chatPersistenceEnabled = mxchatChat.chat_persistence_toggle === 'on'; if (!chatPersistenceEnabled) return; $.ajax({ url: mxchatChat.ajax_url, type: 'POST', data: { action: 'mxchat_check_new_messages', session_id: sessionId, last_seen_id: lastSeenMessageId, nonce: mxchatChat.nonce }, success: function(response) { if (response.success && response.data.hasNewMessages) { showNotification(); } } }); } // ==================================== // LIVE AGENT FUNCTIONALITY // ==================================== function startPolling(botId) { botId = botId || 'default'; var instance = MxChatInstances.get(botId); // Clear any existing interval first stopPolling(botId); instance.pollingInterval = setInterval(function() { checkForAgentMessages(botId); }, 5000); } function stopPolling(botId) { botId = botId || 'default'; var instance = MxChatInstances.get(botId); if (instance.pollingInterval) { clearInterval(instance.pollingInterval); instance.pollingInterval = null; } } function checkForAgentMessages(botId) { botId = botId || 'default'; var instance = MxChatInstances.get(botId); const sessionId = getChatSession(botId); $.ajax({ url: mxchatChat.ajax_url, type: 'POST', dataType: 'json', data: { action: 'mxchat_fetch_new_messages', session_id: sessionId, last_seen_id: instance.lastSeenMessageId, persistence_enabled: 'true', nonce: mxchatChat.nonce }, success: function (response) { if (response.success && response.data?.new_messages) { let hasNewMessage = false; response.data.new_messages.forEach(function (message) { if (message.role === "agent" && !instance.processedMessageIds.has(message.id)) { hasNewMessage = true; appendMessage("agent", message.content, '', [], false, botId); instance.lastSeenMessageId = message.id; instance.processedMessageIds.add(message.id); } }); if (hasNewMessage) { enableChatInput(botId); } var $floatingChatbot = getElement(botId, 'floating-chatbot'); if (hasNewMessage && $floatingChatbot.hasClass('hidden')) { showNotification(botId); } scrollToBottom(botId, true); } // Handle chat mode transitions (e.g. agent ended chat via !endchat) if (response.success && response.data?.chat_mode) { updateChatModeIndicator(response.data.chat_mode, botId); } }, error: function (xhr, status, error) { // Polling error - silently continue } }); } // ==================================== // CHAT HISTORY & PERSISTENCE // ==================================== function loadChatHistory(botId, onComplete) { botId = botId || 'default'; var instance = MxChatInstances.get(botId); // Prevent duplicate loading if (instance.chatHistoryLoaded) { if (onComplete) onComplete(); return; } // Use getChatSession which returns null if no session exists (does NOT create one) var sessionId = getChatSession(botId); var chatPersistenceEnabled = mxchatChat.chat_persistence_toggle === 'on'; // No session yet — nothing to load. History will load after first message via ensureSession. if (!sessionId) { instance.chatHistoryLoaded = true; if (onComplete) onComplete(); return; } if (chatPersistenceEnabled && sessionId) { $.ajax({ url: mxchatChat.ajax_url, type: 'POST', dataType: 'json', data: { action: 'mxchat_fetch_conversation_history', session_id: sessionId }, success: function(response) { // Handle session reset (IP changed while user was away) if (response.success === false && response.data && response.data.action === 'reset_session') { // Silent reset — new session but don't clear UI MxChatInstances.silentResetSession(botId); instance.chatHistoryLoaded = true; // Prevent retry loop if (onComplete) onComplete(); return; } // Check if the response indicates success if (response.success) { // Handle case where conversation data exists and is an array if (response.data && Array.isArray(response.data.conversation)) { var $chatBox = getElement(botId, 'chat-box'); var $fragment = $(document.createDocumentFragment()); let highestMessageId = instance.lastSeenMessageId; // Update chat mode if provided if (response.data.chat_mode) { updateChatModeIndicator(response.data.chat_mode, botId); } // Only process if there are actual messages if (response.data.conversation.length > 0) { // IMPORTANT: Clear existing messages before loading history $chatBox.empty(); $.each(response.data.conversation, function(index, message) { // Skip agent messages if persistence is off if (!chatPersistenceEnabled && message.role === 'agent') { return; } var messageClass, messageBgColor, messageFontColor; switch (message.role) { case 'user': messageClass = 'user-message'; messageBgColor = userMessageBgColor; messageFontColor = userMessageFontColor; break; case 'agent': messageClass = 'agent-message'; messageBgColor = liveAgentMessageBgColor; messageFontColor = liveAgentMessageFontColor; break; default: messageClass = 'bot-message'; messageBgColor = botMessageBgColor; messageFontColor = botMessageFontColor; break; } var messageElement = $('
').addClass(messageClass) .css({ 'background': messageBgColor, 'color': messageFontColor }); var content = message.content; content = content.replace(/\\'/g, "'").replace(/\\"/g, '"'); content = decodeHTMLEntities(content); // Skip linkify for messages containing structured HTML // (forms, product cards, galleries, etc.) to avoid // markdown formatting corrupting HTML attributes // (e.g. underscores in name="field_name" becoming tags) if (content.includes("mxchat-product-card") || content.includes("mxchat-image-gallery") || content.includes("mxchat-featured-products") || content.includes(" response.json()) .then(data => { if (data.success) { container.style.display = 'none'; nameElement.textContent = ''; instance.activePdfFile = null; appendMessage('bot', 'PDF removed.', '', [], false, botId); } }) .catch(error => { // Error removing PDF - silently continue }); } function removeActiveWord(botId) { botId = botId || 'default'; var instance = MxChatInstances.get(botId); const container = getElementDOM(botId, 'active-word-container'); const nameElement = getElementDOM(botId, 'active-word-name'); if (!container || !nameElement || !instance.activeWordFile) return; fetch(mxchatChat.ajax_url, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', }, body: new URLSearchParams({ 'action': 'mxchat_remove_word', 'session_id': getChatSession(botId), 'nonce': mxchatChat.nonce }) }) .then(response => response.json()) .then(data => { if (data.success) { container.style.display = 'none'; nameElement.textContent = ''; instance.activeWordFile = null; appendMessage('bot', 'Word document removed.', '', [], false, botId); } }) .catch(error => { // Error removing Word document - silently continue }); } // ==================================== // CONSENT & COMPLIANCE (GDPR) // ==================================== function initializeChatVisibility(botId) { botId = botId || 'default'; const complianzEnabled = mxchatChat.complianz_toggle === 'on' || mxchatChat.complianz_toggle === '1' || mxchatChat.complianz_toggle === 1; if (complianzEnabled && typeof cmplz_has_consent === "function" && typeof complianz !== 'undefined') { // Initial check checkConsentAndShowChat(botId); // Listen for consent changes $(document).on('cmplz_status_change', function(event) { checkConsentAndShowChat(botId); }); } else { // If Complianz is not enabled, always show getElement(botId, 'floating-chatbot-button') .css('display', 'flex') .removeClass('hidden no-consent') .fadeTo(500, 1); // Also check pre-chat message when Complianz is not enabled checkPreChatDismissal(botId); } } function checkConsentAndShowChat(botId) { botId = botId || 'default'; var consentStatus = cmplz_has_consent('marketing'); var consentType = complianz.consenttype; let $widget = getElement(botId, 'floating-chatbot-button'); let $chatbot = getElement(botId, 'floating-chatbot'); let $preChat = getElement(botId, 'pre-chat-message'); if (consentStatus === true) { $widget .removeClass('no-consent') .css('display', 'flex') .removeClass('hidden') .fadeTo(500, 1); $chatbot.removeClass('no-consent'); // Show pre-chat message if not dismissed checkPreChatDismissal(botId); } else { $widget .addClass('no-consent') .fadeTo(500, 0, function() { $(this) .css('display', 'none') .addClass('hidden'); }); $chatbot.addClass('no-consent'); // Hide pre-chat message when no consent $preChat.hide(); } } // ==================================== // PRE-CHAT MESSAGE HANDLING // ==================================== function checkPreChatDismissal(botId) { botId = botId || 'default'; try { var dismissedAt = localStorage.getItem('mxchat_pre_chat_dismissed_' + botId); if (dismissedAt) { // Re-show after 24 hours var elapsed = Date.now() - parseInt(dismissedAt, 10); if (elapsed < 86400000) { getElement(botId, 'pre-chat-message').hide(); return; } // Expired — clear and show again localStorage.removeItem('mxchat_pre_chat_dismissed_' + botId); } getElement(botId, 'pre-chat-message').fadeIn(250); } catch (e) { // localStorage unavailable — show the message getElement(botId, 'pre-chat-message').fadeIn(250); } } function handlePreChatDismissal(botId) { botId = botId || 'default'; getElement(botId, 'pre-chat-message').fadeOut(200); try { localStorage.setItem('mxchat_pre_chat_dismissed_' + botId, String(Date.now())); } catch (e) { // localStorage unavailable — dismissal won't persist } } // ==================================== // UTILITY FUNCTIONS // ==================================== function copyToClipboard(text) { var tempInput = $(''); $('body').append(tempInput); tempInput.val(text).select(); document.execCommand('copy'); tempInput.remove(); } function isImageHtml(str) { return str.startsWith(''); } // ==================================== // EVENT HANDLERS & INITIALIZATION // ==================================== $(document).on('click', '.mxchat-popular-question', function () { var question = $(this).text(); var botId = getBotIdFromElement(this); // Append the question as if the user typed it appendMessage("user", question, '', [], false, botId); // Only collapse if there are questions if (hasQuickQuestions(botId)) { collapseQuickQuestions(botId); } // Send the question to the server sendMessageToChatbot(question, botId); }); $(document).on('click', '.questions-toggle-btn', function(e) { e.preventDefault(); e.stopPropagation(); var botId = getBotIdFromElement(this); expandQuickQuestions(botId); }); $(document).on('click', '.questions-collapse-btn', function(e) { e.preventDefault(); e.stopPropagation(); var botId = getBotIdFromElement(this); collapseQuickQuestions(botId); }); // Chatbot visibility toggle handlers - use class selector for multi-instance support // Handles click + Enter/Space keypresses for keyboard accessibility (WCAG 2.1 SC 2.1.1). $(document).on('click keydown', '.floating-chatbot-button', function(e) { if (e.type === 'keydown') { if (e.key !== 'Enter' && e.key !== ' ' && e.key !== 'Spacebar') return; e.preventDefault(); } var botId = getBotIdFromElement(this); var $chatbot = getElement(botId, 'floating-chatbot'); var $badge = getElement(botId, 'chat-notification-badge'); var $preChat = getElement(botId, 'pre-chat-message'); if ($chatbot.hasClass('hidden')) { $chatbot.removeClass('hidden').addClass('visible') .attr('aria-modal', 'true').attr('role', 'dialog'); $(this).addClass('hidden').attr('aria-expanded', 'true'); $badge.hide(); // Hide notification when opening chat disableScroll(); $preChat.fadeOut(250); // First open per page load: re-fetch behavior settings in case // this page's inline values came from a stale full-page cache // (plan-32db95). Idempotent — later opens are a no-op. mxchatRefreshDynamicSettings(); // Load chat history for returning visitors (persistence) var chatPersistenceEnabled = typeof mxchatChat !== 'undefined' && mxchatChat.chat_persistence_toggle === 'on'; if (chatPersistenceEnabled) { MxChatInstances.ensureSession(botId); } // Deferred email check — only on first widget open var emailBlocker = getElementDOM(botId, 'email-blocker'); var instance = MxChatInstances.get(botId); if (emailBlocker && !instance.emailCheckDone) { instance.emailCheckDone = true; resolveEmailState(botId); } else if (!emailBlocker) { // No email collection — still route through showChatContainerForBot // so the loader is shown while chat history loads showChatContainerForBot(botId); } // Move keyboard focus into the message input after the open transition. setTimeout(function() { var chatInput = getElementDOM(botId, 'chat-input'); if (chatInput && !chatInput.disabled) { try { chatInput.focus({ preventScroll: true }); } catch (err) { chatInput.focus(); } } }, 300); } else { $chatbot.removeClass('visible').addClass('hidden').removeAttr('aria-modal'); $(this).removeClass('hidden').attr('aria-expanded', 'false'); enableScroll(); checkPreChatDismissal(botId); } }); // Allow clicking anywhere on the title bar to close the chatbot. // Returns keyboard focus to the launcher so keyboard users don't get // stranded at (WCAG SC 2.4.3 Focus Order). :focus-visible is // heuristic-based so mouse-triggered close won't show a focus ring. $(document).on('click', '.chatbot-top-bar', function() { var botId = getBotIdFromElement(this); getElement(botId, 'floating-chatbot').addClass('hidden').removeClass('visible').removeAttr('aria-modal'); var $launcher = getElement(botId, 'floating-chatbot-button'); $launcher.removeClass('hidden').attr('aria-expanded', 'false'); enableScroll(); try { $launcher.trigger('focus'); } catch (err) { /* no-op */ } }); // Global Escape-key handler — closes any visible chat widget and // returns focus to its launcher. Standard modal-dismissal pattern; // pairs with aria-modal="true" set on the widget when it opens. $(document).on('keydown', function(e) { if (e.key !== 'Escape' && e.key !== 'Esc') return; var $visible = $('.floating-chatbot.visible'); if (!$visible.length) return; e.preventDefault(); $visible.each(function() { var botId = getBotIdFromElement(this); $(this).addClass('hidden').removeClass('visible').removeAttr('aria-modal'); var $launcher = getElement(botId, 'floating-chatbot-button'); $launcher.removeClass('hidden').attr('aria-expanded', 'false'); try { $launcher.trigger('focus'); } catch (err) { /* no-op */ } }); enableScroll(); }); $(document).on('click', '.close-pre-chat-message', function(e) { e.stopPropagation(); // Prevent triggering the parent .pre-chat-message click var botId = getBotIdFromElement(this); handlePreChatDismissal(botId); }); // PDF upload button handlers - use class selector $(document).on('click', '.pdf-upload-btn', function() { var botId = getBotIdFromElement(this); var pdfInput = getElementDOM(botId, 'pdf-upload'); if (pdfInput) pdfInput.click(); }); // Word upload button handlers - use class selector $(document).on('click', '.word-upload-btn', function() { var botId = getBotIdFromElement(this); var wordInput = getElementDOM(botId, 'word-upload'); if (wordInput) wordInput.click(); }); // PDF file input change handler - delegated, bot-aware (was bound to stale un-suffixed id 'pdf-upload') $(document).on('change', '.pdf-upload', async function(e) { var botId = getBotIdFromElement(this); var instance = MxChatInstances.get(botId); const file = this.files[0]; const sessionId = MxChatInstances.ensureSession(botId); if (!file || file.type !== 'application/pdf') { alert('Please select a valid PDF file.'); return; } if (!sessionId) { alert('Error: No session ID found'); return; } if (!mxchatChat || !mxchatChat.ajax_url || !mxchatChat.nonce) { alert('Error: Ajax configuration missing'); return; } // Disable buttons and show loading state const uploadBtn = getElementDOM(botId, 'pdf-upload-btn'); const sendBtn = getElementDOM(botId, 'send-button'); if (!uploadBtn) return; const originalBtnContent = uploadBtn.innerHTML; try { // Wait for page-cache nonce refresh before reading mxchatChat.nonce. See plan-c5457f. await new Promise(function(resolve) { refreshNonceIfNeeded(resolve); }); const formData = new FormData(); formData.append('action', 'mxchat_upload_pdf'); formData.append('pdf_file', file); formData.append('session_id', sessionId); formData.append('nonce', mxchatChat.nonce); uploadBtn.disabled = true; if (sendBtn) sendBtn.disabled = true; uploadBtn.innerHTML = ` `; const response = await fetch(mxchatChat.ajax_url, { method: 'POST', body: formData }); const data = await response.json(); if (data.success) { // Hide popular questions if they exist if (hasQuickQuestions(botId)) { collapseQuickQuestions(botId); } // Show the active PDF name showActivePdf(data.data.filename, botId); appendMessage('bot', data.data.message, '', [], false, botId); scrollToBottom(botId); instance.activePdfFile = data.data.filename; } else { alert('Failed to upload PDF. Please try again.'); } } catch (error) { alert('Error uploading file. Please try again.'); } finally { uploadBtn.disabled = false; if (sendBtn) sendBtn.disabled = false; uploadBtn.innerHTML = originalBtnContent; this.value = ''; // Reset file input } }); // Word file input change handler - delegated, bot-aware (was bound to stale un-suffixed id 'word-upload') $(document).on('change', '.word-upload', async function(e) { var botId = getBotIdFromElement(this); var instance = MxChatInstances.get(botId); const file = this.files[0]; const sessionId = MxChatInstances.ensureSession(botId); if (!file || file.type !== 'application/vnd.openxmlformats-officedocument.wordprocessingml.document') { alert('Please select a valid Word document (.docx).'); return; } if (!sessionId) { alert('Error: No session ID found'); return; } if (!mxchatChat || !mxchatChat.ajax_url || !mxchatChat.nonce) { alert('Error: Ajax configuration missing'); return; } // Disable buttons and show loading state const uploadBtn = getElementDOM(botId, 'word-upload-btn'); const sendBtn = getElementDOM(botId, 'send-button'); if (!uploadBtn) return; const originalBtnContent = uploadBtn.innerHTML; try { // Wait for page-cache nonce refresh before reading mxchatChat.nonce. See plan-c5457f. await new Promise(function(resolve) { refreshNonceIfNeeded(resolve); }); const formData = new FormData(); formData.append('action', 'mxchat_upload_word'); formData.append('word_file', file); formData.append('session_id', sessionId); formData.append('nonce', mxchatChat.nonce); uploadBtn.disabled = true; if (sendBtn) sendBtn.disabled = true; uploadBtn.innerHTML = ` `; const response = await fetch(mxchatChat.ajax_url, { method: 'POST', body: formData }); const data = await response.json(); if (data.success) { // Hide popular questions if they exist if (hasQuickQuestions(botId)) { collapseQuickQuestions(botId); } // Show the active Word document name showActiveWord(data.data.filename, botId); appendMessage('bot', data.data.message, '', [], false, botId); scrollToBottom(botId); instance.activeWordFile = data.data.filename; } else { alert('Failed to upload Word document. Please try again.'); } } catch (error) { alert('Error uploading file. Please try again.'); } finally { uploadBtn.disabled = false; if (sendBtn) sendBtn.disabled = false; uploadBtn.innerHTML = originalBtnContent; this.value = ''; // Reset file input } }); // Remove button click handlers - delegated, bot-aware (were bound to stale un-suffixed ids) $(document).on('click', '.remove-pdf-btn', function(e) { e.preventDefault(); e.stopPropagation(); removeActivePdf(getBotIdFromElement(this)); }); $(document).on('click', '.remove-word-btn', function(e) { e.preventDefault(); e.stopPropagation(); removeActiveWord(getBotIdFromElement(this)); }); // Window resize handlers $(window).on('resize orientationchange', function() { setFullHeight(); }); // ==================================== // TOOLBAR & STYLING SETUP // ==================================== // Apply toolbar settings if (mxchatChat.chat_toolbar_toggle === 'on') { $('.chat-toolbar').show(); } else { $('.chat-toolbar').hide(); } // Apply toolbar icon colors const toolbarElements = [ '#mxchat-chatbot .toolbar-btn svg', '#mxchat-chatbot .active-pdf-name', '#mxchat-chatbot .active-word-name', '#mxchat-chatbot .remove-pdf-btn svg', '#mxchat-chatbot .remove-word-btn svg', '#mxchat-chatbot .toolbar-perplexity svg' ]; toolbarElements.forEach(selector => { $(selector).css({ 'fill': toolbarIconColor, 'stroke': toolbarIconColor, 'color': toolbarIconColor }); }); // ==================================== // INIT LOADER & CHAT CONTAINER HELPERS // ==================================== // These must be outside the email collection block so they're always available // (used by persistence loading even when email collection is off) function showInitLoader(botId) { var loader = getElementDOM(botId, 'mxchat-init-loader'); if (loader) loader.style.display = 'flex'; } function hideInitLoader(botId) { var loader = getElementDOM(botId, 'mxchat-init-loader'); if (loader) loader.style.display = 'none'; } function showEmailFormForBot(botId) { hideInitLoader(botId); var emailBlocker = getElementDOM(botId, 'email-blocker'); var chatContainer = getElementDOM(botId, 'chat-container'); if (emailBlocker) emailBlocker.style.display = 'flex'; if (chatContainer) chatContainer.style.display = 'none'; } function showChatContainerForBot(botId) { var emailBlocker = getElementDOM(botId, 'email-blocker'); var chatContainer = getElementDOM(botId, 'chat-container'); if (emailBlocker) emailBlocker.style.display = 'none'; var instance = MxChatInstances.get(botId); var chatPersistenceEnabled = mxchatChat.chat_persistence_toggle === 'on'; // If persistence is on and history hasn't loaded yet, show loader // while history loads to prevent flash of empty chat if (chatPersistenceEnabled && !instance.chatHistoryLoaded) { if (chatContainer) chatContainer.style.display = 'none'; showInitLoader(botId); loadChatHistory(botId, function() { hideInitLoader(botId); if (chatContainer) chatContainer.style.display = 'flex'; scrollToBottom(botId, true); }); } else { hideInitLoader(botId); if (chatContainer) chatContainer.style.display = 'flex'; if (typeof loadChatHistory === 'function') { loadChatHistory(botId); } } } // ==================================== // EMAIL COLLECTION SETUP - MULTI-INSTANCE VERSION // ==================================== // Only run email collection setup if it's enabled if (mxchatChat && mxchatChat.email_collection_enabled === 'on') { // Track submitting state per bot const emailSubmittingState = {}; // Add CSS animations for email form (once globally) if (!document.getElementById('email-error-styles')) { const style = document.createElement('style'); style.id = 'email-error-styles'; style.textContent = ` @keyframes fadeInError { from { opacity: 0; transform: translateY(-5px); } to { opacity: 1; transform: translateY(0); } } .email-input-shake { animation: shake 0.5s ease-in-out; } @keyframes shake { 0%, 100% { transform: translateX(0); } 25% { transform: translateX(-5px); } 75% { transform: translateX(5px); } } @keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } } .email-spinner { display: inline-block; vertical-align: middle; } `; document.head.appendChild(style); } function isValidEmailAddress(email) { const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; return emailRegex.test(email.trim()) && email.length <= 254; } function isValidNameInput(name) { return name && name.trim().length >= 2 && name.trim().length <= 100; } /** * Replace {visitor_name} placeholder in intro message with actual visitor name * @param {string} botId - The bot instance ID * @param {string} visitorName - The visitor's name to insert */ function replaceVisitorNamePlaceholder(botId, visitorName) { var chatBox = getElementDOM(botId, 'chat-box'); if (!chatBox) return; // Find the first bot message (intro message) var introMessage = chatBox.querySelector('.bot-message'); if (!introMessage) return; var messageContent = introMessage.querySelector('div[dir="auto"]'); if (!messageContent) return; var html = messageContent.innerHTML; // Replace {visitor_name} placeholder (case-insensitive) if (visitorName && visitorName.trim()) { // Escape HTML to prevent XSS var safeName = $('
').text(visitorName.trim()).html(); html = html.replace(/\{visitor_name\}/gi, safeName); } else { // Remove placeholder and clean up spacing if no name provided html = html.replace(/\{visitor_name\}/gi, ''); // Clean up any double spaces that might result html = html.replace(/\s{2,}/g, ' ').trim(); } messageContent.innerHTML = html; } function setEmailSubmissionState(botId, loading) { var submitButton = getElementDOM(botId, 'email-submit-button'); var emailInput = getElementDOM(botId, 'user-email'); var nameInput = getElementDOM(botId, 'user-name'); if (loading) { emailSubmittingState[botId] = true; if (submitButton) submitButton.disabled = true; if (emailInput) emailInput.disabled = true; if (nameInput) nameInput.disabled = true; if (submitButton && !submitButton.getAttribute('data-original-html')) { submitButton.setAttribute('data-original-html', submitButton.innerHTML); const originalText = submitButton.textContent; submitButton.innerHTML = ` ${originalText} `; submitButton.style.opacity = '0.8'; } } else { emailSubmittingState[botId] = false; if (submitButton) submitButton.disabled = false; if (emailInput) emailInput.disabled = false; if (nameInput) nameInput.disabled = false; if (submitButton) { const originalHtml = submitButton.getAttribute('data-original-html'); if (originalHtml) { submitButton.innerHTML = originalHtml; } submitButton.style.opacity = '1'; } } } function showEmailError(botId, message) { clearEmailError(botId); var emailForm = getElementDOM(botId, 'email-collection-form'); if (!emailForm) return; const errorDiv = document.createElement('div'); errorDiv.className = 'email-error'; errorDiv.style.cssText = ` color: #e74c3c; font-size: 12px; margin-top: 8px; padding: 4px 0; animation: fadeInError 0.3s ease; `; errorDiv.textContent = message; emailForm.appendChild(errorDiv); // Add shake animation to inputs var emailInput = getElementDOM(botId, 'user-email'); var nameInput = getElementDOM(botId, 'user-name'); if (emailInput) { emailInput.classList.add('email-input-shake'); setTimeout(() => emailInput.classList.remove('email-input-shake'), 500); } if (nameInput) { nameInput.classList.add('email-input-shake'); setTimeout(() => nameInput.classList.remove('email-input-shake'), 500); } } function clearEmailError(botId) { var emailForm = getElementDOM(botId, 'email-collection-form'); if (emailForm) { const existingErrors = emailForm.querySelectorAll('.email-error'); existingErrors.forEach(error => error.remove()); } } // Resolve email state using server-side data when available, AJAX fallback otherwise function resolveEmailState(botId) { if (mxchatChat.skip_email_check && mxchatChat.initial_email_state) { if (mxchatChat.initial_email_state.show_email_form) { showEmailFormForBot(botId); } else { showChatContainerForBot(botId); } } else { checkSessionAndEmailForBot(botId); } } function checkSessionAndEmailForBot(botId) { const sessionId = MxChatInstances.ensureSession(botId); // Hide both panels while we check — show loader instead var emailBlocker = getElementDOM(botId, 'email-blocker'); var chatContainer = getElementDOM(botId, 'chat-container'); if (emailBlocker) emailBlocker.style.display = 'none'; if (chatContainer) chatContainer.style.display = 'none'; showInitLoader(botId); fetch(mxchatChat.ajax_url, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', }, body: new URLSearchParams({ action: 'mxchat_check_email_provided', session_id: sessionId, nonce: mxchatChat.nonce, }) }) .then((response) => { if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } return response.json(); }) .then((data) => { if (data.success) { if (data.data.logged_in || data.data.email) { showChatContainerForBot(botId); } else { showEmailFormForBot(botId); } } else { showEmailFormForBot(botId); } }) .catch((error) => { showEmailFormForBot(botId); }); } // Event delegation for email form submission $(document).on('submit', '.email-collection-form', function(e) { e.preventDefault(); e.stopPropagation(); var botId = getBotIdFromElement(this); // Prevent double submission if (emailSubmittingState[botId]) { return false; } var emailInput = getElementDOM(botId, 'user-email'); var nameInput = getElementDOM(botId, 'user-name'); var userEmail = emailInput ? emailInput.value.trim() : ''; var userName = nameInput ? nameInput.value.trim() : ''; var sessionId = MxChatInstances.ensureSession(botId); // Validate email if (!userEmail) { showEmailError(botId, 'Please enter your email address.'); return false; } if (!isValidEmailAddress(userEmail)) { showEmailError(botId, 'Please enter a valid email address.'); return false; } // Validate name if field exists and has content if (nameInput && userName && !isValidNameInput(userName)) { showEmailError(botId, 'Please enter a valid name (2-100 characters).'); return false; } clearEmailError(botId); setEmailSubmissionState(botId, true); // Prepare form data const formData = new URLSearchParams({ action: 'mxchat_handle_save_email_and_response', email: userEmail, session_id: sessionId, nonce: mxchatChat.nonce, }); if (userName) { formData.append('name', userName); } fetch(mxchatChat.ajax_url, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', }, body: formData }) .then((response) => { if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } return response.json(); }) .then((data) => { setEmailSubmissionState(botId, false); if (data.success) { showChatContainerForBot(botId); // Replace {visitor_name} placeholder in intro message with actual name if (userName) { replaceVisitorNamePlaceholder(botId, userName); } else { // Remove placeholder if no name provided replaceVisitorNamePlaceholder(botId, ''); } if (data.message && typeof appendMessage === 'function') { setTimeout(() => { appendMessage('bot', data.message, '', [], false, botId); if (typeof scrollToBottom === 'function') { scrollToBottom(botId); } }, 100); } } else { showEmailError(botId, data.message || 'Failed to save email. Please try again.'); } }) .catch((error) => { setEmailSubmissionState(botId, false); showEmailError(botId, 'An error occurred. Please try again.'); }); return false; }); // Real-time email validation using event delegation $(document).on('input', '.mxchat-email-input', function() { var botId = getBotIdFromElement(this); var $input = $(this); // Clear previous timeout clearTimeout($input.data('validationTimeout')); // Debounce validation var timeout = setTimeout(() => { var email = this.value.trim(); clearEmailError(botId); if (email && !isValidEmailAddress(email)) { showEmailError(botId, 'Please enter a valid email address.'); } }, 500); $input.data('validationTimeout', timeout); }); // Handle Enter key in email input $(document).on('keypress', '.mxchat-email-input', function(e) { if (e.key === 'Enter') { e.preventDefault(); var botId = getBotIdFromElement(this); if (!emailSubmittingState[botId]) { $(this).closest('.email-collection-form').submit(); } } }); // Handle Enter key in name input $(document).on('keypress', '.mxchat-name-input', function(e) { if (e.key === 'Enter') { e.preventDefault(); var botId = getBotIdFromElement(this); if (!emailSubmittingState[botId]) { $(this).closest('.email-collection-form').submit(); } } }); // Initialize email check for all bot instances // For floating bots: defer until widget is opened (zero passive AJAX) // For embedded bots: check immediately since the form is visible $('.mxchat-chatbot-wrapper').each(function() { var botId = $(this).data('bot-id') || 'default'; var emailBlocker = getElementDOM(botId, 'email-blocker'); if (emailBlocker) { if (isEmbeddedBot(botId)) { // Embedded bots are always visible — check now resolveEmailState(botId); } // Floating bots: handled in the widget open handler } else if (isEmbeddedBot(botId)) { // Embedded bot, no email collection — load history with loader var chatPersistenceEnabled = typeof mxchatChat !== 'undefined' && mxchatChat.chat_persistence_toggle === 'on'; if (chatPersistenceEnabled) { MxChatInstances.ensureSession(botId); showChatContainerForBot(botId); } } }); } // Open chatbot when pre-chat message is clicked - use class selector for multi-instance $(document).on('click', '.pre-chat-message', function() { var botId = getBotIdFromElement(this); var $chatbot = getElement(botId, 'floating-chatbot'); if ($chatbot.hasClass('hidden')) { $chatbot.removeClass('hidden').addClass('visible'); getElement(botId, 'floating-chatbot-button').addClass('hidden'); handlePreChatDismissal(botId); disableScroll(); // Disable scroll when chatbot opens // Load chat history for returning visitors (persistence) var chatPersistenceEnabled = typeof mxchatChat !== 'undefined' && mxchatChat.chat_persistence_toggle === 'on'; if (chatPersistenceEnabled) { MxChatInstances.ensureSession(botId); } // Deferred email check — only on first widget open var emailBlocker = getElementDOM(botId, 'email-blocker'); var instance = MxChatInstances.get(botId); if (emailBlocker && !instance.emailCheckDone) { instance.emailCheckDone = true; resolveEmailState(botId); } else if (!emailBlocker) { showChatContainerForBot(botId); } } }); // Legacy duplicate close handler removed — handled by single event delegation above function hasQuickQuestions(botId) { botId = botId || 'default'; var questionsContainer = getElementDOM(botId, 'mxchat-popular-questions'); if (!questionsContainer) return false; const questionButtons = questionsContainer.querySelectorAll('.mxchat-popular-question'); return questionButtons.length > 0; } /** * Check if a bot is embedded (not floating) * Embedded bots don't have a .floating-chatbot wrapper */ function isEmbeddedBot(botId) { botId = botId || 'default'; var floatingWrapper = document.getElementById('floating-chatbot-' + botId); return !floatingWrapper; } function collapseQuickQuestions(botId) { botId = botId || 'default'; const questionsContainer = getElementDOM(botId, 'mxchat-popular-questions'); if (questionsContainer && hasQuickQuestions(botId)) { questionsContainer.classList.add('collapsed'); questionsContainer.classList.add('has-been-collapsed'); try { sessionStorage.setItem('mxchat_questions_collapsed_' + botId, 'true'); sessionStorage.setItem('mxchat_questions_has_been_collapsed_' + botId, 'true'); } catch (e) { // Ignore if sessionStorage is not available } } } function expandQuickQuestions(botId) { botId = botId || 'default'; const questionsContainer = getElementDOM(botId, 'mxchat-popular-questions'); if (questionsContainer && hasQuickQuestions(botId)) { questionsContainer.classList.remove('collapsed'); try { sessionStorage.setItem('mxchat_questions_collapsed_' + botId, 'false'); } catch (e) { // Ignore if sessionStorage is not available } } } function checkQuickQuestionsState(botId) { botId = botId || 'default'; if (!hasQuickQuestions(botId)) { return; // Don't do anything if no questions exist } // Skip restoring collapsed state for embedded bots - they should always start expanded if (isEmbeddedBot(botId)) { return; } try { const isCollapsed = sessionStorage.getItem('mxchat_questions_collapsed_' + botId); const hasBeenCollapsed = sessionStorage.getItem('mxchat_questions_has_been_collapsed_' + botId); const questionsContainer = getElementDOM(botId, 'mxchat-popular-questions'); if (questionsContainer) { if (hasBeenCollapsed === 'true') { questionsContainer.classList.add('has-been-collapsed'); } if (isCollapsed === 'true') { questionsContainer.classList.add('collapsed'); } } } catch (e) { // Ignore if sessionStorage is not available } } // Global delegation for dynamically added links as fallback // Use class selector for multi-instance support $(document).on('click', '.chat-box a[href]:not([data-tracked])', function(e) { const $link = $(this); const messageDiv = $link.closest('.bot-message, .agent-message'); // Only process bot/agent message links if (messageDiv.length > 0) { const originalHref = $link.attr('href'); if (originalHref && (originalHref.startsWith('http://') || originalHref.startsWith('https://'))) { e.preventDefault(); e.stopPropagation(); // Mark as tracked $link.attr('data-tracked', 'true'); // Get bot ID from the chat box context var botId = getBotIdFromElement(this); // Get message context from the message div const messageText = messageDiv.text().substring(0, 200); $.ajax({ url: mxchatChat.ajax_url, type: 'POST', data: { action: 'mxchat_track_url_click', session_id: getChatSession(botId), url: originalHref, message_context: messageText, nonce: mxchatChat.nonce }, complete: function() { if ($link.attr('target') === '_blank' || linkTarget === '_blank') { window.open(originalHref, '_blank'); } else { window.location.href = originalHref; } } }); return false; } } }); // ==================================== // MAIN INITIALIZATION // ==================================== // Initialize all chatbot instances on the page initializeAllInstances(); // Legacy initialization for single bot compatibility $('.floating-chatbot.hidden').each(function() { var botId = getBotIdFromElement(this); getElement(botId, 'floating-chatbot-button').removeClass('hidden'); }); // Initialize when document is ready setFullHeight(); // Note: trackOriginatingPage() and loadChatHistory() are now deferred // until the user's first interaction via MxChatInstances.ensureSession() // Initialize chat visibility for all instances $('.mxchat-chatbot-wrapper').each(function() { var botId = $(this).data('bot-id') || 'default'; initializeChatVisibility(botId); }); // Make functions globally available for add-ons window.hasQuickQuestions = hasQuickQuestions; window.collapseQuickQuestions = collapseQuickQuestions; window.appendMessage = appendMessage; window.appendThinkingMessage = appendThinkingMessage; window.scrollToBottom = scrollToBottom; window.scrollElementToTop = scrollElementToTop; window.replaceLastMessage = replaceLastMessage; window.callMxChat = callMxChat; window.callMxChatStream = callMxChatStream; window.shouldUseStreaming = shouldUseStreaming; window.getChatSession = getChatSession; window.getPageContext = getPageContext; window.updateStreamingMessage = updateStreamingMessage; window.MxChatInstances = MxChatInstances; window.getElement = getElement; window.getElementDOM = getElementDOM; window.getBotIdFromElement = getBotIdFromElement; }); // End of jQuery ready // ==================================== // GLOBAL EVENT LISTENERS (Outside jQuery) // ==================================== // Event listener for copy button (code blocks) document.addEventListener("click", (e) => { if (e.target.classList.contains("mxchat-copy-button")) { const copyButton = e.target; const codeBlock = copyButton .closest(".mxchat-code-block-container") .querySelector(".mxchat-code-block code"); if (codeBlock) { // Preserve formatting using innerText navigator.clipboard.writeText(codeBlock.innerText).then(() => { copyButton.textContent = "Copied!"; copyButton.setAttribute("aria-label", "Copied to clipboard"); setTimeout(() => { copyButton.textContent = "Copy"; copyButton.setAttribute("aria-label", "Copy to clipboard"); }, 2000); }); } } }); // ============================================================================ // SATISFACTION RATING (v3.2.6) // ============================================================================ // Per-session 👍/👎 prompt that appears in the chat-box after 60s of user // inactivity following a bot reply. One prompt per session, deduped via // localStorage. Runs ONLY when the satisfaction_rating_enabled option is on — // the option (default off) is authoritative. jQuery(function($) { if (typeof mxchatChat === 'undefined') return; // wp_localize_script stringifies scalars: a PHP boolean false arrives as // '' and true as '1', so this must be an explicit-enable allowlist — the // old "disabled when exactly false/'off'" check let '' through and the // bubble rendered on sites with the option off/unset (plan-4bba64). PHP // now emits 'on'/'off' strings; true/'1'/1 keep cached pre-fix HTML // (boolean-true localizations) working. // NOTE (plan-32db95): this gate reads the INLINE value at DOM ready and is // deliberately NOT re-evaluated after the widget's dynamic-settings refresh // merges fresh values over mxchatChat (that merge fires on first widget // open, after this module has already decided). Re-evaluating would mean // restructuring the whole module to late-bind its listeners — not worth it // for a prompt that is at worst stale for one page load on a cached page. var sre = mxchatChat.satisfaction_rating_enabled; if (sre !== 'on' && sre !== true && sre !== '1' && sre !== 1) return; // wp_localize_script stringifies ints, so accept both number and numeric string. var idleRaw = mxchatChat.satisfaction_rating_idle_seconds; var idleSeconds = (typeof idleRaw === 'number') ? idleRaw : parseInt(idleRaw, 10); if (!isFinite(idleSeconds)) idleSeconds = 60; if (idleSeconds < 5) idleSeconds = 5; if (idleSeconds > 600) idleSeconds = 600; var IDLE_MS = idleSeconds * 1000; var MIN_BOT_REPLIES = 2; var ratingState = {}; function getState(botId) { if (!ratingState[botId]) { ratingState[botId] = { idleTimer: null, botReplies: 0, promptShown: false, dismissed: false }; } return ratingState[botId]; } function getSessionId(botId) { if (typeof MxChatInstances !== 'undefined' && MxChatInstances.getChatSession) { return MxChatInstances.getChatSession(botId); } return null; } function isAlreadyRated(sessionId) { if (!sessionId) return false; try { return localStorage.getItem('mxchat_rated:' + sessionId) === '1'; } catch (e) { return false; } } function markRated(sessionId) { if (!sessionId) return; try { localStorage.setItem('mxchat_rated:' + sessionId, '1'); } catch (e) {} } function esc(s) { return String(s == null ? '' : s) .replace(/&/g, '&').replace(//g, '>') .replace(/"/g, '"').replace(/'/g, '''); } // Mirror shouldSkipInlineColors so rating bubbles defer to AI-theme CSS. function ratingSkipInlineColors(botId) { if (mxchatChat.skip_inline_colors) return true; var botAssignments = mxchatChat.bot_theme_assignments || {}; return botAssignments.hasOwnProperty(botId); } function botBubbleStyleAttr(botId) { if (ratingSkipInlineColors(botId)) return ''; var bg = mxchatChat.bot_message_bg_color; var fg = mxchatChat.bot_message_font_color; if (!bg && !fg) return ''; return ' style="background-color: ' + esc(bg || '') + '; color: ' + esc(fg || '') + ';"'; } // Reads the rating bubble's actual computed fg+bg (whatever paints it — // the inline color pickers OR the mxchat-theme AI customizer's injected CSS) // and paints the filled "Send" pill so it fills with the bot font color and // labels in the bubble bg. Mirrors mxchatSyncMenuColors(~:1512) for the read. // We paint the submit button DIRECTLY (inline longhand) rather than relying // on the CSS rule's var()s: Chromium resolves an INHERITED custom property // unreliably inside a descendant's `background`, so a bubble-level var would // silently fall back to the literal (white-block bug all over again). Inline // longhand always wins. Same transparent-guard as the menu so we never paint // a see-through value — in that case the CSS literal fallbacks keep it legible. function syncRatingBubbleColors(botId) { var $chatBox = getChatBoxByBotId(botId); if (!$chatBox || !$chatBox.length) return; var bubbleEl = $chatBox.find('.mxchat-rating-bot-bubble').last()[0]; if (!bubbleEl) return; var cs = window.getComputedStyle(bubbleEl); var fg = cs.color; var bg = cs.backgroundColor; var hasFg = fg && fg !== 'rgba(0, 0, 0, 0)' && fg !== 'transparent'; var hasBg = bg && bg !== 'rgba(0, 0, 0, 0)' && bg !== 'transparent'; // Expose on the bubble too, for any inheriting styles / future use. if (hasFg) bubbleEl.style.setProperty('--mxchat-bot-fg', fg); if (hasBg) bubbleEl.style.setProperty('--mxchat-bot-bg', bg); // Paint the Send pill directly — the part that actually fixes the bug. var submitEl = bubbleEl.querySelector('.mxchat-rating-submit'); if (submitEl) { if (hasFg) submitEl.style.backgroundColor = fg; // fill = bot font color if (hasBg) submitEl.style.color = bg; // label = bubble background } } function copy(key) { var c = mxchatChat.satisfaction_rating_copy || {}; var d = { question: 'Was this helpful?', helpful: 'Helpful', not_helpful: 'Not helpful', dismiss: 'Dismiss', thanks: 'Thanks! Anything we should improve? (optional)', placeholder: 'Tell us what could be better…', send: 'Send', skip: 'Skip', saved: 'Thanks for the feedback.' }; return c[key] || d[key]; } function thumbUpSvg() { return ''; } function thumbDownSvg() { return ''; } function buildPromptHtml(botId) { var styleAttr = botBubbleStyleAttr(botId); return '' + '
' + '
' + '
' + esc(copy('question')) + '
' + '
' + '' + '' + '' + '' + '' + '
' + '
' + '
'; } function buildFeedbackHtml(botId, rating) { var styleAttr = botBubbleStyleAttr(botId); return '' + '
' + '
' + '
' + esc(copy('thanks')) + '
' + '' + '
' + '' + '' + '
' + '
' + '
'; } function buildSavedHtml(botId) { var styleAttr = botBubbleStyleAttr(botId); return '' + '
' + '
' + esc(copy('saved')) + '
' + '
'; } function getChatBoxByBotId(botId) { var $byId = $('#chat-box-' + botId); if ($byId.length) return $byId.first(); return $('.chat-box').first(); } function scrollChatBoxToBottom($chatBox) { if (!$chatBox || !$chatBox.length) return; $chatBox.scrollTop($chatBox[0].scrollHeight); } function showPrompt(botId) { var s = getState(botId); if (s.promptShown || s.dismissed) return; var sessionId = getSessionId(botId); if (!sessionId) return; if (isAlreadyRated(sessionId)) { s.promptShown = true; return; } var $chatBox = getChatBoxByBotId(botId); if (!$chatBox.length) return; if ($chatBox.find('.mxchat-rating-prompt').length) { s.promptShown = true; return; } $chatBox.append(buildPromptHtml(botId)); syncRatingBubbleColors(botId); s.promptShown = true; scrollChatBoxToBottom($chatBox); } function submitRating(botId, rating, feedback) { var sessionId = getSessionId(botId); if (!sessionId) return; $.post(mxchatChat.ajax_url, { action: 'mxchat_save_rating', session_id: sessionId, bot_id: botId, rating: rating, feedback: feedback || '' }); markRated(sessionId); } function onBotReply(botId) { var s = getState(botId); s.botReplies += 1; if (s.promptShown || s.dismissed) return; var sessionId = getSessionId(botId); if (sessionId && isAlreadyRated(sessionId)) { s.promptShown = true; return; } if (s.botReplies < MIN_BOT_REPLIES) return; if (s.idleTimer) clearTimeout(s.idleTimer); s.idleTimer = setTimeout(function() { showPrompt(botId); }, IDLE_MS); } function onUserMessage(botId) { var s = getState(botId); if (s.idleTimer) { clearTimeout(s.idleTimer); s.idleTimer = null; } } function botIdFromChatBox(el) { var id = el && el.id ? el.id : ''; return id.indexOf('chat-box-') === 0 ? id.substring('chat-box-'.length) : 'default'; } function setupObserver(chatBox) { var botId = botIdFromChatBox(chatBox); try { var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { for (var i = 0; i < m.addedNodes.length; i++) { var node = m.addedNodes[i]; if (!node || node.nodeType !== 1) continue; var $n = $(node); if ($n.hasClass('mxchat-rating-bot-bubble') || $n.hasClass('mxchat-rating-prompt') || $n.hasClass('mxchat-rating-feedback') || $n.hasClass('mxchat-rating-saved')) continue; 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) else if ($n.hasClass('user-message')) onUserMessage(botId); } }); }); observer.observe(chatBox, { childList: true }); } catch (e) { /* noop */ } } $('.chat-box').each(function() { setupObserver(this); }); $(document).on('click', '.mxchat-rating-btn', function(e) { e.preventDefault(); var $btn = $(this); var $prompt = $btn.closest('.mxchat-rating-prompt'); var $wrap = $btn.closest('.mxchat-rating-bot-bubble'); var botId = $prompt.data('bot-id') || 'default'; var rating = parseInt($btn.attr('data-rating'), 10); if (rating !== 1 && rating !== -1) return; submitRating(botId, rating, ''); ($wrap.length ? $wrap : $prompt).replaceWith(buildFeedbackHtml(botId, rating)); syncRatingBubbleColors(botId); scrollChatBoxToBottom(getChatBoxByBotId(botId)); }); $(document).on('click', '.mxchat-rating-dismiss', function(e) { e.preventDefault(); var $prompt = $(this).closest('.mxchat-rating-prompt'); var $wrap = $(this).closest('.mxchat-rating-bot-bubble'); var botId = $prompt.data('bot-id') || 'default'; var s = getState(botId); s.dismissed = true; markRated(getSessionId(botId)); ($wrap.length ? $wrap : $prompt).remove(); }); function closeFeedback($fb) { var botId = $fb.data('bot-id') || 'default'; var $wrap = $fb.closest('.mxchat-rating-bot-bubble'); ($wrap.length ? $wrap : $fb).replaceWith(buildSavedHtml(botId)); syncRatingBubbleColors(botId); scrollChatBoxToBottom(getChatBoxByBotId(botId)); } $(document).on('click', '.mxchat-rating-skip', function(e) { e.preventDefault(); closeFeedback($(this).closest('.mxchat-rating-feedback')); }); $(document).on('click', '.mxchat-rating-submit', function(e) { e.preventDefault(); var $fb = $(this).closest('.mxchat-rating-feedback'); var botId = $fb.data('bot-id') || 'default'; var rating = parseInt($fb.attr('data-rating'), 10); if (rating !== 1 && rating !== -1) { closeFeedback($fb); return; } var text = String($fb.find('.mxchat-rating-feedback-input').val() || '').trim(); if (text !== '') { submitRating(botId, rating, text); } closeFeedback($fb); }); });