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
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
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' )
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
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
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.
20702353 .ff-nr-ai-dialog-message li {
20712354 margin-bottom : 3px ;
20722355 }
2073- </ style >
2356+ </ style >
0 commit comments