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: 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('');
$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);
}
});
// 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 = $('