UNPKG

@flowfuse/nr-assistant

Version:
407 lines (385 loc) 23.4 kB
<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} SuggestionSourceContext * @property {Array} flow - The current flow * @property {Object} source - The source node that the suggestion is being requested for * @property {number} sourcePort - The port of the source node that the suggestion is being requested for * @property {number} sourcePortType - The type of the source port (0 for output port, 1 for input port) * @property {string} workspace - The current workspace id */ /** * @typedef {Object} Suggestion * @property {string} label - The label for the suggestion * @property {Array<Object>} nodes - The nodes that make up the suggestion */ const AI_TIMEOUT = 90000 // default request timeout in milliseconds const assistantOptions = { enabled: false, requestTimeout: AI_TIMEOUT, completionsEnabled: true } let mcpReady = false let completionsReady = false debug('loading suggestion-source plugin...') const plugin = { type: 'node-red-flow-suggestion-source', /** * Get suggestions for the given context. * @param {SuggestionSourceContext} context - The context to get suggestions for. * @returns {Promise<Array<Suggestion>>} - A promise that resolves to an array of suggestions. */ getSuggestions: async function (context) { debug('getSuggestions', context) if (!assistantOptions.enabled || !mcpReady) { return [] } const { flow, source, sourcePort, sourcePortType, workspace } = context || {} if (!context || typeof sourcePortType !== 'number' || !source || typeof sourcePort !== 'number' || !workspace) { // if the context is not valid or the source port type is an input port, we cannot provide suggestions debug('Invalid context or source port type, cannot provide suggestions') return [] } if (sourcePortType !== 0) { // For now, only allow predictions for output ports (sourcePortType 0 === source node output port) debug('Source port type is not an output port, reverse suggestions are not supported') return [] } // eslint-disable-next-line no-unused-vars const [error, promise, _xhr] = await getNextPredictions(flow, source, sourcePortType, sourcePort) if (error || !promise) { debug('Error getting next predictions...') debug(error) return [] } const structuredContent = await promise if (!structuredContent || !structuredContent.suggestions || structuredContent.suggestions.length === 0) { debug('No suggestions found in response for the context provided') return [] } const MAX_SUGGESTIONS = 3 // limit the number of suggestions to top 3 - this keeps the entries in the type search priority list visible const excludeTypes = ['debug', 'function', 'change', 'switch', 'junction'] // exclude node types that are always offered first in the priority list // Reformat the suggestions to match the expected format of the plugin /** @type {Array<Suggestion>} */ const typeSearchSuggestions = structuredContent.suggestions.map(nodes => { if (Array.isArray(nodes) && nodes.length > 0) { // if a single node suggestion is provided, and that node is already in the priority // list of the type search, skip it if (nodes.length === 1 && nodes[0].type && excludeTypes.includes(nodes[0].type)) { return null } return { label: '', // let core handle the label nodes: nodes.map(node => { return { x: 0, y: 0, ...node } }) } } return null }).filter(s => s !== null).slice(0, MAX_SUGGESTIONS) debug('prediction', typeSearchSuggestions) return typeSearchSuggestions || [] }, name: 'Node-RED Assistant Completions', 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 assistantOptions.completionsEnabled = msg?.completionsEnabled !== false } if (topic === 'nr-assistant/mcp/ready') { mcpReady = !!msg?.enabled // Since the frontend has now obviously loaded, lets signal the backend to load the models. // For this, we will use the newer RED.comms.send API (`comms.send` API was created before // the `suggestions` API so, if the `send` function is not available, we can assume the // suggestions API is not available - i.e. dont bother sending the request to load completions // since the lack of `send` means the backend doesnt have the suggestions API! if (RED.comms.send) { debug('sending initialise request to backend') RED.comms.send('nr-assistant/completions/load', { enabled: assistantOptions.enabled, mcpReady }) } else { console.warn('RED.comms.send is not available, cannot initialise completions') } } if (topic === 'nr-assistant/completions/ready') { // This event is fired only after MCP has loaded and the "nr-assistant/mcp/ready" event has // been received by the frontend. That triggers the `nr-assistant/completions/load` event // to signal to the backend to request and load completion data (model/vocab etc). // This is done to avoid loading the completions unnecessarily (i.e. if the MCP is not // enabled/ready/supported, we don't need to load the completions!) Also, for times when many // instances restart (pipeline activities), we don't want to load the completions down // to potentially 1000s of instances when most of them may never even open the frontend // where these completions are actually used! completionsReady = !!msg?.enabled if (!completionsReady) { debug('Completions not enabled, cannot provide suggestions on node:add') return } debug('Completions are ready') let suggestionXhr = null const justAdded = { node: null, opts: null, sourcePort: null } const SKIP = 'skip' const CONTINUE = 'continue' const CONTINUE_NO_LINK_ADDED = 'continue-timeout' let linkAddedPromise = null let linkAddedResolve = null let linkAddedTimeout = null RED.events.on('nodes:add', async function (n, opts) { debug('nodes:add', n, opts) if (!completionsReady || !assistantOptions.enabled) { return } if (suggestionXhr) { debug('Aborting previous suggestionXhr') suggestionXhr.abort('abort') suggestionXhr = null } if (linkAddedResolve) { debug('Aborting previous linkAddedPromise') linkAddedResolve(SKIP) clearTimeout(linkAddedTimeout) linkAddedResolve = null linkAddedTimeout = null } // only support nodes with at least one output port if (!n.outputs || n.outputs < 1) { debug('Node does not have any outputs, skipping prediction') return } // if the opts are not provided or the source is not palette, typeSearch, or suggestion skip the prediction if (!opts || (opts.source !== 'palette' && opts.source !== 'typeSearch' && opts.source !== 'suggestion') || opts.splice === true) { debug('opts not valid for prediction, skipping') return } // Store the node and options for later use justAdded.node = n justAdded.opts = opts justAdded.sourcePort = 0 // default to first output port // Create a promise that will be resolved by the links:add handler linkAddedPromise = new Promise((resolve) => { linkAddedResolve = resolve linkAddedTimeout = setTimeout(() => { if (linkAddedResolve && linkAddedTimeout) { justAdded.link = false linkAddedResolve(CONTINUE_NO_LINK_ADDED) linkAddedResolve = null } }, 50) }) // Wait for either the link to be added or a timeout const action = await linkAddedPromise clearTimeout(linkAddedTimeout) linkAddedPromise = null linkAddedResolve = null if (action === SKIP) { debug('linkAddedPromise skipped') return } else if (action === CONTINUE) { debug('linkAddedPromise continued') } else if (action === CONTINUE_NO_LINK_ADDED) { debug('linkAddedPromise continued after timeout') } else { console.warn('Unexpected action:', action) } const upstreamNodes = RED.nodes.getAllUpstreamNodes(justAdded.node) const nodes = [justAdded.node, ...upstreamNodes].reverse() if (suggestionXhr) { suggestionXhr.abort('abort') suggestionXhr = null } /** @type {[error:Error, prediction:Object, xhr:JQueryXHR]} */ const predictResult = getNextPredictions(nodes, justAdded.node, justAdded.sourcePort) if (predictResult[0]) { console.warn('Error getting next predictions:', predictResult[0]) return } // const predictResult = predictNext(nodes, justAdded.node, justAdded.sourcePort) // TODO: consider sourcePort! const promise = predictResult[1] // the promise that resolves with the prediction result suggestionXhr = predictResult[2] // the xhr reference is used to abort the request if needed promise.then(result => { suggestionXhr = null // reset the xhr reference const suggestions = result.suggestions || [] if (!Array.isArray(suggestions) || suggestions.length === 0) { debug('No suggestions found in prediction result') return } // e.g. // suggestion = { source: 'be4d6bc6b3dfc9e0', sourcePort: 0, nodes: [{ type: 'debug', id: '1234567890abcdef', x: 100, y: 200, z: 'flow-id' }] } const suggestionOptions = { clickToApply: true, source: justAdded.node, sourcePort: justAdded.sourcePort || 0, // default to first output port position: 'relative', nodes: suggestions.slice(0, 5) // limit to 5 inline suggestions } debug('Prediction for next node:', suggestionOptions) RED.view.setSuggestedFlow(suggestionOptions) }).catch(error => { console.warn('Error predicting next node:', error) }).finally(() => { suggestionXhr = null // reset the xhr reference // reset the justAdded object justAdded.opts = null // reset the opts reference justAdded.node = null // reset the node reference justAdded.sourcePort = null // reset the sourcePort reference justAdded.link = null // reset the link reference }) }) RED.events.on('links:add', function (link) { debug('links:add', link) if (!completionsReady || !assistantOptions.enabled) { return } if (!linkAddedPromise || !linkAddedResolve) { // no link added promise, so we can skip this debug('No link added promise, probably a wire up operation rather than a node:add->links:add combo. Just return!') return } if (!justAdded.opts || (justAdded.opts.source !== 'palette' && justAdded.opts.source !== 'typeSearch' && justAdded.opts.source !== 'suggestion')) { debug('links:add fired but not from palette or type search. opts:', justAdded.opts) linkAddedResolve(SKIP) return } if (!justAdded.node) { debug('No node added, skipping prediction') linkAddedResolve(SKIP) return } if (link.source?.id !== justAdded.node.id && link.target?.id !== justAdded.node.id) { debug('Not the node we just added, skipping prediction') linkAddedResolve(SKIP) return } // determine if this is a forward link (out-->in) if (link.source?.id === justAdded.node.id) { debug('This is a reverse link (in-->out), skipping prediction') linkAddedResolve(SKIP) return } // If we get here, the link is relevant, so resolve the promise if (justAdded.link === null) { justAdded.link = link // store the link for later use } linkAddedResolve(CONTINUE) }) } }) } } RED.plugins.registerPlugin('ff-assistant-completions', plugin) /** * Get the next predictions for the given flow and source node. * @param {Array} flow - The current flow * @param {Object} sourceNode - The source node that the prediction is being requested for * @param {number} sourcePortType - The type of the source port (0 for output port, 1 for input port) * @param {number} [sourcePort=0] - The port of the source node that the prediction is being requested for * @return {[Error|null, Promise<Object|null>, JQueryXHR|null]} - An array containing an error (if any), a promise that resolves with the prediction result, and the XHR object for the request that permits aborting the request if needed */ function getNextPredictions (flow, sourceNode, sourcePortType, sourcePort = 0) { if (!assistantOptions.enabled) { const error = new Error(RED.notify(plugin._('errors.assistant-not-enabled'))) return [error, Promise.resolve(null)] } // only allow predictions for output ports (sourcePortType === 0 (source node output port)) if (!sourceNode || sourceNode.length === 0 || sourcePortType !== 0) { return [null, Promise.resolve(null)] } const { flow: nodes } = FFAssistantUtils.cleanFlow(flow) const { flow: cleanedSourceFlow } = FFAssistantUtils.cleanFlow([sourceNode]) || {} /** @type {JQueryXHR} */ let xhr = null const url = 'nr-assistant/mcp/tools/predict_next' // e.g. 'nr-assistant/json' const transactionId = generateId(8) + '-' + Date.now() // a unique id for this transaction const data = { transactionId, flow: nodes, sourceNode: cleanedSourceFlow[0], sourcePort, 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 promise = new Promise((resolve, reject) => { xhr = $.ajax({ url, type: 'POST', data, timeout: assistantOptions.requestTimeout, success: (reply, textStatus, jqXHR) => { // busyNotification.close() if (reply?.error) { debug('predictNext -> ajax -> error', reply.error) return resolve(null) } try { if (typeof reply.data !== 'object' || !reply.data) { debug('Invalid reply data', reply.data) return resolve(null) } const { result, ...replyData } = reply.data if (!result || replyData.transactionId !== transactionId) { debug('Prediction transaction ID mismatch', result, transactionId) return resolve(null) } if (!result || !result.structuredContent || !result.structuredContent.suggestions || Array.isArray(result.structuredContent.suggestions) === false || result.structuredContent.suggestions.length === 0) { debug('No result text in reply', reply.data) return resolve(null) } return resolve(result.structuredContent) } catch (error) { console.warn('Error processing prediction response', error) resolve(null) } }, error: (jqXHR, textStatus, errorThrown) => { // console.log('explainSelectedNodes -> ajax -> error', jqXHR, textStatus, errorThrown) // busyNotification.close() if (textStatus === 'abort' || errorThrown === 'abort' || jqXHR.statusText === 'abort') { // user cancelled return } resolve(null) // resolve with null to indicate no prediction }, complete: function () { xhr = null // busyNotification.close() } }) }) return [null, promise, xhr] } 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>