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.
@@ -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 || 'https://api.openai.com/v1/chat/completions'
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",
@@ -50,6 +50,7 @@
50
50
  "selectlists": {
51
51
  "llmProvider": {
52
52
  "openai_compat": "OpenAI-compatible (chat/completions)",
53
+ "anthropic": "Anthropic (Claude)",
53
54
  "ollama": "Ollama (local, beta)"
54
55
  }
55
56
  },
@@ -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",
@@ -50,6 +50,7 @@
50
50
  "selectlists": {
51
51
  "llmProvider": {
52
52
  "openai_compat": "OpenAI-compatible (chat/completions)",
53
+ "anthropic": "Anthropic (Claude)",
53
54
  "ollama": "Ollama (local, beta)"
54
55
  }
55
56
  },
@@ -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",
@@ -50,6 +50,7 @@
50
50
  "selectlists": {
51
51
  "llmProvider": {
52
52
  "openai_compat": "OpenAI-compatible (chat/completions)",
53
+ "anthropic": "Anthropic (Claude)",
53
54
  "ollama": "Ollama (local, beta)"
54
55
  }
55
56
  },