PluginProbe
MxChat – AI Chatbot & Content Generation for WordPress / 3.2.21
MxChat – AI Chatbot & Content Generation for WordPress v3.2.21
3.2.21 3.2.20 3.2.19 3.2.18 3.2.17 3.2.16 3.2.15 3.2.14 3.2.12 3.2.13 3.2.11 3.2.10 3.2.9 3.2.8 3.2.7 3.2.6 3.2.5 3.2.4 3.2.3 3.2.2 3.2.1 2.0.3 2.0.4 2.0.5 2.0.6 All 152 releases
mxchat-basic / js / mxchat-admin.js

mxchat-admin.js in MxChat – AI Chatbot & Content Generation for WordPress 3.2.21, at js/mxchat-admin.js

4,127 lines 189.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 // Simple debounce function implementation
2 function debounce(func, wait) {
3 let timeout;
4 return function executedFunction(...args) {
5 const later = () => {
6 clearTimeout(timeout);
7 func(...args);
8 };
9 clearTimeout(timeout);
10 timeout = setTimeout(later, wait);
11 };
12 }
13
14 // ─── Modal close safety ──────────────────────────────────────────────────
15 // A click event fires on the common ancestor of its mousedown and mouseup, so
16 // releasing a text-selection drag past a dialog's edge dispatches the click on
17 // the overlay. Modals holding editable fields therefore never close on the
18 // overlay and confirm before discarding; read-only ones require the whole
19 // gesture to land on the overlay.
20
21 // Serializes a modal's visible fields so edits can be detected on close.
22 function mxchatModalSnapshot(modal) {
23 const parts = [];
24 modal.querySelectorAll('input, textarea, select').forEach((field) => {
25 if (field.type === 'hidden') return;
26 parts.push(field.type === 'checkbox' || field.type === 'radio' ? (field.checked ? '1' : '0') : field.value);
27 });
28 return JSON.stringify(parts);
29 }
30
31 // Wraps a modal's closeModal so an explicit close confirms when fields changed.
32 function mxchatGuardedClose(modal, closeModal) {
33 const snapshot = mxchatModalSnapshot(modal);
34 const message = (typeof mxchatAdmin !== 'undefined' && mxchatAdmin.discard_changes_confirm)
35 ? mxchatAdmin.discard_changes_confirm
36 : 'Discard your unsaved changes?';
37 return (e) => {
38 if (mxchatModalSnapshot(modal) !== snapshot && !window.confirm(message)) return;
39 closeModal(e);
40 };
41 }
42
43 // Esc-to-close for a modal. Binds one named keydown handler and returns the detach
44 // function the modal's own close path must call. `{ once: true }` cannot be used
45 // here: it removes the listener on the first keydown of ANY key, so typing a single
46 // character killed Esc for the rest of the modal's life, and every open that saw no
47 // keypress left another listener stacked on document.
48 function mxchatBindEscClose(modal, onEscape) {
49 function onKeydown(e) {
50 if (e.key === 'Escape' && modal.classList.contains('active')) {
51 onEscape(e);
52 }
53 }
54 document.addEventListener('keydown', onKeydown);
55 return function detachEsc() {
56 document.removeEventListener('keydown', onKeydown);
57 };
58 }
59
60 // Overlay-close for read-only modals, ignoring clicks that began inside.
61 function mxchatDragSafeOverlayClose(modal, closeModal) {
62 let downOnOverlay = false;
63 modal.addEventListener('mousedown', (e) => { downOnOverlay = (e.target === modal); });
64 modal.addEventListener('click', (e) => {
65 const overlayGesture = downOnOverlay;
66 downOnOverlay = false;
67 if (e.target === modal && overlayGesture) closeModal(e);
68 });
69 }
70
71 // Helper function to open edit modal for intents/actions
72 function mxchatOpenEditModal(intentId, phrases) {
73 const modal = document.getElementById('mxchat-edit-modal');
74 if (!modal) return;
75
76 // Get form fields
77 const intentIdField = document.getElementById('edit_intent_id');
78 const phrasesField = document.getElementById('edit_phrases');
79
80 // Set values
81 intentIdField.value = intentId;
82 phrasesField.value = phrases;
83
84 // Show modal with animation
85 modal.style.display = 'flex';
86 requestAnimationFrame(() => {
87 modal.classList.add('active');
88 });
89
90 // Set up close handlers
91 let detachEsc = null;
92 const closeModal = () => {
93 if (detachEsc) {
94 detachEsc();
95 detachEsc = null;
96 }
97 modal.classList.remove('active');
98 setTimeout(() => {
99 modal.style.display = 'none';
100 }, 300); // Match the CSS transition time
101 };
102
103 // This modal holds edits: close only on explicit controls, confirming when dirty.
104 const guardedClose = mxchatGuardedClose(modal, closeModal);
105
106 // Close button handler
107 const closeBtn = modal.querySelector('.mxchat-modal-close');
108 if (closeBtn) {
109 closeBtn.onclick = guardedClose;
110 }
111
112 // Cancel button handler
113 const cancelBtn = modal.querySelector('.mxchat-modal-cancel');
114 if (cancelBtn) {
115 cancelBtn.onclick = guardedClose;
116 }
117
118 // Escape key to close modal
119 detachEsc = mxchatBindEscClose(modal, guardedClose);
120
121 // Focus the textarea
122 phrasesField.focus();
123 }
124 // Live Agent Notice Dismissal Function
125 function dismissLiveAgentNotice() {
126 if (typeof jQuery !== 'undefined' && typeof mxchatLiveAgent !== 'undefined') {
127 jQuery.post(mxchatLiveAgent.ajaxurl, {
128 action: 'dismiss_live_agent_notice',
129 nonce: mxchatLiveAgent.nonce
130 }, function(response) {
131 if (response.success) {
132 jQuery('#mxchat-disabled-notice').fadeOut(300);
133 }
134 }).fail(function() {
135 // Fallback: just hide the notice if AJAX fails
136 jQuery('#mxchat-disabled-notice').fadeOut(300);
137 });
138 } else {
139 // Fallback for cases where jQuery or localized data isn't available
140 var notice = document.getElementById('mxchat-disabled-notice');
141 if (notice) {
142 notice.style.display = 'none';
143 }
144 }
145 }
146
147 // Theme Migration Notice Dismissal Function
148 function dismissThemeMigrationNotice() {
149 if (typeof jQuery !== 'undefined' && typeof mxchatThemeMigration !== 'undefined') {
150 jQuery.post(mxchatThemeMigration.ajaxurl, {
151 action: 'dismiss_theme_migration_notice',
152 nonce: mxchatThemeMigration.nonce
153 }, function(response) {
154 if (response.success) {
155 jQuery('#mxchat-theme-migration-notice').fadeOut(300);
156 }
157 }).fail(function() {
158 // Fallback: just hide the notice if AJAX fails
159 jQuery('#mxchat-theme-migration-notice').fadeOut(300);
160 });
161 } else {
162 // Fallback for cases where jQuery or localized data isn't available
163 var notice = document.getElementById('mxchat-theme-migration-notice');
164 if (notice) {
165 notice.style.display = 'none';
166 }
167 }
168 }
169
170 // Updated mxchatOpenActionModal function to integrate with the new selector
171 function mxchatOpenActionModal(isEdit = false, actionId = '', label = '', phrases = '', threshold = 85, callbackFunction = '') {
172 const modal = document.getElementById('mxchat-action-modal');
173 if (!modal) return;
174
175 // Get form fields
176 const actionIdField = document.getElementById('edit_action_id');
177 const labelField = document.getElementById('intent_label');
178 const phrasesField = document.getElementById('action_phrases');
179 const formActionType = document.getElementById('form_action_type');
180 const callbackGroup = document.getElementById('callback_selection_group');
181 const callbackSelect = document.getElementById('callback_function');
182 const saveButton = document.getElementById('mxchat-save-action-btn');
183 const nonceContainer = document.getElementById('action-nonce-container');
184 const thresholdSlider = document.getElementById('similarity_threshold');
185 const thresholdDisplay = document.querySelector('.mxchat-threshold-value-display');
186
187 // Set up modal for edit or create
188 if (isEdit) {
189 saveButton.textContent = 'Update Action';
190 formActionType.value = 'mxchat_edit_intent';
191 actionIdField.value = actionId;
192 labelField.value = label;
193 phrasesField.value = phrases;
194 callbackGroup.style.display = 'none'; // Hide callback selection when editing
195 thresholdSlider.value = threshold; // Set the current threshold value
196 thresholdDisplay.textContent = threshold + '%'; // Update display
197
198 // Remove the required attribute when editing
199 callbackSelect.removeAttribute('required');
200
201 // Update the nonce field for editing
202 nonceContainer.innerHTML = ''; // Clear existing nonce
203 if (typeof mxchatAdmin !== 'undefined' && mxchatAdmin.edit_intent_nonce) {
204 nonceContainer.innerHTML = `<input type="hidden" name="_wpnonce" value="${mxchatAdmin.edit_intent_nonce}">`;
205 }
206 } else {
207 saveButton.textContent = 'Save Action';
208 formActionType.value = 'mxchat_add_intent';
209 actionIdField.value = '';
210 labelField.value = '';
211 phrasesField.value = '';
212 callbackGroup.style.display = 'block'; // Show callback selection when creating
213 thresholdSlider.value = 85; // Default value for new actions
214 thresholdDisplay.textContent = '85%'; // Default display
215
216 // Ensure the required attribute is present when adding
217 callbackSelect.setAttribute('required', 'required');
218
219 // Update the nonce field for adding
220 nonceContainer.innerHTML = ''; // Clear existing nonce
221 if (typeof mxchatAdmin !== 'undefined' && mxchatAdmin.add_intent_nonce) {
222 nonceContainer.innerHTML = `<input type="hidden" name="_wpnonce" value="${mxchatAdmin.add_intent_nonce}">`;
223 }
224 }
225
226 // Show modal with animation
227 modal.style.display = 'flex';
228 requestAnimationFrame(() => {
229 modal.classList.add('active');
230 });
231
232 // Set up close handlers
233 let detachEsc = null;
234 const closeModal = () => {
235 if (detachEsc) {
236 detachEsc();
237 detachEsc = null;
238 }
239 modal.classList.remove('active');
240 setTimeout(() => {
241 modal.style.display = 'none';
242 }, 300); // Match the CSS transition time
243 };
244
245 // This modal holds edits: close only on explicit controls, confirming when dirty.
246 const guardedClose = mxchatGuardedClose(modal, closeModal);
247
248 // Close button handler
249 const closeBtn = modal.querySelector('.mxchat-modal-close');
250 if (closeBtn) {
251 closeBtn.onclick = guardedClose;
252 }
253
254 // Cancel button handler
255 const cancelBtn = modal.querySelector('.mxchat-modal-cancel');
256 if (cancelBtn) {
257 cancelBtn.onclick = guardedClose;
258 }
259
260 // Escape key to close modal
261 detachEsc = mxchatBindEscClose(modal, guardedClose);
262
263 // Focus the first field
264 labelField.focus();
265
266 // Dispatch an event for the action type selector to catch
267 const event = new CustomEvent('mxchatModalOpened', {
268 detail: {
269 isEdit: isEdit,
270 callbackFunction: callbackFunction || (isEdit ? callbackSelect.value : '')
271 }
272 });
273 document.dispatchEvent(event);
274
275 return closeModal; // Return close function for external use
276 }
277
278 // Initialize event listeners
279 document.addEventListener('DOMContentLoaded', () => {
280 // Set up edit button handlers for intents
281 document.querySelectorAll('.mxchat-edit-button').forEach(button => {
282 button.onclick = () => {
283 const intentId = button.dataset.intentId;
284 const phrases = button.dataset.phrases;
285 mxchatOpenEditModal(intentId, phrases);
286 };
287 });
288
289 document.querySelectorAll('.mxchat-action-card .mxchat-edit-button').forEach(button => {
290 button.onclick = () => {
291 const actionId = button.dataset.actionId;
292 const phrases = button.dataset.phrases;
293 const label = button.dataset.label;
294 const threshold = button.dataset.threshold || 85;
295 const callbackFunction = button.dataset.callbackFunction;
296 const enabledBots = button.dataset.enabledBots; // ADD THIS LINE
297
298 mxchatOpenActionModal(true, actionId, label, phrases, threshold, callbackFunction, enabledBots);
299 };
300 });
301
302 // Set up add new action buttons (new functionality)
303 const addActionBtn = document.getElementById('mxchat-add-action-btn');
304 if (addActionBtn) {
305 addActionBtn.onclick = () => mxchatOpenActionModal();
306 }
307
308 const createFirstAction = document.getElementById('mxchat-create-first-action');
309 if (createFirstAction) {
310 createFirstAction.onclick = () => mxchatOpenActionModal();
311 }
312
313 // Setup category-specific new action buttons (new functionality)
314 document.querySelectorAll('.mxchat-new-action-button').forEach(button => {
315 button.onclick = () => {
316 const category = button.closest('.mxchat-new-action-card').dataset.category;
317 const closeModal = mxchatOpenActionModal();
318
319 // Pre-select the appropriate callback based on category
320 if (category) {
321 const callbackSelect = document.getElementById('callback_function');
322 if (callbackSelect) {
323 setTimeout(() => {
324 // Map categories to default callbacks
325 const categoryToCallback = {
326 'data_collection': 'mxchat_handle_form_collection',
327 'integrations': 'mxchat_handle_slack_message',
328 'custom_actions': 'mxchat_handle_custom_action',
329 'recommendations': 'mxchat_handle_product_recommendations'
330 // Add more mappings as needed
331 };
332
333 if (categoryToCallback[category]) {
334 callbackSelect.value = categoryToCallback[category];
335 }
336 }, 100);
337 }
338 }
339 };
340 });
341
342 // Handle action toggle switches (new functionality)
343 document.querySelectorAll('.mxchat-action-toggle').forEach(toggle => {
344 toggle.onchange = function() {
345 const actionId = this.dataset.actionId;
346 const isEnabled = this.checked;
347
348 // Show loading indicator
349 const loadingEl = document.getElementById('mxchat-action-loading');
350 if (loadingEl) loadingEl.style.display = 'flex';
351
352 // Send AJAX request to update status
353 fetch(ajaxurl, {
354 method: 'POST',
355 headers: {
356 'Content-Type': 'application/x-www-form-urlencoded',
357 },
358 body: new URLSearchParams({
359 action: 'mxchat_toggle_action',
360 intent_id: actionId,
361 enabled: isEnabled ? 1 : 0,
362 nonce: mxchatAdmin.toggle_action_nonce // Use the correct nonce
363 })
364 })
365 .then(response => response.json())
366 .then(data => {
367 if (!data.success) {
368 alert('Failed to update action status: ' + (data.data?.message || 'Unknown error'));
369 this.checked = !isEnabled; // Revert the toggle
370 }
371 })
372 .catch(error => {
373 //console.error('Error:', error);
374 alert('Server error. Please try again.');
375 this.checked = !isEnabled; // Revert the toggle
376 })
377 .finally(() => {
378 if (loadingEl) loadingEl.style.display = 'none';
379 });
380 };
381 });
382
383 // Handle threshold sliders in action cards (new functionality)
384 document.querySelectorAll('.mxchat-threshold-slider').forEach(slider => {
385 slider.oninput = function() {
386 const actionId = this.id.replace('intent_threshold_', '');
387 document.getElementById('threshold_output_' + actionId).textContent = this.value + '%';
388 };
389 });
390
391 // Handle threshold save buttons in action cards (new functionality)
392 document.querySelectorAll('.mxchat-threshold-save').forEach(button => {
393 button.onclick = function(e) {
394 e.preventDefault();
395 const form = this.closest('form');
396 const intentId = form.querySelector('input[name="intent_id"]').value;
397 const threshold = form.querySelector('input[name="intent_threshold"]').value;
398 const nonce = form.querySelector('input[name="_wpnonce"]').value;
399
400 // Show loading indicator
401 const loadingEl = document.getElementById('mxchat-action-loading');
402 if (loadingEl) loadingEl.style.display = 'flex';
403
404 // Send AJAX request
405 fetch(ajaxurl, {
406 method: 'POST',
407 headers: {
408 'Content-Type': 'application/x-www-form-urlencoded',
409 },
410 body: new URLSearchParams({
411 action: 'mxchat_update_intent_threshold',
412 intent_id: intentId,
413 intent_threshold: threshold,
414 _wpnonce: nonce
415 })
416 })
417 .then(response => response.json())
418 .then(data => {
419 if (data.success) {
420 // Visual feedback of success
421 const card = this.closest('.mxchat-action-card');
422 card.style.background = 'rgba(120, 115, 245, 0.1)';
423 setTimeout(() => {
424 card.style.background = 'white';
425 }, 300);
426 } else {
427 alert('Failed to update threshold: ' + (data.data?.message || 'Unknown error'));
428 }
429 })
430 .catch(error => {
431 //console.error('Error:', error);
432 alert('Server error. Please try again.');
433 })
434 .finally(() => {
435 if (loadingEl) loadingEl.style.display = 'none';
436 });
437 };
438 });
439 });
440
441 jQuery(document).ready(function($) {
442 // Ensure we have a debounce function (use lodash if available, otherwise use our implementation)
443 const useDebounce = (window._ && window._.debounce) ? window._.debounce : debounce;
444
445 // --- AJAX Auto-Save ---
446 let $autosaveSections = $('.mxchat-autosave-section');
447
448 // *** ADD THIS: Extend auto-save sections to include Pinecone settings ***
449 const $pineconeAutosaveSection = $('#mxchat-kb-tab-pinecone');
450 if ($pineconeAutosaveSection.length) {
451 $autosaveSections = $autosaveSections.add($pineconeAutosaveSection);
452 //console.log('Added Pinecone section to auto-save monitoring');
453 }
454
455 // Track whether fields have been modified by user
456 const userModifiedFields = new Set();
457
458 if ($autosaveSections.length) {
459 // Track user interactions with input fields to determine if changes are user-initiated
460 $autosaveSections.find('input, textarea, select').on('focus keydown paste', function() {
461 const fieldName = $(this).attr('name');
462 if (fieldName) {
463 userModifiedFields.add(fieldName);
464 }
465 });
466
467 // Handle real-time range slider value updates
468 $autosaveSections.find('input[type="range"]').on('input', function() {
469 const value = $(this).val();
470 const $slider = $(this);
471 const sliderId = $slider.attr('id');
472 // Find the corresponding value display span (convention: id_value)
473 const $valueSpan = $('#' + sliderId + '_value');
474 if ($valueSpan.length) {
475 $valueSpan.text(value);
476 } else {
477 // Fallback for similarity_threshold which uses threshold_value
478 $('#threshold_value').text(value);
479 }
480 });
481
482 // Handle all input changes (including range slider)
483 // Store pending AJAX requests and debounce timers per field to prevent freezing
484 const pendingRequests = {};
485 const fieldDebounceTimers = {};
486
487 // Helper function to save rate limit fields with request tracking
488 function triggerFieldSave($field, name, pendingRequests) {
489 const value = $field.val();
490
491 // Remove any existing feedback containers for this field
492 $field.siblings('.feedback-container').remove();
493 $field.parent().find('.feedback-container').remove();
494
495 // Create feedback container
496 const feedbackContainer = $('<div class="feedback-container"></div>');
497 const spinner = $('<div class="saving-spinner"></div>');
498 const successIcon = $('<div class="success-icon">✔</div>');
499 $field.after(feedbackContainer);
500 feedbackContainer.append(spinner);
501
502 // Rate limits use the main settings action
503 const ajaxAction = 'mxchat_save_setting';
504 const nonce = mxchatAdmin.setting_nonce;
505
506 // Store the AJAX request so it can be aborted if needed
507 pendingRequests[name] = $.ajax({
508 url: mxchatAdmin.ajax_url,
509 type: 'POST',
510 data: {
511 action: ajaxAction,
512 name: name,
513 value: value,
514 _ajax_nonce: nonce
515 },
516 success: function(response) {
517 delete pendingRequests[name];
518 if (response.success) {
519 spinner.fadeOut(200, function() {
520 feedbackContainer.append(successIcon);
521 successIcon.fadeIn(200).delay(800).fadeOut(200, function() {
522 feedbackContainer.remove();
523 });
524 });
525 } else {
526 feedbackContainer.remove();
527 }
528 },
529 error: function(xhr, textStatus, error) {
530 delete pendingRequests[name];
531 // Don't show error for aborted requests
532 if (textStatus !== 'abort') {
533 feedbackContainer.remove();
534 }
535 }
536 });
537 }
538
539 // .mxchat-la-field — the live-agent schedule editors' day inputs (plans
540 // 8ccaa2 + 99d7a4, one editor per channel). They are nameless by design:
541 // each editor folds them into its own hidden live_agent_schedule_<channel>
542 // input and fires ONE change, which this same handler then saves. Without
543 // the exclusion each keystroke would POST a nameless field the server can
544 // only reject.
545 // .mxchat-acf-group-toggle is excluded: group toggles are nameless and
546 // save through their own batch action (bf57e0), never this per-field path.
547 $autosaveSections.find('input, textarea, select').not('#model, #openrouter_selected_model, .mxchat-la-field, .mxchat-acf-group-toggle').on('change', function() {
548 const $field = $(this);
549 const name = $field.attr('name');
550
551 // Debounce rate limit fields to prevent UI freezing from rapid changes
552 if (name && name.indexOf('rate_limits') !== -1) {
553 // Clear any pending timer for this field
554 if (fieldDebounceTimers[name]) {
555 clearTimeout(fieldDebounceTimers[name]);
556 }
557 // Abort any pending AJAX request for this field
558 if (pendingRequests[name]) {
559 pendingRequests[name].abort();
560 // Remove any existing feedback containers for this field
561 $field.siblings('.feedback-container').remove();
562 $field.parent().find('.feedback-container').remove();
563 }
564 // Debounce the save operation
565 const fieldRef = $field;
566 fieldDebounceTimers[name] = setTimeout(function() {
567 triggerFieldSave(fieldRef, name, pendingRequests);
568 }, 300);
569 return;
570 }
571
572 // Skip saving for API key fields that haven't been interacted with and are empty
573 const isApiKeyField = name && (
574 name === 'loops_api_key' ||
575 name === 'api_key' ||
576 name === 'xai_api_key' ||
577 name === 'claude_api_key' ||
578 name === 'voyage_api_key' ||
579 name === 'gemini_api_key' ||
580 name === 'deepseek_api_key' ||
581 name.indexOf('_api_key') !== -1
582 );
583
584 // Skip processing if:
585 // 1. It's an API key field
586 // 2. The user hasn't interacted with it
587 // 3. The field is empty
588 if (isApiKeyField && !userModifiedFields.has(name) && (!$field.val() || $field.val().trim() === '')) {
589 //console.log('Skipping auto-save for untouched API key field:', name);
590 return;
591 }
592
593 let value;
594
595 // Handle different input types
596 if ($field.attr('type') === 'checkbox') {
597 // *** UPDATED: Handle Pinecone checkboxes differently ***
598 if (name && name.indexOf('mxchat_pinecone_addon_options') !== -1) {
599 value = $field.is(':checked') ? '1' : '0';
600 } else {
601 value = $field.is(':checked') ? 'on' : 'off';
602 }
603 } else {
604 value = $field.val();
605 }
606
607 // Create feedback container
608 const feedbackContainer = $('<div class="feedback-container"></div>');
609 const spinner = $('<div class="saving-spinner"></div>');
610 const successIcon = $('<div class="success-icon">✔</div>');
611
612 // Position feedback container based on input type
613 if ($field.closest('.toggle-switch').length) {
614 // Try td first (old layout), then mxc-field-control (new card layout), then fallback to after toggle
615 var $container = $field.closest('td');
616 if (!$container.length) {
617 $container = $field.closest('.mxc-field-control');
618 }
619 if ($container.length) {
620 $container.append(feedbackContainer);
621 } else {
622 $field.closest('.toggle-switch').after(feedbackContainer);
623 }
624 } else if ($field.closest('.mxchat-toggle-switch').length) {
625 // Try mxchat-toggle-container first, then parent div, then fallback to after toggle
626 var $toggleContainer = $field.closest('.mxchat-toggle-container');
627 if ($toggleContainer.length) {
628 $toggleContainer.append(feedbackContainer);
629 } else {
630 $field.closest('.mxchat-toggle-switch').after(feedbackContainer);
631 }
632 } else if ($field.closest('.slider-container').length) {
633 $field.closest('.slider-container').after(feedbackContainer);
634 } else {
635 $field.after(feedbackContainer);
636 }
637 feedbackContainer.append(spinner);
638
639 // Determine which AJAX action and nonce to use:
640 var ajaxAction, nonce;
641 // *** UPDATED: Add Pinecone fields, chunking fields, ACF fields, and custom meta to prompts action ***
642 if (name.indexOf('mxchat_prompts_options') !== -1 ||
643 name === 'mxchat_auto_sync_posts' ||
644 name === 'mxchat_auto_sync_pages' ||
645 name.indexOf('mxchat_auto_sync_') === 0 ||
646 name.indexOf('mxchat_pinecone_addon_options') !== -1 ||
647 name.indexOf('mxchat_chunk') === 0 ||
648 name.indexOf('mxchat_acf_field_') === 0 ||
649 name === 'mxchat_custom_meta_whitelist') { // Chunking, ACF field settings, and custom meta
650 ajaxAction = 'mxchat_save_prompts_setting';
651 nonce = mxchatPromptsAdmin.prompts_setting_nonce;
652 } else {
653 // Otherwise, use the existing AJAX action.
654 ajaxAction = 'mxchat_save_setting';
655 nonce = mxchatAdmin.setting_nonce;
656 }
657
658 // *** ADD THIS: Debug logging for Pinecone fields ***
659 if (name && name.indexOf('mxchat_pinecone_addon_options') !== -1) {
660 //console.log('Saving Pinecone field:', name, '=', value);
661 }
662
663 // AJAX save request
664 $.ajax({
665 url: (ajaxAction === 'mxchat_save_prompts_setting') ? mxchatPromptsAdmin.ajax_url : mxchatAdmin.ajax_url,
666 type: 'POST',
667 data: {
668 action: ajaxAction,
669 name: name,
670 value: value,
671 _ajax_nonce: nonce
672 },
673 success: function(response) {
674 if (response.success) {
675 spinner.fadeOut(200, function() {
676 feedbackContainer.append(successIcon);
677 successIcon.fadeIn(200).delay(1000).fadeOut(200, function() {
678 feedbackContainer.remove();
679 });
680 });
681
682 // *** ADD THIS: Refresh API key status after saving an API key ***
683 const isApiKeyField = name && (
684 name === 'api_key' ||
685 name === 'xai_api_key' ||
686 name === 'claude_api_key' ||
687 name === 'voyage_api_key' ||
688 name === 'gemini_api_key' ||
689 name === 'deepseek_api_key' ||
690 name === 'openrouter_api_key' ||
691 name.indexOf('_api_key') !== -1
692 );
693
694 if (isApiKeyField && typeof window.mxchatRefreshAPIKeyStatus === 'function') {
695 window.mxchatRefreshAPIKeyStatus();
696 }
697
698 // *** ADD THIS: Update Pinecone checkbox state after successful save ***
699 if (name && name.indexOf('mxchat_pinecone_addon_options[mxchat_use_pinecone]') !== -1) {
700 //console.log('Pinecone toggle saved successfully, value:', value);
701
702 // The checkbox state is already updated by the user interaction
703 // But let's make sure the UI state matches the saved value
704 var $checkbox = $('input[name="mxchat_pinecone_addon_options[mxchat_use_pinecone]"]');
705 var settingsDiv = $('.mxchat-pinecone-settings');
706
707 // Double-check the UI state matches what was saved
708 if (value === '1' && !$checkbox.is(':checked')) {
709 $checkbox.prop('checked', true);
710 settingsDiv.slideDown(300);
711 } else if (value === '0' && $checkbox.is(':checked')) {
712 $checkbox.prop('checked', false);
713 settingsDiv.slideUp(300);
714 }
715
716 //console.log('Pinecone UI state synchronized');
717
718 // Check if Knowledge Import tab is currently active
719 if ($('.mxchat-kb-tab-button[data-tab="import"]').hasClass('active')) {
720 // Show a notice that we need to refresh
721 var $knowledgeCard = $('#mxchat-kb-tab-import .mxchat-card').eq(1);
722 if ($knowledgeCard.length > 0) {
723 // Add a refresh notice at the top of the knowledge base card
724 var refreshNotice = $('<div class="notice notice-warning" style="margin: 15px 0; padding: 10px 15px;">' +
725 '<p style="margin: 0;">' +
726 '<span class="dashicons dashicons-info" style="color: #f0ad4e; margin-right: 5px;"></span>' +
727 'Database settings have changed. ' +
728 '<a href="#" onclick="location.reload(); return false;" style="font-weight: bold;">Click here to refresh</a> to see the updated knowledge base.' +
729 '</p></div>');
730
731 $knowledgeCard.prepend(refreshNotice);
732 }
733 } else {
734 // If not on import tab, set a flag to refresh when they go there
735 sessionStorage.setItem('mxchat_pinecone_changed', 'true');
736 }
737 }
738
739 // *** ADD THIS: Debug logging for successful saves ***
740 if (name && name.indexOf('mxchat_pinecone_addon_options') !== -1) {
741 //console.log('Pinecone field saved successfully:', name, '=', value);
742 }
743
744 // Check if the response contains a "no changes" message and log it
745 if (response.data && response.data.message === 'No changes detected') {
746 //console.log('No changes detected for field:', name);
747 }
748 } else {
749 // Only show alert for actual errors, not for "no changes"
750 let errorMessage = response.data?.message || 'Unknown error';
751
752 // Don't display an alert for "no changes" message
753 if (errorMessage !== 'No changes detected' && errorMessage !== 'Update failed or no changes') {
754 alert('Error saving: ' + errorMessage);
755 } else {
756 // Still provide visual feedback that no changes were needed
757 spinner.fadeOut(200, function() {
758 feedbackContainer.append(successIcon);
759 successIcon.fadeIn(200).delay(1000).fadeOut(200, function() {
760 feedbackContainer.remove();
761 });
762 });
763 //console.log('No changes detected for field:', name);
764 return;
765 }
766
767 // Only revert checkbox state if it was an actual error
768 if (errorMessage !== 'No changes detected' && errorMessage !== 'Update failed or no changes') {
769 if ($field.attr('type') === 'checkbox') {
770 $field.prop('checked', !$field.is(':checked'));
771 }
772 }
773
774 // Always clean up the feedback container
775 feedbackContainer.remove();
776 }
777 },
778 error: function(xhr, textStatus, error) {
779 //console.error('AJAX Error:', textStatus, error);
780 alert('An error occurred while saving. Please try again.');
781
782 // Revert checkbox state on error
783 if ($field.attr('type') === 'checkbox') {
784 $field.prop('checked', !$field.is(':checked'));
785 }
786
787 feedbackContainer.remove();
788 }
789 });
790 });
791
792 // Initialize color pickers with debouncing
793 $autosaveSections.find('.my-color-field').each(function() {
794 const $colorField = $(this);
795
796 $(this).wpColorPicker({
797 change: useDebounce(function(event, ui) {
798 // Safety check - ensure we have a valid field and value
799 if (!$colorField || !$colorField.val()) {
800 //console.warn('Color picker not ready');
801 return;
802 }
803
804 const name = $colorField.attr('name');
805 const value = $colorField.val();
806
807 if (!name || !value) {
808 //console.warn('Missing required color picker values');
809 return;
810 }
811
812 // Create feedback container
813 const feedbackContainer = $('<div class="feedback-container"></div>');
814 const spinner = $('<div class="saving-spinner"></div>');
815 const successIcon = $('<div class="success-icon">✔</div>');
816
817 // Position feedback container
818 $colorField.closest('.wp-picker-container').after(feedbackContainer);
819 feedbackContainer.append(spinner);
820
821 // Determine which AJAX action and nonce to use:
822 var ajaxAction, nonce;
823 // Use the new AJAX action for submenu fields:
824 if (name.indexOf('mxchat_prompts_options') !== -1 ||
825 name === 'mxchat_auto_sync_posts' ||
826 name === 'mxchat_auto_sync_pages' ||
827 name.indexOf('mxchat_auto_sync_') === 0) { // Modified to catch all auto-sync fields
828 ajaxAction = 'mxchat_save_prompts_setting';
829 nonce = mxchatPromptsAdmin.prompts_setting_nonce;
830 } else {
831 // Otherwise, use the existing AJAX action.
832 ajaxAction = 'mxchat_save_setting';
833 nonce = mxchatAdmin.setting_nonce;
834 }
835 // AJAX save request
836 $.ajax({
837 url: (ajaxAction === 'mxchat_save_prompts_setting') ? mxchatPromptsAdmin.ajax_url : mxchatAdmin.ajax_url,
838 type: 'POST',
839 data: {
840 action: ajaxAction,
841 name: name,
842 value: value,
843 _ajax_nonce: nonce
844 },
845 success: function(response) {
846 if (response.success) {
847 spinner.fadeOut(200, function() {
848 feedbackContainer.append(successIcon);
849 successIcon.fadeIn(200).delay(1000).fadeOut(200, function() {
850 feedbackContainer.remove();
851 });
852 });
853 } else {
854 alert('Error saving: ' + (response.data?.message || 'Unknown error'));
855 feedbackContainer.remove();
856 }
857 },
858 error: function() {
859 alert('An error occurred while saving.');
860 feedbackContainer.remove();
861 }
862 });
863 }, 500)
864 });
865 });
866
867 }
868
869 // ========================================
870 // POST TYPE VISIBILITY SETTINGS
871 // ========================================
872 (function() {
873 const $appendToBody = $('#append_to_body');
874 const $visibilityOptions = $('#post-type-visibility-options');
875 const $modeRadios = $('input[name="post_type_visibility_mode"]');
876 const $postTypeList = $('#post-type-list');
877 const $postTypeCheckboxes = $('input[name="post_type_visibility_list[]"]');
878
879 // Toggle visibility options based on auto-display toggle
880 $appendToBody.on('change', function() {
881 if ($(this).is(':checked')) {
882 $visibilityOptions.slideDown(200);
883 } else {
884 $visibilityOptions.slideUp(200);
885 }
886 });
887
888 // Toggle post type list based on mode selection
889 $modeRadios.on('change', function() {
890 const mode = $(this).val();
891 if (mode === 'all') {
892 $postTypeList.slideUp(200);
893 } else {
894 $postTypeList.slideDown(200);
895 }
896
897 // Save mode via AJAX
898 savePostTypeVisibility('post_type_visibility_mode', mode);
899 });
900
901 // Save post type list when checkboxes change
902 $postTypeCheckboxes.on('change', function() {
903 // Collect all checked post types
904 const selectedPostTypes = [];
905 $postTypeCheckboxes.filter(':checked').each(function() {
906 selectedPostTypes.push($(this).val());
907 });
908
909 // Save as JSON array
910 savePostTypeVisibility('post_type_visibility_list', JSON.stringify(selectedPostTypes));
911 });
912
913 // Helper function to save post type visibility settings
914 function savePostTypeVisibility(name, value) {
915 if (typeof mxchatAdmin === 'undefined') return;
916
917 // Find the container element for feedback
918 const $container = name === 'post_type_visibility_mode'
919 ? $('.mxchat-visibility-mode')
920 : $postTypeList;
921
922 // Remove any existing feedback
923 $container.find('.mxchat-save-feedback').remove();
924
925 // Create feedback element
926 const $feedback = $('<span class="mxchat-save-feedback" style="margin-left: 10px; font-size: 12px;"></span>');
927 $feedback.text('Saving...').css('color', '#666');
928
929 if (name === 'post_type_visibility_mode') {
930 $container.append($feedback);
931 } else {
932 $container.before($feedback);
933 }
934
935 $.ajax({
936 url: mxchatAdmin.ajax_url,
937 type: 'POST',
938 data: {
939 action: 'mxchat_save_setting',
940 name: name,
941 value: value,
942 _ajax_nonce: mxchatAdmin.setting_nonce
943 },
944 success: function(response) {
945 if (response.success) {
946 $feedback.text('✓ Saved').css('color', '#46b450');
947 setTimeout(function() {
948 $feedback.fadeOut(300, function() {
949 $(this).remove();
950 });
951 }, 1500);
952 } else {
953 $feedback.text('Error saving').css('color', '#dc3232');
954 }
955 },
956 error: function() {
957 $feedback.text('Error saving').css('color', '#dc3232');
958 }
959 });
960 }
961 })();
962
963 // Toggle visibility handlers
964 function toggleVisibility(selector) {
965 $(selector).on('click', function() {
966 var inputField = $(this).prev('input');
967 // Check if this is a CSS-masked field (type="text" with mxchat-api-key-field class)
968 if (inputField.hasClass('mxchat-api-key-field')) {
969 // Use CSS class toggle for CSS-masked fields
970 if (inputField.hasClass('mxchat-show-key')) {
971 inputField.removeClass('mxchat-show-key');
972 $(this).text('Show');
973 } else {
974 inputField.addClass('mxchat-show-key');
975 $(this).text('Hide');
976 }
977 } else {
978 // Legacy type toggle for password fields
979 if (inputField.attr('type') === 'password') {
980 inputField.attr('type', 'text');
981 $(this).text('Hide');
982 } else {
983 inputField.attr('type', 'password');
984 $(this).text('Show');
985 }
986 }
987 });
988 }
989
990 // Initialize all toggle visibility buttons
991 [
992 '#toggleApiKeyVisibility',
993 '#toggleWooCommerceSecretVisibility',
994 '#toggleVoyageAPIKeyVisibility',
995 '#toggleLoopsApiKeyVisibility',
996 '#toggleXaiApiKeyVisibility',
997 '#toggleClaudeApiKeyVisibility',
998 '#toggleBraveApiKeyVisibility',
999 '#toggleWebhookUrlVisibility',
1000 '#toggleSecretKeyVisibility',
1001 '#toggleBotTokenVisibility',
1002 '#toggleDeepSeekApiKeyVisibility',
1003 '#toggleGeminiApiKeyVisibility',
1004 '#toggleOpenRouterApiKeyVisibility'
1005 ].forEach(toggleVisibility);
1006
1007 function setupMxChatModelSelector() {
1008 const $modelSelect = $('#model');
1009 const $modelSelectorButton = $('<button>', {
1010 type: 'button',
1011 id: 'mxchat_model_selector_btn',
1012 class: 'button-primary mxchat-model-selector-btn',
1013 text: 'Select AI Model'
1014 });
1015
1016 // Replace the select dropdown with a button
1017 $modelSelect.hide().after($modelSelectorButton);
1018
1019 // Update button text to show currently selected model
1020 function updateButtonText() {
1021 const selectedModel = $modelSelect.val();
1022
1023 // Check if OpenRouter is selected
1024 if (selectedModel === 'openrouter') {
1025 const openrouterModelId = $('#openrouter_selected_model').val();
1026 const openrouterModelName = $('#openrouter_selected_model_name').val();
1027
1028 if (openrouterModelName && openrouterModelName.trim() !== '') {
1029 $modelSelectorButton.text('OpenRouter: ' + openrouterModelName);
1030 } else if (openrouterModelId && openrouterModelId.trim() !== '') {
1031 $modelSelectorButton.text('OpenRouter: ' + openrouterModelId);
1032 } else {
1033 $modelSelectorButton.text('OpenRouter - Select Model');
1034 }
1035 } else {
1036 const selectedModelText = $modelSelect.find('option:selected').text();
1037 $modelSelectorButton.text(selectedModelText);
1038 }
1039 }
1040
1041 // Initialize button text
1042 updateButtonText();
1043
1044 // Create and append modal HTML
1045 const modelSelectorModal = `
1046 <div id="mxchat_model_selector_modal" class="mxchat-model-selector-modal">
1047 <div class="mxchat-model-selector-modal-content">
1048 <div class="mxchat-model-selector-modal-header">
1049 <h3>Select AI Model</h3>
1050 <span class="mxchat-model-selector-modal-close">&times;</span>
1051 </div>
1052 <div class="mxchat-model-selector-modal-body">
1053 <div class="mxchat-model-selector-search-container">
1054 <input type="text" id="mxchat_model_search_input" class="mxchat-model-search-input" placeholder="Search models...">
1055 </div>
1056 <div class="mxchat-model-selector-categories">
1057 <button class="mxchat-model-category-btn active" data-category="all">All</button>
1058 <button class="mxchat-model-category-btn" data-category="openrouter">OpenRouter</button>
1059 <button class="mxchat-model-category-btn" data-category="gemini">Google Gemini</button>
1060 <button class="mxchat-model-category-btn" data-category="openai">OpenAI</button>
1061 <button class="mxchat-model-category-btn" data-category="claude">Claude</button>
1062 <button class="mxchat-model-category-btn" data-category="xai">X.AI</button>
1063 <button class="mxchat-model-category-btn" data-category="deepseek">DeepSeek</button>
1064 <button class="mxchat-model-category-btn" data-category="custom">Custom / Local</button>
1065 </div>
1066 <div class="mxchat-model-selector-grid" id="mxchat_models_grid"></div>
1067 </div>
1068 <div class="mxchat-model-selector-modal-footer">
1069 <button id="mxchat_cancel_model_selection" class="button mxchat-model-cancel-btn">Cancel</button>
1070 </div>
1071 </div>
1072 </div>
1073 `;
1074
1075 $('body').append(modelSelectorModal);
1076
1077 // MOVE THIS OUTSIDE - Make it a property of the window object so it's accessible globally
1078 window.populateModelsGrid = function(filter = '', category = 'all') {
1079 const $grid = $('#mxchat_models_grid');
1080 $grid.empty();
1081
1082 // Catalog refactor (plan-d14e89): when class-mxchat-model-catalog.php
1083 // is loaded (via wp_localize_script as mxchatChatModelCatalog), use
1084 // its data so a single edit there flows to this picker grid. The
1085 // inline fallback below keeps the picker working if for some reason
1086 // the localize hasn't run (e.g. legacy admin page bootstrap order).
1087 const models = (typeof mxchatChatModelCatalog === 'object' && mxchatChatModelCatalog) ? mxchatChatModelCatalog : {
1088 openrouter: [
1089 { value: 'openrouter', label: 'OpenRouter', description: 'Access 100+ models from multiple providers (add API key to browse)' }
1090 ],
1091 gemini: [
1092 { value: 'gemini-3.5-flash', label: 'Gemini 3.5 Flash', description: 'Stable — newest Flash generation, recommended default' },
1093 ],
1094 openai: [
1095 { value: 'gpt-5.6-sol', label: 'GPT-5.6 Sol', description: 'Recommended — newest OpenAI flagship for reasoning, coding and chat' },
1096 ],
1097 claude: [
1098 { value: 'claude-fable-5', label: 'Claude Fable 5', description: 'Latest Flagship — newest and most capable Anthropic model' },
1099 { value: 'claude-opus-5', label: 'Claude Opus 5', description: 'Latest Opus — best for complex agentic and coding work' },
1100 { value: 'claude-opus-4-8', label: 'Claude Opus 4.8', description: 'Previous Opus generation' },
1101 { value: 'claude-opus-4-7', label: 'Claude Opus 4.7', description: 'Previous Anthropic flagship model' },
1102 ],
1103 xai: [
1104 { value: 'grok-4.6', label: 'Grok 4.6', description: 'Newest xAI flagship — 500K context, accepts image input' },
1105 ],
1106 deepseek: [
1107 { value: 'deepseek-v4-flash', label: 'DeepSeek V4 Flash', description: 'Fast and cost-effective' },
1108 { value: 'deepseek-v4-pro', label: 'DeepSeek V4 Pro', description: 'Most capable DeepSeek model' },
1109 ],
1110 custom: [
1111 { value: 'custom-provider', label: 'Custom Provider', description: 'OpenAI-compatible local LLM — configure in API Keys tab' },
1112 ],
1113 };
1114
1115 let allModels = [];
1116 Object.keys(models).forEach(key => {
1117 if (category === 'all' || category === key) {
1118 allModels = allModels.concat(models[key]);
1119 }
1120 });
1121
1122 // Filter by search term if present
1123 if (filter) {
1124 const lowerFilter = filter.toLowerCase();
1125 allModels = allModels.filter(model =>
1126 model.label.toLowerCase().includes(lowerFilter) ||
1127 model.description.toLowerCase().includes(lowerFilter)
1128 );
1129 }
1130
1131 // Create model cards
1132 allModels.forEach(model => {
1133 const isSelected = $modelSelect.val() === model.value;
1134 const $modelCard = $(`
1135 <div class="mxchat-model-selector-card ${isSelected ? 'mxchat-model-selected' : ''}" data-value="${model.value}">
1136 <div class="mxchat-model-selector-icon">${getModelIcon(model.value)}</div>
1137 <div class="mxchat-model-selector-info">
1138 <h4 class="mxchat-model-selector-title">${model.label}</h4>
1139 <p class="mxchat-model-selector-description">${model.description}</p>
1140 </div>
1141 ${isSelected ? '<div class="mxchat-model-selector-checkmark">✓</div>' : ''}
1142 </div>
1143 `);
1144 $grid.append($modelCard);
1145 });
1146 };
1147
1148 // Helper function to get icon for each model
1149 function getModelIcon(modelValue) {
1150 if (modelValue === 'openrouter') return '<span class="dashicons dashicons-networking" style="font-size: 24px; color: #6750A4;"></span>';
1151 if (modelValue.startsWith('gemini-')) return '<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 48 48" class="mxchat-model-icon-gemini"><defs><path id="a" d="M44.5 20H24v8.5h11.8C34.7 33.9 30.1 37 24 37c-7.2 0-13-5.8-13-13s5.8-13 13-13c3.1 0 5.9 1.1 8.1 2.9l6.4-6.4C34.6 4.1 29.6 2 24 2 11.8 2 2 11.8 2 24s9.8 22 22 22c11 0 21-8 21-22 0-1.3-.2-2.7-.5-4z"></path></defs><clipPath id="b"><use xlink:href="#a" overflow="visible"></use></clipPath><path clip-path="url(#b)" fill="#FBBC05" d="M0 37V11l17 13z"></path><path clip-path="url(#b)" fill="#EA4335" d="M0 11l17 13 7-6.1L48 14V0H0z"></path><path clip-path="url(#b)" fill="#34A853" d="M0 37l30-23 7.9 1L48 0v48H0z"></path><path clip-path="url(#b)" fill="#4285F4" d="M48 48L17 24l-4-3 35-10z"></path></svg>';
1152 if (modelValue.startsWith('gpt-')) return '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 320" class="mxchat-model-icon-openai"><path fill="currentColor" d="M297 131a80.6 80.6 0 0 0-93.7-104.2 80.6 80.6 0 0 0-137 29A80.6 80.6 0 0 0 23 189a80.6 80.6 0 0 0 93.7 104.2 80.6 80.6 0 0 0 137-29A80.7 80.7 0 0 0 297.1 131zM176.9 299c-14 .1-27.6-4.8-38.4-13.8l1.9-1 63.7-36.9c3.3-1.8 5.3-5.3 5.2-9v-89.9l27 15.6c.3.1.4.4.5.7v74.4a60 60 0 0 1-60 60zM47.9 244a59.7 59.7 0 0 1-7.1-40.1l1.9 1.1 63.7 36.8c3.2 1.9 7.2 1.9 10.5 0l77.8-45V228c0 .3-.2.6-.4.8L129.9 266a60 60 0 0 1-82-22zM31.2 105c7-12.2 18-21.5 31.2-26.3v75.8c0 3.7 2 7.2 5.2 9l77.8 45-27 15.5a1 1 0 0 1-.9 0L53.1 187a60 60 0 0 1-22-82zm221.2 51.5-77.8-45 27-15.5a1 1 0 0 1 .9 0l64.4 37.1a60 60 0 0 1-9.3 108.2v-75.8c0-3.7-2-7.2-5.2-9zm26.8-40.4-1.9-1.1-63.7-36.8a10.4 10.4 0 0 0-10.5 0L125.4 123V92c0-.3 0-.6.3-.8L190.1 54a60 60 0 0 1 89.1 62.1zm-168.5 55.4-27-15.5a1 1 0 0 1-.4-.7V80.9a60 60 0 0 1 98.3-46.1l-1.9 1L116 72.8a10.3 10.3 0 0 0-5.2 9v89.8zm14.6-31.5 34.7-20 34.6 20v40L160 200l-34.7-20z"></path></svg>';
1153 if (modelValue.startsWith('claude-')) return '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 176" fill="none" class="mxchat-model-icon-claude"><path fill="currentColor" d="m147.487 0l70.081 175.78H256L185.919 0zM66.183 106.221l23.98-61.774l23.98 61.774zM70.07 0L0 175.78h39.18l14.33-36.914h73.308l14.328 36.914h39.179L110.255 0z"></path></svg>';
1154 if (modelValue.startsWith('grok-')) return '<svg fill="currentColor" fill-rule="evenodd" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" class="mxchat-model-icon-xai"><path d="M6.469 8.776L16.512 23h-4.464L2.005 8.776H6.47zm-.004 7.9l2.233 3.164L6.467 23H2l4.465-6.324zM22 2.582V23h-3.659V7.764L22 2.582zM22 1l-9.952 14.095-2.233-3.163L17.533 1H22z"></path></svg>';
1155 if (modelValue === 'custom-provider' || modelValue.startsWith('custom-')) return '<span class="dashicons dashicons-admin-site-alt3" style="font-size: 24px; color: #2c8a3d;"></span>';
1156 if (modelValue.startsWith('deepseek-')) return '<svg height="1em" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg" class="mxchat-model-icon-deepseek"><path d="M23.748 4.482c-.254-.124-.364.113-.512.234-.051.039-.094.09-.137.136-.372.397-.806.657-1.373.626-.829-.046-1.537.214-2.163.848-.133-.782-.575-1.248-1.247-1.548-.352-.156-.708-.311-.955-.65-.172-.241-.219-.51-.305-.774-.055-.16-.11-.323-.293-.35-.2-.031-.278.136-.356.276-.313.572-.434 1.202-.422 1.84.027 1.436.633 2.58 1.838 3.393.137.093.172.187.129.323-.082.28-.18.552-.266.833-.055.179-.137.217-.329.14a5.526 5.526 0 01-1.736-1.18c-.857-.828-1.631-1.742-2.597-2.458a11.365 11.365 0 00-.689-.471c-.985-.957.13-1.743.388-1.836.27-.098.093-.432-.779-.428-.872.004-1.67.295-2.687.684a3.055 3.055 0 01-.465.137 9.597 9.597 0 00-2.883-.102c-1.885.21-3.39 1.102-4.497 2.623C.082 8.606-.231 10.684.152 12.85c.403 2.284 1.569 4.175 3.36 5.653 1.858 1.533 3.997 2.284 6.438 2.14 1.482-.085 3.133-.284 4.994-1.86.47.234.962.327 1.78.397.63.059 1.236-.03 1.705-.128.735-.156.684-.837.419-.961-2.155-1.004-1.682-.595-2.113-.926 1.096-1.296 2.746-2.642 3.392-7.003.05-.347.007-.565 0-.845-.004-.17.035-.237.23-.256a4.173 4.173 0 001.545-.475c1.396-.763 1.96-2.015 2.093-3.517.02-.23-.004-.467-.247-.588zM11.581 18c-2.089-1.642-3.102-2.183-3.52-2.16-.392.024-.321.471-.235.763.09.288.207.486.371.739.114.167.192.416-.113.603-.673.416-1.842-.14-1.897-.167-1.361-.802-2.5-1.86-3.301-3.307-.774-1.393-1.224-2.887-1.298-4.482-.02-.386.093-.522.477-.592a4.696 4.696 0 011.529-.039c2.132.312 3.946 1.265 5.468 2.774.868.86 1.525 1.887 2.202 2.891.72 1.066 1.494 2.082 2.48 2.914.348.292.625.514.891.677-.802.09-2.14.11-3.054-.614zm1-6.44a.306.306 0 01.415-.287.302.302 0 01.2.288.306.306 0 01-.31.307.303.303 0 01-.304-.308zm3.11 1.596c-.2.081-.399.151-.59.16a1.245 1.245 0 01-.798-.254c-.274-.23-.47-.358-.552-.758a1.73 1.73 0 01.016-.588c.07-.327-.008-.537-.239-.727-.187-.156-.426-.199-.688-.199a.559.559 0 01-.254-.078c-.11-.054-.2-.19-.114-.358.028-.054.16-.186.192-.21.356-.202.767-.136 1.146.016.352.144.618.408 1.001.782.391.451.462.576.685.914.176.265.336.537.445.848.067.195-.019.354-.25.452z" fill="currentColor"></path></svg>';
1157 return '<span class="dashicons dashicons-admin-generic mxchat-model-icon-generic"></span>';
1158 }
1159
1160 // Event handlers
1161 $modelSelectorButton.on('click', function() {
1162 $('#mxchat_model_selector_modal').show();
1163 window.populateModelsGrid('', 'all');
1164 });
1165
1166 // Deep link from the model-liveness admin notice (b65e8d): land on Settings
1167 // with the picker already open, so "pick a current model" is one click.
1168 try {
1169 if (new URLSearchParams(window.location.search).get('mxchat_open_model_picker') === '1') {
1170 $modelSelectorButton.trigger('click');
1171 }
1172 } catch (e) {}
1173
1174 $('.mxchat-model-selector-modal-close, #mxchat_cancel_model_selection').on('click', function() {
1175 $('#mxchat_model_selector_modal').hide();
1176 });
1177
1178 $('.mxchat-model-category-btn').on('click', function() {
1179 $('.mxchat-model-category-btn').removeClass('active');
1180 $(this).addClass('active');
1181 const category = $(this).data('category');
1182 const searchTerm = $('#mxchat_model_search_input').val();
1183 window.populateModelsGrid(searchTerm, category);
1184 });
1185
1186 $('#mxchat_model_search_input').on('input', function() {
1187 const searchTerm = $(this).val();
1188 const activeCategory = $('.mxchat-model-category-btn.active').data('category');
1189 window.populateModelsGrid(searchTerm, activeCategory);
1190 });
1191
1192 $(document).on('click', '.mxchat-model-selector-card', function() {
1193 const modelValue = $(this).data('value');
1194 const $modelSelect = $('#model');
1195 const $clickedCard = $(this);
1196
1197 // Check if OpenRouter was selected
1198 if (modelValue === 'openrouter') {
1199 // Load OpenRouter models instead of closing
1200 loadOpenRouterModels();
1201 } else {
1202 // Remove selection from all other cards
1203 $('.mxchat-model-selector-card').removeClass('mxchat-model-selected').find('.mxchat-model-selector-checkmark').remove();
1204
1205 // Add selection to clicked card immediately for instant feedback
1206 $clickedCard.addClass('mxchat-model-selected');
1207 if ($clickedCard.find('.mxchat-model-selector-checkmark').length === 0) {
1208 $clickedCard.append('<div class="mxchat-model-selector-checkmark">✓</div>');
1209 }
1210
1211 // Brief delay to show the selection, then start saving
1212 setTimeout(function() {
1213 // Normal model selection
1214 $modelSelect.val(modelValue).trigger('change');
1215
1216 // Show loading state on the card
1217 $clickedCard.css('pointer-events', 'none');
1218 const originalContent = $clickedCard.find('.mxchat-model-selector-title').html();
1219 $clickedCard.find('.mxchat-model-selector-title').html(
1220 '<span class="spinner is-active" style="float: none; margin: 0 5px 0 0;"></span> Saving...'
1221 );
1222
1223 // Manually save the model via AJAX
1224 jQuery.ajax({
1225 url: mxchatAdmin.ajax_url,
1226 type: 'POST',
1227 data: {
1228 action: 'mxchat_save_setting',
1229 name: 'model',
1230 value: modelValue,
1231 _ajax_nonce: mxchatAdmin.setting_nonce
1232 },
1233 success: function(response) {
1234 if (response.success) {
1235 // Show success state
1236 $clickedCard.find('.mxchat-model-selector-title').html(
1237 '<span class="dashicons dashicons-yes" style="color: #46b450; margin-top: 3px;"></span> Saved!'
1238 );
1239
1240 // Update button text after successful save
1241 const selectedModelText = $modelSelect.find('option:selected').text();
1242 $('#mxchat_model_selector_btn').text(selectedModelText);
1243
1244 // Close modal after a short delay to show the success message
1245 setTimeout(function() {
1246 $('#mxchat_model_selector_modal').hide();
1247 // Restore original content and remove selection for next time
1248 $clickedCard.find('.mxchat-model-selector-title').html(originalContent);
1249 $clickedCard.removeClass('mxchat-model-selected').find('.mxchat-model-selector-checkmark').remove();
1250 $clickedCard.css('pointer-events', 'auto');
1251 }, 600);
1252 } else {
1253 // Show error state
1254 $clickedCard.find('.mxchat-model-selector-title').html(
1255 '<span class="dashicons dashicons-no" style="color: #dc3232;"></span> Error!'
1256 );
1257
1258 setTimeout(function() {
1259 $clickedCard.find('.mxchat-model-selector-title').html(originalContent);
1260 $clickedCard.removeClass('mxchat-model-selected').find('.mxchat-model-selector-checkmark').remove();
1261 $clickedCard.css('pointer-events', 'auto');
1262 }, 1500);
1263 }
1264 },
1265 error: function() {
1266 // Show error state
1267 $clickedCard.find('.mxchat-model-selector-title').html(
1268 '<span class="dashicons dashicons-no" style="color: #dc3232;"></span> Error!'
1269 );
1270
1271 setTimeout(function() {
1272 $clickedCard.find('.mxchat-model-selector-title').html(originalContent);
1273 $clickedCard.removeClass('mxchat-model-selected').find('.mxchat-model-selector-checkmark').remove();
1274 $clickedCard.css('pointer-events', 'auto');
1275 }, 1500);
1276 }
1277 });
1278 }, 300); // 300ms delay to show the selection before starting to save
1279 }
1280 });
1281
1282 // Close modal when clicking outside
1283 $(window).on('click', function(event) {
1284 if ($(event.target).is('#mxchat_model_selector_modal')) {
1285 $('#mxchat_model_selector_modal').hide();
1286 }
1287 });
1288 }
1289
1290 function loadOpenRouterModels() {
1291 const apiKey = $('#openrouter_api_key').val(); // This line already re-checks the field
1292 const $modal = $('#mxchat_model_selector_modal');
1293 const $modalBody = $modal.find('.mxchat-model-selector-modal-body');
1294
1295 if (!apiKey || apiKey.trim() === '') {
1296 // Show error message in modal
1297 $modalBody.html(`
1298 <div style="text-align: center; padding: 40px;">
1299 <span class="dashicons dashicons-warning" style="font-size: 48px; color: #d63638; margin-bottom: 20px;"></span>
1300 <h3>OpenRouter API Key Required</h3>
1301 <p>Please enter your OpenRouter API key in the settings before selecting a model. If you're seeing this message and recently entered API key, try refreshing.</p>
1302 <button class="button button-primary" id="mxchat_back_to_models">Back to Models</button>
1303 </div>
1304 `);
1305
1306 $('#mxchat_back_to_models').on('click', function(e) {
1307 e.preventDefault();
1308 // CHANGE THIS: Instead of reloading, restore the original modal content
1309 $modalBody.html(`
1310 <div class="mxchat-model-selector-search-container">
1311 <input type="text" id="mxchat_model_search_input" class="mxchat-model-search-input" placeholder="Search models...">
1312 </div>
1313 <div class="mxchat-model-selector-categories">
1314 <button class="mxchat-model-category-btn active" data-category="all">All</button>
1315 <button class="mxchat-model-category-btn" data-category="openrouter">OpenRouter</button>
1316 <button class="mxchat-model-category-btn" data-category="gemini">Google Gemini</button>
1317 <button class="mxchat-model-category-btn" data-category="openai">OpenAI</button>
1318 <button class="mxchat-model-category-btn" data-category="claude">Claude</button>
1319 <button class="mxchat-model-category-btn" data-category="xai">X.AI</button>
1320 <button class="mxchat-model-category-btn" data-category="deepseek">DeepSeek</button>
1321 </div>
1322 <div class="mxchat-model-selector-grid" id="mxchat_models_grid"></div>
1323 `);
1324
1325 // Re-populate the grid
1326 populateModelsGrid('', 'all');
1327
1328 // Re-bind event handlers
1329 rebindModalEventHandlers();
1330 });
1331 return;
1332 }
1333
1334 // Show loading state
1335 $modalBody.html(`
1336 <div style="text-align: center; padding: 60px 20px;">
1337 <div class="spinner is-active" style="float: none; margin: 0 auto 20px;"></div>
1338 <h3>Loading OpenRouter Models...</h3>
1339 <p>Fetching available models from OpenRouter</p>
1340 </div>
1341 `);
1342
1343 // Fetch models from OpenRouter
1344 jQuery.ajax({
1345 url: mxchatAdmin.ajax_url,
1346 type: 'POST',
1347 data: {
1348 action: 'mxchat_fetch_openrouter_models',
1349 api_key: apiKey,
1350 nonce: mxchatAdmin.fetch_openrouter_models_nonce
1351 },
1352 success: function(response) {
1353 if (response.success && response.data.models) {
1354 displayOpenRouterModels(response.data.models);
1355 } else {
1356 $modalBody.html(`
1357 <div style="text-align: center; padding: 40px;">
1358 <span class="dashicons dashicons-warning" style="font-size: 48px; color: #d63638; margin-bottom: 20px;"></span>
1359 <h3>Error Loading Models</h3>
1360 <p>${response.data.message || 'Failed to load models from OpenRouter'}</p>
1361 <button class="button button-primary" id="mxchat_back_to_models">Back to Models</button>
1362 </div>
1363 `);
1364
1365 $('#mxchat_back_to_models').on('click', function(e) {
1366 e.preventDefault();
1367 // CHANGE THIS: Restore original content instead of reloading
1368 restoreOriginalModalContent();
1369 });
1370 }
1371 },
1372 error: function() {
1373 $modalBody.html(`
1374 <div style="text-align: center; padding: 40px;">
1375 <span class="dashicons dashicons-warning" style="font-size: 48px; color: #d63638; margin-bottom: 20px;"></span>
1376 <h3>Connection Error</h3>
1377 <p>Failed to connect to OpenRouter. Please check your API key and try again.</p>
1378 <button class="button button-primary" id="mxchat_back_to_models">Back to Models</button>
1379 </div>
1380 `);
1381
1382 $('#mxchat_back_to_models').on('click', function(e) {
1383 e.preventDefault();
1384 // CHANGE THIS: Restore original content instead of reloading
1385 restoreOriginalModalContent();
1386 });
1387 }
1388 });
1389 }
1390 function restoreOriginalModalContent() {
1391 const $modalBody = $('#mxchat_model_selector_modal').find('.mxchat-model-selector-modal-body');
1392
1393 $modalBody.html(`
1394 <div class="mxchat-model-selector-search-container">
1395 <input type="text" id="mxchat_model_search_input" class="mxchat-model-search-input" placeholder="Search models...">
1396 </div>
1397 <div class="mxchat-model-selector-categories">
1398 <button class="mxchat-model-category-btn active" data-category="all">All</button>
1399 <button class="mxchat-model-category-btn" data-category="openrouter">OpenRouter</button>
1400 <button class="mxchat-model-category-btn" data-category="gemini">Google Gemini</button>
1401 <button class="mxchat-model-category-btn" data-category="openai">OpenAI</button>
1402 <button class="mxchat-model-category-btn" data-category="claude">Claude</button>
1403 <button class="mxchat-model-category-btn" data-category="xai">X.AI</button>
1404 <button class="mxchat-model-category-btn" data-category="deepseek">DeepSeek</button>
1405 </div>
1406 <div class="mxchat-model-selector-grid" id="mxchat_models_grid"></div>
1407 `);
1408
1409 // Re-populate the grid
1410 window.populateModelsGrid('', 'all');
1411
1412 // Re-bind event handlers
1413 rebindModalEventHandlers();
1414 }
1415 function rebindModalEventHandlers() {
1416 const $modal = $('#mxchat_model_selector_modal');
1417
1418 // Re-bind category button clicks
1419 $('.mxchat-model-category-btn').off('click').on('click', function() {
1420 $('.mxchat-model-category-btn').removeClass('active');
1421 $(this).addClass('active');
1422 const category = $(this).data('category');
1423 const searchTerm = $('#mxchat_model_search_input').val();
1424 window.populateModelsGrid(searchTerm, category);
1425 });
1426
1427 // Re-bind search input
1428 $('#mxchat_model_search_input').off('input').on('input', function() {
1429 const searchTerm = $(this).val();
1430 const activeCategory = $('.mxchat-model-category-btn.active').data('category');
1431 populateModelsGrid(searchTerm, activeCategory);
1432 });
1433 }
1434
1435 function restoreDefaultModalFooter() {
1436 const $modalFooter = $('#mxchat_model_selector_modal').find('.mxchat-model-selector-modal-footer');
1437
1438 // Restore default footer buttons
1439 $modalFooter.html(`
1440 <button id="mxchat_cancel_model_selection" class="button mxchat-model-cancel-btn">Cancel</button>
1441 `);
1442
1443 // Re-bind cancel button
1444 $('#mxchat_cancel_model_selection').on('click', function() {
1445 $('#mxchat_model_selector_modal').hide();
1446 });
1447 }
1448
1449 function displayOpenRouterModels(models) {
1450 const $modal = $('#mxchat_model_selector_modal');
1451 const $modalBody = $modal.find('.mxchat-model-selector-modal-body');
1452 const $modalFooter = $modal.find('.mxchat-model-selector-modal-footer');
1453 const currentSelected = $('#openrouter_selected_model').val();
1454
1455 // Variable to store the currently selected model (in the UI, not yet saved)
1456 let pendingSelection = {
1457 modelId: currentSelected || null,
1458 modelName: $('#openrouter_selected_model_name').val() || null
1459 };
1460
1461 // Build new modal content with search and models
1462 const newContent = `
1463 <div class="mxchat-model-selector-search-container">
1464 <input type="text" id="mxchat_openrouter_search" class="mxchat-model-search-input" placeholder="Search OpenRouter models...">
1465 <p style="margin: 10px 0; color: #666; font-size: 13px;">
1466 <strong>${models.length} models available</strong> ·
1467 <a href="#" id="mxchat_back_to_provider_select" style="color: #2271b1;">← Back to providers</a>
1468 </p>
1469 </div>
1470 <div class="mxchat-model-selector-grid" id="mxchat_openrouter_models_grid"></div>
1471 `;
1472
1473 // Update footer with Save button for OpenRouter
1474 const footerContent = `
1475 <button id="mxchat_back_to_models_footer" class="button mxchat-model-cancel-btn">Back to Providers</button>
1476 <button id="mxchat_save_openrouter_model" class="button button-primary" disabled>
1477 <span class="dashicons dashicons-saved" style="margin-top: 3px;"></span> Save Selected Model
1478 </button>
1479 `;
1480
1481 $modalBody.html(newContent);
1482 $modalFooter.html(footerContent);
1483
1484 // Function to render models
1485 function renderOpenRouterModels(filterText = '') {
1486 const $grid = $('#mxchat_openrouter_models_grid');
1487 $grid.empty();
1488
1489 let filteredModels = models;
1490 if (filterText) {
1491 const lowerFilter = filterText.toLowerCase();
1492 filteredModels = models.filter(m =>
1493 m.id.toLowerCase().includes(lowerFilter) ||
1494 m.name.toLowerCase().includes(lowerFilter) ||
1495 (m.description && m.description.toLowerCase().includes(lowerFilter))
1496 );
1497 }
1498
1499 filteredModels.forEach(model => {
1500 const isSelected = pendingSelection.modelId === model.id;
1501 const contextLength = model.context_length ? `${(model.context_length / 1000).toFixed(0)}K` : '';
1502 const promptPrice = model.pricing.prompt ? `$${(model.pricing.prompt * 1000000).toFixed(2)}/1M` : '';
1503
1504 const $card = jQuery(`
1505 <div class="mxchat-openrouter-card ${isSelected ? 'mxchat-model-selected' : ''}" data-model-id="${model.id}" data-model-name="${model.name}">
1506 <div class="mxchat-model-selector-icon">
1507 ${getOpenRouterIcon(model.id)}
1508 </div>
1509 <div class="mxchat-model-selector-info">
1510 <h4 class="mxchat-model-selector-title">${model.name}</h4>
1511 <div style="font-size: 12px; color: #666; margin-top: 5px;">
1512 ${contextLength ? '<span style="margin-right: 12px;">📄 ' + contextLength + '</span>' : ''}
1513 ${promptPrice ? '<span>💰 ' + promptPrice + '</span>' : ''}
1514 </div>
1515 </div>
1516 ${isSelected ? '<div class="mxchat-model-selector-checkmark">✓</div>' : ''}
1517 </div>
1518 `);
1519
1520 $grid.append($card);
1521 });
1522 }
1523
1524 // Helper to get icon
1525 function getOpenRouterIcon(modelId) {
1526 if (modelId.includes('gpt') || modelId.includes('openai')) {
1527 return '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 320" class="mxchat-model-icon-openai"><path fill="currentColor" d="M297 131a80.6 80.6 0 0 0-93.7-104.2 80.6 80.6 0 0 0-137 29A80.6 80.6 0 0 0 23 189a80.6 80.6 0 0 0 93.7 104.2 80.6 80.6 0 0 0 137-29A80.7 80.7 0 0 0 297.1 131zM176.9 299c-14 .1-27.6-4.8-38.4-13.8l1.9-1 63.7-36.9c3.3-1.8 5.3-5.3 5.2-9v-89.9l27 15.6c.3.1.4.4.5.7v74.4a60 60 0 0 1-60 60zM47.9 244a59.7 59.7 0 0 1-7.1-40.1l1.9 1.1 63.7 36.8c3.2 1.9 7.2 1.9 10.5 0l77.8-45V228c0 .3-.2.6-.4.8L129.9 266a60 60 0 0 1-82-22zM31.2 105c7-12.2 18-21.5 31.2-26.3v75.8c0 3.7 2 7.2 5.2 9l77.8 45-27 15.5a1 1 0 0 1-.9 0L53.1 187a60 60 0 0 1-22-82zm221.2 51.5-77.8-45 27-15.5a1 1 0 0 1 .9 0l64.4 37.1a60 60 0 0 1-9.3 108.2v-75.8c0-3.7-2-7.2-5.2-9zm26.8-40.4-1.9-1.1-63.7-36.8a10.4 10.4 0 0 0-10.5 0L125.4 123V92c0-.3 0-.6.3-.8L190.1 54a60 60 0 0 1 89.1 62.1zm-168.5 55.4-27-15.5a1 1 0 0 1-.4-.7V80.9a60 60 0 0 1 98.3-46.1l-1.9 1L116 72.8a10.3 10.3 0 0 0-5.2 9v89.8zm14.6-31.5 34.7-20 34.6 20v40L160 200l-34.7-20z"></path></svg>';
1528 } else if (modelId.includes('claude') || modelId.includes('anthropic')) {
1529 return '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 176" fill="none" class="mxchat-model-icon-claude"><path fill="currentColor" d="m147.487 0l70.081 175.78H256L185.919 0zM66.183 106.221l23.98-61.774l23.98 61.774zM70.07 0L0 175.78h39.18l14.33-36.914h73.308l14.328 36.914h39.179L110.255 0z"></path></svg>';
1530 } else if (modelId.includes('gemini') || modelId.includes('google')) {
1531 return '<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 48 48" class="mxchat-model-icon-gemini"><defs><path id="a" d="M44.5 20H24v8.5h11.8C34.7 33.9 30.1 37 24 37c-7.2 0-13-5.8-13-13s5.8-13 13-13c3.1 0 5.9 1.1 8.1 2.9l6.4-6.4C34.6 4.1 29.6 2 24 2 11.8 2 2 11.8 2 24s9.8 22 22 22c11 0 21-8 21-22 0-1.3-.2-2.7-.5-4z"></path></defs><clipPath id="b"><use xlink:href="#a" overflow="visible"></use></clipPath><path clip-path="url(#b)" fill="#FBBC05" d="M0 37V11l17 13z"></path><clip-path="url(#b)" fill="#EA4335" d="M0 11l17 13 7-6.1L48 14V0H0z"></path><path clip-path="url(#b)" fill="#34A853" d="M0 37l30-23 7.9 1L48 0v48H0z"></path><path clip-path="url(#b)" fill="#4285F4" d="M48 48L17 24l-4-3 35-10z"></path></svg>';
1532 }
1533 return '<span class="dashicons dashicons-cloud" style="font-size: 24px; color: #6750A4;"></span>';
1534 }
1535
1536 // Initial render
1537 renderOpenRouterModels();
1538
1539 // Search handler
1540 $('#mxchat_openrouter_search').on('input', function() {
1541 renderOpenRouterModels($(this).val());
1542 });
1543
1544 $('#mxchat_back_to_provider_select').on('click', function(e) {
1545 e.preventDefault();
1546 // Restore footer to default state
1547 restoreDefaultModalFooter();
1548 // Instead of location.reload(), restore original content
1549 restoreOriginalModalContent();
1550 });
1551
1552 // Back to providers footer button
1553 $('#mxchat_back_to_models_footer').on('click', function(e) {
1554 e.preventDefault();
1555 // Restore footer to default state
1556 restoreDefaultModalFooter();
1557 // Restore original content
1558 restoreOriginalModalContent();
1559 });
1560
1561 // Model selection - just highlight, don't save yet
1562 $(document).on('click', '.mxchat-openrouter-card', function(e) {
1563 e.stopPropagation(); // Prevent triggering the regular model card handler
1564
1565 const modelId = $(this).data('model-id');
1566 const modelName = $(this).data('model-name');
1567
1568 // Remove selection from all cards
1569 $('.mxchat-openrouter-card').removeClass('mxchat-model-selected').find('.mxchat-model-selector-checkmark').remove();
1570
1571 // Add selection to clicked card
1572 $(this).addClass('mxchat-model-selected');
1573 if ($(this).find('.mxchat-model-selector-checkmark').length === 0) {
1574 $(this).append('<div class="mxchat-model-selector-checkmark">✓</div>');
1575 }
1576
1577 // Update pending selection
1578 pendingSelection.modelId = modelId;
1579 pendingSelection.modelName = modelName;
1580
1581 // Enable the save button
1582 $('#mxchat_save_openrouter_model').prop('disabled', false);
1583 });
1584
1585 // Save button handler
1586 $('#mxchat_save_openrouter_model').on('click', function() {
1587 const $saveButton = $(this);
1588
1589 if (!pendingSelection.modelId) {
1590 return;
1591 }
1592
1593 // Disable button and show loading state
1594 $saveButton.prop('disabled', true).html('<span class="spinner is-active" style="float: none; margin: 0 5px 0 0;"></span> Saving...');
1595
1596 // First, save that we're using OpenRouter
1597 jQuery.ajax({
1598 url: mxchatAdmin.ajax_url,
1599 type: 'POST',
1600 data: {
1601 action: 'mxchat_save_setting',
1602 name: 'model',
1603 value: 'openrouter',
1604 _ajax_nonce: mxchatAdmin.setting_nonce
1605 },
1606 success: function() {
1607 // After model is set, save the model ID
1608 jQuery.ajax({
1609 url: mxchatAdmin.ajax_url,
1610 type: 'POST',
1611 data: {
1612 action: 'mxchat_save_setting',
1613 name: 'openrouter_selected_model',
1614 value: pendingSelection.modelId,
1615 _ajax_nonce: mxchatAdmin.setting_nonce
1616 },
1617 success: function() {
1618 // Update DOM immediately
1619 $('#openrouter_selected_model').val(pendingSelection.modelId);
1620
1621 // After model ID is saved, save the display name
1622 jQuery.ajax({
1623 url: mxchatAdmin.ajax_url,
1624 type: 'POST',
1625 data: {
1626 action: 'mxchat_save_setting',
1627 name: 'openrouter_selected_model_name',
1628 value: pendingSelection.modelName,
1629 _ajax_nonce: mxchatAdmin.setting_nonce
1630 },
1631 success: function() {
1632 // Update DOM immediately
1633 $('#openrouter_selected_model_name').val(pendingSelection.modelName);
1634
1635 // Update button text
1636 $('#mxchat_model_selector_btn').text('OpenRouter: ' + pendingSelection.modelName);
1637
1638 // Update the "Currently using" message
1639 const $currentSelection = $('#openrouter-current-selection');
1640 if ($currentSelection.length) {
1641 $currentSelection.html(
1642 '<span class="dashicons dashicons-yes" style="font-size: 16px; vertical-align: middle;"></span> ' +
1643 'Currently using: <strong>' + pendingSelection.modelName + '</strong>'
1644 ).show();
1645 }
1646
1647 // Show success state briefly
1648 $saveButton.html('<span class="dashicons dashicons-yes" style="color: #46b450; margin-top: 3px;"></span> Saved!');
1649
1650 // Close modal after short delay
1651 setTimeout(function() {
1652 // Restore footer to default state
1653 restoreDefaultModalFooter();
1654 $modal.hide();
1655 }, 800);
1656 },
1657 error: function() {
1658 $saveButton.prop('disabled', false).html('<span class="dashicons dashicons-saved" style="margin-top: 3px;"></span> Save Selected Model');
1659 alert('Failed to save model name. Please try again.');
1660 }
1661 });
1662 },
1663 error: function() {
1664 $saveButton.prop('disabled', false).html('<span class="dashicons dashicons-saved" style="margin-top: 3px;"></span> Save Selected Model');
1665 alert('Failed to save model ID. Please try again.');
1666 }
1667 });
1668 },
1669 error: function() {
1670 $saveButton.prop('disabled', false).html('<span class="dashicons dashicons-saved" style="margin-top: 3px;"></span> Save Selected Model');
1671 alert('Failed to save OpenRouter selection. Please try again.');
1672 }
1673 });
1674 });
1675
1676 }
1677
1678 // 3.2.3: Confirmation dialog shown when the user attempts to switch embedding
1679 // models after they've already embedded content with a different model. Mixing
1680 // embeddings from two models silently breaks similarity matching.
1681 function showEmbeddingSwitchWarning(data, newValue, onChoice) {
1682 $('#mxchat_embedding_switch_warning').remove();
1683
1684 const dimsBlock = data.dims_differ ? `
1685 <div class="mxchat-embed-warn-dims">
1686 <strong>Dimension mismatch:</strong>
1687 Existing vectors are ${data.active_dims}-dimensional, but ${data.new_label}
1688 produces ${data.new_dims}-dimensional vectors.
1689 If you use Pinecone, your index will reject queries entirely until you re-embed.
1690 </div>` : '';
1691
1692 const $modal = $(`
1693 <div id="mxchat_embedding_switch_warning" class="mxchat-embed-warn-overlay">
1694 <div class="mxchat-embed-warn-dialog">
1695 <div class="mxchat-embed-warn-header">
1696 <span class="mxchat-embed-warn-icon">⚠️</span>
1697 <h3>Switching embedding models will break similarity matching</h3>
1698 </div>
1699 <div class="mxchat-embed-warn-body">
1700 <p>
1701 Your knowledge base and actions are currently embedded with
1702 <code>${data.active_label}</code>. Switching to
1703 <code>${data.new_label}</code> means new queries are embedded with
1704 a different model than the stored vectors — the chatbot will return
1705 inaccurate results or fail to match anything.
1706 </p>
1707 ${dimsBlock}
1708 <p><strong>To switch safely:</strong></p>
1709 <ol>
1710 <li>Go to <em>Knowledge Base</em> and delete all existing entries.</li>
1711 <li>Go to <em>Actions</em> and delete all existing actions.</li>
1712 <li>Come back here, switch the model, then re-import your content and re-add your actions.</li>
1713 </ol>
1714 </div>
1715 <div class="mxchat-embed-warn-footer">
1716 <button type="button" class="button button-secondary" id="mxchat_embed_warn_cancel">Cancel — keep ${data.active_label}</button>
1717 <button type="button" class="button button-primary mxchat-embed-warn-danger" id="mxchat_embed_warn_continue">Switch anyway (I'll handle it)</button>
1718 </div>
1719 </div>
1720 </div>
1721 `);
1722
1723 $('body').append($modal);
1724
1725 let resolved = false;
1726 function resolve(confirmed) {
1727 if (resolved) return;
1728 resolved = true;
1729 $modal.remove();
1730 if (typeof onChoice === 'function') onChoice(confirmed);
1731 }
1732
1733 // Click on the overlay background dismisses (cancel)
1734 $modal.on('click', function(e) {
1735 if (e.target === $modal[0]) resolve(false);
1736 });
1737 $modal.find('#mxchat_embed_warn_cancel').on('click', function() { resolve(false); });
1738 $modal.find('#mxchat_embed_warn_continue').on('click', function() { resolve(true); });
1739 }
1740
1741 // Embedding model selector - completely separate from chat model selector
1742 function setupMxChatEmbeddingModelSelector() {
1743 const $embeddingModelSelect = $('#embedding_model');
1744
1745 // Skip if the element doesn't exist on the page
1746 if ($embeddingModelSelect.length === 0) {
1747 return;
1748 }
1749
1750 const $embeddingModelSelectorButton = $('<button>', {
1751 type: 'button',
1752 id: 'mxchat_embedding_model_selector_btn',
1753 class: 'button-primary mxchat-embedding-model-selector-btn', // Changed class name to be more specific
1754 text: 'Select Embedding Model'
1755 });
1756
1757 // Replace the select dropdown with a button
1758 $embeddingModelSelect.hide().after($embeddingModelSelectorButton);
1759
1760 // Custom-provider embeddings lock (plan ae02cb): while "Use custom provider
1761 // for embeddings" is on, the standard picker is inert — the effective model
1762 // is the Custom Embedding Model field. Lock the button (which also makes the
1763 // switch-warning preflight unreachable), show the explanatory note, and keep
1764 // both in sync with the toggle without a reload.
1765 const $customEmbedToggle = $('#custom_provider_for_embeddings');
1766 function syncCustomEmbeddingLock() {
1767 const locked = $customEmbedToggle.length
1768 ? $customEmbedToggle.is(':checked')
1769 : $embeddingModelSelect.prop('disabled');
1770 $embeddingModelSelect.prop('disabled', locked);
1771 $embeddingModelSelectorButton.prop('disabled', locked);
1772 $('#mxchat_embedding_custom_note').toggle(locked);
1773 if (locked) {
1774 $('#' + embeddingModalId).hide();
1775 $('.mxchat-embedding-api-status').hide();
1776 } else if (typeof window.mxchatRefreshAPIKeyStatus === 'function') {
1777 window.mxchatRefreshAPIKeyStatus();
1778 }
1779 }
1780 // NOTE: invoked further down, after embeddingModalId exists — calling it
1781 // here would hit the const's temporal dead zone whenever the page loads
1782 // with the lock already on.
1783
1784 // Update button text to show currently selected model
1785 function updateButtonText() {
1786 const selectedModel = $embeddingModelSelect.val();
1787 const selectedModelText = $embeddingModelSelect.find('option:selected').text();
1788 $embeddingModelSelectorButton.text(selectedModelText);
1789 }
1790
1791 // Initialize button text
1792 updateButtonText();
1793
1794 // Create a unique ID for the modal to avoid conflicts
1795 const embeddingModalId = 'mxchat_embedding_model_selector_modal';
1796
1797 // Create and append modal HTML with unique IDs
1798 const embeddingModelSelectorModal = `
1799 <div id="${embeddingModalId}" class="mxchat-embedding-model-selector-modal">
1800 <div class="mxchat-embedding-model-selector-modal-content">
1801 <div class="mxchat-embedding-model-selector-modal-header">
1802 <h3>Select Embedding Model</h3>
1803 <span class="mxchat-embedding-model-selector-modal-close">&times;</span>
1804 </div>
1805 <div class="mxchat-embedding-model-selector-modal-body">
1806 <div class="mxchat-embedding-model-selector-search-container">
1807 <input type="text" id="mxchat_embedding_model_search_input" class="mxchat-embedding-model-search-input" placeholder="Search models...">
1808 </div>
1809 <div class="mxchat-embedding-model-selector-categories">
1810 <button class="mxchat-embedding-model-category-btn active" data-category="all">All</button>
1811 <button class="mxchat-embedding-model-category-btn" data-category="openai">OpenAI</button>
1812 <button class="mxchat-embedding-model-category-btn" data-category="voyage">Voyage AI</button>
1813 <button class="mxchat-embedding-model-category-btn" data-category="gemini">Google Gemini</button>
1814 </div>
1815 <div class="mxchat-embedding-model-selector-grid" id="mxchat_embedding_models_grid"></div>
1816 </div>
1817 <div class="mxchat-embedding-model-selector-modal-footer">
1818 <button id="mxchat_cancel_embedding_model_selection" class="button mxchat-embedding-model-cancel-btn">Cancel</button>
1819 </div>
1820 </div>
1821 </div>
1822 `;
1823
1824 // Use jQuery's append to ensure it doesn't clash with existing modals
1825 $('body').append(embeddingModelSelectorModal);
1826
1827 // Apply the custom-embeddings lock now that the modal id is live, and keep
1828 // it in sync with the toggle.
1829 syncCustomEmbeddingLock();
1830 $customEmbedToggle.on('change.embeddingModelSelector', syncCustomEmbeddingLock);
1831
1832 // Populate models grid
1833 function populateEmbeddingModelsGrid(filter = '', category = 'all') {
1834 const $grid = $('#mxchat_embedding_models_grid');
1835 $grid.empty();
1836
1837 // Define embedding models with descriptions and context lengths
1838 const models = {
1839 openai: [
1840 {
1841 value: 'text-embedding-3-small',
1842 label: 'TE3 Small',
1843 description: 'Fast and cost-effective embeddings (1536 dimensions, 8K context)'
1844 },
1845 {
1846 value: 'text-embedding-ada-002',
1847 label: 'Ada 2',
1848 description: 'Balanced performance embeddings (1536 dimensions, 8K context)'
1849 },
1850 {
1851 value: 'text-embedding-3-large',
1852 label: 'TE3 Large',
1853 description: 'High-performance embeddings (3072 dimensions, 8K context)'
1854 }
1855 ],
1856 voyage: [
1857 {
1858 value: 'voyage-3-large',
1859 label: 'Voyage-3 Large',
1860 description: 'Advanced semantic search embeddings (2048 dimensions, 32K context)'
1861 }
1862 ],
1863 gemini: [
1864 {
1865 value: 'gemini-embedding-001',
1866 label: 'Gemini Embedding',
1867 description: 'Stable SOTA embeddings (1536 dimensions, 8K context)'
1868 }
1869 ]
1870 };
1871
1872 let allModels = [];
1873 Object.keys(models).forEach(key => {
1874 if (category === 'all' || category === key) {
1875 allModels = allModels.concat(models[key]);
1876 }
1877 });
1878
1879 // Filter by search term if present
1880 if (filter) {
1881 const lowerFilter = filter.toLowerCase();
1882 allModels = allModels.filter(model =>
1883 model.label.toLowerCase().includes(lowerFilter) ||
1884 model.description.toLowerCase().includes(lowerFilter)
1885 );
1886 }
1887
1888 // Create model cards
1889 allModels.forEach(model => {
1890 const isSelected = $embeddingModelSelect.val() === model.value;
1891 let providerClass = 'mxchat-embedding-model-provider-openai';
1892
1893 if (model.value.startsWith('voyage-')) {
1894 providerClass = 'mxchat-embedding-model-provider-voyage';
1895 } else if (model.value.startsWith('gemini-embedding-')) {
1896 providerClass = 'mxchat-embedding-model-provider-gemini';
1897 }
1898
1899 let iconHTML = '';
1900 if (model.value.startsWith('voyage-')) {
1901 iconHTML = '<span class="dashicons dashicons-chart-line mxchat-embedding-model-icon-voyage"></span>';
1902 } else if (model.value.startsWith('gemini-embedding-')) {
1903 iconHTML = '<span class="dashicons dashicons-google mxchat-embedding-model-icon-gemini"></span>';
1904 } else {
1905 iconHTML = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 320" class="mxchat-embedding-model-icon-openai"><path fill="currentColor" d="M297 131a80.6 80.6 0 0 0-93.7-104.2 80.6 80.6 0 0 0-137 29A80.6 80.6 0 0 0 23 189a80.6 80.6 0 0 0 93.7 104.2 80.6 80.6 0 0 0 137-29A80.7 80.7 0 0 0 297.1 131zM176.9 299c-14 .1-27.6-4.8-38.4-13.8l1.9-1 63.7-36.9c3.3-1.8 5.3-5.3 5.2-9v-89.9l27 15.6c.3.1.4.4.5.7v74.4a60 60 0 0 1-60 60zM47.9 244a59.7 59.7 0 0 1-7.1-40.1l1.9 1.1 63.7 36.8c3.2 1.9 7.2 1.9 10.5 0l77.8-45V228c0 .3-.2.6-.4.8L129.9 266a60 60 0 0 1-82-22zM31.2 105c7-12.2 18-21.5 31.2-26.3v75.8c0 3.7 2 7.2 5.2 9l77.8 45-27 15.5a1 1 0 0 1-.9 0L53.1 187a60 60 0 0 1-22-82zm221.2 51.5-77.8-45 27-15.5a1 1 0 0 1 .9 0l64.4 37.1a60 60 0 0 1-9.3 108.2v-75.8c0-3.7-2-7.2-5.2-9zm26.8-40.4-1.9-1.1-63.7-36.8a10.4 10.4 0 0 0-10.5 0L125.4 123V92c0-.3 0-.6.3-.8L190.1 54a60 60 0 0 1 89.1 62.1zm-168.5 55.4-27-15.5a1 1 0 0 1-.4-.7V80.9a60 60 0 0 1 98.3-46.1l-1.9 1L116 72.8a10.3 10.3 0 0 0-5.2 9v89.8zm14.6-31.5 34.7-20 34.6 20v40L160 200l-34.7-20z"></path></svg>';
1906 }
1907
1908 const $modelCard = $(`
1909 <div class="mxchat-embedding-model-selector-card ${isSelected ? 'mxchat-embedding-model-selected' : ''} ${providerClass}" data-value="${model.value}">
1910 <div class="mxchat-embedding-model-selector-icon">
1911 ${iconHTML}
1912 </div>
1913 <div class="mxchat-embedding-model-selector-info">
1914 <h4 class="mxchat-embedding-model-selector-title">${model.label}</h4>
1915 <p class="mxchat-embedding-model-selector-description">${model.description}</p>
1916 </div>
1917 ${isSelected ? '<div class="mxchat-embedding-model-selector-checkmark">✓</div>' : ''}
1918 </div>
1919 `);
1920
1921 $grid.append($modelCard);
1922 });
1923 }
1924
1925 // Event handlers - use namespaced events to avoid conflicts
1926 $embeddingModelSelectorButton.on('click.embeddingModelSelector', function(e) {
1927 e.stopPropagation(); // Prevent event bubbling
1928 $('#' + embeddingModalId).show();
1929 populateEmbeddingModelsGrid('', 'all');
1930 });
1931
1932 $('.mxchat-embedding-model-selector-modal-close, #mxchat_cancel_embedding_model_selection').on('click.embeddingModelSelector', function(e) {
1933 e.stopPropagation(); // Prevent event bubbling
1934 $('#' + embeddingModalId).hide();
1935 });
1936
1937 $('.mxchat-embedding-model-selector-categories .mxchat-embedding-model-category-btn').on('click.embeddingModelSelector', function(e) {
1938 e.stopPropagation(); // Prevent event bubbling
1939 $('.mxchat-embedding-model-selector-categories .mxchat-embedding-model-category-btn').removeClass('active');
1940 $(this).addClass('active');
1941 const category = $(this).data('category');
1942 const searchTerm = $('#mxchat_embedding_model_search_input').val();
1943 populateEmbeddingModelsGrid(searchTerm, category);
1944 });
1945
1946 $('#mxchat_embedding_model_search_input').on('input.embeddingModelSelector', function() {
1947 const searchTerm = $(this).val();
1948 const activeCategory = $('.mxchat-embedding-model-selector-categories .mxchat-embedding-model-category-btn.active').data('category');
1949 populateEmbeddingModelsGrid(searchTerm, activeCategory);
1950 });
1951
1952 // 3.2.3: Commit a model change — same as the original click handler, just
1953 // factored out so it can be invoked from both the safe path and the
1954 // post-confirmation path.
1955 function commitEmbeddingModelChange(modelValue) {
1956 $embeddingModelSelect.val(modelValue);
1957 const changeEvent = new Event('change', { bubbles: true });
1958 $embeddingModelSelect[0].dispatchEvent(changeEvent);
1959 updateButtonText();
1960 $('#' + embeddingModalId).hide();
1961 }
1962
1963 // Use a direct selector to avoid conflicts with other card elements
1964 $(document).on('click.embeddingModelSelector', '.mxchat-embedding-model-selector-grid .mxchat-embedding-model-selector-card', function(e) {
1965 e.stopPropagation(); // Prevent event bubbling
1966 const modelValue = $(this).data('value');
1967 const previousValue = $embeddingModelSelect.val();
1968
1969 // No-op if the user clicked the already-selected card
1970 if (modelValue === previousValue) {
1971 $('#' + embeddingModalId).hide();
1972 return;
1973 }
1974
1975 // 3.2.3: Preflight — if the user has content embedded with a different
1976 // model, surface a confirmation dialog before committing the switch.
1977 $.post(ajaxurl, {
1978 action: 'mxchat_check_embedding_switch',
1979 security: (typeof mxchatAdmin !== 'undefined' ? mxchatAdmin.nonce : ''),
1980 new_model: modelValue
1981 }).done(function(resp) {
1982 if (resp && resp.success && resp.data && resp.data.is_mismatch) {
1983 showEmbeddingSwitchWarning(resp.data, modelValue, function(confirmed) {
1984 if (confirmed) {
1985 commitEmbeddingModelChange(modelValue);
1986 }
1987 });
1988 } else {
1989 commitEmbeddingModelChange(modelValue);
1990 }
1991 }).fail(function() {
1992 // If preflight fails, fall back to the original behavior so a network
1993 // hiccup doesn't block legitimate model switches.
1994 commitEmbeddingModelChange(modelValue);
1995 });
1996 return;
1997 });
1998
1999 // Close modal when clicking outside - use namespaced events
2000 $(window).on('click.embeddingModelSelector', function(event) {
2001 if ($(event.target).is('#' + embeddingModalId)) {
2002 $('#' + embeddingModalId).hide();
2003 }
2004 });
2005 }
2006
2007 // Call this function after the DOM is fully loaded
2008 $(document).ready(function() {
2009 setupMxChatModelSelector();
2010 setupMxChatEmbeddingModelSelector();
2011 });
2012
2013 // Add Intent Form Submission
2014 $('#mxchat-add-intent-form').on('submit', function(event) {
2015 $('#mxchat-intent-loading').show();
2016 $('#mxchat-intent-loading-text').show();
2017 $(this).find('button[type="submit"]').hide();
2018 });
2019
2020 // Inline Edit Functionality
2021 $('.edit-button').on('click', function() {
2022 var row = $(this).closest('tr');
2023
2024 // Clear URL field if it's a manual content URL (mxchat:// protocol)
2025 var urlEdit = row.find('.url-edit');
2026 if (urlEdit.length && urlEdit.val().indexOf('mxchat://') === 0) {
2027 urlEdit.val('');
2028 }
2029
2030 // Expand the accordion to show the edit textarea (fixes short content editing)
2031 var contentFull = row.find('.mxchat-content-full');
2032 if (contentFull.length && contentFull.is(':hidden')) {
2033 contentFull.show();
2034 row.find('.mxchat-content-preview').hide();
2035 }
2036
2037 row.find('.content-view, .url-view').hide();
2038 row.find('.content-edit, .url-edit').show();
2039 row.find('.edit-button').hide();
2040 row.find('.save-button').show();
2041 });
2042
2043 // Save button handler
2044 // Save button handler
2045 $('.save-button').on('click', function() {
2046 var button = $(this);
2047 var row = button.closest('tr');
2048 var id = button.data('id');
2049 var nonce = button.data('nonce'); // Get nonce from button data attribute
2050 var newContent = row.find('.content-edit').val();
2051 var newUrl = row.find('.url-edit').val();
2052
2053 //console.log('Nonce from button:', nonce); // Debug
2054
2055 button.prop('disabled', true);
2056 button.text('Saving...');
2057
2058 $.ajax({
2059 url: mxchatAdmin.ajax_url,
2060 type: 'POST',
2061 data: {
2062 action: 'mxchat_save_inline_prompt',
2063 id: id,
2064 article_content: newContent,
2065 article_url: newUrl,
2066 _ajax_nonce: nonce // Use nonce from button
2067 },
2068 success: function(response) {
2069 button.prop('disabled', false);
2070 button.text('Save');
2071
2072 if (response.success) {
2073 row.find('.content-view').html(newContent.replace(/\n/g, "<br>"));
2074 if (newUrl) {
2075 row.find('.url-view').html('<a href="' + newUrl + '" target="_blank"><span class="dashicons dashicons-external"></span> View Source</a>');
2076 } else {
2077 row.find('.url-view').html('<span class="mxchat-na">Manual Content</span>');
2078 }
2079
2080 row.find('.content-edit, .url-edit').hide();
2081 row.find('.content-view, .url-view').show();
2082 row.find('.save-button').hide();
2083 row.find('.edit-button').show();
2084
2085 // Restore accordion state - show preview, hide full content
2086 row.find('.mxchat-content-preview').show();
2087 row.find('.mxchat-content-full').hide();
2088 } else {
2089 alert('Error saving content: ' + (response.data?.message || 'Unknown error'));
2090 }
2091 },
2092 error: function() {
2093 button.prop('disabled', false);
2094 button.text('Save');
2095 alert('An error occurred while saving.');
2096 }
2097 });
2098 });
2099
2100
2101 // Questions handling
2102 $('.mxchat-add-question').on('click', function () {
2103 const container = $('#mxchat-additional-questions-container');
2104 const questionCount = container.find('.mxchat-question-row').length + 4;
2105 const questionIndex = container.find('.mxchat-question-row').length;
2106
2107 const newQuestion = `
2108 <div class="mxchat-question-row">
2109 <input type="text"
2110 name="additional_popular_questions[]"
2111 placeholder="Enter Additional Popular Question ${questionCount}"
2112 class="regular-text mxchat-question-input"
2113 data-question-index="${questionIndex}" />
2114 <button type="button" class="button mxchat-remove-question"
2115 aria-label="Remove question">Remove</button>
2116 </div>
2117 `;
2118 container.append(newQuestion);
2119 });
2120
2121 $(document).on('click', '.mxchat-remove-question', function () {
2122 $(this).closest('.mxchat-question-row').remove();
2123 saveQuestions();
2124 });
2125
2126 $(document).on('change', '.mxchat-question-input', function() {
2127 saveQuestions();
2128 });
2129
2130 function saveQuestions() {
2131 const questions = [];
2132 $('.mxchat-question-input').each(function() {
2133 const value = $(this).val().trim();
2134 if (value) {
2135 questions.push(value);
2136 }
2137 });
2138
2139 const feedbackContainer = $('<div class="feedback-container"></div>');
2140 const spinner = $('<div class="saving-spinner"></div>');
2141 const successIcon = $('<div class="success-icon">✔</div>');
2142
2143 // Append feedback after the add button
2144 $('.mxchat-add-question').after(feedbackContainer);
2145 feedbackContainer.append(spinner);
2146
2147 // Save via AJAX
2148 $.ajax({
2149 url: mxchatAdmin.ajax_url,
2150 type: 'POST',
2151 data: {
2152 action: 'mxchat_save_setting',
2153 name: 'additional_popular_questions',
2154 value: JSON.stringify(questions),
2155 _ajax_nonce: mxchatAdmin.setting_nonce
2156 },
2157 success: function(response) {
2158 if (response.success) {
2159 spinner.fadeOut(200, function() {
2160 feedbackContainer.append(successIcon);
2161 successIcon.fadeIn(200).delay(1000).fadeOut(200, function() {
2162 feedbackContainer.remove();
2163 });
2164 });
2165 } else {
2166 alert('Error saving questions: ' + (response.data?.message || 'Unknown error'));
2167 feedbackContainer.remove();
2168 }
2169 },
2170 error: function() {
2171 alert('An error occurred while saving questions.');
2172 feedbackContainer.remove();
2173 }
2174 });
2175 }
2176
2177 // Live agent status handler
2178 const statusToggle = document.getElementById('live_agent_status');
2179 const statusText = statusToggle?.parentElement.nextElementSibling?.querySelector('.status-text');
2180 if (statusToggle && statusText) {
2181 statusToggle.addEventListener('change', function() {
2182 // Update display text
2183 statusText.textContent = this.checked ? 'Online' : 'Offline';
2184
2185 // Send the correct on/off value to the server
2186 if (window.mxchatSaveSetting) {
2187 window.mxchatSaveSetting('live_agent_status', this.checked ? 'on' : 'off');
2188 }
2189 });
2190 }
2191
2192 // Live agent availability schedules (plans 8ccaa2 + 99d7a4).
2193 // The editor markup exists once PER CHANNEL (Slack + Telegram tabs), so
2194 // everything is scoped inside each .mxchat-la-schedule container via classes
2195 // — no page-global ids. Each editor collects its own day grid into its own
2196 // hidden live_agent_schedule_<channel> input as JSON and fires one change
2197 // event, so the existing autosave handler above does the POST.
2198 $('.mxchat-la-schedule').each(function() {
2199 const $laSchedule = $(this);
2200 const $laEnabled = $laSchedule.find('.mxchat-la-enabled');
2201 const $laHidden = $laSchedule.find('.mxchat-la-hidden');
2202 const $laDays = $laSchedule.find('.mxchat-la-days');
2203 const $laStatus = $laSchedule.find('.mxchat-la-status-text');
2204
2205 function laCollect() {
2206 const days = {};
2207 $laDays.find('.mxchat-la-day').each(function() {
2208 const $day = $(this);
2209 const n = $day.data('day');
2210 days[n] = {
2211 enabled: $day.find('.mxchat-la-day-enabled').is(':checked'),
2212 start: $day.find('.mxchat-la-start').val() || '09:00',
2213 end: $day.find('.mxchat-la-end').val() || '17:00'
2214 };
2215 });
2216 return { enabled: $laEnabled.is(':checked'), days: days };
2217 }
2218
2219 function laSync() {
2220 const schedule = laCollect();
2221 $laSchedule.toggleClass('is-active', schedule.enabled);
2222 $laDays.attr('aria-hidden', schedule.enabled ? 'false' : 'true');
2223 $laStatus.text(
2224 schedule.enabled
2225 ? (mxchatAdmin.i18n_scheduled_hours || 'Scheduled hours')
2226 : (mxchatAdmin.i18n_always_available || 'Always available')
2227 );
2228 $laDays.find('.mxchat-la-day').each(function() {
2229 const $day = $(this);
2230 $day.toggleClass('is-on', $day.find('.mxchat-la-day-enabled').is(':checked'));
2231 });
2232 // Hand this channel's schedule to the shared autosave transport.
2233 $laHidden.val(JSON.stringify(schedule)).trigger('change');
2234 }
2235
2236 $laSchedule.find('.mxchat-la-field').on('change', laSync);
2237 });
2238
2239 // Function to adjust the textarea height to content
2240 function adjustTextareaHeight() {
2241 this.style.height = 'auto'; // Reset to auto to calculate scrollHeight
2242 this.style.height = this.scrollHeight + 'px'; // Expand to content height
2243 }
2244
2245 // Function to reset the textarea height to initial
2246 function resetTextareaHeight() {
2247 this.style.height = ''; // Remove inline height, reverting to CSS default
2248 }
2249
2250 // Target the specific textarea by ID
2251 var $textarea = $('#system_prompt_instructions');
2252
2253 // Bind events
2254 $textarea.on('focus input', adjustTextareaHeight) // Expand on focus and input
2255 .on('blur', resetTextareaHeight); // Reset on blur
2256 });
2257
2258
2259
2260 document.addEventListener('DOMContentLoaded', function() {
2261 // Check if we're on the correct page before initializing
2262 const modal = document.getElementById('mxchat-action-modal');
2263
2264 // Only initialize if the modal exists on this page
2265 if (modal) {
2266 //console.log('MXChat Action Modal JS Loaded');
2267
2268 // Initialize the action modal functionality
2269 initStepBasedActionModal();
2270 }
2271
2272 // Function to initialize the step-based action modal
2273 function initStepBasedActionModal() {
2274 // We already checked for modal existence above, so no need to check again
2275
2276 const actionStep1 = document.getElementById('mxchat-action-step-1');
2277 const actionStep2 = document.getElementById('mxchat-action-step-2');
2278 const backToStep1Btn = document.getElementById('mxchat-back-to-step-1');
2279 const searchInput = document.getElementById('action-type-search');
2280 const categoryButtons = modal.querySelectorAll('.mxchat-category-button');
2281 const actionCards = modal.querySelectorAll('.mxchat-action-type-card');
2282 const actionForm = document.getElementById('mxchat-action-form');
2283 const callbackInput = document.getElementById('callback_function');
2284 const actionIdField = document.getElementById('edit_action_id');
2285 const labelField = document.getElementById('intent_label');
2286 const phrasesField = document.getElementById('action_phrases');
2287 const formActionType = document.getElementById('form_action_type');
2288 const nonceContainer = document.getElementById('action-nonce-container');
2289 const thresholdSlider = document.getElementById('similarity_threshold');
2290 const thresholdDisplay = document.querySelector('.mxchat-threshold-value-display');
2291
2292 // Rest of your initialization code remains the same...
2293
2294 // Log the structure of one action card for debugging
2295 if (actionCards.length > 0) {
2296 //console.log('First action card data attributes:', actionCards[0].dataset);
2297 //console.log('First action card HTML:', actionCards[0].outerHTML);
2298 }
2299
2300 // Add click event listeners to category buttons
2301 categoryButtons.forEach(button => {
2302 button.addEventListener('click', function() {
2303 //console.log('Category button clicked:', this.dataset.category);
2304
2305 // Remove active class from all buttons
2306 categoryButtons.forEach(btn => btn.classList.remove('active'));
2307
2308 // Add active class to clicked button
2309 this.classList.add('active');
2310
2311 // Get selected category
2312 const category = this.dataset.category;
2313
2314 // Filter action cards
2315 filterActionCards(category, searchInput.value);
2316 });
2317 });
2318
2319 // Add search functionality
2320 if (searchInput) {
2321 searchInput.addEventListener('input', function() {
2322 // Get active category
2323 const activeCategory = modal.querySelector('.mxchat-category-button.active')?.dataset.category || 'all';
2324 //console.log('Search input changed, active category:', activeCategory);
2325
2326 // Filter action cards
2327 filterActionCards(activeCategory, this.value);
2328 });
2329 }
2330
2331 // Add click event listeners to action cards
2332 actionCards.forEach(card => {
2333 card.addEventListener('click', function() {
2334 // Get the action data
2335 const isPro = this.dataset.pro === 'true';
2336 const isInstalled = this.dataset.installed === 'true';
2337 const addonName = this.dataset.addon || '';
2338 const actionValue = this.dataset.value;
2339 const actionLabel = this.dataset.label;
2340 const actionIcon = this.querySelector('.dashicons').getAttribute('class').replace('dashicons dashicons-', '');
2341 const actionDescription = this.querySelector('p').textContent;
2342
2343 // Check if this is a promotional card (add-on not installed)
2344 const isPromo = this.dataset.promo === 'true';
2345
2346 if (isPromo || (addonName && !isInstalled)) {
2347 // Add-on required but not installed — show informational notice
2348 const addonDisplayName = this.querySelector('.mxchat-addon-info')?.textContent?.replace('Requires ', '').replace('— Get Add-on', '').trim() || addonName + ' Add-on';
2349 showAddonRequiredNotice(addonDisplayName);
2350 return;
2351 }
2352
2353 // If we get here, the action is available - proceed as normal
2354 callbackInput.value = actionValue;
2355
2356 // Update the selected action display in step 2
2357 document.getElementById('selected-action-title').textContent = actionLabel;
2358 document.getElementById('selected-action-description').textContent = actionDescription;
2359 document.getElementById('selected-action-icon').innerHTML =
2360 `<span class="dashicons dashicons-${actionIcon}"></span>`;
2361
2362 // Set a default label based on the action type (user can change it)
2363 if (!labelField.value) {
2364 labelField.value = actionLabel;
2365 }
2366
2367 // Move to step 2
2368 actionStep1.classList.remove('active');
2369 actionStep2.classList.add('active');
2370
2371 // Update modal title
2372 });
2373 });
2374
2375 // Back button functionality
2376 if (backToStep1Btn) {
2377 backToStep1Btn.addEventListener('click', function() {
2378 //console.log('Back button clicked');
2379 actionStep2.classList.remove('active');
2380 actionStep1.classList.add('active');
2381 });
2382 }
2383
2384 // Function to filter action cards by category and search term
2385 function filterActionCards(category, searchTerm) {
2386 searchTerm = searchTerm.toLowerCase().trim();
2387 //console.log(`Filtering cards by category: "${category}", search: "${searchTerm}"`);
2388
2389 let visibleCount = 0;
2390
2391 // Show all cards initially with animation
2392 actionCards.forEach((card, index) => {
2393 // Reset animation
2394 card.style.animation = 'none';
2395 // Trigger reflow
2396 void card.offsetWidth;
2397
2398 // Determine if card should be visible based on category and search term
2399 const cardCategory = card.dataset.category || '';
2400 const matchesCategory = category === 'all' || cardCategory === category;
2401
2402 const cardTitle = card.querySelector('h4')?.textContent?.toLowerCase() || '';
2403 const cardDesc = card.querySelector('p')?.textContent?.toLowerCase() || '';
2404 const matchesSearch = searchTerm === '' ||
2405 cardTitle.includes(searchTerm) ||
2406 cardDesc.includes(searchTerm);
2407
2408 // Show/hide card with animation
2409 if (matchesCategory && matchesSearch) {
2410 card.style.display = 'flex';
2411 // Staggered animation for cards
2412 card.style.animation = `fadeIn 0.2s ease forwards ${index * 0.03}s`;
2413 visibleCount++;
2414 } else {
2415 card.style.display = 'none';
2416 }
2417 });
2418
2419 //console.log(`Filter results: ${visibleCount} cards visible out of ${actionCards.length}`);
2420 }
2421
2422 // Function to show notice for Pro features
2423 function showProFeatureNotice() {
2424 //console.log('Showing Pro feature notice');
2425 // Check if we already have a notification container
2426 let noticeContainer = document.querySelector('.mxchat-pro-notice');
2427
2428 if (!noticeContainer) {
2429 // Create the notice container
2430 noticeContainer = document.createElement('div');
2431 noticeContainer.className = 'mxchat-pro-notice';
2432
2433 // Create content
2434 noticeContainer.innerHTML = `
2435 <div class="mxchat-pro-notice-content">
2436 <h3>MxChat Pro Feature</h3>
2437 <p>This action is available in the Pro version only.</p>
2438 <div class="mxchat-pro-notice-buttons">
2439 <button class="mxchat-button-secondary mxchat-pro-notice-close">Close</button>
2440 <a href="https://mxchat.ai/" class="mxchat-button-primary">Upgrade to Pro</a>
2441 </div>
2442 </div>
2443 `;
2444
2445 // Append to body
2446 document.body.appendChild(noticeContainer);
2447
2448 // Add close functionality
2449 const closeButton = noticeContainer.querySelector('.mxchat-pro-notice-close');
2450 closeButton.addEventListener('click', function() {
2451 noticeContainer.classList.remove('active');
2452 setTimeout(() => {
2453 noticeContainer.remove();
2454 }, 300);
2455 });
2456
2457 // Click outside to close (drag-safe)
2458 mxchatDragSafeOverlayClose(noticeContainer, function() {
2459 closeButton.click();
2460 });
2461
2462 // Show with animation
2463 setTimeout(() => {
2464 noticeContainer.classList.add('active');
2465 }, 10);
2466 } else {
2467 // If it already exists, just make it visible again
2468 noticeContainer.classList.add('active');
2469 }
2470 }
2471
2472 // Function to show notice for add-on requirements
2473 function showAddonRequiredNotice(addonName) {
2474 //console.log(`Showing add-on notice for: ${addonName}`);
2475 // Check if we already have a notification container
2476 let noticeContainer = document.querySelector('.mxchat-addon-notice');
2477
2478 if (!noticeContainer) {
2479 // Create the notice container
2480 noticeContainer = document.createElement('div');
2481 noticeContainer.className = 'mxchat-addon-notice';
2482
2483 // Create content
2484 noticeContainer.innerHTML = `
2485 <div class="mxchat-addon-notice-content">
2486 <span class="mxchat-addon-notice-icon">🧩</span>
2487 <h3>Add-on Required</h3>
2488 <p>This action requires the <strong>${addonName}</strong> add-on to be installed.</p>
2489 <div class="mxchat-addon-notice-buttons">
2490 <button class="mxchat-button-secondary mxchat-addon-notice-close">Close</button>
2491 <a href="admin.php?page=mxchat-addons" class="mxchat-button-primary">Get Add-ons</a>
2492 </div>
2493 </div>
2494 `;
2495
2496 // Append to body
2497 document.body.appendChild(noticeContainer);
2498
2499 // Add close functionality
2500 const closeButton = noticeContainer.querySelector('.mxchat-addon-notice-close');
2501 closeButton.addEventListener('click', function() {
2502 noticeContainer.classList.remove('active');
2503 setTimeout(() => {
2504 noticeContainer.remove();
2505 }, 300);
2506 });
2507
2508 // Click outside to close (drag-safe)
2509 mxchatDragSafeOverlayClose(noticeContainer, function() {
2510 closeButton.click();
2511 });
2512
2513 // Show with animation
2514 setTimeout(() => {
2515 noticeContainer.classList.add('active');
2516 }, 10);
2517 } else {
2518 // If it already exists, update the content
2519 const addonNameElement = noticeContainer.querySelector('p strong');
2520 if (addonNameElement) {
2521 addonNameElement.textContent = addonName;
2522 }
2523
2524 // Make it visible again
2525 noticeContainer.classList.add('active');
2526 }
2527 }
2528
2529 // Form submission handling
2530 if (actionForm) {
2531 actionForm.addEventListener('submit', function() {
2532 //console.log('Form submitted');
2533 document.getElementById('mxchat-action-loading').style.display = 'flex';
2534 this.querySelector('button[type="submit"]').disabled = true;
2535 });
2536 }
2537 }
2538
2539 // Setup add action buttons (only if we're on the correct page)
2540 if (modal) {
2541 // Update the modal open function to support the step-based flow
2542 window.mxchatOpenActionModal = function(isEdit = false, actionId = '', label = '', phrases = '', threshold = 85, callbackFunction = '', enabledBots = null) {
2543 //console.log('Modal opening, edit mode:', isEdit);
2544
2545 // Get form fields
2546 const actionIdField = document.getElementById('edit_action_id');
2547 const labelField = document.getElementById('intent_label');
2548 const phrasesField = document.getElementById('action_phrases');
2549 const formActionType = document.getElementById('form_action_type');
2550 const callbackInput = document.getElementById('callback_function');
2551 const saveButton = document.getElementById('mxchat-save-action-btn');
2552 const nonceContainer = document.getElementById('action-nonce-container');
2553 const thresholdSlider = document.getElementById('similarity_threshold');
2554 const thresholdDisplay = document.querySelector('.mxchat-threshold-value-display');
2555 const actionStep1 = document.getElementById('mxchat-action-step-1');
2556 const actionStep2 = document.getElementById('mxchat-action-step-2');
2557 const searchInput = document.getElementById('action-type-search');
2558
2559 // Set up modal for edit or create
2560 if (isEdit) {
2561 saveButton.textContent = 'Update Action';
2562 formActionType.value = 'mxchat_edit_intent';
2563 actionIdField.value = actionId;
2564 labelField.value = label;
2565 phrasesField.value = phrases;
2566 callbackInput.value = callbackFunction;
2567 thresholdSlider.value = threshold; // Set the current threshold value
2568 thresholdDisplay.textContent = threshold + '%'; // Update display
2569
2570 // NEW: Handle bot selection checkboxes for editing
2571 // First uncheck all bot checkboxes
2572 document.querySelectorAll('input[name="enabled_bots[]"]').forEach(checkbox => {
2573 checkbox.checked = false;
2574 });
2575
2576 // Then check the ones that should be enabled
2577 if (enabledBots) {
2578 let botsArray;
2579 if (typeof enabledBots === 'string') {
2580 try {
2581 botsArray = JSON.parse(enabledBots);
2582 } catch (e) {
2583 console.warn('Failed to parse enabledBots:', enabledBots);
2584 botsArray = ['default']; // fallback
2585 }
2586 } else if (Array.isArray(enabledBots)) {
2587 botsArray = enabledBots;
2588 } else {
2589 botsArray = ['default']; // fallback
2590 }
2591
2592 botsArray.forEach(botId => {
2593 const checkbox = document.querySelector(`input[name="enabled_bots[]"][value="${botId}"]`);
2594 if (checkbox) {
2595 checkbox.checked = true;
2596 }
2597 });
2598 } else {
2599 // Fallback to default if no bot data
2600 const defaultCheckbox = document.querySelector('input[name="enabled_bots[]"][value="default"]');
2601 if (defaultCheckbox) {
2602 defaultCheckbox.checked = true;
2603 }
2604 }
2605
2606 // Update the nonce field for editing
2607 nonceContainer.innerHTML = ''; // Clear existing nonce
2608 if (typeof mxchatAdmin !== 'undefined' && mxchatAdmin.edit_intent_nonce) {
2609 nonceContainer.innerHTML = `<input type="hidden" name="_wpnonce" value="${mxchatAdmin.edit_intent_nonce}">`;
2610 }
2611
2612 // For editing, go directly to step 2 and update the selected action display
2613 actionStep1.classList.remove('active');
2614 actionStep2.classList.add('active');
2615
2616 // Find the matching action card to get its details
2617 const actionCards = document.querySelectorAll('.mxchat-action-type-card');
2618 let foundCard = null;
2619
2620 actionCards.forEach(card => {
2621 if (card.dataset.value === callbackFunction) {
2622 foundCard = card;
2623 }
2624 });
2625
2626 if (foundCard) {
2627 //console.log('Found matching action card for:', callbackFunction);
2628 const actionLabel = foundCard.dataset.label || foundCard.querySelector('h4')?.textContent || '';
2629 const actionIconElement = foundCard.querySelector('.dashicons');
2630 const actionIcon = actionIconElement
2631 ? actionIconElement.getAttribute('class').replace('dashicons dashicons-', '')
2632 : 'admin-generic';
2633 const actionDescription = foundCard.querySelector('p')?.textContent || '';
2634
2635 document.getElementById('selected-action-title').textContent = actionLabel;
2636 document.getElementById('selected-action-description').textContent = actionDescription;
2637 document.getElementById('selected-action-icon').innerHTML =
2638 `<span class="dashicons dashicons-${actionIcon}"></span>`;
2639 } else {
2640 //console.log('No matching action card found for:', callbackFunction);
2641 // Fallback if we can't find the card
2642 document.getElementById('selected-action-title').textContent = label;
2643 document.getElementById('selected-action-description').textContent = 'Configure this action for your chatbot';
2644 document.getElementById('selected-action-icon').innerHTML =
2645 `<span class="dashicons dashicons-admin-generic"></span>`;
2646 }
2647 } else {
2648 //console.log('Setting up create mode');
2649 saveButton.textContent = 'Save Action';
2650 formActionType.value = 'mxchat_add_intent';
2651 actionIdField.value = '';
2652 labelField.value = '';
2653 phrasesField.value = '';
2654 callbackInput.value = '';
2655 thresholdSlider.value = 85; // Default value for new actions
2656 thresholdDisplay.textContent = '85%'; // Default display
2657
2658 // For new actions, ensure default is checked and others are unchecked
2659 document.querySelectorAll('input[name="enabled_bots[]"]').forEach(checkbox => {
2660 checkbox.checked = (checkbox.value === 'default');
2661 });
2662
2663 // Update the nonce field for adding
2664 nonceContainer.innerHTML = ''; // Clear existing nonce
2665 if (typeof mxchatAdmin !== 'undefined' && mxchatAdmin.add_intent_nonce) {
2666 nonceContainer.innerHTML = `<input type="hidden" name="_wpnonce" value="${mxchatAdmin.add_intent_nonce}">`;
2667 }
2668
2669 // For creating new, start at step 1
2670 actionStep1.classList.add('active');
2671 actionStep2.classList.remove('active');
2672 }
2673
2674 // Show modal with animation
2675 modal.style.display = 'flex';
2676 requestAnimationFrame(() => {
2677 modal.classList.add('active');
2678 });
2679
2680 // Set up close handlers
2681 let detachEsc = null;
2682 const closeModal = () => {
2683 //console.log('Closing modal');
2684 if (detachEsc) {
2685 detachEsc();
2686 detachEsc = null;
2687 }
2688 modal.classList.remove('active');
2689 setTimeout(() => {
2690 modal.style.display = 'none';
2691 }, 300); // Match the CSS transition time
2692 };
2693
2694 // This modal holds edits: close only on explicit controls, confirming when dirty.
2695 const guardedClose = mxchatGuardedClose(modal, closeModal);
2696
2697 // Close button handler
2698 const closeBtn = modal.querySelector('.mxchat-modal-close');
2699 if (closeBtn) {
2700 closeBtn.onclick = guardedClose;
2701 }
2702
2703 // Cancel button handler
2704 const cancelBtns = modal.querySelectorAll('.mxchat-modal-cancel');
2705 if (cancelBtns) {
2706 cancelBtns.forEach(btn => {
2707 btn.onclick = guardedClose;
2708 });
2709 }
2710
2711 // Escape key to close modal
2712 detachEsc = mxchatBindEscClose(modal, guardedClose);
2713
2714 // Focus appropriate field based on current step
2715 if (isEdit || actionStep2.classList.contains('active')) {
2716 if (labelField) labelField.focus();
2717 } else {
2718 if (searchInput) searchInput.focus();
2719 }
2720
2721 return closeModal; // Return close function for external use
2722 };
2723 // Setup add action buttons
2724 const addActionBtn = document.getElementById('mxchat-add-action-btn');
2725 if (addActionBtn) {
2726 //console.log('Add action button found');
2727 addActionBtn.onclick = () => window.mxchatOpenActionModal();
2728 }
2729
2730 const createFirstAction = document.getElementById('mxchat-create-first-action');
2731 if (createFirstAction) {
2732 //console.log('Create first action button found');
2733 createFirstAction.onclick = () => window.mxchatOpenActionModal();
2734 }
2735
2736 // Setup edit buttons
2737 const editButtons = document.querySelectorAll('.mxchat-action-card .mxchat-edit-button');
2738 //console.log('Edit buttons found:', editButtons.length);
2739 editButtons.forEach(button => {
2740 button.onclick = () => {
2741 const actionId = button.dataset.actionId;
2742 const phrases = button.dataset.phrases;
2743 const label = button.dataset.label;
2744 const threshold = button.dataset.threshold || 85;
2745 const callbackFunction = button.dataset.callbackFunction;
2746 const enabledBots = button.dataset.enabledBots; // ADD THIS LINE
2747
2748 window.mxchatOpenActionModal(true, actionId, label, phrases, threshold, callbackFunction, enabledBots);
2749 };
2750 });
2751 }
2752 });
2753
2754 jQuery(document).ready(function($) {
2755 // Auto-expand custom post types container if any are checked
2756 // Note: Click handler is in admin-knowledge-page.php inline script (initCustomPostTypesToggle)
2757 function autoExpandIfNeeded() {
2758 const $container = $('#mxchat-custom-post-types-container');
2759 const $toggleBtn = $('#mxchat-custom-post-types-toggle');
2760
2761 if ($container.length === 0) return;
2762
2763 const hasCheckedItems = $container.find('input[type="checkbox"]:checked').length > 0;
2764
2765 if (hasCheckedItems) {
2766 $container.show();
2767 const $icon = $toggleBtn.find('span:last-child');
2768 if ($icon.length) {
2769 $icon.text('');
2770 }
2771 }
2772 }
2773
2774 // Run on page load
2775 autoExpandIfNeeded();
2776 });
2777
2778 document.addEventListener('DOMContentLoaded', function() {
2779 var viewSampleBtn = document.getElementById('mxchatViewSampleBtn');
2780 var modal = document.getElementById('mxchatSampleModal');
2781 var modalClose = document.getElementById('mxchatModalClose');
2782 var closeBtn = document.getElementById('mxchatCloseBtn');
2783 var copyBtn = document.getElementById('mxchatCopyBtn');
2784 var instructionsContent = document.querySelector('.mxchat-instructions-content');
2785 var modalContent = document.querySelector('.mxchat-instructions-modal-content');
2786
2787 if (!viewSampleBtn || !modal) {
2788 return;
2789 }
2790
2791 // Open modal
2792 viewSampleBtn.addEventListener('click', function(e) {
2793 e.preventDefault();
2794 e.stopPropagation();
2795 modal.classList.add('mxchat-instructions-show');
2796 });
2797
2798 // Close modal function
2799 function closeModal(e) {
2800 if (e) {
2801 e.preventDefault();
2802 e.stopPropagation();
2803 }
2804 modal.classList.remove('mxchat-instructions-show');
2805 }
2806
2807 // Close modal events
2808 if (modalClose) {
2809 modalClose.addEventListener('click', function(e) {
2810 closeModal(e);
2811 });
2812 }
2813
2814 if (closeBtn) {
2815 closeBtn.addEventListener('click', function(e) {
2816 closeModal(e);
2817 });
2818 }
2819
2820 // Close on backdrop click ONLY (not on hover), and only when the whole gesture
2821 // happened on the backdrop — selecting the sample text and releasing past the
2822 // dialog edge otherwise dispatches the click on the overlay and closes it.
2823 mxchatDragSafeOverlayClose(modal, closeModal);
2824
2825 // Prevent modal content clicks from closing the modal
2826 if (modalContent) {
2827 modalContent.addEventListener('click', function(e) {
2828 e.stopPropagation();
2829 });
2830 }
2831
2832 // Close on escape key
2833 document.addEventListener('keydown', function(e) {
2834 if (e.key === 'Escape' && modal.classList.contains('mxchat-instructions-show')) {
2835 closeModal();
2836 }
2837 });
2838
2839 // Copy functionality
2840 if (copyBtn && instructionsContent) {
2841 copyBtn.addEventListener('click', function(e) {
2842 e.preventDefault();
2843 e.stopPropagation();
2844
2845 var text = instructionsContent.textContent;
2846
2847 if (navigator.clipboard) {
2848 navigator.clipboard.writeText(text).then(function() {
2849 showCopySuccess();
2850 }).catch(function() {
2851 fallbackCopy(text);
2852 });
2853 } else {
2854 fallbackCopy(text);
2855 }
2856 });
2857 }
2858
2859 function fallbackCopy(text) {
2860 var textArea = document.createElement('textarea');
2861 textArea.value = text;
2862 textArea.style.position = 'fixed';
2863 textArea.style.left = '-999999px';
2864 textArea.style.top = '-999999px';
2865 document.body.appendChild(textArea);
2866 textArea.select();
2867 try {
2868 document.execCommand('copy');
2869 showCopySuccess();
2870 } catch (err) {
2871 console.error('Copy failed');
2872 }
2873 document.body.removeChild(textArea);
2874 }
2875
2876 function showCopySuccess() {
2877 var originalText = copyBtn.innerHTML;
2878 copyBtn.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20,6 9,17 4,12"/></svg>Copied!';
2879
2880 setTimeout(function() {
2881 copyBtn.innerHTML = originalText;
2882 }, 2000);
2883 }
2884 });
2885
2886 jQuery(document).ready(function($) {
2887 var emailDependentFields = [
2888 '#email_blocker_header_content',
2889 '#email_blocker_button_text',
2890 '#enable_name_field',
2891 '#name_field_placeholder'
2892 ];
2893
2894 // Add visual indicators
2895 emailDependentFields.forEach(function(fieldId) {
2896 var $row = $(fieldId).closest('tr');
2897 $row.addClass('email-dependent-field');
2898
2899 // Add an icon to show it's dependent
2900 var $label = $row.find('th label, th');
2901 $label.prepend('<span class="email-dependent-icon" style="color: #0073aa; margin-right: 5px;">↳</span>');
2902 });
2903
2904 // Hide initially with opacity for smoother transition
2905 emailDependentFields.forEach(function(fieldId) {
2906 $(fieldId).closest('tr').hide().css('opacity', '0');
2907 });
2908
2909 function toggleEmailFields() {
2910 var emailEnabled = $('#enable_email_block').is(':checked');
2911
2912 emailDependentFields.forEach(function(fieldId) {
2913 var $row = $(fieldId).closest('tr');
2914 if (emailEnabled) {
2915 $row.slideDown(400).animate({opacity: 1}, 200);
2916 } else {
2917 $row.animate({opacity: 0}, 200).slideUp(400);
2918 }
2919 });
2920 }
2921
2922 toggleEmailFields();
2923 $('#enable_email_block').on('change', toggleEmailFields);
2924 });
2925
2926
2927
2928 //Handle role restriction changes
2929 jQuery(document).ready(function($) {
2930 //Handle role restriction changes for both data sources
2931 $(document).on('change', '.mxchat-role-select', function() {
2932 const $select = $(this);
2933 const entryId = $select.data('entry-id');
2934 const dataSource = $select.data('data-source');
2935 const roleRestriction = $select.val();
2936 const nonce = $select.data('nonce');
2937
2938 // Visual feedback
2939 $select.prop('disabled', true).addClass('updating');
2940
2941 $.ajax({
2942 url: ajaxurl,
2943 type: 'POST',
2944 data: {
2945 action: 'mxchat_update_role_restriction',
2946 nonce: nonce,
2947 entry_id: entryId,
2948 data_source: dataSource, // NEW: Include data source
2949 role_restriction: roleRestriction
2950 },
2951 success: function(response) {
2952 if (response.success) {
2953 // Show success feedback
2954 $select.removeClass('updating').addClass('updated');
2955 setTimeout(() => {
2956 $select.removeClass('updated');
2957 }, 2000);
2958 } else {
2959 alert('Failed to update role restriction: ' + response.data);
2960 // Revert selection
2961 $select.val($select.data('original-value'));
2962 }
2963 },
2964 error: function() {
2965 alert('Error updating role restriction');
2966 // Revert selection
2967 $select.val($select.data('original-value'));
2968 },
2969 complete: function() {
2970 $select.prop('disabled', false);
2971 }
2972 });
2973
2974 // Store original value for potential revert
2975 $select.data('original-value', roleRestriction);
2976 });
2977
2978 // Store initial values
2979 $('.mxchat-role-select').each(function() {
2980 $(this).data('original-value', $(this).val());
2981 });
2982 });
2983
2984 // Bot Selector Handler
2985 jQuery(document).ready(function($) {
2986 var saveTimer;
2987
2988 // Function to update bot_id in forms
2989 function updateBotIdInForm(formSelector) {
2990 var botId = $('#mxchat-bot-selector').val();
2991 var form = $(formSelector);
2992
2993 if (form.length > 0) {
2994 // Remove existing bot_id hidden input
2995 form.find('input[name="bot_id"]').remove();
2996
2997 // Add new bot_id hidden input if not default
2998 if (botId && botId !== 'default') {
2999 form.append('<input type="hidden" name="bot_id" value="' + botId + '">');
3000 }
3001 }
3002 }
3003
3004 $('#mxchat-bot-selector').on('change', function() {
3005 var botId = $(this).val();
3006
3007 // Clear any existing timer
3008 clearTimeout(saveTimer);
3009
3010 // Update all forms with new bot_id when bot selection changes
3011 updateBotIdInForm('#mxchat-url-form');
3012 updateBotIdInForm('#mxchat-content-form');
3013
3014 // Save the selection via AJAX
3015 $.ajax({
3016 url: ajaxurl,
3017 type: 'POST',
3018 data: {
3019 action: 'mxchat_save_selected_bot',
3020 bot_id: botId,
3021 nonce: mxchatAdmin.setting_nonce
3022 },
3023 success: function(response) {
3024 if (response.success) {
3025 // Show saved indicator
3026 $('#mxchat-bot-save-status').fadeIn().delay(2000).fadeOut();
3027
3028 // Reload the page after a short delay to refresh content
3029 saveTimer = setTimeout(function() {
3030 var currentUrl = new URL(window.location.href);
3031 currentUrl.searchParams.set('bot_id', botId);
3032 currentUrl.searchParams.set('page', 'mxchat-prompts');
3033 window.location.href = currentUrl.toString();
3034 }, 500);
3035 }
3036 },
3037 error: function() {
3038 console.error('Failed to save bot selection');
3039 }
3040 });
3041 });
3042
3043 // Initialize forms with current bot_id when the page loads
3044 setTimeout(function() {
3045 updateBotIdInForm('#mxchat-url-form');
3046 updateBotIdInForm('#mxchat-content-form');
3047 }, 100);
3048 });
3049
3050 // API Key Status Indicator for Chat Models
3051 jQuery(document).ready(function($) {
3052 function updateChatModelAPIStatus(apiKeyStatuses) {
3053 var selectedModel = $('#model').val();
3054
3055 // Hide all status messages
3056 $('.mxchat-api-status').hide();
3057
3058 // Return early if no model is selected
3059 if (!selectedModel) {
3060 return;
3061 }
3062
3063 // Map models to providers
3064 var provider = null;
3065
3066 if (selectedModel.startsWith('gpt-')) {
3067 provider = 'openai';
3068 } else if (selectedModel.startsWith('claude-')) {
3069 provider = 'claude';
3070 } else if (selectedModel.startsWith('grok-')) {
3071 provider = 'xai';
3072 } else if (selectedModel.startsWith('deepseek-')) {
3073 provider = 'deepseek';
3074 } else if (selectedModel.startsWith('gemini-')) {
3075 provider = 'gemini';
3076 } else if (selectedModel === 'openrouter') {
3077 provider = 'openrouter';
3078 }
3079
3080 // If we have fresh API key data, update the messages
3081 if (apiKeyStatuses && provider && apiKeyStatuses[provider] !== undefined) {
3082 var $statusElement = $('.mxchat-api-status[data-provider="' + provider + '"]');
3083 var hasKey = apiKeyStatuses[provider];
3084
3085 if (hasKey) {
3086 $statusElement.html('<span style="color: #00a32a;">✓ API key for ' + getProviderName(provider) + ' detected</span>');
3087 } else {
3088 $statusElement.html('<span style="color: #d63638;">⚠ No API key for ' + getProviderName(provider) + ' detected. Please enter API key in API Keys tab.</span>');
3089 }
3090 }
3091
3092 // Show the appropriate status message
3093 if (provider) {
3094 $('.mxchat-api-status[data-provider="' + provider + '"]').show();
3095 }
3096 }
3097
3098 function updateEmbeddingModelAPIStatus(apiKeyStatuses) {
3099 var selectedModel = $('#embedding_model').val();
3100
3101 // Hide all status messages
3102 $('.mxchat-embedding-api-status').hide();
3103
3104 // Custom-provider embeddings in use: the standard picker is inert, so
3105 // its provider key statuses are noise (plan ae02cb).
3106 if ($('#embedding_model').prop('disabled')) {
3107 return;
3108 }
3109
3110 // Return early if no model is selected
3111 if (!selectedModel) {
3112 return;
3113 }
3114
3115 // Map models to providers
3116 var provider = null;
3117
3118 if (selectedModel.startsWith('text-embedding-')) {
3119 provider = 'openai';
3120 } else if (selectedModel.startsWith('voyage-')) {
3121 provider = 'voyage';
3122 } else if (selectedModel.startsWith('gemini-embedding-')) {
3123 provider = 'gemini';
3124 }
3125
3126 // If we have fresh API key data, update the messages
3127 if (apiKeyStatuses && provider && apiKeyStatuses[provider] !== undefined) {
3128 var $statusElement = $('.mxchat-embedding-api-status[data-provider="' + provider + '"]');
3129 var hasKey = apiKeyStatuses[provider];
3130
3131 if (hasKey) {
3132 $statusElement.html('<span style="color: #00a32a;">✓ API key for ' + getProviderName(provider) + ' detected</span>');
3133 } else {
3134 $statusElement.html('<span style="color: #d63638;">⚠ No API key for ' + getProviderName(provider) + ' detected. Please enter API key in API Keys tab.</span>');
3135 }
3136 }
3137
3138 // Show the appropriate status message
3139 if (provider) {
3140 $('.mxchat-embedding-api-status[data-provider="' + provider + '"]').show();
3141 }
3142 }
3143
3144 function getProviderName(provider) {
3145 var names = {
3146 'openai': 'OpenAI',
3147 'claude': 'Anthropic (Claude)',
3148 'xai': 'X.AI (Grok)',
3149 'deepseek': 'DeepSeek',
3150 'gemini': 'Google Gemini',
3151 'openrouter': 'OpenRouter',
3152 'voyage': 'Voyage AI'
3153 };
3154 return names[provider] || provider;
3155 }
3156
3157 function refreshAPIKeyStatus() {
3158 $.ajax({
3159 url: ajaxurl,
3160 type: 'POST',
3161 data: {
3162 action: 'mxchat_check_api_keys',
3163 nonce: mxchatAdmin.setting_nonce
3164 },
3165 success: function(response) {
3166 if (response.success && response.data) {
3167 updateChatModelAPIStatus(response.data);
3168 updateEmbeddingModelAPIStatus(response.data);
3169 }
3170 }
3171 });
3172 }
3173
3174 // Expose refresh function globally so auto-save can call it
3175 window.mxchatRefreshAPIKeyStatus = refreshAPIKeyStatus;
3176
3177 // Run on page load
3178 updateChatModelAPIStatus();
3179 updateEmbeddingModelAPIStatus();
3180
3181 // Check if we just saved settings (WordPress redirects with ?settings-updated=true)
3182 var urlParams = new URLSearchParams(window.location.search);
3183 if (urlParams.get('settings-updated') === 'true') {
3184 // Page was just reloaded after save, fetch fresh API key status
3185 refreshAPIKeyStatus();
3186 }
3187
3188 // Run when model changes
3189 $('#model').on('change', function() {
3190 updateChatModelAPIStatus();
3191 updateWebSearchToggleVisibility();
3192 });
3193 $('#embedding_model').on('change', function() { updateEmbeddingModelAPIStatus(); });
3194
3195 // Web Search toggle visibility based on model
3196 function updateWebSearchToggleVisibility() {
3197 var selectedModel = $('#model').val();
3198 var $wrapper = $('#web-search-toggle-wrapper');
3199 var $unavailableMessage = $('#web-search-unavailable-message');
3200
3201 if (!$wrapper.length) return; // Element doesn't exist
3202
3203 // Get the list of web-search-capable models from data attributes (OpenAI + Gemini)
3204 var openaiModelsAttr = $wrapper.data('openai-models');
3205 var geminiModelsAttr = $wrapper.data('gemini-models');
3206 var unsupportedModelsAttr = $wrapper.data('unsupported-models');
3207
3208 var openaiModels = openaiModelsAttr ? String(openaiModelsAttr).split(',') : [];
3209 var geminiModels = geminiModelsAttr ? String(geminiModelsAttr).split(',') : [];
3210 var unsupportedModels = unsupportedModelsAttr ? String(unsupportedModelsAttr).split(',') : [];
3211
3212 // Check if selected model is an OpenAI or Gemini model that supports web search
3213 var isCapable = openaiModels.includes(selectedModel) || geminiModels.includes(selectedModel);
3214 var isSupported = isCapable && !unsupportedModels.includes(selectedModel);
3215
3216 if (isSupported) {
3217 $wrapper.show();
3218 $unavailableMessage.hide();
3219 } else {
3220 $wrapper.hide();
3221 $unavailableMessage.show();
3222 }
3223 }
3224
3225 // Run on page load
3226 updateWebSearchToggleVisibility();
3227 });
3228
3229 // Transcripts Metrics Dashboard
3230 jQuery(document).ready(function($) {
3231 // Tab switching functionality
3232 $('.mxchat-metrics-tab').on('click', function() {
3233 const tabName = $(this).data('tab');
3234
3235 // Update tab buttons
3236 $('.mxchat-metrics-tab').removeClass('active');
3237 $(this).addClass('active');
3238
3239 // Update panels
3240 $('.mxchat-metrics-panel').removeClass('active');
3241 $(`.mxchat-metrics-panel[data-panel="${tabName}"]`).addClass('active');
3242
3243 // Initialize chart if activity tab is shown
3244 if (tabName === 'activity' && typeof mxchatChartData !== 'undefined') {
3245 // Small delay to ensure the canvas is visible
3246 setTimeout(function() {
3247 initActivityChart();
3248 }, 50);
3249 }
3250 });
3251
3252 // Initialize chart function
3253 function initActivityChart() {
3254 const canvas = document.getElementById('mxchat-activity-chart');
3255 if (!canvas) return;
3256
3257 // Check if chart already exists and destroy it
3258 if (canvas.chartInstance) {
3259 canvas.chartInstance.destroy();
3260 }
3261
3262 const ctx = canvas.getContext('2d');
3263
3264 // Create gradient for chats line
3265 const chatsGradient = ctx.createLinearGradient(0, 0, 0, 300);
3266 chatsGradient.addColorStop(0, 'rgba(102, 126, 234, 0.3)');
3267 chatsGradient.addColorStop(1, 'rgba(102, 126, 234, 0.05)');
3268
3269 // Create gradient for messages line
3270 const messagesGradient = ctx.createLinearGradient(0, 0, 0, 300);
3271 messagesGradient.addColorStop(0, 'rgba(118, 75, 162, 0.3)');
3272 messagesGradient.addColorStop(1, 'rgba(118, 75, 162, 0.05)');
3273
3274 // Simple chart without external library
3275 canvas.chartInstance = new SimpleChart(canvas, {
3276 labels: mxchatChartData.labels,
3277 datasets: [
3278 {
3279 label: 'Chats',
3280 data: mxchatChartData.chats,
3281 borderColor: '#667eea',
3282 backgroundColor: chatsGradient,
3283 fill: true
3284 },
3285 {
3286 label: 'Messages',
3287 data: mxchatChartData.messages,
3288 borderColor: '#764ba2',
3289 backgroundColor: messagesGradient,
3290 fill: true
3291 }
3292 ]
3293 });
3294 }
3295
3296 // Simple chart implementation (no external dependencies)
3297 class SimpleChart {
3298 constructor(canvas, config) {
3299 this.canvas = canvas;
3300 this.ctx = canvas.getContext('2d');
3301 this.config = config;
3302 this.padding = { top: 20, right: 20, bottom: 40, left: 50 };
3303 this.render();
3304 }
3305
3306 destroy() {
3307 this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
3308 }
3309
3310 render() {
3311 const dpr = window.devicePixelRatio || 1;
3312 const rect = this.canvas.getBoundingClientRect();
3313
3314 this.canvas.width = rect.width * dpr;
3315 this.canvas.height = rect.height * dpr;
3316 this.ctx.scale(dpr, dpr);
3317
3318 this.canvas.style.width = rect.width + 'px';
3319 this.canvas.style.height = rect.height + 'px';
3320
3321 const width = rect.width - this.padding.left - this.padding.right;
3322 const height = rect.height - this.padding.top - this.padding.bottom;
3323
3324 // Find max value
3325 let maxValue = 0;
3326 this.config.datasets.forEach(dataset => {
3327 const max = Math.max(...dataset.data);
3328 if (max > maxValue) maxValue = max;
3329 });
3330
3331 // Add some padding to max value
3332 maxValue = Math.ceil(maxValue * 1.1);
3333 if (maxValue === 0) maxValue = 10;
3334
3335 // Draw grid lines
3336 this.ctx.strokeStyle = '#e5e7eb';
3337 this.ctx.lineWidth = 1;
3338 const gridLines = 5;
3339
3340 for (let i = 0; i <= gridLines; i++) {
3341 const y = this.padding.top + (height / gridLines) * i;
3342 this.ctx.beginPath();
3343 this.ctx.moveTo(this.padding.left, y);
3344 this.ctx.lineTo(this.padding.left + width, y);
3345 this.ctx.stroke();
3346
3347 // Draw y-axis labels
3348 const value = maxValue - (maxValue / gridLines) * i;
3349 this.ctx.fillStyle = '#6b7280';
3350 this.ctx.font = '12px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif';
3351 this.ctx.textAlign = 'right';
3352 this.ctx.fillText(Math.round(value), this.padding.left - 10, y + 4);
3353 }
3354
3355 // Draw datasets
3356 this.config.datasets.forEach(dataset => {
3357 const points = [];
3358 const xStep = width / (this.config.labels.length - 1 || 1);
3359
3360 dataset.data.forEach((value, index) => {
3361 const x = this.padding.left + (xStep * index);
3362 const y = this.padding.top + height - (value / maxValue * height);
3363 points.push({ x, y, value });
3364 });
3365
3366 // Draw filled area
3367 if (dataset.fill && dataset.backgroundColor) {
3368 this.ctx.fillStyle = dataset.backgroundColor;
3369 this.ctx.beginPath();
3370 this.ctx.moveTo(points[0].x, this.padding.top + height);
3371 points.forEach(point => {
3372 this.ctx.lineTo(point.x, point.y);
3373 });
3374 this.ctx.lineTo(points[points.length - 1].x, this.padding.top + height);
3375 this.ctx.closePath();
3376 this.ctx.fill();
3377 }
3378
3379 // Draw line
3380 this.ctx.strokeStyle = dataset.borderColor;
3381 this.ctx.lineWidth = 3;
3382 this.ctx.lineCap = 'round';
3383 this.ctx.lineJoin = 'round';
3384
3385 this.ctx.beginPath();
3386 points.forEach((point, index) => {
3387 if (index === 0) {
3388 this.ctx.moveTo(point.x, point.y);
3389 } else {
3390 this.ctx.lineTo(point.x, point.y);
3391 }
3392 });
3393 this.ctx.stroke();
3394
3395 // Draw points
3396 points.forEach(point => {
3397 this.ctx.fillStyle = '#ffffff';
3398 this.ctx.beginPath();
3399 this.ctx.arc(point.x, point.y, 5, 0, Math.PI * 2);
3400 this.ctx.fill();
3401 this.ctx.strokeStyle = dataset.borderColor;
3402 this.ctx.lineWidth = 2;
3403 this.ctx.stroke();
3404 });
3405 });
3406
3407 // Draw x-axis labels
3408 const xStep = width / (this.config.labels.length - 1 || 1);
3409 this.ctx.fillStyle = '#6b7280';
3410 this.ctx.font = '12px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif';
3411 this.ctx.textAlign = 'center';
3412
3413 this.config.labels.forEach((label, index) => {
3414 const x = this.padding.left + (xStep * index);
3415 this.ctx.fillText(label, x, this.padding.top + height + 20);
3416 });
3417
3418 // Draw legend
3419 let legendX = this.padding.left;
3420 const legendY = rect.height - 10;
3421
3422 this.config.datasets.forEach((dataset, index) => {
3423 // Color box
3424 this.ctx.fillStyle = dataset.borderColor;
3425 this.ctx.fillRect(legendX, legendY - 8, 12, 12);
3426
3427 // Label
3428 this.ctx.fillStyle = '#374151';
3429 this.ctx.font = '12px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif';
3430 this.ctx.textAlign = 'left';
3431 this.ctx.fillText(dataset.label, legendX + 18, legendY);
3432
3433 legendX += this.ctx.measureText(dataset.label).width + 40;
3434 });
3435 }
3436 }
3437
3438 // Initialize chart on page load if we're on the activity tab
3439 if ($('.mxchat-metrics-tab.active').data('tab') === 'activity' && typeof mxchatChartData !== 'undefined') {
3440 setTimeout(function() {
3441 initActivityChart();
3442 }, 100);
3443 }
3444
3445 // ========================================
3446 // SLACK TEST CONNECTION
3447 // ========================================
3448 $('#mxchat-test-slack-connection').on('click', function() {
3449 var $button = $(this);
3450 var $result = $('#mxchat-slack-test-result');
3451 var originalText = $button.html();
3452
3453 // Show loading state
3454 $button.prop('disabled', true).html(
3455 '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="margin-right: 8px; animation: spin 1s linear infinite;"><circle cx="12" cy="12" r="10" stroke-dasharray="32" stroke-dashoffset="12"/></svg>' +
3456 'Testing...'
3457 );
3458
3459 $.ajax({
3460 url: ajaxurl,
3461 type: 'POST',
3462 data: {
3463 action: 'mxchat_test_slack_connection',
3464 nonce: mxchatAdmin.nonce
3465 },
3466 success: function(response) {
3467 $result.show();
3468 if (response.success) {
3469 $result.html(
3470 '<div style="background: #d4edda; border: 1px solid #c3e6cb; color: #155724; padding: 16px; border-radius: 8px;">' +
3471 '<strong style="display: block; margin-bottom: 8px;">✓ Connection Successful!</strong>' +
3472 '<pre style="margin: 0; white-space: pre-wrap; font-size: 13px;">' + escapeHtml(response.data.message) + '</pre>' +
3473 '</div>'
3474 );
3475 } else {
3476 var bgColor = response.data.partial ? '#fff3cd' : '#f8d7da';
3477 var borderColor = response.data.partial ? '#ffeeba' : '#f5c6cb';
3478 var textColor = response.data.partial ? '#856404' : '#721c24';
3479 var icon = response.data.partial ? '' : '';
3480 var title = response.data.partial ? 'Partial Success - Missing Scopes' : 'Connection Failed';
3481
3482 $result.html(
3483 '<div style="background: ' + bgColor + '; border: 1px solid ' + borderColor + '; color: ' + textColor + '; padding: 16px; border-radius: 8px;">' +
3484 '<strong style="display: block; margin-bottom: 8px;">' + icon + ' ' + title + '</strong>' +
3485 '<pre style="margin: 0; white-space: pre-wrap; font-size: 13px;">' + escapeHtml(response.data.message) + '</pre>' +
3486 (response.data.missing_scopes ? '<p style="margin: 12px 0 0; font-size: 13px;">Add these scopes in your Slack app settings and reinstall the app.</p>' : '') +
3487 '</div>'
3488 );
3489 }
3490 },
3491 error: function() {
3492 $result.show().html(
3493 '<div style="background: #f8d7da; border: 1px solid #f5c6cb; color: #721c24; padding: 16px; border-radius: 8px;">' +
3494 '<strong>✗ Request Failed</strong><br>Could not connect to the server. Please try again.' +
3495 '</div>'
3496 );
3497 },
3498 complete: function() {
3499 $button.prop('disabled', false).html(originalText);
3500 }
3501 });
3502 });
3503
3504 // Helper function to escape HTML
3505 function escapeHtml(text) {
3506 var div = document.createElement('div');
3507 div.appendChild(document.createTextNode(text));
3508 return div.innerHTML;
3509 }
3510
3511 // ========================================
3512 // DEBUG & OPTIMIZATION TOOLS
3513 // ========================================
3514
3515 // Debug Mode Toggle
3516 $('#mxchat_debug_mode').on('change', function() {
3517 var $toggle = $(this);
3518 var enabled = $toggle.is(':checked') ? 'on' : 'off';
3519 var $label = $toggle.closest('label');
3520
3521 // Add loading indicator next to the toggle label
3522 var $indicator = $label.find('.mxchat-save-indicator');
3523 if ($indicator.length === 0) {
3524 $indicator = $('<span class="mxchat-save-indicator" style="margin-left: 10px;"></span>');
3525 $label.append($indicator);
3526 }
3527 $indicator.html('<span class="mxchat-saving-spinner"></span>').show();
3528
3529 $.ajax({
3530 url: ajaxurl,
3531 type: 'POST',
3532 data: {
3533 action: 'mxchat_toggle_debug_mode',
3534 enabled: enabled,
3535 _ajax_nonce: mxchatAdmin.setting_nonce
3536 },
3537 success: function(response) {
3538 if (response.success) {
3539 // Show success indicator
3540 $indicator.html('<span class="mxchat-save-success">✓</span>');
3541 setTimeout(function() {
3542 $indicator.fadeOut(300);
3543 }, 2000);
3544
3545 // Always refresh the log after toggling
3546 refreshDebugLog();
3547 } else {
3548 $indicator.html('<span class="mxchat-save-error">✗</span>');
3549 alert(response.data.message || 'Error toggling debug mode');
3550 $toggle.prop('checked', !$toggle.is(':checked'));
3551 }
3552 },
3553 error: function() {
3554 $indicator.html('<span class="mxchat-save-error">✗</span>');
3555 alert('Error toggling debug mode');
3556 $toggle.prop('checked', !$toggle.is(':checked'));
3557 }
3558 });
3559 });
3560
3561 // Refresh Debug Log
3562 function refreshDebugLog() {
3563 var $container = $('#mxchat-debug-log');
3564 var $countBadge = $('#mxchat-log-count');
3565
3566 $.ajax({
3567 url: ajaxurl,
3568 type: 'POST',
3569 data: {
3570 action: 'mxchat_get_debug_log',
3571 _ajax_nonce: mxchatAdmin.setting_nonce
3572 },
3573 success: function(response) {
3574 if (response.success) {
3575 var log = response.data.log;
3576 var count = response.data.count;
3577
3578 if (count > 0) {
3579 $countBadge.text(count + ' entries').show();
3580 var html = '<div class="mxchat-debug-log-entries">';
3581 log.forEach(function(entry) {
3582 var typeClass = 'mxchat-log-type-' + entry.type;
3583 var typeLabel = entry.type.replace(/_/g, ' ').toUpperCase();
3584 html += '<div class="mxchat-debug-log-entry ' + typeClass + '">';
3585 html += '<div class="mxchat-log-header">';
3586 html += '<span class="mxchat-log-type">' + escapeHtml(typeLabel) + '</span>';
3587 html += '<span class="mxchat-log-time">' + escapeHtml(entry.time) + '</span>';
3588 html += '</div>';
3589 html += '<div class="mxchat-log-message">' + escapeHtml(entry.message) + '</div>';
3590 if (entry.data) {
3591 html += '<div class="mxchat-log-data">' + escapeHtml(JSON.stringify(entry.data, null, 2)) + '</div>';
3592 }
3593 html += '</div>';
3594 });
3595 html += '</div>';
3596 $container.html(html);
3597 } else {
3598 $countBadge.hide();
3599 $container.html(
3600 '<div class="mxchat-debug-log-empty">' +
3601 '<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" style="opacity: 0.3;"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>' +
3602 '<p>No log entries yet. Enable debug mode to start logging.</p>' +
3603 '</div>'
3604 );
3605 }
3606 }
3607 }
3608 });
3609 }
3610
3611 // Load debug log on page load
3612 if ($('#mxchat-debug-log').length) {
3613 refreshDebugLog();
3614 }
3615
3616 // Refresh Log Button
3617 $('#mxchat-refresh-log').on('click', function() {
3618 var $btn = $(this);
3619 $btn.prop('disabled', true);
3620 refreshDebugLog();
3621 setTimeout(function() {
3622 $btn.prop('disabled', false);
3623 }, 500);
3624 });
3625
3626 // Clear Log Button
3627 $('#mxchat-clear-log').on('click', function() {
3628 if (!confirm('Are you sure you want to clear the debug log?')) {
3629 return;
3630 }
3631
3632 var $btn = $(this);
3633 $btn.prop('disabled', true);
3634
3635 $.ajax({
3636 url: ajaxurl,
3637 type: 'POST',
3638 data: {
3639 action: 'mxchat_clear_debug_log',
3640 _ajax_nonce: mxchatAdmin.setting_nonce
3641 },
3642 success: function(response) {
3643 if (response.success) {
3644 refreshDebugLog();
3645 } else {
3646 alert(response.data.message || 'Error clearing log');
3647 }
3648 },
3649 error: function() {
3650 alert('Error clearing log');
3651 },
3652 complete: function() {
3653 $btn.prop('disabled', false);
3654 }
3655 });
3656 });
3657
3658 // Export Settings Button
3659 $('#mxchat-export-settings').on('click', function() {
3660 var $btn = $(this);
3661 var originalHtml = $btn.html();
3662 $btn.prop('disabled', true).html(
3663 '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="animation: spin 1s linear infinite;"><circle cx="12" cy="12" r="10" stroke-dasharray="32" stroke-dashoffset="12"/></svg> Exporting...'
3664 );
3665
3666 $.ajax({
3667 url: ajaxurl,
3668 type: 'POST',
3669 data: {
3670 action: 'mxchat_export_settings',
3671 _ajax_nonce: mxchatAdmin.setting_nonce
3672 },
3673 success: function(response) {
3674 if (response.success) {
3675 // Create and download the JSON file
3676 var dataStr = JSON.stringify(response.data.settings, null, 2);
3677 var blob = new Blob([dataStr], { type: 'application/json' });
3678 var url = URL.createObjectURL(blob);
3679 var a = document.createElement('a');
3680 a.href = url;
3681 a.download = response.data.filename;
3682 document.body.appendChild(a);
3683 a.click();
3684 document.body.removeChild(a);
3685 URL.revokeObjectURL(url);
3686 } else {
3687 alert(response.data.message || 'Error exporting settings');
3688 }
3689 },
3690 error: function() {
3691 alert('Error exporting settings');
3692 },
3693 complete: function() {
3694 $btn.prop('disabled', false).html(originalHtml);
3695 }
3696 });
3697 });
3698
3699 // Reset Settings Modal
3700 var $resetModal = $('#mxchat-reset-modal');
3701 var $resetConfirmInput = $('#mxchat-reset-confirmation');
3702 var $resetConfirmBtn = $('#mxchat-reset-confirm');
3703
3704 $('#mxchat-reset-settings').on('click', function() {
3705 $resetModal.fadeIn(200);
3706 $resetConfirmInput.val('').focus();
3707 $resetConfirmBtn.prop('disabled', true);
3708 });
3709
3710 $('#mxchat-reset-modal-close, #mxchat-reset-cancel, .mxch-modal-backdrop').on('click', function() {
3711 $resetModal.fadeOut(200);
3712 });
3713
3714 $resetConfirmInput.on('input', function() {
3715 var value = $(this).val().toUpperCase();
3716 $resetConfirmBtn.prop('disabled', value !== 'RESET');
3717 });
3718
3719 $resetConfirmBtn.on('click', function() {
3720 var $btn = $(this);
3721 var confirmation = $resetConfirmInput.val();
3722
3723 $btn.prop('disabled', true).text('Resetting...');
3724
3725 $.ajax({
3726 url: ajaxurl,
3727 type: 'POST',
3728 data: {
3729 action: 'mxchat_reset_all_settings',
3730 confirmation: confirmation,
3731 _ajax_nonce: mxchatAdmin.setting_nonce
3732 },
3733 success: function(response) {
3734 if (response.success) {
3735 alert(response.data.message);
3736 window.location.reload();
3737 } else {
3738 alert(response.data.message || 'Error resetting settings');
3739 $btn.prop('disabled', false).text('Reset All Settings');
3740 }
3741 },
3742 error: function() {
3743 alert('Error resetting settings');
3744 $btn.prop('disabled', false).text('Reset All Settings');
3745 }
3746 });
3747 });
3748
3749 // Load debug log on page load if we're on the optimization section
3750 if ($('#optimization').hasClass('active') || window.location.hash === '#optimization') {
3751 refreshDebugLog();
3752 }
3753
3754 // Also refresh when switching to optimization tab
3755 $(document).on('click', '[data-section="optimization"]', function() {
3756 setTimeout(refreshDebugLog, 100);
3757 });
3758 });
3759
3760 // Global rate-limit usage: "Reset counter" button (plan-mxchat-20260603-e9b3f9)
3761 jQuery(document).ready(function($) {
3762 var $usage = $('#mxch-global-usage');
3763 if (!$usage.length) {
3764 return;
3765 }
3766
3767 $('#mxch-global-usage-reset').on('click', function() {
3768 var $btn = $(this);
3769 if (!window.confirm('Reset the global usage counter to zero now? This clears how many messages have been used in the current window.')) {
3770 return;
3771 }
3772
3773 var original = $btn.text();
3774 $btn.prop('disabled', true).text('Resetting…');
3775
3776 $.ajax({
3777 url: mxchatAdmin.ajax_url,
3778 type: 'POST',
3779 data: {
3780 action: 'mxchat_reset_global_rate_limit',
3781 bot_id: $usage.data('bot-id') || 'default',
3782 _ajax_nonce: $usage.data('reset-nonce')
3783 },
3784 success: function(response) {
3785 $btn.prop('disabled', false).text(original);
3786 if (response && response.success) {
3787 $('#mxch-global-usage-fill').css('width', (response.data.pct || 0) + '%');
3788 if (response.data.text) {
3789 $('#mxch-global-usage-text').text(response.data.text);
3790 }
3791 } else {
3792 window.alert((response && response.data && response.data.message) || 'Reset failed. Please try again.');
3793 }
3794 },
3795 error: function() {
3796 $btn.prop('disabled', false).text(original);
3797 window.alert('Reset failed. Please try again.');
3798 }
3799 });
3800 });
3801 });
3802 // ─── Unsaved-edit guard (plan 7787f8) ────────────────────────────────────
3803 // Autosave fires on `change`, which for text fields means blur — an edit made
3804 // with the cursor still in the box is unsaved until the user clicks out. Two
3805 // purely additive affordances close the loss window without touching how or
3806 // when saves fire: a persistent "Unsaved" badge on the field's label while its
3807 // value differs from the last-saved value, and a beforeunload prompt armed
3808 // only while some field is dirty. Baselines re-sync by observing the existing
3809 // autosave requests via ajaxSuccess — the save path itself is not modified.
3810 jQuery(document).ready(function($) {
3811 let $sections = $('.mxchat-autosave-section');
3812 const $pinecone = $('#mxchat-kb-tab-pinecone');
3813 if ($pinecone.length) {
3814 $sections = $sections.add($pinecone);
3815 }
3816 if (!$sections.length) return; // not a MxChat settings screen
3817
3818 const unsavedLabel = (typeof mxchatAdmin !== 'undefined' && mxchatAdmin.unsaved_label)
3819 ? mxchatAdmin.unsaved_label
3820 : 'Unsaved';
3821
3822 function fieldValue($f) {
3823 const type = $f.attr('type');
3824 if (type === 'checkbox' || type === 'radio') {
3825 return $f.is(':checked') ? '1' : '0';
3826 }
3827 const v = $f.val();
3828 return v == null ? '' : String(v);
3829 }
3830
3831 function trackable($f) {
3832 if (!$f.attr('name')) return false;
3833 if ($f.attr('type') === 'hidden') return false;
3834 if ($f.is('#model, #openrouter_selected_model, .mxchat-la-field, select[multiple]')) return false;
3835 return true;
3836 }
3837
3838 // name -> last value confirmed persisted. Captured on the user's FIRST
3839 // focus of a field, not at page load: other ready/async code (model
3840 // pickers, key masks, slider inits) mutates values after load, and a
3841 // load-time snapshot reads those programmatic fills as "unsaved edits"
3842 // and false-arms the guard at rest. An untouched field cannot hold an
3843 // unsaved user edit — same insight as the autosave path's own
3844 // userModifiedFields tracking.
3845 const baseline = new Map();
3846 $sections.on('focusin', 'input, textarea, select', function() {
3847 const $f = $(this);
3848 if (!trackable($f)) return;
3849 const name = $f.attr('name');
3850 if (!baseline.has(name)) {
3851 baseline.set(name, fieldValue($f));
3852 }
3853 });
3854
3855 function isDirty($f) {
3856 const name = $f.attr('name');
3857 return baseline.has(name) && fieldValue($f) !== baseline.get(name);
3858 }
3859
3860 function syncBadge($f) {
3861 const $wrapper = $f.closest('.mxch-field');
3862 const $home = $wrapper.length ? $wrapper.find('.mxch-field-label').first() : $();
3863 let $badge = $home.length
3864 ? $home.children('.mxchat-unsaved-badge')
3865 : $f.nextAll('.mxchat-unsaved-badge').first();
3866 if (isDirty($f)) {
3867 if (!$badge.length) {
3868 $badge = $('<span class="mxchat-unsaved-badge"></span>').text(unsavedLabel);
3869 if ($home.length) {
3870 $home.append($badge);
3871 } else {
3872 $f.after($badge);
3873 }
3874 }
3875 } else {
3876 $badge.remove();
3877 }
3878 }
3879
3880 // Persistent marker only for free-text fields — toggles and selects save on
3881 // the same interaction that changes them; the transient spinner covers those.
3882 const textTypes = ['text', 'number', 'url', 'email', 'password', 'search', 'tel'];
3883 $sections.on('input change', 'input, textarea, select', function() {
3884 const $f = $(this);
3885 if (!trackable($f)) return;
3886 const textLike = $f.is('textarea') || textTypes.indexOf(($f.attr('type') || '').toLowerCase()) !== -1;
3887 if (textLike) {
3888 syncBadge($f);
3889 }
3890 });
3891
3892 // A successful autosave round-trip re-baselines the field it saved. The
3893 // baseline takes the value the request actually SENT, so edits made while
3894 // the save was in flight keep the field dirty and the guard armed.
3895 function rebaselineField(name, saved) {
3896 saved = saved == null ? '' : String(saved);
3897 // Checkboxes go over the wire as on/off (or 1/0); normalize to 1/0.
3898 if (saved === 'on') saved = '1';
3899 if (saved === 'off') saved = '0';
3900 baseline.set(name, saved);
3901 const $fs = $sections.find('[name="' + name.replace(/"/g, '\\"') + '"]');
3902 if ($fs.length > 1) {
3903 // The baseline Map is per-NAME, so duplicate names share one entry
3904 // and dirty-compare against each other's state — the exact shape
3905 // that let the exit beacon revert saves through same-named ACF
3906 // twins before 30e81f moved those toggles onto unique field keys.
3907 console.warn('MxChat: duplicate field name "' + name + '" in autosave sections — per-name dirty tracking may misreport these fields.');
3908 }
3909 $fs.each(function() { syncBadge($(this)); });
3910 }
3911
3912 $(document).ajaxSuccess(function(event, xhr, settings) {
3913 if (!settings || typeof settings.data !== 'string') return;
3914 const isGroupBatch = settings.data.indexOf('action=mxchat_acf_toggle_group') !== -1;
3915 if (!isGroupBatch &&
3916 settings.data.indexOf('action=mxchat_save_setting') === -1 &&
3917 settings.data.indexOf('action=mxchat_save_prompts_setting') === -1) {
3918 return;
3919 }
3920 if (!xhr || !xhr.responseJSON || xhr.responseJSON.success !== true) return;
3921 if (isGroupBatch) {
3922 // A group batch (bf57e0) flips many toggles in one response —
3923 // re-baseline every field it touched, or the pagehide beacon
3924 // would post them all individually on the way out (and silently
3925 // drop everything past BEACON_MAX_FIELDS).
3926 const fields = (xhr.responseJSON.data && xhr.responseJSON.data.fields) || [];
3927 fields.forEach(function(f) {
3928 if (f && f.name) rebaselineField(f.name, f.value);
3929 });
3930 return;
3931 }
3932 let params;
3933 try { params = new URLSearchParams(settings.data); } catch (err) { return; }
3934 const name = params.get('name');
3935 if (!name || !baseline.has(name)) return;
3936 rebaselineField(name, params.get('value'));
3937 });
3938
3939 // Navigation guard — recomputed per-field at the moment of leaving, so it
3940 // arms only when a tracked value genuinely differs from last-saved.
3941 window.addEventListener('beforeunload', function(e) {
3942 let dirty = false;
3943 $sections.find('input, textarea, select').each(function() {
3944 const $f = $(this);
3945 if (trackable($f) && isDirty($f)) {
3946 dirty = true;
3947 return false;
3948 }
3949 });
3950 if (dirty) {
3951 e.preventDefault();
3952 e.returnValue = '';
3953 return '';
3954 }
3955 });
3956
3957 // ─── sendBeacon save-on-exit (plan 18fd68) ───────────────────────────
3958 // The prompt above only warns — the user can click "Leave", and browsers
3959 // skip the dialog entirely without a prior user gesture. sendBeacon
3960 // survives page teardown by design, so each dirty tracked field is also
3961 // posted to the same autosave endpoint on the way out. Bound to pagehide
3962 // ONLY: it fires once per real teardown, after the leave dialog resolves,
3963 // so a cancelled leave never posts and no sent-once flag is needed (a
3964 // beforeunload beacon would fire before the user answers the dialog).
3965 // Fire-and-forget: baseline and badge stay untouched — if the beacon
3966 // lands the server persists it; if the user returns via bfcache the
3967 // field is still tracked dirty and the normal flow continues.
3968 const BEACON_MAX_FIELDS = 8; // a pathological page state must not machine-gun admin-ajax
3969 const BEACON_MAX_VALUE = 60000; // sendBeacon's queue budget is ~64KB; the prompt covered oversized edits
3970
3971 function beaconRoute(name) {
3972 // Mirror of the autosave action routing above — prompts-page fields
3973 // go to mxchat_save_prompts_setting with its own nonce and URL.
3974 const prompts = name.indexOf('mxchat_prompts_options') !== -1 ||
3975 name.indexOf('mxchat_auto_sync_') === 0 ||
3976 name.indexOf('mxchat_pinecone_addon_options') !== -1 ||
3977 name.indexOf('mxchat_chunk') === 0 ||
3978 name.indexOf('mxchat_acf_field_') === 0 ||
3979 name === 'mxchat_custom_meta_whitelist';
3980 if (prompts) {
3981 if (typeof mxchatPromptsAdmin === 'undefined') return null;
3982 return {
3983 url: mxchatPromptsAdmin.ajax_url,
3984 action: 'mxchat_save_prompts_setting',
3985 nonce: mxchatPromptsAdmin.prompts_setting_nonce
3986 };
3987 }
3988 if (typeof mxchatAdmin === 'undefined') return null;
3989 return {
3990 url: mxchatAdmin.ajax_url,
3991 action: 'mxchat_save_setting',
3992 nonce: mxchatAdmin.setting_nonce
3993 };
3994 }
3995
3996 function beaconWireValue($f) {
3997 // Wire format matches the autosave path, not fieldValue()'s 1/0
3998 // dirty-compare normalization: checkboxes post on/off (Pinecone's
3999 // post 1/0), everything else posts val().
4000 if ($f.attr('type') === 'checkbox') {
4001 if (($f.attr('name') || '').indexOf('mxchat_pinecone_addon_options') !== -1) {
4002 return $f.is(':checked') ? '1' : '0';
4003 }
4004 return $f.is(':checked') ? 'on' : 'off';
4005 }
4006 const v = $f.val();
4007 return v == null ? '' : String(v);
4008 }
4009
4010 window.addEventListener('pagehide', function() {
4011 if (!navigator.sendBeacon) return;
4012 let sent = 0;
4013 $sections.find('input, textarea, select').each(function() {
4014 const $f = $(this);
4015 if (!trackable($f) || !isDirty($f)) return;
4016 // An unchecked radio is "dirty" versus its baseline but its val()
4017 // is the wrong group value to persist — the checked sibling (if
4018 // dirty itself) carries the group's real state.
4019 if ($f.attr('type') === 'radio' && !$f.is(':checked')) return;
4020 const name = $f.attr('name');
4021 const route = beaconRoute(name);
4022 if (!route) return;
4023 const value = beaconWireValue($f);
4024 if (value.length > BEACON_MAX_VALUE) return;
4025 const fd = new FormData();
4026 fd.append('action', route.action);
4027 fd.append('name', name);
4028 fd.append('value', value);
4029 fd.append('_ajax_nonce', route.nonce);
4030 if (navigator.sendBeacon(route.url, fd)) {
4031 sent++;
4032 }
4033 if (sent >= BEACON_MAX_FIELDS) return false;
4034 });
4035 });
4036 });
4037
4038 // ─── ACF group-level toggles (plan bf57e0) ──────────────────────────────
4039 // One click includes/excludes every field in an ACF field group via a
4040 // DEDICATED batch action — one option write server-side. Looping the
4041 // per-field autosave endpoint from here would be a lost-update race: each
4042 // request reads the exclusion option before the others have written it
4043 // back, and the last write wins.
4044 jQuery(function($) {
4045 const $groups = $('[data-mxchat-acf-group]');
4046 if (!$groups.length || typeof mxchatPromptsAdmin === 'undefined') return;
4047
4048 function groupInputs($group) {
4049 return $group.find('input.mxchat-autosave-field[name^="mxchat_acf_field_"]');
4050 }
4051
4052 // Derived state, never stored: ON when nothing in the group is excluded,
4053 // OFF when everything is, indeterminate when mixed. Recomputed from the
4054 // field toggles' DOM so it can never drift from the real per-field state.
4055 function refreshGroupToggle($group) {
4056 const $toggle = $group.find('.mxchat-acf-group-toggle').first();
4057 if (!$toggle.length) return;
4058 const $fields = groupInputs($group);
4059 const on = $fields.filter(':checked').length;
4060 $toggle.prop('indeterminate', on > 0 && on < $fields.length);
4061 $toggle.prop('checked', $fields.length > 0 && on === $fields.length);
4062 }
4063
4064 // indeterminate is a JS-only property; the server carries the mixed
4065 // state via data-indeterminate on render.
4066 $groups.find('.mxchat-acf-group-toggle[data-indeterminate="1"]').prop('indeterminate', true);
4067
4068 // A hand-flipped field toggle updates its group's header state right away.
4069 $groups.on('change', 'input.mxchat-autosave-field[name^="mxchat_acf_field_"]', function() {
4070 refreshGroupToggle($(this).closest('[data-mxchat-acf-group]'));
4071 });
4072
4073 $groups.on('change', '.mxchat-acf-group-toggle', function() {
4074 const $toggle = $(this);
4075 const $group = $toggle.closest('[data-mxchat-acf-group]');
4076 // A click on an indeterminate box lands on checked — i.e. include
4077 // everything, the less destructive direction. Documented choice.
4078 const include = $toggle.is(':checked');
4079
4080 const feedbackContainer = $('<div class="feedback-container"></div>');
4081 const spinner = $('<div class="saving-spinner"></div>');
4082 const successIcon = $('<div class="success-icon">✔</div>');
4083 $toggle.closest('.mxchat-toggle-switch').after(feedbackContainer);
4084 feedbackContainer.append(spinner);
4085 $toggle.prop('disabled', true);
4086
4087 $.ajax({
4088 url: mxchatPromptsAdmin.ajax_url,
4089 type: 'POST',
4090 data: {
4091 action: 'mxchat_acf_toggle_group',
4092 group_key: $group.attr('data-mxchat-acf-group'),
4093 state: include ? 'on' : 'off',
4094 _ajax_nonce: $toggle.data('nonce')
4095 },
4096 success: function(response) {
4097 if (response && response.success) {
4098 const fields = (response.data && response.data.fields) || [];
4099 fields.forEach(function(f) {
4100 if (!f || !f.name) return;
4101 $group.find('[name="' + f.name.replace(/"/g, '\\"') + '"]').prop('checked', f.value === 'on');
4102 });
4103 refreshGroupToggle($group);
4104 spinner.fadeOut(200, function() {
4105 feedbackContainer.append(successIcon);
4106 successIcon.fadeIn(200).delay(1000).fadeOut(200, function() {
4107 feedbackContainer.remove();
4108 });
4109 });
4110 } else {
4111 feedbackContainer.remove();
4112 refreshGroupToggle($group); // fall back to the fields' real state
4113 alert('Error saving: ' + ((response && response.data && response.data.message) || 'Unknown error'));
4114 }
4115 },
4116 error: function() {
4117 feedbackContainer.remove();
4118 refreshGroupToggle($group);
4119 alert('Error saving: request failed');
4120 },
4121 complete: function() {
4122 $toggle.prop('disabled', false);
4123 }
4124 });
4125 });
4126 });
4127