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** - April 2026<br/>
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/>
@@ -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: 600, 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 },
@@ -391,4 +391,4 @@
391
391
  </div>
392
392
  </div>
393
393
  </div>
394
- </script>
394
+ </script>
@@ -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 fenced = text.match(/```(?:json)?\s*([\s\S]*?)```/i)
258
- const candidate = fenced && fenced[1] ? String(fenced[1]).trim() : text
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
- if (!input) return null
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(input)
276
+ return JSON.parse(relaxed)
263
277
  } catch (error) {
264
278
  return null
265
279
  }
266
280
  }
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
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
- 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
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
- throw new Error('The LLM response did not contain valid JSON')
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(), timeoutMs || 30000)
2144
+ const timer = setTimeout(() => controller.abort(), resolvedTimeoutMs)
2047
2145
  try {
2048
- const res = await fetch(url, {
2049
- method: 'POST',
2050
- headers: Object.assign({ 'content-type': 'application/json' }, headers || {}),
2051
- body: JSON.stringify(body || {}),
2052
- signal: controller.signal
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(), timeoutMs || 20000)
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 === '') ? 600 : Number(config.llmMaxTokens)
3207
- node.llmTimeoutMs = (config.llmTimeoutMs === undefined || config.llmTimeoutMs === '') ? 30000 : Number(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
- flowContext = buildKnxUltimateFlowInventory({ maxNodes: Math.max(0, Number(node.llmMaxFlowNodesInPrompt) || 0) })
4405
- flowContext = truncatePromptText(flowContext, 5000)
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: Math.max(1, Number(node.llmDocsMaxSnippets) || 1),
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: node.llmTimeoutMs })
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: node.llmMaxTokens }, schemaBody)
6250
- const bodyWithMaxCompletionTokens = Object.assign({ max_completion_tokens: node.llmMaxTokens }, schemaBody)
6251
- const plainBodyWithMaxTokens = Object.assign({ max_tokens: node.llmMaxTokens }, baseBody)
6252
- const plainBodyWithMaxCompletionTokens = Object.assign({ max_completion_tokens: node.llmMaxTokens }, baseBody)
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: node.llmTimeoutMs })
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: node.llmTimeoutMs })
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: node.llmTimeoutMs })
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: node.llmTimeoutMs })
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: node.llmTimeoutMs })
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: node.llmTimeoutMs })
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
- return { provider: 'openai_compat', model: baseBody.model, content }
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 ret = await callLLMChat({
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
  }