UNPKG

@flowfuse/nr-assistant

Version:
973 lines (921 loc) 106 kB
<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,