node-red-contrib-knx-ultimate 4.2.10 → 4.2.12
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 +15 -0
- package/examples/OSC to KNX via Function External Module.json +144 -0
- package/nodes/knxUltimateAI.html +24 -3
- package/nodes/knxUltimateAI.js +223 -51
- package/nodes/knxUltimateViewer.html +22 -1
- package/nodes/knxUltimateViewer.js +27 -8
- package/nodes/plugins/knxUltimateAI-vue/assets/app.css +1 -1
- package/nodes/plugins/knxUltimateAI-vue/assets/app.js +3 -3
- package/nodes/plugins/knxUltimateAI-vue/assets/chunk-jspdf.es.min.js +3 -3
- package/nodes/plugins/knxUltimateAI-vue/index.html +2 -2
- package/nodes/plugins/knxUltimateViewer-vue/assets/app.js +1 -1
- package/nodes/plugins/knxUltimateViewer-vue/index.html +2 -2
- package/nodes/utils/httpAdminAccessToken.js +34 -0
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,21 @@
|
|
|
6
6
|
|
|
7
7
|
# CHANGELOG
|
|
8
8
|
|
|
9
|
+
**Version 4.2.12** - April 2026<br/>
|
|
10
|
+
|
|
11
|
+
- FIX: **KNX AI Web** and **KNX Viewer Web** admin routes now normalize `access_token` auth safely by mapping query token to `Authorization: Bearer ...` only when missing, then removing `access_token` from `req.query` before permission checks.<br/>
|
|
12
|
+
- FIX: resolved intermittent `400 Bad Request` on authenticated web pages caused by duplicate bearer token sources (header + query string).<br/>
|
|
13
|
+
- CHANGE: extracted shared auth normalization helper to `nodes/utils/httpAdminAccessToken.js` to keep AI/Viewer behavior aligned and easier to maintain.<br/>
|
|
14
|
+
|
|
15
|
+
**Version 4.2.11** - April 2026<br/>
|
|
16
|
+
|
|
17
|
+
- UI: **KNX AI Web** improved assistant workflow with chat-style layout (prompt input under messages), clearer prompt focus, and streamlined Ask page text.<br/>
|
|
18
|
+
- UI: **KNX AI Web** added clearer loading feedback for AI operations (area regeneration/deletion and planner generation), including a centered blocking wait overlay during long-running area generation.<br/>
|
|
19
|
+
- NEW: **KNX AI Web** added bulk action **Delete AI Areas** and localized labels for **Regenerate AI Areas** / **Delete AI Areas** across supported UI languages.<br/>
|
|
20
|
+
- FIX: **KNX AI backend** hardened JSON extraction/parsing for LLM outputs and improved timeout/token-limit diagnostics in error messages.<br/>
|
|
21
|
+
- CHANGE: **KNX AI backend** raised default/forced `llmMaxTokens` handling for structured responses and aligned editor defaults for high-token completions.<br/>
|
|
22
|
+
- Docs/wiki: improved **KNX AI Dashboard** pages for end users, added localized guidance updates, and made support CTA links visible in docs navigation.<br/>
|
|
23
|
+
|
|
9
24
|
**Version 4.2.10** - April 2026<br/>
|
|
10
25
|
|
|
11
26
|
- UI: **KNX AI Web** sidebar menu style aligned to Homebridge (font size/weight, spacing, icon/text alignment and sidebar widths) for closer visual consistency.<br/>
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
[
|
|
2
|
+
{
|
|
3
|
+
"id": "tab_osc_knx_fn",
|
|
4
|
+
"type": "tab",
|
|
5
|
+
"label": "OSC -> KNX (Function)",
|
|
6
|
+
"disabled": false,
|
|
7
|
+
"info": ""
|
|
8
|
+
},
|
|
9
|
+
{
|
|
10
|
+
"id": "cmt_osc_knx_fn_intro",
|
|
11
|
+
"type": "comment",
|
|
12
|
+
"z": "tab_osc_knx_fn",
|
|
13
|
+
"name": "Receive OSC in a Function node and map to two KNX DPTs",
|
|
14
|
+
"info": "Prerequisites:\n1) In Node-RED settings.js set functionExternalModules: true and restart Node-RED.\n2) Deploy this flow: Node-RED will install node-osc for this Function node.\n\nOSC mappings handled by the Function node:\n- /knx/switch <value> -> KNX Device DPT 1.001 (true/false)\n- /knx/value <value> -> KNX Device DPT 5.001 (0..255 integer)\n\nExample OSC values:\n- /knx/switch 1\n- /knx/switch false\n- /knx/value 120",
|
|
15
|
+
"x": 410,
|
|
16
|
+
"y": 40,
|
|
17
|
+
"wires": []
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
"id": "fn_osc_receiver",
|
|
21
|
+
"type": "function",
|
|
22
|
+
"z": "tab_osc_knx_fn",
|
|
23
|
+
"name": "OSC Receiver (node-osc)",
|
|
24
|
+
"func": "// Input messages are ignored: this node emits only from the OSC callback defined in Setup.\nreturn null;",
|
|
25
|
+
"outputs": 2,
|
|
26
|
+
"timeout": 0,
|
|
27
|
+
"noerr": 0,
|
|
28
|
+
"initialize": "const listenPort = 8001;\nconst listenHost = \"0.0.0.0\";\n\nconst closeServer = (server) => {\n if (!server || typeof server.close !== \"function\") {\n return Promise.resolve();\n }\n\n try {\n if (typeof server.removeAllListeners === \"function\") {\n server.removeAllListeners(\"message\");\n server.removeAllListeners(\"bundle\");\n server.removeAllListeners(\"error\");\n server.removeAllListeners(\"listening\");\n }\n } catch (err) {\n node.warn(\"Error removing OSC listeners: \" + err.message);\n }\n\n return new Promise((resolve) => {\n let doneCalled = false;\n const done = () => {\n if (doneCalled) return;\n doneCalled = true;\n resolve();\n };\n\n try {\n const maybePromise = server.close((err) => {\n if (err) node.warn(\"OSC close callback error: \" + err.message);\n done();\n });\n\n if (maybePromise && typeof maybePromise.then === \"function\") {\n maybePromise.then(done).catch((err) => {\n node.warn(\"OSC close promise error: \" + err.message);\n done();\n });\n } else {\n setTimeout(done, 150);\n }\n } catch (err) {\n node.warn(\"OSC close throw: \" + err.message);\n done();\n }\n });\n};\n\nif (!nodeOsc || typeof nodeOsc.Server !== \"function\") {\n node.status({ fill: \"red\", shape: \"ring\", text: \"node-osc missing\" });\n node.error(\"node-osc not available. Enable functionExternalModules and deploy again.\");\n return;\n}\n\nconst normalizeBoolean = (value) => {\n if (typeof value === \"boolean\") return value;\n if (typeof value === \"number\") return value !== 0;\n const text = String(value).trim().toLowerCase();\n return text === \"true\" || text === \"1\" || text === \"on\";\n};\n\nconst previousServer = context.get(\"oscServer\");\ncontext.set(\"oscServer\", null);\nnode.status({ fill: \"yellow\", shape: \"ring\", text: \"restarting OSC...\" });\n\nreturn closeServer(previousServer).then(() => {\n const oscServer = new nodeOsc.Server(listenPort, listenHost, function () {\n node.status({ fill: \"green\", shape: \"dot\", text: \"OSC \" + listenHost + \":\" + listenPort });\n });\n\n context.set(\"oscServer\", oscServer);\n\n oscServer.on(\"error\", function (err) {\n node.warn(\"OSC server error: \" + (err && err.message ? err.message : err));\n });\n\n oscServer.on(\"message\", function (oscMsg) {\n if (!Array.isArray(oscMsg) || oscMsg.length < 2) return;\n\n const address = String(oscMsg[0] || \"\");\n const value = oscMsg[1];\n\n if (address === \"/knx/switch\") {\n node.send([{\n payload: normalizeBoolean(value),\n oscAddress: address,\n rawOsc: oscMsg\n }, null]);\n return;\n }\n\n if (address === \"/knx/value\") {\n const parsed = Number(value);\n if (!Number.isFinite(parsed)) {\n node.warn(\"OSC /knx/value not numeric: \" + value);\n return;\n }\n\n node.send([null, {\n payload: Math.max(0, Math.min(255, Math.round(parsed))),\n oscAddress: address,\n rawOsc: oscMsg\n }]);\n }\n });\n}).catch((err) => {\n node.status({ fill: \"red\", shape: \"ring\", text: \"OSC start error\" });\n node.error(\"Unable to start OSC server: \" + (err && err.message ? err.message : err));\n});",
|
|
29
|
+
"finalize": "const closeServer = (server) => {\n if (!server || typeof server.close !== \"function\") {\n return Promise.resolve();\n }\n\n try {\n if (typeof server.removeAllListeners === \"function\") {\n server.removeAllListeners(\"message\");\n server.removeAllListeners(\"bundle\");\n server.removeAllListeners(\"error\");\n server.removeAllListeners(\"listening\");\n }\n } catch (err) {\n node.warn(\"Error removing OSC listeners in finalize: \" + err.message);\n }\n\n return new Promise((resolve) => {\n let doneCalled = false;\n const done = () => {\n if (doneCalled) return;\n doneCalled = true;\n resolve();\n };\n\n try {\n const maybePromise = server.close((err) => {\n if (err) node.warn(\"OSC finalize close callback error: \" + err.message);\n done();\n });\n\n if (maybePromise && typeof maybePromise.then === \"function\") {\n maybePromise.then(done).catch((err) => {\n node.warn(\"OSC finalize close promise error: \" + err.message);\n done();\n });\n } else {\n setTimeout(done, 150);\n }\n } catch (err) {\n node.warn(\"OSC finalize close throw: \" + err.message);\n done();\n }\n });\n};\n\nconst oscServer = context.get(\"oscServer\");\ncontext.set(\"oscServer\", null);\nnode.status({ fill: \"yellow\", shape: \"ring\", text: \"stopping OSC...\" });\n\nreturn closeServer(oscServer).then(() => {\n node.status({});\n});",
|
|
30
|
+
"libs": [
|
|
31
|
+
{
|
|
32
|
+
"var": "nodeOsc",
|
|
33
|
+
"module": "node-osc"
|
|
34
|
+
}
|
|
35
|
+
],
|
|
36
|
+
"x": 230,
|
|
37
|
+
"y": 180,
|
|
38
|
+
"wires": [
|
|
39
|
+
[
|
|
40
|
+
"knx_bool_1"
|
|
41
|
+
],
|
|
42
|
+
[
|
|
43
|
+
"knx_int_1"
|
|
44
|
+
]
|
|
45
|
+
]
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
"id": "knx_bool_1",
|
|
49
|
+
"type": "knxUltimate",
|
|
50
|
+
"z": "tab_osc_knx_fn",
|
|
51
|
+
"server": "osc_knx_cfg_1",
|
|
52
|
+
"topic": "1/1/1",
|
|
53
|
+
"outputtopic": "",
|
|
54
|
+
"dpt": "1.001",
|
|
55
|
+
"initialread": false,
|
|
56
|
+
"notifyreadrequest": false,
|
|
57
|
+
"notifyresponse": true,
|
|
58
|
+
"notifywrite": true,
|
|
59
|
+
"notifyreadrequestalsorespondtobus": false,
|
|
60
|
+
"notifyreadrequestalsorespondtobusdefaultvalueifnotinitialized": "",
|
|
61
|
+
"listenallga": false,
|
|
62
|
+
"name": "KNX Switch (DPT 1.001)",
|
|
63
|
+
"outputtype": "write",
|
|
64
|
+
"outputRBE": false,
|
|
65
|
+
"inputRBE": false,
|
|
66
|
+
"x": 520,
|
|
67
|
+
"y": 150,
|
|
68
|
+
"wires": [
|
|
69
|
+
[
|
|
70
|
+
"dbg_bool_out"
|
|
71
|
+
]
|
|
72
|
+
]
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
"id": "knx_int_1",
|
|
76
|
+
"type": "knxUltimate",
|
|
77
|
+
"z": "tab_osc_knx_fn",
|
|
78
|
+
"server": "osc_knx_cfg_1",
|
|
79
|
+
"topic": "1/1/2",
|
|
80
|
+
"outputtopic": "",
|
|
81
|
+
"dpt": "5.001",
|
|
82
|
+
"initialread": false,
|
|
83
|
+
"notifyreadrequest": false,
|
|
84
|
+
"notifyresponse": true,
|
|
85
|
+
"notifywrite": true,
|
|
86
|
+
"notifyreadrequestalsorespondtobus": false,
|
|
87
|
+
"notifyreadrequestalsorespondtobusdefaultvalueifnotinitialized": "",
|
|
88
|
+
"listenallga": false,
|
|
89
|
+
"name": "KNX Value (DPT 5.001)",
|
|
90
|
+
"outputtype": "write",
|
|
91
|
+
"outputRBE": false,
|
|
92
|
+
"inputRBE": false,
|
|
93
|
+
"x": 510,
|
|
94
|
+
"y": 220,
|
|
95
|
+
"wires": [
|
|
96
|
+
[
|
|
97
|
+
"dbg_int_out"
|
|
98
|
+
]
|
|
99
|
+
]
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
"id": "dbg_bool_out",
|
|
103
|
+
"type": "debug",
|
|
104
|
+
"z": "tab_osc_knx_fn",
|
|
105
|
+
"name": "Bool KNX out",
|
|
106
|
+
"active": true,
|
|
107
|
+
"tosidebar": true,
|
|
108
|
+
"console": false,
|
|
109
|
+
"tostatus": false,
|
|
110
|
+
"complete": "true",
|
|
111
|
+
"targetType": "full",
|
|
112
|
+
"x": 720,
|
|
113
|
+
"y": 150,
|
|
114
|
+
"wires": []
|
|
115
|
+
},
|
|
116
|
+
{
|
|
117
|
+
"id": "dbg_int_out",
|
|
118
|
+
"type": "debug",
|
|
119
|
+
"z": "tab_osc_knx_fn",
|
|
120
|
+
"name": "Int KNX out",
|
|
121
|
+
"active": true,
|
|
122
|
+
"tosidebar": true,
|
|
123
|
+
"console": false,
|
|
124
|
+
"tostatus": false,
|
|
125
|
+
"complete": "true",
|
|
126
|
+
"targetType": "full",
|
|
127
|
+
"x": 710,
|
|
128
|
+
"y": 220,
|
|
129
|
+
"wires": []
|
|
130
|
+
},
|
|
131
|
+
{
|
|
132
|
+
"id": "osc_knx_cfg_1",
|
|
133
|
+
"type": "knxUltimate-config",
|
|
134
|
+
"z": "",
|
|
135
|
+
"host": "224.0.23.12",
|
|
136
|
+
"port": "3671",
|
|
137
|
+
"physAddr": "15.15.203",
|
|
138
|
+
"suppressACKRequest": false,
|
|
139
|
+
"csv": "",
|
|
140
|
+
"KNXEthInterface": "Auto",
|
|
141
|
+
"KNXEthInterfaceManuallyInput": "",
|
|
142
|
+
"autoReconnect": "yes"
|
|
143
|
+
}
|
|
144
|
+
]
|
package/nodes/knxUltimateAI.html
CHANGED
|
@@ -36,8 +36,8 @@
|
|
|
36
36
|
llmModel: { value: "gpt-5.4" },
|
|
37
37
|
llmSystemPrompt: { value: "" },
|
|
38
38
|
llmTemperature: { value: 0.2, required: false, validate: RED.validators.number() },
|
|
39
|
-
llmMaxTokens: { value:
|
|
40
|
-
llmTimeoutMs: { value:
|
|
39
|
+
llmMaxTokens: { value: 50000, required: false, validate: RED.validators.number() },
|
|
40
|
+
llmTimeoutMs: { value: 120000, required: false, validate: RED.validators.number() },
|
|
41
41
|
llmMaxEventsInPrompt: { value: 120, required: false, validate: RED.validators.number() },
|
|
42
42
|
llmIncludeRaw: { value: false },
|
|
43
43
|
llmIncludeFlowContext: { value: true },
|
|
@@ -99,10 +99,31 @@
|
|
|
99
99
|
toggleProvider();
|
|
100
100
|
|
|
101
101
|
const nodeId = this.id;
|
|
102
|
+
const resolveAdminRoot = () => {
|
|
103
|
+
const raw = (RED.settings && typeof RED.settings.httpAdminRoot === "string") ? RED.settings.httpAdminRoot : "/";
|
|
104
|
+
const trimmed = String(raw || "/").trim();
|
|
105
|
+
if (trimmed === "" || trimmed === "/") return "";
|
|
106
|
+
return "/" + trimmed.replace(/^\/+|\/+$/g, "");
|
|
107
|
+
};
|
|
108
|
+
const resolveAccessToken = () => {
|
|
109
|
+
try {
|
|
110
|
+
const tokens = (RED.settings && typeof RED.settings.get === "function") ? RED.settings.get("auth-tokens") : null;
|
|
111
|
+
const token = tokens && typeof tokens.access_token === "string" ? tokens.access_token.trim() : "";
|
|
112
|
+
return token;
|
|
113
|
+
} catch (error) {
|
|
114
|
+
return "";
|
|
115
|
+
}
|
|
116
|
+
};
|
|
102
117
|
|
|
103
118
|
$("#knx-ai-open-web-page-vue").on("click", function (evt) {
|
|
104
119
|
evt.preventDefault();
|
|
105
|
-
const
|
|
120
|
+
const adminRoot = resolveAdminRoot();
|
|
121
|
+
const targetBase = adminRoot + "/knxUltimateAI/sidebar/page";
|
|
122
|
+
const params = new URLSearchParams();
|
|
123
|
+
if (nodeId) params.set("nodeId", nodeId);
|
|
124
|
+
const accessToken = resolveAccessToken();
|
|
125
|
+
if (accessToken) params.set("access_token", accessToken);
|
|
126
|
+
const target = targetBase + (params.toString() ? ("?" + params.toString()) : "");
|
|
106
127
|
const wnd = window.open(target, "_blank", "noopener,noreferrer");
|
|
107
128
|
try { if (wnd && typeof wnd.focus === "function") wnd.focus(); } catch (e) { }
|
|
108
129
|
});
|
package/nodes/knxUltimateAI.js
CHANGED
|
@@ -3,6 +3,7 @@ const loggerClass = require('./utils/sysLogger')
|
|
|
3
3
|
const dptlib = require('knxultimate').dptlib
|
|
4
4
|
const fs = require('fs')
|
|
5
5
|
const path = require('path')
|
|
6
|
+
const { getRequestAccessToken, normalizeAuthFromAccessTokenQuery } = require('./utils/httpAdminAccessToken')
|
|
6
7
|
let googleTranslateTTS = null
|
|
7
8
|
try {
|
|
8
9
|
googleTranslateTTS = require('google-translate-tts')
|
|
@@ -16,17 +17,23 @@ let adminEndpointsRegistered = false
|
|
|
16
17
|
const aiRuntimeNodes = new Map()
|
|
17
18
|
const knxAiVueDistDir = path.join(__dirname, 'plugins', 'knxUltimateAI-vue')
|
|
18
19
|
|
|
19
|
-
const sendKnxAiVueIndex = (res) => {
|
|
20
|
+
const sendKnxAiVueIndex = (req, res) => {
|
|
20
21
|
const entryPath = path.join(knxAiVueDistDir, 'index.html')
|
|
21
|
-
fs.
|
|
22
|
-
if (error ||
|
|
22
|
+
fs.readFile(entryPath, 'utf8', (error, html) => {
|
|
23
|
+
if (error || typeof html !== 'string') {
|
|
23
24
|
res.status(503).type('text/plain').send('KNX AI Vue build not found. Run "npm run knx-ai:build" in the module root.')
|
|
24
25
|
return
|
|
25
26
|
}
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
res.
|
|
29
|
-
|
|
27
|
+
const rawToken = getRequestAccessToken(req)
|
|
28
|
+
if (!rawToken) {
|
|
29
|
+
res.type('text/html').send(html)
|
|
30
|
+
return
|
|
31
|
+
}
|
|
32
|
+
const encodedToken = encodeURIComponent(rawToken)
|
|
33
|
+
const htmlWithToken = html
|
|
34
|
+
.replace('./assets/app.js', `./assets/app.js?access_token=${encodedToken}`)
|
|
35
|
+
.replace('./assets/app.css', `./assets/app.css?access_token=${encodedToken}`)
|
|
36
|
+
res.type('text/html').send(htmlWithToken)
|
|
30
37
|
})
|
|
31
38
|
}
|
|
32
39
|
|
|
@@ -254,31 +261,124 @@ const buildLlmSummarySnapshot = (summary) => {
|
|
|
254
261
|
const extractJsonFragmentFromText = (value) => {
|
|
255
262
|
const text = String(value || '').trim()
|
|
256
263
|
if (!text) throw new Error('Empty AI response')
|
|
257
|
-
const
|
|
258
|
-
|
|
264
|
+
const normalizeCandidate = (input) => String(input || '')
|
|
265
|
+
.replace(/^\uFEFF/, '')
|
|
266
|
+
.replace(/^\s*json\s*\n/i, '')
|
|
267
|
+
.trim()
|
|
268
|
+
|
|
259
269
|
const tryParse = (input) => {
|
|
260
|
-
|
|
270
|
+
const source = normalizeCandidate(input)
|
|
271
|
+
if (!source) return null
|
|
261
272
|
try {
|
|
262
|
-
return JSON.parse(
|
|
273
|
+
return JSON.parse(source)
|
|
274
|
+
} catch (error) {}
|
|
275
|
+
// Fallback: tolerate comments and trailing commas that some models emit.
|
|
276
|
+
const relaxed = source
|
|
277
|
+
.replace(/\/\*[\s\S]*?\*\//g, '')
|
|
278
|
+
.replace(/^\s*\/\/.*$/gm, '')
|
|
279
|
+
.replace(/,\s*([}\]])/g, '$1')
|
|
280
|
+
.trim()
|
|
281
|
+
if (!relaxed || relaxed === source) return null
|
|
282
|
+
try {
|
|
283
|
+
return JSON.parse(relaxed)
|
|
263
284
|
} catch (error) {
|
|
264
285
|
return null
|
|
265
286
|
}
|
|
266
287
|
}
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
288
|
+
|
|
289
|
+
const extractBalancedJsonSlices = (input, maxSlices = 24) => {
|
|
290
|
+
const source = String(input || '')
|
|
291
|
+
const out = []
|
|
292
|
+
for (let i = 0; i < source.length; i += 1) {
|
|
293
|
+
const ch = source[i]
|
|
294
|
+
if (ch !== '{' && ch !== '[') continue
|
|
295
|
+
const stack = [ch === '{' ? '}' : ']']
|
|
296
|
+
let inString = false
|
|
297
|
+
let escaped = false
|
|
298
|
+
for (let j = i + 1; j < source.length; j += 1) {
|
|
299
|
+
const current = source[j]
|
|
300
|
+
if (inString) {
|
|
301
|
+
if (escaped) {
|
|
302
|
+
escaped = false
|
|
303
|
+
continue
|
|
304
|
+
}
|
|
305
|
+
if (current === '\\') {
|
|
306
|
+
escaped = true
|
|
307
|
+
continue
|
|
308
|
+
}
|
|
309
|
+
if (current === '"') inString = false
|
|
310
|
+
continue
|
|
311
|
+
}
|
|
312
|
+
if (current === '"') {
|
|
313
|
+
inString = true
|
|
314
|
+
continue
|
|
315
|
+
}
|
|
316
|
+
if (current === '{') {
|
|
317
|
+
stack.push('}')
|
|
318
|
+
continue
|
|
319
|
+
}
|
|
320
|
+
if (current === '[') {
|
|
321
|
+
stack.push(']')
|
|
322
|
+
continue
|
|
323
|
+
}
|
|
324
|
+
if ((current === '}' || current === ']') && stack.length) {
|
|
325
|
+
if (current !== stack[stack.length - 1]) break
|
|
326
|
+
stack.pop()
|
|
327
|
+
if (!stack.length) {
|
|
328
|
+
const slice = normalizeCandidate(source.slice(i, j + 1))
|
|
329
|
+
if (slice) out.push(slice)
|
|
330
|
+
i = j
|
|
331
|
+
break
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
if (out.length >= maxSlices) break
|
|
336
|
+
}
|
|
337
|
+
return out
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const candidates = []
|
|
341
|
+
const seen = new Set()
|
|
342
|
+
const pushCandidate = (input) => {
|
|
343
|
+
const normalized = normalizeCandidate(input)
|
|
344
|
+
if (!normalized || seen.has(normalized)) return
|
|
345
|
+
seen.add(normalized)
|
|
346
|
+
candidates.push(normalized)
|
|
274
347
|
}
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
348
|
+
|
|
349
|
+
pushCandidate(text)
|
|
350
|
+
const fencedRe = /```(?:[a-zA-Z0-9_-]+)?\s*([\s\S]*?)```/g
|
|
351
|
+
let fenceMatch
|
|
352
|
+
while ((fenceMatch = fencedRe.exec(text)) !== null) {
|
|
353
|
+
pushCandidate(fenceMatch[1])
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
for (const candidate of candidates) {
|
|
357
|
+
const direct = tryParse(candidate)
|
|
358
|
+
if (direct !== null) return direct
|
|
359
|
+
|
|
360
|
+
const objectStart = candidate.indexOf('{')
|
|
361
|
+
const objectEnd = candidate.lastIndexOf('}')
|
|
362
|
+
if (objectStart !== -1 && objectEnd !== -1 && objectEnd > objectStart) {
|
|
363
|
+
const parsedObject = tryParse(candidate.slice(objectStart, objectEnd + 1))
|
|
364
|
+
if (parsedObject !== null) return parsedObject
|
|
365
|
+
}
|
|
366
|
+
const arrayStart = candidate.indexOf('[')
|
|
367
|
+
const arrayEnd = candidate.lastIndexOf(']')
|
|
368
|
+
if (arrayStart !== -1 && arrayEnd !== -1 && arrayEnd > arrayStart) {
|
|
369
|
+
const parsedArray = tryParse(candidate.slice(arrayStart, arrayEnd + 1))
|
|
370
|
+
if (parsedArray !== null) return parsedArray
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
const balancedSlices = extractBalancedJsonSlices(candidate)
|
|
374
|
+
for (const slice of balancedSlices) {
|
|
375
|
+
const parsedSlice = tryParse(slice)
|
|
376
|
+
if (parsedSlice !== null) return parsedSlice
|
|
377
|
+
}
|
|
280
378
|
}
|
|
281
|
-
|
|
379
|
+
|
|
380
|
+
const preview = text.slice(0, 180).replace(/\s+/g, ' ').trim()
|
|
381
|
+
throw new Error(`The LLM response did not contain valid JSON${preview ? ` (preview: ${preview})` : ''}`)
|
|
282
382
|
}
|
|
283
383
|
|
|
284
384
|
const normalizeValueForCompare = (value) => {
|
|
@@ -2046,15 +2146,25 @@ const buildRelevantDocsContext = ({ moduleRootDir, question, preferredLangDir, m
|
|
|
2046
2146
|
}
|
|
2047
2147
|
|
|
2048
2148
|
const postJson = async ({ url, headers, body, timeoutMs }) => {
|
|
2149
|
+
const resolvedTimeoutMs = Math.max(1000, Number(timeoutMs) || 30000)
|
|
2049
2150
|
const controller = new AbortController()
|
|
2050
|
-
const timer = setTimeout(() => controller.abort(),
|
|
2151
|
+
const timer = setTimeout(() => controller.abort(), resolvedTimeoutMs)
|
|
2051
2152
|
try {
|
|
2052
|
-
|
|
2053
|
-
|
|
2054
|
-
|
|
2055
|
-
|
|
2056
|
-
|
|
2057
|
-
|
|
2153
|
+
let res
|
|
2154
|
+
try {
|
|
2155
|
+
res = await fetch(url, {
|
|
2156
|
+
method: 'POST',
|
|
2157
|
+
headers: Object.assign({ 'content-type': 'application/json' }, headers || {}),
|
|
2158
|
+
body: JSON.stringify(body || {}),
|
|
2159
|
+
signal: controller.signal
|
|
2160
|
+
})
|
|
2161
|
+
} catch (error) {
|
|
2162
|
+
const isAbort = (error && error.name === 'AbortError') || /\babort(ed)?\b/i.test(String(error && error.message ? error.message : ''))
|
|
2163
|
+
if (isAbort) {
|
|
2164
|
+
throw new Error(`LLM request timeout after ${Math.round(resolvedTimeoutMs / 1000)}s. Increase "Timeout ms" in the KNX AI node settings or reduce prompt context.`)
|
|
2165
|
+
}
|
|
2166
|
+
throw error
|
|
2167
|
+
}
|
|
2058
2168
|
const text = await res.text()
|
|
2059
2169
|
let json
|
|
2060
2170
|
try {
|
|
@@ -2076,8 +2186,9 @@ const postJson = async ({ url, headers, body, timeoutMs }) => {
|
|
|
2076
2186
|
}
|
|
2077
2187
|
|
|
2078
2188
|
const getJson = async ({ url, headers, timeoutMs }) => {
|
|
2189
|
+
const resolvedTimeoutMs = Math.max(1000, Number(timeoutMs) || 20000)
|
|
2079
2190
|
const controller = new AbortController()
|
|
2080
|
-
const timer = setTimeout(() => controller.abort(),
|
|
2191
|
+
const timer = setTimeout(() => controller.abort(), resolvedTimeoutMs)
|
|
2081
2192
|
try {
|
|
2082
2193
|
const res = await fetch(url, { method: 'GET', headers: headers || {}, signal: controller.signal })
|
|
2083
2194
|
const text = await res.text()
|
|
@@ -2453,12 +2564,14 @@ module.exports = function (RED) {
|
|
|
2453
2564
|
if (!adminEndpointsRegistered) {
|
|
2454
2565
|
adminEndpointsRegistered = true
|
|
2455
2566
|
|
|
2567
|
+
RED.httpAdmin.use('/knxUltimateAI/sidebar', normalizeAuthFromAccessTokenQuery)
|
|
2568
|
+
|
|
2456
2569
|
RED.httpAdmin.get('/knxUltimateAI/sidebar/page', RED.auth.needsPermission('knxUltimate-config.read'), (req, res) => {
|
|
2457
|
-
sendKnxAiVueIndex(res)
|
|
2570
|
+
sendKnxAiVueIndex(req, res)
|
|
2458
2571
|
})
|
|
2459
2572
|
|
|
2460
2573
|
RED.httpAdmin.get('/knxUltimateAI/sidebar/page-vue', RED.auth.needsPermission('knxUltimate-config.read'), (req, res) => {
|
|
2461
|
-
sendKnxAiVueIndex(res)
|
|
2574
|
+
sendKnxAiVueIndex(req, res)
|
|
2462
2575
|
})
|
|
2463
2576
|
|
|
2464
2577
|
RED.httpAdmin.get('/knxUltimateAI/sidebar/page/assets/:file', RED.auth.needsPermission('knxUltimate-config.read'), (req, res) => {
|
|
@@ -2469,6 +2582,16 @@ module.exports = function (RED) {
|
|
|
2469
2582
|
})
|
|
2470
2583
|
})
|
|
2471
2584
|
|
|
2585
|
+
// Alias for relative asset URLs resolved from ".../sidebar/page?nodeId=..."
|
|
2586
|
+
// which become ".../sidebar/assets/<file>" in browsers.
|
|
2587
|
+
RED.httpAdmin.get('/knxUltimateAI/sidebar/assets/:file', RED.auth.needsPermission('knxUltimate-config.read'), (req, res) => {
|
|
2588
|
+
sendStaticFileSafe({
|
|
2589
|
+
rootDir: knxAiVueDistDir,
|
|
2590
|
+
relativePath: path.join('assets', String(req.params?.file || '')),
|
|
2591
|
+
res
|
|
2592
|
+
})
|
|
2593
|
+
})
|
|
2594
|
+
|
|
2472
2595
|
RED.httpAdmin.get('/knxUltimateAI/sidebar/page-vue/assets/:file', RED.auth.needsPermission('knxUltimate-config.read'), (req, res) => {
|
|
2473
2596
|
sendStaticFileSafe({
|
|
2474
2597
|
rootDir: knxAiVueDistDir,
|
|
@@ -2616,6 +2739,25 @@ module.exports = function (RED) {
|
|
|
2616
2739
|
}
|
|
2617
2740
|
})
|
|
2618
2741
|
|
|
2742
|
+
RED.httpAdmin.post('/knxUltimateAI/sidebar/areas/delete-llm', RED.auth.needsPermission('knxUltimate-config.write'), async (req, res) => {
|
|
2743
|
+
try {
|
|
2744
|
+
const nodeId = req.body?.nodeId ? String(req.body.nodeId) : ''
|
|
2745
|
+
if (!nodeId) {
|
|
2746
|
+
res.status(400).json({ error: 'Missing nodeId' })
|
|
2747
|
+
return
|
|
2748
|
+
}
|
|
2749
|
+
const n = aiRuntimeNodes.get(nodeId) || RED.nodes.getNode(nodeId)
|
|
2750
|
+
if (!n || n.type !== 'knxUltimateAI' || typeof n.deleteAllLlmAreas !== 'function') {
|
|
2751
|
+
res.status(404).json({ error: 'KNX AI node not found' })
|
|
2752
|
+
return
|
|
2753
|
+
}
|
|
2754
|
+
const ret = await n.deleteAllLlmAreas()
|
|
2755
|
+
res.json(ret)
|
|
2756
|
+
} catch (error) {
|
|
2757
|
+
res.status(error.status || 500).json({ error: error.message || String(error) })
|
|
2758
|
+
}
|
|
2759
|
+
})
|
|
2760
|
+
|
|
2619
2761
|
RED.httpAdmin.post('/knxUltimateAI/sidebar/areas/catalog', RED.auth.needsPermission('knxUltimate-config.read'), async (req, res) => {
|
|
2620
2762
|
try {
|
|
2621
2763
|
const nodeId = req.body?.nodeId ? String(req.body.nodeId) : ''
|
|
@@ -3207,8 +3349,8 @@ module.exports = function (RED) {
|
|
|
3207
3349
|
node.llmModel = config.llmModel || 'gpt-4o-mini'
|
|
3208
3350
|
node.llmSystemPrompt = config.llmSystemPrompt || 'You are a KNX building automation assistant. Analyze KNX bus traffic and provide actionable insights.'
|
|
3209
3351
|
node.llmTemperature = (config.llmTemperature === undefined || config.llmTemperature === '') ? 0.2 : Number(config.llmTemperature)
|
|
3210
|
-
node.llmMaxTokens = (config.llmMaxTokens === undefined || config.llmMaxTokens === '') ?
|
|
3211
|
-
node.llmTimeoutMs = (config.llmTimeoutMs === undefined || config.llmTimeoutMs === '') ?
|
|
3352
|
+
node.llmMaxTokens = (config.llmMaxTokens === undefined || config.llmMaxTokens === '') ? 50000 : Number(config.llmMaxTokens)
|
|
3353
|
+
node.llmTimeoutMs = (config.llmTimeoutMs === undefined || config.llmTimeoutMs === '') ? 120000 : Number(config.llmTimeoutMs)
|
|
3212
3354
|
node.llmMaxEventsInPrompt = (config.llmMaxEventsInPrompt === undefined || config.llmMaxEventsInPrompt === '') ? 120 : Number(config.llmMaxEventsInPrompt)
|
|
3213
3355
|
node.llmIncludeRaw = config.llmIncludeRaw !== undefined ? coerceBoolean(config.llmIncludeRaw) : false
|
|
3214
3356
|
node.llmIncludeFlowContext = config.llmIncludeFlowContext !== undefined ? coerceBoolean(config.llmIncludeFlowContext) : true
|
|
@@ -5011,13 +5153,15 @@ module.exports = function (RED) {
|
|
|
5011
5153
|
translated: false
|
|
5012
5154
|
}
|
|
5013
5155
|
}
|
|
5156
|
+
const jsonMaxTokens = Math.max(50000, Number(node.llmMaxTokens) || 0)
|
|
5014
5157
|
const llmResponse = await callLLMChat({
|
|
5015
5158
|
systemPrompt: [
|
|
5016
5159
|
'You are a KNX installer assistant.',
|
|
5017
5160
|
'Translate only human-readable KNX test labels.',
|
|
5018
5161
|
'Return JSON only.'
|
|
5019
5162
|
].join(' '),
|
|
5020
|
-
userContent: buildTestPlanTranslationPrompt({ language: targetLanguage, languageName: targetLanguageName, plan })
|
|
5163
|
+
userContent: buildTestPlanTranslationPrompt({ language: targetLanguage, languageName: targetLanguageName, plan }),
|
|
5164
|
+
maxTokensOverride: jsonMaxTokens
|
|
5021
5165
|
})
|
|
5022
5166
|
const parsed = extractJsonFragmentFromText(llmResponse.content)
|
|
5023
5167
|
const translatedSteps = Array.isArray(parsed)
|
|
@@ -5059,13 +5203,15 @@ module.exports = function (RED) {
|
|
|
5059
5203
|
const suggestGaRoleOverridesWithLlm = async ({ gaCatalog }) => {
|
|
5060
5204
|
const candidates = (Array.isArray(gaCatalog) ? gaCatalog : []).filter(item => item && item.ga && isAmbiguousGaRoleSource(item.baseRoleSource || item.roleSource))
|
|
5061
5205
|
if (!candidates.length) return {}
|
|
5206
|
+
const jsonMaxTokens = Math.max(50000, Number(node.llmMaxTokens) || 0)
|
|
5062
5207
|
const llmResponse = await callLLMChat({
|
|
5063
5208
|
systemPrompt: [
|
|
5064
5209
|
'You are a KNX installation modeling assistant.',
|
|
5065
5210
|
'Classify KNX group addresses as command, status, or neutral for installers.',
|
|
5066
5211
|
'Return JSON only.'
|
|
5067
5212
|
].join(' '),
|
|
5068
|
-
userContent: buildGaRoleSuggestionPrompt({ gaCatalog: candidates })
|
|
5213
|
+
userContent: buildGaRoleSuggestionPrompt({ gaCatalog: candidates }),
|
|
5214
|
+
maxTokensOverride: jsonMaxTokens
|
|
5069
5215
|
})
|
|
5070
5216
|
const parsed = extractJsonFragmentFromText(llmResponse.content)
|
|
5071
5217
|
const gaCatalogMap = new Map(candidates.map(item => [String(item.ga).trim(), item]))
|
|
@@ -5743,17 +5889,38 @@ module.exports = function (RED) {
|
|
|
5743
5889
|
}
|
|
5744
5890
|
}
|
|
5745
5891
|
|
|
5892
|
+
node.deleteAllLlmAreas = async () => {
|
|
5893
|
+
const currentOverrides = Object.assign({}, loadAreaOverrides())
|
|
5894
|
+
const llmAreaIds = Object.keys(currentOverrides).filter(key => String(key || '').startsWith('llm:'))
|
|
5895
|
+
llmAreaIds.forEach((areaId) => {
|
|
5896
|
+
delete currentOverrides[areaId]
|
|
5897
|
+
})
|
|
5898
|
+
writeAreaOverrides(currentOverrides)
|
|
5899
|
+
const summary = node._lastSummary || rebuildCachedSummaryNow()
|
|
5900
|
+
return {
|
|
5901
|
+
ok: true,
|
|
5902
|
+
deletedCount: llmAreaIds.length,
|
|
5903
|
+
areas: buildAreasSnapshot({ summary }),
|
|
5904
|
+
profiles: buildProfilesSnapshot(),
|
|
5905
|
+
actuatorTests: buildActuatorTestsSnapshot(),
|
|
5906
|
+
testPlans: buildAiTestPlansSnapshot(),
|
|
5907
|
+
gaCatalog: getGaCatalogSnapshot()
|
|
5908
|
+
}
|
|
5909
|
+
}
|
|
5910
|
+
|
|
5746
5911
|
node.regenerateLlmAreas = async () => {
|
|
5747
5912
|
if (!node.llmEnabled) throw new Error('LLM is disabled in the KNX AI node config')
|
|
5748
5913
|
const gaCatalog = getGaCatalogSnapshot()
|
|
5749
5914
|
if (!Array.isArray(gaCatalog) || !gaCatalog.length) throw new Error('No ETS group addresses available')
|
|
5915
|
+
const jsonMaxTokens = Math.max(50000, Number(node.llmMaxTokens) || 0)
|
|
5750
5916
|
const llmResponse = await callLLMChat({
|
|
5751
5917
|
systemPrompt: [
|
|
5752
5918
|
'You are a KNX installation modeling assistant.',
|
|
5753
5919
|
'Group KNX group addresses into practical installer-friendly operational areas.',
|
|
5754
5920
|
'Return JSON only.'
|
|
5755
5921
|
].join(' '),
|
|
5756
|
-
userContent: buildAreaRegenerationPrompt({ gaCatalog })
|
|
5922
|
+
userContent: buildAreaRegenerationPrompt({ gaCatalog }),
|
|
5923
|
+
maxTokensOverride: jsonMaxTokens
|
|
5757
5924
|
})
|
|
5758
5925
|
const parsed = extractJsonFragmentFromText(llmResponse.content)
|
|
5759
5926
|
const rawAreas = Array.isArray(parsed)
|
|
@@ -5826,6 +5993,7 @@ module.exports = function (RED) {
|
|
|
5826
5993
|
if (!installerPrompt) throw new Error('Missing prompt')
|
|
5827
5994
|
const gaCatalog = getGaCatalogSnapshot()
|
|
5828
5995
|
if (!Array.isArray(gaCatalog) || !gaCatalog.length) throw new Error('No ETS group addresses available')
|
|
5996
|
+
const jsonMaxTokens = Math.max(50000, Number(node.llmMaxTokens) || 0)
|
|
5829
5997
|
const llmResponse = await callLLMChat({
|
|
5830
5998
|
systemPrompt: [
|
|
5831
5999
|
'You are a KNX installation modeling assistant.',
|
|
@@ -5838,7 +6006,8 @@ module.exports = function (RED) {
|
|
|
5838
6006
|
draftDescription: description,
|
|
5839
6007
|
currentGaList: Array.isArray(gaList) ? gaList : [],
|
|
5840
6008
|
gaCatalog
|
|
5841
|
-
})
|
|
6009
|
+
}),
|
|
6010
|
+
maxTokensOverride: jsonMaxTokens
|
|
5842
6011
|
})
|
|
5843
6012
|
const parsed = extractJsonFragmentFromText(llmResponse.content)
|
|
5844
6013
|
const gaCatalogMap = new Map(gaCatalog.map(item => [String(item && item.ga ? item.ga : '').trim(), item]))
|
|
@@ -6216,10 +6385,13 @@ module.exports = function (RED) {
|
|
|
6216
6385
|
if (!node.llmApiKey && node.llmProvider !== 'ollama') {
|
|
6217
6386
|
throw new Error('Missing API key: paste only the OpenAI key (starts with sk-), without "Bearer"')
|
|
6218
6387
|
}
|
|
6219
|
-
|
|
6220
|
-
|
|
6221
|
-
|
|
6222
|
-
|
|
6388
|
+
const maxTokensRaw = (maxTokensOverride !== null && maxTokensOverride !== undefined && maxTokensOverride !== '')
|
|
6389
|
+
? Number(maxTokensOverride)
|
|
6390
|
+
: Number(node.llmMaxTokens)
|
|
6391
|
+
const resolvedMaxTokens = Number.isFinite(maxTokensRaw) && maxTokensRaw > 0 ? Math.round(maxTokensRaw) : 10000
|
|
6392
|
+
const configuredTimeoutMs = Number(node.llmTimeoutMs)
|
|
6393
|
+
const resolvedTimeoutMs = Number.isFinite(configuredTimeoutMs) && configuredTimeoutMs > 0 ? Math.round(configuredTimeoutMs) : 30000
|
|
6394
|
+
const effectiveTimeoutMs = Math.max(120000, resolvedTimeoutMs)
|
|
6223
6395
|
|
|
6224
6396
|
if (node.llmProvider === 'ollama') {
|
|
6225
6397
|
const url = node.llmBaseUrl || 'http://localhost:11434/api/chat'
|
|
@@ -6234,7 +6406,7 @@ module.exports = function (RED) {
|
|
|
6234
6406
|
temperature: node.llmTemperature
|
|
6235
6407
|
}
|
|
6236
6408
|
}
|
|
6237
|
-
const json = await postJson({ url, body, timeoutMs:
|
|
6409
|
+
const json = await postJson({ url, body, timeoutMs: effectiveTimeoutMs })
|
|
6238
6410
|
const content = json && json.message && typeof json.message.content === 'string' ? json.message.content : safeStringify(json)
|
|
6239
6411
|
return { provider: 'ollama', model: body.model, content, finishReason: String(json && json.done_reason ? json.done_reason : '') }
|
|
6240
6412
|
}
|
|
@@ -6279,26 +6451,26 @@ module.exports = function (RED) {
|
|
|
6279
6451
|
|
|
6280
6452
|
let json
|
|
6281
6453
|
try {
|
|
6282
|
-
json = await postJson({ url, headers, body: bodyWithMaxTokens, timeoutMs:
|
|
6454
|
+
json = await postJson({ url, headers, body: bodyWithMaxTokens, timeoutMs: effectiveTimeoutMs })
|
|
6283
6455
|
} catch (error) {
|
|
6284
6456
|
const msg = (error && error.message) ? String(error.message) : ''
|
|
6285
6457
|
if (isResponseFormatCompatibilityError(msg)) {
|
|
6286
6458
|
try {
|
|
6287
|
-
json = await postJson({ url, headers, body: plainBodyWithMaxTokens, timeoutMs:
|
|
6459
|
+
json = await postJson({ url, headers, body: plainBodyWithMaxTokens, timeoutMs: effectiveTimeoutMs })
|
|
6288
6460
|
} catch (innerError) {
|
|
6289
6461
|
const innerMsg = (innerError && innerError.message) ? String(innerError.message) : ''
|
|
6290
6462
|
if (innerMsg.includes("Unsupported parameter: 'max_tokens'") || innerMsg.includes('max_completion_tokens')) {
|
|
6291
|
-
json = await postJson({ url, headers, body: plainBodyWithMaxCompletionTokens, timeoutMs:
|
|
6463
|
+
json = await postJson({ url, headers, body: plainBodyWithMaxCompletionTokens, timeoutMs: effectiveTimeoutMs })
|
|
6292
6464
|
} else if (innerMsg.includes("Unsupported parameter: 'max_completion_tokens'")) {
|
|
6293
|
-
json = await postJson({ url, headers, body: plainBodyWithMaxTokens, timeoutMs:
|
|
6465
|
+
json = await postJson({ url, headers, body: plainBodyWithMaxTokens, timeoutMs: effectiveTimeoutMs })
|
|
6294
6466
|
} else {
|
|
6295
6467
|
throw innerError
|
|
6296
6468
|
}
|
|
6297
6469
|
}
|
|
6298
6470
|
} else if (msg.includes("Unsupported parameter: 'max_tokens'") || msg.includes('max_completion_tokens')) {
|
|
6299
|
-
json = await postJson({ url, headers, body: bodyWithMaxCompletionTokens, timeoutMs:
|
|
6471
|
+
json = await postJson({ url, headers, body: bodyWithMaxCompletionTokens, timeoutMs: effectiveTimeoutMs })
|
|
6300
6472
|
} else if (msg.includes("Unsupported parameter: 'max_completion_tokens'")) {
|
|
6301
|
-
json = await postJson({ url, headers, body: bodyWithMaxTokens, timeoutMs:
|
|
6473
|
+
json = await postJson({ url, headers, body: bodyWithMaxTokens, timeoutMs: effectiveTimeoutMs })
|
|
6302
6474
|
} else {
|
|
6303
6475
|
throw error
|
|
6304
6476
|
}
|