node-red-contrib-knx-ultimate 4.3.18 → 4.3.20
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 +12 -0
- package/nodes/knxUltimate-config.html +14 -0
- package/nodes/knxUltimateAI.html +15 -7
- package/nodes/knxUltimateAI.js +564 -2
- package/nodes/locales/de/knxUltimate-config.json +1 -0
- package/nodes/locales/de/knxUltimateAI.json +1 -0
- package/nodes/locales/en/knxUltimate-config.json +1 -0
- package/nodes/locales/en/knxUltimateAI.json +1 -0
- package/nodes/locales/es/knxUltimate-config.json +1 -0
- package/nodes/locales/es/knxUltimateAI.json +1 -0
- package/nodes/locales/fr/knxUltimate-config.json +1 -0
- package/nodes/locales/fr/knxUltimateAI.json +1 -0
- package/nodes/locales/it/knxUltimate-config.json +1 -0
- package/nodes/locales/it/knxUltimateAI.json +1 -0
- package/nodes/locales/zh-CN/knxUltimate-config.json +1 -0
- package/nodes/locales/zh-CN/knxUltimateAI.json +1 -0
- package/nodes/plugins/knxUltimateAI-vue/assets/app.css +1 -1
- package/nodes/plugins/knxUltimateAI-vue/assets/app.js +3 -3
- package/package.json +1 -1
package/nodes/knxUltimateAI.js
CHANGED
|
@@ -18,6 +18,362 @@ let adminEndpointsRegistered = false
|
|
|
18
18
|
const aiRuntimeNodes = new Map()
|
|
19
19
|
const knxAiVueDistDir = path.join(__dirname, 'plugins', 'knxUltimateAI-vue')
|
|
20
20
|
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
// KNX AI Flow Builder helpers
|
|
23
|
+
// Build a node "catalog" (type + editable fields) from this package's own
|
|
24
|
+
// editor .html files, so the LLM knows exactly which node types and config
|
|
25
|
+
// fields it can emit when generating a Node-RED flow to paste in the editor.
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
|
|
28
|
+
// Native Node-RED core nodes we explicitly allow in generated flows.
|
|
29
|
+
const KNX_AI_FLOW_CORE_NODES = [
|
|
30
|
+
{ type: 'tab', paletteLabel: 'Flow tab (do not emit, added automatically)', category: 'config', inputs: 0, outputs: 0, fields: {} },
|
|
31
|
+
{ type: 'inject', paletteLabel: 'inject (manual/scheduled trigger)', category: 'common', inputs: 0, outputs: 1, fields: { name: {}, props: {}, repeat: {}, crontab: {}, once: {}, topic: {}, payload: {}, payloadType: {} } },
|
|
32
|
+
{ type: 'debug', paletteLabel: 'debug (sidebar log)', category: 'common', inputs: 1, outputs: 0, fields: { name: {}, active: {}, complete: {}, console: {}, tosidebar: {} } },
|
|
33
|
+
{ type: 'function', paletteLabel: 'function (custom JavaScript)', category: 'function', inputs: 1, outputs: 1, fields: { name: {}, func: {}, outputs: {}, initialize: {}, finalize: {} } },
|
|
34
|
+
{ type: 'switch', paletteLabel: 'switch (route by rules)', category: 'function', inputs: 1, outputs: 1, fields: { name: {}, property: {}, propertyType: {}, rules: {}, outputs: {} } },
|
|
35
|
+
{ type: 'change', paletteLabel: 'change (set/move/delete properties)', category: 'function', inputs: 1, outputs: 1, fields: { name: {}, rules: {} } },
|
|
36
|
+
{ type: 'range', paletteLabel: 'range (scale a number)', category: 'function', inputs: 1, outputs: 1, fields: { name: {}, minin: {}, maxin: {}, minout: {}, maxout: {}, action: {}, round: {}, property: {} } },
|
|
37
|
+
{ type: 'delay', paletteLabel: 'delay (delay/rate limit)', category: 'function', inputs: 1, outputs: 1, fields: { name: {}, pauseType: {}, timeout: {}, timeoutUnits: {}, rate: {}, rateUnits: {} } },
|
|
38
|
+
{ type: 'trigger', paletteLabel: 'trigger (send-then-reset / debounce)', category: 'function', inputs: 1, outputs: 1, fields: { name: {}, op1: {}, op2: {}, duration: {}, units: {}, reset: {} } },
|
|
39
|
+
{ type: 'comment', paletteLabel: 'comment (annotation)', category: 'common', inputs: 0, outputs: 0, fields: { name: {}, info: {} } },
|
|
40
|
+
{ type: 'link in', paletteLabel: 'link in', category: 'common', inputs: 0, outputs: 1, fields: { name: {}, links: {} } },
|
|
41
|
+
{ type: 'link out', paletteLabel: 'link out', category: 'common', inputs: 1, outputs: 0, fields: { name: {}, links: {} } }
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
// Scan a JS object literal body (the text between its outer braces) and return,
|
|
45
|
+
// for each top-level key, the inner object text. String- and comment-aware so
|
|
46
|
+
// commented-out entries (e.g. "//buttonState: {value:true}") are ignored.
|
|
47
|
+
const knxAiScanObjectEntries = (body) => {
|
|
48
|
+
const entries = {}
|
|
49
|
+
const len = body.length
|
|
50
|
+
let i = 0
|
|
51
|
+
let depth = 0
|
|
52
|
+
let pendingKey = ''
|
|
53
|
+
let collecting = ''
|
|
54
|
+
let innerStart = -1
|
|
55
|
+
while (i < len) {
|
|
56
|
+
const c = body[i]
|
|
57
|
+
const next = body[i + 1]
|
|
58
|
+
if (c === '/' && next === '/') {
|
|
59
|
+
i += 2
|
|
60
|
+
while (i < len && body[i] !== '\n') i++
|
|
61
|
+
continue
|
|
62
|
+
}
|
|
63
|
+
if (c === '/' && next === '*') {
|
|
64
|
+
i += 2
|
|
65
|
+
while (i < len && !(body[i] === '*' && body[i + 1] === '/')) i++
|
|
66
|
+
i += 2
|
|
67
|
+
continue
|
|
68
|
+
}
|
|
69
|
+
if (c === '"' || c === "'" || c === '`') {
|
|
70
|
+
i++
|
|
71
|
+
while (i < len) {
|
|
72
|
+
if (body[i] === '\\') { i += 2; continue }
|
|
73
|
+
if (body[i] === c) { i++; break }
|
|
74
|
+
i++
|
|
75
|
+
}
|
|
76
|
+
continue
|
|
77
|
+
}
|
|
78
|
+
if (c === '{') {
|
|
79
|
+
if (depth === 0 && pendingKey) { collecting = pendingKey; innerStart = i + 1 }
|
|
80
|
+
depth++
|
|
81
|
+
i++
|
|
82
|
+
continue
|
|
83
|
+
}
|
|
84
|
+
if (c === '}') {
|
|
85
|
+
depth--
|
|
86
|
+
if (depth === 0 && collecting) {
|
|
87
|
+
entries[collecting] = body.slice(innerStart, i)
|
|
88
|
+
collecting = ''
|
|
89
|
+
pendingKey = ''
|
|
90
|
+
}
|
|
91
|
+
i++
|
|
92
|
+
continue
|
|
93
|
+
}
|
|
94
|
+
if (depth === 0 && /[A-Za-z_$]/.test(c)) {
|
|
95
|
+
let j = i
|
|
96
|
+
while (j < len && /[A-Za-z0-9_$]/.test(body[j])) j++
|
|
97
|
+
pendingKey = body.slice(i, j)
|
|
98
|
+
i = j
|
|
99
|
+
continue
|
|
100
|
+
}
|
|
101
|
+
i++
|
|
102
|
+
}
|
|
103
|
+
return entries
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Given full text and the index of an opening brace, return the substring up to
|
|
107
|
+
// and including the matching closing brace (string/comment aware).
|
|
108
|
+
const knxAiSliceBalanced = (text, openIndex) => {
|
|
109
|
+
const len = text.length
|
|
110
|
+
let i = openIndex
|
|
111
|
+
let depth = 0
|
|
112
|
+
while (i < len) {
|
|
113
|
+
const c = text[i]
|
|
114
|
+
const next = text[i + 1]
|
|
115
|
+
if (c === '/' && next === '/') {
|
|
116
|
+
i += 2
|
|
117
|
+
while (i < len && text[i] !== '\n') i++
|
|
118
|
+
continue
|
|
119
|
+
}
|
|
120
|
+
if (c === '/' && next === '*') {
|
|
121
|
+
i += 2
|
|
122
|
+
while (i < len && !(text[i] === '*' && text[i + 1] === '/')) i++
|
|
123
|
+
i += 2
|
|
124
|
+
continue
|
|
125
|
+
}
|
|
126
|
+
if (c === '"' || c === "'" || c === '`') {
|
|
127
|
+
i++
|
|
128
|
+
while (i < len) {
|
|
129
|
+
if (text[i] === '\\') { i += 2; continue }
|
|
130
|
+
if (text[i] === c) { i++; break }
|
|
131
|
+
i++
|
|
132
|
+
}
|
|
133
|
+
continue
|
|
134
|
+
}
|
|
135
|
+
if (c === '{') depth++
|
|
136
|
+
else if (c === '}') {
|
|
137
|
+
depth--
|
|
138
|
+
if (depth === 0) return text.slice(openIndex, i + 1)
|
|
139
|
+
}
|
|
140
|
+
i++
|
|
141
|
+
}
|
|
142
|
+
return text.slice(openIndex)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const knxAiMatchAfter = (text, regex) => {
|
|
146
|
+
const m = regex.exec(text)
|
|
147
|
+
return m ? m[1] : ''
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Parse one editor `defaults: { ... }` block into a field map.
|
|
151
|
+
// Each field becomes { configType, isConfig } where configType is set when the
|
|
152
|
+
// field references a config node (e.g. server: { type: 'knxUltimate-config' }).
|
|
153
|
+
const knxAiParseDefaultsFields = (defaultsBody) => {
|
|
154
|
+
const fields = {}
|
|
155
|
+
const entries = knxAiScanObjectEntries(defaultsBody)
|
|
156
|
+
Object.keys(entries).forEach((key) => {
|
|
157
|
+
const inner = entries[key] || ''
|
|
158
|
+
const configType = knxAiMatchAfter(inner, /\btype\s*:\s*['"]([^'"]+)['"]/)
|
|
159
|
+
fields[key] = configType ? { configType, isConfig: true } : {}
|
|
160
|
+
})
|
|
161
|
+
return fields
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
let knxAiPackageNodeCatalogCache = null
|
|
165
|
+
|
|
166
|
+
// Read every registerType(...) declaration in this package's editor .html files
|
|
167
|
+
// and return a catalog: [{ type, paletteLabel, category, inputs, outputs, fields }].
|
|
168
|
+
const buildKnxAiPackageNodeCatalog = () => {
|
|
169
|
+
if (knxAiPackageNodeCatalogCache) return knxAiPackageNodeCatalogCache
|
|
170
|
+
const catalog = []
|
|
171
|
+
const seen = new Set()
|
|
172
|
+
let nodeMap = {}
|
|
173
|
+
try {
|
|
174
|
+
const pkg = require(path.join(__dirname, '..', 'package.json'))
|
|
175
|
+
nodeMap = (pkg['node-red'] && pkg['node-red'].nodes) || {}
|
|
176
|
+
} catch (error) {
|
|
177
|
+
nodeMap = {}
|
|
178
|
+
}
|
|
179
|
+
Object.keys(nodeMap).forEach((mapKey) => {
|
|
180
|
+
try {
|
|
181
|
+
const jsRel = String(nodeMap[mapKey] || '')
|
|
182
|
+
const base = path.basename(jsRel).replace(/\.js$/i, '')
|
|
183
|
+
const htmlPath = path.join(__dirname, `${base}.html`)
|
|
184
|
+
if (!fs.existsSync(htmlPath)) return
|
|
185
|
+
const html = fs.readFileSync(htmlPath, 'utf8')
|
|
186
|
+
const re = /registerType\(\s*['"]([^'"]+)['"]\s*,\s*\{/g
|
|
187
|
+
let m
|
|
188
|
+
while ((m = re.exec(html))) {
|
|
189
|
+
const type = m[1]
|
|
190
|
+
if (seen.has(type)) continue
|
|
191
|
+
seen.add(type)
|
|
192
|
+
const objOpen = html.indexOf('{', m.index + m[0].length - 1)
|
|
193
|
+
if (objOpen < 0) continue
|
|
194
|
+
const objText = knxAiSliceBalanced(html, objOpen)
|
|
195
|
+
const category = knxAiMatchAfter(objText, /\bcategory\s*:\s*['"]([^'"]+)['"]/)
|
|
196
|
+
const paletteLabel = knxAiMatchAfter(objText, /\bpaletteLabel\s*:\s*['"]([^'"]+)['"]/)
|
|
197
|
+
const inputsRaw = knxAiMatchAfter(objText, /\binputs\s*:\s*(\d+)/)
|
|
198
|
+
const outputsRaw = knxAiMatchAfter(objText, /\boutputs\s*:\s*(\d+)/)
|
|
199
|
+
let fields = {}
|
|
200
|
+
const defIdx = objText.search(/\bdefaults\s*:\s*\{/)
|
|
201
|
+
if (defIdx >= 0) {
|
|
202
|
+
const braceIdx = objText.indexOf('{', defIdx)
|
|
203
|
+
const defaultsBlock = knxAiSliceBalanced(objText, braceIdx)
|
|
204
|
+
fields = knxAiParseDefaultsFields(defaultsBlock.slice(1, -1))
|
|
205
|
+
}
|
|
206
|
+
catalog.push({
|
|
207
|
+
type,
|
|
208
|
+
paletteLabel: paletteLabel || type,
|
|
209
|
+
category: category || '',
|
|
210
|
+
inputs: inputsRaw === '' ? 1 : Number(inputsRaw),
|
|
211
|
+
outputs: outputsRaw === '' ? 1 : Number(outputsRaw),
|
|
212
|
+
isConfig: category === 'config',
|
|
213
|
+
fields
|
|
214
|
+
})
|
|
215
|
+
}
|
|
216
|
+
} catch (error) {
|
|
217
|
+
// skip nodes we cannot parse
|
|
218
|
+
}
|
|
219
|
+
})
|
|
220
|
+
knxAiPackageNodeCatalogCache = catalog
|
|
221
|
+
return catalog
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Combined catalog (package + core), the set of config-node types, and a
|
|
225
|
+
// per-type map of which fields are config references.
|
|
226
|
+
const buildKnxAiFlowCatalog = () => {
|
|
227
|
+
const packageNodes = buildKnxAiPackageNodeCatalog()
|
|
228
|
+
const all = packageNodes.concat(KNX_AI_FLOW_CORE_NODES)
|
|
229
|
+
const configTypes = new Set()
|
|
230
|
+
const configFieldsByType = {}
|
|
231
|
+
const allowedTypes = new Set()
|
|
232
|
+
all.forEach((node) => {
|
|
233
|
+
allowedTypes.add(node.type)
|
|
234
|
+
if (node.isConfig) configTypes.add(node.type)
|
|
235
|
+
const refs = []
|
|
236
|
+
Object.keys(node.fields || {}).forEach((field) => {
|
|
237
|
+
const meta = node.fields[field]
|
|
238
|
+
if (meta && meta.isConfig && meta.configType) {
|
|
239
|
+
refs.push({ field, configType: meta.configType })
|
|
240
|
+
configTypes.add(meta.configType)
|
|
241
|
+
}
|
|
242
|
+
})
|
|
243
|
+
if (refs.length) configFieldsByType[node.type] = refs
|
|
244
|
+
})
|
|
245
|
+
return { nodes: all, packageNodes, configTypes, configFieldsByType, allowedTypes }
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Render the catalog as a compact text block for the LLM system/user prompt.
|
|
249
|
+
const renderKnxAiCatalogForPrompt = (catalog) => {
|
|
250
|
+
const lines = []
|
|
251
|
+
catalog.nodes
|
|
252
|
+
.filter(node => node.type !== 'tab')
|
|
253
|
+
.forEach((node) => {
|
|
254
|
+
const fieldNames = Object.keys(node.fields || {}).map((field) => {
|
|
255
|
+
const meta = node.fields[field]
|
|
256
|
+
return (meta && meta.isConfig && meta.configType) ? `${field}[ref:${meta.configType}]` : field
|
|
257
|
+
})
|
|
258
|
+
const io = `${node.inputs}in/${node.outputs}out`
|
|
259
|
+
const fieldsText = fieldNames.length ? ` | fields: ${fieldNames.join(', ')}` : ''
|
|
260
|
+
lines.push(`- ${node.type} — ${node.paletteLabel} (${io})${fieldsText}`)
|
|
261
|
+
})
|
|
262
|
+
return lines.join('\n')
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// Try hard to extract a JSON flow (array of node objects) from an LLM reply.
|
|
266
|
+
const parseKnxAiFlowFromLlm = (content) => {
|
|
267
|
+
const raw = String(content || '').trim()
|
|
268
|
+
if (!raw) return { nodes: [], notes: '', error: 'Empty model response' }
|
|
269
|
+
let text = raw
|
|
270
|
+
const fence = text.match(/```(?:json)?\s*([\s\S]*?)```/i)
|
|
271
|
+
if (fence) text = fence[1].trim()
|
|
272
|
+
const tryParse = (candidate) => {
|
|
273
|
+
try { return JSON.parse(candidate) } catch (error) { return undefined }
|
|
274
|
+
}
|
|
275
|
+
const fromObject = (obj) => {
|
|
276
|
+
if (Array.isArray(obj)) return { nodes: obj, notes: '' }
|
|
277
|
+
if (obj && typeof obj === 'object') {
|
|
278
|
+
const nodes = Array.isArray(obj.flow) ? obj.flow : (Array.isArray(obj.nodes) ? obj.nodes : null)
|
|
279
|
+
if (nodes) return { nodes, notes: String(obj.notes || obj.comment || '') }
|
|
280
|
+
}
|
|
281
|
+
return null
|
|
282
|
+
}
|
|
283
|
+
let parsed = tryParse(text)
|
|
284
|
+
if (parsed === undefined) {
|
|
285
|
+
const firstArr = text.indexOf('[')
|
|
286
|
+
const lastArr = text.lastIndexOf(']')
|
|
287
|
+
const firstObj = text.indexOf('{')
|
|
288
|
+
const lastObj = text.lastIndexOf('}')
|
|
289
|
+
if (firstArr >= 0 && lastArr > firstArr) parsed = tryParse(text.slice(firstArr, lastArr + 1))
|
|
290
|
+
if (parsed === undefined && firstObj >= 0 && lastObj > firstObj) parsed = tryParse(text.slice(firstObj, lastObj + 1))
|
|
291
|
+
}
|
|
292
|
+
if (parsed === undefined) return { nodes: [], notes: '', error: 'Could not parse JSON from model response' }
|
|
293
|
+
const shaped = fromObject(parsed)
|
|
294
|
+
if (!shaped) return { nodes: [], notes: '', error: 'Model response did not contain a flow array' }
|
|
295
|
+
return { nodes: shaped.nodes, notes: shaped.notes, error: '' }
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// Validate / normalize the generated nodes into an importable flow:
|
|
299
|
+
// - drops invalid + tab nodes, regenerates unique ids, rewires references
|
|
300
|
+
// - puts every wire-able node on a fresh flow tab
|
|
301
|
+
// - points config references at real existing config nodes when possible
|
|
302
|
+
const normalizeKnxAiGeneratedFlow = ({ rawNodes, catalog, knxServerId, existingConfigByType, genId }) => {
|
|
303
|
+
const warnings = []
|
|
304
|
+
const allowedTypes = catalog.allowedTypes
|
|
305
|
+
const configTypes = catalog.configTypes
|
|
306
|
+
const configFieldsByType = catalog.configFieldsByType
|
|
307
|
+
const input = Array.isArray(rawNodes) ? rawNodes : []
|
|
308
|
+
|
|
309
|
+
// Keep only objects with a usable type; skip tab nodes (we add our own) and
|
|
310
|
+
// config nodes (we reference existing ones instead of duplicating them).
|
|
311
|
+
const kept = []
|
|
312
|
+
input.forEach((node) => {
|
|
313
|
+
if (!node || typeof node !== 'object' || Array.isArray(node)) return
|
|
314
|
+
const type = String(node.type || '').trim()
|
|
315
|
+
if (!type || type === 'tab') return
|
|
316
|
+
if (configTypes.has(type)) {
|
|
317
|
+
warnings.push(`Skipped a generated config node of type "${type}"; existing config nodes are reused instead.`)
|
|
318
|
+
return
|
|
319
|
+
}
|
|
320
|
+
if (!allowedTypes.has(type)) {
|
|
321
|
+
warnings.push(`Node type "${type}" is not part of the allowed catalog and may not import cleanly.`)
|
|
322
|
+
}
|
|
323
|
+
kept.push(node)
|
|
324
|
+
})
|
|
325
|
+
|
|
326
|
+
// Map old ids -> fresh ids for every kept node.
|
|
327
|
+
const idRemap = new Map()
|
|
328
|
+
kept.forEach((node) => {
|
|
329
|
+
const oldId = String(node.id || '').trim()
|
|
330
|
+
const newId = genId()
|
|
331
|
+
if (oldId) idRemap.set(oldId, newId)
|
|
332
|
+
node.id = newId
|
|
333
|
+
})
|
|
334
|
+
|
|
335
|
+
const tabId = genId()
|
|
336
|
+
let x = 140
|
|
337
|
+
let y = 80
|
|
338
|
+
const out = kept.map((node) => {
|
|
339
|
+
const type = String(node.type).trim()
|
|
340
|
+
node.z = tabId
|
|
341
|
+
if (!Number.isFinite(Number(node.x))) { node.x = x }
|
|
342
|
+
if (!Number.isFinite(Number(node.y))) { node.y = y; y += 70; if (y > 80 + 70 * 6) { y = 80; x += 220 } }
|
|
343
|
+
// Remap wires (arrays of arrays of node ids).
|
|
344
|
+
if (Array.isArray(node.wires)) {
|
|
345
|
+
node.wires = node.wires.map(port => (Array.isArray(port)
|
|
346
|
+
? port.map(id => idRemap.get(String(id)) || String(id))
|
|
347
|
+
: []))
|
|
348
|
+
} else {
|
|
349
|
+
node.wires = type === 'link out' || type === 'debug' || type === 'comment' ? [] : [[]]
|
|
350
|
+
}
|
|
351
|
+
// Resolve config-node references.
|
|
352
|
+
const refs = configFieldsByType[type] || []
|
|
353
|
+
refs.forEach(({ field, configType }) => {
|
|
354
|
+
const current = String(node[field] || '').trim()
|
|
355
|
+
if (current && idRemap.has(current)) {
|
|
356
|
+
node[field] = idRemap.get(current)
|
|
357
|
+
return
|
|
358
|
+
}
|
|
359
|
+
const existing = existingConfigByType.get(configType) || []
|
|
360
|
+
if (configType === 'knxUltimate-config' && knxServerId) {
|
|
361
|
+
if (!current || !existing.some(c => c.id === current)) node[field] = knxServerId
|
|
362
|
+
return
|
|
363
|
+
}
|
|
364
|
+
if (!current || !existing.some(c => c.id === current)) {
|
|
365
|
+
if (existing.length === 1) node[field] = existing[0].id
|
|
366
|
+
else if (existing.length > 1) warnings.push(`Node "${type}" needs a ${configType} config: set it manually after import (several available).`)
|
|
367
|
+
else { node[field] = ''; warnings.push(`Node "${type}" needs a ${configType} config node, but none exists yet. Configure it after import.`) }
|
|
368
|
+
}
|
|
369
|
+
})
|
|
370
|
+
return node
|
|
371
|
+
})
|
|
372
|
+
|
|
373
|
+
const tabNode = { id: tabId, type: 'tab', label: 'KNX AI generated flow', disabled: false, info: '' }
|
|
374
|
+
return { nodes: [tabNode].concat(out), warnings, tabId }
|
|
375
|
+
}
|
|
376
|
+
|
|
21
377
|
const sendKnxAiVueIndex = (req, res) => {
|
|
22
378
|
const entryPath = path.join(knxAiVueDistDir, 'index.html')
|
|
23
379
|
fs.readFile(entryPath, 'utf8', (error, html) => {
|
|
@@ -2363,6 +2719,47 @@ const deriveModelsUrlFromBaseUrl = (baseUrl) => {
|
|
|
2363
2719
|
|
|
2364
2720
|
const OPENAI_COMPAT_DEFAULT_CHAT_URL = 'https://api.openai.com/v1/chat/completions'
|
|
2365
2721
|
const OLLAMA_DEFAULT_CHAT_URL = 'http://localhost:11434/api/chat'
|
|
2722
|
+
const ANTHROPIC_DEFAULT_MESSAGES_URL = 'https://api.anthropic.com/v1/messages'
|
|
2723
|
+
const ANTHROPIC_DEFAULT_MODELS_URL = 'https://api.anthropic.com/v1/models'
|
|
2724
|
+
const ANTHROPIC_API_VERSION = '2023-06-01'
|
|
2725
|
+
const ANTHROPIC_DEFAULT_MODEL = 'claude-opus-4-8'
|
|
2726
|
+
|
|
2727
|
+
// Anthropic's native Messages API (/v1/messages) is not OpenAI-compatible: it uses
|
|
2728
|
+
// x-api-key + anthropic-version headers and a {role, content[]} response shape.
|
|
2729
|
+
const buildAnthropicHeaders = (apiKey) => ({
|
|
2730
|
+
'x-api-key': String(apiKey || ''),
|
|
2731
|
+
'anthropic-version': ANTHROPIC_API_VERSION
|
|
2732
|
+
})
|
|
2733
|
+
|
|
2734
|
+
// Concatenate the text blocks of an Anthropic Messages API response (thinking
|
|
2735
|
+
// blocks, if any, are ignored: we only want the visible answer text).
|
|
2736
|
+
const extractAnthropicText = (json) => {
|
|
2737
|
+
if (!json || !Array.isArray(json.content)) return ''
|
|
2738
|
+
return json.content
|
|
2739
|
+
.filter(block => block && block.type === 'text' && typeof block.text === 'string')
|
|
2740
|
+
.map(block => block.text)
|
|
2741
|
+
.join('')
|
|
2742
|
+
.trim()
|
|
2743
|
+
}
|
|
2744
|
+
|
|
2745
|
+
// Resolve the /v1/models URL from a configured /v1/messages base URL.
|
|
2746
|
+
const deriveAnthropicModelsUrl = (baseUrl) => {
|
|
2747
|
+
const raw = String(baseUrl || '').trim()
|
|
2748
|
+
if (!raw) return ANTHROPIC_DEFAULT_MODELS_URL
|
|
2749
|
+
try {
|
|
2750
|
+
const u = new URL(raw)
|
|
2751
|
+
const path = u.pathname || '/'
|
|
2752
|
+
if (/\/messages\/?$/.test(path)) {
|
|
2753
|
+
u.pathname = path.replace(/\/messages\/?$/, '/models')
|
|
2754
|
+
return u.toString()
|
|
2755
|
+
}
|
|
2756
|
+
if (/\/models\/?$/.test(path)) return u.toString()
|
|
2757
|
+
u.pathname = '/v1/models'
|
|
2758
|
+
return u.toString()
|
|
2759
|
+
} catch (error) {
|
|
2760
|
+
return ANTHROPIC_DEFAULT_MODELS_URL
|
|
2761
|
+
}
|
|
2762
|
+
}
|
|
2366
2763
|
|
|
2367
2764
|
const normalizeUrlForCompare = (value) => {
|
|
2368
2765
|
const raw = String(value || '').trim()
|
|
@@ -3557,6 +3954,33 @@ module.exports = function (RED) {
|
|
|
3557
3954
|
}
|
|
3558
3955
|
})
|
|
3559
3956
|
|
|
3957
|
+
RED.httpAdmin.post('/knxUltimateAI/sidebar/flow/generate', RED.auth.needsPermission('knxUltimate-config.write'), async (req, res) => {
|
|
3958
|
+
try {
|
|
3959
|
+
const nodeId = req.body?.nodeId ? String(req.body.nodeId) : ''
|
|
3960
|
+
const prompt = req.body?.prompt ? String(req.body.prompt) : ''
|
|
3961
|
+
const language = req.body?.language
|
|
3962
|
+
? String(req.body.language)
|
|
3963
|
+
: extractLanguageCodeFromHeader(req.headers && req.headers['accept-language'] ? String(req.headers['accept-language']) : '', 'en')
|
|
3964
|
+
if (!nodeId) {
|
|
3965
|
+
res.status(400).json({ error: 'Missing nodeId' })
|
|
3966
|
+
return
|
|
3967
|
+
}
|
|
3968
|
+
if (!prompt.trim()) {
|
|
3969
|
+
res.status(400).json({ error: 'Missing prompt' })
|
|
3970
|
+
return
|
|
3971
|
+
}
|
|
3972
|
+
const n = aiRuntimeNodes.get(nodeId) || RED.nodes.getNode(nodeId)
|
|
3973
|
+
if (!n || n.type !== 'knxUltimateAI' || typeof n.generateAiFlow !== 'function') {
|
|
3974
|
+
res.status(404).json({ error: 'KNX AI node not found' })
|
|
3975
|
+
return
|
|
3976
|
+
}
|
|
3977
|
+
const ret = await n.generateAiFlow({ prompt, language })
|
|
3978
|
+
res.json(ret)
|
|
3979
|
+
} catch (error) {
|
|
3980
|
+
res.status(error.status || 500).json({ error: error.message || String(error) })
|
|
3981
|
+
}
|
|
3982
|
+
})
|
|
3983
|
+
|
|
3560
3984
|
RED.httpAdmin.post('/knxUltimateAI/sidebar/test-plans/save', RED.auth.needsPermission('knxUltimate-config.write'), async (req, res) => {
|
|
3561
3985
|
try {
|
|
3562
3986
|
const nodeId = req.body?.nodeId ? String(req.body.nodeId) : ''
|
|
@@ -3744,6 +4168,15 @@ module.exports = function (RED) {
|
|
|
3744
4168
|
return
|
|
3745
4169
|
}
|
|
3746
4170
|
|
|
4171
|
+
if (provider === 'anthropic') {
|
|
4172
|
+
const modelsUrl = deriveAnthropicModelsUrl(baseUrl)
|
|
4173
|
+
const json = await getJson({ url: modelsUrl, headers: buildAnthropicHeaders(apiKey) })
|
|
4174
|
+
let ids = (json && Array.isArray(json.data)) ? json.data.map(m => m && m.id).filter(Boolean) : []
|
|
4175
|
+
ids.sort()
|
|
4176
|
+
res.json({ provider, baseUrl: modelsUrl, models: ids, filtered: false })
|
|
4177
|
+
return
|
|
4178
|
+
}
|
|
4179
|
+
|
|
3747
4180
|
// OpenAI-compatible: /v1/models
|
|
3748
4181
|
const modelsUrl = deriveModelsUrlFromBaseUrl(baseUrl)
|
|
3749
4182
|
const headers = {}
|
|
@@ -3851,13 +4284,17 @@ module.exports = function (RED) {
|
|
|
3851
4284
|
|
|
3852
4285
|
node.llmEnabled = config.llmEnabled !== undefined ? coerceBoolean(config.llmEnabled) : false
|
|
3853
4286
|
node.llmProvider = config.llmProvider || 'openai_compat'
|
|
3854
|
-
node.llmBaseUrl = config.llmBaseUrl || '
|
|
4287
|
+
node.llmBaseUrl = config.llmBaseUrl || ''
|
|
3855
4288
|
if (node.llmProvider === 'ollama') {
|
|
3856
4289
|
node.llmBaseUrl = resolveOllamaChatUrl(node.llmBaseUrl)
|
|
4290
|
+
} else if (node.llmProvider === 'anthropic') {
|
|
4291
|
+
node.llmBaseUrl = node.llmBaseUrl || ANTHROPIC_DEFAULT_MESSAGES_URL
|
|
4292
|
+
} else {
|
|
4293
|
+
node.llmBaseUrl = node.llmBaseUrl || 'https://api.openai.com/v1/chat/completions'
|
|
3857
4294
|
}
|
|
3858
4295
|
// Prefer Node-RED credentials store, fallback to legacy config field (backward compatible)
|
|
3859
4296
|
node.llmApiKey = sanitizeApiKey((node.credentials && node.credentials.llmApiKey) ? node.credentials.llmApiKey : (config.llmApiKey || ''))
|
|
3860
|
-
node.llmModel = config.llmModel || 'gpt-4o-mini'
|
|
4297
|
+
node.llmModel = config.llmModel || (node.llmProvider === 'anthropic' ? ANTHROPIC_DEFAULT_MODEL : 'gpt-4o-mini')
|
|
3861
4298
|
node.llmSystemPrompt = config.llmSystemPrompt || 'You are a KNX building automation assistant. Analyze KNX bus traffic and provide actionable insights.'
|
|
3862
4299
|
node.llmTemperature = (config.llmTemperature === undefined || config.llmTemperature === '') ? 0.2 : Number(config.llmTemperature)
|
|
3863
4300
|
node.llmMaxTokens = (config.llmMaxTokens === undefined || config.llmMaxTokens === '') ? 50000 : Number(config.llmMaxTokens)
|
|
@@ -7126,6 +7563,23 @@ module.exports = function (RED) {
|
|
|
7126
7563
|
return { provider: 'ollama', model: body.model, content, finishReason: String(json && json.done_reason ? json.done_reason : '') }
|
|
7127
7564
|
}
|
|
7128
7565
|
|
|
7566
|
+
if (node.llmProvider === 'anthropic') {
|
|
7567
|
+
// Anthropic native Messages API (not OpenAI-compatible).
|
|
7568
|
+
const url = node.llmBaseUrl || ANTHROPIC_DEFAULT_MESSAGES_URL
|
|
7569
|
+
const headers = buildAnthropicHeaders(node.llmApiKey)
|
|
7570
|
+
const sys = systemPrompt || node.llmSystemPrompt || ''
|
|
7571
|
+
const body = {
|
|
7572
|
+
model: node.llmModel || ANTHROPIC_DEFAULT_MODEL,
|
|
7573
|
+
max_tokens: resolvedMaxTokens,
|
|
7574
|
+
messages: [{ role: 'user', content: userContent }]
|
|
7575
|
+
}
|
|
7576
|
+
if (sys) body.system = sys
|
|
7577
|
+
const json = await postJson({ url, headers, body, timeoutMs: effectiveTimeoutMs })
|
|
7578
|
+
const content = extractAnthropicText(json)
|
|
7579
|
+
const finishReason = String(json && json.stop_reason ? json.stop_reason : '')
|
|
7580
|
+
return { provider: 'anthropic', model: body.model, content, finishReason }
|
|
7581
|
+
}
|
|
7582
|
+
|
|
7129
7583
|
// Default: OpenAI-compatible chat/completions
|
|
7130
7584
|
const url = node.llmBaseUrl || 'https://api.openai.com/v1/chat/completions'
|
|
7131
7585
|
const headers = {}
|
|
@@ -7254,6 +7708,114 @@ module.exports = function (RED) {
|
|
|
7254
7708
|
}
|
|
7255
7709
|
}
|
|
7256
7710
|
|
|
7711
|
+
node.generateAiFlow = async ({ prompt, language } = {}) => {
|
|
7712
|
+
const question = String(prompt || '').trim()
|
|
7713
|
+
if (!question) throw new Error('Missing prompt')
|
|
7714
|
+
const targetLanguage = normalizeLanguageCode(language, 'en')
|
|
7715
|
+
const catalog = buildKnxAiFlowCatalog()
|
|
7716
|
+
|
|
7717
|
+
// Discover existing config nodes (KNX server, Hue bridge, ...) so generated
|
|
7718
|
+
// nodes can reference real ids instead of inventing them.
|
|
7719
|
+
const existingConfigByType = new Map()
|
|
7720
|
+
if (typeof RED.nodes.eachNode === 'function') {
|
|
7721
|
+
RED.nodes.eachNode((n) => {
|
|
7722
|
+
if (!n || !catalog.configTypes.has(n.type)) return
|
|
7723
|
+
const list = existingConfigByType.get(n.type) || []
|
|
7724
|
+
list.push({ id: n.id, name: String(n.name || n.label || '').trim() })
|
|
7725
|
+
existingConfigByType.set(n.type, list)
|
|
7726
|
+
})
|
|
7727
|
+
}
|
|
7728
|
+
const knxServerId = (node.serverKNX && node.serverKNX.id) ? node.serverKNX.id : ''
|
|
7729
|
+
const knxServerName = (node.serverKNX && node.serverKNX.name) ? node.serverKNX.name : ''
|
|
7730
|
+
|
|
7731
|
+
// KNX group-address context (capped to keep the prompt within budget).
|
|
7732
|
+
const fullGaCatalog = getGaCatalogSnapshot()
|
|
7733
|
+
const GA_LIMIT = 600
|
|
7734
|
+
const gaLines = fullGaCatalog.slice(0, GA_LIMIT).map((item) => {
|
|
7735
|
+
const ga = String(item.ga || '').trim()
|
|
7736
|
+
const dpt = String(item.dpt || '').trim() || '?'
|
|
7737
|
+
const label = String(item.label || '').trim()
|
|
7738
|
+
const role = String(item.role || '').trim() || 'neutral'
|
|
7739
|
+
return `${ga} | dpt ${dpt} | ${role} | ${label}`
|
|
7740
|
+
})
|
|
7741
|
+
const gaTruncated = fullGaCatalog.length > GA_LIMIT
|
|
7742
|
+
|
|
7743
|
+
const configLines = []
|
|
7744
|
+
if (knxServerId) configLines.push(`knxUltimate-config (KNX bus): id="${knxServerId}"${knxServerName ? ` name="${knxServerName}"` : ''} — USE THIS for the "server" field of knxUltimate nodes.`)
|
|
7745
|
+
existingConfigByType.forEach((list, type) => {
|
|
7746
|
+
list.forEach((cfg) => {
|
|
7747
|
+
if (type === 'knxUltimate-config' && cfg.id === knxServerId) return
|
|
7748
|
+
configLines.push(`${type}: id="${cfg.id}"${cfg.name ? ` name="${cfg.name}"` : ''}`)
|
|
7749
|
+
})
|
|
7750
|
+
})
|
|
7751
|
+
|
|
7752
|
+
const systemPrompt = [
|
|
7753
|
+
'You are a Node-RED flow generator for the node-red-contrib-knx-ultimate package.',
|
|
7754
|
+
'From the user request you output a single Node-RED flow (a JSON array of node objects) that the user will import via the editor (Menu > Import > paste JSON).',
|
|
7755
|
+
'',
|
|
7756
|
+
'STRICT OUTPUT RULES:',
|
|
7757
|
+
'- Reply with ONLY a JSON object: {"flow": [ ...node objects... ], "notes": "<short explanation in the user language>"}. No prose, no markdown fences.',
|
|
7758
|
+
'- Use ONLY node types from the CATALOG below. Never invent node types or field names.',
|
|
7759
|
+
'- Every node needs a unique string "id", a "type", and (for wire-able nodes) a "wires" array of arrays of target node ids.',
|
|
7760
|
+
'- Connect nodes by listing the downstream node id inside the upstream node\'s "wires".',
|
|
7761
|
+
'- For KNX devices use type "knxUltimate": set "server" to the given KNX config id, "topic" to the group address, "setTopicType":"str", "dpt" to the DPT. To READ from the bus keep "notifywrite":true and use the node\'s output. To WRITE to the bus, set "outputtype":"write" and send a msg.payload to its input.',
|
|
7762
|
+
'- Put automation logic in "function" nodes (plain JavaScript, must `return msg;`). Prefer function nodes over exotic nodes when in doubt.',
|
|
7763
|
+
'- Reference config nodes (KNX server, Hue bridge, ...) ONLY by the ids listed in EXISTING CONFIG NODES. Do not create config/tab nodes; the importer adds the tab automatically.',
|
|
7764
|
+
'- Give each node sensible "x" and "y" coordinates for a left-to-right layout.',
|
|
7765
|
+
'- Only use group addresses from the KNX GROUP ADDRESSES list. If the request needs a GA that is not listed, explain it in "notes" and leave that node\'s topic empty.'
|
|
7766
|
+
].join('\n')
|
|
7767
|
+
|
|
7768
|
+
const userContent = [
|
|
7769
|
+
`USER REQUEST (answer notes in language "${targetLanguage}"):`,
|
|
7770
|
+
question,
|
|
7771
|
+
'',
|
|
7772
|
+
'NODE CATALOG (type — description (Nin/Mout) | fields; [ref:X] = id of an X config node):',
|
|
7773
|
+
renderKnxAiCatalogForPrompt(catalog),
|
|
7774
|
+
'',
|
|
7775
|
+
'EXISTING CONFIG NODES (reference these ids):',
|
|
7776
|
+
configLines.length ? configLines.join('\n') : '(none found)',
|
|
7777
|
+
'',
|
|
7778
|
+
`KNX GROUP ADDRESSES (ga | dpt | role | label)${gaTruncated ? ` — showing first ${GA_LIMIT} of ${fullGaCatalog.length}` : ''}:`,
|
|
7779
|
+
gaLines.length ? gaLines.join('\n') : '(no group addresses imported)',
|
|
7780
|
+
'',
|
|
7781
|
+
'Return the JSON object now.'
|
|
7782
|
+
].join('\n')
|
|
7783
|
+
|
|
7784
|
+
const configuredMaxTokens = Math.max(12000, Number(node.llmMaxTokens) || 0)
|
|
7785
|
+
const ret = await callLLMChat({ systemPrompt, userContent, maxTokensOverride: configuredMaxTokens })
|
|
7786
|
+
const parsed = parseKnxAiFlowFromLlm(ret && ret.content)
|
|
7787
|
+
if (parsed.error && (!parsed.nodes || parsed.nodes.length === 0)) {
|
|
7788
|
+
throw new Error(`The model did not return a valid flow: ${parsed.error}`)
|
|
7789
|
+
}
|
|
7790
|
+
const genId = (typeof RED.util === 'object' && typeof RED.util.generateId === 'function')
|
|
7791
|
+
? RED.util.generateId
|
|
7792
|
+
: () => Math.random().toString(16).slice(2, 10) + Math.random().toString(16).slice(2, 10)
|
|
7793
|
+
const normalized = normalizeKnxAiGeneratedFlow({
|
|
7794
|
+
rawNodes: parsed.nodes,
|
|
7795
|
+
catalog,
|
|
7796
|
+
knxServerId,
|
|
7797
|
+
existingConfigByType,
|
|
7798
|
+
genId
|
|
7799
|
+
})
|
|
7800
|
+
const flow = normalized.nodes
|
|
7801
|
+
return {
|
|
7802
|
+
ok: true,
|
|
7803
|
+
flow,
|
|
7804
|
+
flowJson: JSON.stringify(flow, null, 2),
|
|
7805
|
+
notes: parsed.notes || '',
|
|
7806
|
+
warnings: normalized.warnings,
|
|
7807
|
+
generation: {
|
|
7808
|
+
provider: ret && ret.provider ? ret.provider : '',
|
|
7809
|
+
model: ret && ret.model ? ret.model : '',
|
|
7810
|
+
finishReason: ret && ret.finishReason ? ret.finishReason : '',
|
|
7811
|
+
nodeCount: Math.max(0, flow.length - 1),
|
|
7812
|
+
gaTruncated,
|
|
7813
|
+
language: targetLanguage,
|
|
7814
|
+
languageName: languageNameFromCode(targetLanguage)
|
|
7815
|
+
}
|
|
7816
|
+
}
|
|
7817
|
+
}
|
|
7818
|
+
|
|
7257
7819
|
const callLLM = async ({ question }) => {
|
|
7258
7820
|
const summary = rebuildCachedSummaryNow()
|
|
7259
7821
|
const userContent = buildLLMPrompt({ question, summary })
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
"discovering": "Suche...",
|
|
20
20
|
"interfaces_found": "Schnittstellen gefunden.",
|
|
21
21
|
"discovery_failed": "Suche fehlgeschlagen.",
|
|
22
|
+
"weinzierl_ack_autoenabled": "Weinzierl-Interface erkannt: 'Suppress ACK request' wurde automatisch aktiviert. Du kannst es im Tab 'Erweitert' deaktivieren.",
|
|
22
23
|
"address": "Adresse",
|
|
23
24
|
"iface_auto": "Auto",
|
|
24
25
|
"iface_manual": "Schnittstellennamen manuell eingeben",
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
"discovering": "Discovering...",
|
|
20
20
|
"interfaces_found": "interfaces found.",
|
|
21
21
|
"discovery_failed": "Discovery failed.",
|
|
22
|
+
"weinzierl_ack_autoenabled": "Weinzierl interface detected: 'Suppress ACK request' has been enabled automatically. You can disable it in the Advanced tab.",
|
|
22
23
|
"address": "Address",
|
|
23
24
|
"iface_auto": "Auto",
|
|
24
25
|
"iface_manual": "Manually enter interface's name",
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
"discovering": "Descubriendo ...",
|
|
20
20
|
"interfaces_found": "interfaces encontradas.",
|
|
21
21
|
"discovery_failed": "El descubrimiento falló.",
|
|
22
|
+
"weinzierl_ack_autoenabled": "Interface Weinzierl detectado: se ha activado automáticamente 'Suppress ACK request'. Puedes desactivarlo en la pestaña Avanzado.",
|
|
22
23
|
"address": "DIRECCIÓN",
|
|
23
24
|
"iface_auto": "Auto",
|
|
24
25
|
"iface_manual": "Ingrese manualmente el nombre de la interfaz",
|