| @@ -10,8 +10,65 @@ | ||
| 10 | 10 | timeout = setTimeout(later, wait); |
| 11 | 11 | }; |
| 12 | 12 | } |
| 13 | 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 | + | |
| 14 | 71 | // Helper function to open edit modal for intents/actions |
| 15 | 72 | function mxchatOpenEditModal(intentId, phrases) { |
| 16 | 73 | const modal = document.getElementById('mxchat-edit-modal'); |
| 17 | 74 | if (!modal) return; |
| @@ -30,9 +87,14 @@ | ||
| 30 | 87 | modal.classList.add('active'); |
| 31 | 88 | }); |
| 32 | 89 | |
| 33 | 90 | // Set up close handlers |
| 91 | + let detachEsc = null; | |
| 34 | 92 | const closeModal = () => { |
| 93 | + if (detachEsc) { | |
| 94 | + detachEsc(); | |
| 95 | + detachEsc = null; | |
| 96 | + } | |
| 35 | 97 | modal.classList.remove('active'); |
| 36 | 98 | setTimeout(() => { |
| 37 | 99 | modal.style.display = 'none'; |
| 38 | 100 | }, 300); // Match the CSS transition time |
| @@ -37,26 +99,25 @@ | ||
| 37 | 99 | modal.style.display = 'none'; |
| 38 | 100 | }, 300); // Match the CSS transition time |
| 39 | 101 | }; |
| 40 | 102 | |
| 103 | + // This modal holds edits: close only on explicit controls, confirming when dirty. | |
| 104 | + const guardedClose = mxchatGuardedClose(modal, closeModal); | |
| 105 | + | |
| 41 | 106 | // Close button handler |
| 42 | 107 | const closeBtn = modal.querySelector('.mxchat-modal-close'); |
| 43 | 108 | if (closeBtn) { |
| 44 | - closeBtn.onclick = closeModal; | |
| 109 | + closeBtn.onclick = guardedClose; | |
| 45 | 110 | } |
| 46 | 111 | |
| 47 | 112 | // Cancel button handler |
| 48 | 113 | const cancelBtn = modal.querySelector('.mxchat-modal-cancel'); |
| 49 | 114 | if (cancelBtn) { |
| 50 | - cancelBtn.onclick = closeModal; | |
| 115 | + cancelBtn.onclick = guardedClose; | |
| 51 | 116 | } |
| 52 | 117 | |
| 53 | - // Click outside modal to close | |
| 54 | - modal.onclick = (e) => { | |
| 55 | - if (e.target === modal) { | |
| 56 | - closeModal(); | |
| 57 | - } | |
| 58 | - }; | |
| 118 | + // Escape key to close modal | |
| 119 | + detachEsc = mxchatBindEscClose(modal, guardedClose); | |
| 59 | 120 | |
| 60 | 121 | // Focus the textarea |
| 61 | 122 | phrasesField.focus(); |
| 62 | 123 | } |
| @@ -168,9 +229,14 @@ | ||
| 168 | 229 | modal.classList.add('active'); |
| 169 | 230 | }); |
| 170 | 231 | |
| 171 | 232 | // Set up close handlers |
| 233 | + let detachEsc = null; | |
| 172 | 234 | const closeModal = () => { |
| 235 | + if (detachEsc) { | |
| 236 | + detachEsc(); | |
| 237 | + detachEsc = null; | |
| 238 | + } | |
| 173 | 239 | modal.classList.remove('active'); |
| 174 | 240 | setTimeout(() => { |
| 175 | 241 | modal.style.display = 'none'; |
| 176 | 242 | }, 300); // Match the CSS transition time |
| @@ -175,33 +241,25 @@ | ||
| 175 | 241 | modal.style.display = 'none'; |
| 176 | 242 | }, 300); // Match the CSS transition time |
| 177 | 243 | }; |
| 178 | 244 | |
| 245 | + // This modal holds edits: close only on explicit controls, confirming when dirty. | |
| 246 | + const guardedClose = mxchatGuardedClose(modal, closeModal); | |
| 247 | + | |
| 179 | 248 | // Close button handler |
| 180 | 249 | const closeBtn = modal.querySelector('.mxchat-modal-close'); |
| 181 | 250 | if (closeBtn) { |
| 182 | - closeBtn.onclick = closeModal; | |
| 251 | + closeBtn.onclick = guardedClose; | |
| 183 | 252 | } |
| 184 | 253 | |
| 185 | 254 | // Cancel button handler |
| 186 | 255 | const cancelBtn = modal.querySelector('.mxchat-modal-cancel'); |
| 187 | 256 | if (cancelBtn) { |
| 188 | - cancelBtn.onclick = closeModal; | |
| 257 | + cancelBtn.onclick = guardedClose; | |
| 189 | 258 | } |
| 190 | 259 | |
| 191 | - // Click outside modal to close | |
| 192 | - modal.onclick = (e) => { | |
| 193 | - if (e.target === modal) { | |
| 194 | - closeModal(); | |
| 195 | - } | |
| 196 | - }; | |
| 197 | - | |
| 198 | 260 | // Escape key to close modal |
| 199 | - document.addEventListener('keydown', function(e) { | |
| 200 | - if (e.key === 'Escape' && modal.classList.contains('active')) { | |
| 201 | - closeModal(); | |
| 202 | - } | |
| 203 | - }, { once: true }); | |
| 261 | + detachEsc = mxchatBindEscClose(modal, guardedClose); | |
| 204 | 262 | |
| 205 | 263 | // Focus the first field |
| 206 | 264 | labelField.focus(); |
| 207 | 265 | |
| @@ -477,9 +535,17 @@ | ||
| 477 | 535 | } |
| 478 | 536 | }); |
| 479 | 537 | } |
| 480 | 538 | |
| 481 | - $autosaveSections.find('input, textarea, select').not('#model, #openrouter_selected_model').on('change', function() { | |
| 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() { | |
| 482 | 548 | const $field = $(this); |
| 483 | 549 | const name = $field.attr('name'); |
| 484 | 550 | |
| 485 | 551 | // Debounce rate limit fields to prevent UI freezing from rapid changes |
| @@ -1025,20 +1091,22 @@ | ||
| 1025 | 1091 | gemini: [ |
| 1026 | 1092 | { value: 'gemini-3.5-flash', label: 'Gemini 3.5 Flash', description: 'Stable — newest Flash generation, recommended default' }, |
| 1027 | 1093 | ], |
| 1028 | 1094 | openai: [ |
| 1029 | - { value: 'gpt-5.1-chat-latest', label: 'GPT-5.1 Chat Latest', description: 'Recommended for most use cases' }, | |
| 1095 | + { value: 'gpt-5.6-sol', label: 'GPT-5.6 Sol', description: 'Recommended — newest OpenAI flagship for reasoning, coding and chat' }, | |
| 1030 | 1096 | ], |
| 1031 | 1097 | claude: [ |
| 1032 | 1098 | { value: 'claude-fable-5', label: 'Claude Fable 5', description: 'Latest Flagship — newest and most capable Anthropic model' }, |
| 1033 | - { value: 'claude-opus-4-8', label: 'Claude Opus 4.8', description: 'Previous flagship — most capable Opus-tier 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' }, | |
| 1034 | 1101 | { value: 'claude-opus-4-7', label: 'Claude Opus 4.7', description: 'Previous Anthropic flagship model' }, |
| 1035 | 1102 | ], |
| 1036 | 1103 | xai: [ |
| 1037 | - { value: 'grok-4-0709', label: 'Grok 4', description: 'Latest flagship model' }, | |
| 1104 | + { value: 'grok-4.6', label: 'Grok 4.6', description: 'Newest xAI flagship — 500K context, accepts image input' }, | |
| 1038 | 1105 | ], |
| 1039 | 1106 | deepseek: [ |
| 1040 | - { value: 'deepseek-chat', label: 'DeepSeek-V3', description: 'Advanced AI assistant' }, | |
| 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' }, | |
| 1041 | 1109 | ], |
| 1042 | 1110 | custom: [ |
| 1043 | 1111 | { value: 'custom-provider', label: 'Custom Provider', description: 'OpenAI-compatible local LLM — configure in API Keys tab' }, |
| 1044 | 1112 | ], |
| @@ -1093,9 +1161,17 @@ | ||
| 1093 | 1161 | $modelSelectorButton.on('click', function() { |
| 1094 | 1162 | $('#mxchat_model_selector_modal').show(); |
| 1095 | 1163 | window.populateModelsGrid('', 'all'); |
| 1096 | 1164 | }); |
| 1097 | - | |
| 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 | + | |
| 1098 | 1174 | $('.mxchat-model-selector-modal-close, #mxchat_cancel_model_selection').on('click', function() { |
| 1099 | 1175 | $('#mxchat_model_selector_modal').hide(); |
| 1100 | 1176 | }); |
| 1101 | 1177 | |
| @@ -1679,9 +1755,33 @@ | ||
| 1679 | 1755 | }); |
| 1680 | 1756 | |
| 1681 | 1757 | // Replace the select dropdown with a button |
| 1682 | 1758 | $embeddingModelSelect.hide().after($embeddingModelSelectorButton); |
| 1683 | - | |
| 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 | + | |
| 1684 | 1784 | // Update button text to show currently selected model |
| 1685 | 1785 | function updateButtonText() { |
| 1686 | 1786 | const selectedModel = $embeddingModelSelect.val(); |
| 1687 | 1787 | const selectedModelText = $embeddingModelSelect.find('option:selected').text(); |
| @@ -1722,8 +1822,13 @@ | ||
| 1722 | 1822 | `; |
| 1723 | 1823 | |
| 1724 | 1824 | // Use jQuery's append to ensure it doesn't clash with existing modals |
| 1725 | 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); | |
| 1726 | 1831 | |
| 1727 | 1832 | // Populate models grid |
| 1728 | 1833 | function populateEmbeddingModelsGrid(filter = '', category = 'all') { |
| 1729 | 1834 | const $grid = $('#mxchat_embedding_models_grid'); |
| @@ -2083,8 +2188,55 @@ | ||
| 2083 | 2188 | } |
| 2084 | 2189 | }); |
| 2085 | 2190 | } |
| 2086 | 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 | + | |
| 2087 | 2239 | // Function to adjust the textarea height to content |
| 2088 | 2240 | function adjustTextareaHeight() { |
| 2089 | 2241 | this.style.height = 'auto'; // Reset to auto to calculate scrollHeight |
| 2090 | 2242 | this.style.height = this.scrollHeight + 'px'; // Expand to content height |
| @@ -2300,14 +2452,12 @@ | ||
| 2300 | 2452 | setTimeout(() => { |
| 2301 | 2453 | noticeContainer.remove(); |
| 2302 | 2454 | }, 300); |
| 2303 | 2455 | }); |
| 2304 | - | |
| 2305 | - // Click outside to close | |
| 2306 | - noticeContainer.addEventListener('click', function(e) { | |
| 2307 | - if (e.target === noticeContainer) { | |
| 2308 | - closeButton.click(); | |
| 2309 | - } | |
| 2456 | + | |
| 2457 | + // Click outside to close (drag-safe) | |
| 2458 | + mxchatDragSafeOverlayClose(noticeContainer, function() { | |
| 2459 | + closeButton.click(); | |
| 2310 | 2460 | }); |
| 2311 | 2461 | |
| 2312 | 2462 | // Show with animation |
| 2313 | 2463 | setTimeout(() => { |
| @@ -2353,14 +2503,12 @@ | ||
| 2353 | 2503 | setTimeout(() => { |
| 2354 | 2504 | noticeContainer.remove(); |
| 2355 | 2505 | }, 300); |
| 2356 | 2506 | }); |
| 2357 | - | |
| 2358 | - // Click outside to close | |
| 2359 | - noticeContainer.addEventListener('click', function(e) { | |
| 2360 | - if (e.target === noticeContainer) { | |
| 2361 | - closeButton.click(); | |
| 2362 | - } | |
| 2507 | + | |
| 2508 | + // Click outside to close (drag-safe) | |
| 2509 | + mxchatDragSafeOverlayClose(noticeContainer, function() { | |
| 2510 | + closeButton.click(); | |
| 2363 | 2511 | }); |
| 2364 | 2512 | |
| 2365 | 2513 | // Show with animation |
| 2366 | 2514 | setTimeout(() => { |
| @@ -2529,43 +2677,40 @@ | ||
| 2529 | 2677 | modal.classList.add('active'); |
| 2530 | 2678 | }); |
| 2531 | 2679 | |
| 2532 | 2680 | // Set up close handlers |
| 2681 | + let detachEsc = null; | |
| 2533 | 2682 | const closeModal = () => { |
| 2534 | 2683 | //console.log('Closing modal'); |
| 2684 | + if (detachEsc) { | |
| 2685 | + detachEsc(); | |
| 2686 | + detachEsc = null; | |
| 2687 | + } | |
| 2535 | 2688 | modal.classList.remove('active'); |
| 2536 | 2689 | setTimeout(() => { |
| 2537 | 2690 | modal.style.display = 'none'; |
| 2538 | 2691 | }, 300); // Match the CSS transition time |
| 2539 | 2692 | }; |
| 2540 | - | |
| 2693 | + | |
| 2694 | + // This modal holds edits: close only on explicit controls, confirming when dirty. | |
| 2695 | + const guardedClose = mxchatGuardedClose(modal, closeModal); | |
| 2696 | + | |
| 2541 | 2697 | // Close button handler |
| 2542 | 2698 | const closeBtn = modal.querySelector('.mxchat-modal-close'); |
| 2543 | 2699 | if (closeBtn) { |
| 2544 | - closeBtn.onclick = closeModal; | |
| 2700 | + closeBtn.onclick = guardedClose; | |
| 2545 | 2701 | } |
| 2546 | - | |
| 2702 | + | |
| 2547 | 2703 | // Cancel button handler |
| 2548 | 2704 | const cancelBtns = modal.querySelectorAll('.mxchat-modal-cancel'); |
| 2549 | 2705 | if (cancelBtns) { |
| 2550 | 2706 | cancelBtns.forEach(btn => { |
| 2551 | - btn.onclick = closeModal; | |
| 2707 | + btn.onclick = guardedClose; | |
| 2552 | 2708 | }); |
| 2553 | 2709 | } |
| 2554 | - | |
| 2555 | - // Click outside modal to close | |
| 2556 | - modal.onclick = (e) => { | |
| 2557 | - if (e.target === modal) { | |
| 2558 | - closeModal(); | |
| 2559 | - } | |
| 2560 | - }; | |
| 2561 | - | |
| 2710 | + | |
| 2562 | 2711 | // Escape key to close modal |
| 2563 | - document.addEventListener('keydown', function(e) { | |
| 2564 | - if (e.key === 'Escape' && modal.classList.contains('active')) { | |
| 2565 | - closeModal(); | |
| 2566 | - } | |
| 2567 | - }, { once: true }); | |
| 2712 | + detachEsc = mxchatBindEscClose(modal, guardedClose); | |
| 2568 | 2713 | |
| 2569 | 2714 | // Focus appropriate field based on current step |
| 2570 | 2715 | if (isEdit || actionStep2.classList.contains('active')) { |
| 2571 | 2716 | if (labelField) labelField.focus(); |
| @@ -2671,15 +2816,12 @@ | ||
| 2671 | 2816 | closeModal(e); |
| 2672 | 2817 | }); |
| 2673 | 2818 | } |
| 2674 | 2819 | |
| 2675 | - // Close on backdrop click ONLY (not on hover) | |
| 2676 | - modal.addEventListener('click', function(e) { | |
| 2677 | - // Only close if clicking directly on the overlay, not on child elements | |
| 2678 | - if (e.target === modal) { | |
| 2679 | - closeModal(e); | |
| 2680 | - } | |
| 2681 | - }); | |
| 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); | |
| 2682 | 2824 | |
| 2683 | 2825 | // Prevent modal content clicks from closing the modal |
| 2684 | 2826 | if (modalContent) { |
| 2685 | 2827 | modalContent.addEventListener('click', function(e) { |
| @@ -2958,8 +3100,14 @@ | ||
| 2958 | 3100 | |
| 2959 | 3101 | // Hide all status messages |
| 2960 | 3102 | $('.mxchat-embedding-api-status').hide(); |
| 2961 | 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 | + | |
| 2962 | 3110 | // Return early if no model is selected |
| 2963 | 3111 | if (!selectedModel) { |
| 2964 | 3112 | return; |
| 2965 | 3113 | } |
| @@ -3649,5 +3797,330 @@ | ||
| 3649 | 3797 | window.alert('Reset failed. Please try again.'); |
| 3650 | 3798 | } |
| 3651 | 3799 | }); |
| 3652 | 3800 | }); |
| 3653 | -}); | |
| 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 | +}); | |