UNPKG

@flowfuse/nr-assistant

Version:
808 lines (774 loc) 41.9 kB
<script> (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 debug('loading...') RED.plugins.registerPlugin('flowfuse-nr-assistant', { type: 'assistant', name: 'Node-RED Assistant Plugin', icon: 'font-awesome/fa-magic', onadd: async function () { 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) } }) } }) function initAssistant () { if (assistantInitialised) { return } debug('initialising...') if (!assistantOptions.enabled) { console.warn('The FlowFuse Assistant is 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 } else { const funcCommandId = 'nr-assistant-fn-inline' const jsonCommandId = 'nr-assistant-json-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 } }) 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('The FlowFuse Assistant is 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('The FlowFuse Assistant is 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()) } }) } // setup actions for function builder const funcBuilderTitle = 'FlowFuse Function Node Assistant' RED.actions.add('ff:nr-assistant-function-builder', showFunctionBuilderPrompt, { label: funcBuilderTitle }) // FUTURE: setup actions for flow builder // const flowBuilderTitle = 'FlowFuse Flow Assistant' // RED.actions.add('ff:nr-assistant-flow-builder', showFlowBuilderPrompt, { label: flowBuilderTitle }) const toolbarMenuButton = $('<li><a id="red-ui-header-button-ff-ai" class="button" href="#"></a></li>') const toolbarMenuButtonAnchor = toolbarMenuButton.find('a') toolbarMenuButtonAnchor.css('mask-image', 'url("resources/@flowfuse/nr-assistant/assistant-button.svg")') toolbarMenuButtonAnchor.css('mask-repeat', 'no-repeat') toolbarMenuButtonAnchor.css('mask-position', 'center') toolbarMenuButtonAnchor.css('background-color', 'currentColor') toolbarMenuButtonAnchor.css('height', '28px') // for backwards compatibility with <= NR3.x inline-block toolbar styling 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 } toolbarMenuButtonAnchor.on('click', function (e) { RED.actions.invoke('ff:nr-assistant-function-builder') }) RED.popover.tooltip(toolbarMenuButtonAnchor, 'FlowFuse Assistant') 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 || 'FlowFuse Assistant', explanation: uiOptions?.explanation || 'The FlowFuse Assistant 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('Busy processing your request. Please wait...', 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: 'FlowFuse Assistant', 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 dialog.dialog({ autoOpen: true, title: title || 'FlowFuse Assistant', modal: true, closeOnEscape: true, height: minHeight, width: minWidth, minHeight, minWidth, resizable: true, draggable: true, open: function (event, ui) { RED.keyboard.disable() input.focus() input.select() }, close: function (event, ui) { RED.keyboard.enable() }, 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('The FlowFuse Assistant is not enabled', 'warning') return } getUserInput({ defaultInput: previousFunctionBuilderPrompt, title: title || 'FlowFuse Assistant : Create A Function Node', explanation: 'The FlowFuse Assistant can help you create a Function Node.', description: 'Enter a short description of what you want it to do.' }).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('Busy processing your request. Please wait...', 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) } }) } }) } let previousFlowBuilderPrompt // eslint-disable-next-line no-unused-vars function showFlowBuilderPrompt (title) { if (!assistantOptions.enabled) { RED.notify('The FlowFuse Assistant is not enabled', 'warning') return } getUserInput({ defaultInput: previousFlowBuilderPrompt, title: title || 'FlowFuse Assistant : Flow Builder', explanation: 'The FlowFuse Assistant can help you create a new flow.', description: 'Enter a short description of what you want the flow to do.' }).then((prompt) => { /** @type {JQueryXHR} */ let xhr = null if (prompt) { previousFlowBuilderPrompt = prompt const url = 'nr-assistant/flow' const transactionId = generateId(8) + '-' + Date.now() // a unique id for this transaction const body = { prompt, transactionId, context: { modulesAllowed } } const busyNotification = showBusyNotification('Busy processing your request. Please wait...', function () { if (xhr) { xhr.abort('abort') xhr = null } }) xhr = $.ajax({ url, type: 'POST', data: body, timeout: assistantOptions.requestTimeout, success: (reply, textStatus, jqXHR) => { 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) => { busyNotification.close() if (textStatus === 'abort' || errorThrown === 'abort' || jqXHR.statusText === 'abort') { // user cancelled return } processAIErrorResponse(jqXHR, textStatus, errorThrown) } }) } }) } /** * Shows a busy notification with a cancel button * @param {string} [message] - The message to display in the notification * @param {function} [onCancel] - The function to call when the cancel button is clicked * @param {object} [context] - The context object to pass to the onCancel callback function * @returns {{close: () => {}}} - The notification object */ function showBusyNotification (message, onCancel, context, poop) { message = message || 'Busy processing your request. Please wait...' const busyMessage = $('<div>') $('<span>').text(message).appendTo(busyMessage) $('<i>').addClass('fa fa-spinner fa-spin fa-fw').appendTo(busyMessage) // eslint-disable-next-line prefer-const let notification const buttons = [ { text: 'cancel', click: function () { if (onCancel) { const result = onCancel(context) if (result === false) { return } } notification.close() } } ] notification = showNotification(busyMessage, { type: 'success', timeout: 0, fixed: true, modal: true, buttons }) return notification } /** * Shows a busy notification with a cancel button * @param {string} message - The message to display in the notification * @param {object} [options] - The options to pass to the notification * @param {'error'|'warning'|'success'|'compact'} [options.type] - The type of notification to display * @param {Number} [options.timeout] - How long the notification should be shown for, in milliseconds. Default: 5000. This is ignored if the fixed property is set. * @param {boolean} [options.fixed] - Do not hide the notification after a timeout. This also prevents the click-to-close default behaviour of the notification. * @param {boolean} [options.modal] - Whether the notification should be modal * @param {array} [options.buttons] - An array of buttons to display in the notification. Each button should be an object with a text property and a click property. The click property should be a function to call when the button is clicked. The function will be passed the context object. * @returns {{close: () => {}}} - The notification object */ function showNotification (message, options) { options = options || {} options.type = options.type || 'success' options.timeout = Object.prototype.hasOwnProperty.call(options, 'timeout') ? options.timeout : 5000 options.fixed = options.fixed || false options.modal = options.modal || false const pendingNotification = RED.notify(message, options) return pendingNotification } function importFlow (flow, addFlow) { if (RED.workspaces.isLocked && RED.workspaces.isLocked()) { addFlow = true // force import to create a new tab } let newNodes = flow try { if (typeof flow === 'string') { try { flow = flow.trim() if (flow.length === 0) { return } newNodes = JSON.parse(flow) } catch (err) { const e = new Error(RED._('clipboard.invalidFlow', { message: err.message })) e.code = 'NODE_RED' throw e } } const importOptions = { generateIds: true, addFlow } RED.notify('Place your generated flow onto the workspace', 'compact') RED.view.importNodes(newNodes, importOptions) } catch (error) { // console.log(error) RED.notify('Sorry, something went wrong, please try again', 'error') } } function processAIErrorResponse (jqXHR, textStatus, errorThrown) { console.warn('error', jqXHR, textStatus, errorThrown) if (jqXHR.status === 429) { // get x- rate limit reset header const reset = jqXHR.getResponseHeader('x-ratelimit-reset') if (reset) { const resetTime = new Date(reset * 1000) const now = new Date() const diff = resetTime - now const seconds = Math.floor(diff / 1000) const minutes = Math.floor(seconds / 60) const remainingSeconds = seconds % 60 if (minutes > 0) { RED.notify(`Sorry, the FlowFuse Assistant is busy. Please try again in ${minutes} minute${minutes > 1 ? 's' : ''}.`, 'warning') } else { RED.notify(`Sorry, the FlowFuse Assistant is busy. Please try again in ${remainingSeconds} second${remainingSeconds > 1 ? 's' : ''}.`, 'warning') } return } RED.notify('Sorry, the FlowFuse Assistant is busy. Please try again later.', 'warning') return } if (jqXHR.status === 404) { RED.notify('Sorry, the FlowFuse Assistant is not available at the moment', 'warning') return } if (jqXHR.status === 401) { RED.notify('Sorry, you are not authorised to use the FlowFuse Assistant', 'warning') return } if (jqXHR.status >= 400 && jqXHR.status < 500) { let message = 'Sorry, the FlowFuse Assistant cannot help with this request' if (jqXHR.responseJSON?.body?.code === 'assistant_service_denied' && jqXHR.responseJSON?.body?.error) { message = jqXHR.responseJSON.body.error } RED.notify(message, 'warning') return } RED.notify('Sorry, something went wrong, please try again', 'error') } function getMonacoEditorForModel (model) { // Get the URI of the model, scan the editors for the model with the same URI const modelUri = model.uri.toString() const editors = monaco.editor.getEditors() return editors.find(editor => editor && document.body.contains(editor.getDomNode()) && editor.getModel()?.uri?.toString() === modelUri) } function generateId (length = 16) { if (typeof length !== 'number' || length < 1) { throw new Error('Invalid length') } const iterations = Math.ceil(length / 2) const bytes = [] for (let i = 0; i < iterations; i++) { bytes.push(Math.round(0xff * Math.random()).toString(16).padStart(2, '0')) } return bytes.join('').substring(0, length) } function debug () { if (RED.nrAssistant?.DEBUG) { // eslint-disable-next-line no-console console.log('[nr-assistant]', ...arguments) } } }(RED, $)) </script>