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
mxchat-basic / assets / editor-assistant / sidebar.js

sidebar.js in MxChat – AI Chatbot & Content Generation for WordPress 3.2.25, at assets/editor-assistant/sidebar.js

554 lines 22.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 /**
2 * MxChat Editor Assistant — Gutenberg sidebar (v0.2).
3 *
4 * No JSX, no build step. Uses wp.element.createElement directly and
5 * wp.plugins.registerPlugin to mount a PluginSidebar in the block editor.
6 *
7 * v0.2 adds:
8 * 1. Streaming — single-block transforms stream token-by-token into the
9 * result preview via a fetch ReadableStream reader against the admin-ajax
10 * SSE endpoint. Falls back to the non-streaming REST call where the
11 * browser lacks streaming fetch.
12 * 2. Multi-block — when 2+ blocks are selected, each is transformed in
13 * parallel via the non-streaming /transform endpoint and shown as its own
14 * progress row; "Accept all" writes each result back to its block.
15 * 3. Grok + custom-provider support is server-side (see class-editor-assistant-rest.php);
16 * no client change needed — the same action call now succeeds for those models.
17 */
18 (function (wp) {
19 if (!wp || !wp.plugins || !wp.editPost || !wp.element) {
20 return;
21 }
22
23 var settings = window.MxChatEditorAssistant || {};
24 var i18n = settings.i18n || {};
25 var actions = settings.actions || [];
26 var locales = settings.locales || [];
27 var supportedBlocks = settings.supportedBlocks || [];
28
29 // Streaming needs fetch + ReadableStream. Modern Chrome/Edge/Safari/Firefox
30 // all have it; older webviews fall back to the non-streaming REST call.
31 var canStream = !!(window.fetch && window.ReadableStream && settings.ajaxUrl && settings.streamNonce);
32
33 var el = wp.element.createElement;
34 var Fragment = wp.element.Fragment;
35 var useState = wp.element.useState;
36 var useEffect = wp.element.useEffect;
37 var registerPlugin = wp.plugins.registerPlugin;
38 var PluginSidebar = wp.editPost.PluginSidebar;
39 var PluginSidebarMoreMenuItem = wp.editPost.PluginSidebarMoreMenuItem;
40 var Button = wp.components.Button;
41 var TextareaControl = wp.components.TextareaControl;
42 var SelectControl = wp.components.SelectControl;
43 var Notice = wp.components.Notice;
44 var Spinner = wp.components.Spinner;
45 var Panel = wp.components.Panel;
46 var PanelBody = wp.components.PanelBody;
47 var useSelect = wp.data.useSelect;
48 var useDispatch = wp.data.useDispatch;
49
50 var errorPrefix = i18n.errorPrefix || 'Error:';
51
52 function sprintf1(tpl, a) { return (tpl || '').replace('%d', a).replace('%1$d', a); }
53 function sprintf2(tpl, a, b) { return (tpl || '').replace('%1$d', a).replace('%2$d', b); }
54
55 /**
56 * Extract a block's editable text content. The supported block list keeps
57 * the read/write surface simple: each block stores its main text in either
58 * `content`, `value`, or `text` depending on type.
59 */
60 function readBlockText(block) {
61 if (!block || !block.attributes) return '';
62 var attrs = block.attributes;
63 if (typeof attrs.content === 'string') return attrs.content;
64 if (attrs.content && attrs.content.toString) return attrs.content.toString();
65 if (typeof attrs.value === 'string') return attrs.value;
66 if (typeof attrs.text === 'string') return attrs.text;
67 return '';
68 }
69
70 function pickContentAttribute(block) {
71 if (!block || !block.attributes) return null;
72 var attrs = block.attributes;
73 if (typeof attrs.content !== 'undefined') return 'content';
74 if (typeof attrs.value !== 'undefined') return 'value';
75 if (typeof attrs.text !== 'undefined') return 'text';
76 return null;
77 }
78
79 function isSupported(block) {
80 if (!block) return false;
81 return supportedBlocks.indexOf(block.name) !== -1;
82 }
83
84 function MxChatSidebar() {
85 var stateLocale = useState((locales[0] && locales[0].value) || 'en');
86 var locale = stateLocale[0];
87 var setLocale = stateLocale[1];
88
89 var stateBusy = useState(false);
90 var busy = stateBusy[0];
91 var setBusy = stateBusy[1];
92
93 // Single-block streaming state.
94 var stateStreaming = useState(false);
95 var streaming = stateStreaming[0];
96 var setStreaming = stateStreaming[1];
97
98 var stateStreamText = useState('');
99 var streamText = stateStreamText[0];
100 var setStreamText = stateStreamText[1];
101
102 // Single-block final (editable) result.
103 var stateResult = useState(null);
104 var resultObj = stateResult[0];
105 var setResult = stateResult[1];
106
107 // Multi-block jobs.
108 var stateJobs = useState(null);
109 var multiJobs = stateJobs[0];
110 var setMultiJobs = stateJobs[1];
111
112 var stateError = useState('');
113 var error = stateError[0];
114 var setError = stateError[1];
115
116 // Read both single + multi selection so 2+ highlighted blocks act together.
117 var selection = useSelect(function (select) {
118 var be = select('core/block-editor');
119 var multi = (be.getMultiSelectedBlocks ? be.getMultiSelectedBlocks() : []) || [];
120 return { multi: multi, single: be.getSelectedBlock() };
121 }, []);
122
123 var dispatch = useDispatch('core/block-editor');
124
125 var targets = (selection.multi && selection.multi.length >= 2)
126 ? selection.multi
127 : (selection.single ? [selection.single] : []);
128 var isMulti = targets.length >= 2;
129 var primaryBlock = targets.length === 1 ? targets[0] : null;
130 var hasAnySupported = targets.some(isSupported);
131
132 // selection signature → reset transient state when the selection changes.
133 var selectionKey = targets.map(function (b) { return b.clientId; }).join(',');
134 useEffect(function () {
135 setResult(null);
136 setMultiJobs(null);
137 setStreamText('');
138 setStreaming(false);
139 setError('');
140 }, [selectionKey]);
141
142 function preflight() {
143 setError('');
144 // Folded into core (plan-8cb0cb) as a free feature — no PRO license gate.
145 if (!targets.length || !hasAnySupported) {
146 setError(i18n.unsupportedNotice || 'Select a supported block.');
147 return false;
148 }
149 return true;
150 }
151
152 // ── Single-block streaming ──────────────────────────────────────────
153 function runStreamSingle(actionDef, block) {
154 setResult(null);
155 setMultiJobs(null);
156 setStreamText('');
157 var text = readBlockText(block);
158 if (!text || !text.trim()) {
159 setError(i18n.emptyContent || 'Block has no text content.');
160 return;
161 }
162
163 // Browsers without streaming fetch → non-streaming one-shot.
164 if (!canStream) {
165 runNonStreamSingle(actionDef, block, text);
166 return;
167 }
168
169 setStreaming(true);
170 var acc = '';
171 var streamErr = '';
172
173 var params = new URLSearchParams();
174 params.append('action', settings.streamAction);
175 params.append('nonce', settings.streamNonce);
176 params.append('action_slug', actionDef.slug);
177 params.append('content', text);
178 if (actionDef.needs_locale) { params.append('locale', locale); }
179
180 fetch(settings.ajaxUrl, {
181 method: 'POST',
182 credentials: 'same-origin',
183 headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
184 body: params.toString()
185 }).then(function (resp) {
186 if (!resp.body || !resp.body.getReader) {
187 // Stream body unavailable — degrade to text parse.
188 return resp.text().then(function (full) { parseFrames(full, true); });
189 }
190 var reader = resp.body.getReader();
191 var decoder = new TextDecoder();
192 var buffer = '';
193
194 function parseFrames(chunk, isFinal) {
195 buffer += chunk;
196 var parts = buffer.split('\n\n');
197 if (!isFinal) { buffer = parts.pop(); } else { buffer = ''; }
198 parts.forEach(function (part) {
199 var line = part.trim();
200 if (line.indexOf('data: ') !== 0) return;
201 var payload = line.slice(6);
202 if (payload === '[DONE]') return;
203 try {
204 var obj = JSON.parse(payload);
205 if (obj && obj.error) {
206 streamErr = obj.error;
207 } else if (obj && typeof obj.content === 'string') {
208 acc += obj.content;
209 setStreamText(acc);
210 }
211 } catch (e) { /* ignore partial */ }
212 });
213 }
214
215 function pump() {
216 return reader.read().then(function (r) {
217 if (r.done) { parseFrames('', true); finalize(); return; }
218 parseFrames(decoder.decode(r.value, { stream: true }), false);
219 return pump();
220 });
221 }
222
223 function finalize() {
224 setStreaming(false);
225 if (acc) {
226 setResult({ text: acc, actionLabel: actionDef.label, clientId: block.clientId, attr: pickContentAttribute(block) });
227 }
228 if (streamErr && !acc) {
229 setError(errorPrefix + ' ' + streamErr);
230 }
231 }
232
233 return pump();
234 }).catch(function (err) {
235 setStreaming(false);
236 setError(errorPrefix + ' ' + ((err && err.message) ? err.message : 'stream failed'));
237 });
238 }
239
240 function runNonStreamSingle(actionDef, block, text) {
241 setBusy(true);
242 var payload = { action: actionDef.slug, content: text };
243 if (actionDef.needs_locale) { payload.locale = locale; }
244 wp.apiFetch({ path: '/mxchat-editor/v1/transform', method: 'POST', data: payload })
245 .then(function (response) {
246 setBusy(false);
247 if (response && response.result) {
248 setResult({ text: response.result, actionLabel: actionDef.label, clientId: block.clientId, attr: pickContentAttribute(block) });
249 } else {
250 setError(errorPrefix + ' empty response');
251 }
252 }).catch(function (err) {
253 setBusy(false);
254 setError(errorPrefix + ' ' + ((err && err.message) ? err.message : 'request failed'));
255 });
256 }
257
258 // ── Multi-block parallel transform ──────────────────────────────────
259 function runMultiBlock(actionDef, blocks) {
260 setResult(null);
261 setStreamText('');
262 var jobs = blocks.map(function (b, i) {
263 var supported = isSupported(b);
264 return {
265 clientId: b.clientId,
266 index: i,
267 blockName: b.name,
268 attr: pickContentAttribute(b),
269 supported: supported,
270 status: supported ? 'pending' : 'skipped',
271 text: '',
272 error: ''
273 };
274 });
275 setMultiJobs(jobs.slice());
276 setBusy(true);
277
278 function updateJob(i, patch) {
279 setMultiJobs(function (prev) {
280 if (!prev) return prev;
281 var next = prev.slice();
282 next[i] = Object.assign({}, next[i], patch);
283 return next;
284 });
285 }
286
287 var promises = blocks.map(function (block, i) {
288 if (!isSupported(block)) { return Promise.resolve(); }
289 var text = readBlockText(block);
290 if (!text || !text.trim()) {
291 updateJob(i, { status: 'failed', error: i18n.emptyContent || 'No text content.' });
292 return Promise.resolve();
293 }
294 updateJob(i, { status: 'working' });
295 var payload = { action: actionDef.slug, content: text };
296 if (actionDef.needs_locale) { payload.locale = locale; }
297 return wp.apiFetch({ path: '/mxchat-editor/v1/transform', method: 'POST', data: payload })
298 .then(function (resp) {
299 if (resp && resp.result) {
300 updateJob(i, { status: 'done', text: resp.result });
301 } else {
302 updateJob(i, { status: 'failed', error: 'empty response' });
303 }
304 }).catch(function (err) {
305 updateJob(i, { status: 'failed', error: (err && err.message) ? err.message : 'request failed' });
306 });
307 });
308
309 Promise.all(promises).then(function () { setBusy(false); });
310 }
311
312 function runAction(actionDef) {
313 if (!preflight()) { return; }
314 if (isMulti) {
315 runMultiBlock(actionDef, targets);
316 } else {
317 runStreamSingle(actionDef, primaryBlock);
318 }
319 }
320
321 // ── Accept / discard ────────────────────────────────────────────────
322 function acceptSingle() {
323 if (!resultObj || !resultObj.clientId || !resultObj.attr) { return; }
324 var update = {};
325 update[resultObj.attr] = resultObj.text;
326 dispatch.updateBlockAttributes(resultObj.clientId, update);
327 setResult(null);
328 }
329
330 function acceptAll() {
331 if (!multiJobs) { return; }
332 multiJobs.forEach(function (job) {
333 if (job.status === 'done' && job.attr && job.clientId) {
334 var update = {};
335 update[job.attr] = job.text;
336 dispatch.updateBlockAttributes(job.clientId, update);
337 }
338 });
339 setMultiJobs(null);
340 }
341
342 function discardResult() {
343 setResult(null);
344 setMultiJobs(null);
345 setStreamText('');
346 setError('');
347 }
348
349 function editJobText(index, value) {
350 setMultiJobs(function (prev) {
351 if (!prev) return prev;
352 var next = prev.slice();
353 next[index] = Object.assign({}, next[index], { text: value });
354 return next;
355 });
356 }
357
358 // ── Render ──────────────────────────────────────────────────────────
359 var children = [];
360
361 if (!targets.length || !hasAnySupported) {
362 children.push(
363 el(Notice, { status: 'info', isDismissible: false, key: 'select-block' },
364 i18n.selectBlockNotice || 'Select a supported block.'
365 )
366 );
367 } else if (isMulti) {
368 children.push(
369 el(Notice, { status: 'info', isDismissible: false, key: 'multi-info' },
370 sprintf1(i18n.blocksLabelSuffix || '(%d blocks)', targets.length) + ' '
371 + (i18n.sidebarHeading || 'selected')
372 )
373 );
374 }
375
376 var actionsDisabled = busy || streaming || !hasAnySupported;
377
378 // Action buttons grid.
379 var actionEls = actions.map(function (action) {
380 var label = action.label;
381 if (isMulti) {
382 label = action.label + ' ' + sprintf1(i18n.blocksLabelSuffix || '(%d blocks)', targets.length);
383 }
384 return el(Button, {
385 key: action.slug,
386 variant: 'secondary',
387 disabled: actionsDisabled,
388 onClick: function () { runAction(action); },
389 className: 'mxchea-action-button',
390 title: action.description,
391 }, label);
392 });
393
394 children.push(
395 el('div', { className: 'mxchea-actions-grid', key: 'actions' }, actionEls)
396 );
397
398 // Locale picker.
399 children.push(
400 el(SelectControl, {
401 key: 'locale',
402 label: i18n.translateTo || 'Translate to',
403 value: locale,
404 options: locales,
405 onChange: function (v) { setLocale(v); },
406 __nextHasNoMarginBottom: true,
407 })
408 );
409
410 if (busy) {
411 children.push(
412 el('div', { className: 'mxchea-busy', key: 'busy' },
413 el(Spinner, {}),
414 el('span', null, i18n.workingLabel || 'Working…')
415 )
416 );
417 }
418
419 if (error) {
420 children.push(
421 el(Notice, { status: 'error', isDismissible: false, key: 'err' }, error)
422 );
423 }
424
425 // Single-block streaming preview (live).
426 if (streaming) {
427 children.push(
428 el('div', { className: 'mxchea-result mxchea-streaming', key: 'streaming' },
429 el('div', { className: 'mxchea-result-heading' },
430 el(Spinner, {}),
431 el('span', null, i18n.streamingLabel || 'Streaming…')
432 ),
433 el('div', { className: 'mxchea-stream-text' }, streamText || ' ')
434 )
435 );
436 }
437
438 // Single-block final result.
439 if (resultObj && !streaming) {
440 children.push(
441 el('div', { className: 'mxchea-result', key: 'result' },
442 el('div', { className: 'mxchea-result-heading' },
443 (i18n.resultHeading || 'Result') + ' — ' + resultObj.actionLabel
444 ),
445 el(TextareaControl, {
446 value: resultObj.text,
447 onChange: function (v) { setResult(Object.assign({}, resultObj, { text: v })); },
448 rows: 8,
449 __nextHasNoMarginBottom: true,
450 }),
451 el('div', { className: 'mxchea-result-actions' },
452 el(Button, { variant: 'primary', onClick: acceptSingle }, i18n.accept || 'Accept'),
453 el(Button, { variant: 'tertiary', onClick: discardResult }, i18n.discard || 'Discard')
454 )
455 )
456 );
457 }
458
459 // Multi-block result rows.
460 if (multiJobs) {
461 var doneCount = multiJobs.filter(function (j) { return j.status === 'done'; }).length;
462 var rows = multiJobs.map(function (job) {
463 var statusText;
464 var statusClass;
465 if (job.status === 'skipped') { statusText = i18n.skipped || 'Skipped'; statusClass = 'is-skipped'; }
466 else if (job.status === 'failed') { statusText = (i18n.failed || 'Failed') + (job.error ? ': ' + job.error : ''); statusClass = 'is-failed'; }
467 else if (job.status === 'done') { statusText = i18n.done || 'Done'; statusClass = 'is-done'; }
468 else if (job.status === 'working') { statusText = i18n.workingLabel || 'Working…'; statusClass = 'is-working'; }
469 else { statusText = i18n.pending || 'Waiting…'; statusClass = 'is-pending'; }
470
471 var rowChildren = [
472 el('div', { className: 'mxchea-job-head', key: 'head' },
473 el('span', { className: 'mxchea-job-label' },
474 (i18n.blockLabel || 'Block') + ' ' + (job.index + 1) + ' '),
475 el('code', { className: 'mxchea-job-type' }, job.blockName),
476 el('span', { className: 'mxchea-job-status ' + statusClass }, statusText)
477 )
478 ];
479 if (job.status === 'done') {
480 rowChildren.push(
481 el(TextareaControl, {
482 key: 'ta',
483 value: job.text,
484 onChange: function (v) { editJobText(job.index, v); },
485 rows: 4,
486 __nextHasNoMarginBottom: true,
487 })
488 );
489 }
490 return el('div', { className: 'mxchea-job-row', key: 'job-' + job.clientId }, rowChildren);
491 });
492
493 var multiChildren = [
494 el('div', { className: 'mxchea-result-heading', key: 'mh' },
495 sprintf2(i18n.appliedSummary || 'Applied to %1$d of %2$d selected blocks.', doneCount, multiJobs.length))
496 ].concat(rows);
497
498 if (!busy && doneCount > 0) {
499 multiChildren.push(
500 el('div', { className: 'mxchea-result-actions', key: 'acts' },
501 el(Button, { variant: 'primary', onClick: acceptAll }, i18n.acceptAll || 'Accept all'),
502 el(Button, { variant: 'tertiary', onClick: discardResult }, i18n.discard || 'Discard')
503 )
504 );
505 }
506
507 children.push(
508 el('div', { className: 'mxchea-result mxchea-multi', key: 'multi' }, multiChildren)
509 );
510 }
511
512 return el('div', { className: 'mxchea-sidebar' }, children);
513 }
514
515 var sidebarIcon = el('svg', {
516 xmlns: 'http://www.w3.org/2000/svg',
517 width: 20, height: 20, viewBox: '0 0 24 24',
518 fill: 'none', stroke: 'currentColor', strokeWidth: 2,
519 strokeLinecap: 'round', strokeLinejoin: 'round',
520 },
521 el('path', { d: 'M15 4V2' }),
522 el('path', { d: 'M15 16v-2' }),
523 el('path', { d: 'M8 9h2' }),
524 el('path', { d: 'M20 9h2' }),
525 el('path', { d: 'M17.8 11.8 19 13' }),
526 el('path', { d: 'M15 9h.01' }),
527 el('path', { d: 'M17.8 6.2 19 5' }),
528 el('path', { d: 'm3 21 9-9' }),
529 el('path', { d: 'M12.2 6.2 11 5' })
530 );
531
532 registerPlugin('mxchat-editor-assistant', {
533 render: function () {
534 return el(Fragment, null,
535 el(PluginSidebarMoreMenuItem, {
536 target: 'mxchat-editor-assistant-sidebar',
537 icon: sidebarIcon,
538 }, i18n.panelTitle || 'MxChat Editor Assistant'),
539 el(PluginSidebar, {
540 name: 'mxchat-editor-assistant-sidebar',
541 title: i18n.panelTitle || 'MxChat Editor Assistant',
542 icon: sidebarIcon,
543 },
544 el(Panel, null,
545 el(PanelBody, { title: i18n.sidebarHeading || 'Block actions', initialOpen: true },
546 el(MxChatSidebar)
547 )
548 )
549 )
550 );
551 },
552 });
553 })(window.wp);
554