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 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
+ ]
@@ -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: 10000, required: false, validate: RED.validators.number() },
40
- llmTimeoutMs: { value: 30000, required: false, validate: RED.validators.number() },
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 target = "knxUltimateAI/sidebar/page" + (nodeId ? ("?nodeId=" + encodeURIComponent(nodeId)) : "");
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
  });
@@ -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.stat(entryPath, (error, stats) => {
22
- if (error || !stats || !stats.isFile()) {
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
- res.sendFile(entryPath, (sendError) => {
27
- if (!sendError || res.headersSent) return
28
- res.status(sendError.statusCode || 500).type('text/plain').send(sendError.message || String(sendError))
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 fenced = text.match(/```(?:json)?\s*([\s\S]*?)```/i)
258
- const candidate = fenced && fenced[1] ? String(fenced[1]).trim() : text
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
- if (!input) return null
270
+ const source = normalizeCandidate(input)
271
+ if (!source) return null
261
272
  try {
262
- return JSON.parse(input)
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
- const direct = tryParse(candidate)
268
- if (direct !== null) return direct
269
- const objectStart = candidate.indexOf('{')
270
- const objectEnd = candidate.lastIndexOf('}')
271
- if (objectStart !== -1 && objectEnd !== -1 && objectEnd > objectStart) {
272
- const parsedObject = tryParse(candidate.slice(objectStart, objectEnd + 1))
273
- if (parsedObject !== null) return parsedObject
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
- const arrayStart = candidate.indexOf('[')
276
- const arrayEnd = candidate.lastIndexOf(']')
277
- if (arrayStart !== -1 && arrayEnd !== -1 && arrayEnd > arrayStart) {
278
- const parsedArray = tryParse(candidate.slice(arrayStart, arrayEnd + 1))
279
- if (parsedArray !== null) return parsedArray
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
- throw new Error('The LLM response did not contain valid JSON')
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(), timeoutMs || 30000)
2151
+ const timer = setTimeout(() => controller.abort(), resolvedTimeoutMs)
2051
2152
  try {
2052
- const res = await fetch(url, {
2053
- method: 'POST',
2054
- headers: Object.assign({ 'content-type': 'application/json' }, headers || {}),
2055
- body: JSON.stringify(body || {}),
2056
- signal: controller.signal
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(), timeoutMs || 20000)
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 === '') ? 10000 : Number(config.llmMaxTokens)
3211
- node.llmTimeoutMs = (config.llmTimeoutMs === undefined || config.llmTimeoutMs === '') ? 30000 : Number(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
- const maxTokensRaw = (maxTokensOverride !== null && maxTokensOverride !== undefined && maxTokensOverride !== '')
6220
- ? Number(maxTokensOverride)
6221
- : Number(node.llmMaxTokens)
6222
- const resolvedMaxTokens = Number.isFinite(maxTokensRaw) && maxTokensRaw > 0 ? Math.round(maxTokensRaw) : 10000
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: node.llmTimeoutMs })
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: node.llmTimeoutMs })
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: node.llmTimeoutMs })
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: node.llmTimeoutMs })
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: node.llmTimeoutMs })
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: node.llmTimeoutMs })
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: node.llmTimeoutMs })
6473
+ json = await postJson({ url, headers, body: bodyWithMaxTokens, timeoutMs: effectiveTimeoutMs })
6302
6474
  } else {
6303
6475
  throw error
6304
6476
  }