node-red-contrib-knx-ultimate 4.2.9 → 4.2.10

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,7 @@
6
6
 
7
7
  # CHANGELOG
8
8
 
9
- **Version 4.2.9** - April 2026<br/>
9
+ **Version 4.2.10** - April 2026<br/>
10
10
 
11
11
  - 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
12
  - UI: **KNX AI Web** removed sidebar menu subtitles to keep navigation labels clean and compact.<br/>
@@ -36,7 +36,7 @@
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() },
39
+ llmMaxTokens: { value: 10000, required: false, validate: RED.validators.number() },
40
40
  llmTimeoutMs: { value: 30000, required: false, validate: RED.validators.number() },
41
41
  llmMaxEventsInPrompt: { value: 120, required: false, validate: RED.validators.number() },
42
42
  llmIncludeRaw: { value: false },
@@ -391,4 +391,4 @@
391
391
  </div>
392
392
  </div>
393
393
  </div>
394
- </script>
394
+ </script>
@@ -1624,6 +1624,10 @@ const buildOpenAICompatFallbackText = (json) => {
1624
1624
  return `No assistant text was returned by the provider${usageText}.`
1625
1625
  }
1626
1626
 
1627
+ const isOpenAICompatLengthFallbackText = (value) => {
1628
+ return /^The model stopped because of token limit\b/i.test(String(value || '').trim())
1629
+ }
1630
+
1627
1631
  const DPT_OPTIONS_CACHE = new Map()
1628
1632
 
1629
1633
  const getDptValueOptions = (dptId) => {
@@ -3203,7 +3207,7 @@ module.exports = function (RED) {
3203
3207
  node.llmModel = config.llmModel || 'gpt-4o-mini'
3204
3208
  node.llmSystemPrompt = config.llmSystemPrompt || 'You are a KNX building automation assistant. Analyze KNX bus traffic and provide actionable insights.'
3205
3209
  node.llmTemperature = (config.llmTemperature === undefined || config.llmTemperature === '') ? 0.2 : Number(config.llmTemperature)
3206
- node.llmMaxTokens = (config.llmMaxTokens === undefined || config.llmMaxTokens === '') ? 600 : Number(config.llmMaxTokens)
3210
+ node.llmMaxTokens = (config.llmMaxTokens === undefined || config.llmMaxTokens === '') ? 10000 : Number(config.llmMaxTokens)
3207
3211
  node.llmTimeoutMs = (config.llmTimeoutMs === undefined || config.llmTimeoutMs === '') ? 30000 : Number(config.llmTimeoutMs)
3208
3212
  node.llmMaxEventsInPrompt = (config.llmMaxEventsInPrompt === undefined || config.llmMaxEventsInPrompt === '') ? 120 : Number(config.llmMaxEventsInPrompt)
3209
3213
  node.llmIncludeRaw = config.llmIncludeRaw !== undefined ? coerceBoolean(config.llmIncludeRaw) : false
@@ -4377,51 +4381,61 @@ module.exports = function (RED) {
4377
4381
  }, 90)
4378
4382
  }
4379
4383
 
4380
- const buildLLMPrompt = ({ question, summary }) => {
4384
+ const buildLLMPrompt = ({ question, summary, compact = false } = {}) => {
4385
+ const compactMode = compact === true
4381
4386
  const maxEventsRequested = Math.max(10, Number(node.llmMaxEventsInPrompt) || 120)
4382
- const maxEvents = Math.min(240, maxEventsRequested)
4387
+ const maxEvents = Math.min(compactMode ? 80 : 240, maxEventsRequested)
4383
4388
  const recent = node._history.slice(-maxEvents)
4384
4389
  const wantsSvgChart = shouldGenerateSvgChart(question)
4385
4390
  const areasSnapshot = buildAreasSnapshot({ summary })
4386
4391
  const areasContext = buildAreasPromptContext(areasSnapshot)
4387
4392
  const summaryForPrompt = buildLlmSummarySnapshot(summary)
4388
- const summaryText = truncatePromptText(safeStringify(summaryForPrompt), 10000)
4393
+ const summaryText = truncatePromptText(safeStringify(summaryForPrompt), compactMode ? 4000 : 10000)
4389
4394
  const lines = recent.map(t => {
4390
4395
  const payloadStr = normalizeValueForCompare(t.payload)
4391
4396
  const rawStr = (node.llmIncludeRaw && t.rawHex) ? ` raw=${t.rawHex}` : ''
4392
4397
  const devName = t.devicename ? ` (${t.devicename})` : ''
4393
4398
  return `${new Date(t.ts).toISOString()} ${t.event} ${t.source} -> ${t.destination}${devName} dpt=${t.dpt} payload=${payloadStr}${rawStr}`
4394
4399
  })
4395
- const recentLines = takeLastItemsByCharBudget(lines, 7000)
4400
+ const recentLines = takeLastItemsByCharBudget(lines, compactMode ? 2600 : 7000)
4396
4401
 
4397
4402
  let flowContext = ''
4398
4403
  if (node.llmIncludeFlowContext) {
4404
+ const flowMaxChars = compactMode ? 1400 : 5000
4399
4405
  const ttlMs = 10 * 1000
4400
4406
  const now = nowMs()
4401
4407
  if (node._flowContextCache && node._flowContextCache.text && (now - (node._flowContextCache.at || 0)) < ttlMs) {
4402
4408
  flowContext = node._flowContextCache.text
4403
4409
  } else {
4404
- flowContext = buildKnxUltimateFlowInventory({ maxNodes: Math.max(0, Number(node.llmMaxFlowNodesInPrompt) || 0) })
4405
- flowContext = truncatePromptText(flowContext, 5000)
4410
+ const configuredMaxFlowNodes = Math.max(0, Number(node.llmMaxFlowNodesInPrompt) || 0)
4411
+ const maxFlowNodes = compactMode
4412
+ ? (configuredMaxFlowNodes > 0 ? Math.min(configuredMaxFlowNodes, 30) : 0)
4413
+ : configuredMaxFlowNodes
4414
+ flowContext = buildKnxUltimateFlowInventory({ maxNodes: maxFlowNodes })
4415
+ flowContext = truncatePromptText(flowContext, flowMaxChars)
4406
4416
  node._flowContextCache = { at: now, text: flowContext }
4407
4417
  }
4418
+ flowContext = truncatePromptText(flowContext, flowMaxChars)
4408
4419
  }
4409
4420
 
4410
4421
  let docsContext = ''
4411
4422
  if (node.llmIncludeDocsSnippets) {
4423
+ const docsMaxCharsConfigured = Math.max(500, Math.min(5000, Number(node.llmDocsMaxChars) || 500))
4424
+ const docsMaxChars = compactMode ? Math.min(docsMaxCharsConfigured, 1200) : docsMaxCharsConfigured
4425
+ const docsMaxSnippetsConfigured = Math.max(1, Number(node.llmDocsMaxSnippets) || 1)
4426
+ const docsMaxSnippets = compactMode ? Math.min(docsMaxSnippetsConfigured, 2) : docsMaxSnippetsConfigured
4412
4427
  const ttlMs = 30 * 1000
4413
4428
  const now = nowMs()
4414
4429
  const q = String(question || '').trim()
4415
4430
  if (node._docsContextCache && node._docsContextCache.text && node._docsContextCache.question === q && (now - (node._docsContextCache.at || 0)) < ttlMs) {
4416
- docsContext = node._docsContextCache.text
4431
+ docsContext = truncatePromptText(node._docsContextCache.text, docsMaxChars)
4417
4432
  } else {
4418
4433
  const preferredLangDir = (node.llmDocsLanguage && node.llmDocsLanguage !== 'auto') ? node.llmDocsLanguage : ''
4419
- const docsMaxChars = Math.max(500, Math.min(5000, Number(node.llmDocsMaxChars) || 500))
4420
4434
  docsContext = buildRelevantDocsContext({
4421
4435
  moduleRootDir,
4422
4436
  question: q,
4423
4437
  preferredLangDir,
4424
- maxSnippets: Math.max(1, Number(node.llmDocsMaxSnippets) || 1),
4438
+ maxSnippets: docsMaxSnippets,
4425
4439
  maxChars: docsMaxChars
4426
4440
  })
4427
4441
  docsContext = truncatePromptText(docsContext, docsMaxChars)
@@ -6197,11 +6211,15 @@ module.exports = function (RED) {
6197
6211
  }
6198
6212
  }
6199
6213
 
6200
- const callLLMChat = async ({ systemPrompt, userContent, jsonSchema = null }) => {
6214
+ const callLLMChat = async ({ systemPrompt, userContent, jsonSchema = null, maxTokensOverride = null }) => {
6201
6215
  if (!node.llmEnabled) throw new Error('LLM is disabled in node config')
6202
6216
  if (!node.llmApiKey && node.llmProvider !== 'ollama') {
6203
6217
  throw new Error('Missing API key: paste only the OpenAI key (starts with sk-), without "Bearer"')
6204
6218
  }
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
6205
6223
 
6206
6224
  if (node.llmProvider === 'ollama') {
6207
6225
  const url = node.llmBaseUrl || 'http://localhost:11434/api/chat'
@@ -6218,7 +6236,7 @@ module.exports = function (RED) {
6218
6236
  }
6219
6237
  const json = await postJson({ url, body, timeoutMs: node.llmTimeoutMs })
6220
6238
  const content = json && json.message && typeof json.message.content === 'string' ? json.message.content : safeStringify(json)
6221
- return { provider: 'ollama', model: body.model, content }
6239
+ return { provider: 'ollama', model: body.model, content, finishReason: String(json && json.done_reason ? json.done_reason : '') }
6222
6240
  }
6223
6241
 
6224
6242
  // Default: OpenAI-compatible chat/completions
@@ -6246,10 +6264,10 @@ module.exports = function (RED) {
6246
6264
 
6247
6265
  // Some OpenAI models (and some compatible gateways) require `max_completion_tokens` instead of `max_tokens`.
6248
6266
  // 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)
6267
+ const bodyWithMaxTokens = Object.assign({ max_tokens: resolvedMaxTokens }, schemaBody)
6268
+ const bodyWithMaxCompletionTokens = Object.assign({ max_completion_tokens: resolvedMaxTokens }, schemaBody)
6269
+ const plainBodyWithMaxTokens = Object.assign({ max_tokens: resolvedMaxTokens }, baseBody)
6270
+ const plainBodyWithMaxCompletionTokens = Object.assign({ max_completion_tokens: resolvedMaxTokens }, baseBody)
6253
6271
 
6254
6272
  const isResponseFormatCompatibilityError = (message) => {
6255
6273
  const msg = String(message || '')
@@ -6286,7 +6304,8 @@ module.exports = function (RED) {
6286
6304
  }
6287
6305
  }
6288
6306
  const content = extractOpenAICompatText(json) || buildOpenAICompatFallbackText(json)
6289
- return { provider: 'openai_compat', model: baseBody.model, content }
6307
+ const finishReason = String(json && json.choices && json.choices[0] && json.choices[0].finish_reason ? json.choices[0].finish_reason : '')
6308
+ return { provider: 'openai_compat', model: baseBody.model, content, finishReason }
6290
6309
  }
6291
6310
 
6292
6311
  node.generateAiTestPlan = async ({ areaId, prompt, language } = {}) => {
@@ -6351,10 +6370,27 @@ module.exports = function (RED) {
6351
6370
  const callLLM = async ({ question }) => {
6352
6371
  const summary = rebuildCachedSummaryNow()
6353
6372
  const userContent = buildLLMPrompt({ question, summary })
6354
- const ret = await callLLMChat({
6373
+ const configuredMaxTokens = Math.max(10000, Number(node.llmMaxTokens) || 0)
6374
+ let ret = await callLLMChat({
6355
6375
  systemPrompt: node.llmSystemPrompt || '',
6356
- userContent
6376
+ userContent,
6377
+ maxTokensOverride: configuredMaxTokens
6357
6378
  })
6379
+ const finishReason = String(ret && ret.finishReason ? ret.finishReason : '').trim().toLowerCase()
6380
+ const lengthLimited = finishReason === 'length' || isOpenAICompatLengthFallbackText(ret && ret.content)
6381
+ if (lengthLimited) {
6382
+ const compactPrompt = buildLLMPrompt({ question, summary, compact: true })
6383
+ const retryMaxTokens = Math.min(16000, Math.max(10000, Math.round(configuredMaxTokens * 1.25)))
6384
+ try {
6385
+ ret = await callLLMChat({
6386
+ systemPrompt: node.llmSystemPrompt || '',
6387
+ userContent: compactPrompt,
6388
+ maxTokensOverride: retryMaxTokens
6389
+ })
6390
+ } catch (retryError) {
6391
+ // Keep the first provider answer if retry fails.
6392
+ }
6393
+ }
6358
6394
  const finalContent = ensureSvgChartResponse({ question, summary, content: ret.content })
6359
6395
  return Object.assign({}, ret, { content: finalContent, summary })
6360
6396
  }
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "engines": {
4
4
  "node": ">=20.18.1"
5
5
  },
6
- "version": "4.2.9",
6
+ "version": "4.2.10",
7
7
  "description": "Control your KNX and KNX Secure intallation via Node-Red! A bunch of KNX nodes, with integrated Philips HUE control, ETS group address importer, and KNX routing between interfaces. Easy to use and highly configurable.",
8
8
  "files": [
9
9
  "nodes/",