@flowfuse/nr-assistant
Version:
FlowFuse Node-RED assistant plugin
986 lines (940 loc) • 70.9 kB
HTML
<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