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 +1 -1
- package/nodes/knxUltimateAI.html +2 -2
- package/nodes/knxUltimateAI.js +55 -19
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
# CHANGELOG
|
|
8
8
|
|
|
9
|
-
**Version 4.2.
|
|
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/>
|
package/nodes/knxUltimateAI.html
CHANGED
|
@@ -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:
|
|
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>
|
package/nodes/knxUltimateAI.js
CHANGED
|
@@ -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 === '') ?
|
|
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
|
-
|
|
4405
|
-
|
|
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:
|
|
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:
|
|
6250
|
-
const bodyWithMaxCompletionTokens = Object.assign({ max_completion_tokens:
|
|
6251
|
-
const plainBodyWithMaxTokens = Object.assign({ max_tokens:
|
|
6252
|
-
const plainBodyWithMaxCompletionTokens = Object.assign({ max_completion_tokens:
|
|
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
|
-
|
|
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
|
|
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.
|
|
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/",
|