UNPKG

@flowfuse/nr-assistant

Version:
986 lines (940 loc) 70.9 kB
<script src="/resources/@flowfuse/nr-assistant/sharedUtils.js"></script> <script> /* global FFAssistantUtils */ /* loaded from sharedUtils.js */ /* global RED, $ */ /* loaded from Node-RED core */ (function (RED, n) { 'use strict' /** * @typedef {Object} PromptOptions * @property {string} method - The method used for routing the call in the API (e.g. 'function', 'json'). * @property {string} lang - The language type to be generated (e.g. 'javascript', 'json', 'yaml'). * @property {string} type - The type of the node. * @property {string} [selectedText] - Any selected text. // future feature */ /** * @typedef {Object} PromptUIOptions * @property {string} title - The title of the FlowFuse Assistant. * @property {string} explanation - The explanation of what the FlowFuse Assistant can help with. * @property {string} description - A short description of what you want the FlowFuse Assistant to do. */ const AI_TIMEOUT = 90000 // default request timeout in milliseconds const modulesAllowed = RED.settings.functionExternalModules !== false const assistantOptions = { enabled: false, requestTimeout: AI_TIMEOUT } let assistantInitialised = false let mcpReady = false debug('loading...') const plugin = { type: 'assistant', name: 'Node-RED Assistant Plugin', icon: 'font-awesome/fa-magic', onadd: async function () { if (!window.FFAssistantUtils) { console.warn('FFAssistantUtils lib is not loaded. Completions might not work as expected.') } RED.comms.subscribe('nr-assistant/#', (topic, msg) => { debug('comms', topic, msg) if (topic === 'nr-assistant/initialise') { assistantOptions.enabled = !!msg?.enabled assistantOptions.requestTimeout = msg?.requestTimeout || AI_TIMEOUT initAssistant(msg) RED.actions.add('flowfuse-nr-assistant:function-builder', showFunctionBuilderPrompt, { label: '@flowfuse/nr-assistant/flowfuse-nr-assistant:function-builder.action.label' }) setMenuShortcutKey('ff-assistant-function-builder', 'red-ui-workspace', 'ctrl-alt-f', 'flowfuse-nr-assistant:function-builder') } if (topic === 'nr-assistant/mcp/ready') { mcpReady = !!msg?.enabled && assistantOptions.enabled if (mcpReady) { debug('assistant MCP initialised') RED.actions.add('flowfuse-nr-assistant:explain-flows', explainSelectedNodes, { label: '@flowfuse/nr-assistant/flowfuse-nr-assistant:explain-flows.action.label' }) const menuEntry = { id: 'ff-assistant-explain-flows', icon: 'ff-assistant-menu-icon explain-flows', label: `<span>${plugin._('explain-flows.menu.label')}</span>`, sublabel: plugin._('explain-flows.menu.description'), onselect: 'flowfuse-nr-assistant:explain-flows', shortcutSpan: $('<span class="red-ui-popover-key"></span>'), visible: true } RED.menu.addItem('red-ui-header-button-ff-ai', menuEntry) setMenuShortcutKey('ff-assistant-explain-flows', 'red-ui-workspace', 'ctrl-alt-e', 'flowfuse-nr-assistant:explain-flows') } } }) } } RED.plugins.registerPlugin('flowfuse-nr-assistant', plugin) function initAssistant () { if (assistantInitialised) { return } debug('initialising...') if (!assistantOptions.enabled) { console.warn(plugin._('errors.assistant-not-enabled')) return } if (!window.monaco) { console.warn('Monaco editor not found. Unable to register code lens provider. Consider using the Monaco editor for a better experience.') return } const funcCommandId = 'nr-assistant-fn-inline' const jsonCommandId = 'nr-assistant-json-inline' const cssCommandId = 'nr-assistant-css-inline' const db2uiTemplateCommandId = 'nr-assistant-html-dashboard2-template-inline' debug('registering code lens providers...') monaco.languages.registerCodeLensProvider('javascript', { provideCodeLenses: function (model, token) { const thisEditor = getMonacoEditorForModel(model) if (!thisEditor) { return } const node = RED.view.selection()?.nodes?.[0] // only support function nodes for now if (!node || !node.type === 'function') { return } // Only support the "on message" editor for now // determine which editor is active and if it the "on message" editor // if not, return nothing to prevent the code lens from showing let isFuncTabEditor let el = thisEditor.getDomNode() while (el && el.tagName !== 'FORM') { if (el.id === 'node-input-func-editor' || el.id === 'func-tab-body') { isFuncTabEditor = true break } el = el.parentNode } if (!isFuncTabEditor) { return } return { lenses: [ { range: { startLineNumber: 1, startColumn: 1, endLineNumber: 2, endColumn: 1 }, id: funcCommandId } ], dispose: () => { } } }, resolveCodeLens: function (model, codeLens, token) { if (codeLens.id !== funcCommandId) { return codeLens } codeLens.command = { id: codeLens.id, title: 'Ask the FlowFuse Assistant 🪄', tooltip: 'Click to ask FlowFuse Assistant for help writing code', arguments: [model, codeLens, token] } return codeLens } }) monaco.languages.registerCodeLensProvider('json', { provideCodeLenses: function (model, token) { const thisEditor = getMonacoEditorForModel(model) if (!thisEditor) { return } return { lenses: [ { range: { startLineNumber: 1, startColumn: 1, endLineNumber: 2, endColumn: 1 }, id: jsonCommandId } ], dispose: () => { } } }, resolveCodeLens: function (model, codeLens, token) { if (codeLens.id !== jsonCommandId) { return codeLens } codeLens.command = { id: codeLens.id, title: 'Ask the FlowFuse Assistant 🪄', tooltip: 'Click to ask FlowFuse Assistant for help with JSON', arguments: [model, codeLens, token] } return codeLens } }) monaco.languages.registerCodeLensProvider('css', { provideCodeLenses: function (model, token) { debug('CSS CodeLens provider called', model, token) const thisEditor = getMonacoEditorForModel(model) const node = RED.view.selection()?.nodes?.[0] if (!thisEditor || !node) { return } return { lenses: [ { range: { startLineNumber: 1, startColumn: 1, endLineNumber: 2, endColumn: 1 }, id: cssCommandId } ], dispose: () => { } } }, resolveCodeLens: function (model, codeLens, token) { debug('CSS CodeLens resolve called', model, codeLens, token) if (codeLens.id !== cssCommandId) { return codeLens } codeLens.command = { id: codeLens.id, title: 'Ask the FlowFuse Assistant 🪄', tooltip: 'Click to ask FlowFuse Assistant for help with CSS', arguments: [model, codeLens, token] } return codeLens } }) monaco.languages.registerCodeLensProvider('html', { provideCodeLenses: function (model, token) { debug('HTML CodeLens provider called', model, token) const thisEditor = getMonacoEditorForModel(model) if (!thisEditor) { return } const node = RED.view.selection()?.nodes?.[0] // only support dashboard2 ui-template nodes for now if (!node || node.type !== 'ui-template' || node._def?.set?.id !== '@flowfuse/node-red-dashboard/ui-template') { return } return { lenses: [ { range: { startLineNumber: 1, startColumn: 1, endLineNumber: 2, endColumn: 1 }, id: db2uiTemplateCommandId } ], dispose: () => { } } }, resolveCodeLens: function (model, codeLens, token) { debug('HTML CodeLens resolve called', model, codeLens, token) if (codeLens.id !== db2uiTemplateCommandId) { return codeLens } codeLens.command = { id: codeLens.id, title: 'Ask the FlowFuse Assistant 🪄', tooltip: 'Click to ask FlowFuse Assistant for help with VUE or HTML', arguments: [model, codeLens, token] } return codeLens } }) debug('registering commands...') monaco.editor.registerCommand(funcCommandId, function (accessor, model, codeLens, token) { debug('running command', funcCommandId) const node = RED.view.selection()?.nodes?.[0] if (!node) { console.warn('No node selected') // should not happen return } if (!assistantOptions.enabled) { RED.notify(plugin._('errors.assistant-not-enabled'), 'warning') return } const thisEditor = getMonacoEditorForModel(model) if (thisEditor) { if (!document.body.contains(thisEditor.getDomNode())) { console.warn('Editor is no longer in the DOM, cannot proceed.') return } // walk up the tree to find the parent div with an id and include that in context let subType = 'on message' let parent = thisEditor.getDomNode().parentNode while (parent?.tagName !== 'FORM') { if (parent.id) { break } parent = parent.parentNode } switch (parent?.id) { case 'func-tab-init': case 'node-input-init-editor': subType = 'on start' break case 'func-tab-body': case 'node-input-func-editor': subType = 'on message' break case 'func-tab-finalize': case 'node-input-finalize-editor': subType = 'on message' break } // FUTURE: for including selected text in the context for features like "fix my code", "refactor this", "what is this?" etc // const userSelection = triggeredEditor.getSelection() // const selectedText = model.getValueInRange(userSelection) /** @type {PromptOptions} */ const promptOptions = { method: 'function', lang: 'javascript', type: 'function', subType // selectedText: model.getValueInRange(userSelection) } /** @type {PromptUIOptions} */ const uiOptions = { title: 'FlowFuse Assistant : Function Code', explanation: 'The FlowFuse Assistant can help you write JavaScript code.', description: 'Enter a short description of what you want it to do.' } doPrompt(node, thisEditor, promptOptions, uiOptions, (error, response) => { if (error) { console.warn('Error processing request', error) return } debug('function response', response) const responseData = response?.data if (responseData?.func?.length > 0) { // ensure the editor is still present in the DOM if (!document.body.contains(thisEditor.getDomNode())) { console.warn('Editor is no longer in the DOM') return } thisEditor.focus() // insert the generated code at the current cursor position overwriting any selected text const currentSelection = thisEditor.getSelection() thisEditor.executeEdits('', [ { range: new monaco.Range(currentSelection.startLineNumber, currentSelection.startColumn, currentSelection.endLineNumber, currentSelection.endColumn), text: responseData.func } ]) // update the nodes output count the AI suggests a different number of outputs if (typeof responseData?.outputs === 'number' && responseData.outputs >= 0) { const outputsField = $('#node-input-outputs') const currentOutputs = parseInt(outputsField.val()) if (!isNaN(currentOutputs) && typeof currentOutputs === 'number' && currentOutputs !== responseData.outputs) { outputsField.val(responseData.outputs) outputsField.trigger('change') } } // update libs - get the current list of libs then scan the response for any new ones // if the lib is not already in the list, add it if (modulesAllowed) { if (Array.isArray(responseData?.node_modules) && responseData.node_modules.length > 0) { const _libs = [] const libs = $('#node-input-libs-container').editableList('items') libs.each(function (i) { const item = $(this) const v = item.find('.node-input-libs-var').val() let n = item.find('.node-input-libs-val').typedInput('type') if (n === '_custom_') { n = item.find('.node-input-libs-val').val() } if ((!v || (v === '')) || (!n || (n === ''))) { return } _libs.push({ var: v, module: n }) }) responseData.node_modules.forEach((lib) => { const existing = _libs.find(l => l.module === lib.module) if (!existing) { $('#node-input-libs-container').editableList('addItem', { var: lib.var, module: lib.module }) } }) } } } }) } else { console.warn('Could not find editor for model', model.uri.toString()) } }) monaco.editor.registerCommand(jsonCommandId, function (accessor, model, codeLens, token) { debug('running command', jsonCommandId) const node = RED.view.selection()?.nodes?.[0] if (!node) { console.warn('No node selected') // should not happen return } if (!assistantOptions.enabled) { RED.notify(plugin._('errors.assistant-not-enabled'), 'warning') return } const thisEditor = getMonacoEditorForModel(model) if (thisEditor) { if (!document.body.contains(thisEditor.getDomNode())) { console.warn('Editor is no longer in the DOM, cannot proceed.') return } // FUTURE: for including selected text in the context for features like "fix my code", "refactor this", "what is this?" etc // const userSelection = triggeredEditor.getSelection() // const selectedText = model.getValueInRange(userSelection) /** @type {PromptOptions} */ const promptOptions = { method: 'json', lang: 'json', type: node.type // selectedText: model.getValueInRange(userSelection) } /** @type {PromptUIOptions} */ const uiOptions = { title: 'FlowFuse Assistant : JSON', explanation: 'The FlowFuse Assistant can help you write JSON.', description: 'Enter a short description of what you want it to do.' } doPrompt(node, thisEditor, promptOptions, uiOptions, (error, response) => { if (error) { console.warn('Error processing request', error) return } debug('json response', response) const responseData = response?.data if (responseData && responseData.json) { // ensure the editor is still present in the DOM if (!document.body.contains(thisEditor.getDomNode())) { console.warn('Editor is no longer in the DOM') return } thisEditor.focus() const currentSelection = thisEditor.getSelection() thisEditor.executeEdits('', [ { range: new monaco.Range(currentSelection.startLineNumber, currentSelection.startColumn, currentSelection.endLineNumber, currentSelection.endColumn), text: responseData.json } ]) } }) } else { console.warn('Could not find editor for model', model.uri.toString()) } }) monaco.editor.registerCommand(cssCommandId, function (accessor, model, codeLens, token) { debug('running command', cssCommandId) const node = RED.view.selection()?.nodes?.[0] if (!node) { console.warn('No node selected') // should not happen return } if (!assistantOptions.enabled) { RED.notify(plugin._('errors.assistant-not-enabled'), 'warning') return } const thisEditor = getMonacoEditorForModel(model) if (thisEditor) { if (!document.body.contains(thisEditor.getDomNode())) { console.warn('Editor is no longer in the DOM, cannot proceed.') return } // FUTURE: for including selected text in the context for features like "fix my code", "refactor this", "what is this?" etc // const userSelection = triggeredEditor.getSelection() // const selectedText = model.getValueInRange(userSelection) /** @type {PromptOptions} */ const promptOptions = { method: 'css', lang: 'css', type: node.type // selectedText: model.getValueInRange(userSelection) } /** @type {PromptUIOptions} */ const uiOptions = { title: 'FlowFuse Assistant : CSS', explanation: 'The FlowFuse Assistant can help you write CSS.', description: 'Enter a short description of what you want it to do.' } doPrompt(node, thisEditor, promptOptions, uiOptions, (error, response) => { if (error) { console.warn('Error processing request', error) return } debug('css response', response) const responseData = response?.data if (responseData && responseData.css) { // ensure the editor is still present in the DOM if (!document.body.contains(thisEditor.getDomNode())) { console.warn('Editor is no longer in the DOM') return } thisEditor.focus() const currentSelection = thisEditor.getSelection() thisEditor.executeEdits('', [ { range: new monaco.Range(currentSelection.startLineNumber, currentSelection.startColumn, currentSelection.endLineNumber, currentSelection.endColumn), text: responseData.css } ]) } }) } else { console.warn('Could not find editor for model', model.uri.toString()) } }) monaco.editor.registerCommand(db2uiTemplateCommandId, function (accessor, model, codeLens, token) { debug('running command', db2uiTemplateCommandId) const node = RED.view.selection()?.nodes?.[0] if (!node) { console.warn('No node selected') // should not happen return } if (!assistantOptions.enabled) { RED.notify(plugin._('errors.assistant-not-enabled'), 'warning') return } const thisEditor = getMonacoEditorForModel(model) if (thisEditor) { if (!document.body.contains(thisEditor.getDomNode())) { console.warn('Editor is no longer in the DOM, cannot proceed.') return } // FUTURE: for including selected text in the context for features like "fix my code", "refactor this", "what is this?" etc // const userSelection = triggeredEditor.getSelection() // const selectedText = model.getValueInRange(userSelection) /** @type {PromptOptions} */ const promptOptions = { method: 'dashboard2-template', lang: 'html', type: node.type // selectedText: model.getValueInRange(userSelection) } /** @type {PromptUIOptions} */ const uiOptions = { title: 'FlowFuse Assistant : Dashboard 2 UI Template', explanation: 'The FlowFuse Assistant can help you write HTML, VUE and JavaScript.', description: 'Enter a short description of what you want it to do.' } doPrompt(node, thisEditor, promptOptions, uiOptions, (error, response) => { if (error) { console.warn('Error processing request', error) return } debug('html response', response) const responseData = response?.data if (responseData && responseData.html) { // ensure the editor is still present in the DOM if (!document.body.contains(thisEditor.getDomNode())) { console.warn('Editor is no longer in the DOM') return } thisEditor.focus() const currentSelection = thisEditor.getSelection() thisEditor.executeEdits('', [ { range: new monaco.Range(currentSelection.startLineNumber, currentSelection.startColumn, currentSelection.endLineNumber, currentSelection.endColumn), text: responseData.html } ]) } }) } else { console.warn('Could not find editor for model', model.uri.toString()) } }) const toolbarMenuButton = $('<li><a id="red-ui-header-button-ff-ai" class="button" href="#"></a></li>') const toolbarMenuButtonAnchor = toolbarMenuButton.find('a') const deployButtonLi = $('#red-ui-header-button-deploy').closest('li') if (deployButtonLi.length) { deployButtonLi.before(toolbarMenuButton) // add the button before the deploy button } else { toolbarMenuButton.prependTo('.red-ui-header-toolbar') // add the button leftmost of the toolbar } RED.popover.tooltip(toolbarMenuButtonAnchor, plugin._('name')) /* NOTE: For the menu entries icons' property... If `.icon` is a URL (e.g. resource/xxx/icon.svg), the RED.menu API will add it as an <img> tag. That makes it impossible to set the fill colour of the SVG PATH via a CSS var. So, by not specifying an icon URL, an <i> tag with the class set to <icon> will be created by the API This permits us to use CSS classes (defined below) that can set the icon and affect the fill colour */ debug('Building FlowFuse Assistant menu') const ffAssistantMenu = [ { id: 'ff-assistant-title', label: plugin._('name'), visible: true }, // header null, // separator { id: 'ff-assistant-function-builder', icon: 'ff-assistant-menu-icon function', label: `<span>${plugin._('function-builder.menu.label')}</span>`, sublabel: plugin._('function-builder.menu.description'), onselect: 'flowfuse-nr-assistant:function-builder', shortcutSpan: $('<span class="red-ui-popover-key"></span>'), visible: true } ] RED.menu.init({ id: 'red-ui-header-button-ff-ai', options: ffAssistantMenu }) assistantInitialised = true } const previousPrompts = {} /** * Prompts the user for input and sends the input to the AI for processing * @param {import('node-red').Node} node - The node to associate the prompt with * @param {import('monaco-editor').editor.IStandaloneCodeEditor} editor - The editor to associate the prompt with * @param {PromptOptions} promptOptions - The options to pass to the prompt * @param {PromptUIOptions} [uiOptions] - The options to pass to the prompt * @param {(error, response) => {}} callback - The callback function to call when the prompt is complete * @returns {void} */ function doPrompt (node, editor, promptOptions, uiOptions, callback) { const thisEditor = editor let xhr = null if (!node || !thisEditor) { console.warn('No node or editor found') callback(null, null) } const modulesAllowed = RED.settings.functionExternalModules !== false promptOptions = promptOptions || {} const nodeId = node.id const transactionId = `${nodeId}-${Date.now()}` // a unique id for this transaction const prevPromptKey = `${promptOptions?.method}-${promptOptions?.subType || 'default'}` const defaultInput = Object.prototype.hasOwnProperty.call(uiOptions, 'defaultInput') ? uiOptions.defaultInput : previousPrompts[prevPromptKey] || '' debug('doPrompt', promptOptions, uiOptions) getUserInput({ defaultInput: defaultInput || '', title: uiOptions?.title || plugin._('name'), explanation: uiOptions?.explanation || plugin._('name') + ' can help you write code.', description: uiOptions?.description || 'Enter a short description of what you want it to do.' }).then((prompt) => { if (!prompt) { callback(null, null) } previousPrompts[prevPromptKey] = prompt const data = { prompt, transactionId, context: { type: promptOptions.type, subType: promptOptions.subType, scope: 'inline', // inline denotes that the prompt is for a inline code (i.e. the monaco editor) modulesAllowed, codeSection: promptOptions.subType // selection: selectedText // FUTURE: include the selected text in the context for features like "fix my code", "refactor this", "what is this?" etc } } const busyNotification = showBusyNotification(plugin._('notifications.busy'), function () { if (xhr) { xhr.abort('abort') xhr = null } }) xhr = $.ajax({ url: 'nr-assistant/' + (promptOptions.method || promptOptions.lang), // e.g. 'nr-assistant/json' type: 'POST', data, success: function (reply, textStatus, jqXHR) { debug('doPrompt -> ajax -> success', reply) if (reply?.error) { RED.notify(reply.error, 'error') callback(new Error(reply.error), null) return } if (reply?.data?.transactionId !== transactionId) { callback(new Error('Transaction ID mismatch'), null) return } callback(null, reply?.data) }, error: (jqXHR, textStatus, errorThrown) => { debug('doPrompt -> ajax -> error', jqXHR, textStatus, errorThrown) if (textStatus === 'abort' || errorThrown === 'abort' || jqXHR.statusText === 'abort') { // user cancelled callback(null, null) return } processAIErrorResponse(jqXHR, textStatus, errorThrown) callback(new Error('Error processing request'), null) }, complete: function () { xhr = null busyNotification.close() } }) }) } function getUserInput ({ title, explanation, description, placeholder, defaultInput } = { title: plugin._('name'), explanation: 'The FlowFuse Assistant can help you create things.', description: 'Enter a short description explaining what you want it to do.', placeholder: '', defaultInput: '' }) { const bodyText = [] if (explanation) { bodyText.push(`<p style="">${explanation}</p>`) } if (description) { bodyText.push(`<p>${description}</p>`) } const body = bodyText.join('') return new Promise((resolve, reject) => { const dialog = $('<div id="ff-nr-ai-dialog-input" class="hide red-ui-editor"></div>') const containerDiv = $('<div style="height: 100%;display: flex;flex-direction: column; height: calc(100% - 12px);">') if (body) { containerDiv.append('<div style="margin-bottom: 8px; margin-top: -10px">' + body + '</div>') } const form = $('<form id="ff-nr-ai-dialog-input-fields" style="flex-grow: 1; margin-bottom: 6px;"></form>') const input = $('<textarea id="ff-nr-ai-dialog-input-editor" style="height:100%;width:100%; position:relative; resize: none;" maxlength="400" placeholder="' + (placeholder || '') + '">' + (defaultInput || '') + '</textarea>') form.append(input) containerDiv.append(form) dialog.append(containerDiv) const minHeight = 260 + (description ? 32 : 0) + (explanation ? 32 : 0) const minWidth = 480 const dialogControl = dialog.dialog({ title: title || plugin._('name'), modal: true, closeOnEscape: true, height: minHeight, width: minWidth, minHeight, minWidth, resizable: true, draggable: true, show: { effect: 'fade', duration: 300 }, hide: { effect: 'fade', duration: 300 }, open: function (event, ui) { RED.keyboard.disable() input.focus() input.select() }, close: function (event, ui) { RED.keyboard.enable() dialogControl.remove() }, buttons: [ { text: 'Ask the FlowFuse Assistant 🪄', // class: 'primary', click: function () { const prompt = dialog.find('#ff-nr-ai-dialog-input-editor').val() resolve(prompt) $(this).dialog('close') } } ], cancel: function (event, ui) { resolve(null) $(this).dialog('close') } }) }) } let previousFunctionBuilderPrompt function showFunctionBuilderPrompt (title) { if (!assistantOptions.enabled) { RED.notify(plugin._('errors.assistant-not-enabled'), 'warning') return } getUserInput({ defaultInput: previousFunctionBuilderPrompt, title: title || 'FlowFuse Assistant : Create A Function Node', explanation: plugin._('function-builder.dialog-input.explanation'), description: plugin._('function-builder.dialog-input.description') }).then((prompt) => { /** @type {JQueryXHR} */ let xhr = null if (prompt) { previousFunctionBuilderPrompt = prompt const url = 'nr-assistant/function' const transactionId = generateId(8) + '-' + Date.now() // a unique id for this transaction const body = { prompt, transactionId, context: { scope: 'node', // "node" denotes that the prompt is for a generating a function node (as importable JSON flow code) modulesAllowed } } const busyNotification = showBusyNotification(plugin._('notifications.busy'), function () { if (xhr) { xhr.abort('abort') xhr = null } }) xhr = $.ajax({ url, type: 'POST', data: body, timeout: assistantOptions.requestTimeout, success: (reply, textStatus, jqXHR) => { // console.log('showFunctionBuilderPrompt -> ajax -> success', reply) busyNotification.close() try { const flowJson = typeof reply?.data?.flow === 'string' ? JSON.parse(reply.data.flow) : reply?.data?.flow if (flowJson && Array.isArray(flowJson) && flowJson.length > 0) { importFlow(flowJson) } else { processAIErrorResponse(jqXHR, textStatus, 'No data in response from server') } } catch (error) { RED.notify('Sorry, something went wrong, please try again', 'error') } }, error: (jqXHR, textStatus, errorThrown) => { // console.log('showFunctionBuilderPrompt -> ajax -> error', jqXHR, textStatus, errorThrown) busyNotification.close() if (textStatus === 'abort' || errorThrown === 'abort' || jqXHR.statusText === 'abort') { // user cancelled return } processAIErrorResponse(jqXHR, textStatus, errorThrown) } }) } }) } /** * Show a dialog with the given title and content. * * NOTE: Only basic sanitization is performed. Call RED.utils.renderMarkdown or RED.utils.sanitize before passing it in. * @param {string} title - The title of the dialog * @param {string} content - The content of the dialog, can be HTML * @param {'text'|'html'} [renderMode='text'] - Whether the content is HTML or plain text. * @param {object} [options] - jQuery UI Options for the dialog */ function showMessage (title, content, renderMode = 'text', options = {}) { const window70vw = $(window).width() * 0.70 // 70% of viewport width const window70vh = $(window).height() * 0.70 // 70% of viewport height // super basic sanitisation for xss in content if (typeof content !== 'string' || content.length === 0) { console.warn('Content must be a string') return } else if (renderMode === 'html' && (content.includes('<script>') || content.includes('<' + '/script>'))) { content = content.replace(/<script>/g, '```\n').replace(/<\/script>/g, '```') // basic sanitization } // First some basic sizing calculations to ensure the dialog is not too small/large let initialMaxWidth = $('#red-ui-notifications').width() || 500 // start off with 500px as the initial maxWidth if (content.length > 3000) { initialMaxWidth = 1000 } else if (content.length > 2000) { initialMaxWidth = 850 } else if (content.length > 1500) { initialMaxWidth = 700 } else if (content.length > 750) { initialMaxWidth = 600 } // Now render the content as HTML so later we can gauge its size and dynamically adjust to keep it reasonable const $dialog = $('<div>') $dialog.css({ overflow: 'auto', height: 'auto', width: 'auto', minHeight: 200, maxHeight: window70vh, minWidth: window.innerWidth > 500 ? 500 : initialMaxWidth < window70vw ? initialMaxWidth : window70vw, // minimum width of 500px if possible maxWidth: initialMaxWidth, display: 'none' // initially hidden }) if (renderMode === 'html') { $dialog.html(content) // render as HTML } else { $dialog.text(content) // render as plain text } $dialog.appendTo('body') const initialWidth = $dialog.width() const width = initialWidth > window70vw ? window70vw : initialWidth const initialHeight = $dialog.height() + 40 // +40 for padding/grace etc const height = initialHeight > window70vh ? window70vh : initialHeight // now remove the divs min/max so that it sizes to the maximum size of the jquery dialog $dialog.css({ maxWidth: '', minWidth: '', maxHeight: '', minHeight: '' }) // set the default dialog options const defaultOptions = { title: title || plugin._('name'), modal: true, resizable: true, width, height, dialogClass: 'ff-nr-ai-dialog-message', closeOnEscape: true, draggable: true, minHeight: 280, minWidth: window.innerWidth > 600 ? 600 : window.innerWidth, // minimum width of 500px if possible show: { effect: 'fade', duration: 300 }, hide: { effect: 'fade', duration: 300 }, open: function () { RED.keyboard.disable() }, close: function () { RED.keyboard.enable() $(this).dialog('destroy').remove() // clean up } } // create the dialog with any additional options passed in & return it for later programmatic control return $dialog.dialog($.extend({}, defaultOptions, options)) } function explainSelectedNodes () { if (!assistantOptions.enabled) { RED.notify(plugin._('errors.assistant-not-enabled'), 'warning') return } const selection = RED.view.selection() if (!selection || !selection.nodes || selection.nodes.length === 0) { RED.notify(plugin._('explain-flows.errors.no-nodes-selected'), 'warning') return } const { flow: nodes, nodeCount: totalNodeCount } = FFAssistantUtils.cleanFlow(selection.nodes) if (totalNodeCount > 100) { // TODO: increase or make configurable RED.notify(plugin._('explain-flows.errors.too-many-nodes-selected'), 'warning') return } /** @type {JQueryXHR} */ let xhr = null const url = 'nr-assistant/mcp/prompts/explain_flow' // e.g. 'nr-assistant/json' const transactionId = generateId(8) + '-' + Date.now() // a unique id for this transaction const data = { transactionId, nodes: JSON.stringify(nodes), flowName: '', // FUTURE: include the parent flow name in the context to aid with the explanation userContext: '' // FUTURE: include user textual input context for more personalized explanations } const busyNotification = showBusyNotification(plugin._('notifications.busy'), function () { if (xhr) { xhr.abort('abort') xhr = null } }) xhr = $.ajax({ url, type: 'POST', data, timeout: assistantOptions.requestTimeout, success: (reply, textStatus, jqXHR) => { busyNotification.close() if (reply?.error) { RED.notify(reply.error, 'error') // callback(new Error(reply.error), null) return } try { const text = reply.data let dlg = null const options = { buttons: [{ text: plugin._('explain-flows.dialog-result.close-button'), icon: 'ui-icon-close', class: 'primary', click: function () { $(dlg).dialog('close') } }] } if (text && te