Skip to content

Commit 61fa3a0

Browse files
authored
Merge pull request #106 from FlowFuse/103-support-expert-actions
Add support expert actions
2 parents 903d447 + e235d3f commit 61fa3a0

File tree

3 files changed

+292
-4
lines changed

3 files changed

+292
-4
lines changed

index.html

Lines changed: 287 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,8 @@
4545
enabled: false,
4646
tablesEnabled: false,
4747
inlineCompletionsEnabled: false,
48-
requestTimeout: AI_TIMEOUT
48+
requestTimeout: AI_TIMEOUT,
49+
assistantVersion: null
4950
}
5051
let initialisedInterlock = false
5152
let mcpReadyInterlock = false
@@ -62,6 +63,7 @@
6263
RED.comms.subscribe('nr-assistant/#', (topic, msg) => {
6364
debug('comms', topic, msg)
6465
if (topic === 'nr-assistant/initialise') {
66+
assistantOptions.assistantVersion = msg?.assistantVersion
6567
assistantOptions.standalone = !!msg?.standalone
6668
assistantOptions.enabled = !!msg?.enabled
6769
assistantOptions.requestTimeout = msg?.requestTimeout || AI_TIMEOUT
@@ -198,6 +200,164 @@
198200
}
199201

200202
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+
201361
registerMonacoExtensions()
202362
RED.actions.add('flowfuse-nr-assistant:function-builder', showFunctionBuilderPrompt, { label: '@flowfuse/nr-assistant/flowfuse-nr-assistant:function-builder.action.label' })
203363
setMenuShortcutKey('ff-assistant-function-builder', 'red-ui-workspace', 'ctrl-alt-f', 'flowfuse-nr-assistant:function-builder')
@@ -832,7 +992,7 @@
832992
nodeType: node.type,
833993
nodeModule: node._def?.set?.module || 'node-red'
834994
}
835-
995+
836996
context.outputs = +(node.outputs || 1)
837997
if (isNaN(context.outputs) || context.outputs < 1) {
838998
context.outputs = 1
@@ -1956,6 +2116,129 @@
19562116
}
19572117
RED.menu.refreshShortcuts()
19582118
}
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+
19592242
function debug (...args) {
19602243
if (RED.nrAssistant?.DEBUG) {
19612244
const scriptName = 'assistant-index.html.js' // must match the sourceURL set in the script below
@@ -2010,7 +2293,7 @@
20102293
flex-grow: 1;
20112294
}
20122295

2013-
/*
2296+
/*
20142297
As menu icons are drawn by the (RED.menu.init api) as <img> tags, styling the fill of the path is impossible.
20152298
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.
20162299
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 @@
20702353
.ff-nr-ai-dialog-message li {
20712354
margin-bottom: 3px;
20722355
}
2073-
</style>
2356+
</style>

lib/assistant.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@ class Assistant {
8686
}
8787
}
8888
const clientSettings = {
89+
assistantVersion: require('../package.json').version,
8990
enabled: this.options.enabled !== false && !!this.options.url,
9091
tablesEnabled: this.options.tables?.enabled === true,
9192
inlineCompletionsEnabled: this.options.completions?.inlineEnabled === true,

test/unit/lib/assistant.test.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,7 @@ describe('assistant', () => {
9696
let assistant
9797
/** @type {import('got').Got} */
9898
let fakeGot
99+
const packageVersion = require('../../../package.json').version
99100

100101
beforeEach(() => {
101102
// first, delete the cached assistant module
@@ -209,6 +210,7 @@ describe('assistant', () => {
209210
RED.comms.publish.calledThrice.should.be.true()
210211
RED.comms.publish.firstCall.args[0].should.equal('nr-assistant/initialise')
211212
RED.comms.publish.firstCall.args[1].should.eql({
213+
assistantVersion: packageVersion, // required for the frontend to support ff-expert features
212214
enabled: true,
213215
tablesEnabled: false,
214216
requestTimeout: 60000,
@@ -217,6 +219,7 @@ describe('assistant', () => {
217219

218220
RED.comms.publish.secondCall.args[0].should.equal('nr-assistant/mcp/ready')
219221
RED.comms.publish.secondCall.args[1].should.eql({
222+
assistantVersion: packageVersion, // required for the frontend to support ff-expert features
220223
enabled: true,
221224
tablesEnabled: false,
222225
inlineCompletionsEnabled: false,
@@ -251,6 +254,7 @@ describe('assistant', () => {
251254
RED.comms.publish.called.should.be.true()
252255
RED.comms.publish.firstCall.args[0].should.equal('nr-assistant/initialise')
253256
RED.comms.publish.firstCall.args[1].should.eql({
257+
assistantVersion: packageVersion,
254258
enabled: true,
255259
tablesEnabled: false,
256260
inlineCompletionsEnabled: true,

0 commit comments

Comments
 (0)