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