@flowfuse/nr-assistant 0.3.1-2c05106-202506300804.0 → 0.3.1-6237044-202507301022.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.
@@ -0,0 +1,56 @@
1
+ class CompletionsLabeller {
2
+ constructor ({ inputFeatureLabels, classifierLabels, nodeLabels }) {
3
+ this.inputFeatureLabels = inputFeatureLabels
4
+ this.classifierLabels = classifierLabels
5
+ this.nodeLabels = nodeLabels
6
+ }
7
+
8
+ ohe (node) {
9
+ return this.inputFeatureLabels.map(cat => (cat === node ? 1 : 0))
10
+ }
11
+
12
+ countNodes (sequence) {
13
+ return this.nodeLabels.map(cat => sequence.filter(n => n === cat).length)
14
+ }
15
+
16
+ /**
17
+ * Encode a sequence of nodes into a feature vector.
18
+ * @param {string[]} userInput - Array of node type names.
19
+ * @returns {number[]} Encoded feature vector.
20
+ */
21
+ encode_sequence (userInput) {
22
+ const inputNode = userInput[0]
23
+ const recentNode = userInput[userInput.length - 1]
24
+ const sequenceLength = userInput.length
25
+
26
+ const inputOhe = this.ohe(inputNode)
27
+ const recentOhe = this.ohe(recentNode)
28
+ const counts = this.countNodes(userInput)
29
+
30
+ // Concatenate all features (order must match training)
31
+ // [sequence_length, ...input_ohe, ...recent_ohe, ...counts]
32
+ return [
33
+ sequenceLength,
34
+ ...inputOhe,
35
+ ...recentOhe,
36
+ ...counts
37
+ ]
38
+ }
39
+
40
+ /**
41
+ * Decode model predictions into human-readable labels.
42
+ * @param {Float32Array} predictions - Array of model predictions (probabilities).
43
+ * @param {number} [topN=5] - Number of top predictions to return.
44
+ * @returns {{ className: string, confidence: number, classIndex: number }[]}
45
+ */
46
+ decode_predictions (predictions, topN = 5) {
47
+ return [...predictions]
48
+ .map((confidence, classIndex) => {
49
+ return { confidence, classIndex, className: this.classifierLabels[classIndex] }
50
+ }).sort((a, b) => b.confidence - a.confidence)
51
+ .slice(0, topN) // Get top N predictions
52
+ }
53
+ }
54
+
55
+ module.exports.CompletionsLabeller = CompletionsLabeller
56
+ module.exports.default = CompletionsLabeller
@@ -0,0 +1,65 @@
1
+ function buildReverseGraph (flow) {
2
+ const reverseGraph = new Map()
3
+ for (const node of flow) {
4
+ const outputs = (node && node.wires && node.wires.flat()) || []
5
+ for (const output of outputs) {
6
+ if (!reverseGraph.has(output)) {
7
+ reverseGraph.set(output, [])
8
+ }
9
+ reverseGraph.get(output).push(node.id) // output is downstream, node.id is upstream
10
+ }
11
+ }
12
+ return reverseGraph
13
+ }
14
+
15
+ /**
16
+ * Find the longest upstream path in a flow graph.
17
+ *
18
+ * Interesting parts:
19
+ * - Uses DFS (depth-first search) to find the longest upstream path
20
+ * - Handles circular references by using a Set to track visited nodes
21
+ * @param {Array} graph - The reverse graph where keys are node IDs and values are arrays of upstream node IDs.
22
+ * @param {string} startId - The ID of the node from which to start the search.
23
+ * @returns {Array} - An array of node IDs representing the longest upstream path.
24
+ * @throws {Error} - Throws an error if a circular reference is detected.
25
+ */
26
+ function findLongestUpstreamPath (graph, startId) {
27
+ const visited = new Set()
28
+ function dfs (current, path) {
29
+ if (visited.has(current)) {
30
+ throw new Error(`Circular reference detected at node ${current}`)
31
+ }
32
+ visited.add(current)
33
+ let maxSubPath = []
34
+ for (const parent of graph.get(current) || []) {
35
+ const subPath = dfs(parent, path)
36
+ if (subPath.length > maxSubPath.length) {
37
+ maxSubPath = subPath
38
+ }
39
+ }
40
+ visited.delete(current)
41
+
42
+ return [...maxSubPath, current]
43
+ }
44
+ return dfs(startId, [])
45
+ }
46
+
47
+ /**
48
+ * Get the longest upstream path in a flow graph.
49
+ * @param {Array<{id: string, type: string, wires: Array<Array<string>>, [key: string]: any}>} flow - The flow graph.
50
+ * @param {string} finalNodeId - The ID of the final node.
51
+ * @returns {Array} - An array of nodes representing the longest upstream path.
52
+ */
53
+ function getLongestUpstreamPath (flow, finalNodeId) {
54
+ const reverseGraph = buildReverseGraph(flow)
55
+ const idToNodeLookup = Object.fromEntries(flow.map(node => [node.id, node]))
56
+ const pathIds = findLongestUpstreamPath(reverseGraph, finalNodeId)
57
+ const result = pathIds.map(id => idToNodeLookup[id])
58
+ return result.filter(node => node !== undefined && node.id !== finalNodeId)
59
+ }
60
+
61
+ module.exports = {
62
+ getLongestUpstreamPath,
63
+ buildReverseGraph,
64
+ findLongestUpstreamPath
65
+ }
@@ -0,0 +1,18 @@
1
+ module.exports = {
2
+ getSettings: (RED) => {
3
+ const assistantSettings = (RED.settings.flowforge && RED.settings.flowforge.assistant) || {}
4
+ if (assistantSettings.enabled !== true) {
5
+ assistantSettings.enabled = false
6
+ assistantSettings.completions = null // if the assistant is not enabled, completions should not be enabled
7
+ }
8
+ assistantSettings.mcp = assistantSettings.mcp || {
9
+ enabled: true // default to enabled
10
+ }
11
+ assistantSettings.completions = assistantSettings.completions || {
12
+ enabled: true, // default to enabled
13
+ modelUrl: null,
14
+ vocabularyUrl: null
15
+ }
16
+ return assistantSettings
17
+ }
18
+ }
package/lib/utils.js ADDED
@@ -0,0 +1,21 @@
1
+ const tryJsonParse = (str, defaultValue = undefined) => {
2
+ try {
3
+ return JSON.parse(str)
4
+ } catch (e) {
5
+ return defaultValue
6
+ }
7
+ }
8
+
9
+ const hasProperty = (obj, prop) => {
10
+ return isObject(obj) && Object.prototype.hasOwnProperty.call(obj, prop)
11
+ }
12
+
13
+ const isObject = (obj) => {
14
+ return obj !== null && typeof obj === 'object' && !Array.isArray(obj)
15
+ }
16
+
17
+ module.exports = {
18
+ tryJsonParse,
19
+ hasProperty,
20
+ isObject
21
+ }
@@ -24,7 +24,11 @@
24
24
  "description": "Explain the selected nodes"
25
25
  },
26
26
  "dialog-result": {
27
- "title": "FlowFuse Assistant: Explain Flows"
27
+ "title": "FlowFuse Assistant: Explain Flows",
28
+ "copy-button": "Copy",
29
+ "close-button": "Close",
30
+ "comment-node-button": "Comment Node",
31
+ "comment-node-name": "FlowFuse Assistant Explanation"
28
32
  },
29
33
  "errors": {
30
34
  "no-nodes-selected": "No nodes selected. Please select one or more nodes to explain.",
@@ -35,6 +39,8 @@
35
39
  "busy": "Busy processing your request. Please wait..."
36
40
  },
37
41
  "errors":{
38
- "assistant-not-enabled": "The FlowFuse Assistant is not enabled"
42
+ "assistant-not-enabled": "The FlowFuse Assistant is not enabled",
43
+ "copy-failed": "Failed to copy to clipboard",
44
+ "something-went-wrong": "Something went wrong. Please try again later."
39
45
  }
40
46
  }
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "@flowfuse/nr-assistant",
3
- "version": "0.3.1-2c05106-202506300804.0",
3
+ "version": "0.3.1-6237044-202507301022.0",
4
4
  "description": "FlowFuse Node-RED assistant plugin",
5
5
  "main": "index.js",
6
6
  "scripts": {
7
- "test": "echo \"Error: no test specified\" && exit 0",
7
+ "test": "mocha --exit \"test/**/*.test.js\"",
8
8
  "lint": "eslint -c .eslintrc --ext js,html \"*.js\" \"*.html\"",
9
9
  "lint:fix": "eslint -c .eslintrc --ext js,html \"*.js\" \"*.html\" --fix"
10
10
  },
@@ -29,20 +29,27 @@
29
29
  "node-red": {
30
30
  "version": ">=2.2.0",
31
31
  "plugins": {
32
- "flowfuse-nr-assistant": "index.js"
32
+ "flowfuse-nr-assistant": "index.js",
33
+ "ff-assistant-completions": "completions.html"
33
34
  }
34
35
  },
35
36
  "engines": {
36
37
  "node": ">=16.x"
37
38
  },
38
39
  "dependencies": {
39
- "@modelcontextprotocol/sdk": "^1.12.1",
40
- "got": "^11.8.6"
40
+ "@modelcontextprotocol/sdk": "^1.17.0",
41
+ "got": "^11.8.6",
42
+ "onnxruntime-web": "^1.22.0",
43
+ "semver": "^7.7.2",
44
+ "zod": "^3.25.76"
41
45
  },
42
46
  "devDependencies": {
43
47
  "eslint": "^8.48.0",
44
48
  "eslint-config-standard": "^17.1.0",
45
49
  "eslint-plugin-html": "7.1.0",
46
- "eslint-plugin-no-only-tests": "^3.1.0"
50
+ "eslint-plugin-no-only-tests": "^3.1.0",
51
+ "mocha": "^11.6.0",
52
+ "should": "^13.2.3",
53
+ "sinon": "^18.0.0"
47
54
  }
48
55
  }
@@ -0,0 +1,70 @@
1
+ /**
2
+ * FFA Assistant Utils
3
+ * Shared utility functions for the FlowFuse Assistant
4
+ * To import this in js backend code, use:
5
+ * const { cleanFlow } = require('flowfuse-nr-assistant/resources/sharedUtils.js')
6
+ * To import this in frontend code, use:
7
+ * <script src="/resources/@flowfuse/nr-assistant/sharedUtils.js"></script>
8
+ * To use this in the browser, you can access it via:
9
+ * FFAssistantUtils.cleanFlow(nodeArray)
10
+ */
11
+
12
+ 'use strict';
13
+
14
+ (function (root, factory) {
15
+ if (typeof module === 'object' && module.exports) {
16
+ // Node.js / CommonJS
17
+ module.exports = factory()
18
+ } else {
19
+ // Browser
20
+ root.FFAssistantUtils = root.FFAssistantUtils || {}
21
+ Object.assign(root.FFAssistantUtils, factory())
22
+ }
23
+ }(typeof self !== 'undefined' ? self : this, function () {
24
+ 'use strict'
25
+ /**
26
+ * Cleans a single or an array nodes by removing internal properties and circular references.
27
+ * @param {Array<Object> | Object} flow - The node or array of nodes to clean
28
+ * @returns {{nodes: Array, totalNodeCount: number}} - The cleaned nodes and the total node count
29
+ */
30
+ function cleanFlow (flow) {
31
+ if (!flow) return { flow: [], nodeCount: 0 }
32
+ const nodeArray = Array.isArray(flow) ? flow : [flow]
33
+ const nodes = [...nodeArray] // make a shallow copy of the array
34
+ let totalNodeCount = 0
35
+ const MAX_DEPTH = 10 // maximum depth to recurse into groups
36
+ const visited = new Set() // to avoid circular references
37
+ // the input is an array of node. each node is an object.
38
+ // if the .type is 'group' and it has a .nodes array, we need to clean those nodes as well (and any nested groups).
39
+ const recursiveClean = (node, depth = 0) => {
40
+ if (!node || typeof node !== 'object' || !node.id) {
41
+ return null // if the node is not an object or doesn't have an id, return null
42
+ }
43
+ if (visited.has(node.id) || depth > MAX_DEPTH) {
44
+ return null
45
+ }
46
+ totalNodeCount += 1
47
+ visited.add(node.id)
48
+ const cleaned = { ...node }
49
+ delete cleaned._ // remove the internal _ property
50
+ delete cleaned._def // remove the definition
51
+ delete cleaned._config // remove the config
52
+ delete cleaned.validationErrors // remove validation errors
53
+ if (node.type === 'group') {
54
+ delete cleaned._childGroups // this can cause circular references
55
+ delete cleaned._parentGroup // this can cause circular references
56
+ cleaned.nodes = []
57
+ if (Array.isArray(node.nodes) && node.nodes.length > 0) {
58
+ cleaned.nodes = node.nodes.map(childNode => recursiveClean(childNode, depth + 1))
59
+ .filter(childNode => childNode !== null) // filter out any null nodes
60
+ }
61
+ }
62
+ return cleaned
63
+ }
64
+ return {
65
+ flow: nodes.map(node => recursiveClean(node)).filter(node => node !== null), // filter out any null nodes
66
+ nodeCount: totalNodeCount
67
+ }
68
+ }
69
+ return { cleanFlow }
70
+ }))