@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/index.js CHANGED
@@ -1,12 +1,5 @@
1
1
  module.exports = (RED) => {
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
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 = RED.settings.flowforge?.assistant || { enabled: false }
20
- const clientSettings = {
21
- enabled: assistantSettings.enabled !== false && !!assistantSettings.url,
22
- requestTimeout: assistantSettings.requestTimeout || 60000
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
- 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
- }
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
  }