v1.0.0.1
StocketBase
- (function () {
- 'use strict';
-
- var HELPER_KEY = 'stocketbase_dashboard_ai_helper';
- var SVG_NS = 'http://www.w3.org/2000/svg';
-
- var config = document.getElementById('ai-helper-config');
- if (!config) {
- return;
- }
-
- try {
- if (localStorage.getItem(HELPER_KEY) === '0') {
- return;
- }
- } catch (error) {
- }
-
- var features = [];
- try {
- features = JSON.parse(config.dataset.features || '[]');
- } catch (error) {
- features = [];
- }
-
- var endpoint = config.dataset.endpoint;
- var csrfToken = config.dataset.csrf;
- var conversation = [];
- var changes = [];
- var busy = false;
- var lastFocusedBlockId = '';
- var ui = {};
-
- var TYPE_LABELS = {
- paragraph: 'paragraph',
- quote: 'quote',
- heading2: 'heading',
- heading3: 'subheading',
- list: 'list',
- code: 'code block',
- table: 'table',
- checklist: 'checklist',
- callout: 'callout',
- separator: 'separator',
- image: 'image',
- video: 'video',
- embed: 'embed'
- };
-
- var QUICK_ACTIONS = [
- { feature: 'writing', label: 'Write', text: 'Write ', send: false },
- { feature: 'improve_text', label: 'Improve', text: 'Improve the wording of the text without changing its meaning.', send: true },
- { feature: 'spelling_fix', label: 'Fix spelling', text: 'Correct the spelling mistakes.', send: true },
- { feature: 'spell_check', label: 'Check words', text: 'Check the words for spelling mistakes and list them.', send: true },
- { feature: 'grammar_check', label: 'Check grammar', text: 'Check the grammar and list the problems.', send: true },
- { feature: 'format_fix', label: 'Fix formatting', text: 'Fix the formatting of the blocks.', send: true },
- { feature: 'translate', label: 'Translate', text: 'Translate to ', send: false }
- ];
-
- function el(tag, className, text) {
- var node = document.createElement(tag);
- if (className) {
- node.className = className;
- }
- if (text !== undefined) {
- node.textContent = text;
- }
- return node;
- }
-
- function icon(name) {
- var svg = document.createElementNS(SVG_NS, 'svg');
- svg.setAttribute('class', 'icon icon-sm');
- svg.setAttribute('aria-hidden', 'true');
- var use = document.createElementNS(SVG_NS, 'use');
- use.setAttribute('href', '#icon-' + name);
- svg.appendChild(use);
- return svg;
- }
-
- function editor() {
- return window.stocketbaseBlockEditor || null;
- }
-
- var FIELD_LABELS = { title: 'title', excerpt: 'short summary', seo_title: 'Google title', seo_description: 'Google description' };
-
- function fieldElement(name) {
- var target = editor();
- var root = target && target.root ? target.root : document.getElementById('block-editor-root');
- var form = root ? root.closest('form') : null;
- var input = form ? form.elements[name] : null;
-
- if (!input || typeof input.value !== 'string' || input.readOnly || input.disabled || input.type === 'hidden') {
- return null;
- }
-
- return input;
- }
-
- function collectFields() {
- var values = {};
- Object.keys(FIELD_LABELS).forEach(function (name) {
- var input = fieldElement(name);
- if (input) {
- values[name] = String(input.value || '');
- }
- });
-
- return values;
- }
-
- function setField(name, value) {
- var input = fieldElement(name);
- if (!input) {
- return;
- }
-
- input.value = value;
- input.dispatchEvent(new Event('input', { bubbles: true }));
- input.dispatchEvent(new Event('change', { bubbles: true }));
- }
-
- function plain(html) {
- return new DOMParser().parseFromString(String(html || ''), 'text/html').body.textContent || '';
- }
-
- function blockText(block) {
- var data = block.data || {};
- if (block.type === 'list') {
- return (data.items || []).join('\n');
- }
- if (block.type === 'checklist') {
- return (data.items || []).map(function (item) { return item.text; }).join('\n');
- }
- if (block.type === 'table') {
- return (data.rows || []).map(function (row) { return row.join(' | '); }).join('\n');
- }
- if (block.type === 'code') {
- return data.code || '';
- }
- if (block.type === 'separator') {
- return '';
- }
- return plain(data.text || '');
- }
-
- function findIndex(list, id) {
- for (var i = 0; i < list.length; i++) {
- if (list[i].id === id) {
- return i;
- }
- }
- return -1;
- }
-
- function typeLabel(type) {
- return TYPE_LABELS[type] || type;
- }
-
- function computeApply(blocks, actions) {
- var list = JSON.parse(JSON.stringify(blocks));
- var insertedAfter = {};
-
- actions.forEach(function (action) {
- var index;
- if (action.op === 'update_block') {
- index = findIndex(list, action.id);
- if (index !== -1) {
- list[index] = { id: list[index].id, type: action.type || list[index].type, data: action.data };
- }
- } else if (action.op === 'insert_block') {
- var block = { id: action.new_id, type: action.type, data: action.data };
- var key = action.after_id;
- var offset = insertedAfter[key] || 0;
- if (key === 'end') {
- list.push(block);
- } else if (key === 'start') {
- list.splice(offset, 0, block);
- insertedAfter[key] = offset + 1;
- } else {
- index = findIndex(list, key);
- if (index !== -1) {
- list.splice(index + 1 + offset, 0, block);
- insertedAfter[key] = offset + 1;
- }
- }
- } else if (action.op === 'delete_block') {
- list = list.filter(function (item) { return item.id !== action.id; });
- } else if (action.op === 'move_block') {
- index = findIndex(list, action.id);
- if (index !== -1) {
- var moved = list.splice(index, 1)[0];
- list.splice(Math.max(0, Math.min(list.length, action.to_position - 1)), 0, moved);
- }
- }
- });
-
- return list;
- }
-
- function describe(action, blocks) {
- var index = action.id ? findIndex(blocks, action.id) : -1;
- var current = index !== -1 ? blocks[index] : null;
- var position = index !== -1 ? ' #' + (index + 1) : '';
-
- if (action.op === 'update_field') {
- var fieldInput = fieldElement(action.field);
- return { title: 'Edit ' + (FIELD_LABELS[action.field] || action.field), before: fieldInput ? String(fieldInput.value || '') : '', after: action.value };
- }
-
- if (action.op === 'update_block') {
- return { title: 'Edit ' + typeLabel(current ? current.type : action.type) + position, before: current ? blockText(current) : '', after: blockText({ type: action.type, data: action.data }) };
- }
- if (action.op === 'insert_block') {
- return { title: 'Add ' + typeLabel(action.type), before: '', after: blockText({ type: action.type, data: action.data }) };
- }
- if (action.op === 'delete_block') {
- return { title: 'Remove ' + typeLabel(current ? current.type : 'block') + position, before: current ? blockText(current) : '', after: '' };
- }
- return { title: 'Move ' + typeLabel(current ? current.type : 'block') + position + ' to position ' + action.to_position, before: '', after: '' };
- }
-
- function scrollToEnd() {
- ui.messages.scrollTop = ui.messages.scrollHeight;
- }
-
- function addMessage(kind, text) {
- var bubble = el('div', 'ai-msg ai-msg-' + kind, text);
- ui.messages.appendChild(bubble);
- scrollToEnd();
- return bubble;
- }
-
- function updateUndoButton() {
- ui.undo.disabled = changes.length === 0;
- }
-
- function undoEntry(entry) {
- if (changes[changes.length - 1] !== entry) {
- addMessage('note', 'Undo the newer changes first.');
- return;
- }
-
- var target = editor();
- if (!target) {
- return;
- }
-
- var fieldNames = Object.keys(entry.fieldsAfter || {});
- var fieldsEdited = fieldNames.some(function (name) {
- var input = fieldElement(name);
- return input !== null && String(input.value || '') !== entry.fieldsAfter[name];
- });
-
- 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?')) {
- return;
- }
-
- target.replaceBlocks(entry.before);
- fieldNames.forEach(function (name) {
- setField(name, entry.fieldsBefore[name]);
- });
- changes.pop();
- entry.onUndone();
- updateUndoButton();
- }
-
- function addProposal(actions, rejected) {
- var target = editor();
- var blocks = target ? target.getBlocks() : [];
- var card = el('div', 'ai-proposal');
- card.style.flexShrink = '0';
- var list = el('div', 'ai-proposal-list');
-
- actions.forEach(function (action) {
- var info = describe(action, blocks);
- var row = el('details', 'ai-change');
- var summary = el('summary');
- summary.appendChild(el('span', 'ai-change-title', info.title));
- if (action.summary) {
- summary.appendChild(el('span', 'ai-change-note', action.summary));
- }
- row.appendChild(summary);
-
- if (info.before !== '') {
- row.appendChild(el('div', 'ai-diff-label', 'Before'));
- row.appendChild(el('pre', 'ai-diff ai-diff-before', info.before));
- }
- if (info.after !== '') {
- row.appendChild(el('div', 'ai-diff-label', 'After'));
- row.appendChild(el('pre', 'ai-diff ai-diff-after', info.after));
- }
- list.appendChild(row);
- });
- card.appendChild(list);
-
- if (rejected > 0) {
- card.appendChild(el('div', 'ai-proposal-note', rejected + ' suggested change' + (rejected === 1 ? ' was' : 's were') + ' blocked by the AI settings or failed validation.'));
- }
-
- var bar = el('div', 'ai-proposal-actions');
- var applyButton = el('button', 'dash-btn-small dash-btn-primary');
- applyButton.type = 'button';
- applyButton.appendChild(icon('check'));
- applyButton.appendChild(document.createTextNode('Apply ' + actions.length + ' change' + (actions.length === 1 ? '' : 's')));
- var discardButton = el('button', 'dash-btn-small');
- discardButton.type = 'button';
- discardButton.appendChild(icon('x'));
- discardButton.appendChild(document.createTextNode('Discard'));
- var undoButton = el('button', 'dash-btn-small');
- undoButton.type = 'button';
- undoButton.appendChild(icon('refresh'));
- undoButton.appendChild(document.createTextNode('Undo'));
- undoButton.style.display = 'none';
- var status = el('span', 'ai-proposal-status');
-
- bar.appendChild(applyButton);
- bar.appendChild(discardButton);
- bar.appendChild(undoButton);
- bar.appendChild(status);
- card.insertBefore(bar, card.firstChild);
-
- var entry = null;
-
- applyButton.addEventListener('click', function () {
- var active = editor();
- if (!active) {
- return;
- }
-
- if (typeof active.replaceBlocks !== 'function') {
- status.textContent = 'Please refresh the page (Ctrl+F5) and try again.';
- return;
- }
-
- var before = active.getBlocks();
- var next = computeApply(before, actions);
- var fieldsBefore = {};
- var fieldsAfter = {};
- actions.forEach(function (action) {
- var input = action.op === 'update_field' ? fieldElement(action.field) : null;
- if (input && String(input.value || '') !== action.value) {
- if (!(action.field in fieldsBefore)) {
- fieldsBefore[action.field] = String(input.value || '');
- }
- fieldsAfter[action.field] = action.value;
- }
- });
- var blocksChanged = JSON.stringify(next) !== JSON.stringify(before);
-
- if (!blocksChanged && Object.keys(fieldsAfter).length === 0) {
- status.textContent = 'Nothing needed changing.';
- applyButton.style.display = 'none';
- discardButton.style.display = 'none';
- return;
- }
-
- if (blocksChanged) {
- active.replaceBlocks(next);
- }
- Object.keys(fieldsAfter).forEach(function (name) {
- setField(name, fieldsAfter[name]);
- });
- entry = {
- before: before,
- fieldsBefore: fieldsBefore,
- fieldsAfter: fieldsAfter,
- after: JSON.stringify(active.getBlocks()),
- onUndone: function () {
- status.textContent = 'Undone.';
- undoButton.style.display = 'none';
- }
- };
- var appliedEntry = entry;
- entry.undo = function () {
- undoEntry(appliedEntry);
- };
- changes.push(entry);
- updateUndoButton();
-
- applyButton.style.display = 'none';
- discardButton.style.display = 'none';
- undoButton.style.display = '';
- status.textContent = 'Applied.';
- });
-
- discardButton.addEventListener('click', function () {
- applyButton.style.display = 'none';
- discardButton.style.display = 'none';
- status.textContent = 'Discarded.';
- });
-
- undoButton.addEventListener('click', function () {
- if (entry) {
- undoEntry(entry);
- }
- });
-
- ui.messages.appendChild(card);
- scrollToEnd();
- }
-
- function setBusy(value) {
- busy = value;
- ui.send.disabled = value;
- ui.input.disabled = value;
- if (value) {
- ui.thinking = addMessage('note', 'Thinking…');
- } else if (ui.thinking) {
- ui.thinking.remove();
- ui.thinking = null;
- }
- }
-
- function send(text) {
- if (busy) {
- return;
- }
-
- text = String(text || '').trim();
- if (text === '') {
- return;
- }
-
- var target = editor();
- if (!target) {
- addMessage('error', 'The editor is still loading. Give it a second.');
- return;
- }
-
- var blocks = target.getBlocks();
- var focusId = findIndex(blocks, lastFocusedBlockId) !== -1 ? lastFocusedBlockId : '';
- var scope = ui.scope.value;
-
- if (scope === 'block') {
- if (focusId === '') {
- addMessage('error', 'Click into a block first, or switch to "Everything".');
- return;
- }
- blocks = blocks.filter(function (block) { return block.id === focusId; });
- }
-
- addMessage('user', text);
- ui.input.value = '';
- setBusy(true);
-
- fetch(endpoint, {
- method: 'POST',
- credentials: 'same-origin',
- headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrfToken },
- body: JSON.stringify({ message: text, blocks: blocks, fields: scope === 'block' ? {} : collectFields(), scope: scope, focus_id: focusId, history: conversation.slice(-10) })
- })
- .then(function (response) {
- return response.json().catch(function () {
- return { ok: false, error: 'Something went wrong on the server. Please try again.' };
- });
- })
- .then(function (data) {
- setBusy(false);
- if (!data.ok) {
- addMessage('error', data.error || 'That didn\'t work. Please try again.');
- return;
- }
-
- conversation.push({ role: 'user', content: text });
- conversation.push({ role: 'assistant', content: data.reply || '' });
- conversation = conversation.slice(-10);
-
- if (data.reply) {
- addMessage('assistant', data.reply);
- }
- if (data.actions && data.actions.length) {
- addProposal(data.actions, data.rejected || 0);
- } else if (data.rejected) {
- addMessage('note', data.rejected + ' suggested change' + (data.rejected === 1 ? ' was' : 's were') + ' blocked by the AI settings.');
- }
- })
- .catch(function () {
- setBusy(false);
- addMessage('error', 'We couldn\'t reach your store. Check your connection.');
- });
- }
-
- function togglePanel(open) {
- ui.panel.classList.toggle('is-open', open);
- ui.fab.classList.toggle('is-hidden', open);
- if (open) {
- ui.input.focus();
- }
- }
-
- function build() {
- ui.fab = el('button', 'ai-fab');
- ui.fab.type = 'button';
- ui.fab.setAttribute('aria-label', 'Open the AI assistant');
- ui.fab.appendChild(icon('sparkles'));
- ui.fab.appendChild(el('span', null, 'AI'));
-
- ui.panel = el('section', 'ai-panel');
- ui.panel.setAttribute('aria-label', 'AI helper');
-
- var header = el('div', 'ai-header');
- var titleBox = el('div', 'ai-header-title');
- titleBox.appendChild(icon('sparkles'));
- var titles = el('div');
- titles.appendChild(el('div', 'ai-title', 'AI helper'));
- titles.appendChild(el('div', 'ai-subtitle', (config.dataset.provider || '') + ' · ' + (config.dataset.model || '')));
- titleBox.appendChild(titles);
- header.appendChild(titleBox);
-
- var headerActions = el('div', 'ai-header-actions');
- ui.undo = el('button', 'dash-btn-small');
- ui.undo.type = 'button';
- ui.undo.title = 'Undo the last change';
- ui.undo.appendChild(icon('refresh'));
- ui.undo.appendChild(document.createTextNode('Undo'));
- ui.undo.disabled = true;
- var closeButton = el('button', 'ai-icon-button');
- closeButton.type = 'button';
- closeButton.setAttribute('aria-label', 'Close the AI assistant');
- closeButton.appendChild(icon('x'));
- headerActions.appendChild(ui.undo);
- headerActions.appendChild(closeButton);
- header.appendChild(headerActions);
-
- ui.messages = el('div', 'ai-messages');
- ui.messages.setAttribute('aria-live', 'polite');
-
- var quick = el('div', 'ai-quick');
- QUICK_ACTIONS.forEach(function (item) {
- if (features.indexOf(item.feature) === -1) {
- return;
- }
- var chip = el('button', 'ai-chip', item.label);
- chip.type = 'button';
- chip.addEventListener('click', function () {
- if (item.send) {
- send(item.text);
- } else {
- ui.input.value = item.text;
- ui.input.focus();
- }
- });
- quick.appendChild(chip);
- });
-
- var scopeRow = el('div', 'ai-scope');
- scopeRow.appendChild(el('label', null, 'Scope'));
- ui.scope = el('select');
- [['article', 'Everything'], ['block', 'Selected block']].forEach(function (option) {
- var node = el('option', null, option[1]);
- node.value = option[0];
- ui.scope.appendChild(node);
- });
- scopeRow.appendChild(ui.scope);
-
- var composer = el('div', 'ai-composer');
- ui.input = el('textarea');
- ui.input.rows = 2;
- ui.input.maxLength = 4000;
- ui.input.placeholder = 'What should I help with?';
- ui.send = el('button', 'dash-btn dash-btn-primary ai-send');
- ui.send.type = 'button';
- ui.send.setAttribute('aria-label', 'Send');
- ui.send.appendChild(icon('arrow-up'));
- composer.appendChild(ui.input);
- composer.appendChild(ui.send);
-
- ui.panel.appendChild(header);
- ui.panel.appendChild(ui.messages);
- if (quick.childNodes.length) {
- ui.panel.appendChild(quick);
- }
- ui.panel.appendChild(scopeRow);
- ui.panel.appendChild(composer);
-
- document.body.appendChild(ui.fab);
- document.body.appendChild(ui.panel);
-
- 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.');
-
- ui.fab.addEventListener('click', function () { togglePanel(true); });
- closeButton.addEventListener('click', function () { togglePanel(false); });
- ui.undo.addEventListener('click', function () {
- if (changes.length) {
- changes[changes.length - 1].undo();
- }
- });
- ui.send.addEventListener('click', function () { send(ui.input.value); });
- ui.input.addEventListener('keydown', function (event) {
- if (event.key === 'Enter' && !event.shiftKey && !event.isComposing) {
- event.preventDefault();
- send(ui.input.value);
- }
- });
- ui.panel.addEventListener('keydown', function (event) {
- if (event.key === 'Escape') {
- togglePanel(false);
- }
- event.stopPropagation();
- });
-
- document.addEventListener('focusin', function (event) {
- var wrapper = event.target && event.target.closest ? event.target.closest('.be-block') : null;
- if (wrapper && wrapper.dataset.blockId) {
- lastFocusedBlockId = wrapper.dataset.blockId;
- }
- });
- }
-
- function init() {
- build();
- }
-
- if (document.readyState === 'loading') {
- document.addEventListener('DOMContentLoaded', init);
- } else {
- init();
- }
- })();
-