WebOrbiton
v1.0.0.1

StocketBase

621 lines · 19.7 KB
  1. (function () {
  2. 'use strict';
  3. ​
  4. var HELPER_KEY = 'stocketbase_dashboard_ai_helper';
  5. var SVG_NS = 'http://www.w3.org/2000/svg';
  6. ​
  7. var config = document.getElementById('ai-helper-config');
  8. if (!config) {
  9. return;
  10. }
  11. ​
  12. try {
  13. if (localStorage.getItem(HELPER_KEY) === '0') {
  14. return;
  15. }
  16. } catch (error) {
  17. }
  18. ​
  19. var features = [];
  20. try {
  21. features = JSON.parse(config.dataset.features || '[]');
  22. } catch (error) {
  23. features = [];
  24. }
  25. ​
  26. var endpoint = config.dataset.endpoint;
  27. var csrfToken = config.dataset.csrf;
  28. var conversation = [];
  29. var changes = [];
  30. var busy = false;
  31. var lastFocusedBlockId = '';
  32. var ui = {};
  33. ​
  34. var TYPE_LABELS = {
  35. paragraph: 'paragraph',
  36. quote: 'quote',
  37. heading2: 'heading',
  38. heading3: 'subheading',
  39. list: 'list',
  40. code: 'code block',
  41. table: 'table',
  42. checklist: 'checklist',
  43. callout: 'callout',
  44. separator: 'separator',
  45. image: 'image',
  46. video: 'video',
  47. embed: 'embed'
  48. };
  49. ​
  50. var QUICK_ACTIONS = [
  51. { feature: 'writing', label: 'Write', text: 'Write ', send: false },
  52. { feature: 'improve_text', label: 'Improve', text: 'Improve the wording of the text without changing its meaning.', send: true },
  53. { feature: 'spelling_fix', label: 'Fix spelling', text: 'Correct the spelling mistakes.', send: true },
  54. { feature: 'spell_check', label: 'Check words', text: 'Check the words for spelling mistakes and list them.', send: true },
  55. { feature: 'grammar_check', label: 'Check grammar', text: 'Check the grammar and list the problems.', send: true },
  56. { feature: 'format_fix', label: 'Fix formatting', text: 'Fix the formatting of the blocks.', send: true },
  57. { feature: 'translate', label: 'Translate', text: 'Translate to ', send: false }
  58. ];
  59. ​
  60. function el(tag, className, text) {
  61. var node = document.createElement(tag);
  62. if (className) {
  63. node.className = className;
  64. }
  65. if (text !== undefined) {
  66. node.textContent = text;
  67. }
  68. return node;
  69. }
  70. ​
  71. function icon(name) {
  72. var svg = document.createElementNS(SVG_NS, 'svg');
  73. svg.setAttribute('class', 'icon icon-sm');
  74. svg.setAttribute('aria-hidden', 'true');
  75. var use = document.createElementNS(SVG_NS, 'use');
  76. use.setAttribute('href', '#icon-' + name);
  77. svg.appendChild(use);
  78. return svg;
  79. }
  80. ​
  81. function editor() {
  82. return window.stocketbaseBlockEditor || null;
  83. }
  84. ​
  85. var FIELD_LABELS = { title: 'title', excerpt: 'short summary', seo_title: 'Google title', seo_description: 'Google description' };
  86. ​
  87. function fieldElement(name) {
  88. var target = editor();
  89. var root = target && target.root ? target.root : document.getElementById('block-editor-root');
  90. var form = root ? root.closest('form') : null;
  91. var input = form ? form.elements[name] : null;
  92. ​
  93. if (!input || typeof input.value !== 'string' || input.readOnly || input.disabled || input.type === 'hidden') {
  94. return null;
  95. }
  96. ​
  97. return input;
  98. }
  99. ​
  100. function collectFields() {
  101. var values = {};
  102. Object.keys(FIELD_LABELS).forEach(function (name) {
  103. var input = fieldElement(name);
  104. if (input) {
  105. values[name] = String(input.value || '');
  106. }
  107. });
  108. ​
  109. return values;
  110. }
  111. ​
  112. function setField(name, value) {
  113. var input = fieldElement(name);
  114. if (!input) {
  115. return;
  116. }
  117. ​
  118. input.value = value;
  119. input.dispatchEvent(new Event('input', { bubbles: true }));
  120. input.dispatchEvent(new Event('change', { bubbles: true }));
  121. }
  122. ​
  123. function plain(html) {
  124. return new DOMParser().parseFromString(String(html || ''), 'text/html').body.textContent || '';
  125. }
  126. ​
  127. function blockText(block) {
  128. var data = block.data || {};
  129. if (block.type === 'list') {
  130. return (data.items || []).join('\n');
  131. }
  132. if (block.type === 'checklist') {
  133. return (data.items || []).map(function (item) { return item.text; }).join('\n');
  134. }
  135. if (block.type === 'table') {
  136. return (data.rows || []).map(function (row) { return row.join(' | '); }).join('\n');
  137. }
  138. if (block.type === 'code') {
  139. return data.code || '';
  140. }
  141. if (block.type === 'separator') {
  142. return '';
  143. }
  144. return plain(data.text || '');
  145. }
  146. ​
  147. function findIndex(list, id) {
  148. for (var i = 0; i < list.length; i++) {
  149. if (list[i].id === id) {
  150. return i;
  151. }
  152. }
  153. return -1;
  154. }
  155. ​
  156. function typeLabel(type) {
  157. return TYPE_LABELS[type] || type;
  158. }
  159. ​
  160. function computeApply(blocks, actions) {
  161. var list = JSON.parse(JSON.stringify(blocks));
  162. var insertedAfter = {};
  163. ​
  164. actions.forEach(function (action) {
  165. var index;
  166. if (action.op === 'update_block') {
  167. index = findIndex(list, action.id);
  168. if (index !== -1) {
  169. list[index] = { id: list[index].id, type: action.type || list[index].type, data: action.data };
  170. }
  171. } else if (action.op === 'insert_block') {
  172. var block = { id: action.new_id, type: action.type, data: action.data };
  173. var key = action.after_id;
  174. var offset = insertedAfter[key] || 0;
  175. if (key === 'end') {
  176. list.push(block);
  177. } else if (key === 'start') {
  178. list.splice(offset, 0, block);
  179. insertedAfter[key] = offset + 1;
  180. } else {
  181. index = findIndex(list, key);
  182. if (index !== -1) {
  183. list.splice(index + 1 + offset, 0, block);
  184. insertedAfter[key] = offset + 1;
  185. }
  186. }
  187. } else if (action.op === 'delete_block') {
  188. list = list.filter(function (item) { return item.id !== action.id; });
  189. } else if (action.op === 'move_block') {
  190. index = findIndex(list, action.id);
  191. if (index !== -1) {
  192. var moved = list.splice(index, 1)[0];
  193. list.splice(Math.max(0, Math.min(list.length, action.to_position - 1)), 0, moved);
  194. }
  195. }
  196. });
  197. ​
  198. return list;
  199. }
  200. ​
  201. function describe(action, blocks) {
  202. var index = action.id ? findIndex(blocks, action.id) : -1;
  203. var current = index !== -1 ? blocks[index] : null;
  204. var position = index !== -1 ? ' #' + (index + 1) : '';
  205. ​
  206. if (action.op === 'update_field') {
  207. var fieldInput = fieldElement(action.field);
  208. return { title: 'Edit ' + (FIELD_LABELS[action.field] || action.field), before: fieldInput ? String(fieldInput.value || '') : '', after: action.value };
  209. }
  210. ​
  211. if (action.op === 'update_block') {
  212. return { title: 'Edit ' + typeLabel(current ? current.type : action.type) + position, before: current ? blockText(current) : '', after: blockText({ type: action.type, data: action.data }) };
  213. }
  214. if (action.op === 'insert_block') {
  215. return { title: 'Add ' + typeLabel(action.type), before: '', after: blockText({ type: action.type, data: action.data }) };
  216. }
  217. if (action.op === 'delete_block') {
  218. return { title: 'Remove ' + typeLabel(current ? current.type : 'block') + position, before: current ? blockText(current) : '', after: '' };
  219. }
  220. return { title: 'Move ' + typeLabel(current ? current.type : 'block') + position + ' to position ' + action.to_position, before: '', after: '' };
  221. }
  222. ​
  223. function scrollToEnd() {
  224. ui.messages.scrollTop = ui.messages.scrollHeight;
  225. }
  226. ​
  227. function addMessage(kind, text) {
  228. var bubble = el('div', 'ai-msg ai-msg-' + kind, text);
  229. ui.messages.appendChild(bubble);
  230. scrollToEnd();
  231. return bubble;
  232. }
  233. ​
  234. function updateUndoButton() {
  235. ui.undo.disabled = changes.length === 0;
  236. }
  237. ​
  238. function undoEntry(entry) {
  239. if (changes[changes.length - 1] !== entry) {
  240. addMessage('note', 'Undo the newer changes first.');
  241. return;
  242. }
  243. ​
  244. var target = editor();
  245. if (!target) {
  246. return;
  247. }
  248. ​
  249. var fieldNames = Object.keys(entry.fieldsAfter || {});
  250. var fieldsEdited = fieldNames.some(function (name) {
  251. var input = fieldElement(name);
  252. return input !== null && String(input.value || '') !== entry.fieldsAfter[name];
  253. });
  254. ​
  255. if ((JSON.stringify(target.getBlocks()) !== entry.after || fieldsEdited) && !window.confirm('You made edits after this change. Undoing it will throw those edits away too. Go ahead?')) {
  256. return;
  257. }
  258. ​
  259. target.replaceBlocks(entry.before);
  260. fieldNames.forEach(function (name) {
  261. setField(name, entry.fieldsBefore[name]);
  262. });
  263. changes.pop();
  264. entry.onUndone();
  265. updateUndoButton();
  266. }
  267. ​
  268. function addProposal(actions, rejected) {
  269. var target = editor();
  270. var blocks = target ? target.getBlocks() : [];
  271. var card = el('div', 'ai-proposal');
  272. card.style.flexShrink = '0';
  273. var list = el('div', 'ai-proposal-list');
  274. ​
  275. actions.forEach(function (action) {
  276. var info = describe(action, blocks);
  277. var row = el('details', 'ai-change');
  278. var summary = el('summary');
  279. summary.appendChild(el('span', 'ai-change-title', info.title));
  280. if (action.summary) {
  281. summary.appendChild(el('span', 'ai-change-note', action.summary));
  282. }
  283. row.appendChild(summary);
  284. ​
  285. if (info.before !== '') {
  286. row.appendChild(el('div', 'ai-diff-label', 'Before'));
  287. row.appendChild(el('pre', 'ai-diff ai-diff-before', info.before));
  288. }
  289. if (info.after !== '') {
  290. row.appendChild(el('div', 'ai-diff-label', 'After'));
  291. row.appendChild(el('pre', 'ai-diff ai-diff-after', info.after));
  292. }
  293. list.appendChild(row);
  294. });
  295. card.appendChild(list);
  296. ​
  297. if (rejected > 0) {
  298. card.appendChild(el('div', 'ai-proposal-note', rejected + ' suggested change' + (rejected === 1 ? ' was' : 's were') + ' blocked by the AI settings or failed validation.'));
  299. }
  300. ​
  301. var bar = el('div', 'ai-proposal-actions');
  302. var applyButton = el('button', 'dash-btn-small dash-btn-primary');
  303. applyButton.type = 'button';
  304. applyButton.appendChild(icon('check'));
  305. applyButton.appendChild(document.createTextNode('Apply ' + actions.length + ' change' + (actions.length === 1 ? '' : 's')));
  306. var discardButton = el('button', 'dash-btn-small');
  307. discardButton.type = 'button';
  308. discardButton.appendChild(icon('x'));
  309. discardButton.appendChild(document.createTextNode('Discard'));
  310. var undoButton = el('button', 'dash-btn-small');
  311. undoButton.type = 'button';
  312. undoButton.appendChild(icon('refresh'));
  313. undoButton.appendChild(document.createTextNode('Undo'));
  314. undoButton.style.display = 'none';
  315. var status = el('span', 'ai-proposal-status');
  316. ​
  317. bar.appendChild(applyButton);
  318. bar.appendChild(discardButton);
  319. bar.appendChild(undoButton);
  320. bar.appendChild(status);
  321. card.insertBefore(bar, card.firstChild);
  322. ​
  323. var entry = null;
  324. ​
  325. applyButton.addEventListener('click', function () {
  326. var active = editor();
  327. if (!active) {
  328. return;
  329. }
  330. ​
  331. if (typeof active.replaceBlocks !== 'function') {
  332. status.textContent = 'Please refresh the page (Ctrl+F5) and try again.';
  333. return;
  334. }
  335. ​
  336. var before = active.getBlocks();
  337. var next = computeApply(before, actions);
  338. var fieldsBefore = {};
  339. var fieldsAfter = {};
  340. actions.forEach(function (action) {
  341. var input = action.op === 'update_field' ? fieldElement(action.field) : null;
  342. if (input && String(input.value || '') !== action.value) {
  343. if (!(action.field in fieldsBefore)) {
  344. fieldsBefore[action.field] = String(input.value || '');
  345. }
  346. fieldsAfter[action.field] = action.value;
  347. }
  348. });
  349. var blocksChanged = JSON.stringify(next) !== JSON.stringify(before);
  350. ​
  351. if (!blocksChanged && Object.keys(fieldsAfter).length === 0) {
  352. status.textContent = 'Nothing needed changing.';
  353. applyButton.style.display = 'none';
  354. discardButton.style.display = 'none';
  355. return;
  356. }
  357. ​
  358. if (blocksChanged) {
  359. active.replaceBlocks(next);
  360. }
  361. Object.keys(fieldsAfter).forEach(function (name) {
  362. setField(name, fieldsAfter[name]);
  363. });
  364. entry = {
  365. before: before,
  366. fieldsBefore: fieldsBefore,
  367. fieldsAfter: fieldsAfter,
  368. after: JSON.stringify(active.getBlocks()),
  369. onUndone: function () {
  370. status.textContent = 'Undone.';
  371. undoButton.style.display = 'none';
  372. }
  373. };
  374. var appliedEntry = entry;
  375. entry.undo = function () {
  376. undoEntry(appliedEntry);
  377. };
  378. changes.push(entry);
  379. updateUndoButton();
  380. ​
  381. applyButton.style.display = 'none';
  382. discardButton.style.display = 'none';
  383. undoButton.style.display = '';
  384. status.textContent = 'Applied.';
  385. });
  386. ​
  387. discardButton.addEventListener('click', function () {
  388. applyButton.style.display = 'none';
  389. discardButton.style.display = 'none';
  390. status.textContent = 'Discarded.';
  391. });
  392. ​
  393. undoButton.addEventListener('click', function () {
  394. if (entry) {
  395. undoEntry(entry);
  396. }
  397. });
  398. ​
  399. ui.messages.appendChild(card);
  400. scrollToEnd();
  401. }
  402. ​
  403. function setBusy(value) {
  404. busy = value;
  405. ui.send.disabled = value;
  406. ui.input.disabled = value;
  407. if (value) {
  408. ui.thinking = addMessage('note', 'Thinking…');
  409. } else if (ui.thinking) {
  410. ui.thinking.remove();
  411. ui.thinking = null;
  412. }
  413. }
  414. ​
  415. function send(text) {
  416. if (busy) {
  417. return;
  418. }
  419. ​
  420. text = String(text || '').trim();
  421. if (text === '') {
  422. return;
  423. }
  424. ​
  425. var target = editor();
  426. if (!target) {
  427. addMessage('error', 'The editor is still loading. Give it a second.');
  428. return;
  429. }
  430. ​
  431. var blocks = target.getBlocks();
  432. var focusId = findIndex(blocks, lastFocusedBlockId) !== -1 ? lastFocusedBlockId : '';
  433. var scope = ui.scope.value;
  434. ​
  435. if (scope === 'block') {
  436. if (focusId === '') {
  437. addMessage('error', 'Click into a block first, or switch to "Everything".');
  438. return;
  439. }
  440. blocks = blocks.filter(function (block) { return block.id === focusId; });
  441. }
  442. ​
  443. addMessage('user', text);
  444. ui.input.value = '';
  445. setBusy(true);
  446. ​
  447. fetch(endpoint, {
  448. method: 'POST',
  449. credentials: 'same-origin',
  450. headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrfToken },
  451. body: JSON.stringify({ message: text, blocks: blocks, fields: scope === 'block' ? {} : collectFields(), scope: scope, focus_id: focusId, history: conversation.slice(-10) })
  452. })
  453. .then(function (response) {
  454. return response.json().catch(function () {
  455. return { ok: false, error: 'Something went wrong on the server. Please try again.' };
  456. });
  457. })
  458. .then(function (data) {
  459. setBusy(false);
  460. if (!data.ok) {
  461. addMessage('error', data.error || 'That didn\'t work. Please try again.');
  462. return;
  463. }
  464. ​
  465. conversation.push({ role: 'user', content: text });
  466. conversation.push({ role: 'assistant', content: data.reply || '' });
  467. conversation = conversation.slice(-10);
  468. ​
  469. if (data.reply) {
  470. addMessage('assistant', data.reply);
  471. }
  472. if (data.actions && data.actions.length) {
  473. addProposal(data.actions, data.rejected || 0);
  474. } else if (data.rejected) {
  475. addMessage('note', data.rejected + ' suggested change' + (data.rejected === 1 ? ' was' : 's were') + ' blocked by the AI settings.');
  476. }
  477. })
  478. .catch(function () {
  479. setBusy(false);
  480. addMessage('error', 'We couldn\'t reach your store. Check your connection.');
  481. });
  482. }
  483. ​
  484. function togglePanel(open) {
  485. ui.panel.classList.toggle('is-open', open);
  486. ui.fab.classList.toggle('is-hidden', open);
  487. if (open) {
  488. ui.input.focus();
  489. }
  490. }
  491. ​
  492. function build() {
  493. ui.fab = el('button', 'ai-fab');
  494. ui.fab.type = 'button';
  495. ui.fab.setAttribute('aria-label', 'Open the AI assistant');
  496. ui.fab.appendChild(icon('sparkles'));
  497. ui.fab.appendChild(el('span', null, 'AI'));
  498. ​
  499. ui.panel = el('section', 'ai-panel');
  500. ui.panel.setAttribute('aria-label', 'AI helper');
  501. ​
  502. var header = el('div', 'ai-header');
  503. var titleBox = el('div', 'ai-header-title');
  504. titleBox.appendChild(icon('sparkles'));
  505. var titles = el('div');
  506. titles.appendChild(el('div', 'ai-title', 'AI helper'));
  507. titles.appendChild(el('div', 'ai-subtitle', (config.dataset.provider || '') + ' · ' + (config.dataset.model || '')));
  508. titleBox.appendChild(titles);
  509. header.appendChild(titleBox);
  510. ​
  511. var headerActions = el('div', 'ai-header-actions');
  512. ui.undo = el('button', 'dash-btn-small');
  513. ui.undo.type = 'button';
  514. ui.undo.title = 'Undo the last change';
  515. ui.undo.appendChild(icon('refresh'));
  516. ui.undo.appendChild(document.createTextNode('Undo'));
  517. ui.undo.disabled = true;
  518. var closeButton = el('button', 'ai-icon-button');
  519. closeButton.type = 'button';
  520. closeButton.setAttribute('aria-label', 'Close the AI assistant');
  521. closeButton.appendChild(icon('x'));
  522. headerActions.appendChild(ui.undo);
  523. headerActions.appendChild(closeButton);
  524. header.appendChild(headerActions);
  525. ​
  526. ui.messages = el('div', 'ai-messages');
  527. ui.messages.setAttribute('aria-live', 'polite');
  528. ​
  529. var quick = el('div', 'ai-quick');
  530. QUICK_ACTIONS.forEach(function (item) {
  531. if (features.indexOf(item.feature) === -1) {
  532. return;
  533. }
  534. var chip = el('button', 'ai-chip', item.label);
  535. chip.type = 'button';
  536. chip.addEventListener('click', function () {
  537. if (item.send) {
  538. send(item.text);
  539. } else {
  540. ui.input.value = item.text;
  541. ui.input.focus();
  542. }
  543. });
  544. quick.appendChild(chip);
  545. });
  546. ​
  547. var scopeRow = el('div', 'ai-scope');
  548. scopeRow.appendChild(el('label', null, 'Scope'));
  549. ui.scope = el('select');
  550. [['article', 'Everything'], ['block', 'Selected block']].forEach(function (option) {
  551. var node = el('option', null, option[1]);
  552. node.value = option[0];
  553. ui.scope.appendChild(node);
  554. });
  555. scopeRow.appendChild(ui.scope);
  556. ​
  557. var composer = el('div', 'ai-composer');
  558. ui.input = el('textarea');
  559. ui.input.rows = 2;
  560. ui.input.maxLength = 4000;
  561. ui.input.placeholder = 'What should I help with?';
  562. ui.send = el('button', 'dash-btn dash-btn-primary ai-send');
  563. ui.send.type = 'button';
  564. ui.send.setAttribute('aria-label', 'Send');
  565. ui.send.appendChild(icon('arrow-up'));
  566. composer.appendChild(ui.input);
  567. composer.appendChild(ui.send);
  568. ​
  569. ui.panel.appendChild(header);
  570. ui.panel.appendChild(ui.messages);
  571. if (quick.childNodes.length) {
  572. ui.panel.appendChild(quick);
  573. }
  574. ui.panel.appendChild(scopeRow);
  575. ui.panel.appendChild(composer);
  576. ​
  577. document.body.appendChild(ui.fab);
  578. document.body.appendChild(ui.panel);
  579. ​
  580. addMessage('note', 'Your text is sent to ' + (config.dataset.provider || 'the AI service') + '. You see every suggestion first, nothing changes until you press Apply, and you can always undo.');
  581. ​
  582. ui.fab.addEventListener('click', function () { togglePanel(true); });
  583. closeButton.addEventListener('click', function () { togglePanel(false); });
  584. ui.undo.addEventListener('click', function () {
  585. if (changes.length) {
  586. changes[changes.length - 1].undo();
  587. }
  588. });
  589. ui.send.addEventListener('click', function () { send(ui.input.value); });
  590. ui.input.addEventListener('keydown', function (event) {
  591. if (event.key === 'Enter' && !event.shiftKey && !event.isComposing) {
  592. event.preventDefault();
  593. send(ui.input.value);
  594. }
  595. });
  596. ui.panel.addEventListener('keydown', function (event) {
  597. if (event.key === 'Escape') {
  598. togglePanel(false);
  599. }
  600. event.stopPropagation();
  601. });
  602. ​
  603. document.addEventListener('focusin', function (event) {
  604. var wrapper = event.target && event.target.closest ? event.target.closest('.be-block') : null;
  605. if (wrapper && wrapper.dataset.blockId) {
  606. lastFocusedBlockId = wrapper.dataset.blockId;
  607. }
  608. });
  609. }
  610. ​
  611. function init() {
  612. build();
  613. }
  614. ​
  615. if (document.readyState === 'loading') {
  616. document.addEventListener('DOMContentLoaded', init);
  617. } else {
  618. init();
  619. }
  620. })();
  621. ​