@flowfuse/nr-assistant 0.1.1-cc561a2-202407081555.0 → 0.1.2-72f7066-202407151302.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.
Files changed (3) hide show
  1. package/index.html +306 -108
  2. package/index.js +4 -5
  3. package/package.json +1 -1
package/index.html CHANGED
@@ -1,6 +1,22 @@
1
1
  <script>
2
2
  (function (RED, n) {
3
3
  'use strict'
4
+
5
+ /**
6
+ * @typedef {Object} PromptOptions
7
+ * @property {string} method - The method used for routing the call in the API (e.g. 'function', 'json').
8
+ * @property {string} lang - The language type to be generated (e.g. 'javascript', 'json', 'yaml').
9
+ * @property {string} type - The type of the node.
10
+ * @property {string} [selectedText] - Any selected text. // future feature
11
+ */
12
+
13
+ /**
14
+ * @typedef {Object} PromptUIOptions
15
+ * @property {string} title - The title of the FlowFuse Assistant.
16
+ * @property {string} explanation - The explanation of what the FlowFuse Assistant can help with.
17
+ * @property {string} description - A short description of what you want the FlowFuse Assistant to do.
18
+ */
19
+
4
20
  const AI_TIMEOUT = 90000 // default request timeout in milliseconds
5
21
  const modulesAllowed = RED.settings.functionExternalModules !== false
6
22
  const assistantOptions = {
@@ -8,7 +24,7 @@
8
24
  requestTimeout: AI_TIMEOUT
9
25
  }
10
26
  let assistantInitialised = false
11
- debug('nr-assistant loading...')
27
+ debug('loading...')
12
28
  RED.plugins.registerPlugin('flowfuse-nr-assistant', {
13
29
  type: 'assistant',
14
30
  name: 'Node-RED Assistant Plugin',
@@ -29,7 +45,7 @@
29
45
  if (assistantInitialised) {
30
46
  return
31
47
  }
32
- debug('nr-assistant initialising...')
48
+ debug('initialising...')
33
49
  if (!assistantOptions.enabled) {
34
50
  console.warn('The FlowFuse Assistant is not enabled')
35
51
  return
@@ -39,7 +55,11 @@
39
55
  console.warn('Monaco editor not found. Unable to register code lens provider. Consider using the Monaco editor for a better experience.')
40
56
  return
41
57
  } else {
42
- const commandId = 'nr-assistant-fn-inline'
58
+ const funcCommandId = 'nr-assistant-fn-inline'
59
+ const jsonCommandId = 'nr-assistant-json-inline'
60
+
61
+ debug('registering code lens providers...')
62
+
43
63
  monaco.languages.registerCodeLensProvider('javascript', {
44
64
  provideCodeLenses: function (model, token) {
45
65
  const thisEditor = getMonacoEditorForModel(model)
@@ -77,14 +97,14 @@
77
97
  endLineNumber: 2,
78
98
  endColumn: 1
79
99
  },
80
- id: commandId
100
+ id: funcCommandId
81
101
  }
82
102
  ],
83
103
  dispose: () => { }
84
104
  }
85
105
  },
86
106
  resolveCodeLens: function (model, codeLens, token) {
87
- if (codeLens.id !== commandId) {
107
+ if (codeLens.id !== funcCommandId) {
88
108
  return codeLens
89
109
  }
90
110
  codeLens.command = {
@@ -97,8 +117,45 @@
97
117
  }
98
118
  })
99
119
 
100
- let previousPrompt = null
101
- monaco.editor.registerCommand(commandId, function (accessor, model, codeLens, token) {
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
+ }
151
+ return codeLens
152
+ }
153
+ })
154
+
155
+ debug('registering commands...')
156
+
157
+ monaco.editor.registerCommand(funcCommandId, function (accessor, model, codeLens, token) {
158
+ debug('running command', funcCommandId)
102
159
  const node = RED.view.selection()?.nodes?.[0]
103
160
  if (!node) {
104
161
  console.warn('No node selected') // should not happen
@@ -108,16 +165,15 @@
108
165
  RED.notify('The FlowFuse Assistant is not enabled', 'warning')
109
166
  return
110
167
  }
111
- let xhr = null
112
- const nodeId = node.id
113
- const transactionId = `${nodeId}-${Date.now()}` // a unique id for this transaction
114
168
  const thisEditor = getMonacoEditorForModel(model)
115
169
  if (thisEditor) {
116
170
  if (!document.body.contains(thisEditor.getDomNode())) {
117
171
  console.warn('Editor is no longer in the DOM, cannot proceed.')
118
172
  return
119
173
  }
174
+
120
175
  // walk up the tree to find the parent div with an id and include that in context
176
+ let subType = 'on message'
121
177
  let parent = thisEditor.getDomNode().parentNode
122
178
  while (parent?.tagName !== 'FORM') {
123
179
  if (parent.id) {
@@ -125,113 +181,163 @@
125
181
  }
126
182
  parent = parent.parentNode
127
183
  }
128
- // const editorSection = parent?.id || '' // FUTURE: determine the code section users code lens was triggered in. Will be used to help the assistant understand the context of the code
129
- const editorSection = 'on message' // hard coded for now since the code lens is only available in the "on message" editor in this first iteration
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'
196
+ break
197
+ }
130
198
 
131
199
  // FUTURE: for including selected text in the context for features like "fix my code", "refactor this", "what is this?" etc
132
200
  // const userSelection = triggeredEditor.getSelection()
133
201
  // const selectedText = model.getValueInRange(userSelection)
134
-
135
- getUserInput({
136
- defaultInput: previousPrompt,
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 = {
137
212
  title: 'FlowFuse Assistant : Function Code',
138
- explanation: 'The FlowFuse Assistant can help you write code.',
139
- description: 'Enter a short description of what you want the function to do.'
140
- }).then((prompt) => {
141
- if (prompt) {
142
- previousPrompt = prompt
143
- const data = {
144
- prompt,
145
- transactionId,
146
- context: {
147
- scope: 'inline', // inline denotes that the prompt is for a inline code (i.e. the monaco editor)
148
- modulesAllowed,
149
- codeSection: editorSection
150
- // selection: selectedText // FUTURE: include the selected text in the context for features like "fix my code", "refactor this", "what is this?" etc
151
- }
213
+ explanation: 'The FlowFuse Assistant can help you write JavaScript code.',
214
+ description: 'Enter a short description of what you want it to do.'
215
+ }
216
+ doPrompt(node, thisEditor, promptOptions, uiOptions, (error, response) => {
217
+ if (error) {
218
+ console.warn('Error processing request', error)
219
+ return
220
+ }
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
152
228
  }
153
- const busyNotification = showBusyNotification('Busy processing your request. Please wait...', function () {
154
- if (xhr) {
155
- xhr.abort('abort')
156
- xhr = null
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
157
236
  }
158
- })
159
- xhr = $.ajax({
160
- url: 'nr-assistant/function', // /function denotes this is a request for NODE JavaScript code
161
- type: 'POST',
162
- data,
163
- success: function (reply, textStatus, jqXHR) {
164
- const responseData = reply?.data?.data
165
- if (responseData?.func?.length > 0) {
166
- const currentSelection = thisEditor.getSelection()
167
- // ensure the editor is still present in the DOM
168
- if (!document.body.contains(thisEditor.getDomNode())) {
169
- console.warn('Editor is no longer in the DOM')
170
- return
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
+
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()
171
260
  }
172
- busyNotification.close()
173
- thisEditor.focus()
174
- // insert the generated code at the current cursor position overwriting any selected text
175
- thisEditor.executeEdits('', [
176
- {
177
- range: new monaco.Range(currentSelection.startLineNumber, currentSelection.startColumn, currentSelection.endLineNumber, currentSelection.endColumn),
178
- text: responseData.func
179
- }
180
- ])
181
- // update the nodes output count the AI suggests a different number of outputs
182
- if (typeof responseData?.outputs === 'number' && responseData.outputs >= 0) {
183
- const outputsField = $('#node-input-outputs')
184
- const currentOutputs = parseInt(outputsField.val())
185
- if (!isNaN(currentOutputs) && typeof currentOutputs === 'number' && currentOutputs !== responseData.outputs) {
186
- outputsField.val(responseData.outputs)
187
- outputsField.trigger('change')
188
- }
261
+ if ((!v || (v === '')) ||
262
+ (!n || (n === ''))) {
263
+ return
189
264
  }
265
+ _libs.push({
266
+ var: v,
267
+ module: n
268
+ })
269
+ })
190
270
 
191
- // update libs - get the current list of libs then scan the response for any new ones
192
- // if the lib is not already in the list, add it
193
- if (modulesAllowed) {
194
- if (Array.isArray(responseData?.node_modules) && responseData.node_modules.length > 0) {
195
- const _libs = []
196
- const libs = $('#node-input-libs-container').editableList('items')
197
- libs.each(function (i) {
198
- const item = $(this)
199
- const v = item.find('.node-input-libs-var').val()
200
- let n = item.find('.node-input-libs-val').typedInput('type')
201
- if (n === '_custom_') {
202
- n = item.find('.node-input-libs-val').val()
203
- }
204
- if ((!v || (v === '')) ||
205
- (!n || (n === ''))) {
206
- return
207
- }
208
- _libs.push({
209
- var: v,
210
- module: n
211
- })
212
- })
213
-
214
- responseData.node_modules.forEach((lib) => {
215
- const existing = _libs.find(l => l.module === lib.module)
216
- if (!existing) {
217
- $('#node-input-libs-container').editableList('addItem', { var: lib.var, module: lib.module })
218
- }
219
- })
220
- }
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 })
221
275
  }
222
- } else {
223
- processAIErrorResponse(jqXHR, textStatus, 'No response from server')
224
- }
225
- },
226
- error: (jqXHR, textStatus, errorThrown) => {
227
- busyNotification.close()
228
- if (textStatus === 'abort' || errorThrown === 'abort' || jqXHR.statusText === 'abort') {
229
- // user cancelled
230
- return
231
- }
232
- processAIErrorResponse(jqXHR, textStatus, errorThrown)
276
+ })
277
+ }
278
+ }
279
+ }
280
+ })
281
+ } else {
282
+ console.warn('Could not find editor for model', model.uri.toString())
283
+ }
284
+ })
285
+
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
291
+ return
292
+ }
293
+ if (!assistantOptions.enabled) {
294
+ RED.notify('The FlowFuse Assistant is not enabled', 'warning')
295
+ return
296
+ }
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.')
301
+ return
302
+ }
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)
323
+ return
324
+ }
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
332
+ }
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
233
339
  }
234
- })
340
+ ])
235
341
  }
236
342
  })
237
343
  } else {
@@ -258,6 +364,95 @@
258
364
  assistantInitialised = true
259
365
  }
260
366
 
367
+ const previousPrompts = {}
368
+
369
+ /**
370
+ * Prompts the user for input and sends the input to the AI for processing
371
+ * @param {import('node-red').Node} node - The node to associate the prompt with
372
+ * @param {import('monaco-editor').editor.IStandaloneCodeEditor} editor - The editor to associate the prompt with
373
+ * @param {PromptOptions} promptOptions - The options to pass to the prompt
374
+ * @param {PromptUIOptions} [uiOptions] - The options to pass to the prompt
375
+ * @param {(error, response) => {}} callback - The callback function to call when the prompt is complete
376
+ * @returns {void}
377
+ */
378
+ function doPrompt (node, editor, promptOptions, uiOptions, callback) {
379
+ const thisEditor = editor
380
+ let xhr = null
381
+ if (!node || !thisEditor) {
382
+ console.warn('No node or editor found')
383
+ callback(null, null)
384
+ }
385
+ const modulesAllowed = RED.settings.functionExternalModules !== false
386
+ promptOptions = promptOptions || {}
387
+
388
+ const nodeId = node.id
389
+ const transactionId = `${nodeId}-${Date.now()}` // a unique id for this transaction
390
+ const prevPromptKey = `${promptOptions?.method}-${promptOptions?.subType || 'default'}`
391
+ const defaultInput = Object.prototype.hasOwnProperty.call(uiOptions, 'defaultInput') ? uiOptions.defaultInput : previousPrompts[prevPromptKey] || ''
392
+ debug('doPrompt', promptOptions, uiOptions)
393
+ getUserInput({
394
+ defaultInput: defaultInput || '',
395
+ title: uiOptions?.title || 'FlowFuse Assistant',
396
+ explanation: uiOptions?.explanation || 'The FlowFuse Assistant can help you write code.',
397
+ description: uiOptions?.description || 'Enter a short description of what you want it to do.'
398
+ }).then((prompt) => {
399
+ if (!prompt) {
400
+ callback(null, null)
401
+ }
402
+ previousPrompts[prevPromptKey] = prompt
403
+ const data = {
404
+ prompt,
405
+ transactionId,
406
+ context: {
407
+ type: promptOptions.type,
408
+ subType: promptOptions.subType,
409
+ scope: 'inline', // inline denotes that the prompt is for a inline code (i.e. the monaco editor)
410
+ modulesAllowed,
411
+ codeSection: promptOptions.subType
412
+ // selection: selectedText // FUTURE: include the selected text in the context for features like "fix my code", "refactor this", "what is this?" etc
413
+ }
414
+ }
415
+ const busyNotification = showBusyNotification('Busy processing your request. Please wait...', function () {
416
+ if (xhr) {
417
+ xhr.abort('abort')
418
+ xhr = null
419
+ }
420
+ })
421
+ xhr = $.ajax({
422
+ url: 'nr-assistant/' + (promptOptions.method || promptOptions.lang), // e.g. 'nr-assistant/json'
423
+ type: 'POST',
424
+ data,
425
+ success: function (reply, textStatus, jqXHR) {
426
+ debug('doPrompt -> ajax -> success', reply)
427
+ if (reply?.error) {
428
+ RED.notify(reply.error, 'error')
429
+ callback(new Error(reply.error), null)
430
+ return
431
+ }
432
+ if (reply?.data?.transactionId !== transactionId) {
433
+ callback(new Error('Transaction ID mismatch'), null)
434
+ return
435
+ }
436
+ callback(null, reply?.data)
437
+ },
438
+ error: (jqXHR, textStatus, errorThrown) => {
439
+ debug('doPrompt -> ajax -> error', jqXHR, textStatus, errorThrown)
440
+ if (textStatus === 'abort' || errorThrown === 'abort' || jqXHR.statusText === 'abort') {
441
+ // user cancelled
442
+ callback(null, null)
443
+ return
444
+ }
445
+ processAIErrorResponse(jqXHR, textStatus, errorThrown)
446
+ callback(new Error('Error processing request'), null)
447
+ },
448
+ complete: function () {
449
+ xhr = null
450
+ busyNotification.close()
451
+ }
452
+ })
453
+ })
454
+ }
455
+
261
456
  function getUserInput ({ title, explanation, description, placeholder, defaultInput } = {
262
457
  title: 'FlowFuse Assistant',
263
458
  explanation: 'The FlowFuse Assistant can help you create things.',
@@ -369,7 +564,7 @@
369
564
  if (flowJson && Array.isArray(flowJson) && flowJson.length > 0) {
370
565
  importFlow(flowJson)
371
566
  } else {
372
- processAIErrorResponse(jqXHR, textStatus, 'No response from server')
567
+ processAIErrorResponse(jqXHR, textStatus, 'No data in response from server')
373
568
  }
374
569
  } catch (error) {
375
570
  RED.notify('Sorry, something went wrong, please try again', 'error')
@@ -433,7 +628,7 @@
433
628
  if (flowJson && Array.isArray(flowJson) && flowJson.length > 0) {
434
629
  importFlow(flowJson)
435
630
  } else {
436
- processAIErrorResponse(jqXHR, textStatus, 'No response from server')
631
+ processAIErrorResponse(jqXHR, textStatus, 'No data in response from server')
437
632
  }
438
633
  } catch (error) {
439
634
  RED.notify('Sorry, something went wrong, please try again', 'error')
@@ -506,6 +701,9 @@
506
701
  }
507
702
 
508
703
  function importFlow (flow, addFlow) {
704
+ if (RED.workspaces.isLocked && RED.workspaces.isLocked()) {
705
+ addFlow = true // force import to create a new tab
706
+ }
509
707
  let newNodes = flow
510
708
  try {
511
709
  if (typeof flow === 'string') {
@@ -560,4 +758,4 @@
560
758
  }
561
759
  }
562
760
  }(RED, $))
563
- </script>
761
+ </script>
package/index.js CHANGED
@@ -27,22 +27,21 @@ module.exports = (RED) => {
27
27
  RED.log.info('FlowFuse Assistant Plugin loaded')
28
28
 
29
29
  RED.httpAdmin.post('/nr-assistant/:method', RED.auth.needsPermission('write'), function (req, res) {
30
- const method = req.params.method || 'fn'
30
+ const method = req.params.method
31
31
  // limit method to prevent path traversal
32
- if (/[^a-zA-Z0-9-_]/.test(method)) {
32
+ if (!method || typeof method !== 'string' || /[^a-z0-9-_]/.test(method)) {
33
33
  res.status(400)
34
34
  res.json({ status: 'error', message: 'Invalid method' })
35
35
  return
36
36
  }
37
37
  const input = req.body
38
- const prompt = input.prompt
39
- if (!input.prompt) {
38
+ if (!input || !input.prompt || typeof input.prompt !== 'string') {
40
39
  res.status(400)
41
40
  res.json({ status: 'error', message: 'prompt is required' })
42
41
  return
43
42
  }
44
43
  const body = {
45
- prompt, // this is the prompt to the AI
44
+ prompt: input.prompt, // this is the prompt to the AI
46
45
  promptHint: input.promptHint, // this is used to let the AI know what we are generating (`function node? Node JavaScript? flow?)
47
46
  context: input.context, // this is used to provide additional context to the AI (e.g. the selected text of the function node)
48
47
  transactionId: input.transactionId // used to correlate the request with the response
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flowfuse/nr-assistant",
3
- "version": "0.1.1-cc561a2-202407081555.0",
3
+ "version": "0.1.2-72f7066-202407151302.0",
4
4
  "description": "FlowFuse Node-RED assistant plugin",
5
5
  "main": "index.js",
6
6
  "scripts": {