PluginProbe
MxChat – AI Chatbot & Content Generation for WordPress / 3.2.23
MxChat – AI Chatbot & Content Generation for WordPress v3.2.23
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 +341 -6 3.2.18 → 3.2.23 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,12 +1088,12 @@
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.6-sol', label: 'GPT-5.6 Sol', description: 'Recommended — newest OpenAI flagship for reasoning, coding and chat' },
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 1099 { value: 'claude-opus-5', label: 'Claude Opus 5', description: 'Latest Opus — best for complex agentic and coding work' },
@@ -1098,9 +1100,9 @@
1098 1100 { value: 'claude-opus-4-8', label: 'Claude Opus 4.8', description: 'Previous Opus generation' },
1099 1101 { value: 'claude-opus-4-7', label: 'Claude Opus 4.7', description: 'Previous Anthropic flagship model' },
1100 1102 ],
1101 1103 xai: [
1102 - { 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' },
1103 1105 ],
1104 1106 deepseek: [
1105 1107 { value: 'deepseek-v4-flash', label: 'DeepSeek V4 Flash', description: 'Fast and cost-effective' },
1106 1108 { value: 'deepseek-v4-pro', label: 'DeepSeek V4 Pro', description: 'Most capable DeepSeek model' },
@@ -1159,9 +1161,17 @@
1159 1161 $modelSelectorButton.on('click', function() {
1160 1162 $('#mxchat_model_selector_modal').show();
1161 1163 window.populateModelsGrid('', 'all');
1162 1164 });
1163 -
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 +
1164 1174 $('.mxchat-model-selector-modal-close, #mxchat_cancel_model_selection').on('click', function() {
1165 1175 $('#mxchat_model_selector_modal').hide();
1166 1176 });
1167 1177
@@ -3787,5 +3797,330 @@
3787 3797 window.alert('Reset failed. Please try again.');
3788 3798 }
3789 3799 });
3790 3800 });
3791 -});
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 +});