@flowfuse/nr-assistant 0.1.4-cba1235-202504140623.0 → 0.2.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/index.html CHANGED
@@ -24,6 +24,7 @@
24
24
  requestTimeout: AI_TIMEOUT
25
25
  }
26
26
  let assistantInitialised = false
27
+ let assistantMCPOneTimeFlag = false
27
28
  debug('loading...')
28
29
  RED.plugins.registerPlugin('flowfuse-nr-assistant', {
29
30
  type: 'assistant',
@@ -37,6 +38,13 @@
37
38
  assistantOptions.requestTimeout = msg?.requestTimeout || AI_TIMEOUT
38
39
  initAssistant(msg)
39
40
  }
41
+ if (topic === 'nr-assistant/mcp/ready' && !assistantMCPOneTimeFlag) {
42
+ assistantMCPOneTimeFlag = true
43
+ if (assistantOptions.enabled) {
44
+ debug('assistant MCP initialised')
45
+ RED.actions.add('flowfuse-nr-assistant:explain-selected-nodes', explainSelectedNodes, { label: '@flowfuse/nr-assistant/flowfuse-nr-assistant:actions.explain-selected-nodes' })
46
+ }
47
+ }
40
48
  })
41
49
  }
42
50
  })
@@ -54,305 +62,304 @@
54
62
  if (!window.monaco) {
55
63
  console.warn('Monaco editor not found. Unable to register code lens provider. Consider using the Monaco editor for a better experience.')
56
64
  return
57
- } else {
58
- const funcCommandId = 'nr-assistant-fn-inline'
59
- const jsonCommandId = 'nr-assistant-json-inline'
65
+ }
60
66
 
61
- debug('registering code lens providers...')
67
+ const funcCommandId = 'nr-assistant-fn-inline'
68
+ const jsonCommandId = 'nr-assistant-json-inline'
62
69
 
63
- monaco.languages.registerCodeLensProvider('javascript', {
64
- provideCodeLenses: function (model, token) {
65
- const thisEditor = getMonacoEditorForModel(model)
66
- if (!thisEditor) {
67
- return
68
- }
69
- const node = RED.view.selection()?.nodes?.[0]
70
+ debug('registering code lens providers...')
70
71
 
71
- // only support function nodes for now
72
- if (!node || !node.type === 'function') {
73
- return
74
- }
75
- // Only support the "on message" editor for now
76
- // determine which editor is active and if it the "on message" editor
77
- // if not, return nothing to prevent the code lens from showing
78
- let isFuncTabEditor
79
- let el = thisEditor.getDomNode()
80
- while (el && el.tagName !== 'FORM') {
81
- if (el.id === 'node-input-func-editor' || el.id === 'func-tab-body') {
82
- isFuncTabEditor = true
83
- break
84
- }
85
- el = el.parentNode
86
- }
87
- if (!isFuncTabEditor) {
88
- return
89
- }
72
+ monaco.languages.registerCodeLensProvider('javascript', {
73
+ provideCodeLenses: function (model, token) {
74
+ const thisEditor = getMonacoEditorForModel(model)
75
+ if (!thisEditor) {
76
+ return
77
+ }
78
+ const node = RED.view.selection()?.nodes?.[0]
90
79
 
91
- return {
92
- lenses: [
93
- {
94
- range: {
95
- startLineNumber: 1,
96
- startColumn: 1,
97
- endLineNumber: 2,
98
- endColumn: 1
99
- },
100
- id: funcCommandId
101
- }
102
- ],
103
- dispose: () => { }
104
- }
105
- },
106
- resolveCodeLens: function (model, codeLens, token) {
107
- if (codeLens.id !== funcCommandId) {
108
- return codeLens
109
- }
110
- codeLens.command = {
111
- id: codeLens.id,
112
- title: 'Ask the FlowFuse Assistant 🪄',
113
- tooltip: 'Click to ask FlowFuse Assistant for help writing code',
114
- arguments: [model, codeLens, token]
80
+ // only support function nodes for now
81
+ if (!node || !node.type === 'function') {
82
+ return
83
+ }
84
+ // Only support the "on message" editor for now
85
+ // determine which editor is active and if it the "on message" editor
86
+ // if not, return nothing to prevent the code lens from showing
87
+ let isFuncTabEditor
88
+ let el = thisEditor.getDomNode()
89
+ while (el && el.tagName !== 'FORM') {
90
+ if (el.id === 'node-input-func-editor' || el.id === 'func-tab-body') {
91
+ isFuncTabEditor = true
92
+ break
115
93
  }
116
- return codeLens
94
+ el = el.parentNode
95
+ }
96
+ if (!isFuncTabEditor) {
97
+ return
117
98
  }
118
- })
119
99
 
120
- monaco.languages.registerCodeLensProvider('json', {
121
- provideCodeLenses: function (model, token) {
122
- const thisEditor = getMonacoEditorForModel(model)
123
- if (!thisEditor) {
124
- return
125
- }
126
- return {
127
- lenses: [
128
- {
129
- range: {
130
- startLineNumber: 1,
131
- startColumn: 1,
132
- endLineNumber: 2,
133
- endColumn: 1
134
- },
135
- id: jsonCommandId
136
- }
137
- ],
138
- dispose: () => { }
139
- }
140
- },
141
- resolveCodeLens: function (model, codeLens, token) {
142
- if (codeLens.id !== jsonCommandId) {
143
- return codeLens
144
- }
145
- codeLens.command = {
146
- id: codeLens.id,
147
- title: 'Ask the FlowFuse Assistant 🪄',
148
- tooltip: 'Click to ask FlowFuse Assistant for help with JSON',
149
- arguments: [model, codeLens, token]
150
- }
100
+ return {
101
+ lenses: [
102
+ {
103
+ range: {
104
+ startLineNumber: 1,
105
+ startColumn: 1,
106
+ endLineNumber: 2,
107
+ endColumn: 1
108
+ },
109
+ id: funcCommandId
110
+ }
111
+ ],
112
+ dispose: () => { }
113
+ }
114
+ },
115
+ resolveCodeLens: function (model, codeLens, token) {
116
+ if (codeLens.id !== funcCommandId) {
151
117
  return codeLens
152
118
  }
153
- })
154
-
155
- debug('registering commands...')
119
+ codeLens.command = {
120
+ id: codeLens.id,
121
+ title: 'Ask the FlowFuse Assistant 🪄',
122
+ tooltip: 'Click to ask FlowFuse Assistant for help writing code',
123
+ arguments: [model, codeLens, token]
124
+ }
125
+ return codeLens
126
+ }
127
+ })
156
128
 
157
- monaco.editor.registerCommand(funcCommandId, function (accessor, model, codeLens, token) {
158
- debug('running command', funcCommandId)
159
- const node = RED.view.selection()?.nodes?.[0]
160
- if (!node) {
161
- console.warn('No node selected') // should not happen
129
+ monaco.languages.registerCodeLensProvider('json', {
130
+ provideCodeLenses: function (model, token) {
131
+ const thisEditor = getMonacoEditorForModel(model)
132
+ if (!thisEditor) {
162
133
  return
163
134
  }
164
- if (!assistantOptions.enabled) {
165
- RED.notify('The FlowFuse Assistant is not enabled', 'warning')
135
+ return {
136
+ lenses: [
137
+ {
138
+ range: {
139
+ startLineNumber: 1,
140
+ startColumn: 1,
141
+ endLineNumber: 2,
142
+ endColumn: 1
143
+ },
144
+ id: jsonCommandId
145
+ }
146
+ ],
147
+ dispose: () => { }
148
+ }
149
+ },
150
+ resolveCodeLens: function (model, codeLens, token) {
151
+ if (codeLens.id !== jsonCommandId) {
152
+ return codeLens
153
+ }
154
+ codeLens.command = {
155
+ id: codeLens.id,
156
+ title: 'Ask the FlowFuse Assistant 🪄',
157
+ tooltip: 'Click to ask FlowFuse Assistant for help with JSON',
158
+ arguments: [model, codeLens, token]
159
+ }
160
+ return codeLens
161
+ }
162
+ })
163
+
164
+ debug('registering commands...')
165
+
166
+ monaco.editor.registerCommand(funcCommandId, function (accessor, model, codeLens, token) {
167
+ debug('running command', funcCommandId)
168
+ const node = RED.view.selection()?.nodes?.[0]
169
+ if (!node) {
170
+ console.warn('No node selected') // should not happen
171
+ return
172
+ }
173
+ if (!assistantOptions.enabled) {
174
+ RED.notify('The FlowFuse Assistant is not enabled', 'warning')
175
+ return
176
+ }
177
+ const thisEditor = getMonacoEditorForModel(model)
178
+ if (thisEditor) {
179
+ if (!document.body.contains(thisEditor.getDomNode())) {
180
+ console.warn('Editor is no longer in the DOM, cannot proceed.')
166
181
  return
167
182
  }
168
- const thisEditor = getMonacoEditorForModel(model)
169
- if (thisEditor) {
170
- if (!document.body.contains(thisEditor.getDomNode())) {
171
- console.warn('Editor is no longer in the DOM, cannot proceed.')
172
- return
173
- }
174
183
 
175
- // walk up the tree to find the parent div with an id and include that in context
176
- let subType = 'on message'
177
- let parent = thisEditor.getDomNode().parentNode
178
- while (parent?.tagName !== 'FORM') {
179
- if (parent.id) {
180
- break
181
- }
182
- parent = parent.parentNode
183
- }
184
- switch (parent?.id) {
185
- case 'func-tab-init':
186
- case 'node-input-init-editor':
187
- subType = 'on start'
188
- break
189
- case 'func-tab-body':
190
- case 'node-input-func-editor':
191
- subType = 'on message'
192
- break
193
- case 'func-tab-finalize':
194
- case 'node-input-finalize-editor':
195
- subType = 'on message'
184
+ // walk up the tree to find the parent div with an id and include that in context
185
+ let subType = 'on message'
186
+ let parent = thisEditor.getDomNode().parentNode
187
+ while (parent?.tagName !== 'FORM') {
188
+ if (parent.id) {
196
189
  break
197
190
  }
191
+ parent = parent.parentNode
192
+ }
193
+ switch (parent?.id) {
194
+ case 'func-tab-init':
195
+ case 'node-input-init-editor':
196
+ subType = 'on start'
197
+ break
198
+ case 'func-tab-body':
199
+ case 'node-input-func-editor':
200
+ subType = 'on message'
201
+ break
202
+ case 'func-tab-finalize':
203
+ case 'node-input-finalize-editor':
204
+ subType = 'on message'
205
+ break
206
+ }
198
207
 
199
- // FUTURE: for including selected text in the context for features like "fix my code", "refactor this", "what is this?" etc
200
- // const userSelection = triggeredEditor.getSelection()
201
- // const selectedText = model.getValueInRange(userSelection)
202
- /** @type {PromptOptions} */
203
- const promptOptions = {
204
- method: 'function',
205
- lang: 'javascript',
206
- type: 'function',
207
- subType
208
- // selectedText: model.getValueInRange(userSelection)
209
- }
210
- /** @type {PromptUIOptions} */
211
- const uiOptions = {
212
- title: 'FlowFuse Assistant : Function Code',
213
- explanation: 'The FlowFuse Assistant can help you write JavaScript code.',
214
- description: 'Enter a short description of what you want it to do.'
208
+ // FUTURE: for including selected text in the context for features like "fix my code", "refactor this", "what is this?" etc
209
+ // const userSelection = triggeredEditor.getSelection()
210
+ // const selectedText = model.getValueInRange(userSelection)
211
+ /** @type {PromptOptions} */
212
+ const promptOptions = {
213
+ method: 'function',
214
+ lang: 'javascript',
215
+ type: 'function',
216
+ subType
217
+ // selectedText: model.getValueInRange(userSelection)
218
+ }
219
+ /** @type {PromptUIOptions} */
220
+ const uiOptions = {
221
+ title: 'FlowFuse Assistant : Function Code',
222
+ explanation: 'The FlowFuse Assistant can help you write JavaScript code.',
223
+ description: 'Enter a short description of what you want it to do.'
224
+ }
225
+ doPrompt(node, thisEditor, promptOptions, uiOptions, (error, response) => {
226
+ if (error) {
227
+ console.warn('Error processing request', error)
228
+ return
215
229
  }
216
- doPrompt(node, thisEditor, promptOptions, uiOptions, (error, response) => {
217
- if (error) {
218
- console.warn('Error processing request', error)
230
+ debug('function response', response)
231
+ const responseData = response?.data
232
+ if (responseData?.func?.length > 0) {
233
+ // ensure the editor is still present in the DOM
234
+ if (!document.body.contains(thisEditor.getDomNode())) {
235
+ console.warn('Editor is no longer in the DOM')
219
236
  return
220
237
  }
221
- debug('function response', response)
222
- const responseData = response?.data
223
- if (responseData?.func?.length > 0) {
224
- // ensure the editor is still present in the DOM
225
- if (!document.body.contains(thisEditor.getDomNode())) {
226
- console.warn('Editor is no longer in the DOM')
227
- return
238
+ thisEditor.focus()
239
+ // insert the generated code at the current cursor position overwriting any selected text
240
+ const currentSelection = thisEditor.getSelection()
241
+ thisEditor.executeEdits('', [
242
+ {
243
+ range: new monaco.Range(currentSelection.startLineNumber, currentSelection.startColumn, currentSelection.endLineNumber, currentSelection.endColumn),
244
+ text: responseData.func
228
245
  }
229
- thisEditor.focus()
230
- // insert the generated code at the current cursor position overwriting any selected text
231
- const currentSelection = thisEditor.getSelection()
232
- thisEditor.executeEdits('', [
233
- {
234
- range: new monaco.Range(currentSelection.startLineNumber, currentSelection.startColumn, currentSelection.endLineNumber, currentSelection.endColumn),
235
- text: responseData.func
236
- }
237
- ])
238
- // update the nodes output count the AI suggests a different number of outputs
239
- if (typeof responseData?.outputs === 'number' && responseData.outputs >= 0) {
240
- const outputsField = $('#node-input-outputs')
241
- const currentOutputs = parseInt(outputsField.val())
242
- if (!isNaN(currentOutputs) && typeof currentOutputs === 'number' && currentOutputs !== responseData.outputs) {
243
- outputsField.val(responseData.outputs)
244
- outputsField.trigger('change')
245
- }
246
+ ])
247
+ // update the nodes output count the AI suggests a different number of outputs
248
+ if (typeof responseData?.outputs === 'number' && responseData.outputs >= 0) {
249
+ const outputsField = $('#node-input-outputs')
250
+ const currentOutputs = parseInt(outputsField.val())
251
+ if (!isNaN(currentOutputs) && typeof currentOutputs === 'number' && currentOutputs !== responseData.outputs) {
252
+ outputsField.val(responseData.outputs)
253
+ outputsField.trigger('change')
246
254
  }
255
+ }
247
256
 
248
- // update libs - get the current list of libs then scan the response for any new ones
249
- // if the lib is not already in the list, add it
250
- if (modulesAllowed) {
251
- if (Array.isArray(responseData?.node_modules) && responseData.node_modules.length > 0) {
252
- const _libs = []
253
- const libs = $('#node-input-libs-container').editableList('items')
254
- libs.each(function (i) {
255
- const item = $(this)
256
- const v = item.find('.node-input-libs-var').val()
257
- let n = item.find('.node-input-libs-val').typedInput('type')
258
- if (n === '_custom_') {
259
- n = item.find('.node-input-libs-val').val()
260
- }
261
- if ((!v || (v === '')) ||
262
- (!n || (n === ''))) {
263
- return
264
- }
265
- _libs.push({
266
- var: v,
267
- module: n
268
- })
269
- })
270
-
271
- responseData.node_modules.forEach((lib) => {
272
- const existing = _libs.find(l => l.module === lib.module)
273
- if (!existing) {
274
- $('#node-input-libs-container').editableList('addItem', { var: lib.var, module: lib.module })
275
- }
257
+ // update libs - get the current list of libs then scan the response for any new ones
258
+ // if the lib is not already in the list, add it
259
+ if (modulesAllowed) {
260
+ if (Array.isArray(responseData?.node_modules) && responseData.node_modules.length > 0) {
261
+ const _libs = []
262
+ const libs = $('#node-input-libs-container').editableList('items')
263
+ libs.each(function (i) {
264
+ const item = $(this)
265
+ const v = item.find('.node-input-libs-var').val()
266
+ let n = item.find('.node-input-libs-val').typedInput('type')
267
+ if (n === '_custom_') {
268
+ n = item.find('.node-input-libs-val').val()
269
+ }
270
+ if ((!v || (v === '')) ||
271
+ (!n || (n === ''))) {
272
+ return
273
+ }
274
+ _libs.push({
275
+ var: v,
276
+ module: n
276
277
  })
277
- }
278
+ })
279
+
280
+ responseData.node_modules.forEach((lib) => {
281
+ const existing = _libs.find(l => l.module === lib.module)
282
+ if (!existing) {
283
+ $('#node-input-libs-container').editableList('addItem', { var: lib.var, module: lib.module })
284
+ }
285
+ })
278
286
  }
279
287
  }
280
- })
281
- } else {
282
- console.warn('Could not find editor for model', model.uri.toString())
283
- }
284
- })
288
+ }
289
+ })
290
+ } else {
291
+ console.warn('Could not find editor for model', model.uri.toString())
292
+ }
293
+ })
285
294
 
286
- monaco.editor.registerCommand(jsonCommandId, function (accessor, model, codeLens, token) {
287
- debug('running command', jsonCommandId)
288
- const node = RED.view.selection()?.nodes?.[0]
289
- if (!node) {
290
- console.warn('No node selected') // should not happen
295
+ monaco.editor.registerCommand(jsonCommandId, function (accessor, model, codeLens, token) {
296
+ debug('running command', jsonCommandId)
297
+ const node = RED.view.selection()?.nodes?.[0]
298
+ if (!node) {
299
+ console.warn('No node selected') // should not happen
300
+ return
301
+ }
302
+ if (!assistantOptions.enabled) {
303
+ RED.notify('The FlowFuse Assistant is not enabled', 'warning')
304
+ return
305
+ }
306
+ const thisEditor = getMonacoEditorForModel(model)
307
+ if (thisEditor) {
308
+ if (!document.body.contains(thisEditor.getDomNode())) {
309
+ console.warn('Editor is no longer in the DOM, cannot proceed.')
291
310
  return
292
311
  }
293
- if (!assistantOptions.enabled) {
294
- RED.notify('The FlowFuse Assistant is not enabled', 'warning')
295
- return
312
+
313
+ // FUTURE: for including selected text in the context for features like "fix my code", "refactor this", "what is this?" etc
314
+ // const userSelection = triggeredEditor.getSelection()
315
+ // const selectedText = model.getValueInRange(userSelection)
316
+ /** @type {PromptOptions} */
317
+ const promptOptions = {
318
+ method: 'json',
319
+ lang: 'json',
320
+ type: node.type
321
+ // selectedText: model.getValueInRange(userSelection)
296
322
  }
297
- const thisEditor = getMonacoEditorForModel(model)
298
- if (thisEditor) {
299
- if (!document.body.contains(thisEditor.getDomNode())) {
300
- console.warn('Editor is no longer in the DOM, cannot proceed.')
323
+ /** @type {PromptUIOptions} */
324
+ const uiOptions = {
325
+ title: 'FlowFuse Assistant : JSON',
326
+ explanation: 'The FlowFuse Assistant can help you write JSON.',
327
+ description: 'Enter a short description of what you want it to do.'
328
+ }
329
+ doPrompt(node, thisEditor, promptOptions, uiOptions, (error, response) => {
330
+ if (error) {
331
+ console.warn('Error processing request', error)
301
332
  return
302
333
  }
303
-
304
- // FUTURE: for including selected text in the context for features like "fix my code", "refactor this", "what is this?" etc
305
- // const userSelection = triggeredEditor.getSelection()
306
- // const selectedText = model.getValueInRange(userSelection)
307
- /** @type {PromptOptions} */
308
- const promptOptions = {
309
- method: 'json',
310
- lang: 'json',
311
- type: node.type
312
- // selectedText: model.getValueInRange(userSelection)
313
- }
314
- /** @type {PromptUIOptions} */
315
- const uiOptions = {
316
- title: 'FlowFuse Assistant : JSON',
317
- explanation: 'The FlowFuse Assistant can help you write JSON.',
318
- description: 'Enter a short description of what you want it to do.'
319
- }
320
- doPrompt(node, thisEditor, promptOptions, uiOptions, (error, response) => {
321
- if (error) {
322
- console.warn('Error processing request', error)
334
+ debug('json response', response)
335
+ const responseData = response?.data
336
+ if (responseData && responseData.json) {
337
+ // ensure the editor is still present in the DOM
338
+ if (!document.body.contains(thisEditor.getDomNode())) {
339
+ console.warn('Editor is no longer in the DOM')
323
340
  return
324
341
  }
325
- debug('json response', response)
326
- const responseData = response?.data
327
- if (responseData && responseData.json) {
328
- // ensure the editor is still present in the DOM
329
- if (!document.body.contains(thisEditor.getDomNode())) {
330
- console.warn('Editor is no longer in the DOM')
331
- return
342
+ thisEditor.focus()
343
+ const currentSelection = thisEditor.getSelection()
344
+ thisEditor.executeEdits('', [
345
+ {
346
+ range: new monaco.Range(currentSelection.startLineNumber, currentSelection.startColumn, currentSelection.endLineNumber, currentSelection.endColumn),
347
+ text: responseData.json
332
348
  }
333
- thisEditor.focus()
334
- const currentSelection = thisEditor.getSelection()
335
- thisEditor.executeEdits('', [
336
- {
337
- range: new monaco.Range(currentSelection.startLineNumber, currentSelection.startColumn, currentSelection.endLineNumber, currentSelection.endColumn),
338
- text: responseData.json
339
- }
340
- ])
341
- }
342
- })
343
- } else {
344
- console.warn('Could not find editor for model', model.uri.toString())
345
- }
346
- })
347
- }
349
+ ])
350
+ }
351
+ })
352
+ } else {
353
+ console.warn('Could not find editor for model', model.uri.toString())
354
+ }
355
+ })
348
356
 
349
357
  // setup actions for function builder
350
- const funcBuilderTitle = 'FlowFuse Function Node Assistant'
351
- RED.actions.add('ff:nr-assistant-function-builder', showFunctionBuilderPrompt, { label: funcBuilderTitle })
358
+ RED.actions.add('flowfuse-nr-assistant:function-builder', showFunctionBuilderPrompt, { label: '@flowfuse/nr-assistant/flowfuse-nr-assistant:actions.function-builder' })
352
359
 
353
360
  // FUTURE: setup actions for flow builder
354
361
  // const flowBuilderTitle = 'FlowFuse Flow Assistant'
355
- // RED.actions.add('ff:nr-assistant-flow-builder', showFlowBuilderPrompt, { label: flowBuilderTitle })
362
+ // RED.actions.add('ff:assistant-flow-builder', showFlowBuilderPrompt, { label: flowBuilderTitle })
356
363
 
357
364
  const toolbarMenuButton = $('<li><a id="red-ui-header-button-ff-ai" class="button" href="#"></a></li>')
358
365
  const toolbarMenuButtonAnchor = toolbarMenuButton.find('a')
@@ -368,7 +375,7 @@
368
375
  toolbarMenuButton.prependTo('.red-ui-header-toolbar') // add the button leftmost of the toolbar
369
376
  }
370
377
  toolbarMenuButtonAnchor.on('click', function (e) {
371
- RED.actions.invoke('ff:nr-assistant-function-builder')
378
+ RED.actions.invoke('flowfuse-nr-assistant:function-builder')
372
379
  })
373
380
  RED.popover.tooltip(toolbarMenuButtonAnchor, 'FlowFuse Assistant')
374
381
  assistantInitialised = true
@@ -594,6 +601,113 @@
594
601
  })
595
602
  }
596
603
 
604
+ function explainSelectedNodes () {
605
+ if (!assistantOptions.enabled) {
606
+ RED.notify('The FlowFuse Assistant is not enabled', 'warning')
607
+ return
608
+ }
609
+ const selection = RED.view.selection()
610
+ if (!selection || !selection.nodes || selection.nodes.length === 0) {
611
+ RED.notify(RED._('@flowfuse/nr-assistant/flowfuse-nr-assistant:errors.common.need-1-or-more-nodes-selected'), 'warning')
612
+ return
613
+ }
614
+ if (selection.nodes.length > 100) { // TODO: increase or make configurable
615
+ RED.notify(RED._('@flowfuse/nr-assistant/flowfuse-nr-assistant:errors.common.need-100-or-less-nodes-selected'), 'warning')
616
+ return
617
+ }
618
+
619
+ const nodes = []
620
+ for (const node of selection.nodes) {
621
+ const n = { ...node }
622
+ delete n._
623
+ delete n._def
624
+ delete n._config
625
+ delete n.validationErrors
626
+ nodes.push(n)
627
+ }
628
+
629
+ /** @type {JQueryXHR} */
630
+ let xhr = null
631
+ const url = 'nr-assistant/mcp/prompts/explain_flow' // e.g. 'nr-assistant/json'
632
+ const transactionId = generateId(8) + '-' + Date.now() // a unique id for this transaction
633
+ const data = {
634
+ transactionId,
635
+ nodes: JSON.stringify(nodes),
636
+ flowName: '', // FUTURE: include the parent flow name in the context to aid with the explanation
637
+ userContext: '' // FUTURE: include user textual input context for more personalized explanations
638
+ }
639
+ const busyNotification = showBusyNotification('Busy processing your request. Please wait...', function () {
640
+ if (xhr) {
641
+ xhr.abort('abort')
642
+ xhr = null
643
+ }
644
+ })
645
+ xhr = $.ajax({
646
+ url,
647
+ type: 'POST',
648
+ data,
649
+ timeout: assistantOptions.requestTimeout,
650
+ success: (reply, textStatus, jqXHR) => {
651
+ busyNotification.close()
652
+ if (reply?.error) {
653
+ RED.notify(reply.error, 'error')
654
+ // callback(new Error(reply.error), null)
655
+ return
656
+ }
657
+
658
+ try {
659
+ const style = 'success'
660
+ const text = reply.data || 'No reply from server'
661
+
662
+ // FUTURE: Ask for a summary and details in json format and parse it
663
+ // let style = 'success'
664
+ // try {
665
+ // const parsedJson = JSON.parse(reply.data)
666
+ // if (!parsedJson.summary && !parsedJson.details) {
667
+ // text = 'No summary or details available!'
668
+ // style = 'warning'
669
+ // }
670
+ // const textBuilder = []
671
+ // if (parsedJson.summary) {
672
+ // textBuilder.push('### Summary')
673
+ // textBuilder.push(parsedJson.summary)
674
+ // textBuilder.push('')
675
+ // }
676
+ // if (parsedJson.details) {
677
+ // textBuilder.push('### Details')
678
+ // textBuilder.push(parsedJson.details)
679
+ // textBuilder.push('')
680
+ // }
681
+ // text = textBuilder.join('\n')
682
+ // } catch (error) {
683
+ // console.warn('Error parsing reply data', error)
684
+ // text = reply.data || 'No data in response from server'
685
+ // }
686
+ showNotification(
687
+ RED.utils.renderMarkdown(text),
688
+ { fixed: true, modal: true, timeout: 0, type: style }
689
+ )
690
+ } catch (error) {
691
+ console.warn('Error rendering reply', error)
692
+ showNotification('Sorry, something went wrong, please try again', { type: 'error' })
693
+ }
694
+ },
695
+ error: (jqXHR, textStatus, errorThrown) => {
696
+ // console.log('showFunctionBuilderPrompt -> ajax -> error', jqXHR, textStatus, errorThrown)
697
+ busyNotification.close()
698
+ if (textStatus === 'abort' || errorThrown === 'abort' || jqXHR.statusText === 'abort') {
699
+ // user cancelled
700
+ return
701
+ }
702
+ processAIErrorResponse(jqXHR, textStatus, errorThrown)
703
+ },
704
+ complete: function () {
705
+ xhr = null
706
+ busyNotification.close()
707
+ }
708
+ })
709
+ }
710
+
597
711
  let previousFlowBuilderPrompt
598
712
  // eslint-disable-next-line no-unused-vars
599
713
  function showFlowBuilderPrompt (title) {
@@ -701,13 +815,24 @@
701
815
  * @returns {{close: () => {}}} - The notification object
702
816
  */
703
817
  function showNotification (message, options) {
818
+ let notification = null
704
819
  options = options || {}
705
820
  options.type = options.type || 'success'
706
821
  options.timeout = Object.prototype.hasOwnProperty.call(options, 'timeout') ? options.timeout : 5000
707
822
  options.fixed = options.fixed || false
708
823
  options.modal = options.modal || false
709
- const pendingNotification = RED.notify(message, options)
710
- return pendingNotification
824
+ if (options.fixed && !options.buttons?.length) {
825
+ // add a close button if the notification is fixed or has no buttons
826
+ options.buttons = []
827
+ options.buttons.push({
828
+ text: 'Close',
829
+ click: function () {
830
+ notification.close()
831
+ }
832
+ })
833
+ }
834
+ notification = RED.notify(message, options)
835
+ return notification
711
836
  }
712
837
 
713
838
  function importFlow (flow, addFlow) {
package/index.js CHANGED
@@ -1,5 +1,13 @@
1
1
  module.exports = (RED) => {
2
2
  const { default: got } = require('got')
3
+ const { z } = require('zod')
4
+
5
+ /** @type {import('@modelcontextprotocol/sdk/client/index.js').Client} */
6
+ let mcpClient = null
7
+ /** @type {import('@modelcontextprotocol/sdk/server/index.js').Server} */
8
+ // eslint-disable-next-line no-unused-vars
9
+ let mcpServer = null
10
+
3
11
  RED.plugins.registerPlugin('flowfuse-nr-assistant', {
4
12
  type: 'assistant',
5
13
  name: 'Node-RED Assistant Plugin',
@@ -24,6 +32,27 @@ module.exports = (RED) => {
24
32
  return
25
33
  }
26
34
 
35
+ if (clientSettings.enabled) {
36
+ mcp().then(({ client, server }) => {
37
+ RED.log.info('FlowFuse Assistant MCP Client / Server initialized')
38
+ mcpClient = client
39
+ mcpServer = server
40
+ // tell frontend that the MCP client is ready so it can add the action(s) to the Action List
41
+ RED.comms.publish('nr-assistant/mcp/ready', clientSettings, true /* retain */)
42
+ }).catch((error) => {
43
+ mcpClient = null
44
+ mcpServer = null
45
+ const nodeVersion = process.versions.node
46
+ // ESM Support in Node 20 is much better than versions v18-, so lets include a node version
47
+ // warning as a hint/prompt (Node 18 is EOL as of writing this)
48
+ if (parseInt(nodeVersion.split('.')[0], 10) < 20) {
49
+ RED.log.error('Failed to initialize FlowFuse Assistant MCP Client / Server. This may be due to using Node.js version < 20.', error)
50
+ } else {
51
+ RED.log.error('Failed to initialize FlowFuse Assistant MCP Client / Server.', error)
52
+ }
53
+ })
54
+ }
55
+
27
56
  RED.log.info('FlowFuse Assistant Plugin loaded')
28
57
 
29
58
  RED.httpAdmin.post('/nr-assistant/:method', RED.auth.needsPermission('write'), function (req, res) {
@@ -82,6 +111,148 @@ module.exports = (RED) => {
82
111
  RED.log.warn(message)
83
112
  })
84
113
  })
114
+
115
+ RED.httpAdmin.get('/nr-assistant/mcp/prompts', RED.auth.needsPermission('write'), async function (req, res) {
116
+ if (!mcpClient) {
117
+ res.status(500).json({ status: 'error', message: 'MCP Client is not initialized' })
118
+ return
119
+ }
120
+ try {
121
+ const prompts = await mcpClient.getPrompts()
122
+ res.json({ status: 'ok', data: prompts })
123
+ } catch (error) {
124
+ RED.log.error('Failed to retrieve MCP prompts:', error)
125
+ res.status(500).json({ status: 'error', message: 'Failed to retrieve MCP prompts' })
126
+ }
127
+ })
128
+
129
+ RED.httpAdmin.post('/nr-assistant/mcp/prompts/:promptId', RED.auth.needsPermission('write'), async function (req, res) {
130
+ if (!mcpClient) {
131
+ res.status(500).json({ status: 'error', message: 'MCP Client is not initialized' })
132
+ return
133
+ }
134
+ const promptId = req.params.promptId
135
+ if (!promptId || typeof promptId !== 'string') {
136
+ res.status(400).json({ status: 'error', message: 'Invalid prompt ID' })
137
+ return
138
+ }
139
+ const input = req.body
140
+ if (!input || !input.nodes || typeof input.nodes !== 'string') {
141
+ res.status(400).json({ status: 'error', message: 'nodes selection is required' })
142
+ return
143
+ }
144
+ try {
145
+ const response = await mcpClient.getPrompt({
146
+ name: promptId,
147
+ arguments: {
148
+ nodes: input.nodes,
149
+ flowName: input.flowName ?? undefined,
150
+ userContext: input.userContext ?? undefined
151
+ }
152
+ })
153
+
154
+ const body = {
155
+ prompt: promptId, // this is the prompt to the AI
156
+ transactionId: input.transactionId, // used to correlate the request with the response
157
+ context: {
158
+ type: 'prompt',
159
+ promptId,
160
+ prompt: response
161
+ }
162
+ }
163
+
164
+ // join url & method (taking care of trailing slashes)
165
+ const url = `${assistantSettings.url.replace(/\/$/, '')}/mcp`
166
+ const responseFromAI = await got.post(url, {
167
+ headers: {
168
+ Accept: '*/*',
169
+ 'Accept-Language': 'en-GB,en-US;q=0.9,en;q=0.8,es;q=0.7',
170
+ Authorization: `Bearer ${assistantSettings.token}`,
171
+ 'Content-Type': 'application/json'
172
+ },
173
+ json: body
174
+ })
175
+ const responseBody = JSON.parse(responseFromAI.body)
176
+ // Assuming the response from the AI is in the expected format
177
+ if (!responseBody || responseFromAI.statusCode !== 200) {
178
+ res.status(responseFromAI.statusCode || 500).json({ status: 'error', message: 'AI response was not successful', data: responseBody })
179
+ return
180
+ }
181
+ // If the response is successful, return the data
182
+ res.json({
183
+ status: 'ok',
184
+ data: responseBody.data || responseBody // Use data if available, otherwise return the whole response
185
+ })
186
+ } catch (error) {
187
+ RED.log.error('Failed to execute MCP prompt:', error)
188
+ res.status(500).json({ status: 'error', message: 'Failed to execute MCP prompt' })
189
+ }
190
+ })
85
191
  }
86
192
  })
193
+
194
+ async function mcp () {
195
+ const { Client } = await import('@modelcontextprotocol/sdk/client/index.js')
196
+ const { McpServer } = await import('@modelcontextprotocol/sdk/server/mcp.js')
197
+ const { InMemoryTransport } = await import('@modelcontextprotocol/sdk/inMemory.js')
198
+ // Create in-process server
199
+ const server = new McpServer({
200
+ name: 'NR MCP Server',
201
+ version: '1.0.0'
202
+ })
203
+
204
+ server.prompt('explain_flow', 'Explain what the selected node-red flow of nodes do', {
205
+ nodes: z
206
+ .string()
207
+ .startsWith('[')
208
+ .endsWith(']')
209
+ .min(23) // Minimum length for a valid JSON array
210
+ .max(100000) // on average, an exported node is ~400-1000 characters long, 100000 characters _should_ realistically be enough for a flow of 100 nodes
211
+ .describe('JSON string that represents a flow of Node-RED nodes'),
212
+ flowName: z.string().optional().describe('Optional name of the flow to explain'),
213
+ userContext: z.string().optional().describe('Optional user context to aid explanation')
214
+ }, async ({ nodes, flowName, userContext }) => {
215
+ const promptBuilder = []
216
+ // 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
217
+ 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.')
218
+ if (flowName) {
219
+ promptBuilder.push(`The parent flow is named "${flowName}".`)
220
+ promptBuilder.push('')
221
+ }
222
+ if (userContext) {
223
+ promptBuilder.push(`User Context: "${userContext}".`)
224
+ promptBuilder.push('')
225
+ }
226
+ promptBuilder.push('Here are the nodes in the flow:')
227
+ promptBuilder.push('```json')
228
+ promptBuilder.push(nodes)
229
+ promptBuilder.push('```')
230
+ return {
231
+ messages: [{
232
+ role: 'user',
233
+ content: {
234
+ type: 'text',
235
+ text: promptBuilder.join('\n')
236
+ }
237
+ }]
238
+ }
239
+ })
240
+
241
+ // Create in-process client
242
+ const client = new Client({
243
+ name: 'NR MCP Client',
244
+ version: '1.0.0'
245
+ })
246
+
247
+ const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair()
248
+ await Promise.all([
249
+ server.connect(serverTransport),
250
+ client.connect(clientTransport)
251
+ ])
252
+
253
+ return {
254
+ client,
255
+ server
256
+ }
257
+ }
87
258
  }
@@ -0,0 +1,13 @@
1
+ {
2
+ "name": "FlowFuse Assistant",
3
+ "actions": {
4
+ "function-builder": "FlowFuse Assistant: Create a Function Node",
5
+ "explain-selected-nodes": "FlowFuse Assistant: Explain Selected Nodes"
6
+ },
7
+ "errors": {
8
+ "common": {
9
+ "need-1-or-more-nodes-selected": "Please select 1 node or more nodes.",
10
+ "need-100-or-less-nodes-selected": "Please select less than 100 nodes."
11
+ }
12
+ }
13
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flowfuse/nr-assistant",
3
- "version": "0.1.4-cba1235-202504140623.0",
3
+ "version": "0.2.0",
4
4
  "description": "FlowFuse Node-RED assistant plugin",
5
5
  "main": "index.js",
6
6
  "scripts": {
@@ -36,6 +36,7 @@
36
36
  "node": ">=16.x"
37
37
  },
38
38
  "dependencies": {
39
+ "@modelcontextprotocol/sdk": "^1.12.1",
39
40
  "got": "^11.8.6"
40
41
  },
41
42
  "devDependencies": {