node-red-contrib-knx-ultimate 6.3.19 → 6.3.22
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 +15 -0
- package/nodes/knxUltimateAI.html +85 -12
- package/nodes/knxUltimateAI.js +453 -146
- package/nodes/locales/de/knxUltimateAI.html +7 -3
- package/nodes/locales/de/knxUltimateAI.json +17 -3
- package/nodes/locales/en/knxUltimateAI.html +7 -3
- package/nodes/locales/en/knxUltimateAI.json +17 -3
- package/nodes/locales/es/knxUltimateAI.html +7 -3
- package/nodes/locales/es/knxUltimateAI.json +17 -3
- package/nodes/locales/fr/knxUltimateAI.html +7 -3
- package/nodes/locales/fr/knxUltimateAI.json +17 -3
- package/nodes/locales/it/knxUltimateAI.html +7 -3
- package/nodes/locales/it/knxUltimateAI.json +17 -3
- package/nodes/locales/zh-CN/knxUltimateAI.html +7 -3
- package/nodes/locales/zh-CN/knxUltimateAI.json +17 -3
- package/nodes/utils/knxAiEventHistory.js +246 -0
- package/package.json +1 -1
package/nodes/knxUltimateAI.js
CHANGED
|
@@ -46,6 +46,13 @@ const {
|
|
|
46
46
|
normalizeSearchText,
|
|
47
47
|
resolveKnxAiCamera
|
|
48
48
|
} = require('./utils/knxAiCamera')
|
|
49
|
+
const {
|
|
50
|
+
KNX_AI_ADAPTER_HISTORY_MIN_HOURS,
|
|
51
|
+
buildKnxAiHistoryEventKey,
|
|
52
|
+
createKnxAiHistoryAccumulator,
|
|
53
|
+
formatKnxAiAdapterHistoryEventForPrompt,
|
|
54
|
+
normalizeKnxAiAdapterHistoryEvent
|
|
55
|
+
} = require('./utils/knxAiEventHistory')
|
|
49
56
|
let googleTranslateTTS = null
|
|
50
57
|
try {
|
|
51
58
|
googleTranslateTTS = require('google-translate-tts')
|
|
@@ -71,7 +78,10 @@ const KNX_AI_CLOUD_LLM_TIMEOUT_MIN_MS = 120000
|
|
|
71
78
|
const KNX_AI_LOCAL_LLM_TIMEOUT_MIN_MS = 10 * 60 * 1000
|
|
72
79
|
const KNX_AI_MINIMAL_CONTEXT_MAX_TOKENS = 16 * 1024
|
|
73
80
|
const KNX_AI_COMPACT_CONTEXT_MAX_TOKENS = 64 * 1024
|
|
81
|
+
const KNX_AI_LMSTUDIO_PROMPT_CONTEXT_MAX_TOKENS = 16 * 1024
|
|
82
|
+
const KNX_AI_OLLAMA_CONTEXT_MAX_TOKENS = 16 * 1024
|
|
74
83
|
const KNX_AI_ROUTINE_FEEDBACK_TIMEOUT_MS = 4000
|
|
84
|
+
const KNX_AI_ADAPTER_HISTORY_RETENTION_DAYS = Math.max(1, KNX_AI_TRAFFIC_DEFAULTS.historyStoreRetentionDays)
|
|
75
85
|
|
|
76
86
|
const resolveKnxAiLlmTimeoutMs = ({ provider, configuredTimeoutMs } = {}) => {
|
|
77
87
|
const configured = Number(configuredTimeoutMs)
|
|
@@ -83,13 +93,77 @@ const resolveKnxAiLlmTimeoutMs = ({ provider, configuredTimeoutMs } = {}) => {
|
|
|
83
93
|
|
|
84
94
|
const resolveKnxAiPromptContextMode = ({ provider, contextLength } = {}) => {
|
|
85
95
|
if (provider !== 'ollama' && provider !== 'lmstudio') return 'full'
|
|
86
|
-
const
|
|
96
|
+
const reportedTokens = Math.max(0, Number(contextLength) || 0)
|
|
97
|
+
// Local providers may advertise a 131K model capability even when that is a
|
|
98
|
+
// poor operational choice. Never let the advertised maximum promote KNX AI
|
|
99
|
+
// to the huge "full" prompt. The 16K semantic view retains every action type
|
|
100
|
+
// while selecting only question-relevant KNX/history/project context.
|
|
101
|
+
const localPromptCap = provider === 'lmstudio'
|
|
102
|
+
? KNX_AI_LMSTUDIO_PROMPT_CONTEXT_MAX_TOKENS
|
|
103
|
+
: KNX_AI_OLLAMA_CONTEXT_MAX_TOKENS
|
|
104
|
+
const tokens = Math.min(reportedTokens || localPromptCap, localPromptCap)
|
|
87
105
|
if (!tokens) return 'full'
|
|
88
106
|
if (tokens <= KNX_AI_MINIMAL_CONTEXT_MAX_TOKENS) return 'minimal'
|
|
89
107
|
if (tokens <= KNX_AI_COMPACT_CONTEXT_MAX_TOKENS) return 'compact'
|
|
90
108
|
return 'full'
|
|
91
109
|
}
|
|
92
110
|
|
|
111
|
+
const resolveKnxAiOperationalContextLimit = ({ provider, contextLength } = {}) => {
|
|
112
|
+
const normalizedProvider = String(provider || '').trim().toLowerCase()
|
|
113
|
+
const localPromptCap = normalizedProvider === 'lmstudio'
|
|
114
|
+
? KNX_AI_LMSTUDIO_PROMPT_CONTEXT_MAX_TOKENS
|
|
115
|
+
: normalizedProvider === 'ollama'
|
|
116
|
+
? KNX_AI_OLLAMA_CONTEXT_MAX_TOKENS
|
|
117
|
+
: 0
|
|
118
|
+
if (!localPromptCap) {
|
|
119
|
+
return {
|
|
120
|
+
provider: normalizedProvider,
|
|
121
|
+
tokens: 0,
|
|
122
|
+
mode: 'provider-managed'
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
const activeContextLength = Math.max(0, Number(contextLength) || 0)
|
|
126
|
+
return {
|
|
127
|
+
provider: normalizedProvider,
|
|
128
|
+
tokens: Math.min(activeContextLength || localPromptCap, localPromptCap),
|
|
129
|
+
mode: 'fixed'
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const measureKnxAiPromptContext = ({ body, provider, model } = {}) => {
|
|
134
|
+
const requestBody = body && typeof body === 'object' ? body : {}
|
|
135
|
+
const textParts = []
|
|
136
|
+
let imageCount = 0
|
|
137
|
+
const appendContent = (content) => {
|
|
138
|
+
if (typeof content === 'string') {
|
|
139
|
+
textParts.push(content)
|
|
140
|
+
return
|
|
141
|
+
}
|
|
142
|
+
if (!Array.isArray(content)) return
|
|
143
|
+
content.forEach(part => {
|
|
144
|
+
if (!part || typeof part !== 'object') return
|
|
145
|
+
if (part.type === 'text' && typeof part.text === 'string') textParts.push(part.text)
|
|
146
|
+
if (part.type === 'image' || part.type === 'image_url') imageCount += 1
|
|
147
|
+
})
|
|
148
|
+
}
|
|
149
|
+
appendContent(requestBody.system)
|
|
150
|
+
;(Array.isArray(requestBody.messages) ? requestBody.messages : []).forEach(message => {
|
|
151
|
+
if (!message || typeof message !== 'object') return
|
|
152
|
+
appendContent(message.content)
|
|
153
|
+
if (Array.isArray(message.images)) imageCount += message.images.length
|
|
154
|
+
})
|
|
155
|
+
const promptText = textParts.join('\n')
|
|
156
|
+
const bytes = Buffer.byteLength(promptText, 'utf8')
|
|
157
|
+
return {
|
|
158
|
+
provider: String(provider || '').trim().toLowerCase(),
|
|
159
|
+
model: String(model || requestBody.model || '').trim(),
|
|
160
|
+
bytes,
|
|
161
|
+
characters: promptText.length,
|
|
162
|
+
estimatedInputTokens: bytes > 0 ? Math.max(1, Math.ceil(bytes / 4)) : 0,
|
|
163
|
+
imageCount
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
93
167
|
const selectKnxAiCatalogForPrompt = ({ catalog, question, mode = 'full' } = {}) => {
|
|
94
168
|
const source = Array.isArray(catalog) ? catalog : []
|
|
95
169
|
if (mode === 'full') return source.slice(0, 600)
|
|
@@ -324,6 +398,8 @@ const summarizeKnxAiChatContext = ({ node, nodeId, redUserDir } = {}) => {
|
|
|
324
398
|
const configDir = path.join(knxAiDir, 'config')
|
|
325
399
|
const telegramArchiveRoot = path.join(knxAiDir, 'history')
|
|
326
400
|
const telegramNodeDir = safeNodeId ? path.join(telegramArchiveRoot, safeNodeId) : ''
|
|
401
|
+
const adapterArchiveRoot = path.join(knxAiDir, 'adapter-history')
|
|
402
|
+
const adapterNodeDir = safeNodeId ? path.join(adapterArchiveRoot, safeNodeId) : ''
|
|
327
403
|
|
|
328
404
|
const files = [
|
|
329
405
|
{
|
|
@@ -346,11 +422,20 @@ const summarizeKnxAiChatContext = ({ node, nodeId, redUserDir } = {}) => {
|
|
|
346
422
|
}
|
|
347
423
|
|
|
348
424
|
return {
|
|
349
|
-
|
|
425
|
+
contextLimit: resolveKnxAiOperationalContextLimit({
|
|
426
|
+
provider: node && node.llmProvider,
|
|
427
|
+
contextLength: node && node.llmContextLength
|
|
428
|
+
}),
|
|
429
|
+
lastPromptUsage: node && node._lastChatPromptUsage
|
|
430
|
+
? Object.assign({}, node._lastChatPromptUsage)
|
|
431
|
+
: null,
|
|
432
|
+
sources: ['knxTraffic', 'adapterHistory', 'etsProject', 'memoryEducation', 'cameras', 'ttsUltimate'],
|
|
350
433
|
files: files.map(item => Object.assign({}, item, { exists: fs.existsSync(item.path) })),
|
|
351
434
|
telegramDirectories: [
|
|
352
435
|
{ id: 'archiveRoot', path: telegramArchiveRoot, exists: fs.existsSync(telegramArchiveRoot) },
|
|
353
|
-
...(telegramNodeDir ? [{ id: 'nodeArchive', path: telegramNodeDir, exists: fs.existsSync(telegramNodeDir) }] : [])
|
|
436
|
+
...(telegramNodeDir ? [{ id: 'nodeArchive', path: telegramNodeDir, exists: fs.existsSync(telegramNodeDir) }] : []),
|
|
437
|
+
{ id: 'adapterArchiveRoot', path: adapterArchiveRoot, exists: fs.existsSync(adapterArchiveRoot) },
|
|
438
|
+
...(adapterNodeDir ? [{ id: 'adapterNodeArchive', path: adapterNodeDir, exists: fs.existsSync(adapterNodeDir) }] : [])
|
|
354
439
|
],
|
|
355
440
|
telegramFilePattern: 'YYYY-MM-DD.jsonl'
|
|
356
441
|
}
|
|
@@ -1819,7 +1904,26 @@ const coerceKnxAiCommandPayload = (value, { dpt } = {}) => {
|
|
|
1819
1904
|
const resolveKnxAiOperationEvent = (candidate) => {
|
|
1820
1905
|
const item = candidate && typeof candidate === 'object' ? candidate : {}
|
|
1821
1906
|
const raw = String(item.event || item.operation || item.action || '').trim().toLowerCase()
|
|
1822
|
-
|
|
1907
|
+
const normalized = raw.replace(/[\s-]+/g, '_')
|
|
1908
|
+
const compact = normalized.replace(/[^a-z]/g, '')
|
|
1909
|
+
const readNames = new Set([
|
|
1910
|
+
'groupvalue_read', 'groupvalue_response', 'read', 'query', 'get',
|
|
1911
|
+
'get_state', 'read_state', 'read_status', 'request_status', 'status'
|
|
1912
|
+
])
|
|
1913
|
+
if (readNames.has(normalized) || ['groupvalueread', 'groupvalueresponse', 'getstate', 'readstate', 'readstatus', 'requeststatus'].includes(compact)) {
|
|
1914
|
+
return 'GroupValue_Read'
|
|
1915
|
+
}
|
|
1916
|
+
const writeNames = new Set(['groupvalue_write', 'write', 'set', 'set_state', 'command'])
|
|
1917
|
+
if (writeNames.has(normalized) || ['groupvaluewrite', 'setstate'].includes(compact)) return 'GroupValue_Write'
|
|
1918
|
+
|
|
1919
|
+
// Small local models sometimes omit the operation discriminator on a state
|
|
1920
|
+
// query even though they correctly return exact ETS destinations. An item
|
|
1921
|
+
// without a payload cannot be an actuator write, so treat it as a safe read.
|
|
1922
|
+
// Legacy write proposals that contain a payload remain writes and still pass
|
|
1923
|
+
// through command-role, DPT, payload and confirmation validation.
|
|
1924
|
+
const hasPayload = Object.prototype.hasOwnProperty.call(item, 'payload') || Object.prototype.hasOwnProperty.call(item, 'value')
|
|
1925
|
+
const payload = Object.prototype.hasOwnProperty.call(item, 'payload') ? item.payload : item.value
|
|
1926
|
+
if (!hasPayload || payload === null || payload === undefined) return 'GroupValue_Read'
|
|
1823
1927
|
return 'GroupValue_Write'
|
|
1824
1928
|
}
|
|
1825
1929
|
|
|
@@ -2505,7 +2609,18 @@ const parseQuestionTimeRange = (question, nowTs = Date.now()) => {
|
|
|
2505
2609
|
return { fromTs: yesterdayStart, toTs: yesterdayEnd, label: 'yesterday', explicit: true }
|
|
2506
2610
|
}
|
|
2507
2611
|
|
|
2508
|
-
const
|
|
2612
|
+
const lastHoursMatch = text.match(/\b(?:last|ultime|ultimi|letzte[n]?|derni[eè]res?|[uú]ltimas?)\s+(\d{1,3})\s+(?:hour|hours|ora|ore|stunde|stunden|heure|heures|hora|horas)\b/)
|
|
2613
|
+
if (lastHoursMatch) {
|
|
2614
|
+
const hours = Math.max(1, Number(lastHoursMatch[1] || 1))
|
|
2615
|
+
return {
|
|
2616
|
+
fromTs: nowTs - (hours * 60 * 60 * 1000),
|
|
2617
|
+
toTs: nowTs,
|
|
2618
|
+
label: `last ${hours} hours`,
|
|
2619
|
+
explicit: true
|
|
2620
|
+
}
|
|
2621
|
+
}
|
|
2622
|
+
|
|
2623
|
+
const lastDaysMatch = text.match(/\b(?:last|ultimi|ultime|letzte[n]?|derni[eè]res?|[uú]ltimos?|[uú]ltimas?)\s+(\d{1,3})\s+(?:day|days|giorno|giorni|tag|tage|tagen|jour|jours|d[ií]a|d[ií]as)\b/)
|
|
2509
2624
|
if (lastDaysMatch) {
|
|
2510
2625
|
const days = Math.max(1, Number(lastDaysMatch[1] || 1))
|
|
2511
2626
|
return {
|
|
@@ -4090,12 +4205,11 @@ const findLmStudioModel = ({ catalog, model }) => {
|
|
|
4090
4205
|
}) || null
|
|
4091
4206
|
}
|
|
4092
4207
|
|
|
4093
|
-
const
|
|
4208
|
+
const resolveLmStudioModelContext = async ({
|
|
4094
4209
|
baseUrl,
|
|
4095
4210
|
apiKey,
|
|
4096
4211
|
model,
|
|
4097
|
-
get = getJson
|
|
4098
|
-
post = postJson
|
|
4212
|
+
get = getJson
|
|
4099
4213
|
} = {}) => {
|
|
4100
4214
|
const selectedModel = String(model || '').trim()
|
|
4101
4215
|
if (!selectedModel) throw new Error('No Bionic LM Studio model selected')
|
|
@@ -4109,74 +4223,40 @@ const ensureLmStudioModelMaxContext = async ({
|
|
|
4109
4223
|
model: selectedModel
|
|
4110
4224
|
})
|
|
4111
4225
|
if (!descriptor) throw new Error(`Bionic LM Studio model not found: ${selectedModel}`)
|
|
4112
|
-
const
|
|
4113
|
-
if (!
|
|
4226
|
+
const maxContextLength = Math.max(0, Number(descriptor.maxContextLength) || 0)
|
|
4227
|
+
if (!maxContextLength) {
|
|
4114
4228
|
throw new Error(`Bionic LM Studio did not report max_context_length for model "${descriptor.id}"`)
|
|
4115
4229
|
}
|
|
4116
|
-
|
|
4230
|
+
// A loaded instance reflects the context explicitly chosen in Bionic LM
|
|
4231
|
+
// Studio. Preserve it instead of treating max_context_length (a capability)
|
|
4232
|
+
// as the desired runtime configuration and silently reloading the model.
|
|
4233
|
+
const readyInstance = descriptor.loadedInstances.find(instance => {
|
|
4234
|
+
return instance.id === selectedModel && instance.contextLength > 0
|
|
4235
|
+
}) || descriptor.loadedInstances.find(instance => instance.contextLength > 0)
|
|
4117
4236
|
if (readyInstance) {
|
|
4118
4237
|
return {
|
|
4119
4238
|
model: descriptor.id,
|
|
4120
4239
|
displayName: descriptor.displayName,
|
|
4121
4240
|
instanceId: readyInstance.id,
|
|
4122
|
-
contextLength:
|
|
4123
|
-
maxContextLength
|
|
4241
|
+
contextLength: readyInstance.contextLength,
|
|
4242
|
+
maxContextLength,
|
|
4243
|
+
active: true,
|
|
4124
4244
|
changed: false
|
|
4125
4245
|
}
|
|
4126
4246
|
}
|
|
4127
4247
|
|
|
4128
|
-
|
|
4129
|
-
|
|
4130
|
-
|
|
4131
|
-
|
|
4132
|
-
|
|
4133
|
-
|
|
4134
|
-
|
|
4135
|
-
|
|
4136
|
-
|
|
4137
|
-
|
|
4138
|
-
|
|
4139
|
-
|
|
4140
|
-
try {
|
|
4141
|
-
const loaded = await post({
|
|
4142
|
-
url: loadUrl,
|
|
4143
|
-
headers,
|
|
4144
|
-
body: {
|
|
4145
|
-
model: descriptor.id,
|
|
4146
|
-
context_length: targetContextLength,
|
|
4147
|
-
echo_load_config: true
|
|
4148
|
-
},
|
|
4149
|
-
timeoutMs: KNX_AI_LOCAL_LLM_TIMEOUT_MIN_MS
|
|
4150
|
-
})
|
|
4151
|
-
const appliedContextLength = Math.max(0, Number(loaded && loaded.load_config && loaded.load_config.context_length) || targetContextLength)
|
|
4152
|
-
return {
|
|
4153
|
-
model: descriptor.id,
|
|
4154
|
-
displayName: descriptor.displayName,
|
|
4155
|
-
instanceId: String(loaded && loaded.instance_id || descriptor.id),
|
|
4156
|
-
contextLength: appliedContextLength,
|
|
4157
|
-
maxContextLength: targetContextLength,
|
|
4158
|
-
changed: true
|
|
4159
|
-
}
|
|
4160
|
-
} catch (error) {
|
|
4161
|
-
const previousContextLength = descriptor.loadedInstances.reduce((max, instance) => Math.max(max, instance.contextLength), 0)
|
|
4162
|
-
if (previousContextLength > 0) {
|
|
4163
|
-
try {
|
|
4164
|
-
await post({
|
|
4165
|
-
url: loadUrl,
|
|
4166
|
-
headers,
|
|
4167
|
-
body: {
|
|
4168
|
-
model: descriptor.id,
|
|
4169
|
-
context_length: previousContextLength,
|
|
4170
|
-
echo_load_config: true
|
|
4171
|
-
},
|
|
4172
|
-
timeoutMs: KNX_AI_LOCAL_LLM_TIMEOUT_MIN_MS
|
|
4173
|
-
})
|
|
4174
|
-
} catch (restoreError) { /* best-effort restoration of the previous instance */ }
|
|
4175
|
-
}
|
|
4176
|
-
const detail = String(error && error.message ? error.message : error)
|
|
4177
|
-
const loadError = new Error(`Bionic LM Studio could not load "${descriptor.displayName}" with its maximum context (${targetContextLength} tokens). ${detail}`)
|
|
4178
|
-
if (error && error.status !== undefined) loadError.status = error.status
|
|
4179
|
-
throw loadError
|
|
4248
|
+
// Do not load an inactive model through the management API. Bionic LM
|
|
4249
|
+
// Studio's JIT loader must remain free to apply the user's saved per-model
|
|
4250
|
+
// defaults (including context length) when the first chat request arrives.
|
|
4251
|
+
// Until that happens, build a conservative prompt that fits within 16K.
|
|
4252
|
+
return {
|
|
4253
|
+
model: descriptor.id,
|
|
4254
|
+
displayName: descriptor.displayName,
|
|
4255
|
+
instanceId: '',
|
|
4256
|
+
contextLength: Math.min(maxContextLength, KNX_AI_MINIMAL_CONTEXT_MAX_TOKENS),
|
|
4257
|
+
maxContextLength,
|
|
4258
|
+
active: false,
|
|
4259
|
+
changed: false
|
|
4180
4260
|
}
|
|
4181
4261
|
}
|
|
4182
4262
|
|
|
@@ -4269,7 +4349,7 @@ const resolveOllamaModelMaxContext = async ({ baseUrl, model, post = postJson }
|
|
|
4269
4349
|
return {
|
|
4270
4350
|
model: selectedModel,
|
|
4271
4351
|
maxContextLength,
|
|
4272
|
-
contextLength: maxContextLength
|
|
4352
|
+
contextLength: Math.min(maxContextLength, KNX_AI_OLLAMA_CONTEXT_MAX_TOKENS)
|
|
4273
4353
|
}
|
|
4274
4354
|
}
|
|
4275
4355
|
|
|
@@ -5773,7 +5853,7 @@ module.exports = function (RED) {
|
|
|
5773
5853
|
if (!apiKey && deployedNode && deployedNode.credentials && deployedNode.credentials.llmApiKey) {
|
|
5774
5854
|
apiKey = sanitizeApiKey(deployedNode.credentials.llmApiKey)
|
|
5775
5855
|
}
|
|
5776
|
-
const result = await
|
|
5856
|
+
const result = await resolveLmStudioModelContext({
|
|
5777
5857
|
baseUrl,
|
|
5778
5858
|
apiKey,
|
|
5779
5859
|
model: body.model
|
|
@@ -6148,6 +6228,9 @@ module.exports = function (RED) {
|
|
|
6148
6228
|
node._gaLabelCsvCache = { ref: null, map: {} }
|
|
6149
6229
|
node._busConnectionWatchTimer = null
|
|
6150
6230
|
node._historyDiskLastPruneAt = 0
|
|
6231
|
+
node._historyDiskPending = new Map()
|
|
6232
|
+
node._adapterHistoryDiskLastPruneAt = 0
|
|
6233
|
+
node._adapterHistoryDiskPending = new Map()
|
|
6151
6234
|
node._homeMemory = createEmptyKnxAiHomeMemory()
|
|
6152
6235
|
node._homeMemoryWriteTimer = null
|
|
6153
6236
|
node._homeMemoryPeriodicTimer = null
|
|
@@ -7261,7 +7344,7 @@ module.exports = function (RED) {
|
|
|
7261
7344
|
}, 90)
|
|
7262
7345
|
}
|
|
7263
7346
|
|
|
7264
|
-
const buildLLMPrompt = ({ question, summary, compact = false, languageHint = '' } = {}) => {
|
|
7347
|
+
const buildLLMPrompt = ({ question, summary, compact = false, languageHint = '', includeDocs = true } = {}) => {
|
|
7265
7348
|
const promptMode = compact === 'minimal' ? 'minimal' : compact === true || compact === 'compact' ? 'compact' : 'full'
|
|
7266
7349
|
const compactMode = promptMode !== 'full'
|
|
7267
7350
|
const minimalMode = promptMode === 'minimal'
|
|
@@ -7269,6 +7352,12 @@ module.exports = function (RED) {
|
|
|
7269
7352
|
const maxEvents = Math.min(minimalMode ? 20 : compactMode ? 50 : 240, maxEventsRequested)
|
|
7270
7353
|
const promptEvents = selectTelegramsForPrompt({ question, maxEvents })
|
|
7271
7354
|
const recent = Array.isArray(promptEvents.events) ? promptEvents.events : []
|
|
7355
|
+
const adapterPromptEvents = selectAdapterEventsForPrompt({
|
|
7356
|
+
question,
|
|
7357
|
+
maxEvents: minimalMode ? 12 : compactMode ? 30 : 160,
|
|
7358
|
+
range: promptEvents.range
|
|
7359
|
+
})
|
|
7360
|
+
const recentAdapterEvents = Array.isArray(adapterPromptEvents.events) ? adapterPromptEvents.events : []
|
|
7272
7361
|
const wantsSvgChart = shouldGenerateSvgChart(question)
|
|
7273
7362
|
const wantsFunctionNodeSourceContext = shouldIncludeFunctionNodeSourceContext(question)
|
|
7274
7363
|
const areasSnapshot = buildAreasSnapshot({ summary })
|
|
@@ -7286,7 +7375,14 @@ module.exports = function (RED) {
|
|
|
7286
7375
|
return `${new Date(t.ts).toISOString()} ${t.event} ${t.source} -> ${t.destination}${devName} dpt=${t.dpt} payload=${payloadStr}${rawStr}`
|
|
7287
7376
|
})
|
|
7288
7377
|
const recentLines = takeLastItemsByCharBudget(lines, minimalMode ? 1000 : compactMode ? 2200 : 7000)
|
|
7289
|
-
const archiveScopeLine = `Prompt event source: ${promptEvents.source}. Time range: ${promptEvents.range && promptEvents.range.label ? promptEvents.range.label : 'recent events'}. Events selected: ${recent.length}.`
|
|
7378
|
+
const archiveScopeLine = `Prompt event source: ${promptEvents.source}. Time range: ${promptEvents.range && promptEvents.range.label ? promptEvents.range.label : 'recent events'}${promptEvents.range && promptEvents.range.clampedToRetention ? ` (clamped to ${promptEvents.range.retentionDays} available day(s))` : ''}. Events selected: ${recent.length}.`
|
|
7379
|
+
const knxArchiveSummary = truncatePromptText(safeStringify(promptEvents.summary || {}), minimalMode ? 1200 : compactMode ? 3000 : 9000)
|
|
7380
|
+
const adapterArchiveSummary = truncatePromptText(safeStringify(adapterPromptEvents.summary || {}), minimalMode ? 1000 : compactMode ? 2600 : 8000)
|
|
7381
|
+
const adapterLines = takeLastItemsByCharBudget(
|
|
7382
|
+
recentAdapterEvents.map(formatKnxAiAdapterHistoryEventForPrompt).filter(Boolean),
|
|
7383
|
+
minimalMode ? 700 : compactMode ? 1800 : 6000
|
|
7384
|
+
)
|
|
7385
|
+
const adapterArchiveScopeLine = `Adapter event source: ${adapterPromptEvents.source}. Time range: ${adapterPromptEvents.range && adapterPromptEvents.range.label ? adapterPromptEvents.range.label : 'last 24 hours'}${adapterPromptEvents.range && adapterPromptEvents.range.clampedToRetention ? ` (clamped to ${adapterPromptEvents.range.retentionDays} available day(s))` : ''}. Events selected: ${recentAdapterEvents.length}.`
|
|
7290
7386
|
|
|
7291
7387
|
let flowContext = ''
|
|
7292
7388
|
const flowMaxChars = minimalMode ? 600 : compactMode ? 1200 : 5000
|
|
@@ -7327,7 +7423,7 @@ module.exports = function (RED) {
|
|
|
7327
7423
|
}
|
|
7328
7424
|
|
|
7329
7425
|
let docsContext = ''
|
|
7330
|
-
if (node.llmIncludeDocsSnippets) {
|
|
7426
|
+
if (includeDocs && node.llmIncludeDocsSnippets) {
|
|
7331
7427
|
const docsMaxCharsConfigured = Math.max(500, Math.min(5000, Number(node.llmDocsMaxChars) || 500))
|
|
7332
7428
|
const docsMaxChars = minimalMode
|
|
7333
7429
|
? Math.min(docsMaxCharsConfigured, 500)
|
|
@@ -7388,10 +7484,22 @@ module.exports = function (RED) {
|
|
|
7388
7484
|
wantsSvgChart ? '- Prefer width via viewBox and include labels + legend when useful.' : '',
|
|
7389
7485
|
wantsSvgChart ? '' : '',
|
|
7390
7486
|
archiveScopeLine,
|
|
7487
|
+
'The KNX archive summary below is calculated from every stored telegram in the requested interval. Use its totals for counts; the selected telegrams are only a relevant/recent sample.',
|
|
7488
|
+
'KNX historical archive summary (JSON):',
|
|
7489
|
+
knxArchiveSummary,
|
|
7391
7490
|
'',
|
|
7392
7491
|
'Selected KNX telegrams:',
|
|
7393
7492
|
recentLines.join('\n'),
|
|
7394
7493
|
'',
|
|
7494
|
+
adapterArchiveScopeLine,
|
|
7495
|
+
`Adapter history retention: ${KNX_AI_ADAPTER_HISTORY_RETENTION_DAYS} day(s), with a guaranteed minimum query window of ${KNX_AI_ADAPTER_HISTORY_MIN_HOURS} hours.`,
|
|
7496
|
+
'The adapter archive summary below is calculated from every stored event in the requested interval. Use its totals for counts; the selected events are only a relevant/recent sample.',
|
|
7497
|
+
'Adapter historical archive summary (JSON):',
|
|
7498
|
+
adapterArchiveSummary,
|
|
7499
|
+
'',
|
|
7500
|
+
'Selected adapter events:',
|
|
7501
|
+
adapterLines.length ? adapterLines.join('\n') : '(no stored adapter events in this interval)',
|
|
7502
|
+
'',
|
|
7395
7503
|
'User request:',
|
|
7396
7504
|
question || ''
|
|
7397
7505
|
].join('\n')
|
|
@@ -7445,6 +7553,15 @@ module.exports = function (RED) {
|
|
|
7445
7553
|
|
|
7446
7554
|
const getHistoryArchiveFile = (dayKey) => path.join(getHistoryArchiveDir(), `${String(dayKey || '').trim() || formatArchiveDayKey(Date.now())}.jsonl`)
|
|
7447
7555
|
|
|
7556
|
+
const getAdapterHistoryArchiveDir = () => {
|
|
7557
|
+
const baseDir = (node.serverKNX && node.serverKNX.userDir)
|
|
7558
|
+
? node.serverKNX.userDir
|
|
7559
|
+
: path.join(RED.settings.userDir, 'knxultimatestorage')
|
|
7560
|
+
return path.join(baseDir, 'knxai', 'adapter-history', node.id)
|
|
7561
|
+
}
|
|
7562
|
+
|
|
7563
|
+
const getAdapterHistoryArchiveFile = dayKey => path.join(getAdapterHistoryArchiveDir(), `${String(dayKey || '').trim() || formatArchiveDayKey(Date.now())}.jsonl`)
|
|
7564
|
+
|
|
7448
7565
|
const getHomeMemoryFile = () => {
|
|
7449
7566
|
const baseDir = (node.serverKNX && node.serverKNX.userDir)
|
|
7450
7567
|
? node.serverKNX.userDir
|
|
@@ -7752,7 +7869,7 @@ module.exports = function (RED) {
|
|
|
7752
7869
|
try {
|
|
7753
7870
|
if (!fs.existsSync(dirPath)) return
|
|
7754
7871
|
const entries = fs.readdirSync(dirPath, { withFileTypes: true })
|
|
7755
|
-
const cutoffTs = now - (
|
|
7872
|
+
const cutoffTs = now - (retentionDays * 24 * 60 * 60 * 1000)
|
|
7756
7873
|
const cutoffDayKey = formatArchiveDayKey(cutoffTs)
|
|
7757
7874
|
for (let i = 0; i < entries.length; i++) {
|
|
7758
7875
|
const entry = entries[i]
|
|
@@ -7776,7 +7893,10 @@ module.exports = function (RED) {
|
|
|
7776
7893
|
const dayKey = formatArchiveDayKey(telegram.ts || Date.now())
|
|
7777
7894
|
const filePath = getHistoryArchiveFile(dayKey)
|
|
7778
7895
|
const line = JSON.stringify(telegram) + '\n'
|
|
7896
|
+
const pendingKey = buildKnxAiHistoryEventKey(telegram, 'knx')
|
|
7897
|
+
if (pendingKey) node._historyDiskPending.set(pendingKey, telegram)
|
|
7779
7898
|
fs.appendFile(filePath, line, 'utf8', (error) => {
|
|
7899
|
+
if (pendingKey && node._historyDiskPending.get(pendingKey) === telegram) node._historyDiskPending.delete(pendingKey)
|
|
7780
7900
|
if (error) node.sysLogger?.warn(`KNX AI history append error: ${error.message || error}`)
|
|
7781
7901
|
})
|
|
7782
7902
|
pruneHistoryArchiveFiles()
|
|
@@ -7820,45 +7940,68 @@ module.exports = function (RED) {
|
|
|
7820
7940
|
}
|
|
7821
7941
|
}
|
|
7822
7942
|
|
|
7823
|
-
const
|
|
7824
|
-
|
|
7943
|
+
const loadHistoryQueryFromDisk = ({ fromTs, toTs, limit = 240, question = '' } = {}) => {
|
|
7944
|
+
const emptyAccumulator = () => createKnxAiHistoryAccumulator({ kind: 'knx', question, limit }).finish()
|
|
7945
|
+
if (node.historyStoreToDisk !== true) return emptyAccumulator()
|
|
7825
7946
|
const archiveDir = getHistoryArchiveDir()
|
|
7826
7947
|
try {
|
|
7827
|
-
if (!fs.existsSync(archiveDir)) return []
|
|
7828
7948
|
const from = Number(fromTs || 0)
|
|
7829
7949
|
const to = Number(toTs || 0)
|
|
7830
|
-
if (!Number.isFinite(from) || !Number.isFinite(to) || to < from) return
|
|
7950
|
+
if (!Number.isFinite(from) || !Number.isFinite(to) || to < from) return emptyAccumulator()
|
|
7951
|
+
const accumulator = createKnxAiHistoryAccumulator({ kind: 'knx', question, limit })
|
|
7952
|
+
const pending = node._historyDiskPending instanceof Map ? node._historyDiskPending : new Map()
|
|
7831
7953
|
const dayKeys = collectArchiveDayKeysBetween({ fromTs: from, toTs: to })
|
|
7832
|
-
if (
|
|
7833
|
-
|
|
7834
|
-
|
|
7835
|
-
|
|
7836
|
-
|
|
7837
|
-
|
|
7838
|
-
|
|
7839
|
-
|
|
7840
|
-
|
|
7841
|
-
|
|
7842
|
-
|
|
7843
|
-
|
|
7844
|
-
|
|
7845
|
-
|
|
7846
|
-
|
|
7847
|
-
|
|
7848
|
-
|
|
7849
|
-
|
|
7954
|
+
if (fs.existsSync(archiveDir)) {
|
|
7955
|
+
for (let i = 0; i < dayKeys.length; i++) {
|
|
7956
|
+
const filePath = getHistoryArchiveFile(dayKeys[i])
|
|
7957
|
+
if (!fs.existsSync(filePath)) continue
|
|
7958
|
+
const raw = fs.readFileSync(filePath, 'utf8')
|
|
7959
|
+
if (!raw || String(raw).trim() === '') continue
|
|
7960
|
+
const lines = raw.split(/\r?\n/)
|
|
7961
|
+
for (let j = 0; j < lines.length; j++) {
|
|
7962
|
+
const line = lines[j]
|
|
7963
|
+
if (!line) continue
|
|
7964
|
+
try {
|
|
7965
|
+
const telegram = JSON.parse(line)
|
|
7966
|
+
const ts = Number(telegram && telegram.ts ? telegram.ts : 0)
|
|
7967
|
+
if (!Number.isFinite(ts) || ts < from || ts > to) continue
|
|
7968
|
+
const key = buildKnxAiHistoryEventKey(telegram, 'knx')
|
|
7969
|
+
if (key && pending.has(key)) continue
|
|
7970
|
+
accumulator.add(telegram)
|
|
7971
|
+
} catch (error) {
|
|
7972
|
+
// Ignore malformed archive rows.
|
|
7973
|
+
}
|
|
7850
7974
|
}
|
|
7851
7975
|
}
|
|
7852
7976
|
}
|
|
7853
|
-
|
|
7854
|
-
|
|
7855
|
-
|
|
7977
|
+
pending.forEach(telegram => {
|
|
7978
|
+
const ts = Number(telegram && telegram.ts ? telegram.ts : 0)
|
|
7979
|
+
if (Number.isFinite(ts) && ts >= from && ts <= to) accumulator.add(telegram)
|
|
7980
|
+
})
|
|
7981
|
+
return accumulator.finish()
|
|
7856
7982
|
} catch (error) {
|
|
7857
7983
|
node.sysLogger?.warn(`KNX AI history load slice error: ${error.message || error}`)
|
|
7858
|
-
return
|
|
7984
|
+
return emptyAccumulator()
|
|
7859
7985
|
}
|
|
7860
7986
|
}
|
|
7861
7987
|
|
|
7988
|
+
const clampArchiveRangeToRetention = ({ range, retentionDays }) => {
|
|
7989
|
+
const now = nowMs()
|
|
7990
|
+
const days = Math.max(1, Number(retentionDays) || 1)
|
|
7991
|
+
const earliest = now - (days * 24 * 60 * 60 * 1000)
|
|
7992
|
+
const source = range && typeof range === 'object'
|
|
7993
|
+
? range
|
|
7994
|
+
: { fromTs: now - (24 * 60 * 60 * 1000), toTs: now, label: 'last 24 hours', explicit: false }
|
|
7995
|
+
const fromTs = Math.max(earliest, Number(source.fromTs || earliest))
|
|
7996
|
+
const toTs = Math.min(now, Number(source.toTs || now))
|
|
7997
|
+
return Object.assign({}, source, {
|
|
7998
|
+
fromTs,
|
|
7999
|
+
toTs: Math.max(fromTs, toTs),
|
|
8000
|
+
retentionDays: days,
|
|
8001
|
+
clampedToRetention: Number(source.fromTs || 0) < earliest
|
|
8002
|
+
})
|
|
8003
|
+
}
|
|
8004
|
+
|
|
7862
8005
|
const selectTelegramsForPrompt = ({ question, maxEvents }) => {
|
|
7863
8006
|
const now = nowMs()
|
|
7864
8007
|
const maxItems = Math.max(10, Number(maxEvents) || 120)
|
|
@@ -7866,36 +8009,132 @@ module.exports = function (RED) {
|
|
|
7866
8009
|
const fallbackRange = node.historyStoreToDisk === true
|
|
7867
8010
|
? { fromTs: now - (24 * 60 * 60 * 1000), toTs: now, label: 'last 24 hours', explicit: false }
|
|
7868
8011
|
: { fromTs: now - (Math.max(5, Number(node.historyWindowSec || 5)) * 1000), toTs: now, label: 'memory window', explicit: false }
|
|
7869
|
-
const range =
|
|
8012
|
+
const range = clampArchiveRangeToRetention({
|
|
8013
|
+
range: explicitRange || fallbackRange,
|
|
8014
|
+
retentionDays: node.historyStoreRetentionDays
|
|
8015
|
+
})
|
|
7870
8016
|
|
|
7871
8017
|
let selected = []
|
|
7872
8018
|
let source = 'memory'
|
|
8019
|
+
let archiveSummary = null
|
|
7873
8020
|
if (node.historyStoreToDisk === true) {
|
|
7874
|
-
const
|
|
7875
|
-
|
|
7876
|
-
|
|
7877
|
-
|
|
7878
|
-
if (!telegram || typeof telegram !== 'object') return
|
|
7879
|
-
const key = [
|
|
7880
|
-
Number(telegram.ts || 0),
|
|
7881
|
-
String(telegram.event || ''),
|
|
7882
|
-
String(telegram.source || ''),
|
|
7883
|
-
String(telegram.destination || ''),
|
|
7884
|
-
normalizeValueForCompare(telegram.payload),
|
|
7885
|
-
String(telegram.rawHex || '')
|
|
7886
|
-
].join('|')
|
|
7887
|
-
dedupe.set(key, telegram)
|
|
7888
|
-
})
|
|
7889
|
-
selected = Array.from(dedupe.values()).sort((a, b) => Number(a.ts || 0) - Number(b.ts || 0)).slice(-maxItems)
|
|
7890
|
-
source = 'archive+memory'
|
|
8021
|
+
const query = loadHistoryQueryFromDisk({ fromTs: range.fromTs, toTs: range.toTs, limit: maxItems, question })
|
|
8022
|
+
selected = query.events
|
|
8023
|
+
archiveSummary = query.summary
|
|
8024
|
+
source = 'daily JSONL archive'
|
|
7891
8025
|
} else {
|
|
7892
8026
|
selected = node._history.slice(-maxItems)
|
|
8027
|
+
const accumulator = createKnxAiHistoryAccumulator({ kind: 'knx', question, limit: maxItems })
|
|
8028
|
+
selected.forEach(telegram => accumulator.add(telegram))
|
|
8029
|
+
const memoryQuery = accumulator.finish()
|
|
8030
|
+
selected = memoryQuery.events
|
|
8031
|
+
archiveSummary = memoryQuery.summary
|
|
7893
8032
|
}
|
|
7894
8033
|
|
|
7895
8034
|
return {
|
|
7896
8035
|
events: selected,
|
|
7897
8036
|
source,
|
|
7898
|
-
range
|
|
8037
|
+
range,
|
|
8038
|
+
summary: archiveSummary
|
|
8039
|
+
}
|
|
8040
|
+
}
|
|
8041
|
+
|
|
8042
|
+
const pruneAdapterHistoryArchiveFiles = ({ force = false } = {}) => {
|
|
8043
|
+
const now = nowMs()
|
|
8044
|
+
if (!force && (now - Number(node._adapterHistoryDiskLastPruneAt || 0)) < (60 * 60 * 1000)) return
|
|
8045
|
+
node._adapterHistoryDiskLastPruneAt = now
|
|
8046
|
+
const dirPath = getAdapterHistoryArchiveDir()
|
|
8047
|
+
try {
|
|
8048
|
+
if (!fs.existsSync(dirPath)) return
|
|
8049
|
+
const entries = fs.readdirSync(dirPath, { withFileTypes: true })
|
|
8050
|
+
const retentionDays = Math.max(1, KNX_AI_ADAPTER_HISTORY_RETENTION_DAYS)
|
|
8051
|
+
const cutoffDayKey = formatArchiveDayKey(now - (retentionDays * 24 * 60 * 60 * 1000))
|
|
8052
|
+
entries.forEach(entry => {
|
|
8053
|
+
if (!entry || !entry.isFile()) return
|
|
8054
|
+
const match = String(entry.name || '').match(/^(\d{4}-\d{2}-\d{2})\.jsonl$/)
|
|
8055
|
+
if (!match || match[1] >= cutoffDayKey) return
|
|
8056
|
+
try { fs.unlinkSync(path.join(dirPath, entry.name)) } catch (error) { /* ignore */ }
|
|
8057
|
+
})
|
|
8058
|
+
} catch (error) {
|
|
8059
|
+
node.sysLogger?.warn(`KNX AI adapter history prune error: ${error.message || error}`)
|
|
8060
|
+
}
|
|
8061
|
+
}
|
|
8062
|
+
|
|
8063
|
+
const persistAdapterEventToDisk = ({ event, adapter, provider } = {}) => {
|
|
8064
|
+
const normalized = normalizeKnxAiAdapterHistoryEvent({ event, adapter, provider, nowTs: nowMs() })
|
|
8065
|
+
if (!normalized) return null
|
|
8066
|
+
const archiveDir = getAdapterHistoryArchiveDir()
|
|
8067
|
+
if (!ensureDirectorySync(archiveDir)) return normalized
|
|
8068
|
+
const filePath = getAdapterHistoryArchiveFile(formatArchiveDayKey(normalized.ts))
|
|
8069
|
+
const pendingKey = buildKnxAiHistoryEventKey(normalized, 'adapter')
|
|
8070
|
+
if (pendingKey) node._adapterHistoryDiskPending.set(pendingKey, normalized)
|
|
8071
|
+
fs.appendFile(filePath, `${JSON.stringify(normalized)}\n`, 'utf8', error => {
|
|
8072
|
+
if (pendingKey && node._adapterHistoryDiskPending.get(pendingKey) === normalized) node._adapterHistoryDiskPending.delete(pendingKey)
|
|
8073
|
+
if (error) node.sysLogger?.warn(`KNX AI adapter history append error: ${error.message || error}`)
|
|
8074
|
+
})
|
|
8075
|
+
pruneAdapterHistoryArchiveFiles()
|
|
8076
|
+
return normalized
|
|
8077
|
+
}
|
|
8078
|
+
|
|
8079
|
+
const loadAdapterHistoryQueryFromDisk = ({ fromTs, toTs, limit = 160, question = '' } = {}) => {
|
|
8080
|
+
const accumulator = createKnxAiHistoryAccumulator({ kind: 'adapter', question, limit })
|
|
8081
|
+
const from = Number(fromTs || 0)
|
|
8082
|
+
const to = Number(toTs || 0)
|
|
8083
|
+
if (!Number.isFinite(from) || !Number.isFinite(to) || to < from) return accumulator.finish()
|
|
8084
|
+
const pending = node._adapterHistoryDiskPending instanceof Map ? node._adapterHistoryDiskPending : new Map()
|
|
8085
|
+
try {
|
|
8086
|
+
const archiveDir = getAdapterHistoryArchiveDir()
|
|
8087
|
+
const dayKeys = collectArchiveDayKeysBetween({ fromTs: from, toTs: to })
|
|
8088
|
+
if (fs.existsSync(archiveDir)) {
|
|
8089
|
+
dayKeys.forEach(dayKey => {
|
|
8090
|
+
const filePath = getAdapterHistoryArchiveFile(dayKey)
|
|
8091
|
+
if (!fs.existsSync(filePath)) return
|
|
8092
|
+
const raw = fs.readFileSync(filePath, 'utf8')
|
|
8093
|
+
if (!raw || String(raw).trim() === '') return
|
|
8094
|
+
raw.split(/\r?\n/).forEach(line => {
|
|
8095
|
+
if (!line) return
|
|
8096
|
+
try {
|
|
8097
|
+
const item = JSON.parse(line)
|
|
8098
|
+
const ts = Number(item && item.ts ? item.ts : 0)
|
|
8099
|
+
if (!Number.isFinite(ts) || ts < from || ts > to) return
|
|
8100
|
+
const key = buildKnxAiHistoryEventKey(item, 'adapter')
|
|
8101
|
+
if (key && pending.has(key)) return
|
|
8102
|
+
accumulator.add(item)
|
|
8103
|
+
} catch (error) { /* ignore malformed archive rows */ }
|
|
8104
|
+
})
|
|
8105
|
+
})
|
|
8106
|
+
}
|
|
8107
|
+
pending.forEach(item => {
|
|
8108
|
+
const ts = Number(item && item.ts ? item.ts : 0)
|
|
8109
|
+
if (Number.isFinite(ts) && ts >= from && ts <= to) accumulator.add(item)
|
|
8110
|
+
})
|
|
8111
|
+
} catch (error) {
|
|
8112
|
+
node.sysLogger?.warn(`KNX AI adapter history load error: ${error.message || error}`)
|
|
8113
|
+
}
|
|
8114
|
+
return accumulator.finish()
|
|
8115
|
+
}
|
|
8116
|
+
|
|
8117
|
+
const selectAdapterEventsForPrompt = ({ question, maxEvents, range } = {}) => {
|
|
8118
|
+
const effectiveRange = clampArchiveRangeToRetention({
|
|
8119
|
+
range: range || parseQuestionTimeRange(question, nowMs()) || {
|
|
8120
|
+
fromTs: nowMs() - (KNX_AI_ADAPTER_HISTORY_MIN_HOURS * 60 * 60 * 1000),
|
|
8121
|
+
toTs: nowMs(),
|
|
8122
|
+
label: `last ${KNX_AI_ADAPTER_HISTORY_MIN_HOURS} hours`,
|
|
8123
|
+
explicit: false
|
|
8124
|
+
},
|
|
8125
|
+
retentionDays: KNX_AI_ADAPTER_HISTORY_RETENTION_DAYS
|
|
8126
|
+
})
|
|
8127
|
+
const query = loadAdapterHistoryQueryFromDisk({
|
|
8128
|
+
fromTs: effectiveRange.fromTs,
|
|
8129
|
+
toTs: effectiveRange.toTs,
|
|
8130
|
+
limit: Math.max(1, Number(maxEvents) || 160),
|
|
8131
|
+
question
|
|
8132
|
+
})
|
|
8133
|
+
return {
|
|
8134
|
+
events: query.events,
|
|
8135
|
+
summary: query.summary,
|
|
8136
|
+
source: 'daily JSONL adapter archive',
|
|
8137
|
+
range: effectiveRange
|
|
7899
8138
|
}
|
|
7900
8139
|
}
|
|
7901
8140
|
|
|
@@ -9636,14 +9875,19 @@ module.exports = function (RED) {
|
|
|
9636
9875
|
if (node._lmStudioContextPromise && node._lmStudioContextPromise.key === key) {
|
|
9637
9876
|
return node._lmStudioContextPromise.promise
|
|
9638
9877
|
}
|
|
9639
|
-
const promise =
|
|
9878
|
+
const promise = resolveLmStudioModelContext({
|
|
9640
9879
|
baseUrl: node.llmBaseUrl,
|
|
9641
9880
|
apiKey: node.llmApiKey,
|
|
9642
9881
|
model: node.llmModel
|
|
9643
9882
|
}).then(result => {
|
|
9644
9883
|
node.llmContextLength = Math.max(0, Number(result && result.contextLength) || node.llmContextLength)
|
|
9645
|
-
|
|
9646
|
-
|
|
9884
|
+
if (result && result.active === true) {
|
|
9885
|
+
node._lmStudioContextReadyKey = `${node.llmBaseUrl}\u0000${node.llmModel}\u0000${node.llmContextLength}`
|
|
9886
|
+
node._lmStudioContextReadyResult = result
|
|
9887
|
+
} else {
|
|
9888
|
+
node._lmStudioContextReadyKey = ''
|
|
9889
|
+
node._lmStudioContextReadyResult = null
|
|
9890
|
+
}
|
|
9647
9891
|
return result
|
|
9648
9892
|
}).finally(() => {
|
|
9649
9893
|
if (node._lmStudioContextPromise && node._lmStudioContextPromise.key === key) {
|
|
@@ -9660,7 +9904,11 @@ module.exports = function (RED) {
|
|
|
9660
9904
|
const model = node.llmModel || 'llama3.1'
|
|
9661
9905
|
const key = `${url}\u0000${model}`
|
|
9662
9906
|
if (!force && node._ollamaContextReadyKey === key && node.llmContextLength > 0) {
|
|
9663
|
-
return {
|
|
9907
|
+
return {
|
|
9908
|
+
model,
|
|
9909
|
+
maxContextLength: Math.max(node.llmContextLength, Number(node._ollamaModelMaxContextLength) || 0),
|
|
9910
|
+
contextLength: node.llmContextLength
|
|
9911
|
+
}
|
|
9664
9912
|
}
|
|
9665
9913
|
const resolveContext = () => resolveOllamaModelMaxContext({ baseUrl: url, model })
|
|
9666
9914
|
let result
|
|
@@ -9671,7 +9919,8 @@ module.exports = function (RED) {
|
|
|
9671
9919
|
await ensureOllamaServerRunning({ baseUrl: url, autoStart: true, timeoutMs: 22000 })
|
|
9672
9920
|
result = await resolveContext()
|
|
9673
9921
|
}
|
|
9674
|
-
node.
|
|
9922
|
+
node._ollamaModelMaxContextLength = Math.max(0, Number(result && result.maxContextLength) || 0)
|
|
9923
|
+
node.llmContextLength = Math.max(0, Number(result && result.contextLength) || 0)
|
|
9675
9924
|
node._ollamaContextReadyKey = key
|
|
9676
9925
|
return result
|
|
9677
9926
|
}
|
|
@@ -9682,7 +9931,24 @@ module.exports = function (RED) {
|
|
|
9682
9931
|
return null
|
|
9683
9932
|
}
|
|
9684
9933
|
|
|
9685
|
-
const
|
|
9934
|
+
const recordChatPromptUsage = ({ body, provider, model } = {}) => {
|
|
9935
|
+
const sequence = Math.max(0, Number(node._chatPromptUsageSequence) || 0) + 1
|
|
9936
|
+
node._chatPromptUsageSequence = sequence
|
|
9937
|
+
node._lastChatPromptUsageSequence = sequence
|
|
9938
|
+
node._lastChatPromptUsage = Object.assign(
|
|
9939
|
+
{ at: new Date().toISOString(), exactInputTokens: 0 },
|
|
9940
|
+
measureKnxAiPromptContext({ body, provider, model })
|
|
9941
|
+
)
|
|
9942
|
+
return sequence
|
|
9943
|
+
}
|
|
9944
|
+
|
|
9945
|
+
const recordExactChatPromptTokens = ({ sequence, inputTokens } = {}) => {
|
|
9946
|
+
const tokens = Math.max(0, Number(inputTokens) || 0)
|
|
9947
|
+
if (!tokens || sequence !== node._lastChatPromptUsageSequence || !node._lastChatPromptUsage) return
|
|
9948
|
+
node._lastChatPromptUsage = Object.assign({}, node._lastChatPromptUsage, { exactInputTokens: Math.round(tokens) })
|
|
9949
|
+
}
|
|
9950
|
+
|
|
9951
|
+
const callLLMChat = async ({ systemPrompt, userContent, images = [], jsonSchema = null, maxTokensOverride = null, trackChatContextUsage = false }) => {
|
|
9686
9952
|
if (!node.llmEnabled) throw new Error('LLM is disabled in node config')
|
|
9687
9953
|
if (node.llmProvider === 'lmstudio' && !String(node.llmModel || '').trim()) {
|
|
9688
9954
|
throw new Error('No Bionic LM Studio model selected. Start the LM Studio API server, refresh the model list and select a model.')
|
|
@@ -9728,10 +9994,16 @@ module.exports = function (RED) {
|
|
|
9728
9994
|
)
|
|
9729
9995
|
}
|
|
9730
9996
|
let json
|
|
9997
|
+
let promptUsageSequence = 0
|
|
9731
9998
|
const requestOllamaChat = requestBody => postLocalLlmWithContextFallbacks({
|
|
9732
9999
|
body: requestBody,
|
|
9733
10000
|
enabled: true,
|
|
9734
|
-
request: compactBody =>
|
|
10001
|
+
request: compactBody => {
|
|
10002
|
+
if (trackChatContextUsage) {
|
|
10003
|
+
promptUsageSequence = recordChatPromptUsage({ body: compactBody, provider: 'ollama', model: compactBody.model })
|
|
10004
|
+
}
|
|
10005
|
+
return postJson({ url, body: compactBody, timeoutMs: effectiveTimeoutMs })
|
|
10006
|
+
}
|
|
9735
10007
|
})
|
|
9736
10008
|
try {
|
|
9737
10009
|
json = await requestOllamaChat(body)
|
|
@@ -9745,6 +10017,7 @@ module.exports = function (RED) {
|
|
|
9745
10017
|
throw decorateOllamaConnectionError({ error, url, action: 'chat with the model' })
|
|
9746
10018
|
}
|
|
9747
10019
|
}
|
|
10020
|
+
recordExactChatPromptTokens({ sequence: promptUsageSequence, inputTokens: json && json.prompt_eval_count })
|
|
9748
10021
|
const content = json && json.message && typeof json.message.content === 'string' ? json.message.content : safeStringify(json)
|
|
9749
10022
|
return { provider: 'ollama', model: body.model, content, finishReason: String(json && json.done_reason ? json.done_reason : '') }
|
|
9750
10023
|
}
|
|
@@ -9775,7 +10048,11 @@ module.exports = function (RED) {
|
|
|
9775
10048
|
}]
|
|
9776
10049
|
}
|
|
9777
10050
|
if (sys) body.system = sys
|
|
10051
|
+
const promptUsageSequence = trackChatContextUsage
|
|
10052
|
+
? recordChatPromptUsage({ body, provider: 'anthropic', model: body.model })
|
|
10053
|
+
: 0
|
|
9778
10054
|
const json = await postJson({ url, headers, body, timeoutMs: effectiveTimeoutMs })
|
|
10055
|
+
recordExactChatPromptTokens({ sequence: promptUsageSequence, inputTokens: json && json.usage && json.usage.input_tokens })
|
|
9779
10056
|
const content = extractAnthropicText(json)
|
|
9780
10057
|
const finishReason = String(json && json.stop_reason ? json.stop_reason : '')
|
|
9781
10058
|
return { provider: 'anthropic', model: body.model, content, finishReason }
|
|
@@ -9829,17 +10106,27 @@ module.exports = function (RED) {
|
|
|
9829
10106
|
? (localOutputTokenLimit > 0 ? { max_tokens: Math.min(resolvedMaxTokens, localOutputTokenLimit) } : {})
|
|
9830
10107
|
: { max_tokens: resolvedMaxTokens }
|
|
9831
10108
|
let json
|
|
10109
|
+
let promptUsageSequence = 0
|
|
9832
10110
|
try {
|
|
9833
10111
|
json = await postLocalLlmWithContextFallbacks({
|
|
9834
10112
|
body: Object.assign(tokenLimitBody, schemaBody),
|
|
9835
10113
|
enabled: node.llmProvider === 'lmstudio',
|
|
9836
|
-
request: requestBody =>
|
|
9837
|
-
|
|
9838
|
-
|
|
9839
|
-
|
|
9840
|
-
|
|
9841
|
-
|
|
9842
|
-
|
|
10114
|
+
request: requestBody => {
|
|
10115
|
+
if (trackChatContextUsage) {
|
|
10116
|
+
promptUsageSequence = recordChatPromptUsage({
|
|
10117
|
+
body: requestBody,
|
|
10118
|
+
provider: node.llmProvider === 'lmstudio' ? 'lmstudio' : 'openai_compat',
|
|
10119
|
+
model: baseBody.model
|
|
10120
|
+
})
|
|
10121
|
+
}
|
|
10122
|
+
return postOpenAiCompatibleChatWithFallbacks({
|
|
10123
|
+
url,
|
|
10124
|
+
headers,
|
|
10125
|
+
body: requestBody,
|
|
10126
|
+
timeoutMs: effectiveTimeoutMs,
|
|
10127
|
+
model: baseBody.model
|
|
10128
|
+
})
|
|
10129
|
+
}
|
|
9843
10130
|
})
|
|
9844
10131
|
} catch (error) {
|
|
9845
10132
|
if (node.llmProvider === 'lmstudio' && isLikelyConnectionFailure(error)) {
|
|
@@ -9849,6 +10136,7 @@ module.exports = function (RED) {
|
|
|
9849
10136
|
}
|
|
9850
10137
|
throw error
|
|
9851
10138
|
}
|
|
10139
|
+
recordExactChatPromptTokens({ sequence: promptUsageSequence, inputTokens: json && json.usage && json.usage.prompt_tokens })
|
|
9852
10140
|
const content = extractOpenAICompatText(json) || buildOpenAICompatFallbackText(json)
|
|
9853
10141
|
const finishReason = String(json && json.choices && json.choices[0] && json.choices[0].finish_reason ? json.choices[0].finish_reason : '')
|
|
9854
10142
|
return { provider: node.llmProvider === 'lmstudio' ? 'lmstudio' : 'openai_compat', model: baseBody.model, content, finishReason }
|
|
@@ -10021,7 +10309,7 @@ module.exports = function (RED) {
|
|
|
10021
10309
|
}
|
|
10022
10310
|
}
|
|
10023
10311
|
|
|
10024
|
-
const callLLM = async ({ question, sessionId = 'default', languageHint = '' }) => {
|
|
10312
|
+
const callLLM = async ({ question, sessionId = 'default', languageHint = '', includeDocs = true }) => {
|
|
10025
10313
|
await ensureSelectedLocalModelContext({ autoStartOllama: true })
|
|
10026
10314
|
const contextMode = resolveKnxAiPromptContextMode({
|
|
10027
10315
|
provider: node.llmProvider,
|
|
@@ -10038,14 +10326,16 @@ module.exports = function (RED) {
|
|
|
10038
10326
|
question,
|
|
10039
10327
|
summary,
|
|
10040
10328
|
compact: contextMode === 'full' ? false : contextMode,
|
|
10041
|
-
languageHint
|
|
10329
|
+
languageHint,
|
|
10330
|
+
includeDocs
|
|
10042
10331
|
})
|
|
10043
10332
|
const userContent = chatContext ? `${chatContext}\n\n${prompt}` : prompt
|
|
10044
10333
|
const configuredMaxTokens = Math.max(10000, Number(node.llmMaxTokens) || 0)
|
|
10045
10334
|
let ret = await callLLMChat({
|
|
10046
10335
|
systemPrompt: node.llmSystemPrompt || '',
|
|
10047
10336
|
userContent,
|
|
10048
|
-
maxTokensOverride: configuredMaxTokens
|
|
10337
|
+
maxTokensOverride: configuredMaxTokens,
|
|
10338
|
+
trackChatContextUsage: includeDocs === false
|
|
10049
10339
|
})
|
|
10050
10340
|
const finishReason = String(ret && ret.finishReason ? ret.finishReason : '').trim().toLowerCase()
|
|
10051
10341
|
const lengthLimited = finishReason === 'length' || isOpenAICompatLengthFallbackText(ret && ret.content)
|
|
@@ -10056,14 +10346,15 @@ module.exports = function (RED) {
|
|
|
10056
10346
|
sessionId,
|
|
10057
10347
|
maxChars: retryMode === 'minimal' ? 600 : 3000
|
|
10058
10348
|
})
|
|
10059
|
-
const compactBasePrompt = buildLLMPrompt({ question, summary, compact: retryMode, languageHint })
|
|
10349
|
+
const compactBasePrompt = buildLLMPrompt({ question, summary, compact: retryMode, languageHint, includeDocs })
|
|
10060
10350
|
const compactPrompt = compactChatContext ? `${compactChatContext}\n\n${compactBasePrompt}` : compactBasePrompt
|
|
10061
10351
|
const retryMaxTokens = Math.min(16000, Math.max(10000, Math.round(configuredMaxTokens * 1.25)))
|
|
10062
10352
|
try {
|
|
10063
10353
|
ret = await callLLMChat({
|
|
10064
10354
|
systemPrompt: node.llmSystemPrompt || '',
|
|
10065
10355
|
userContent: compactPrompt,
|
|
10066
|
-
maxTokensOverride: retryMaxTokens
|
|
10356
|
+
maxTokensOverride: retryMaxTokens,
|
|
10357
|
+
trackChatContextUsage: includeDocs === false
|
|
10067
10358
|
})
|
|
10068
10359
|
} catch (retryError) {
|
|
10069
10360
|
// Keep the first provider answer if retry fails.
|
|
@@ -10135,14 +10426,14 @@ module.exports = function (RED) {
|
|
|
10135
10426
|
if (contextMode !== 'full') {
|
|
10136
10427
|
gaLines = takeFirstItemsByCharBudget(gaLines, contextMode === 'minimal' ? 5000 : 18000)
|
|
10137
10428
|
}
|
|
10138
|
-
//
|
|
10139
|
-
//
|
|
10140
|
-
// below, but starts from the same complete KNX analysis prompt.
|
|
10429
|
+
// Conversational channels keep the live KNX analysis context used by the web
|
|
10430
|
+
// Assistant, but deliberately omit packaged help/README/wiki/example snippets.
|
|
10141
10431
|
const analysisContext = buildLLMPrompt({
|
|
10142
10432
|
question,
|
|
10143
10433
|
summary,
|
|
10144
10434
|
compact: contextMode === 'full' ? false : contextMode,
|
|
10145
|
-
languageHint
|
|
10435
|
+
languageHint,
|
|
10436
|
+
includeDocs: false
|
|
10146
10437
|
})
|
|
10147
10438
|
const fullCameraCatalog = Array.from(node._cameraCatalog.values())
|
|
10148
10439
|
const cameraSearch = normalizeSearchText(question)
|
|
@@ -10184,12 +10475,15 @@ module.exports = function (RED) {
|
|
|
10184
10475
|
node.llmSystemPrompt || 'You are a KNX building automation assistant.',
|
|
10185
10476
|
'',
|
|
10186
10477
|
'KNX CHAT AND CONTROL CONTRACT:',
|
|
10187
|
-
'- Return only one JSON object with exactly this shape: {"reply":"text for the user","language":"it","routine":{"active":false,"name":"","phase":"none
|
|
10478
|
+
'- Return only one JSON object with exactly this top-level shape: {"reply":"text for the user","language":"it","routine":{"active":false,"name":"","phase":"none"},"commands":[],"cameraActions":[],"speechActions":[]}.',
|
|
10479
|
+
'- Begin with every action array empty. Add an item only when the current user request actually needs that action; never copy placeholder addresses, DPTs, cameras, events, or payloads from these instructions.',
|
|
10188
10480
|
'- Use the same language as the user for reply and reason.',
|
|
10189
10481
|
'- Set language to the ISO code matching the current user request: en, it, de, fr, es, or zh.',
|
|
10190
10482
|
'- For an explicit request to refresh, read, query, or retrieve a current KNX state, create GroupValue_Read operations for the exact relevant objects. Use payload null for reads.',
|
|
10191
10483
|
'- GroupValue_Read is allowed for exact status, neutral, or command objects in AVAILABLE KNX OBJECTS because it does not modify the bus state.',
|
|
10192
10484
|
'- For a question that can be answered from recent data, return commands as an empty array. If current data is missing or the user explicitly asks for a fresh read, request it instead of claiming that read-only objects cannot be queried.',
|
|
10485
|
+
'- Historical questions must use the KNX and adapter archive summaries in the supplied analysis context. Totals describe every stored row in the requested interval; selected rows are samples for detail and must not be used as the total count.',
|
|
10486
|
+
'- Adapter history includes automatically detected provider events such as camera motion and smart detections. Do not claim that the absence of an archived event proves physical absence; report only what the adapters recorded.',
|
|
10193
10487
|
'- Create a GroupValue_Write only when the user clearly asks to control an actuator now.',
|
|
10194
10488
|
'- Never invent, guess, transform, or substitute a group address or DPT.',
|
|
10195
10489
|
'- A GroupValue_Write destination must appear in AVAILABLE KNX OBJECTS with role command. Status and neutral objects must never receive GroupValue_Write.',
|
|
@@ -10322,7 +10616,8 @@ module.exports = function (RED) {
|
|
|
10322
10616
|
required: ['reply', 'language', 'routine', 'commands', 'cameraActions', 'speechActions']
|
|
10323
10617
|
}
|
|
10324
10618
|
},
|
|
10325
|
-
maxTokensOverride: configuredMaxTokens
|
|
10619
|
+
maxTokensOverride: configuredMaxTokens,
|
|
10620
|
+
trackChatContextUsage: true
|
|
10326
10621
|
})
|
|
10327
10622
|
|
|
10328
10623
|
let envelope
|
|
@@ -10901,9 +11196,14 @@ module.exports = function (RED) {
|
|
|
10901
11196
|
}
|
|
10902
11197
|
}
|
|
10903
11198
|
|
|
10904
|
-
const handleCameraAdapterEvent = (providerEvent) => {
|
|
11199
|
+
const handleCameraAdapterEvent = (providerEvent, provider = null) => {
|
|
10905
11200
|
const event = normalizeKnxAiCameraEvent(providerEvent)
|
|
10906
|
-
if (!event
|
|
11201
|
+
if (!event) return false
|
|
11202
|
+
const adapter = provider && node._cameraAdapters instanceof Map
|
|
11203
|
+
? node._cameraAdapters.get(String(provider.adapterId || ''))
|
|
11204
|
+
: null
|
|
11205
|
+
persistAdapterEventToDisk({ event: Object.assign({}, providerEvent, event), adapter, provider })
|
|
11206
|
+
if (event.active === false) return true
|
|
10907
11207
|
const now = nowMs()
|
|
10908
11208
|
listAllKnxAiCameraWatches(node._chatContext).filter(watch => cameraWatchMatchesEvent(watch, event)).forEach((watch) => {
|
|
10909
11209
|
const lastAt = Number(node._cameraWatchLastTriggered.get(watch.id) || 0)
|
|
@@ -10958,7 +11258,7 @@ module.exports = function (RED) {
|
|
|
10958
11258
|
if (previousProvider === provider && node._cameraProviderUnsubscribers.has(providerId)) return
|
|
10959
11259
|
if (typeof provider.subscribe === 'function') {
|
|
10960
11260
|
const unsubscribe = provider.subscribe(event => {
|
|
10961
|
-
try { handleCameraAdapterEvent(event) } catch (error) {
|
|
11261
|
+
try { handleCameraAdapterEvent(event, provider) } catch (error) {
|
|
10962
11262
|
try { node.sysLogger?.warn(`KNX AI camera event error: ${error.message || error}`) } catch (logError) { /* ignore */ }
|
|
10963
11263
|
}
|
|
10964
11264
|
})
|
|
@@ -11799,7 +12099,7 @@ module.exports = function (RED) {
|
|
|
11799
12099
|
allowKnxCommands: node.llmAllowKnxCommands,
|
|
11800
12100
|
languageHint: requestLanguage
|
|
11801
12101
|
})
|
|
11802
|
-
: await callLLM({ question, sessionId, languageHint: requestLanguage })
|
|
12102
|
+
: await callLLM({ question, sessionId, languageHint: requestLanguage, includeDocs: false })
|
|
11803
12103
|
const initialRoutine = normalizeKnxAiRoutineDescriptor(ret && ret.routine)
|
|
11804
12104
|
const inspectionCommands = (Array.isArray(ret && ret.commands) ? ret.commands : [])
|
|
11805
12105
|
.filter(command => command && command.event === 'GroupValue_Read')
|
|
@@ -12312,6 +12612,7 @@ module.exports = function (RED) {
|
|
|
12312
12612
|
|
|
12313
12613
|
try {
|
|
12314
12614
|
pruneHistoryArchiveFiles({ force: true })
|
|
12615
|
+
pruneAdapterHistoryArchiveFiles({ force: true })
|
|
12315
12616
|
loadRecentHistoryFromDisk()
|
|
12316
12617
|
loadHomeMemoryFromDisk()
|
|
12317
12618
|
loadChatContextFromDisk()
|
|
@@ -12367,11 +12668,14 @@ module.exports = function (RED) {
|
|
|
12367
12668
|
}
|
|
12368
12669
|
|
|
12369
12670
|
module.exports.__test = {
|
|
12671
|
+
KNX_AI_ADAPTER_HISTORY_RETENTION_DAYS,
|
|
12370
12672
|
KNX_AI_CLOUD_LLM_TIMEOUT_MIN_MS,
|
|
12371
12673
|
KNX_AI_COMPACT_CONTEXT_MAX_TOKENS,
|
|
12372
12674
|
KNX_AI_LOCAL_CONTEXT_RETRY_CHAR_BUDGETS,
|
|
12373
12675
|
KNX_AI_LOCAL_LLM_TIMEOUT_MIN_MS,
|
|
12676
|
+
KNX_AI_LMSTUDIO_PROMPT_CONTEXT_MAX_TOKENS,
|
|
12374
12677
|
KNX_AI_MINIMAL_CONTEXT_MAX_TOKENS,
|
|
12678
|
+
KNX_AI_OLLAMA_CONTEXT_MAX_TOKENS,
|
|
12375
12679
|
KNX_AI_ROUTINE_FEEDBACK_TIMEOUT_MS,
|
|
12376
12680
|
KNX_AI_THINKING_DELAY_MS,
|
|
12377
12681
|
KNX_AI_TRAFFIC_DEFAULTS,
|
|
@@ -12390,7 +12694,7 @@ module.exports.__test = {
|
|
|
12390
12694
|
detectKnxAiLanguageFromText,
|
|
12391
12695
|
deriveLmStudioNativeApiUrl,
|
|
12392
12696
|
dispatchKnxAiTtsUltimateAnnouncement,
|
|
12393
|
-
|
|
12697
|
+
resolveLmStudioModelContext,
|
|
12394
12698
|
executeKnxAiChatAdapter,
|
|
12395
12699
|
extractLlmHttpErrorDetail,
|
|
12396
12700
|
extractOllamaModelMaxContextLength,
|
|
@@ -12410,11 +12714,14 @@ module.exports.__test = {
|
|
|
12410
12714
|
normalizeKnxAiCommandCandidates,
|
|
12411
12715
|
normalizeKnxAiRoutineDescriptor,
|
|
12412
12716
|
normalizeLmStudioModelCatalog,
|
|
12717
|
+
measureKnxAiPromptContext,
|
|
12718
|
+
parseQuestionTimeRange,
|
|
12413
12719
|
parseKnxAiConversationResponse,
|
|
12414
12720
|
postLocalLlmWithContextFallbacks,
|
|
12415
12721
|
postOpenAiCompatibleChatWithFallbacks,
|
|
12416
12722
|
resolveKnxAiLanguage,
|
|
12417
12723
|
resolveKnxAiLlmTimeoutMs,
|
|
12724
|
+
resolveKnxAiOperationalContextLimit,
|
|
12418
12725
|
resolveKnxAiPromptContextMode,
|
|
12419
12726
|
resolveKnxAiOperationEvent,
|
|
12420
12727
|
resolveKnxAiSessionId,
|