@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 +58 -0
- 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 +13 -6
- package/resources/sharedUtils.js +70 -0
package/index.js
CHANGED
|
@@ -1,12 +1,5 @@
|
|
|
1
1
|
module.exports = (RED) => {
|
|
2
|
-
const
|
|
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
|
|
2
|
+
const assistant = require('./lib/assistant.js')
|
|
10
3
|
|
|
11
4
|
RED.plugins.registerPlugin('flowfuse-nr-assistant', {
|
|
12
5
|
type: 'assistant',
|
|
@@ -16,243 +9,15 @@ module.exports = (RED) => {
|
|
|
16
9
|
'*': { exportable: true }
|
|
17
10
|
},
|
|
18
11
|
onadd: function () {
|
|
19
|
-
const assistantSettings =
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
RED.comms.publish('nr-assistant/initialise', clientSettings, true /* retain */)
|
|
25
|
-
|
|
26
|
-
if (!assistantSettings || !assistantSettings.enabled) {
|
|
27
|
-
RED.log.info('FlowFuse Assistant Plugin is disabled')
|
|
28
|
-
return
|
|
29
|
-
}
|
|
30
|
-
if (!assistantSettings.url) {
|
|
31
|
-
RED.log.info('FlowFuse Assistant Plugin is missing url')
|
|
32
|
-
return
|
|
33
|
-
}
|
|
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 */)
|
|
12
|
+
const assistantSettings = require('./lib/settings.js').getSettings(RED)
|
|
13
|
+
if (!assistant.isInitialized && !assistant.isLoading) {
|
|
14
|
+
assistant.init(RED, assistantSettings).then(() => {
|
|
15
|
+
// All good, the assistant is initialized.
|
|
16
|
+
// Any info messages made during initialization are logged in the assistant module
|
|
42
17
|
}).catch((error) => {
|
|
43
|
-
|
|
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
|
-
}
|
|
18
|
+
RED.log.error('Failed to initialize FlowFuse Assistant Plugin:', error)
|
|
53
19
|
})
|
|
54
20
|
}
|
|
55
|
-
|
|
56
|
-
RED.log.info('FlowFuse Assistant Plugin loaded')
|
|
57
|
-
|
|
58
|
-
RED.httpAdmin.post('/nr-assistant/:method', RED.auth.needsPermission('write'), function (req, res) {
|
|
59
|
-
const method = req.params.method
|
|
60
|
-
// limit method to prevent path traversal
|
|
61
|
-
if (!method || typeof method !== 'string' || /[^a-z0-9-_]/.test(method)) {
|
|
62
|
-
res.status(400)
|
|
63
|
-
res.json({ status: 'error', message: 'Invalid method' })
|
|
64
|
-
return
|
|
65
|
-
}
|
|
66
|
-
const input = req.body
|
|
67
|
-
if (!input || !input.prompt || typeof input.prompt !== 'string') {
|
|
68
|
-
res.status(400)
|
|
69
|
-
res.json({ status: 'error', message: 'prompt is required' })
|
|
70
|
-
return
|
|
71
|
-
}
|
|
72
|
-
const body = {
|
|
73
|
-
prompt: input.prompt, // this is the prompt to the AI
|
|
74
|
-
promptHint: input.promptHint, // this is used to let the AI know what we are generating (`function node? Node JavaScript? flow?)
|
|
75
|
-
context: input.context, // this is used to provide additional context to the AI (e.g. the selected text of the function node)
|
|
76
|
-
transactionId: input.transactionId // used to correlate the request with the response
|
|
77
|
-
}
|
|
78
|
-
// join url & method (taking care of trailing slashes)
|
|
79
|
-
const url = `${assistantSettings.url.replace(/\/$/, '')}/${method.replace(/^\//, '')}`
|
|
80
|
-
got.post(url, {
|
|
81
|
-
headers: {
|
|
82
|
-
Accept: '*/*',
|
|
83
|
-
'Accept-Language': 'en-GB,en-US;q=0.9,en;q=0.8,es;q=0.7',
|
|
84
|
-
Authorization: `Bearer ${assistantSettings.token}`,
|
|
85
|
-
'Content-Type': 'application/json'
|
|
86
|
-
},
|
|
87
|
-
json: body
|
|
88
|
-
}).then(response => {
|
|
89
|
-
const data = JSON.parse(response.body)
|
|
90
|
-
res.json({
|
|
91
|
-
status: 'ok',
|
|
92
|
-
data
|
|
93
|
-
})
|
|
94
|
-
}).catch((error) => {
|
|
95
|
-
let body = error.response?.body
|
|
96
|
-
if (typeof body === 'string') {
|
|
97
|
-
try {
|
|
98
|
-
body = JSON.parse(body)
|
|
99
|
-
} catch (e) {
|
|
100
|
-
// ignore
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
let message = 'FlowFuse Assistant request was unsuccessful'
|
|
104
|
-
const errorData = { status: 'error', message, body }
|
|
105
|
-
const errorCode = error.response?.statusCode || 500
|
|
106
|
-
res.status(errorCode).json(errorData)
|
|
107
|
-
RED.log.trace('nr-assistant error:', error)
|
|
108
|
-
if (body && typeof body === 'object' && body.error) {
|
|
109
|
-
message = `${message}: ${body.error}`
|
|
110
|
-
}
|
|
111
|
-
RED.log.warn(message)
|
|
112
|
-
})
|
|
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
|
-
})
|
|
191
21
|
}
|
|
192
22
|
})
|
|
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
|
-
}
|
|
258
23
|
}
|