PluginProbe
MxChat – AI Chatbot & Content Generation for WordPress / 3.2.25
MxChat – AI Chatbot & Content Generation for WordPress v3.2.25
3.2.25 3.2.24 3.2.23 3.2.22 3.2.21 3.2.20 3.2.19 3.2.18 3.2.17 3.2.16 3.2.15 3.2.14 3.2.12 3.2.13 3.2.11 3.2.10 3.2.9 3.2.8 3.2.7 3.2.6 3.2.5 3.2.4 3.2.3 3.2.2 3.2.1 All 156 releases
← All changes | js/mxchat-admin.js +381 -9 3.2.14 → 3.2.25 View file →
@@ -541,9 +541,11 @@
541 541 // each editor folds them into its own hidden live_agent_schedule_<channel>
542 542 // input and fires ONE change, which this same handler then saves. Without
543 543 // the exclusion each keystroke would POST a nameless field the server can
544 544 // only reject.
545 - $autosaveSections.find('input, textarea, select').not('#model, #openrouter_selected_model, .mxchat-la-field').on('change', function() {
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() {
546 548 const $field = $(this);
547 549 const name = $field.attr('name');
548 550
549 551 // Debounce rate limit fields to prevent UI freezing from rapid changes
@@ -1086,23 +1088,25 @@
1086 1088 openrouter: [
1087 1089 { value: 'openrouter', label: 'OpenRouter', description: 'Access 100+ models from multiple providers (add API key to browse)' }
1088 1090 ],
1089 1091 gemini: [
1090 - { value: 'gemini-3.5-flash', label: 'Gemini 3.5 Flash', description: 'Stable — newest Flash generation, recommended default' },
1092 + { value: 'gemini-3.8-flash', label: 'Gemini 3.8 Flash', description: 'Stable — newest Flash generation, recommended default' },
1091 1093 ],
1092 1094 openai: [
1093 - { value: 'gpt-5.1-chat-latest', label: 'GPT-5.1 Chat Latest', description: 'Recommended for most use cases' },
1095 + { value: 'gpt-6-astra', label: 'GPT-6 Astra', description: 'Recommended — newest OpenAI flagship; strongest reasoning, coding and agentic work' },
1094 1096 ],
1095 1097 claude: [
1096 1098 { value: 'claude-fable-5', label: 'Claude Fable 5', description: 'Latest Flagship — newest and most capable Anthropic model' },
1097 - { 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' },
1098 1101 { value: 'claude-opus-4-7', label: 'Claude Opus 4.7', description: 'Previous Anthropic flagship model' },
1099 1102 ],
1100 1103 xai: [
1101 - { 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' },
1102 1105 ],
1103 1106 deepseek: [
1104 - { 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' },
1105 1109 ],
1106 1110 custom: [
1107 1111 { value: 'custom-provider', label: 'Custom Provider', description: 'OpenAI-compatible local LLM — configure in API Keys tab' },
1108 1112 ],
@@ -1157,9 +1161,17 @@
1157 1161 $modelSelectorButton.on('click', function() {
1158 1162 $('#mxchat_model_selector_modal').show();
1159 1163 window.populateModelsGrid('', 'all');
1160 1164 });
1161 -
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 +
1162 1174 $('.mxchat-model-selector-modal-close, #mxchat_cancel_model_selection').on('click', function() {
1163 1175 $('#mxchat_model_selector_modal').hide();
1164 1176 });
1165 1177
@@ -1743,9 +1755,33 @@
1743 1755 });
1744 1756
1745 1757 // Replace the select dropdown with a button
1746 1758 $embeddingModelSelect.hide().after($embeddingModelSelectorButton);
1747 -
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 +
1748 1784 // Update button text to show currently selected model
1749 1785 function updateButtonText() {
1750 1786 const selectedModel = $embeddingModelSelect.val();
1751 1787 const selectedModelText = $embeddingModelSelect.find('option:selected').text();
@@ -1786,8 +1822,13 @@
1786 1822 `;
1787 1823
1788 1824 // Use jQuery's append to ensure it doesn't clash with existing modals
1789 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);
1790 1831
1791 1832 // Populate models grid
1792 1833 function populateEmbeddingModelsGrid(filter = '', category = 'all') {
1793 1834 const $grid = $('#mxchat_embedding_models_grid');
@@ -3059,8 +3100,14 @@
3059 3100
3060 3101 // Hide all status messages
3061 3102 $('.mxchat-embedding-api-status').hide();
3062 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 +
3063 3110 // Return early if no model is selected
3064 3111 if (!selectedModel) {
3065 3112 return;
3066 3113 }
@@ -3750,5 +3797,330 @@
3750 3797 window.alert('Reset failed. Please try again.');
3751 3798 }
3752 3799 });
3753 3800 });
3754 -});
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 +});