@flowfuse/nr-assistant 0.3.1-911c4d1-202506271714.0 → 0.3.1-92d1581-202507160925.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 +11 -6
- package/resources/sharedUtils.js +70 -0
package/README.md
CHANGED
|
@@ -25,7 +25,7 @@ This plugin is designed to assist users of the FlowFuse platform by providing to
|
|
|
25
25
|

|
|
26
26
|
|
|
27
27
|
### Flows Explainer
|
|
28
|
-

|
|
29
29
|
|
|
30
30
|
### FlowFuse Dashboard UI Template Assistant
|
|
31
31
|

|
package/completions.html
ADDED
|
@@ -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
|
|
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'
|
|
44
|
-
|
|
45
|
-
if (
|
|
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
|
-
|
|
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
|
|
976
|
-
|
|
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('
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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()
|