node-red-contrib-knx-ultimate 4.2.9 → 4.2.11
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,7 +6,16 @@
|
|
|
6
6
|
|
|
7
7
|
# CHANGELOG
|
|
8
8
|
|
|
9
|
-
**Version 4.2.
|
|
9
|
+
**Version 4.2.11** - April 2026<br/>
|
|
10
|
+
|
|
11
|
+
- UI: **KNX AI Web** improved assistant workflow with chat-style layout (prompt input under messages), clearer prompt focus, and streamlined Ask page text.<br/>
|
|
12
|
+
- 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/>
|
|
13
|
+
- 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/>
|
|
14
|
+
- FIX: **KNX AI backend** hardened JSON extraction/parsing for LLM outputs and improved timeout/token-limit diagnostics in error messages.<br/>
|
|
15
|
+
- CHANGE: **KNX AI backend** raised default/forced `llmMaxTokens` handling for structured responses and aligned editor defaults for high-token completions.<br/>
|
|
16
|
+
- Docs/wiki: improved **KNX AI Dashboard** pages for end users, added localized guidance updates, and made support CTA links visible in docs navigation.<br/>
|
|
17
|
+
|
|
18
|
+
**Version 4.2.10** - April 2026<br/>
|
|
10
19
|
|
|
11
20
|
- 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/>
|
|
12
21
|
- UI: **KNX AI Web** removed sidebar menu subtitles to keep navigation labels clean and compact.<br/>
|
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 },
|
|
@@ -391,4 +391,4 @@
|
|
|
391
391
|
</div>
|
|
392
392
|
</div>
|
|
393
393
|
</div>
|
|
394
|
-
</script>
|
|
394
|
+
</script>
|
package/nodes/knxUltimateAI.js
CHANGED
|
@@ -254,31 +254,124 @@ const buildLlmSummarySnapshot = (summary) => {
|
|
|
254
254
|
const extractJsonFragmentFromText = (value) => {
|
|
255
255
|
const text = String(value || '').trim()
|
|
256
256
|
if (!text) throw new Error('Empty AI response')
|
|
257
|
-
const
|
|
258
|
-
|
|
257
|
+
const normalizeCandidate = (input) => String(input || '')
|
|
258
|
+
.replace(/^\uFEFF/, '')
|
|
259
|
+
.replace(/^\s*json\s*\n/i, '')
|
|
260
|
+
.trim()
|
|
261
|
+
|
|
259
262
|
const tryParse = (input) => {
|
|
260
|
-
|
|
263
|
+
const source = normalizeCandidate(input)
|
|
264
|
+
if (!source) return null
|
|
265
|
+
try {
|
|
266
|
+
return JSON.parse(source)
|
|
267
|
+
} catch (error) {}
|
|
268
|
+
// Fallback: tolerate comments and trailing commas that some models emit.
|
|
269
|
+
const relaxed = source
|
|
270
|
+
.replace(/\/\*[\s\S]*?\*\//g, '')
|
|
271
|
+
.replace(/^\s*\/\/.*$/gm, '')
|
|
272
|
+
.replace(/,\s*([}\]])/g, '$1')
|
|
273
|
+
.trim()
|
|
274
|
+
if (!relaxed || relaxed === source) return null
|
|
261
275
|
try {
|
|
262
|
-
return JSON.parse(
|
|
276
|
+
return JSON.parse(relaxed)
|
|
263
277
|
} catch (error) {
|
|
264
278
|
return null
|
|
265
279
|
}
|
|
266
280
|
}
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
281
|
+
|
|
282
|
+
const extractBalancedJsonSlices = (input, maxSlices = 24) => {
|
|
283
|
+
const source = String(input || '')
|
|
284
|
+
const out = []
|
|
285
|
+
for (let i = 0; i < source.length; i += 1) {
|
|
286
|
+
const ch = source[i]
|
|
287
|
+
if (ch !== '{' && ch !== '[') continue
|
|
288
|
+
const stack = [ch === '{' ? '}' : ']']
|
|
289
|
+
let inString = false
|
|
290
|
+
let escaped = false
|
|
291
|
+
for (let j = i + 1; j < source.length; j += 1) {
|
|
292
|
+
const current = source[j]
|
|
293
|
+
if (inString) {
|
|
294
|
+
if (escaped) {
|
|
295
|
+
escaped = false
|
|
296
|
+
continue
|
|
297
|
+
}
|
|
298
|
+
if (current === '\\') {
|
|
299
|
+
escaped = true
|
|
300
|
+
continue
|
|
301
|
+
}
|
|
302
|
+
if (current === '"') inString = false
|
|
303
|
+
continue
|
|
304
|
+
}
|
|
305
|
+
if (current === '"') {
|
|
306
|
+
inString = true
|
|
307
|
+
continue
|
|
308
|
+
}
|
|
309
|
+
if (current === '{') {
|
|
310
|
+
stack.push('}')
|
|
311
|
+
continue
|
|
312
|
+
}
|
|
313
|
+
if (current === '[') {
|
|
314
|
+
stack.push(']')
|
|
315
|
+
continue
|
|
316
|
+
}
|
|
317
|
+
if ((current === '}' || current === ']') && stack.length) {
|
|
318
|
+
if (current !== stack[stack.length - 1]) break
|
|
319
|
+
stack.pop()
|
|
320
|
+
if (!stack.length) {
|
|
321
|
+
const slice = normalizeCandidate(source.slice(i, j + 1))
|
|
322
|
+
if (slice) out.push(slice)
|
|
323
|
+
i = j
|
|
324
|
+
break
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
if (out.length >= maxSlices) break
|
|
329
|
+
}
|
|
330
|
+
return out
|
|
274
331
|
}
|
|
275
|
-
|
|
276
|
-
const
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
332
|
+
|
|
333
|
+
const candidates = []
|
|
334
|
+
const seen = new Set()
|
|
335
|
+
const pushCandidate = (input) => {
|
|
336
|
+
const normalized = normalizeCandidate(input)
|
|
337
|
+
if (!normalized || seen.has(normalized)) return
|
|
338
|
+
seen.add(normalized)
|
|
339
|
+
candidates.push(normalized)
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
pushCandidate(text)
|
|
343
|
+
const fencedRe = /```(?:[a-zA-Z0-9_-]+)?\s*([\s\S]*?)```/g
|
|
344
|
+
let fenceMatch
|
|
345
|
+
while ((fenceMatch = fencedRe.exec(text)) !== null) {
|
|
346
|
+
pushCandidate(fenceMatch[1])
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
for (const candidate of candidates) {
|
|
350
|
+
const direct = tryParse(candidate)
|
|
351
|
+
if (direct !== null) return direct
|
|
352
|
+
|
|
353
|
+
const objectStart = candidate.indexOf('{')
|
|
354
|
+
const objectEnd = candidate.lastIndexOf('}')
|
|
355
|
+
if (objectStart !== -1 && objectEnd !== -1 && objectEnd > objectStart) {
|
|
356
|
+
const parsedObject = tryParse(candidate.slice(objectStart, objectEnd + 1))
|
|
357
|
+
if (parsedObject !== null) return parsedObject
|
|
358
|
+
}
|
|
359
|
+
const arrayStart = candidate.indexOf('[')
|
|
360
|
+
const arrayEnd = candidate.lastIndexOf(']')
|
|
361
|
+
if (arrayStart !== -1 && arrayEnd !== -1 && arrayEnd > arrayStart) {
|
|
362
|
+
const parsedArray = tryParse(candidate.slice(arrayStart, arrayEnd + 1))
|
|
363
|
+
if (parsedArray !== null) return parsedArray
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
const balancedSlices = extractBalancedJsonSlices(candidate)
|
|
367
|
+
for (const slice of balancedSlices) {
|
|
368
|
+
const parsedSlice = tryParse(slice)
|
|
369
|
+
if (parsedSlice !== null) return parsedSlice
|
|
370
|
+
}
|
|
280
371
|
}
|
|
281
|
-
|
|
372
|
+
|
|
373
|
+
const preview = text.slice(0, 180).replace(/\s+/g, ' ').trim()
|
|
374
|
+
throw new Error(`The LLM response did not contain valid JSON${preview ? ` (preview: ${preview})` : ''}`)
|
|
282
375
|
}
|
|
283
376
|
|
|
284
377
|
const normalizeValueForCompare = (value) => {
|
|
@@ -1624,6 +1717,10 @@ const buildOpenAICompatFallbackText = (json) => {
|
|
|
1624
1717
|
return `No assistant text was returned by the provider${usageText}.`
|
|
1625
1718
|
}
|
|
1626
1719
|
|
|
1720
|
+
const isOpenAICompatLengthFallbackText = (value) => {
|
|
1721
|
+
return /^The model stopped because of token limit\b/i.test(String(value || '').trim())
|
|
1722
|
+
}
|
|
1723
|
+
|
|
1627
1724
|
const DPT_OPTIONS_CACHE = new Map()
|
|
1628
1725
|
|
|
1629
1726
|
const getDptValueOptions = (dptId) => {
|
|
@@ -2042,15 +2139,25 @@ const buildRelevantDocsContext = ({ moduleRootDir, question, preferredLangDir, m
|
|
|
2042
2139
|
}
|
|
2043
2140
|
|
|
2044
2141
|
const postJson = async ({ url, headers, body, timeoutMs }) => {
|
|
2142
|
+
const resolvedTimeoutMs = Math.max(1000, Number(timeoutMs) || 30000)
|
|
2045
2143
|
const controller = new AbortController()
|
|
2046
|
-
const timer = setTimeout(() => controller.abort(),
|
|
2144
|
+
const timer = setTimeout(() => controller.abort(), resolvedTimeoutMs)
|
|
2047
2145
|
try {
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
|
|
2053
|
-
|
|
2146
|
+
let res
|
|
2147
|
+
try {
|
|
2148
|
+
res = await fetch(url, {
|
|
2149
|
+
method: 'POST',
|
|
2150
|
+
headers: Object.assign({ 'content-type': 'application/json' }, headers || {}),
|
|
2151
|
+
body: JSON.stringify(body || {}),
|
|
2152
|
+
signal: controller.signal
|
|
2153
|
+
})
|
|
2154
|
+
} catch (error) {
|
|
2155
|
+
const isAbort = (error && error.name === 'AbortError') || /\babort(ed)?\b/i.test(String(error && error.message ? error.message : ''))
|
|
2156
|
+
if (isAbort) {
|
|
2157
|
+
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.`)
|
|
2158
|
+
}
|
|
2159
|
+
throw error
|
|
2160
|
+
}
|
|
2054
2161
|
const text = await res.text()
|
|
2055
2162
|
let json
|
|
2056
2163
|
try {
|
|
@@ -2072,8 +2179,9 @@ const postJson = async ({ url, headers, body, timeoutMs }) => {
|
|
|
2072
2179
|
}
|
|
2073
2180
|
|
|
2074
2181
|
const getJson = async ({ url, headers, timeoutMs }) => {
|
|
2182
|
+
const resolvedTimeoutMs = Math.max(1000, Number(timeoutMs) || 20000)
|
|
2075
2183
|
const controller = new AbortController()
|
|
2076
|
-
const timer = setTimeout(() => controller.abort(),
|
|
2184
|
+
const timer = setTimeout(() => controller.abort(), resolvedTimeoutMs)
|
|
2077
2185
|
try {
|
|
2078
2186
|
const res = await fetch(url, { method: 'GET', headers: headers || {}, signal: controller.signal })
|
|
2079
2187
|
const text = await res.text()
|
|
@@ -2612,6 +2720,25 @@ module.exports = function (RED) {
|
|
|
2612
2720
|
}
|
|
2613
2721
|
})
|
|
2614
2722
|
|
|
2723
|
+
RED.httpAdmin.post('/knxUltimateAI/sidebar/areas/delete-llm', RED.auth.needsPermission('knxUltimate-config.write'), async (req, res) => {
|
|
2724
|
+
try {
|
|
2725
|
+
const nodeId = req.body?.nodeId ? String(req.body.nodeId) : ''
|
|
2726
|
+
if (!nodeId) {
|
|
2727
|
+
res.status(400).json({ error: 'Missing nodeId' })
|
|
2728
|
+
return
|
|
2729
|
+
}
|
|
2730
|
+
const n = aiRuntimeNodes.get(nodeId) || RED.nodes.getNode(nodeId)
|
|
2731
|
+
if (!n || n.type !== 'knxUltimateAI' || typeof n.deleteAllLlmAreas !== 'function') {
|
|
2732
|
+
res.status(404).json({ error: 'KNX AI node not found' })
|
|
2733
|
+
return
|
|
2734
|
+
}
|
|
2735
|
+
const ret = await n.deleteAllLlmAreas()
|
|
2736
|
+
res.json(ret)
|
|
2737
|
+
} catch (error) {
|
|
2738
|
+
res.status(error.status || 500).json({ error: error.message || String(error) })
|
|
2739
|
+
}
|
|
2740
|
+
})
|
|
2741
|
+
|
|
2615
2742
|
RED.httpAdmin.post('/knxUltimateAI/sidebar/areas/catalog', RED.auth.needsPermission('knxUltimate-config.read'), async (req, res) => {
|
|
2616
2743
|
try {
|
|
2617
2744
|
const nodeId = req.body?.nodeId ? String(req.body.nodeId) : ''
|
|
@@ -3203,8 +3330,8 @@ module.exports = function (RED) {
|
|
|
3203
3330
|
node.llmModel = config.llmModel || 'gpt-4o-mini'
|
|
3204
3331
|
node.llmSystemPrompt = config.llmSystemPrompt || 'You are a KNX building automation assistant. Analyze KNX bus traffic and provide actionable insights.'
|
|
3205
3332
|
node.llmTemperature = (config.llmTemperature === undefined || config.llmTemperature === '') ? 0.2 : Number(config.llmTemperature)
|
|
3206
|
-
node.llmMaxTokens = (config.llmMaxTokens === undefined || config.llmMaxTokens === '') ?
|
|
3207
|
-
node.llmTimeoutMs = (config.llmTimeoutMs === undefined || config.llmTimeoutMs === '') ?
|
|
3333
|
+
node.llmMaxTokens = (config.llmMaxTokens === undefined || config.llmMaxTokens === '') ? 50000 : Number(config.llmMaxTokens)
|
|
3334
|
+
node.llmTimeoutMs = (config.llmTimeoutMs === undefined || config.llmTimeoutMs === '') ? 120000 : Number(config.llmTimeoutMs)
|
|
3208
3335
|
node.llmMaxEventsInPrompt = (config.llmMaxEventsInPrompt === undefined || config.llmMaxEventsInPrompt === '') ? 120 : Number(config.llmMaxEventsInPrompt)
|
|
3209
3336
|
node.llmIncludeRaw = config.llmIncludeRaw !== undefined ? coerceBoolean(config.llmIncludeRaw) : false
|
|
3210
3337
|
node.llmIncludeFlowContext = config.llmIncludeFlowContext !== undefined ? coerceBoolean(config.llmIncludeFlowContext) : true
|
|
@@ -4377,51 +4504,61 @@ module.exports = function (RED) {
|
|
|
4377
4504
|
}, 90)
|
|
4378
4505
|
}
|
|
4379
4506
|
|
|
4380
|
-
const buildLLMPrompt = ({ question, summary }) => {
|
|
4507
|
+
const buildLLMPrompt = ({ question, summary, compact = false } = {}) => {
|
|
4508
|
+
const compactMode = compact === true
|
|
4381
4509
|
const maxEventsRequested = Math.max(10, Number(node.llmMaxEventsInPrompt) || 120)
|
|
4382
|
-
const maxEvents = Math.min(240, maxEventsRequested)
|
|
4510
|
+
const maxEvents = Math.min(compactMode ? 80 : 240, maxEventsRequested)
|
|
4383
4511
|
const recent = node._history.slice(-maxEvents)
|
|
4384
4512
|
const wantsSvgChart = shouldGenerateSvgChart(question)
|
|
4385
4513
|
const areasSnapshot = buildAreasSnapshot({ summary })
|
|
4386
4514
|
const areasContext = buildAreasPromptContext(areasSnapshot)
|
|
4387
4515
|
const summaryForPrompt = buildLlmSummarySnapshot(summary)
|
|
4388
|
-
const summaryText = truncatePromptText(safeStringify(summaryForPrompt), 10000)
|
|
4516
|
+
const summaryText = truncatePromptText(safeStringify(summaryForPrompt), compactMode ? 4000 : 10000)
|
|
4389
4517
|
const lines = recent.map(t => {
|
|
4390
4518
|
const payloadStr = normalizeValueForCompare(t.payload)
|
|
4391
4519
|
const rawStr = (node.llmIncludeRaw && t.rawHex) ? ` raw=${t.rawHex}` : ''
|
|
4392
4520
|
const devName = t.devicename ? ` (${t.devicename})` : ''
|
|
4393
4521
|
return `${new Date(t.ts).toISOString()} ${t.event} ${t.source} -> ${t.destination}${devName} dpt=${t.dpt} payload=${payloadStr}${rawStr}`
|
|
4394
4522
|
})
|
|
4395
|
-
const recentLines = takeLastItemsByCharBudget(lines, 7000)
|
|
4523
|
+
const recentLines = takeLastItemsByCharBudget(lines, compactMode ? 2600 : 7000)
|
|
4396
4524
|
|
|
4397
4525
|
let flowContext = ''
|
|
4398
4526
|
if (node.llmIncludeFlowContext) {
|
|
4527
|
+
const flowMaxChars = compactMode ? 1400 : 5000
|
|
4399
4528
|
const ttlMs = 10 * 1000
|
|
4400
4529
|
const now = nowMs()
|
|
4401
4530
|
if (node._flowContextCache && node._flowContextCache.text && (now - (node._flowContextCache.at || 0)) < ttlMs) {
|
|
4402
4531
|
flowContext = node._flowContextCache.text
|
|
4403
4532
|
} else {
|
|
4404
|
-
|
|
4405
|
-
|
|
4533
|
+
const configuredMaxFlowNodes = Math.max(0, Number(node.llmMaxFlowNodesInPrompt) || 0)
|
|
4534
|
+
const maxFlowNodes = compactMode
|
|
4535
|
+
? (configuredMaxFlowNodes > 0 ? Math.min(configuredMaxFlowNodes, 30) : 0)
|
|
4536
|
+
: configuredMaxFlowNodes
|
|
4537
|
+
flowContext = buildKnxUltimateFlowInventory({ maxNodes: maxFlowNodes })
|
|
4538
|
+
flowContext = truncatePromptText(flowContext, flowMaxChars)
|
|
4406
4539
|
node._flowContextCache = { at: now, text: flowContext }
|
|
4407
4540
|
}
|
|
4541
|
+
flowContext = truncatePromptText(flowContext, flowMaxChars)
|
|
4408
4542
|
}
|
|
4409
4543
|
|
|
4410
4544
|
let docsContext = ''
|
|
4411
4545
|
if (node.llmIncludeDocsSnippets) {
|
|
4546
|
+
const docsMaxCharsConfigured = Math.max(500, Math.min(5000, Number(node.llmDocsMaxChars) || 500))
|
|
4547
|
+
const docsMaxChars = compactMode ? Math.min(docsMaxCharsConfigured, 1200) : docsMaxCharsConfigured
|
|
4548
|
+
const docsMaxSnippetsConfigured = Math.max(1, Number(node.llmDocsMaxSnippets) || 1)
|
|
4549
|
+
const docsMaxSnippets = compactMode ? Math.min(docsMaxSnippetsConfigured, 2) : docsMaxSnippetsConfigured
|
|
4412
4550
|
const ttlMs = 30 * 1000
|
|
4413
4551
|
const now = nowMs()
|
|
4414
4552
|
const q = String(question || '').trim()
|
|
4415
4553
|
if (node._docsContextCache && node._docsContextCache.text && node._docsContextCache.question === q && (now - (node._docsContextCache.at || 0)) < ttlMs) {
|
|
4416
|
-
docsContext = node._docsContextCache.text
|
|
4554
|
+
docsContext = truncatePromptText(node._docsContextCache.text, docsMaxChars)
|
|
4417
4555
|
} else {
|
|
4418
4556
|
const preferredLangDir = (node.llmDocsLanguage && node.llmDocsLanguage !== 'auto') ? node.llmDocsLanguage : ''
|
|
4419
|
-
const docsMaxChars = Math.max(500, Math.min(5000, Number(node.llmDocsMaxChars) || 500))
|
|
4420
4557
|
docsContext = buildRelevantDocsContext({
|
|
4421
4558
|
moduleRootDir,
|
|
4422
4559
|
question: q,
|
|
4423
4560
|
preferredLangDir,
|
|
4424
|
-
maxSnippets:
|
|
4561
|
+
maxSnippets: docsMaxSnippets,
|
|
4425
4562
|
maxChars: docsMaxChars
|
|
4426
4563
|
})
|
|
4427
4564
|
docsContext = truncatePromptText(docsContext, docsMaxChars)
|
|
@@ -4997,13 +5134,15 @@ module.exports = function (RED) {
|
|
|
4997
5134
|
translated: false
|
|
4998
5135
|
}
|
|
4999
5136
|
}
|
|
5137
|
+
const jsonMaxTokens = Math.max(50000, Number(node.llmMaxTokens) || 0)
|
|
5000
5138
|
const llmResponse = await callLLMChat({
|
|
5001
5139
|
systemPrompt: [
|
|
5002
5140
|
'You are a KNX installer assistant.',
|
|
5003
5141
|
'Translate only human-readable KNX test labels.',
|
|
5004
5142
|
'Return JSON only.'
|
|
5005
5143
|
].join(' '),
|
|
5006
|
-
userContent: buildTestPlanTranslationPrompt({ language: targetLanguage, languageName: targetLanguageName, plan })
|
|
5144
|
+
userContent: buildTestPlanTranslationPrompt({ language: targetLanguage, languageName: targetLanguageName, plan }),
|
|
5145
|
+
maxTokensOverride: jsonMaxTokens
|
|
5007
5146
|
})
|
|
5008
5147
|
const parsed = extractJsonFragmentFromText(llmResponse.content)
|
|
5009
5148
|
const translatedSteps = Array.isArray(parsed)
|
|
@@ -5045,13 +5184,15 @@ module.exports = function (RED) {
|
|
|
5045
5184
|
const suggestGaRoleOverridesWithLlm = async ({ gaCatalog }) => {
|
|
5046
5185
|
const candidates = (Array.isArray(gaCatalog) ? gaCatalog : []).filter(item => item && item.ga && isAmbiguousGaRoleSource(item.baseRoleSource || item.roleSource))
|
|
5047
5186
|
if (!candidates.length) return {}
|
|
5187
|
+
const jsonMaxTokens = Math.max(50000, Number(node.llmMaxTokens) || 0)
|
|
5048
5188
|
const llmResponse = await callLLMChat({
|
|
5049
5189
|
systemPrompt: [
|
|
5050
5190
|
'You are a KNX installation modeling assistant.',
|
|
5051
5191
|
'Classify KNX group addresses as command, status, or neutral for installers.',
|
|
5052
5192
|
'Return JSON only.'
|
|
5053
5193
|
].join(' '),
|
|
5054
|
-
userContent: buildGaRoleSuggestionPrompt({ gaCatalog: candidates })
|
|
5194
|
+
userContent: buildGaRoleSuggestionPrompt({ gaCatalog: candidates }),
|
|
5195
|
+
maxTokensOverride: jsonMaxTokens
|
|
5055
5196
|
})
|
|
5056
5197
|
const parsed = extractJsonFragmentFromText(llmResponse.content)
|
|
5057
5198
|
const gaCatalogMap = new Map(candidates.map(item => [String(item.ga).trim(), item]))
|
|
@@ -5729,17 +5870,38 @@ module.exports = function (RED) {
|
|
|
5729
5870
|
}
|
|
5730
5871
|
}
|
|
5731
5872
|
|
|
5873
|
+
node.deleteAllLlmAreas = async () => {
|
|
5874
|
+
const currentOverrides = Object.assign({}, loadAreaOverrides())
|
|
5875
|
+
const llmAreaIds = Object.keys(currentOverrides).filter(key => String(key || '').startsWith('llm:'))
|
|
5876
|
+
llmAreaIds.forEach((areaId) => {
|
|
5877
|
+
delete currentOverrides[areaId]
|
|
5878
|
+
})
|
|
5879
|
+
writeAreaOverrides(currentOverrides)
|
|
5880
|
+
const summary = node._lastSummary || rebuildCachedSummaryNow()
|
|
5881
|
+
return {
|
|
5882
|
+
ok: true,
|
|
5883
|
+
deletedCount: llmAreaIds.length,
|
|
5884
|
+
areas: buildAreasSnapshot({ summary }),
|
|
5885
|
+
profiles: buildProfilesSnapshot(),
|
|
5886
|
+
actuatorTests: buildActuatorTestsSnapshot(),
|
|
5887
|
+
testPlans: buildAiTestPlansSnapshot(),
|
|
5888
|
+
gaCatalog: getGaCatalogSnapshot()
|
|
5889
|
+
}
|
|
5890
|
+
}
|
|
5891
|
+
|
|
5732
5892
|
node.regenerateLlmAreas = async () => {
|
|
5733
5893
|
if (!node.llmEnabled) throw new Error('LLM is disabled in the KNX AI node config')
|
|
5734
5894
|
const gaCatalog = getGaCatalogSnapshot()
|
|
5735
5895
|
if (!Array.isArray(gaCatalog) || !gaCatalog.length) throw new Error('No ETS group addresses available')
|
|
5896
|
+
const jsonMaxTokens = Math.max(50000, Number(node.llmMaxTokens) || 0)
|
|
5736
5897
|
const llmResponse = await callLLMChat({
|
|
5737
5898
|
systemPrompt: [
|
|
5738
5899
|
'You are a KNX installation modeling assistant.',
|
|
5739
5900
|
'Group KNX group addresses into practical installer-friendly operational areas.',
|
|
5740
5901
|
'Return JSON only.'
|
|
5741
5902
|
].join(' '),
|
|
5742
|
-
userContent: buildAreaRegenerationPrompt({ gaCatalog })
|
|
5903
|
+
userContent: buildAreaRegenerationPrompt({ gaCatalog }),
|
|
5904
|
+
maxTokensOverride: jsonMaxTokens
|
|
5743
5905
|
})
|
|
5744
5906
|
const parsed = extractJsonFragmentFromText(llmResponse.content)
|
|
5745
5907
|
const rawAreas = Array.isArray(parsed)
|
|
@@ -5812,6 +5974,7 @@ module.exports = function (RED) {
|
|
|
5812
5974
|
if (!installerPrompt) throw new Error('Missing prompt')
|
|
5813
5975
|
const gaCatalog = getGaCatalogSnapshot()
|
|
5814
5976
|
if (!Array.isArray(gaCatalog) || !gaCatalog.length) throw new Error('No ETS group addresses available')
|
|
5977
|
+
const jsonMaxTokens = Math.max(50000, Number(node.llmMaxTokens) || 0)
|
|
5815
5978
|
const llmResponse = await callLLMChat({
|
|
5816
5979
|
systemPrompt: [
|
|
5817
5980
|
'You are a KNX installation modeling assistant.',
|
|
@@ -5824,7 +5987,8 @@ module.exports = function (RED) {
|
|
|
5824
5987
|
draftDescription: description,
|
|
5825
5988
|
currentGaList: Array.isArray(gaList) ? gaList : [],
|
|
5826
5989
|
gaCatalog
|
|
5827
|
-
})
|
|
5990
|
+
}),
|
|
5991
|
+
maxTokensOverride: jsonMaxTokens
|
|
5828
5992
|
})
|
|
5829
5993
|
const parsed = extractJsonFragmentFromText(llmResponse.content)
|
|
5830
5994
|
const gaCatalogMap = new Map(gaCatalog.map(item => [String(item && item.ga ? item.ga : '').trim(), item]))
|
|
@@ -6197,11 +6361,18 @@ module.exports = function (RED) {
|
|
|
6197
6361
|
}
|
|
6198
6362
|
}
|
|
6199
6363
|
|
|
6200
|
-
const callLLMChat = async ({ systemPrompt, userContent, jsonSchema = null }) => {
|
|
6364
|
+
const callLLMChat = async ({ systemPrompt, userContent, jsonSchema = null, maxTokensOverride = null }) => {
|
|
6201
6365
|
if (!node.llmEnabled) throw new Error('LLM is disabled in node config')
|
|
6202
6366
|
if (!node.llmApiKey && node.llmProvider !== 'ollama') {
|
|
6203
6367
|
throw new Error('Missing API key: paste only the OpenAI key (starts with sk-), without "Bearer"')
|
|
6204
6368
|
}
|
|
6369
|
+
const maxTokensRaw = (maxTokensOverride !== null && maxTokensOverride !== undefined && maxTokensOverride !== '')
|
|
6370
|
+
? Number(maxTokensOverride)
|
|
6371
|
+
: Number(node.llmMaxTokens)
|
|
6372
|
+
const resolvedMaxTokens = Number.isFinite(maxTokensRaw) && maxTokensRaw > 0 ? Math.round(maxTokensRaw) : 10000
|
|
6373
|
+
const configuredTimeoutMs = Number(node.llmTimeoutMs)
|
|
6374
|
+
const resolvedTimeoutMs = Number.isFinite(configuredTimeoutMs) && configuredTimeoutMs > 0 ? Math.round(configuredTimeoutMs) : 30000
|
|
6375
|
+
const effectiveTimeoutMs = Math.max(120000, resolvedTimeoutMs)
|
|
6205
6376
|
|
|
6206
6377
|
if (node.llmProvider === 'ollama') {
|
|
6207
6378
|
const url = node.llmBaseUrl || 'http://localhost:11434/api/chat'
|
|
@@ -6216,9 +6387,9 @@ module.exports = function (RED) {
|
|
|
6216
6387
|
temperature: node.llmTemperature
|
|
6217
6388
|
}
|
|
6218
6389
|
}
|
|
6219
|
-
const json = await postJson({ url, body, timeoutMs:
|
|
6390
|
+
const json = await postJson({ url, body, timeoutMs: effectiveTimeoutMs })
|
|
6220
6391
|
const content = json && json.message && typeof json.message.content === 'string' ? json.message.content : safeStringify(json)
|
|
6221
|
-
return { provider: 'ollama', model: body.model, content }
|
|
6392
|
+
return { provider: 'ollama', model: body.model, content, finishReason: String(json && json.done_reason ? json.done_reason : '') }
|
|
6222
6393
|
}
|
|
6223
6394
|
|
|
6224
6395
|
// Default: OpenAI-compatible chat/completions
|
|
@@ -6246,10 +6417,10 @@ module.exports = function (RED) {
|
|
|
6246
6417
|
|
|
6247
6418
|
// Some OpenAI models (and some compatible gateways) require `max_completion_tokens` instead of `max_tokens`.
|
|
6248
6419
|
// Try with `max_tokens` first for broad compatibility, then fallback once if the server rejects it.
|
|
6249
|
-
const bodyWithMaxTokens = Object.assign({ max_tokens:
|
|
6250
|
-
const bodyWithMaxCompletionTokens = Object.assign({ max_completion_tokens:
|
|
6251
|
-
const plainBodyWithMaxTokens = Object.assign({ max_tokens:
|
|
6252
|
-
const plainBodyWithMaxCompletionTokens = Object.assign({ max_completion_tokens:
|
|
6420
|
+
const bodyWithMaxTokens = Object.assign({ max_tokens: resolvedMaxTokens }, schemaBody)
|
|
6421
|
+
const bodyWithMaxCompletionTokens = Object.assign({ max_completion_tokens: resolvedMaxTokens }, schemaBody)
|
|
6422
|
+
const plainBodyWithMaxTokens = Object.assign({ max_tokens: resolvedMaxTokens }, baseBody)
|
|
6423
|
+
const plainBodyWithMaxCompletionTokens = Object.assign({ max_completion_tokens: resolvedMaxTokens }, baseBody)
|
|
6253
6424
|
|
|
6254
6425
|
const isResponseFormatCompatibilityError = (message) => {
|
|
6255
6426
|
const msg = String(message || '')
|
|
@@ -6261,32 +6432,33 @@ module.exports = function (RED) {
|
|
|
6261
6432
|
|
|
6262
6433
|
let json
|
|
6263
6434
|
try {
|
|
6264
|
-
json = await postJson({ url, headers, body: bodyWithMaxTokens, timeoutMs:
|
|
6435
|
+
json = await postJson({ url, headers, body: bodyWithMaxTokens, timeoutMs: effectiveTimeoutMs })
|
|
6265
6436
|
} catch (error) {
|
|
6266
6437
|
const msg = (error && error.message) ? String(error.message) : ''
|
|
6267
6438
|
if (isResponseFormatCompatibilityError(msg)) {
|
|
6268
6439
|
try {
|
|
6269
|
-
json = await postJson({ url, headers, body: plainBodyWithMaxTokens, timeoutMs:
|
|
6440
|
+
json = await postJson({ url, headers, body: plainBodyWithMaxTokens, timeoutMs: effectiveTimeoutMs })
|
|
6270
6441
|
} catch (innerError) {
|
|
6271
6442
|
const innerMsg = (innerError && innerError.message) ? String(innerError.message) : ''
|
|
6272
6443
|
if (innerMsg.includes("Unsupported parameter: 'max_tokens'") || innerMsg.includes('max_completion_tokens')) {
|
|
6273
|
-
json = await postJson({ url, headers, body: plainBodyWithMaxCompletionTokens, timeoutMs:
|
|
6444
|
+
json = await postJson({ url, headers, body: plainBodyWithMaxCompletionTokens, timeoutMs: effectiveTimeoutMs })
|
|
6274
6445
|
} else if (innerMsg.includes("Unsupported parameter: 'max_completion_tokens'")) {
|
|
6275
|
-
json = await postJson({ url, headers, body: plainBodyWithMaxTokens, timeoutMs:
|
|
6446
|
+
json = await postJson({ url, headers, body: plainBodyWithMaxTokens, timeoutMs: effectiveTimeoutMs })
|
|
6276
6447
|
} else {
|
|
6277
6448
|
throw innerError
|
|
6278
6449
|
}
|
|
6279
6450
|
}
|
|
6280
6451
|
} else if (msg.includes("Unsupported parameter: 'max_tokens'") || msg.includes('max_completion_tokens')) {
|
|
6281
|
-
json = await postJson({ url, headers, body: bodyWithMaxCompletionTokens, timeoutMs:
|
|
6452
|
+
json = await postJson({ url, headers, body: bodyWithMaxCompletionTokens, timeoutMs: effectiveTimeoutMs })
|
|
6282
6453
|
} else if (msg.includes("Unsupported parameter: 'max_completion_tokens'")) {
|
|
6283
|
-
json = await postJson({ url, headers, body: bodyWithMaxTokens, timeoutMs:
|
|
6454
|
+
json = await postJson({ url, headers, body: bodyWithMaxTokens, timeoutMs: effectiveTimeoutMs })
|
|
6284
6455
|
} else {
|
|
6285
6456
|
throw error
|
|
6286
6457
|
}
|
|
6287
6458
|
}
|
|
6288
6459
|
const content = extractOpenAICompatText(json) || buildOpenAICompatFallbackText(json)
|
|
6289
|
-
|
|
6460
|
+
const finishReason = String(json && json.choices && json.choices[0] && json.choices[0].finish_reason ? json.choices[0].finish_reason : '')
|
|
6461
|
+
return { provider: 'openai_compat', model: baseBody.model, content, finishReason }
|
|
6290
6462
|
}
|
|
6291
6463
|
|
|
6292
6464
|
node.generateAiTestPlan = async ({ areaId, prompt, language } = {}) => {
|
|
@@ -6351,10 +6523,27 @@ module.exports = function (RED) {
|
|
|
6351
6523
|
const callLLM = async ({ question }) => {
|
|
6352
6524
|
const summary = rebuildCachedSummaryNow()
|
|
6353
6525
|
const userContent = buildLLMPrompt({ question, summary })
|
|
6354
|
-
const
|
|
6526
|
+
const configuredMaxTokens = Math.max(10000, Number(node.llmMaxTokens) || 0)
|
|
6527
|
+
let ret = await callLLMChat({
|
|
6355
6528
|
systemPrompt: node.llmSystemPrompt || '',
|
|
6356
|
-
userContent
|
|
6529
|
+
userContent,
|
|
6530
|
+
maxTokensOverride: configuredMaxTokens
|
|
6357
6531
|
})
|
|
6532
|
+
const finishReason = String(ret && ret.finishReason ? ret.finishReason : '').trim().toLowerCase()
|
|
6533
|
+
const lengthLimited = finishReason === 'length' || isOpenAICompatLengthFallbackText(ret && ret.content)
|
|
6534
|
+
if (lengthLimited) {
|
|
6535
|
+
const compactPrompt = buildLLMPrompt({ question, summary, compact: true })
|
|
6536
|
+
const retryMaxTokens = Math.min(16000, Math.max(10000, Math.round(configuredMaxTokens * 1.25)))
|
|
6537
|
+
try {
|
|
6538
|
+
ret = await callLLMChat({
|
|
6539
|
+
systemPrompt: node.llmSystemPrompt || '',
|
|
6540
|
+
userContent: compactPrompt,
|
|
6541
|
+
maxTokensOverride: retryMaxTokens
|
|
6542
|
+
})
|
|
6543
|
+
} catch (retryError) {
|
|
6544
|
+
// Keep the first provider answer if retry fails.
|
|
6545
|
+
}
|
|
6546
|
+
}
|
|
6358
6547
|
const finalContent = ensureSvgChartResponse({ question, summary, content: ret.content })
|
|
6359
6548
|
return Object.assign({}, ret, { content: finalContent, summary })
|
|
6360
6549
|
}
|