@flowfuse/nr-assistant 0.7.1-c2c8c88-202601011948.0 → 0.8.1-317b279-202601221339.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 CHANGED
@@ -1,3 +1,14 @@
1
+ ### 0.8.0
2
+
3
+ - Bump JS-DevTools/npm-publish from 4.1.1 to 4.1.3 (#105)
4
+ - Bump flowfuse/github-actions-workflows/.github/workflows/publish_node_package.yml (#96)
5
+ - Bump flowfuse/github-actions-workflows/.github/workflows/build_node_package.yml (#95)
6
+ - Bump hono from 4.11.3 to 4.11.4 (#107) @app/dependabot
7
+ - Bump @modelcontextprotocol/sdk from 1.24.2 to 1.25.2 (#102) @app/dependabot
8
+ - Add support expert actions (#106) @Steve-Mcl
9
+ - Bump qs from 6.14.0 to 6.14.1 (#100) @app/dependabot
10
+ - Bump flowfuse/github-actions-workflows/.github/workflows/publish_node_package.yml from 0.44.0 to 0.45.0 (#98) @app/dependabot
11
+ - Bump flowfuse/github-actions-workflows/.github/workflows/build_node_package.yml from 0.44.0 to 0.45.0 (#97) @app/dependabot
1
12
 
2
13
  ### 0.7.0
3
14
 
package/index.html CHANGED
@@ -45,7 +45,8 @@
45
45
  enabled: false,
46
46
  tablesEnabled: false,
47
47
  inlineCompletionsEnabled: false,
48
- requestTimeout: AI_TIMEOUT
48
+ requestTimeout: AI_TIMEOUT,
49
+ assistantVersion: null
49
50
  }
50
51
  let initialisedInterlock = false
51
52
  let mcpReadyInterlock = false
@@ -62,6 +63,7 @@
62
63
  RED.comms.subscribe('nr-assistant/#', (topic, msg) => {
63
64
  debug('comms', topic, msg)
64
65
  if (topic === 'nr-assistant/initialise') {
66
+ assistantOptions.assistantVersion = msg?.assistantVersion
65
67
  assistantOptions.standalone = !!msg?.standalone
66
68
  assistantOptions.enabled = !!msg?.enabled
67
69
  assistantOptions.requestTimeout = msg?.requestTimeout || AI_TIMEOUT
@@ -198,6 +200,164 @@
198
200
  }
199
201
 
200
202
  if (!assistantInitialised) {
203
+ // Setup postMessage communication with parent window
204
+ if (!window.parent?.postMessage || window.self === window.top) {
205
+ console.warn('Parent window not detected - certain interactions with the FlowFuse Expert will not be available')
206
+ } else {
207
+ const MESSAGE_SOURCE = 'nr-assistant'
208
+ const MESSAGE_TARGET = 'flowfuse-expert'
209
+ const MESSAGE_SCOPE = 'flowfuse-expert'
210
+
211
+ // proxy certain events from RED Events to the parent window (for state tracking)
212
+ RED.events.on('editor:open', function () {
213
+ window.parent.postMessage({
214
+ type: 'editor:open',
215
+ source: MESSAGE_SOURCE,
216
+ scope: MESSAGE_SCOPE,
217
+ target: MESSAGE_TARGET
218
+ }, '*')
219
+ })
220
+ RED.events.on('editor:close', function () {
221
+ window.parent.postMessage({
222
+ type: 'editor:close',
223
+ source: MESSAGE_SOURCE,
224
+ scope: MESSAGE_SCOPE,
225
+ target: MESSAGE_TARGET
226
+ }, '*')
227
+ })
228
+
229
+ // Define supported actions and their parameter schemas
230
+ const supportedActions = {
231
+ 'core:manage-palette': {
232
+ params: {
233
+ type: 'object',
234
+ properties: {
235
+ view: {
236
+ type: 'string',
237
+ enum: ['nodes', 'install'],
238
+ default: 'install'
239
+ },
240
+ filter: {
241
+ description: 'Optional filter string. e.g. `"node-red-contrib-s7","node-red-contrib-other"` to pre-filter the palette view',
242
+ type: 'string'
243
+ }
244
+ },
245
+ required: ['filter']
246
+ }
247
+ },
248
+ 'custom:import-flow': {
249
+ params: {
250
+ type: 'object',
251
+ properties: {
252
+ flow: {
253
+ type: 'string',
254
+ description: 'The flow JSON to import'
255
+ },
256
+ addFlow: {
257
+ type: 'boolean',
258
+ description: 'Whether to add the flow to the current workspace tab (false) or create a new tab (true). Default: false'
259
+ }
260
+ },
261
+ required: ['flow']
262
+ }
263
+ }
264
+ }
265
+
266
+ // Listen for postMessages from parent window
267
+ window.addEventListener('message', function (event) {
268
+ // prevent own messages being processed
269
+ if (event.source === window.self) {
270
+ return
271
+ }
272
+
273
+ const { type, action, params, target, source, scope } = event.data || {}
274
+
275
+ // Ensure scope and source match expected values
276
+ if (target !== MESSAGE_SOURCE || source !== MESSAGE_TARGET || scope !== MESSAGE_SCOPE) {
277
+ return
278
+ }
279
+
280
+ debug('Received postMessage:', event.data)
281
+
282
+ const postReply = (message) => {
283
+ debug('Posting reply message:', message)
284
+ if (event.source && typeof event.source.postMessage === 'function') {
285
+ event.source.postMessage({
286
+ ...message,
287
+ source: MESSAGE_SOURCE,
288
+ scope: MESSAGE_SCOPE,
289
+ target: MESSAGE_TARGET
290
+ }, event.origin)
291
+ } else {
292
+ console.warn('Unable to post message reply, source not available', message)
293
+ }
294
+ }
295
+
296
+ // handle version request
297
+ if (type === 'get-assistant-version') {
298
+ // Reply with the current version
299
+ postReply({ type, version: assistantOptions.assistantVersion, success: true }, event.origin)
300
+ return
301
+ }
302
+ // handle supported actions request
303
+ if (type === 'get-supported-actions') {
304
+ // Reply with the supported actions and their schemas
305
+ postReply({ type, supportedActions, success: true }, event.origin)
306
+ return
307
+ }
308
+
309
+ // handle action invocation requests (must be registered actions in supportedActions)
310
+ if (type === 'invoke-action' && typeof action === 'string') {
311
+ if (!supportedActions[action]) {
312
+ console.warn(`Action "${action}" is not permitted to be invoked via postMessage`)
313
+ postReply({ type, action, error: 'unknown-action' })
314
+ return
315
+ }
316
+ // Validate params against permitted schema (native/naive parsing for now - may introduce a library later if more complex schemas are needed)
317
+ const actionSchema = supportedActions[action].params
318
+ if (actionSchema) {
319
+ const validation = validateSchema(params, actionSchema)
320
+ if (!validation || !validation.valid) {
321
+ console.warn(`Params for action "${action}" did not validate against the expected schema`, params, actionSchema, validation)
322
+ postReply({ type, action, error: validation.error || 'invalid-parameters' })
323
+ return
324
+ }
325
+ }
326
+
327
+ if (action === 'custom:import-flow') {
328
+ // import-flow is a custom action - handle it here directly
329
+ try {
330
+ importNodes(params.flow, params.addFlow === true)
331
+ postReply({ type, success: true })
332
+ } catch (err) {
333
+ postReply({ type, error: err?.message })
334
+ }
335
+ } else {
336
+ // Handle (supported) native Node-RED actions
337
+ try {
338
+ RED.actions.invoke(action, params)
339
+ postReply({ type, action, success: true })
340
+ } catch (err) {
341
+ postReply({ type, action, error: err?.message })
342
+ }
343
+ }
344
+ return
345
+ }
346
+
347
+ // unknown message type
348
+ postReply({ type: 'error', error: 'unknown-type', data: event.data })
349
+ }, false)
350
+
351
+ // Notify the parent window that the assistant is ready
352
+ window.parent.postMessage({
353
+ type: 'assistant-ready',
354
+ source: MESSAGE_SOURCE,
355
+ scope: MESSAGE_SCOPE,
356
+ target: MESSAGE_TARGET,
357
+ version: assistantOptions.assistantVersion
358
+ }, '*')
359
+ }
360
+
201
361
  registerMonacoExtensions()
202
362
  RED.actions.add('flowfuse-nr-assistant:function-builder', showFunctionBuilderPrompt, { label: '@flowfuse/nr-assistant/flowfuse-nr-assistant:function-builder.action.label' })
203
363
  setMenuShortcutKey('ff-assistant-function-builder', 'red-ui-workspace', 'ctrl-alt-f', 'flowfuse-nr-assistant:function-builder')
@@ -832,7 +992,7 @@
832
992
  nodeType: node.type,
833
993
  nodeModule: node._def?.set?.module || 'node-red'
834
994
  }
835
-
995
+
836
996
  context.outputs = +(node.outputs || 1)
837
997
  if (isNaN(context.outputs) || context.outputs < 1) {
838
998
  context.outputs = 1
@@ -1956,6 +2116,129 @@
1956
2116
  }
1957
2117
  RED.menu.refreshShortcuts()
1958
2118
  }
2119
+
2120
+ /**
2121
+ * Validates data against a simple schema
2122
+ * NOTE: This is a very basic implementation and only supports a subset of JSON Schema for now.
2123
+ * @param {any} data - The data to validate
2124
+ * @param {object} schema - The schema to validate against
2125
+ * @returns {{valid: boolean, error?: string}} - The validation result
2126
+ */
2127
+ function validateSchema (data, schema) {
2128
+ if (schema.type === 'object') {
2129
+ if (typeof data !== 'object') {
2130
+ return {
2131
+ valid: false,
2132
+ error: 'Data is not of type object'
2133
+ }
2134
+ }
2135
+ if (Array.isArray(data)) {
2136
+ return {
2137
+ valid: false,
2138
+ error: 'Data is an array but an object was expected'
2139
+ }
2140
+ }
2141
+ // check required properties
2142
+ if (Array.isArray(schema.required)) {
2143
+ for (const reqProp of schema.required) {
2144
+ if (!(reqProp in data)) {
2145
+ return {
2146
+ valid: false,
2147
+ error: `Data is missing required parameter "${reqProp}"`
2148
+ }
2149
+ }
2150
+ }
2151
+ }
2152
+ // check properties & apply defaults
2153
+ if (schema.properties) {
2154
+ for (const [propName, propSchema] of Object.entries(schema.properties)) {
2155
+ const propExists = propName in data
2156
+ // check type
2157
+ if (propSchema.type && propExists) {
2158
+ const expectedType = propSchema.type
2159
+ const actualType = Array.isArray(data[propName]) ? 'array' : typeof data[propName]
2160
+ if (actualType !== expectedType) {
2161
+ return {
2162
+ valid: false,
2163
+ error: `Data parameter "${propName}" is of type "${actualType}" but expected type is "${expectedType}"`
2164
+ }
2165
+ }
2166
+ }
2167
+ // check enum
2168
+ if (propSchema.enum && propExists) {
2169
+ if (!propSchema.enum.includes(data[propName])) {
2170
+ return {
2171
+ valid: false,
2172
+ error: `Data parameter "${propName}" has invalid value "${data[propName]}". Should be one of: ${propSchema.enum.join(', ')}`
2173
+ }
2174
+ }
2175
+ }
2176
+ // apply defaults
2177
+ if (propSchema.default !== undefined && !propExists) {
2178
+ data[propName] = propSchema.default
2179
+ }
2180
+ }
2181
+ }
2182
+ }
2183
+ return { valid: true }
2184
+ }
2185
+
2186
+ /// Function extracted from Node-RED source `editor-client/src/js/ui/clipboard.js`
2187
+ /**
2188
+ * Performs the import of nodes, handling any conflicts that may arise
2189
+ * @param {string} nodesStr the nodes to import as a string
2190
+ * @param {boolean} addFlow whether to add the nodes to a new flow or to the current flow
2191
+ */
2192
+ function importNodes (nodesStr, addFlow) {
2193
+ let newNodes = nodesStr
2194
+ if (typeof nodesStr === 'string') {
2195
+ try {
2196
+ nodesStr = nodesStr.trim()
2197
+ if (nodesStr.length === 0) {
2198
+ return
2199
+ }
2200
+ newNodes = validateFlowString(nodesStr)
2201
+ } catch (err) {
2202
+ const e = new Error(RED._('clipboard.invalidFlow', { message: 'test' }))
2203
+ e.code = 'NODE_RED'
2204
+ throw e
2205
+ }
2206
+ }
2207
+ const importOptions = { generateIds: true, addFlow }
2208
+ try {
2209
+ RED.view.importNodes(newNodes, importOptions)
2210
+ } catch (error) {
2211
+ // Thrown for import_conflict
2212
+ RED.notify('Import failed:' + error.message, 'error')
2213
+ throw error
2214
+ }
2215
+ }
2216
+
2217
+ /// Function extracted from Node-RED source `editor-client/src/js/ui/clipboard.js`
2218
+ /**
2219
+ * Validates if the provided string looks like valid flow json
2220
+ * @param {string} flowString the string to validate
2221
+ * @returns If valid, returns the node array
2222
+ */
2223
+ function validateFlowString (flowString) {
2224
+ const res = JSON.parse(flowString)
2225
+ if (!Array.isArray(res)) {
2226
+ throw new Error(RED._('clipboard.import.errors.notArray'))
2227
+ }
2228
+ for (let i = 0; i < res.length; i++) {
2229
+ if (typeof res[i] !== 'object') {
2230
+ throw new Error(RED._('clipboard.import.errors.itemNotObject', { index: i }))
2231
+ }
2232
+ if (!Object.hasOwn(res[i], 'id')) {
2233
+ throw new Error(RED._('clipboard.import.errors.missingId', { index: i }))
2234
+ }
2235
+ if (!Object.hasOwn(res[i], 'type')) {
2236
+ throw new Error(RED._('clipboard.import.errors.missingType', { index: i }))
2237
+ }
2238
+ }
2239
+ return res
2240
+ }
2241
+
1959
2242
  function debug (...args) {
1960
2243
  if (RED.nrAssistant?.DEBUG) {
1961
2244
  const scriptName = 'assistant-index.html.js' // must match the sourceURL set in the script below
@@ -2010,7 +2293,7 @@
2010
2293
  flex-grow: 1;
2011
2294
  }
2012
2295
 
2013
- /*
2296
+ /*
2014
2297
  As menu icons are drawn by the (RED.menu.init api) as <img> tags, styling the fill of the path is impossible.
2015
2298
  Instead of using a url (e.g. resource/icon.svg), we add them as a "class name" which node-red then renders as an `<i>` tag.
2016
2299
  The CSS below uses the mask-image property to apply the SVG as a mask, allowing us to set the color via background-color.
@@ -2070,4 +2353,4 @@
2070
2353
  .ff-nr-ai-dialog-message li {
2071
2354
  margin-bottom: 3px;
2072
2355
  }
2073
- </style>
2356
+ </style>
package/lib/assistant.js CHANGED
@@ -86,6 +86,7 @@ class Assistant {
86
86
  }
87
87
  }
88
88
  const clientSettings = {
89
+ assistantVersion: require('../package.json').version,
89
90
  enabled: this.options.enabled !== false && !!this.options.url,
90
91
  tablesEnabled: this.options.tables?.enabled === true,
91
92
  inlineCompletionsEnabled: this.options.completions?.inlineEnabled === true,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flowfuse/nr-assistant",
3
- "version": "0.7.1-c2c8c88-202601011948.0",
3
+ "version": "0.8.1-317b279-202601221339.0",
4
4
  "description": "FlowFuse Node-RED Expert plugin",
5
5
  "main": "index.js",
6
6
  "scripts": {