@flowfuse/nr-assistant 0.1.4-c83f2f1-202409160623.0 → 0.1.4-ed98905-202506111910.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/.github/workflows/publish.yaml +4 -4
- package/.github/workflows/release-publish.yml +3 -3
- package/README.md +9 -2
- package/index.html +386 -261
- package/index.js +171 -0
- package/locales/en-US/index.json +13 -0
- package/package.json +4 -3
|
@@ -11,7 +11,7 @@ on:
|
|
|
11
11
|
|
|
12
12
|
jobs:
|
|
13
13
|
build:
|
|
14
|
-
uses: 'flowfuse/github-actions-workflows/.github/workflows/build_node_package.yml@v0.
|
|
14
|
+
uses: 'flowfuse/github-actions-workflows/.github/workflows/build_node_package.yml@v0.39.0'
|
|
15
15
|
with:
|
|
16
16
|
node: '[
|
|
17
17
|
{"version": "16", "tests": true, "lint": false},
|
|
@@ -24,7 +24,7 @@ jobs:
|
|
|
24
24
|
if: |
|
|
25
25
|
( github.event_name == 'push' && github.ref == 'refs/heads/main' ) ||
|
|
26
26
|
( github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main' )
|
|
27
|
-
uses: 'flowfuse/github-actions-workflows/.github/workflows/publish_node_package.yml@v0.
|
|
27
|
+
uses: 'flowfuse/github-actions-workflows/.github/workflows/publish_node_package.yml@v0.39.0'
|
|
28
28
|
with:
|
|
29
29
|
package_name: nr-assistant
|
|
30
30
|
publish_package: true
|
|
@@ -38,12 +38,12 @@ jobs:
|
|
|
38
38
|
steps:
|
|
39
39
|
- name: Generate a token
|
|
40
40
|
id: generate_token
|
|
41
|
-
uses: tibdex/github-app-token@v2
|
|
41
|
+
uses: tibdex/github-app-token@3beb63f4bd073e61482598c45c71c1019b59b73a # v2.1.0
|
|
42
42
|
with:
|
|
43
43
|
app_id: ${{ secrets.GH_BOT_APP_ID }}
|
|
44
44
|
private_key: ${{ secrets.GH_BOT_APP_KEY }}
|
|
45
45
|
- name: Trigger nr-launcher package build
|
|
46
|
-
uses: benc-uk/workflow-dispatch@v1
|
|
46
|
+
uses: benc-uk/workflow-dispatch@e2e5e9a103e331dad343f381a29e654aea3cf8fc # v1.2.4
|
|
47
47
|
with:
|
|
48
48
|
workflow: publish.yml
|
|
49
49
|
repo: flowfuse/nr-launcher
|
|
@@ -8,12 +8,12 @@ jobs:
|
|
|
8
8
|
publish:
|
|
9
9
|
runs-on: ubuntu-latest
|
|
10
10
|
steps:
|
|
11
|
-
- uses: actions/checkout@v4
|
|
12
|
-
- uses: actions/setup-node@v4
|
|
11
|
+
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
|
12
|
+
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
|
13
13
|
with:
|
|
14
14
|
node-version: 16
|
|
15
15
|
- run: npm ci --omit=dev
|
|
16
|
-
- uses: JS-DevTools/npm-publish@v3
|
|
16
|
+
- uses: JS-DevTools/npm-publish@19c28f1ef146469e409470805ea4279d47c3d35c # v3.1.1
|
|
17
17
|
with:
|
|
18
18
|
token: ${{ secrets.NPM_PUBLISH_TOKEN }}
|
|
19
19
|
access: public
|
package/README.md
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
# FlowFuse Node-RED Assistant
|
|
2
2
|
|
|
3
|
-
A Node-RED plugin to assist FlowFuse users
|
|
3
|
+
A Node-RED plugin to assist FlowFuse users.
|
|
4
4
|
|
|
5
|
+
This plugin can only be used within FlowFuse-managed Node-RED instances.
|
|
5
6
|
|
|
6
7
|
## Installation
|
|
7
8
|
|
|
@@ -11,7 +12,7 @@ npm install @flowfuse/nr-assistant
|
|
|
11
12
|
|
|
12
13
|
## Development
|
|
13
14
|
|
|
14
|
-
Client-side portion of the plugin is in `index.
|
|
15
|
+
Client-side portion of the plugin is in `index.html`. The server side code is in `index.js`
|
|
15
16
|
|
|
16
17
|
## About
|
|
17
18
|
|
|
@@ -20,7 +21,9 @@ This plugin is designed to assist users of the FlowFuse platform by providing to
|
|
|
20
21
|
The capabilities it adds to Node-RED can be found in Node-RED editor on the main toolbar and within the function node editor.
|
|
21
22
|
|
|
22
23
|
## NOTES:
|
|
24
|
+
|
|
23
25
|
* Requires the settings.js file to contain the following:
|
|
26
|
+
|
|
24
27
|
```json
|
|
25
28
|
{
|
|
26
29
|
"flowforge": {
|
|
@@ -34,6 +37,10 @@ The capabilities it adds to Node-RED can be found in Node-RED editor on the main
|
|
|
34
37
|
}
|
|
35
38
|
```
|
|
36
39
|
|
|
40
|
+
These values are automatically set when running within the FlowFuse platform via the `nr-launcher` component.
|
|
41
|
+
|
|
42
|
+
The `url` and `token` are for an AI service hosted by FlowFuse; it is not publicly available for use outside of the FlowFuse platform.
|
|
43
|
+
|
|
37
44
|
|
|
38
45
|
## Limitations
|
|
39
46
|
|
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
|
-
}
|
|
58
|
-
const funcCommandId = 'nr-assistant-fn-inline'
|
|
59
|
-
const jsonCommandId = 'nr-assistant-json-inline'
|
|
65
|
+
}
|
|
60
66
|
|
|
61
|
-
|
|
67
|
+
const funcCommandId = 'nr-assistant-fn-inline'
|
|
68
|
+
const jsonCommandId = 'nr-assistant-json-inline'
|
|
62
69
|
|
|
63
|
-
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
|
|
94
|
+
el = el.parentNode
|
|
95
|
+
}
|
|
96
|
+
if (!isFuncTabEditor) {
|
|
97
|
+
return
|
|
117
98
|
}
|
|
118
|
-
})
|
|
119
99
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
-
|
|
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
|
-
|
|
158
|
-
|
|
159
|
-
const
|
|
160
|
-
if (!
|
|
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
|
-
|
|
165
|
-
|
|
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
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
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
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
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
|
-
}
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
}
|
|
288
|
+
}
|
|
289
|
+
})
|
|
290
|
+
} else {
|
|
291
|
+
console.warn('Could not find editor for model', model.uri.toString())
|
|
292
|
+
}
|
|
293
|
+
})
|
|
285
294
|
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
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
|
-
|
|
294
|
-
|
|
295
|
-
|
|
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
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
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
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
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
|
-
|
|
326
|
-
const
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
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
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
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
|
-
|
|
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:
|
|
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('
|
|
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
|
-
|
|
710
|
-
|
|
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,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@flowfuse/nr-assistant",
|
|
3
|
-
"version": "0.1.4-
|
|
3
|
+
"version": "0.1.4-ed98905-202506111910.0",
|
|
4
4
|
"description": "FlowFuse Node-RED assistant plugin",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"scripts": {
|
|
7
7
|
"test": "echo \"Error: no test specified\" && exit 0",
|
|
8
|
-
"lint": "eslint -c .eslintrc --ext js,html *.js *.html",
|
|
9
|
-
"lint:fix": "eslint -c .eslintrc --ext js,html *.js *.html --fix"
|
|
8
|
+
"lint": "eslint -c .eslintrc --ext js,html \"*.js\" \"*.html\"",
|
|
9
|
+
"lint:fix": "eslint -c .eslintrc --ext js,html \"*.js\" \"*.html\" --fix"
|
|
10
10
|
},
|
|
11
11
|
"keywords": [
|
|
12
12
|
"node-red",
|
|
@@ -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": {
|