| @@ -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 | +}); | |