node-red-contrib-knx-ultimate 6.3.15 → 6.3.16
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 +4 -0
- package/nodes/knxUltimateAI.html +99 -0
- package/nodes/knxUltimateAI.js +732 -26
- package/nodes/locales/de/knxUltimateAI.html +5 -0
- package/nodes/locales/de/knxUltimateAI.json +7 -0
- package/nodes/locales/en/knxUltimateAI.html +5 -0
- package/nodes/locales/en/knxUltimateAI.json +7 -0
- package/nodes/locales/es/knxUltimateAI.html +5 -0
- package/nodes/locales/es/knxUltimateAI.json +7 -0
- package/nodes/locales/fr/knxUltimateAI.html +5 -0
- package/nodes/locales/fr/knxUltimateAI.json +7 -0
- package/nodes/locales/it/knxUltimateAI.html +5 -0
- package/nodes/locales/it/knxUltimateAI.json +7 -0
- package/nodes/locales/zh-CN/knxUltimateAI.html +5 -0
- package/nodes/locales/zh-CN/knxUltimateAI.json +7 -0
- package/nodes/utils/knxAiCamera.js +302 -0
- package/nodes/utils/knxAiChatContext.js +85 -5
- package/package.json +1 -1
- package/resources/KNXAIChatAdapterMappings.js +64 -1
|
@@ -3,6 +3,7 @@ const CHAT_CONTEXT_MAX_BYTES = 512 * 1024
|
|
|
3
3
|
const CHAT_CONTEXT_MAX_SESSIONS = 50
|
|
4
4
|
const CHAT_CONTEXT_MAX_TURNS_PER_SESSION = 8
|
|
5
5
|
const CHAT_CONTEXT_MAX_INSTRUCTIONS_PER_SESSION = 20
|
|
6
|
+
const CHAT_CONTEXT_MAX_CAMERA_WATCHES_PER_SESSION = 20
|
|
6
7
|
const CHAT_CONTEXT_MAX_QUESTION_CHARS = 4000
|
|
7
8
|
const CHAT_CONTEXT_MAX_REPLY_CHARS = 8000
|
|
8
9
|
const CHAT_CONTEXT_MAX_INSTRUCTION_CHARS = 2000
|
|
@@ -42,6 +43,30 @@ const normalizeInstruction = (instruction) => {
|
|
|
42
43
|
}
|
|
43
44
|
}
|
|
44
45
|
|
|
46
|
+
const normalizeCameraWatch = (watch) => {
|
|
47
|
+
if (!watch || typeof watch !== 'object' || Array.isArray(watch)) return null
|
|
48
|
+
const id = clampText(watch.id, 160)
|
|
49
|
+
const cameraId = clampText(watch.cameraId, 160)
|
|
50
|
+
const cameraName = clampText(watch.cameraName, 240)
|
|
51
|
+
const eventType = clampText(watch.eventType, 80)
|
|
52
|
+
if (!id || (!cameraId && !cameraName) || !eventType) return null
|
|
53
|
+
return {
|
|
54
|
+
id,
|
|
55
|
+
createdAt: clampText(watch.createdAt || new Date().toISOString(), 64),
|
|
56
|
+
cameraId,
|
|
57
|
+
cameraName,
|
|
58
|
+
eventType,
|
|
59
|
+
scopeId: clampText(watch.scopeId, 160),
|
|
60
|
+
scopeName: clampText(watch.scopeName, 240),
|
|
61
|
+
objectTypes: Array.from(new Set((Array.isArray(watch.objectTypes) ? watch.objectTypes : [])
|
|
62
|
+
.map(value => clampText(value, 80).toLocaleLowerCase())
|
|
63
|
+
.filter(Boolean))).slice(0, 12),
|
|
64
|
+
cooldownSeconds: Math.max(10, Math.min(86400, Number(watch.cooldownSeconds) || 60)),
|
|
65
|
+
sendSnapshot: watch.sendSnapshot !== false,
|
|
66
|
+
language: clampText(watch.language || 'en', 8).toLocaleLowerCase()
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
45
70
|
const normalizeSession = (session) => {
|
|
46
71
|
if (!session || typeof session !== 'object' || Array.isArray(session)) return null
|
|
47
72
|
const id = normalizeSessionId(session.id)
|
|
@@ -53,11 +78,16 @@ const normalizeSession = (session) => {
|
|
|
53
78
|
.map(normalizeInstruction)
|
|
54
79
|
.filter(Boolean)
|
|
55
80
|
.slice(-CHAT_CONTEXT_MAX_INSTRUCTIONS_PER_SESSION)
|
|
81
|
+
const cameraWatches = (Array.isArray(session.cameraWatches) ? session.cameraWatches : [])
|
|
82
|
+
.map(normalizeCameraWatch)
|
|
83
|
+
.filter(Boolean)
|
|
84
|
+
.slice(-CHAT_CONTEXT_MAX_CAMERA_WATCHES_PER_SESSION)
|
|
56
85
|
return {
|
|
57
86
|
id,
|
|
58
87
|
updatedAt: clampText(session.updatedAt || new Date().toISOString(), 64),
|
|
59
88
|
turns,
|
|
60
|
-
instructions
|
|
89
|
+
instructions,
|
|
90
|
+
cameraWatches
|
|
61
91
|
}
|
|
62
92
|
}
|
|
63
93
|
|
|
@@ -87,7 +117,7 @@ const touchSession = (context, sessionId) => {
|
|
|
87
117
|
const target = normalizeKnxAiChatContext(context)
|
|
88
118
|
const id = normalizeSessionId(sessionId)
|
|
89
119
|
const existing = target.sessions.find(session => session.id === id)
|
|
90
|
-
const session = existing || { id, updatedAt: new Date().toISOString(), turns: [], instructions: [] }
|
|
120
|
+
const session = existing || { id, updatedAt: new Date().toISOString(), turns: [], instructions: [], cameraWatches: [] }
|
|
91
121
|
target.sessions = target.sessions.filter(item => item.id !== id)
|
|
92
122
|
target.sessions.push(session)
|
|
93
123
|
target.sessions = target.sessions.slice(-CHAT_CONTEXT_MAX_SESSIONS)
|
|
@@ -146,19 +176,45 @@ const clearKnxAiChatSession = (context, sessionId) => {
|
|
|
146
176
|
return target
|
|
147
177
|
}
|
|
148
178
|
|
|
179
|
+
const addKnxAiCameraWatch = (context, { sessionId, watch } = {}) => {
|
|
180
|
+
const normalized = normalizeCameraWatch(watch)
|
|
181
|
+
if (!normalized) return normalizeKnxAiChatContext(context)
|
|
182
|
+
const { target, session } = touchSession(context, sessionId)
|
|
183
|
+
session.cameraWatches = (Array.isArray(session.cameraWatches) ? session.cameraWatches : [])
|
|
184
|
+
.filter(item => item.id !== normalized.id)
|
|
185
|
+
session.cameraWatches.push(normalized)
|
|
186
|
+
session.cameraWatches = session.cameraWatches.slice(-CHAT_CONTEXT_MAX_CAMERA_WATCHES_PER_SESSION)
|
|
187
|
+
return target
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const removeKnxAiCameraWatches = (context, { sessionId, predicate } = {}) => {
|
|
191
|
+
const { target, session } = touchSession(context, sessionId)
|
|
192
|
+
const before = Array.isArray(session.cameraWatches) ? session.cameraWatches.length : 0
|
|
193
|
+
session.cameraWatches = (Array.isArray(session.cameraWatches) ? session.cameraWatches : [])
|
|
194
|
+
.filter(watch => !(typeof predicate === 'function' && predicate(watch)))
|
|
195
|
+
return { context: target, removed: Math.max(0, before - session.cameraWatches.length) }
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const listKnxAiCameraWatches = (context, sessionId) => getKnxAiChatSession(context, sessionId).cameraWatches.slice()
|
|
199
|
+
|
|
200
|
+
const listAllKnxAiCameraWatches = (context) => normalizeKnxAiChatContext(context).sessions.flatMap(session => {
|
|
201
|
+
return session.cameraWatches.map(watch => Object.assign({ sessionId: session.id }, watch))
|
|
202
|
+
})
|
|
203
|
+
|
|
149
204
|
const getKnxAiChatSession = (context, sessionId) => {
|
|
150
205
|
const session = findSession(context, sessionId)
|
|
151
206
|
return session || {
|
|
152
207
|
id: normalizeSessionId(sessionId),
|
|
153
208
|
updatedAt: '',
|
|
154
209
|
turns: [],
|
|
155
|
-
instructions: []
|
|
210
|
+
instructions: [],
|
|
211
|
+
cameraWatches: []
|
|
156
212
|
}
|
|
157
213
|
}
|
|
158
214
|
|
|
159
215
|
const buildKnxAiChatPromptContext = ({ context, sessionId, maxChars = 16000 } = {}) => {
|
|
160
216
|
const session = getKnxAiChatSession(context, sessionId)
|
|
161
|
-
if (!session.instructions.length && !session.turns.length) return ''
|
|
217
|
+
if (!session.instructions.length && !session.turns.length && !session.cameraWatches.length) return ''
|
|
162
218
|
const lines = []
|
|
163
219
|
if (session.instructions.length) {
|
|
164
220
|
lines.push('PERSISTENT CHAT INSTRUCTIONS AND PREFERENCES (explicitly supplied by this user; follow them unless they conflict with safety or the KNX contract; newer entries override older conflicting entries):')
|
|
@@ -172,6 +228,16 @@ const buildKnxAiChatPromptContext = ({ context, sessionId, maxChars = 16000 } =
|
|
|
172
228
|
lines.push(`Assistant: ${turn.reply}`)
|
|
173
229
|
})
|
|
174
230
|
}
|
|
231
|
+
if (session.cameraWatches.length) {
|
|
232
|
+
if (lines.length) lines.push('')
|
|
233
|
+
lines.push('ACTIVE CAMERA WATCHES (persistent notification rules created by this chat):')
|
|
234
|
+
session.cameraWatches.forEach((watch) => {
|
|
235
|
+
const camera = watch.cameraName || watch.cameraId
|
|
236
|
+
const scope = watch.scopeName || watch.scopeId
|
|
237
|
+
const objects = watch.objectTypes.length ? `; objects ${watch.objectTypes.join(', ')}` : ''
|
|
238
|
+
lines.push(`- ${watch.id}: ${camera}; event ${watch.eventType}${scope ? `; scope ${scope}` : ''}${objects}; cooldown ${watch.cooldownSeconds}s`)
|
|
239
|
+
})
|
|
240
|
+
}
|
|
175
241
|
return lines.join('\n').slice(0, Math.max(1000, Number(maxChars) || 16000))
|
|
176
242
|
}
|
|
177
243
|
|
|
@@ -201,6 +267,14 @@ const renderKnxAiChatContext = (context) => {
|
|
|
201
267
|
lines.push('### Persistent instructions', '')
|
|
202
268
|
if (!session.instructions.length) lines.push('_None._')
|
|
203
269
|
session.instructions.forEach(item => lines.push(`- ${escapeMarkdownText(item.at)} — ${escapeMarkdownText(item.text)}`))
|
|
270
|
+
lines.push('', '### Camera watches', '')
|
|
271
|
+
if (!session.cameraWatches.length) lines.push('_None._')
|
|
272
|
+
session.cameraWatches.forEach((watch) => {
|
|
273
|
+
const camera = watch.cameraName || watch.cameraId
|
|
274
|
+
const scope = watch.scopeName || watch.scopeId
|
|
275
|
+
const objects = watch.objectTypes.length ? `; objects ${watch.objectTypes.join(', ')}` : ''
|
|
276
|
+
lines.push(`- ${escapeMarkdownText(watch.id)} — ${escapeMarkdownText(camera)}; event ${escapeMarkdownText(watch.eventType)}${scope ? `; scope ${escapeMarkdownText(scope)}` : ''}${objects}; cooldown ${watch.cooldownSeconds}s`)
|
|
277
|
+
})
|
|
204
278
|
lines.push('', '### Recent turns', '')
|
|
205
279
|
if (!session.turns.length) lines.push('_None._')
|
|
206
280
|
session.turns.forEach((turn) => {
|
|
@@ -260,10 +334,12 @@ const conversationMapFromKnxAiChatContext = (context) => {
|
|
|
260
334
|
}
|
|
261
335
|
|
|
262
336
|
module.exports = {
|
|
337
|
+
CHAT_CONTEXT_MAX_CAMERA_WATCHES_PER_SESSION,
|
|
263
338
|
CHAT_CONTEXT_MAX_BYTES,
|
|
264
339
|
CHAT_CONTEXT_MAX_INSTRUCTIONS_PER_SESSION,
|
|
265
340
|
CHAT_CONTEXT_MAX_SESSIONS,
|
|
266
341
|
CHAT_CONTEXT_MAX_TURNS_PER_SESSION,
|
|
342
|
+
addKnxAiCameraWatch,
|
|
267
343
|
addKnxAiChatTurn,
|
|
268
344
|
buildKnxAiChatContextMarkdown,
|
|
269
345
|
buildKnxAiChatPromptContext,
|
|
@@ -272,6 +348,10 @@ module.exports = {
|
|
|
272
348
|
createEmptyKnxAiChatContext,
|
|
273
349
|
extractExplicitKnxAiChatInstruction,
|
|
274
350
|
getKnxAiChatSession,
|
|
351
|
+
listAllKnxAiCameraWatches,
|
|
352
|
+
listKnxAiCameraWatches,
|
|
275
353
|
normalizeKnxAiChatContext,
|
|
276
|
-
|
|
354
|
+
normalizeCameraWatch,
|
|
355
|
+
parseKnxAiChatContextMarkdown,
|
|
356
|
+
removeKnxAiCameraWatches
|
|
277
357
|
}
|
package/package.json
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"engines": {
|
|
4
4
|
"node": ">=20.18.1"
|
|
5
5
|
},
|
|
6
|
-
"version": "6.3.
|
|
6
|
+
"version": "6.3.16",
|
|
7
7
|
"description": "KNX Ultimate is the most advanced KNX integration for Node-RED, providing secure KNX/IP communication, routing, ETS project import, Philips Hue, Matter Controller and Matter Bridge (control matter device via KNX and expose KNX GA via Matter), MQTT, diagnostics with AI, virtual devices, and powerful automation nodes. Build professional, reliable, and scalable smart home and building automation projects with minimal effort.",
|
|
8
8
|
"files": [
|
|
9
9
|
"nodes/",
|
|
@@ -52,6 +52,23 @@ if (content && typeof content === 'object') {
|
|
|
52
52
|
}
|
|
53
53
|
content = String(content === undefined || content === null ? '' : content);
|
|
54
54
|
|
|
55
|
+
const image = msg.knxAi && msg.knxAi.image;
|
|
56
|
+
if (image && Buffer.isBuffer(image.data)) {
|
|
57
|
+
msg.payload = {
|
|
58
|
+
chatId: chatId,
|
|
59
|
+
type: 'photo',
|
|
60
|
+
content: image.data,
|
|
61
|
+
options: {
|
|
62
|
+
caption: content.slice(0, 1024)
|
|
63
|
+
},
|
|
64
|
+
fileOptions: {
|
|
65
|
+
filename: image.filename || 'camera-snapshot.jpg',
|
|
66
|
+
contentType: image.mediaType || 'image/jpeg'
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
return msg;
|
|
70
|
+
}
|
|
71
|
+
|
|
55
72
|
const telegramPayload = {
|
|
56
73
|
chatId: chatId,
|
|
57
74
|
type: 'message',
|
|
@@ -133,7 +150,37 @@ if (chatId === undefined || chatId === null) return;
|
|
|
133
150
|
msg[key] = source[key];
|
|
134
151
|
}
|
|
135
152
|
});
|
|
136
|
-
|
|
153
|
+
|
|
154
|
+
// A persisted camera watch can fire immediately after Node-RED restarts,
|
|
155
|
+
// before a new inbound RedBot message has recreated its conversation helpers.
|
|
156
|
+
// RedBot's Telegram Sender still needs transport metadata and a chat accessor,
|
|
157
|
+
// even when tracking is disabled. Supply the smallest compatible outbound
|
|
158
|
+
// envelope; the configured Sender remains responsible for choosing the bot.
|
|
159
|
+
if (!msg.originalMessage || typeof msg.originalMessage !== 'object') {
|
|
160
|
+
msg.originalMessage = {};
|
|
161
|
+
}
|
|
162
|
+
if (!msg.originalMessage.transport) msg.originalMessage.transport = sourcePayload.transport || 'telegram';
|
|
163
|
+
if (msg.originalMessage.chatId === undefined) msg.originalMessage.chatId = chatId;
|
|
164
|
+
if (sourcePayload.userId !== undefined && msg.originalMessage.userId === undefined) {
|
|
165
|
+
msg.originalMessage.userId = sourcePayload.userId;
|
|
166
|
+
}
|
|
167
|
+
if (typeof msg.chat !== 'function') {
|
|
168
|
+
msg.chat = function () {
|
|
169
|
+
return {
|
|
170
|
+
get: function () { return {}; },
|
|
171
|
+
set: function () { return undefined; },
|
|
172
|
+
remove: function () { return undefined; }
|
|
173
|
+
};
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
if (typeof msg.get !== 'function') {
|
|
177
|
+
msg.get = function (key) {
|
|
178
|
+
if (key === 'userId') return sourcePayload.userId;
|
|
179
|
+
if (key === 'chatId') return chatId;
|
|
180
|
+
if (key === 'transport') return sourcePayload.transport || 'telegram';
|
|
181
|
+
return undefined;
|
|
182
|
+
};
|
|
183
|
+
}
|
|
137
184
|
|
|
138
185
|
let content = msg.payload;
|
|
139
186
|
if (content && typeof content === 'object') {
|
|
@@ -141,6 +188,22 @@ if (content && typeof content === 'object') {
|
|
|
141
188
|
}
|
|
142
189
|
content = String(content === undefined || content === null ? '' : content);
|
|
143
190
|
|
|
191
|
+
const image = msg.knxAi && msg.knxAi.image;
|
|
192
|
+
if (image && Buffer.isBuffer(image.data)) {
|
|
193
|
+
msg.payload = {
|
|
194
|
+
transport: sourcePayload.transport || 'telegram',
|
|
195
|
+
chatId: chatId,
|
|
196
|
+
type: 'photo',
|
|
197
|
+
inbound: false,
|
|
198
|
+
content: image.data,
|
|
199
|
+
filename: image.filename || 'camera-snapshot.jpg',
|
|
200
|
+
mimeType: image.mediaType || 'image/jpeg',
|
|
201
|
+
caption: content.slice(0, 1024)
|
|
202
|
+
};
|
|
203
|
+
if (sourcePayload.userId !== undefined) msg.payload.userId = sourcePayload.userId;
|
|
204
|
+
return msg;
|
|
205
|
+
}
|
|
206
|
+
|
|
144
207
|
const redbotPayload = {
|
|
145
208
|
transport: sourcePayload.transport || 'telegram',
|
|
146
209
|
chatId: chatId,
|