@flowfuse/nr-assistant 0.3.1-2c05106-202506300804.0 → 0.3.1-6237044-202507301022.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/completions.html +406 -0
- package/index.html +77 -22
- package/index.js +7 -242
- package/lib/assistant.js +667 -0
- package/lib/completions/Labeller.js +56 -0
- package/lib/flowGraph.js +65 -0
- package/lib/settings.js +18 -0
- package/lib/utils.js +21 -0
- package/locales/en-US/index.json +8 -2
- package/package.json +13 -6
- package/resources/sharedUtils.js +70 -0
package/lib/assistant.js
ADDED
|
@@ -0,0 +1,667 @@
|
|
|
1
|
+
// a singleton instance of the Assistant class with an init method for accepting the RED instance
|
|
2
|
+
'use strict'
|
|
3
|
+
|
|
4
|
+
const { z } = require('zod')
|
|
5
|
+
const { getLongestUpstreamPath } = require('./flowGraph')
|
|
6
|
+
const { hasProperty } = require('./utils')
|
|
7
|
+
const semver = require('semver')
|
|
8
|
+
|
|
9
|
+
const FF_ASSISTANT_USER_AGENT = 'FlowFuse Assistant Plugin/' + require('../package.json').version
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* @typedef {Object} AssistantSettings
|
|
13
|
+
* @property {boolean} enabled - Whether the Assistant is enabled
|
|
14
|
+
* @property {number} requestTimeout - The timeout for requests to the Assistant backend in milliseconds
|
|
15
|
+
* @property {string} url - The URL of the Assistant server
|
|
16
|
+
* @property {string} token - The authentication token for the Assistant server
|
|
17
|
+
* @property {Object} [got] - The got instance to use for HTTP requests
|
|
18
|
+
* @property {Object} completions - Settings for completions
|
|
19
|
+
* @property {string} completions.modelUrl - The URL to the ML model
|
|
20
|
+
* @property {string} completions.vocabularyUrl - The URL to the completions vocabulary lookup data
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
class Assistant {
|
|
24
|
+
constructor () {
|
|
25
|
+
// Main properties
|
|
26
|
+
/** @type {import('node-red').NodeRedInstance} */
|
|
27
|
+
this.RED = null
|
|
28
|
+
/** @type {import('got').Got} */
|
|
29
|
+
this.got = null
|
|
30
|
+
/** @type {AssistantSettings} */
|
|
31
|
+
this.options = null
|
|
32
|
+
this._loading = false // Flag to indicate if the Assistant is currently loading
|
|
33
|
+
this._enabled = false // Flag to indicate if the Assistant is enabled
|
|
34
|
+
|
|
35
|
+
// MCP Client and Server and associated properties
|
|
36
|
+
/** @type {import('@modelcontextprotocol/sdk/client/index.js').Client} */
|
|
37
|
+
this._mcpClient = null
|
|
38
|
+
/** @type {import('@modelcontextprotocol/sdk/server/index.js').Server} */
|
|
39
|
+
// eslint-disable-next-line no-unused-vars
|
|
40
|
+
this._mcpServer = null
|
|
41
|
+
this.mcpReady = false // Flag to indicate if MCP is ready
|
|
42
|
+
|
|
43
|
+
// ONNX.js and associated properties (primarily for completions)
|
|
44
|
+
/** @type {import('onnxruntime-web') } */
|
|
45
|
+
this._ort = null
|
|
46
|
+
/** @type {import('onnxruntime-web').InferenceSession} */
|
|
47
|
+
this._completionsSession = null
|
|
48
|
+
this.completionsReady = false // Flag to indicate if the completions model is ready
|
|
49
|
+
/** @type {import('./completions/Labeller.js').CompletionsLabeller} */
|
|
50
|
+
this.labeller = null // Instance of CompletionsLabeller for encoding/decoding completions
|
|
51
|
+
|
|
52
|
+
// NOTES: Since this plugin may be loaded via device agent and device agent might be the 2.x stream, we
|
|
53
|
+
// should try to avoid (or handle) instances where Node14 is used, as it does not support ESM imports or
|
|
54
|
+
// private class fields (so for now, we stick to the _old style private properties_ with an underscore prefix).
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Initialize the Assistant instance with the provided RED instance and options.
|
|
59
|
+
* This method sets up the necessary components for the Assistant, including the Model Context Protocol (MCP) and ONNX.js.
|
|
60
|
+
* @param {*} RED - The Node-RED RED API
|
|
61
|
+
* @param {AssistantSettings} options - The options for initializing the Assistant
|
|
62
|
+
*/
|
|
63
|
+
async init (RED, options = {}) {
|
|
64
|
+
if (this._loading) {
|
|
65
|
+
RED.log.debug('FlowFuse Assistant is busy loading')
|
|
66
|
+
return
|
|
67
|
+
}
|
|
68
|
+
try {
|
|
69
|
+
this._loading = true // Set loading to true when initializing
|
|
70
|
+
await this.dispose() // Dispose of any existing instance before initializing a new one
|
|
71
|
+
this.RED = RED
|
|
72
|
+
this.options = options || {}
|
|
73
|
+
this.got = this.options.got || require('got') // got can me passed in for testing purposes
|
|
74
|
+
|
|
75
|
+
if (!this.options.enabled) {
|
|
76
|
+
RED.log.info('FlowFuse Assistant Plugin is not enabled')
|
|
77
|
+
return
|
|
78
|
+
}
|
|
79
|
+
if (!this.options.url || !this.options.token) {
|
|
80
|
+
RED.log.warn('FlowFuse Assistant Plugin configuration is missing required options')
|
|
81
|
+
throw new Error('Plugin configuration is missing required options')
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const nrVersion = this.RED.version()
|
|
85
|
+
const nrMajorVersion = semver.major(nrVersion)
|
|
86
|
+
const nrMinorVersion = semver.minor(nrVersion)
|
|
87
|
+
const nodeMajorVersion = semver.major(process.versions.node)
|
|
88
|
+
|
|
89
|
+
const clientSettings = {
|
|
90
|
+
enabled: this.options.enabled !== false && !!this.options.url,
|
|
91
|
+
requestTimeout: this.options.requestTimeout || 60000
|
|
92
|
+
}
|
|
93
|
+
RED.comms.publish('nr-assistant/initialise', clientSettings, true /* retain */)
|
|
94
|
+
|
|
95
|
+
// ### Initialise Model Context Protocol (MCP)
|
|
96
|
+
// TODO: If "feature" is disabled, skip loading MCP. See issue #57
|
|
97
|
+
this.options.mcp = this.options.mcp || { enabled: true }
|
|
98
|
+
const mcpFeatureEnabled = this.options.mcp.enabled && true // FUTURE: Feature Flag - See issue #57
|
|
99
|
+
const mcpEnabled = mcpFeatureEnabled && this.isInitialized && this.isEnabled
|
|
100
|
+
if (mcpEnabled) {
|
|
101
|
+
try {
|
|
102
|
+
const { client, server } = await this.loadMCP()
|
|
103
|
+
this._mcpClient = client
|
|
104
|
+
this._mcpServer = server
|
|
105
|
+
this.mcpReady = true
|
|
106
|
+
// tell frontend that the MCP client is ready so it can add the action(s) to the Action List
|
|
107
|
+
RED.comms.publish('nr-assistant/mcp/ready', clientSettings, true /* retain */)
|
|
108
|
+
RED.log.info('FlowFuse Assistant Model Context Protocol (MCP) loaded')
|
|
109
|
+
} catch (error) {
|
|
110
|
+
this.mcpReady = false
|
|
111
|
+
// ESM Support in Node 20 is much better than versions v18-, so lets include a node version
|
|
112
|
+
// Write a warning to log as a hint/prompt
|
|
113
|
+
// NOTE: Node 18 is EOL as of writing this
|
|
114
|
+
RED.log.warn('FlowFuse Assistant MCP could not be loaded. Assistant features that require MCP will not be available')
|
|
115
|
+
if (nodeMajorVersion < 20) {
|
|
116
|
+
RED.log.debug(`Node.js version ${nodeMajorVersion} may not be supported by MCP Client / Server.`)
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
} else if (!mcpFeatureEnabled) {
|
|
120
|
+
RED.log.info('FlowFuse Assistant MCP is disabled')
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// ### Initialise completions (depends on MCP so checks the mcpReady flag)
|
|
124
|
+
// TODO: If "feature" is disabled, skip loading. See issue #57
|
|
125
|
+
this.options.completions = this.options.completions || { enabled: true } // default to enabled
|
|
126
|
+
const completionsFeatureEnabled = this.options.completions.enabled && true // FUTURE: Feature Flag - See issue #57
|
|
127
|
+
const completionsSupported = (nrMajorVersion > 4 || (nrMajorVersion === 4 && nrMinorVersion >= 1))
|
|
128
|
+
const completionsEnabled = completionsFeatureEnabled && this.isInitialized && this.isEnabled
|
|
129
|
+
|
|
130
|
+
if (!completionsSupported) {
|
|
131
|
+
RED.log.warn('FlowFuse Assistant Completions require Node-RED 4.1 or greater')
|
|
132
|
+
} else if (!completionsFeatureEnabled) {
|
|
133
|
+
RED.log.info('FlowFuse Assistant Completions are disabled')
|
|
134
|
+
} else if (this.mcpReady && completionsEnabled && completionsSupported) {
|
|
135
|
+
// if modelUrl is not set, use the default model URL from this.options.url + 'assets/completions/model.onnx'
|
|
136
|
+
this.options.completions.modelUrl = this.options.completions.modelUrl || new URL('assets/completions/model.onnx', this.options.url).href
|
|
137
|
+
// if vocabularyUrl is not set, use the default vocabulary URL from this.options.url + 'assets/completions/vocabulary.json'
|
|
138
|
+
this.options.completions.vocabularyUrl = this.options.completions.vocabularyUrl || new URL('assets/completions/vocabulary.json', this.options.url).href
|
|
139
|
+
|
|
140
|
+
// RED.events.once('comms:message:nr-assistant/completions/load', async (opts) => {
|
|
141
|
+
RED.events.once('comms:message:nr-assistant/completions/load', async (opts) => {
|
|
142
|
+
try {
|
|
143
|
+
RED.log.info('FlowFuse Assistant is Loading Advanced Completions...')
|
|
144
|
+
await this.loadCompletions()
|
|
145
|
+
RED.comms.publish('nr-assistant/completions/ready', { enabled: true }, true /* retain */)
|
|
146
|
+
RED.log.info('FlowFuse Assistant Completions Loaded')
|
|
147
|
+
this.completionsReady = true
|
|
148
|
+
} catch (error) {
|
|
149
|
+
this.completionsReady = false
|
|
150
|
+
RED.log.warn('FlowFuse Assistant Advanced Completions could not be loaded.') // degraded functionality
|
|
151
|
+
RED.log.debug(`Completions loading error: ${error.message}`)
|
|
152
|
+
}
|
|
153
|
+
})
|
|
154
|
+
}
|
|
155
|
+
this.initAdminEndpoints(RED) // Initialize the admin endpoints for the Assistant
|
|
156
|
+
const degraded = (mcpEnabled && !this.mcpReady)
|
|
157
|
+
RED.log.info('FlowFuse Assistant Plugin loaded' + (degraded ? ' (reduced functionality)' : ''))
|
|
158
|
+
} finally {
|
|
159
|
+
this._loading = false // Set loading to false when initialization is complete
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async dispose () {
|
|
164
|
+
if (this._completionsSession) {
|
|
165
|
+
await this._completionsSession.release()
|
|
166
|
+
}
|
|
167
|
+
this.labeller = null
|
|
168
|
+
this._completionsSession = null
|
|
169
|
+
this._ort = null
|
|
170
|
+
|
|
171
|
+
try {
|
|
172
|
+
if (this._mcpClient) {
|
|
173
|
+
await this._mcpClient.close()
|
|
174
|
+
}
|
|
175
|
+
if (this._mcpServer) {
|
|
176
|
+
await this._mcpServer.close()
|
|
177
|
+
}
|
|
178
|
+
} finally {
|
|
179
|
+
this._mcpClient = null
|
|
180
|
+
this._mcpServer = null
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
this.RED = null
|
|
184
|
+
this.got = null
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
get isInitialized () {
|
|
188
|
+
return this.RED !== null && this.got !== null
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
get isLoading () {
|
|
192
|
+
return this._loading
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
get isEnabled () {
|
|
196
|
+
if (!this.options) {
|
|
197
|
+
return false
|
|
198
|
+
}
|
|
199
|
+
return !!(this.options.enabled && this.options.url && this.options.token)
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
async loadCompletions () {
|
|
203
|
+
if (!this.isInitialized) {
|
|
204
|
+
throw new Error('Assistant is not initialized')
|
|
205
|
+
}
|
|
206
|
+
if (!this.options || !this.options.completions) {
|
|
207
|
+
throw new Error('Assistant completions options are not set')
|
|
208
|
+
}
|
|
209
|
+
await this._loadCompletionsLabels()
|
|
210
|
+
await this._loadMlRuntime()
|
|
211
|
+
await this._loadCompletionsModel()
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
async _loadCompletionsLabels (url = this.options.completions.vocabularyUrl) {
|
|
215
|
+
const response = await this.got(url, {
|
|
216
|
+
responseType: 'json',
|
|
217
|
+
headers: {
|
|
218
|
+
Authorization: `Bearer ${this.options.token}`,
|
|
219
|
+
'User-Agent': FF_ASSISTANT_USER_AGENT
|
|
220
|
+
}
|
|
221
|
+
})
|
|
222
|
+
if (!response.body || typeof response.body !== 'object') {
|
|
223
|
+
throw new Error('Invalid vocabulary format')
|
|
224
|
+
}
|
|
225
|
+
/** @type {{ input_features: string[], classifications: string[], core_nodes: string[] }} */
|
|
226
|
+
const labels = response.body
|
|
227
|
+
const isArrayOfStrings = (arr) => Array.isArray(arr) && arr.every(item => typeof item === 'string')
|
|
228
|
+
if (!isArrayOfStrings(labels.input_features)) {
|
|
229
|
+
throw new Error('Completion Input Labels are not valid')
|
|
230
|
+
}
|
|
231
|
+
if (!isArrayOfStrings(labels.classifications)) {
|
|
232
|
+
throw new Error('Completion Classifications Labels are not valid')
|
|
233
|
+
}
|
|
234
|
+
if (!isArrayOfStrings(labels.core_nodes)) {
|
|
235
|
+
throw new Error('Completion Core Nodes Labels are not valid')
|
|
236
|
+
}
|
|
237
|
+
const CompletionsLabeller = require('./completions/Labeller.js').CompletionsLabeller // Import the CompletionsLabeller class
|
|
238
|
+
this.labeller = new CompletionsLabeller({
|
|
239
|
+
inputFeatureLabels: labels.input_features,
|
|
240
|
+
classifierLabels: labels.classifications,
|
|
241
|
+
nodeLabels: labels.core_nodes
|
|
242
|
+
})
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
async _loadMlRuntime () {
|
|
246
|
+
this._ort = await import('onnxruntime-web')
|
|
247
|
+
if (!this._ort) {
|
|
248
|
+
throw new Error('Failed to load ML Runtime')
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
async _loadCompletionsModel (url = this.options.completions.modelUrl) {
|
|
253
|
+
try {
|
|
254
|
+
const response = await this.got(url, {
|
|
255
|
+
headers: {
|
|
256
|
+
Authorization: `Bearer ${this.options.token}`,
|
|
257
|
+
'User-Agent': FF_ASSISTANT_USER_AGENT
|
|
258
|
+
},
|
|
259
|
+
responseType: 'buffer' // Ensure we get raw binary
|
|
260
|
+
})
|
|
261
|
+
if (!response.body || !Buffer.isBuffer(response.body)) {
|
|
262
|
+
throw new Error('Invalid model format')
|
|
263
|
+
}
|
|
264
|
+
this._completionsSession = await this._ort.InferenceSession.create(response.body)
|
|
265
|
+
} catch (error) {
|
|
266
|
+
console.error('Error loading ML model:', error)
|
|
267
|
+
throw new Error(`Failed to load ML model: ${error.message}`, { cause: error })
|
|
268
|
+
}
|
|
269
|
+
if (!this._completionsSession) {
|
|
270
|
+
throw new Error('Failed to load ML model')
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
async loadMCP () {
|
|
275
|
+
const { Client } = await import('@modelcontextprotocol/sdk/client/index.js')
|
|
276
|
+
const { McpServer } = await import('@modelcontextprotocol/sdk/server/mcp.js')
|
|
277
|
+
const { InMemoryTransport } = await import('@modelcontextprotocol/sdk/inMemory.js')
|
|
278
|
+
// Create in-process server
|
|
279
|
+
const server = new McpServer({
|
|
280
|
+
name: 'NR MCP Server',
|
|
281
|
+
version: '1.0.0'
|
|
282
|
+
})
|
|
283
|
+
|
|
284
|
+
server.prompt('explain_flow', 'Explain what the selected node-red flow of nodes do', {
|
|
285
|
+
nodes: z
|
|
286
|
+
.string()
|
|
287
|
+
.startsWith('[')
|
|
288
|
+
.endsWith(']')
|
|
289
|
+
.min(23) // Minimum length for a valid JSON array
|
|
290
|
+
.max(100000) // on average, an exported node is ~400-1000 characters long, 100000 characters _should_ realistically be enough for a flow of 100 nodes
|
|
291
|
+
.describe('JSON string that represents a flow of Node-RED nodes'),
|
|
292
|
+
flowName: z.string().optional().describe('Optional name of the flow to explain'),
|
|
293
|
+
userContext: z.string().optional().describe('Optional user context to aid explanation')
|
|
294
|
+
}, async ({ nodes, flowName, userContext }) => {
|
|
295
|
+
const promptBuilder = []
|
|
296
|
+
// promptBuilder.push('Generate a JSON response containing 2 string properties: "summary" and "details". Summary should be a brief overview of what the following Node-RED flow JSON does, Details should provide a little more detail of the flow but should be concise and to the point. Use bullet lists or number lists if it gets too wordy.') // FUTURE: ask for a summary and details in JSON format
|
|
297
|
+
promptBuilder.push('Generate a "### Summary" section, followed by a "### Details" section only. They should explain the following Node-RED flow json. "Summary" should be a brief TLDR, Details should provide a little more information but should be concise and to the point. Use bullet lists or number lists if it gets too wordy.')
|
|
298
|
+
if (flowName) {
|
|
299
|
+
promptBuilder.push(`The parent flow is named "${flowName}".`)
|
|
300
|
+
promptBuilder.push('')
|
|
301
|
+
}
|
|
302
|
+
if (userContext) {
|
|
303
|
+
promptBuilder.push(`User Context: "${userContext}".`)
|
|
304
|
+
promptBuilder.push('')
|
|
305
|
+
}
|
|
306
|
+
promptBuilder.push('Here are the nodes in the flow:')
|
|
307
|
+
promptBuilder.push('```json')
|
|
308
|
+
promptBuilder.push(nodes)
|
|
309
|
+
promptBuilder.push('```')
|
|
310
|
+
return {
|
|
311
|
+
messages: [{
|
|
312
|
+
role: 'user',
|
|
313
|
+
content: {
|
|
314
|
+
type: 'text',
|
|
315
|
+
text: promptBuilder.join('\n')
|
|
316
|
+
}
|
|
317
|
+
}]
|
|
318
|
+
}
|
|
319
|
+
})
|
|
320
|
+
|
|
321
|
+
server.tool('predict_next', 'Predict the next node or nodes to follow the provided nodes in a Node-RED flow', {
|
|
322
|
+
flow: z.array(
|
|
323
|
+
z.object({
|
|
324
|
+
id: z.string()
|
|
325
|
+
}).passthrough()
|
|
326
|
+
).optional().describe('A Node-RED flow related to the prediction.'),
|
|
327
|
+
sourceNode: z.object({
|
|
328
|
+
id: z.string(),
|
|
329
|
+
// allow any other properties in the test object
|
|
330
|
+
[z.string()]: z.any()
|
|
331
|
+
}).passthrough().describe('The node in the flow from which to the prediction will be made'),
|
|
332
|
+
sourcePort: z.number().optional().describe('Optional source port to connect the predicted node to')
|
|
333
|
+
},
|
|
334
|
+
/** @type {import('@modelcontextprotocol/sdk/server/mcp.js').ToolCallback} */
|
|
335
|
+
async ({ flow, sourceNode, sourcePort }) => {
|
|
336
|
+
const attachToNode = sourceNode || {}
|
|
337
|
+
/** @type {Array<{type: string}>} */
|
|
338
|
+
let suggestedNodes = []
|
|
339
|
+
/** @type {Array<{type: string}>} */
|
|
340
|
+
const upstreamNodes = []
|
|
341
|
+
if (flow && flow.length && attachToNode.id) {
|
|
342
|
+
upstreamNodes.push(...getLongestUpstreamPath(flow, attachToNode.id))
|
|
343
|
+
}
|
|
344
|
+
if (this.completionsReady) {
|
|
345
|
+
const allNodes = [...upstreamNodes, attachToNode] // Include the last node in the input
|
|
346
|
+
const typeNames = allNodes.map(node => node.type) // Extract the type names from the nodes
|
|
347
|
+
const vectorizedText = this.labeller.encode_sequence(typeNames)
|
|
348
|
+
|
|
349
|
+
const feeds = {
|
|
350
|
+
X: new this._ort.Tensor('float32', vectorizedText, [1, vectorizedText.length])
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
const results = await this._completionsSession.run(feeds)
|
|
354
|
+
// Get top 3 + 5 additional predictions (only 3 will be used since there are 5 permanent suggestions in the typeSearch, we need enough to fill the suggestions array)
|
|
355
|
+
const predictions = this.labeller.decode_predictions(results.probabilities.cpuData, 3 + 5)
|
|
356
|
+
|
|
357
|
+
suggestedNodes = predictions.map(prediction => ({ type: prediction.className })) // Create new nodes with the predicted types
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// typical patterns like "link in" > "something" > "link out" or "http in" > "something" > "http response"
|
|
361
|
+
// can be recognized and suggested if the model does not predict them...
|
|
362
|
+
if (flow && flow.length > 1) {
|
|
363
|
+
// if the flow has a "split" but not a "join", we can suggest a "join" node
|
|
364
|
+
const hasSplit = flow.some(node => node.type === 'split')
|
|
365
|
+
const hasJoin = flow.some(n => n.type === 'join')
|
|
366
|
+
const joinSuggested = suggestedNodes.some(n => n.type === 'join')
|
|
367
|
+
if (hasSplit && !hasJoin && !joinSuggested && sourceNode.type !== 'split') {
|
|
368
|
+
suggestedNodes.unshift({ type: 'join', x: 0, y: 0 })
|
|
369
|
+
}
|
|
370
|
+
// if the flow contains a "link in" but no "link out" nodes, we can suggest a "link out" node
|
|
371
|
+
const hasLinkIn = flow.some(n => n.type === 'link in')
|
|
372
|
+
const hasLinkOut = flow.some(n => n.type === 'link out')
|
|
373
|
+
const linkOutSuggested = suggestedNodes.some(n => n.type === 'link out')
|
|
374
|
+
if (hasLinkIn && !hasLinkOut && !linkOutSuggested && sourceNode.type !== 'link in') {
|
|
375
|
+
suggestedNodes.unshift({ type: 'link out', x: 0, y: 0 })
|
|
376
|
+
}
|
|
377
|
+
// if the flow has a "http in" but not a "http response", we can suggest an "http response" node
|
|
378
|
+
const hasHTTP = flow.some(n => n.type === 'http in')
|
|
379
|
+
const hasHTTPResponse = flow.some(n => n.type === 'http response')
|
|
380
|
+
const httpResponseSuggested = suggestedNodes.some(n => n.type === 'http response')
|
|
381
|
+
if (hasHTTP && !hasHTTPResponse && !httpResponseSuggested && sourceNode.type !== 'http in') {
|
|
382
|
+
suggestedNodes.unshift({ type: 'http response', x: 0, y: 0 })
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
// if the first suggestion is exactly the same as the source node, move it to the end of the list
|
|
386
|
+
if (suggestedNodes.length > 0 && suggestedNodes[0].type === sourceNode.type) {
|
|
387
|
+
suggestedNodes.push(suggestedNodes.shift())
|
|
388
|
+
}
|
|
389
|
+
const suggestions = suggestedNodes.map(node => [node])
|
|
390
|
+
return {
|
|
391
|
+
structuredContent: {
|
|
392
|
+
sourceId: sourceNode.id,
|
|
393
|
+
sourcePort: sourcePort || 0, // TODO: have the tool accept a sourcePort parameter
|
|
394
|
+
suggestions // use the suggestions array
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
})
|
|
398
|
+
|
|
399
|
+
// Create in-process client
|
|
400
|
+
const client = new Client({
|
|
401
|
+
name: 'NR MCP Client',
|
|
402
|
+
version: '1.0.0'
|
|
403
|
+
})
|
|
404
|
+
|
|
405
|
+
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair()
|
|
406
|
+
await Promise.all([
|
|
407
|
+
server.connect(serverTransport),
|
|
408
|
+
client.connect(clientTransport)
|
|
409
|
+
])
|
|
410
|
+
|
|
411
|
+
return {
|
|
412
|
+
client,
|
|
413
|
+
server
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// #region Admin Endpoints & HTTP Handlers
|
|
418
|
+
|
|
419
|
+
initAdminEndpoints (RED) {
|
|
420
|
+
RED.httpAdmin.post('/nr-assistant/:method', RED.auth.needsPermission('write'), function (req, res) {
|
|
421
|
+
return assistant.handlePostMethodRequest(req, res)
|
|
422
|
+
})
|
|
423
|
+
|
|
424
|
+
RED.httpAdmin.get('/nr-assistant/mcp/prompts', RED.auth.needsPermission('write'), async function (req, res) {
|
|
425
|
+
return assistant.handlePostPromptsRequest(req, res)
|
|
426
|
+
})
|
|
427
|
+
|
|
428
|
+
RED.httpAdmin.post('/nr-assistant/mcp/prompts/:promptId', RED.auth.needsPermission('write'), async function (req, res) {
|
|
429
|
+
return assistant.handlePostPromptRequest(req, res)
|
|
430
|
+
})
|
|
431
|
+
|
|
432
|
+
RED.httpAdmin.post('/nr-assistant/mcp/tools/:toolId', RED.auth.needsPermission('write'), async function (req, res) {
|
|
433
|
+
return assistant.handlePostToolRequest(req, res)
|
|
434
|
+
})
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* Handles POST requests to the /nr-assistant/:method endpoint.
|
|
439
|
+
* This is for handling custom methods that the Assistant can perform.
|
|
440
|
+
* @param {import('express').Request} req - The request object
|
|
441
|
+
* @param {import('express').Response} res - The response object
|
|
442
|
+
*/
|
|
443
|
+
async handlePostMethodRequest (req, res) {
|
|
444
|
+
if (!this.isInitialized || this.isLoading) {
|
|
445
|
+
return res.status(503).send('Assistant is not ready')
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
const method = req.params.method
|
|
449
|
+
// limit method to prevent path traversal
|
|
450
|
+
if (!method || typeof method !== 'string' || /[^a-z0-9-_]/.test(method)) {
|
|
451
|
+
res.status(400)
|
|
452
|
+
res.json({ status: 'error', message: 'Invalid method' })
|
|
453
|
+
return
|
|
454
|
+
}
|
|
455
|
+
const input = req.body
|
|
456
|
+
if (!input || !input.prompt || typeof input.prompt !== 'string') {
|
|
457
|
+
res.status(400)
|
|
458
|
+
res.json({ status: 'error', message: 'prompt is required' })
|
|
459
|
+
return
|
|
460
|
+
}
|
|
461
|
+
const body = {
|
|
462
|
+
prompt: input.prompt, // this is the prompt to the AI
|
|
463
|
+
promptHint: input.promptHint, // this is used to let the AI know what we are generating (`function node? Node JavaScript? flow?)
|
|
464
|
+
context: input.context, // this is used to provide additional context to the AI (e.g. the selected text of the function node)
|
|
465
|
+
transactionId: input.transactionId // used to correlate the request with the response
|
|
466
|
+
}
|
|
467
|
+
// join url & method (taking care of trailing slashes)
|
|
468
|
+
const url = `${this.options.url.replace(/\/$/, '')}/${method.replace(/^\//, '')}`
|
|
469
|
+
this.got.post(url, {
|
|
470
|
+
headers: {
|
|
471
|
+
Accept: '*/*',
|
|
472
|
+
'Accept-Language': 'en-GB,en-US;q=0.9,en;q=0.8,es;q=0.7',
|
|
473
|
+
Authorization: `Bearer ${this.options.token}`,
|
|
474
|
+
'Content-Type': 'application/json',
|
|
475
|
+
'User-Agent': FF_ASSISTANT_USER_AGENT
|
|
476
|
+
},
|
|
477
|
+
json: body
|
|
478
|
+
}).then(response => {
|
|
479
|
+
const data = JSON.parse(response.body)
|
|
480
|
+
res.json({
|
|
481
|
+
status: 'ok',
|
|
482
|
+
data
|
|
483
|
+
})
|
|
484
|
+
}).catch((error) => {
|
|
485
|
+
let body = error.response && error.response.body
|
|
486
|
+
if (typeof body === 'string') {
|
|
487
|
+
try {
|
|
488
|
+
body = JSON.parse(body)
|
|
489
|
+
} catch (e) {
|
|
490
|
+
// ignore
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
let message = 'FlowFuse Assistant request was unsuccessful'
|
|
494
|
+
const errorData = { status: 'error', message, body }
|
|
495
|
+
const errorCode = (error.response && error.response.statusCode) || 500
|
|
496
|
+
res.status(errorCode).json(errorData)
|
|
497
|
+
this.RED.log.trace('nr-assistant error:', error)
|
|
498
|
+
if (body && typeof body === 'object' && body.error) {
|
|
499
|
+
message = `${message}: ${body.error}`
|
|
500
|
+
}
|
|
501
|
+
this.RED.log.warn(message)
|
|
502
|
+
})
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/**
|
|
506
|
+
* Handles POST requests to the /nr-assistant/mcp/prompts endpoint.
|
|
507
|
+
* Returns a list of available prompts from the Model Context Protocol (MCP).
|
|
508
|
+
* @param {import('express').Request} req - The request object
|
|
509
|
+
* @param {import('express').Response} res - The response object
|
|
510
|
+
*/
|
|
511
|
+
async handlePostPromptsRequest (req, res) {
|
|
512
|
+
if (!this.isInitialized || this.isLoading) {
|
|
513
|
+
return res.status(503).send('Assistant is not ready')
|
|
514
|
+
}
|
|
515
|
+
if (!this.mcpReady) {
|
|
516
|
+
return res.status(503).send('Model Context Protocol (MCP) is not ready')
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
try {
|
|
520
|
+
const prompts = await this._mcpClient.getPrompts()
|
|
521
|
+
res.json({ status: 'ok', data: prompts })
|
|
522
|
+
} catch (error) {
|
|
523
|
+
this.RED.log.error('Failed to retrieve MCP prompts:', error)
|
|
524
|
+
res.status(500).json({ status: 'error', message: 'Failed to retrieve MCP prompts' })
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/**
|
|
529
|
+
* Handles POST requests to the /nr-assistant/mcp/prompts/:promptId endpoint.
|
|
530
|
+
* Executes a prompt from the Model Context Protocol (MCP) with the provided prompt ID.
|
|
531
|
+
* @param {import('express').Request} req - The request object
|
|
532
|
+
* @param {import('express').Response} res - The response object
|
|
533
|
+
*/
|
|
534
|
+
async handlePostPromptRequest (req, res) {
|
|
535
|
+
if (!this.isInitialized || this.isLoading) {
|
|
536
|
+
return res.status(503).send('Assistant is not ready')
|
|
537
|
+
}
|
|
538
|
+
if (!this.mcpReady) {
|
|
539
|
+
return res.status(503).send('Model Context Protocol (MCP) is not ready')
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
const promptId = req.params.promptId
|
|
543
|
+
if (!promptId || typeof promptId !== 'string') {
|
|
544
|
+
return res.status(400).json({ status: 'error', message: 'Invalid prompt ID' })
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
const input = req.body
|
|
548
|
+
if (!input || !input.nodes || typeof input.nodes !== 'string') {
|
|
549
|
+
res.status(400).json({ status: 'error', message: 'nodes selection is required' })
|
|
550
|
+
return
|
|
551
|
+
}
|
|
552
|
+
try {
|
|
553
|
+
// Only include flowName and userContext if they are defined
|
|
554
|
+
const promptArgs = { nodes: input.nodes }
|
|
555
|
+
if (input.flowName !== undefined) promptArgs.flowName = input.flowName
|
|
556
|
+
if (input.userContext !== undefined) promptArgs.userContext = input.userContext
|
|
557
|
+
|
|
558
|
+
const response = await this._mcpClient.getPrompt({
|
|
559
|
+
name: promptId,
|
|
560
|
+
arguments: promptArgs
|
|
561
|
+
})
|
|
562
|
+
|
|
563
|
+
const body = {
|
|
564
|
+
prompt: promptId, // this is the prompt to the AI
|
|
565
|
+
transactionId: input.transactionId, // used to correlate the request with the response
|
|
566
|
+
context: {
|
|
567
|
+
type: 'prompt',
|
|
568
|
+
promptId,
|
|
569
|
+
prompt: response
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
// join url & method (taking care of trailing slashes)
|
|
574
|
+
const url = `${this.options.url.replace(/\/$/, '')}/mcp`
|
|
575
|
+
const responseFromAI = await this.got.post(url, {
|
|
576
|
+
headers: {
|
|
577
|
+
Accept: '*/*',
|
|
578
|
+
'Accept-Language': 'en-GB,en-US;q=0.9,en;q=0.8,es;q=0.7',
|
|
579
|
+
Authorization: `Bearer ${this.options.token}`,
|
|
580
|
+
'Content-Type': 'application/json'
|
|
581
|
+
},
|
|
582
|
+
json: body
|
|
583
|
+
})
|
|
584
|
+
const responseBody = JSON.parse(responseFromAI.body)
|
|
585
|
+
// Assuming the response from the AI is in the expected format
|
|
586
|
+
if (!responseBody || responseFromAI.statusCode !== 200) {
|
|
587
|
+
res.status(responseFromAI.statusCode || 500).json({ status: 'error', message: 'AI response was not successful', data: responseBody })
|
|
588
|
+
return
|
|
589
|
+
}
|
|
590
|
+
// If the response is successful, return the data
|
|
591
|
+
res.json({
|
|
592
|
+
status: 'ok',
|
|
593
|
+
data: responseBody.data || responseBody // Use data if available, otherwise return the whole response
|
|
594
|
+
})
|
|
595
|
+
} catch (error) {
|
|
596
|
+
this.RED.log.error('Failed to execute MCP prompt:', error)
|
|
597
|
+
res.status(500).json({ status: 'error', message: 'Failed to execute MCP prompt' })
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
/**
|
|
602
|
+
* Handles POST requests to the /nr-assistant/mcp/tools/:toolId endpoint.
|
|
603
|
+
* Executes a tool from the Model Context Protocol (MCP) with the provided tool ID
|
|
604
|
+
* and input.
|
|
605
|
+
* @param {import('express').Request} req - The request object
|
|
606
|
+
* @param {import('express').Response} res - The response object
|
|
607
|
+
*/
|
|
608
|
+
async handlePostToolRequest (req, res) {
|
|
609
|
+
if (!this.isInitialized || this.isLoading) {
|
|
610
|
+
return res.status(503).send('Assistant is not ready')
|
|
611
|
+
}
|
|
612
|
+
if (!this.mcpReady) {
|
|
613
|
+
return res.status(503).send('Model Context Protocol (MCP) is not ready')
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
let sourcePort = 0 // default source port
|
|
617
|
+
const input = req.body || {}
|
|
618
|
+
const sourceNode = input.sourceNode
|
|
619
|
+
const toolId = req.params.toolId
|
|
620
|
+
|
|
621
|
+
// Validate input
|
|
622
|
+
if (!sourceNode || typeof sourceNode !== 'object') {
|
|
623
|
+
res.status(400).json({ status: 'error', message: 'Invalid input' })
|
|
624
|
+
return
|
|
625
|
+
}
|
|
626
|
+
if (toolId !== 'predict_next') { // only predict_next is currently supported
|
|
627
|
+
res.status(400).json({ status: 'error', message: 'Invalid tool ID' })
|
|
628
|
+
return
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
if (hasProperty(input, 'sourcePort') && !isNaN(+input.sourcePort) && +sourcePort < 0) {
|
|
632
|
+
sourcePort = parseInt(input.sourcePort, 10)
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
// code for predict_next
|
|
636
|
+
try {
|
|
637
|
+
const response = await this._mcpClient.callTool({
|
|
638
|
+
name: toolId,
|
|
639
|
+
arguments: {
|
|
640
|
+
flow: input.flow || undefined, // optional flow nodes
|
|
641
|
+
sourceNode,
|
|
642
|
+
sourcePort
|
|
643
|
+
}
|
|
644
|
+
})
|
|
645
|
+
const body = {
|
|
646
|
+
tool: toolId,
|
|
647
|
+
transactionId: input.transactionId, // used to correlate the request with the response
|
|
648
|
+
result: response
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
// If the response is successful, return the data
|
|
652
|
+
res.json({
|
|
653
|
+
status: 'ok',
|
|
654
|
+
data: body
|
|
655
|
+
})
|
|
656
|
+
} catch (error) {
|
|
657
|
+
this.RED.log.error('Failed to execute MCP tool:', error)
|
|
658
|
+
res.status(500).json({ status: 'error', message: 'Failed to execute MCP tool' })
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
// #endregion
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
const assistant = new Assistant() // singleton instance of the Assistant class
|
|
666
|
+
|
|
667
|
+
module.exports = assistant
|