| @@ -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 |
| @@ -994,8 +1060,9 @@ | ||
| 994 | 1060 | <button class="mxchat-model-category-btn" data-category="openai">OpenAI</button> |
| 995 | 1061 | <button class="mxchat-model-category-btn" data-category="claude">Claude</button> |
| 996 | 1062 | <button class="mxchat-model-category-btn" data-category="xai">X.AI</button> |
| 997 | 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> | |
| 998 | 1065 | </div> |
| 999 | 1066 | <div class="mxchat-model-selector-grid" id="mxchat_models_grid"></div> |
| 1000 | 1067 | </div> |
| 1001 | 1068 | <div class="mxchat-model-selector-modal-footer"> |
| @@ -1010,59 +1077,40 @@ | ||
| 1010 | 1077 | // MOVE THIS OUTSIDE - Make it a property of the window object so it's accessible globally |
| 1011 | 1078 | window.populateModelsGrid = function(filter = '', category = 'all') { |
| 1012 | 1079 | const $grid = $('#mxchat_models_grid'); |
| 1013 | 1080 | $grid.empty(); |
| 1014 | - | |
| 1015 | - const models = { | |
| 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 : { | |
| 1016 | 1088 | openrouter: [ |
| 1017 | 1089 | { value: 'openrouter', label: 'OpenRouter', description: 'Access 100+ models from multiple providers (add API key to browse)' } |
| 1018 | 1090 | ], |
| 1019 | 1091 | gemini: [ |
| 1020 | - { value: 'gemini-3-pro-preview', label: 'Gemini 3 Pro', description: 'Most intelligent model - multimodal understanding & agentic' }, | |
| 1021 | - { value: 'gemini-3-flash-preview', label: 'Gemini 3 Flash', description: 'Most balanced - speed, scale & frontier intelligence' }, | |
| 1022 | - { value: 'gemini-2.5-pro', label: 'Gemini 2.5 Pro', description: 'Advanced thinking - code, math, STEM & long context' }, | |
| 1023 | - { value: 'gemini-2.5-flash', label: 'Gemini 2.5 Flash', description: 'Best price-performance with thinking capabilities' }, | |
| 1024 | - { value: 'gemini-2.5-flash-lite', label: 'Gemini 2.5 Flash-Lite', description: 'Ultra fast - optimized for cost & high throughput' }, | |
| 1025 | - { value: 'gemini-2.0-flash', label: 'Gemini 2.0 Flash', description: 'Deprecated March 2026' }, | |
| 1026 | - { value: 'gemini-2.0-flash-lite', label: 'Gemini 2.0 Flash-Lite', description: 'Deprecated March 2026' }, | |
| 1027 | - { value: 'gemini-1.5-pro', label: 'Gemini 1.5 Pro', description: 'Deprecated September 2025' }, | |
| 1028 | - { value: 'gemini-1.5-flash', label: 'Gemini 1.5 Flash', description: 'Deprecated September 2025' }, | |
| 1092 | + { value: 'gemini-3.5-flash', label: 'Gemini 3.5 Flash', description: 'Stable — newest Flash generation, recommended default' }, | |
| 1029 | 1093 | ], |
| 1030 | 1094 | openai: [ |
| 1031 | - { value: 'gpt-5.4', label: 'GPT-5.4', description: 'Flagship reasoning and coding model' }, | |
| 1032 | - { value: 'gpt-5.4-mini', label: 'GPT-5.4 Mini', description: 'Fast and affordable with 400K context' }, | |
| 1033 | - { value: 'gpt-5.4-nano', label: 'GPT-5.4 Nano', description: 'Fastest and cheapest for lightweight tasks' }, | |
| 1034 | - { value: 'gpt-5.3-chat-latest', label: 'GPT-5.3 Chat', description: 'Optimized for natural conversations with reduced hallucinations' }, | |
| 1035 | - { value: 'gpt-5.2', label: 'GPT-5.2', description: 'Best general-purpose & agentic model with fast responses' }, | |
| 1036 | - { value: 'gpt-5.1-chat-latest', label: 'GPT-5.1 Chat Latest', description: 'Recommended for most use cases' }, | |
| 1037 | - { value: 'gpt-5.1-2025-11-13', label: 'GPT-5.1', description: 'Flagship for coding & agentic tasks with low reasoning (400K context)' }, | |
| 1038 | - { value: 'gpt-5', label: 'GPT-5', description: 'Flagship for coding, reasoning, and agentic tasks across domains' }, | |
| 1039 | - { value: 'gpt-5-mini', label: 'GPT-5 Mini', description: 'Fast and lightweight' }, | |
| 1040 | - { value: 'gpt-5-nano', label: 'GPT-5 Nano', description: 'Fastest and cheapest; ideal for summarization and classification' }, | |
| 1095 | + { value: 'gpt-5.6-sol', label: 'GPT-5.6 Sol', description: 'Recommended — newest OpenAI flagship for reasoning, coding and chat' }, | |
| 1041 | 1096 | ], |
| 1042 | 1097 | claude: [ |
| 1043 | - { value: 'claude-opus-4-6', label: 'Claude Opus 4.6', description: 'Most capable Claude model - recommended' }, | |
| 1044 | - { value: 'claude-sonnet-4-6', label: 'Claude Sonnet 4.6', description: 'Latest Sonnet - excellent balance of speed and capability' }, | |
| 1045 | - { value: 'claude-opus-4-5', label: 'Claude Opus 4.5', description: 'Highly capable for complex tasks' }, | |
| 1046 | - { value: 'claude-sonnet-4-5-20250929', label: 'Claude Sonnet 4.5', description: 'Best for complex agents and coding' }, | |
| 1047 | - { value: 'claude-opus-4-1-20250805', label: 'Claude Opus 4.1', description: 'Exceptional for specialized complex tasks' }, | |
| 1048 | - { value: 'claude-haiku-4-5-20251001', label: 'Claude Haiku 4.5', description: 'Fastest and most intelligent Haiku' }, | |
| 1049 | - { value: 'claude-opus-4-20250514', label: 'Claude 4 Opus', description: 'Complex tasks' }, | |
| 1050 | - { value: 'claude-sonnet-4-20250514', label: 'Claude 4 Sonnet', description: 'High performance' }, | |
| 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' }, | |
| 1051 | 1102 | ], |
| 1052 | 1103 | xai: [ |
| 1053 | - { value: 'grok-4-1-fast-reasoning', label: 'Grok 4.1 Fast (Reasoning)', description: '2M context window and reasoning' }, | |
| 1054 | - { value: 'grok-4-1-fast-non-reasoning', label: 'Grok 4.1 Fast (Non-Reasoning)', description: '2M context window and faster responses' }, | |
| 1055 | - { value: 'grok-4-0709', label: 'Grok 4', description: 'Latest flagship model - unparalleled performance in natural language, math and reasoning' }, | |
| 1056 | - { value: 'grok-3-beta', label: 'Grok-3', description: 'Powerful model with 131K context' }, | |
| 1057 | - { value: 'grok-3-fast-beta', label: 'Grok-3 Fast', description: 'High performance with faster responses' }, | |
| 1058 | - { value: 'grok-3-mini-beta', label: 'Grok-3 Mini', description: 'Affordable model with good performance' }, | |
| 1059 | - { value: 'grok-3-mini-fast-beta', label: 'Grok-3 Mini Fast', description: 'Quick and cost-effective' }, | |
| 1060 | - { value: 'grok-2', label: 'Grok 2', description: 'Latest X.AI model' }, | |
| 1104 | + { value: 'grok-4.6', label: 'Grok 4.6', description: 'Newest xAI flagship — 500K context, accepts image input' }, | |
| 1061 | 1105 | ], |
| 1062 | 1106 | deepseek: [ |
| 1063 | - { 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' }, | |
| 1064 | 1109 | ], |
| 1110 | + custom: [ | |
| 1111 | + { value: 'custom-provider', label: 'Custom Provider', description: 'OpenAI-compatible local LLM — configure in API Keys tab' }, | |
| 1112 | + ], | |
| 1065 | 1113 | }; |
| 1066 | 1114 | |
| 1067 | 1115 | let allModels = []; |
| 1068 | 1116 | Object.keys(models).forEach(key => { |
| @@ -1103,8 +1151,9 @@ | ||
| 1103 | 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>'; |
| 1104 | 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>'; |
| 1105 | 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>'; |
| 1106 | 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>'; | |
| 1107 | 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>'; |
| 1108 | 1157 | return '<span class="dashicons dashicons-admin-generic mxchat-model-icon-generic"></span>'; |
| 1109 | 1158 | } |
| 1110 | 1159 | |
| @@ -1112,9 +1161,17 @@ | ||
| 1112 | 1161 | $modelSelectorButton.on('click', function() { |
| 1113 | 1162 | $('#mxchat_model_selector_modal').show(); |
| 1114 | 1163 | window.populateModelsGrid('', 'all'); |
| 1115 | 1164 | }); |
| 1116 | - | |
| 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 | + | |
| 1117 | 1174 | $('.mxchat-model-selector-modal-close, #mxchat_cancel_model_selection').on('click', function() { |
| 1118 | 1175 | $('#mxchat_model_selector_modal').hide(); |
| 1119 | 1176 | }); |
| 1120 | 1177 | |
| @@ -1617,8 +1674,71 @@ | ||
| 1617 | 1674 | }); |
| 1618 | 1675 | |
| 1619 | 1676 | } |
| 1620 | 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 | + | |
| 1621 | 1741 | // Embedding model selector - completely separate from chat model selector |
| 1622 | 1742 | function setupMxChatEmbeddingModelSelector() { |
| 1623 | 1743 | const $embeddingModelSelect = $('#embedding_model'); |
| 1624 | 1744 | |
| @@ -1635,9 +1755,33 @@ | ||
| 1635 | 1755 | }); |
| 1636 | 1756 | |
| 1637 | 1757 | // Replace the select dropdown with a button |
| 1638 | 1758 | $embeddingModelSelect.hide().after($embeddingModelSelectorButton); |
| 1639 | - | |
| 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 | + | |
| 1640 | 1784 | // Update button text to show currently selected model |
| 1641 | 1785 | function updateButtonText() { |
| 1642 | 1786 | const selectedModel = $embeddingModelSelect.val(); |
| 1643 | 1787 | const selectedModelText = $embeddingModelSelect.find('option:selected').text(); |
| @@ -1678,8 +1822,13 @@ | ||
| 1678 | 1822 | `; |
| 1679 | 1823 | |
| 1680 | 1824 | // Use jQuery's append to ensure it doesn't clash with existing modals |
| 1681 | 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); | |
| 1682 | 1831 | |
| 1683 | 1832 | // Populate models grid |
| 1684 | 1833 | function populateEmbeddingModelsGrid(filter = '', category = 'all') { |
| 1685 | 1834 | const $grid = $('#mxchat_embedding_models_grid'); |
| @@ -1799,25 +1948,53 @@ | ||
| 1799 | 1948 | const activeCategory = $('.mxchat-embedding-model-selector-categories .mxchat-embedding-model-category-btn.active').data('category'); |
| 1800 | 1949 | populateEmbeddingModelsGrid(searchTerm, activeCategory); |
| 1801 | 1950 | }); |
| 1802 | 1951 | |
| 1803 | - // Use a direct selector to avoid conflicts with other card elements | |
| 1804 | - $(document).on('click.embeddingModelSelector', '.mxchat-embedding-model-selector-grid .mxchat-embedding-model-selector-card', function(e) { | |
| 1805 | - e.stopPropagation(); // Prevent event bubbling | |
| 1806 | - const modelValue = $(this).data('value'); | |
| 1807 | - | |
| 1808 | - // Important: Only update this specific select element | |
| 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) { | |
| 1809 | 1956 | $embeddingModelSelect.val(modelValue); |
| 1810 | - | |
| 1811 | - // Manually trigger change only on this element | |
| 1812 | 1957 | const changeEvent = new Event('change', { bubbles: true }); |
| 1813 | 1958 | $embeddingModelSelect[0].dispatchEvent(changeEvent); |
| 1814 | - | |
| 1815 | - // Update button text | |
| 1816 | 1959 | updateButtonText(); |
| 1817 | - | |
| 1818 | - // Hide modal | |
| 1819 | 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; | |
| 1820 | 1997 | }); |
| 1821 | 1998 | |
| 1822 | 1999 | // Close modal when clicking outside - use namespaced events |
| 1823 | 2000 | $(window).on('click.embeddingModelSelector', function(event) { |
| @@ -2011,8 +2188,55 @@ | ||
| 2011 | 2188 | } |
| 2012 | 2189 | }); |
| 2013 | 2190 | } |
| 2014 | 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 | + | |
| 2015 | 2239 | // Function to adjust the textarea height to content |
| 2016 | 2240 | function adjustTextareaHeight() { |
| 2017 | 2241 | this.style.height = 'auto'; // Reset to auto to calculate scrollHeight |
| 2018 | 2242 | this.style.height = this.scrollHeight + 'px'; // Expand to content height |
| @@ -2228,14 +2452,12 @@ | ||
| 2228 | 2452 | setTimeout(() => { |
| 2229 | 2453 | noticeContainer.remove(); |
| 2230 | 2454 | }, 300); |
| 2231 | 2455 | }); |
| 2232 | - | |
| 2233 | - // Click outside to close | |
| 2234 | - noticeContainer.addEventListener('click', function(e) { | |
| 2235 | - if (e.target === noticeContainer) { | |
| 2236 | - closeButton.click(); | |
| 2237 | - } | |
| 2456 | + | |
| 2457 | + // Click outside to close (drag-safe) | |
| 2458 | + mxchatDragSafeOverlayClose(noticeContainer, function() { | |
| 2459 | + closeButton.click(); | |
| 2238 | 2460 | }); |
| 2239 | 2461 | |
| 2240 | 2462 | // Show with animation |
| 2241 | 2463 | setTimeout(() => { |
| @@ -2281,14 +2503,12 @@ | ||
| 2281 | 2503 | setTimeout(() => { |
| 2282 | 2504 | noticeContainer.remove(); |
| 2283 | 2505 | }, 300); |
| 2284 | 2506 | }); |
| 2285 | - | |
| 2286 | - // Click outside to close | |
| 2287 | - noticeContainer.addEventListener('click', function(e) { | |
| 2288 | - if (e.target === noticeContainer) { | |
| 2289 | - closeButton.click(); | |
| 2290 | - } | |
| 2507 | + | |
| 2508 | + // Click outside to close (drag-safe) | |
| 2509 | + mxchatDragSafeOverlayClose(noticeContainer, function() { | |
| 2510 | + closeButton.click(); | |
| 2291 | 2511 | }); |
| 2292 | 2512 | |
| 2293 | 2513 | // Show with animation |
| 2294 | 2514 | setTimeout(() => { |
| @@ -2457,43 +2677,40 @@ | ||
| 2457 | 2677 | modal.classList.add('active'); |
| 2458 | 2678 | }); |
| 2459 | 2679 | |
| 2460 | 2680 | // Set up close handlers |
| 2681 | + let detachEsc = null; | |
| 2461 | 2682 | const closeModal = () => { |
| 2462 | 2683 | //console.log('Closing modal'); |
| 2684 | + if (detachEsc) { | |
| 2685 | + detachEsc(); | |
| 2686 | + detachEsc = null; | |
| 2687 | + } | |
| 2463 | 2688 | modal.classList.remove('active'); |
| 2464 | 2689 | setTimeout(() => { |
| 2465 | 2690 | modal.style.display = 'none'; |
| 2466 | 2691 | }, 300); // Match the CSS transition time |
| 2467 | 2692 | }; |
| 2468 | - | |
| 2693 | + | |
| 2694 | + // This modal holds edits: close only on explicit controls, confirming when dirty. | |
| 2695 | + const guardedClose = mxchatGuardedClose(modal, closeModal); | |
| 2696 | + | |
| 2469 | 2697 | // Close button handler |
| 2470 | 2698 | const closeBtn = modal.querySelector('.mxchat-modal-close'); |
| 2471 | 2699 | if (closeBtn) { |
| 2472 | - closeBtn.onclick = closeModal; | |
| 2700 | + closeBtn.onclick = guardedClose; | |
| 2473 | 2701 | } |
| 2474 | - | |
| 2702 | + | |
| 2475 | 2703 | // Cancel button handler |
| 2476 | 2704 | const cancelBtns = modal.querySelectorAll('.mxchat-modal-cancel'); |
| 2477 | 2705 | if (cancelBtns) { |
| 2478 | 2706 | cancelBtns.forEach(btn => { |
| 2479 | - btn.onclick = closeModal; | |
| 2707 | + btn.onclick = guardedClose; | |
| 2480 | 2708 | }); |
| 2481 | 2709 | } |
| 2482 | - | |
| 2483 | - // Click outside modal to close | |
| 2484 | - modal.onclick = (e) => { | |
| 2485 | - if (e.target === modal) { | |
| 2486 | - closeModal(); | |
| 2487 | - } | |
| 2488 | - }; | |
| 2489 | - | |
| 2710 | + | |
| 2490 | 2711 | // Escape key to close modal |
| 2491 | - document.addEventListener('keydown', function(e) { | |
| 2492 | - if (e.key === 'Escape' && modal.classList.contains('active')) { | |
| 2493 | - closeModal(); | |
| 2494 | - } | |
| 2495 | - }, { once: true }); | |
| 2712 | + detachEsc = mxchatBindEscClose(modal, guardedClose); | |
| 2496 | 2713 | |
| 2497 | 2714 | // Focus appropriate field based on current step |
| 2498 | 2715 | if (isEdit || actionStep2.classList.contains('active')) { |
| 2499 | 2716 | if (labelField) labelField.focus(); |
| @@ -2599,15 +2816,12 @@ | ||
| 2599 | 2816 | closeModal(e); |
| 2600 | 2817 | }); |
| 2601 | 2818 | } |
| 2602 | 2819 | |
| 2603 | - // Close on backdrop click ONLY (not on hover) | |
| 2604 | - modal.addEventListener('click', function(e) { | |
| 2605 | - // Only close if clicking directly on the overlay, not on child elements | |
| 2606 | - if (e.target === modal) { | |
| 2607 | - closeModal(e); | |
| 2608 | - } | |
| 2609 | - }); | |
| 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); | |
| 2610 | 2824 | |
| 2611 | 2825 | // Prevent modal content clicks from closing the modal |
| 2612 | 2826 | if (modalContent) { |
| 2613 | 2827 | modalContent.addEventListener('click', function(e) { |
| @@ -2886,8 +3100,14 @@ | ||
| 2886 | 3100 | |
| 2887 | 3101 | // Hide all status messages |
| 2888 | 3102 | $('.mxchat-embedding-api-status').hide(); |
| 2889 | 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 | + | |
| 2890 | 3110 | // Return early if no model is selected |
| 2891 | 3111 | if (!selectedModel) { |
| 2892 | 3112 | return; |
| 2893 | 3113 | } |
| @@ -2979,18 +3199,20 @@ | ||
| 2979 | 3199 | var $unavailableMessage = $('#web-search-unavailable-message'); |
| 2980 | 3200 | |
| 2981 | 3201 | if (!$wrapper.length) return; // Element doesn't exist |
| 2982 | 3202 | |
| 2983 | - // Get the list of supported OpenAI models from data attribute | |
| 3203 | + // Get the list of web-search-capable models from data attributes (OpenAI + Gemini) | |
| 2984 | 3204 | var openaiModelsAttr = $wrapper.data('openai-models'); |
| 3205 | + var geminiModelsAttr = $wrapper.data('gemini-models'); | |
| 2985 | 3206 | var unsupportedModelsAttr = $wrapper.data('unsupported-models'); |
| 2986 | 3207 | |
| 2987 | - var openaiModels = openaiModelsAttr ? openaiModelsAttr.split(',') : []; | |
| 2988 | - var unsupportedModels = unsupportedModelsAttr ? unsupportedModelsAttr.split(',') : []; | |
| 3208 | + var openaiModels = openaiModelsAttr ? String(openaiModelsAttr).split(',') : []; | |
| 3209 | + var geminiModels = geminiModelsAttr ? String(geminiModelsAttr).split(',') : []; | |
| 3210 | + var unsupportedModels = unsupportedModelsAttr ? String(unsupportedModelsAttr).split(',') : []; | |
| 2989 | 3211 | |
| 2990 | - // Check if selected model is an OpenAI model that supports web search | |
| 2991 | - var isOpenAI = openaiModels.includes(selectedModel); | |
| 2992 | - var isSupported = isOpenAI && !unsupportedModels.includes(selectedModel); | |
| 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); | |
| 2993 | 3215 | |
| 2994 | 3216 | if (isSupported) { |
| 2995 | 3217 | $wrapper.show(); |
| 2996 | 3218 | $unavailableMessage.hide(); |
| @@ -3532,5 +3754,373 @@ | ||
| 3532 | 3754 | // Also refresh when switching to optimization tab |
| 3533 | 3755 | $(document).on('click', '[data-section="optimization"]', function() { |
| 3534 | 3756 | setTimeout(refreshDebugLog, 100); |
| 3535 | 3757 | }); |
| 3536 | -}); | |
| 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 | +}); | |