@flowfuse/nr-assistant
Version:
FlowFuse Node-RED Expert plugin
973 lines (921 loc) • 106 kB
HTML
<script src="resources/@flowfuse/nr-assistant/expertComms.js"></script>
<script src="resources/@flowfuse/nr-assistant/sharedUtils.js"></script>
<script>
/* global FFExpertComms */ /* loaded from expertComms.js */
/* 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} MethodBodyData
* @property {string} prompt - The prompt to be sent to the API.
* @property {string} transactionId - The transaction ID for the request.
* @property {MethodBodyDataContext} [context] - Context data to accompany the request.
*/
/**
* @typedef {Object} MethodBodyDataContext
* @property {string} [nodeName] - The nodes label
* @property {string} [type] - The type of the context (e.g. 'function', 'template').
* @property {string} [subType] - The sub-type of the context (e.g. 'on-start', 'on-message').
* @property {string} [codeSection] - The code section of the context (alias for sub-type)
* @property {string} [scope] - The scope of the context (e.g. 'fim' (Fill-in-the-middle), 'inline' (inline codelens), 'node' (generate a node), 'flow' (generate a flow)).
* @property {Array<string>} [outputs] - The count of outputs the node has (typically only for function nodes)
* @property {Boolean} [modulesAllowed] - Whether external modules are allowed in the function node
* @property {Array<{module:string, var:string}>} [modules] - The list of modules setup/available (typically only for function nodes)
*/
/**
* @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 FIM_TIMEOUT = 5000 // A default, hard upper bound timeout for FIM inline completions
const modulesAllowed = RED.settings.functionExternalModules !== false
const assistantOptions = {
enabled: false,
tablesEnabled: false,
inlineCompletionsEnabled: false,
requestTimeout: AI_TIMEOUT,
assistantVersion: null
}
let initialisedInterlock = false
let mcpReadyInterlock = false
let assistantInitialised = false
debug('Loading Node-RED Assistant Plugin...')
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.assistantVersion = msg?.assistantVersion
assistantOptions.standalone = !!msg?.standalone
assistantOptions.enabled = !!msg?.enabled
assistantOptions.requestTimeout = msg?.requestTimeout || AI_TIMEOUT
assistantOptions.tablesEnabled = msg?.tablesEnabled === true
assistantOptions.inlineCompletionsEnabled = msg?.inlineCompletionsEnabled === true
initAssistant(msg)
}
if (topic === 'nr-assistant/mcp/ready') {
if (!mcpReadyInterlock && !!msg?.enabled) {
mcpReadyInterlock = true
// Complete first time setup
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')
} else if (mcpReadyInterlock) {
if (msg?.enabled) {
RED.menu.setVisible('ff-assistant-explain-flows', true)
} else {
RED.menu.setVisible('ff-assistant-explain-flows', false)
}
}
}
})
}
}
RED.plugins.registerPlugin('flowfuse-nr-assistant', plugin)
function createAssistantMenu () {
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 Expert menu')
const ffAssistantMenu = [
{ id: 'ff-assistant-title', label: plugin._('name'), visible: true }, // header
null // separator
]
RED.menu.init({ id: 'red-ui-header-button-ff-ai', options: ffAssistantMenu })
}
function showLoginPrompt () {
$.ajax({
contentType: 'application/json',
url: 'nr-assistant/auth/start',
method: 'POST',
data: JSON.stringify({
editorURL: window.location.origin + window.location.pathname
})
}).then(data => {
if (data && data.path && data.state) {
const handleAuthCallback = function (evt) {
debug('handleAuthCallback', evt)
try {
const message = JSON.parse(evt.data)
if (message.code === 'flowfuse-auth-complete') {
showNotification('Connected to FlowFuse', { type: 'success' })
if (message.state === data.state) {
RED.menu.setVisible('ff-assistant-login', false)
RED.menu.setVisible('ff-assistant-function-builder', true)
}
} else if (message.code === 'flowfuse-auth-error') {
showNotification('Failed to connect to FlowFuse', { type: 'error' })
console.warn('Failed to connect to FlowFuse:', message.error)
}
} catch (err) {}
window.removeEventListener('message', handleAuthCallback, false)
}
window.open(document.location.toString().replace(/[?#].*$/, '') + data.path, 'FlowFuseNodeREDPluginAuthWindow', 'menubar=no,location=no,toolbar=no,chrome,height=650,width=500')
window.addEventListener('message', handleAuthCallback, false)
} else if (data && data.error) {
RED.notify(`Failed to connect to server: ${data.error}`, { type: 'error' })
}
})
}
function initAssistant (options) {
debug('initialising...', assistantOptions)
if (!initialisedInterlock) {
// Initialiise common UI elements
initialisedInterlock = true
createAssistantMenu()
if (assistantOptions.standalone) {
RED.menu.addItem('red-ui-header-button-ff-ai', {
id: 'ff-assistant-login',
label: `<span>${plugin._('login.menu.label')}</span>`,
onselect: showLoginPrompt,
visible: !assistantOptions.enabled
})
}
RED.menu.addItem('red-ui-header-button-ff-ai', {
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: assistantOptions.enabled
})
}
if (!assistantOptions.enabled) {
if (assistantOptions.standalone) {
RED.menu.setVisible('ff-assistant-login', true)
}
RED.menu.setVisible('ff-assistant-function-builder', false)
console.warn(plugin._('errors.assistant-not-enabled'))
return
} else {
if (assistantOptions.standalone) {
RED.menu.setVisible('ff-assistant-login', false)
}
RED.menu.setVisible('ff-assistant-function-builder', true)
}
if (!assistantInitialised) {
FFExpertComms.init(RED, assistantOptions)
registerMonacoExtensions()
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')
assistantInitialised = true
}
}
function registerMonacoExtensions () {
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'
const ffTablesNodeCommandId = 'nr-assistant-ff-tables-node-inline'
debug('registering code lens providers...')
monaco.languages.registerCodeLensProvider('javascript', {
provideCodeLenses: function (model, token) {
if (!assistantOptions.enabled) {
return
}
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 Expert 🪄',
tooltip: 'Click to ask FlowFuse Expert for help writing code',
arguments: [model, codeLens, token]
}
return codeLens
}
})
monaco.languages.registerCodeLensProvider('json', {
provideCodeLenses: function (model, token) {
if (!assistantOptions.enabled) {
return
}
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 Expert 🪄',
tooltip: 'Click to ask FlowFuse Expert for help with JSON',
arguments: [model, codeLens, token]
}
return codeLens
}
})
monaco.languages.registerCodeLensProvider('css', {
provideCodeLenses: function (model, token) {
if (!assistantOptions.enabled) {
return
}
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 Expert 🪄',
tooltip: 'Click to ask FlowFuse Expert for help with CSS',
arguments: [model, codeLens, token]
}
return codeLens
}
})
monaco.languages.registerCodeLensProvider('html', {
provideCodeLenses: function (model, token) {
if (!assistantOptions.enabled) {
return
}
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 Expert 🪄',
tooltip: 'Click to ask FlowFuse Expert for help with VUE or HTML',
arguments: [model, codeLens, token]
}
return codeLens
}
})
assistantOptions.tablesEnabled && monaco.languages.registerCodeLensProvider('sql', {
provideCodeLenses: function (model, token) {
if (!assistantOptions.enabled) {
return
}
debug('SQL CodeLens provider called', model, token)
const thisEditor = getMonacoEditorForModel(model)
if (!thisEditor) {
return
}
const node = RED.view.selection()?.nodes?.[0]
// only support tables query nodes for now
if (!node || node.type !== 'tables-query' || node._def?.set?.id !== '@flowfuse/nr-tables-nodes/tables-query') {
return
}
return {
lenses: [
{
range: {
startLineNumber: 1,
startColumn: 1,
endLineNumber: 2,
endColumn: 1
},
id: ffTablesNodeCommandId
}
],
dispose: () => { }
}
},
resolveCodeLens: function (model, codeLens, token) {
debug('SQL CodeLens resolve called', model, codeLens, token)
if (codeLens.id !== ffTablesNodeCommandId) {
return codeLens
}
codeLens.command = {
id: codeLens.id,
title: 'Ask the FlowFuse Expert 🪄',
tooltip: 'Click to ask FlowFuse Expert for help with PostgreSQL',
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
}
const subType = getFunctionNodeEditorCodeSection(thisEditor)
/** @type {PromptOptions} */
const promptOptions = {
method: 'function',
lang: 'javascript',
type: 'function',
subType,
codeSection: subType
}
/** @type {PromptUIOptions} */
const uiOptions = {
title: 'FlowFuse Expert : Function Code',
explanation: 'The FlowFuse Expert 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 currentModulesInSetup = getFunctionNodeModules()
responseData.node_modules.forEach((lib) => {
const existing = currentModulesInSetup.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 Expert : JSON',
explanation: 'The FlowFuse Expert 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 Expert : CSS',
explanation: 'The FlowFuse Expert 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 Expert : Dashboard 2 UI Template',
explanation: 'The FlowFuse Expert 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())
}
})
assistantOptions.tablesEnabled && monaco.editor.registerCommand(ffTablesNodeCommandId, function (accessor, model, codeLens, token) {
debug('running command', ffTablesNodeCommandId)
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: 'flowfuse-tables-query',
lang: 'sql',
dialect: 'postgres',
type: node.type
// selectedText: model.getValueInRange(userSelection)
}
/** @type {PromptUIOptions} */
const uiOptions = {
title: 'FlowFuse Expert : FlowFuse Query',
explanation: 'The FlowFuse Expert can help you write SQL queries.',
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('sql response', response)
const responseData = response?.data
if (responseData && responseData.sql) {
// 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.sql
}
])
}
})
} else {
console.warn('Could not find editor for model', model.uri.toString())
}
})
debug('registering inline completions')
if (assistantOptions.inlineCompletionsEnabled) {
const stateByLanguage = {}
const supportedInlineCompletions = [
{
languageId: 'javascript',
nodeType: 'function',
nodeModule: 'node-red'
},
{
languageId: 'css',
nodeType: 'ui-template',
nodeModule: '@flowfuse/node-red-dashboard'
},
{
languageId: 'html',
nodeType: 'ui-template',
nodeModule: '@flowfuse/node-red-dashboard'
},
{
languageId: 'sql',
nodeType: 'tables-query',
nodeModule: '@flowfuse/nr-tables-nodes'
}
]
const getState = (languageId) => {
if (!stateByLanguage[languageId]) {
stateByLanguage[languageId] = {
MAX_FIFO_SIZE: 1, // MVP - only 1 suggestion is supported
suggestions: [],
lastSuggestion: null,
inflightRequest: null
}
}
return stateByLanguage[languageId]
}
const getContext = (node, editor, position) => {
const model = editor.getModel()
/** @type {MethodBodyDataContext} */
const context = {
scope: 'fim',
languageId: model.getLanguageIdAtPosition(position.lineNumber, position.column),
nodeName: node.name || node.type,
nodeType: node.type,
nodeModule: node._def?.set?.module || 'node-red'
}
context.outputs = +(node.outputs || 1)
if (isNaN(context.outputs) || context.outputs < 1) {
context.outputs = 1
}
if (node.type === 'function') {
context.subType = getFunctionNodeEditorCodeSection(editor)
context.codeSection = context.subType
context.modulesAllowed = modulesAllowed
context.modules = modulesAllowed ? getFunctionNodeModules() : []
}
if (node.type === 'tables-query') {
context.dialect = 'postgres'
}
return context
}
// ---------------- Language Strategies ----------------
const languageStrategies = {
javascript: {
adjustIndentation (model, position, suggestionText) {
const currentIndent = model.getLineContent(position.lineNumber).match(/^\s*/)?.[0] ?? ''
return suggestionText.split('\n').map((line, idx) =>
idx === 0 ? line : currentIndent + line.trimEnd()
).join('\n')
}
},
css: {
adjustIndentation (model, position, suggestionText) {
const currentIndent = model.getLineContent(position.lineNumber).match(/^\s*/)?.[0] ?? ''
return suggestionText.split('\n').map((line, idx) =>
idx === 0 ? line : currentIndent + line.trimEnd()
).join('\n')
}
},
html: {
adjustIndentation (model, position, suggestionText) {
const lineContent = model.getLineContent(position.lineNumber).trim()
const currentIndent = model.getLineContent(position.lineNumber).match(/^\s*/)?.[0] ?? ''
const extraIndent =
lineContent.endsWith('>') && !lineContent.endsWith('/>')
? ' '
: ''
return suggestionText.split('\n').map((line, idx) =>
idx === 0 ? line : currentIndent + extraIndent + line.trimEnd()
).join('\n')
}
},
sql: {
adjustIndentation (_model, _position, suggestionText) {
// SQL: flat, no indent
return suggestionText.split('\n').map((l) => l.trim()).join('\n')
}
}
}
// #region "Inline Completion Helper Functions"
const computeInlineCompletionRange = (model, position, suggestionText) => {
const lineContent = model.getLineContent(position.lineNumber)
// Text before and after the cursor
const beforeCursor = lineContent.substring(0, position.column - 1)
const afterCursor = lineContent.substring(position.column - 1)
// 1. Backward overlap (user already typed some of the suggestion)
let backOverlap = 0
for (let i = 0; i < suggestionText.length; i++) {
if (beforeCursor.endsWith(suggestionText.substring(0, i + 1))) {
backOverlap = i + 1
}
}
// 2. Forward overlap (document already contains trailing part of suggestion)
let forwardOverlap = 0
const firstNewline = suggestionText.indexOf('\n')
const suggestionEnd = firstNewline === -1 ? suggestionText : suggestionText.substring(0, firstNewline)
for (let i = 0; i < suggestionEnd.length; i++) {
if (afterCursor.startsWith(suggestionEnd.substring(suggestionEnd.length - (i + 1)))) {
forwardOverlap = i + 1
}
}
return new monaco.Range(
position.lineNumber,
position.column - backOverlap,
position.lineNumber,
position.column + forwardOverlap
)
}
const trimDuplicates = (model, position, suggestionText) => {
// Grab a small lookahead (e.g. next 3 lines of code after cursor)
const lookaheadLines = Math.min(position.lineNumber + 3, model.getLineCount())
const lookahead = model.getValueInRange({
startLineNumber: position.lineNumber,
startColumn: 1,
endLineNumber: lookaheadLines,
endColumn: model.getLineMaxColumn(lookaheadLines)
})
const lookaheadNormalized = lookahead.trimStart()
let trimmed = suggestionText.trimEnd()
// Simple heuristic: if suggestion ends with same text as lookahead, drop it
if (lookaheadNormalized.startsWith(trimmed.split('\n').slice(-1)[0])) {
debug('Trimming duplicate text from suggestion (end matches lookahead)', { lookaheadNormalized, suggestionText })
const lines = trimmed.split('\n')
lines.pop() // remove the duplicate trailing line
trimmed = lines.join('\n')
}
return trimmed
}
// #endregion "Inline Completion Helper Functions"
// --------------- Fetch Wrapper ----------------
const fetchAICompletion = (options, node, editor, model, position, resolve) => {
const state = getState(options.languageId)
// inhibit new request if one is already running
if (state.inflightRequest) {
debug('Skipping FIM request, one already in-flight')
return
}
const fullRange = model.getFullModelRange()
const fimPrefix = model.getValueInRange({
startLineNumber: 1,
startColumn: 1,
endLineNumber: position.lineNumber,