@flowfuse/nr-assistant 0.3.1-911c4d1-202506271714.0 → 0.4.1-7727d4e-202507301240.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/CHANGELOG.md ADDED
@@ -0,0 +1,58 @@
1
+ ### 0.4.0
2
+ - update package for 0.4.0 release
3
+ - Bump flowfuse/github-actions-workflows from 0.39.0 to 0.40.0 (#60)
4
+ - Update imports (#64) @Steve-Mcl
5
+ - Implement node suggestions (#62) @Steve-Mcl
6
+ - Add copy to clipboard and generate comment node to explain dialog (#61) @Steve-Mcl
7
+
8
+ ### 0.3.0
9
+ - Change assistant button to menu for exposing new Flows Explainer by @Steve-Mcl in #53
10
+ - Add menu shortcuts for menu items by @Steve-Mcl in #54
11
+ - Show flow explanation in dialog by @Steve-Mcl in #52
12
+ - Add codelens for CSS and DB2 ui-template by @Steve-Mcl in #56
13
+
14
+ ### 0.2.1
15
+ - Improve README with visuals of what it does by @Steve-Mcl in #49
16
+ - V0.2.1 by @Steve-Mcl in #50
17
+
18
+ ### 0.2.0
19
+
20
+ - Bump flowfuse/github-actions-workflows from 0.19.0 to 0.28.0 by @dependabot in #32
21
+ - Bump flowfuse/github-actions-workflows from 0.28.0 to 0.29.0 by @dependabot in #33
22
+ - Bump flowfuse/github-actions-workflows from 0.29.0 to 0.30.0 by @dependabot in #34
23
+ - Bump flowfuse/github-actions-workflows from 0.30.0 to 0.34.0 by @dependabot in #35
24
+ - Bump flowfuse/github-actions-workflows from 0.34.0 to 0.36.0 by @dependabot in #36
25
+ - Bump flowfuse/github-actions-workflows from 0.36.0 to 0.38.0 by @dependabot in #38
26
+ - Clarify usage restriction of the plugin by @knolleary in #39
27
+ - chore: Pin external actions to commit hash by @ppawlowski in #40
28
+ - chore: fix lint script by @ppawlowski in #41
29
+ - Bump actions/setup-node from 4.3.0 to 4.4.0 by @dependabot in #42
30
+ - Add initial MCP support by @Steve-Mcl in #44
31
+ - V0.2.0 by @Steve-Mcl in #47
32
+
33
+ ### 0.1.3
34
+
35
+ - Fix icon on device agent by @Steve-Mcl in #29
36
+ - bump for 0.1.3 by @Steve-Mcl in #30
37
+
38
+ ### 0.1.2
39
+
40
+ - Fix height of new icon on NR3.x by @Steve-Mcl in #26
41
+ - bump for 0.1.2 by @Steve-Mcl in #27
42
+
43
+ ### 0.1.1
44
+
45
+ - ci: Add build and publish nightly package workflow by @ppawlowski in #7
46
+ - Bump tibdex/github-app-token from 1 to 2 by @dependabot in #11
47
+ - Add JSON editor code lens by @Steve-Mcl in #14
48
+ - Correct handling of locked flows by @Steve-Mcl in #17
49
+ - Update package.json for version bump release by @Steve-Mcl in #20
50
+ - add custom icon to toolbar button by @Steve-Mcl in #21
51
+ - Add comma to settings.js by @kazuhitoyokoi in #22
52
+ - Improved messaging for error responses by @Steve-Mcl in #24
53
+
54
+ ### 0.1.0
55
+
56
+ - add automations by @Steve-Mcl in #1
57
+ - Bump JS-DevTools/npm-publish from 2 to 3 by @dependabot in #5
58
+ - Wording and layout improvements prior to first release by @Steve-Mcl in #8
package/README.md CHANGED
@@ -25,7 +25,7 @@ This plugin is designed to assist users of the FlowFuse platform by providing to
25
25
  ![flowfuse-assistant-json-generator](https://github.com/user-attachments/assets/9d4bf3ef-7ea8-4e72-9e04-73712d5323e3)
26
26
 
27
27
  ### Flows Explainer
28
- ![flowfuse-assistant-flow-explainer](https://github.com/user-attachments/assets/20f5490f-469f-4f95-b63c-cdf216139bd0)
28
+ ![flowfuse-assistant-flow-explainer](https://github.com/user-attachments/assets/6b631048-392b-4028-be8c-bc50f1398a17)
29
29
 
30
30
  ### FlowFuse Dashboard UI Template Assistant
31
31
  ![flowfuse-assistant-ui-template](https://github.com/user-attachments/assets/c6810553-40c0-429e-aa6b-039317b1dc30)
@@ -0,0 +1,406 @@
1
+ <script src="/resources/@flowfuse/nr-assistant/sharedUtils.js"></script>
2
+ <script>
3
+ /* global FFAssistantUtils */ /* loaded from sharedUtils.js */
4
+ /* global RED, $ */ /* loaded from Node-RED core */
5
+ (function (RED, n) {
6
+ 'use strict'
7
+
8
+ /**
9
+ * @typedef {Object} SuggestionSourceContext
10
+ * @property {Array} flow - The current flow
11
+ * @property {Object} source - The source node that the suggestion is being requested for
12
+ * @property {number} sourcePort - The port of the source node that the suggestion is being requested for
13
+ * @property {number} sourcePortType - The type of the source port (0 for output port, 1 for input port)
14
+ * @property {string} workspace - The current workspace id
15
+ */
16
+
17
+ /**
18
+ * @typedef {Object} Suggestion
19
+ * @property {string} label - The label for the suggestion
20
+ * @property {Array<Object>} nodes - The nodes that make up the suggestion
21
+ */
22
+
23
+ const AI_TIMEOUT = 90000 // default request timeout in milliseconds
24
+ const assistantOptions = {
25
+ enabled: false,
26
+ requestTimeout: AI_TIMEOUT,
27
+ completionsEnabled: true
28
+ }
29
+ let mcpReady = false
30
+ let completionsReady = false
31
+
32
+ debug('loading suggestion-source plugin...')
33
+ const plugin = {
34
+ type: 'node-red-flow-suggestion-source',
35
+ /**
36
+ * Get suggestions for the given context.
37
+ * @param {SuggestionSourceContext} context - The context to get suggestions for.
38
+ * @returns {Promise<Array<Suggestion>>} - A promise that resolves to an array of suggestions.
39
+ */
40
+ getSuggestions: async function (context) {
41
+ debug('getSuggestions', context)
42
+ if (!assistantOptions.enabled || !mcpReady) {
43
+ return []
44
+ }
45
+ const { flow, source, sourcePort, sourcePortType, workspace } = context || {}
46
+ if (!context || typeof sourcePortType !== 'number' || !source || typeof sourcePort !== 'number' || !workspace) {
47
+ // if the context is not valid or the source port type is an input port, we cannot provide suggestions
48
+ debug('Invalid context or source port type, cannot provide suggestions')
49
+ return []
50
+ }
51
+ if (sourcePortType !== 0) {
52
+ // For now, only allow predictions for output ports (sourcePortType 0 === source node output port)
53
+ debug('Source port type is not an output port, reverse suggestions are not supported')
54
+ return []
55
+ }
56
+
57
+ // eslint-disable-next-line no-unused-vars
58
+ const [error, promise, _xhr] = await getNextPredictions(flow, source, sourcePortType, sourcePort)
59
+ if (error || !promise) {
60
+ debug('Error getting next predictions...')
61
+ debug(error)
62
+ return []
63
+ }
64
+ const structuredContent = await promise
65
+ if (!structuredContent || !structuredContent.suggestions || structuredContent.suggestions.length === 0) {
66
+ debug('No suggestions found in response for the context provided')
67
+ return []
68
+ }
69
+
70
+ const MAX_SUGGESTIONS = 3 // limit the number of suggestions to top 3 - this keeps the entries in the type search priority list visible
71
+ const excludeTypes = ['debug', 'function', 'change', 'switch', 'junction'] // exclude node types that are always offered first in the priority list
72
+
73
+ // Reformat the suggestions to match the expected format of the plugin
74
+ /** @type {Array<Suggestion>} */
75
+ const typeSearchSuggestions = structuredContent.suggestions.map(nodes => {
76
+ if (Array.isArray(nodes) && nodes.length > 0) {
77
+ // if a single node suggestion is provided, and that node is already in the priority
78
+ // list of the type search, skip it
79
+ if (nodes.length === 1 && nodes[0].type && excludeTypes.includes(nodes[0].type)) {
80
+ return null
81
+ }
82
+ return {
83
+ label: '', // let core handle the label
84
+ nodes: nodes.map(node => {
85
+ return { x: 0, y: 0, ...node }
86
+ })
87
+ }
88
+ }
89
+ return null
90
+ }).filter(s => s !== null).slice(0, MAX_SUGGESTIONS)
91
+ debug('prediction', typeSearchSuggestions)
92
+ return typeSearchSuggestions || []
93
+ },
94
+ name: 'Node-RED Assistant Completions',
95
+ icon: 'font-awesome/fa-magic',
96
+ onadd: async function () {
97
+ if (!window.FFAssistantUtils) {
98
+ console.warn('FFAssistantUtils lib is not loaded. Completions might not work as expected.')
99
+ }
100
+ RED.comms.subscribe('nr-assistant/#', (topic, msg) => {
101
+ debug('comms', topic, msg)
102
+ if (topic === 'nr-assistant/initialise') {
103
+ assistantOptions.enabled = !!msg?.enabled
104
+ assistantOptions.requestTimeout = msg?.requestTimeout || AI_TIMEOUT
105
+ assistantOptions.completionsEnabled = msg?.completionsEnabled !== false
106
+ }
107
+ if (topic === 'nr-assistant/mcp/ready') {
108
+ mcpReady = !!msg?.enabled
109
+ // Since the frontend has now obviously loaded, lets signal the backend to load the models.
110
+ // For this, we will use the newer RED.comms.send API (`comms.send` API was created before
111
+ // the `suggestions` API so, if the `send` function is not available, we can assume the
112
+ // suggestions API is not available - i.e. dont bother sending the request to load completions
113
+ // since the lack of `send` means the backend doesnt have the suggestions API!
114
+ if (RED.comms.send) {
115
+ debug('sending initialise request to backend')
116
+ RED.comms.send('nr-assistant/completions/load', {
117
+ enabled: assistantOptions.enabled,
118
+ mcpReady
119
+ })
120
+ } else {
121
+ console.warn('RED.comms.send is not available, cannot initialise completions')
122
+ }
123
+ }
124
+ if (topic === 'nr-assistant/completions/ready') {
125
+ // This event is fired only after MCP has loaded and the "nr-assistant/mcp/ready" event has
126
+ // been received by the frontend. That triggers the `nr-assistant/completions/load` event
127
+ // to signal to the backend to request and load completion data (model/vocab etc).
128
+ // This is done to avoid loading the completions unnecessarily (i.e. if the MCP is not
129
+ // enabled/ready/supported, we don't need to load the completions!) Also, for times when many
130
+ // instances restart (pipeline activities), we don't want to load the completions down
131
+ // to potentially 1000s of instances when most of them may never even open the frontend
132
+ // where these completions are actually used!
133
+ completionsReady = !!msg?.enabled
134
+ if (!completionsReady) {
135
+ debug('Completions not enabled, cannot provide suggestions on node:add')
136
+ return
137
+ }
138
+ debug('Completions are ready')
139
+ let suggestionXhr = null
140
+ const justAdded = {
141
+ node: null,
142
+ opts: null,
143
+ sourcePort: null
144
+ }
145
+
146
+ const SKIP = 'skip'
147
+ const CONTINUE = 'continue'
148
+ const CONTINUE_NO_LINK_ADDED = 'continue-timeout'
149
+ let linkAddedPromise = null
150
+ let linkAddedResolve = null
151
+ let linkAddedTimeout = null
152
+
153
+ RED.events.on('nodes:add', async function (n, opts) {
154
+ debug('nodes:add', n, opts)
155
+ if (!completionsReady || !assistantOptions.enabled) {
156
+ return
157
+ }
158
+ if (suggestionXhr) {
159
+ debug('Aborting previous suggestionXhr')
160
+ suggestionXhr.abort('abort')
161
+ suggestionXhr = null
162
+ }
163
+ if (linkAddedResolve) {
164
+ debug('Aborting previous linkAddedPromise')
165
+ linkAddedResolve(SKIP)
166
+ clearTimeout(linkAddedTimeout)
167
+ linkAddedResolve = null
168
+ linkAddedTimeout = null
169
+ }
170
+ // only support nodes with at least one output port
171
+ if (!n.outputs || n.outputs < 1) {
172
+ debug('Node does not have any outputs, skipping prediction')
173
+ return
174
+ }
175
+ // if the opts are not provided or the source is not palette, typeSearch, or suggestion skip the prediction
176
+ if (!opts || (opts.source !== 'palette' && opts.source !== 'typeSearch' && opts.source !== 'suggestion') || opts.splice === true) {
177
+ debug('opts not valid for prediction, skipping')
178
+ return
179
+ }
180
+
181
+ // Store the node and options for later use
182
+ justAdded.node = n
183
+ justAdded.opts = opts
184
+ justAdded.sourcePort = 0 // default to first output port
185
+
186
+ // Create a promise that will be resolved by the links:add handler
187
+ linkAddedPromise = new Promise((resolve) => {
188
+ linkAddedResolve = resolve
189
+ linkAddedTimeout = setTimeout(() => {
190
+ if (linkAddedResolve && linkAddedTimeout) {
191
+ justAdded.link = false
192
+ linkAddedResolve(CONTINUE_NO_LINK_ADDED)
193
+ linkAddedResolve = null
194
+ }
195
+ }, 50)
196
+ })
197
+ // Wait for either the link to be added or a timeout
198
+ const action = await linkAddedPromise
199
+ clearTimeout(linkAddedTimeout)
200
+ linkAddedPromise = null
201
+ linkAddedResolve = null
202
+ if (action === SKIP) {
203
+ debug('linkAddedPromise skipped')
204
+ return
205
+ } else if (action === CONTINUE) {
206
+ debug('linkAddedPromise continued')
207
+ } else if (action === CONTINUE_NO_LINK_ADDED) {
208
+ debug('linkAddedPromise continued after timeout')
209
+ } else {
210
+ console.warn('Unexpected action:', action)
211
+ }
212
+
213
+ const upstreamNodes = RED.nodes.getAllUpstreamNodes(justAdded.node)
214
+ const nodes = [justAdded.node, ...upstreamNodes].reverse()
215
+ if (suggestionXhr) {
216
+ suggestionXhr.abort('abort')
217
+ suggestionXhr = null
218
+ }
219
+ /** @type {[error:Error, prediction:Object, xhr:JQueryXHR]} */
220
+ const predictResult = getNextPredictions(nodes, justAdded.node, justAdded.sourcePort)
221
+ if (predictResult[0]) {
222
+ console.warn('Error getting next predictions:', predictResult[0])
223
+ return
224
+ }
225
+ // const predictResult = predictNext(nodes, justAdded.node, justAdded.sourcePort) // TODO: consider sourcePort!
226
+ const promise = predictResult[1] // the promise that resolves with the prediction result
227
+ suggestionXhr = predictResult[2] // the xhr reference is used to abort the request if needed
228
+ promise.then(result => {
229
+ suggestionXhr = null // reset the xhr reference
230
+ const suggestions = result.suggestions || []
231
+ if (!Array.isArray(suggestions) || suggestions.length === 0) {
232
+ debug('No suggestions found in prediction result')
233
+ return
234
+ }
235
+ // e.g.
236
+ // suggestion = { source: 'be4d6bc6b3dfc9e0', sourcePort: 0, nodes: [{ type: 'debug', id: '1234567890abcdef', x: 100, y: 200, z: 'flow-id' }] }
237
+ const suggestionOptions = {
238
+ clickToApply: true,
239
+ source: justAdded.node,
240
+ sourcePort: justAdded.sourcePort || 0, // default to first output port
241
+ position: 'relative',
242
+ nodes: suggestions.slice(0, 5) // limit to 5 inline suggestions
243
+ }
244
+ debug('Prediction for next node:', suggestionOptions)
245
+ RED.view.setSuggestedFlow(suggestionOptions)
246
+ }).catch(error => {
247
+ console.warn('Error predicting next node:', error)
248
+ }).finally(() => {
249
+ suggestionXhr = null // reset the xhr reference
250
+ // reset the justAdded object
251
+ justAdded.opts = null // reset the opts reference
252
+ justAdded.node = null // reset the node reference
253
+ justAdded.sourcePort = null // reset the sourcePort reference
254
+ justAdded.link = null // reset the link reference
255
+ })
256
+ })
257
+ RED.events.on('links:add', function (link) {
258
+ debug('links:add', link)
259
+ if (!completionsReady || !assistantOptions.enabled) {
260
+ return
261
+ }
262
+ if (!linkAddedPromise || !linkAddedResolve) {
263
+ // no link added promise, so we can skip this
264
+ debug('No link added promise, probably a wire up operation rather than a node:add->links:add combo. Just return!')
265
+ return
266
+ }
267
+ if (!justAdded.opts || (justAdded.opts.source !== 'palette' && justAdded.opts.source !== 'typeSearch' && justAdded.opts.source !== 'suggestion')) {
268
+ debug('links:add fired but not from palette or type search. opts:', justAdded.opts)
269
+ linkAddedResolve(SKIP)
270
+ return
271
+ }
272
+
273
+ if (!justAdded.node) {
274
+ debug('No node added, skipping prediction')
275
+ linkAddedResolve(SKIP)
276
+ return
277
+ }
278
+ if (link.source?.id !== justAdded.node.id && link.target?.id !== justAdded.node.id) {
279
+ debug('Not the node we just added, skipping prediction')
280
+ linkAddedResolve(SKIP)
281
+ return
282
+ }
283
+
284
+ // determine if this is a forward link (out-->in)
285
+ if (link.source?.id === justAdded.node.id) {
286
+ debug('This is a reverse link (in-->out), skipping prediction')
287
+ linkAddedResolve(SKIP)
288
+ return
289
+ }
290
+ // If we get here, the link is relevant, so resolve the promise
291
+ if (justAdded.link === null) {
292
+ justAdded.link = link // store the link for later use
293
+ }
294
+ linkAddedResolve(CONTINUE)
295
+ })
296
+ }
297
+ })
298
+ }
299
+ }
300
+ RED.plugins.registerPlugin('ff-assistant-completions', plugin)
301
+
302
+ /**
303
+ * Get the next predictions for the given flow and source node.
304
+ * @param {Array} flow - The current flow
305
+ * @param {Object} sourceNode - The source node that the prediction is being requested for
306
+ * @param {number} sourcePortType - The type of the source port (0 for output port, 1 for input port)
307
+ * @param {number} [sourcePort=0] - The port of the source node that the prediction is being requested for
308
+ * @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
309
+ */
310
+ function getNextPredictions (flow, sourceNode, sourcePortType, sourcePort = 0) {
311
+ if (!assistantOptions.enabled) {
312
+ const error = new Error(RED.notify(plugin._('errors.assistant-not-enabled')))
313
+ return [error, Promise.resolve(null)]
314
+ }
315
+ // only allow predictions for output ports (sourcePortType === 0 (source node output port))
316
+ if (!sourceNode || sourceNode.length === 0 || sourcePortType !== 0) {
317
+ return [null, Promise.resolve(null)]
318
+ }
319
+
320
+ const { flow: nodes } = FFAssistantUtils.cleanFlow(flow)
321
+ const { flow: cleanedSourceFlow } = FFAssistantUtils.cleanFlow([sourceNode]) || {}
322
+
323
+ /** @type {JQueryXHR} */
324
+ let xhr = null
325
+ const url = 'nr-assistant/mcp/tools/predict_next' // e.g. 'nr-assistant/json'
326
+ const transactionId = generateId(8) + '-' + Date.now() // a unique id for this transaction
327
+ const data = {
328
+ transactionId,
329
+ flow: nodes,
330
+ sourceNode: cleanedSourceFlow[0],
331
+ sourcePort,
332
+ flowName: '', // FUTURE: include the parent flow name in the context to aid with the explanation
333
+ userContext: '' // FUTURE: include user textual input context for more personalized explanations
334
+ }
335
+
336
+ const promise = new Promise((resolve, reject) => {
337
+ xhr = $.ajax({
338
+ url,
339
+ type: 'POST',
340
+ data,
341
+ timeout: assistantOptions.requestTimeout,
342
+ success: (reply, textStatus, jqXHR) => {
343
+ // busyNotification.close()
344
+ if (reply?.error) {
345
+ debug('predictNext -> ajax -> error', reply.error)
346
+ return resolve(null)
347
+ }
348
+
349
+ try {
350
+ if (typeof reply.data !== 'object' || !reply.data) {
351
+ debug('Invalid reply data', reply.data)
352
+ return resolve(null)
353
+ }
354
+ const { result, ...replyData } = reply.data
355
+ if (!result || replyData.transactionId !== transactionId) {
356
+ debug('Prediction transaction ID mismatch', result, transactionId)
357
+ return resolve(null)
358
+ }
359
+ if (!result || !result.structuredContent || !result.structuredContent.suggestions || Array.isArray(result.structuredContent.suggestions) === false || result.structuredContent.suggestions.length === 0) {
360
+ debug('No result text in reply', reply.data)
361
+ return resolve(null)
362
+ }
363
+ return resolve(result.structuredContent)
364
+ } catch (error) {
365
+ console.warn('Error processing prediction response', error)
366
+ resolve(null)
367
+ }
368
+ },
369
+ error: (jqXHR, textStatus, errorThrown) => {
370
+ // console.log('explainSelectedNodes -> ajax -> error', jqXHR, textStatus, errorThrown)
371
+ // busyNotification.close()
372
+ if (textStatus === 'abort' || errorThrown === 'abort' || jqXHR.statusText === 'abort') {
373
+ // user cancelled
374
+ return
375
+ }
376
+ resolve(null) // resolve with null to indicate no prediction
377
+ },
378
+ complete: function () {
379
+ xhr = null
380
+ // busyNotification.close()
381
+ }
382
+ })
383
+ })
384
+ return [null, promise, xhr]
385
+ }
386
+
387
+ function generateId (length = 16) {
388
+ if (typeof length !== 'number' || length < 1) {
389
+ throw new Error('Invalid length')
390
+ }
391
+ const iterations = Math.ceil(length / 2)
392
+ const bytes = []
393
+ for (let i = 0; i < iterations; i++) {
394
+ bytes.push(Math.round(0xff * Math.random()).toString(16).padStart(2, '0'))
395
+ }
396
+ return bytes.join('').substring(0, length)
397
+ }
398
+
399
+ function debug () {
400
+ if (RED.nrAssistant?.DEBUG) {
401
+ // eslint-disable-next-line no-console
402
+ console.log('[nr-assistant]', ...arguments)
403
+ }
404
+ }
405
+ }(RED, $))
406
+ </script>
package/index.html CHANGED
@@ -1,4 +1,7 @@
1
+ <script src="/resources/@flowfuse/nr-assistant/sharedUtils.js"></script>
1
2
  <script>
3
+ /* global FFAssistantUtils */ /* loaded from sharedUtils.js */
4
+ /* global RED, $ */ /* loaded from Node-RED core */
2
5
  (function (RED, n) {
3
6
  'use strict'
4
7
 
@@ -24,13 +27,16 @@
24
27
  requestTimeout: AI_TIMEOUT
25
28
  }
26
29
  let assistantInitialised = false
27
- let assistantMCPOneTimeFlag = false
30
+ let mcpReady = false
28
31
  debug('loading...')
29
32
  const plugin = {
30
33
  type: 'assistant',
31
34
  name: 'Node-RED Assistant Plugin',
32
35
  icon: 'font-awesome/fa-magic',
33
36
  onadd: async function () {
37
+ if (!window.FFAssistantUtils) {
38
+ console.warn('FFAssistantUtils lib is not loaded. Completions might not work as expected.')
39
+ }
34
40
  RED.comms.subscribe('nr-assistant/#', (topic, msg) => {
35
41
  debug('comms', topic, msg)
36
42
  if (topic === 'nr-assistant/initialise') {
@@ -40,9 +46,9 @@
40
46
  RED.actions.add('flowfuse-nr-assistant:function-builder', showFunctionBuilderPrompt, { label: '@flowfuse/nr-assistant/flowfuse-nr-assistant:function-builder.action.label' })
41
47
  setMenuShortcutKey('ff-assistant-function-builder', 'red-ui-workspace', 'ctrl-alt-f', 'flowfuse-nr-assistant:function-builder')
42
48
  }
43
- if (topic === 'nr-assistant/mcp/ready' && !assistantMCPOneTimeFlag) {
44
- assistantMCPOneTimeFlag = true
45
- if (assistantOptions.enabled) {
49
+ if (topic === 'nr-assistant/mcp/ready') {
50
+ mcpReady = !!msg?.enabled && assistantOptions.enabled
51
+ if (mcpReady) {
46
52
  debug('assistant MCP initialised')
47
53
  RED.actions.add('flowfuse-nr-assistant:explain-flows', explainSelectedNodes, { label: '@flowfuse/nr-assistant/flowfuse-nr-assistant:explain-flows.action.label' })
48
54
  const menuEntry = {
@@ -927,21 +933,14 @@
927
933
  RED.notify(plugin._('explain-flows.errors.no-nodes-selected'), 'warning')
928
934
  return
929
935
  }
930
- if (selection.nodes.length > 100) { // TODO: increase or make configurable
936
+
937
+ const { flow: nodes, nodeCount: totalNodeCount } = FFAssistantUtils.cleanFlow(selection.nodes)
938
+
939
+ if (totalNodeCount > 100) { // TODO: increase or make configurable
931
940
  RED.notify(plugin._('explain-flows.errors.too-many-nodes-selected'), 'warning')
932
941
  return
933
942
  }
934
943
 
935
- const nodes = []
936
- for (const node of selection.nodes) {
937
- const n = { ...node }
938
- delete n._
939
- delete n._def
940
- delete n._config
941
- delete n.validationErrors
942
- nodes.push(n)
943
- }
944
-
945
944
  /** @type {JQueryXHR} */
946
945
  let xhr = null
947
946
  const url = 'nr-assistant/mcp/prompts/explain_flow' // e.g. 'nr-assistant/json'
@@ -972,11 +971,65 @@
972
971
  }
973
972
 
974
973
  try {
975
- const text = reply.data || 'No reply from server'
976
- showMessage(plugin._('explain-flows.dialog-result.title'), RED.utils.renderMarkdown(text), 'html', {})
974
+ const text = reply.data
975
+ let dlg = null
976
+ const options = {
977
+ buttons: [{
978
+ text: plugin._('explain-flows.dialog-result.close-button'),
979
+ icon: 'ui-icon-close',
980
+ class: 'primary',
981
+ click: function () {
982
+ $(dlg).dialog('close')
983
+ }
984
+ }]
985
+ }
986
+ if (text && text.length > 0) {
987
+ // if copy to clipboard is supported, add a button to copy the text
988
+ const isHttpsOrLocalhost = location.protocol === 'https:' || location.hostname === 'localhost' || location.hostname === '127.0.0.1'
989
+ if (isHttpsOrLocalhost && navigator.clipboard && navigator.clipboard.writeText) {
990
+ options.buttons.unshift(
991
+ {
992
+ text: plugin._('explain-flows.dialog-result.copy-button'),
993
+ icon: 'ui-icon-copy',
994
+ click: function () {
995
+ navigator.clipboard.writeText(text).then(() => {
996
+ showNotification('Copied to clipboard', { type: 'success' })
997
+ }).catch((err) => {
998
+ console.warn('Failed to copy to clipboard', err)
999
+ showNotification(plugin._('errors.copy-failed'), { type: 'error' })
1000
+ })
1001
+ }
1002
+ }
1003
+ )
1004
+ }
1005
+ const isLocked = typeof RED.workspaces.isLocked === 'function' && RED.workspaces.isLocked()
1006
+ if (!isLocked) {
1007
+ options.buttons.unshift(
1008
+ {
1009
+ text: plugin._('explain-flows.dialog-result.comment-node-button'),
1010
+ icon: 'ui-icon-comment',
1011
+ // class: 'primary',
1012
+ click: function () {
1013
+ const commentNode = {
1014
+ type: 'comment',
1015
+ name: plugin._('explain-flows.dialog-result.comment-node-name'),
1016
+ info: text
1017
+ }
1018
+ importFlow(commentNode, false, 'Drop the generated comment node onto the workspace')
1019
+ $(dlg).dialog('close')
1020
+ }
1021
+ }
1022
+ )
1023
+ }
1024
+ }
1025
+ if (!text || text.length === 0) {
1026
+ showNotification('Sorry, no explanation could be generated.', { type: 'warning' })
1027
+ return
1028
+ }
1029
+ dlg = showMessage(plugin._('explain-flows.dialog-result.title'), RED.utils.renderMarkdown(text), 'html', options)
977
1030
  } catch (error) {
978
1031
  console.warn('Error rendering reply', error)
979
- showNotification('Sorry, something went wrong, please try again', { type: 'error' })
1032
+ showNotification(plugin._('errors.something-went-wrong'), { type: 'error' })
980
1033
  }
981
1034
  },
982
1035
  error: (jqXHR, textStatus, errorThrown) => {
@@ -1122,7 +1175,7 @@
1122
1175
  return notification
1123
1176
  }
1124
1177
 
1125
- function importFlow (flow, addFlow) {
1178
+ function importFlow (flow, addFlow, notificationMessage = 'Drop the generated node onto the workspace') {
1126
1179
  if (RED.workspaces.isLocked && RED.workspaces.isLocked()) {
1127
1180
  addFlow = true // force import to create a new tab
1128
1181
  }
@@ -1142,7 +1195,9 @@
1142
1195
  }
1143
1196
  }
1144
1197
  const importOptions = { generateIds: true, addFlow }
1145
- RED.notify('Place your generated flow onto the workspace', 'compact')
1198
+ if (notificationMessage) {
1199
+ RED.notify(notificationMessage, 'compact')
1200
+ }
1146
1201
  RED.view.importNodes(newNodes, importOptions)
1147
1202
  } catch (error) {
1148
1203
  // console.log(error)
@@ -1220,7 +1275,7 @@
1220
1275
  * @param {string} action - The action to set the shortcut for (e.g. 'flowfuse-nr-assistant:function-builder')
1221
1276
  */
1222
1277
  function setMenuShortcutKey (id, scope, key, action) {
1223
- console.warn('setMenuShortcutKey')
1278
+ debug('setMenuShortcutKey')
1224
1279
  if (!scope || !key || !action) {
1225
1280
  console.warn('setMenuShortcutKey called with missing parameters', { scope, key, action })
1226
1281
  return
@@ -1244,7 +1299,7 @@
1244
1299
  const actionItem = $('#' + id + ' > span > span.red-ui-menu-label')
1245
1300
  const hasPopoverSpan = actionItem.find('span.red-ui-popover-key').length > 0
1246
1301
  if (!hasPopoverSpan) {
1247
- console.warn('setMenuShortcutKey: adding popover span to action item', { id, scope, key, action })
1302
+ debug('setMenuShortcutKey: adding popover span to action item', { id, scope, key, action })
1248
1303
  actionItem.append('<span class="red-ui-popover-key"></span>')
1249
1304
  }
1250
1305
  RED.menu.refreshShortcuts()