node-red-contrib-knx-ultimate 4.3.1 → 4.3.2
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 +6 -0
- package/nodes/knxUltimate.js +1 -1
- package/nodes/knxUltimateAI.html +215 -18
- package/nodes/knxUltimateAI.js +289 -70
- package/nodes/knxUltimateHueLight.js +807 -810
- package/nodes/knxUltimateLogger.js +3 -4
- package/nodes/knxUltimateMultiRouting.js +0 -1
- package/nodes/locales/de/knxUltimateAI.html +11 -0
- package/nodes/locales/de/knxUltimateAI.json +13 -2
- package/nodes/locales/en/knxUltimateAI.html +11 -0
- package/nodes/locales/en/knxUltimateAI.json +12 -3
- package/nodes/locales/es/knxUltimateAI.html +11 -0
- package/nodes/locales/es/knxUltimateAI.json +13 -2
- package/nodes/locales/fr/knxUltimateAI.html +11 -0
- package/nodes/locales/fr/knxUltimateAI.json +13 -2
- package/nodes/locales/it/knxUltimateAI.html +11 -0
- package/nodes/locales/it/knxUltimateAI.json +12 -3
- package/nodes/locales/zh-CN/knxUltimateAI.html +11 -0
- package/nodes/locales/zh-CN/knxUltimateAI.json +13 -2
- package/package.json +1 -1
package/nodes/knxUltimateAI.js
CHANGED
|
@@ -3,6 +3,7 @@ const loggerClass = require('./utils/sysLogger')
|
|
|
3
3
|
const dptlib = require('knxultimate').dptlib
|
|
4
4
|
const fs = require('fs')
|
|
5
5
|
const path = require('path')
|
|
6
|
+
const { spawn } = require('child_process')
|
|
6
7
|
const { getRequestAccessToken, normalizeAuthFromAccessTokenQuery } = require('./utils/httpAdminAccessToken')
|
|
7
8
|
let googleTranslateTTS = null
|
|
8
9
|
try {
|
|
@@ -271,7 +272,7 @@ const extractJsonFragmentFromText = (value) => {
|
|
|
271
272
|
if (!source) return null
|
|
272
273
|
try {
|
|
273
274
|
return JSON.parse(source)
|
|
274
|
-
} catch (error) {}
|
|
275
|
+
} catch (error) { }
|
|
275
276
|
// Fallback: tolerate comments and trailing commas that some models emit.
|
|
276
277
|
const relaxed = source
|
|
277
278
|
.replace(/\/\*[\s\S]*?\*\//g, '')
|
|
@@ -511,12 +512,14 @@ const normalizeGaRoleValue = (value, fallback = 'auto') => {
|
|
|
511
512
|
|
|
512
513
|
const parseEtsHierarchyLabel = (value) => {
|
|
513
514
|
const raw = normalizeAreaText(value)
|
|
514
|
-
if (!raw)
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
515
|
+
if (!raw) {
|
|
516
|
+
return {
|
|
517
|
+
raw: '',
|
|
518
|
+
deviceLabel: '',
|
|
519
|
+
mainGroup: '',
|
|
520
|
+
middleGroup: '',
|
|
521
|
+
hierarchyPath: ''
|
|
522
|
+
}
|
|
520
523
|
}
|
|
521
524
|
const match = raw.match(/^\(([^()]+)\)\s*(.*)$/)
|
|
522
525
|
if (!match) {
|
|
@@ -808,15 +811,15 @@ const enrichSuggestedAreasWithSummary = ({ baseSnapshot, summary }) => {
|
|
|
808
811
|
let lastSeenAtMs = 0
|
|
809
812
|
const recentPayloads = []
|
|
810
813
|
; (Array.isArray(area.sampleGAs) ? area.sampleGAs : []).forEach((ga) => {
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
814
|
+
const ts = new Date(String(gaLastSeenAt[ga] || '')).getTime()
|
|
815
|
+
if (Number.isFinite(ts) && ts > 0) {
|
|
816
|
+
lastSeenAtMs = Math.max(lastSeenAtMs, ts)
|
|
817
|
+
if (ts >= activeCutoffMs) activeGaCount += 1
|
|
818
|
+
}
|
|
819
|
+
if (gaLastPayload[ga] !== undefined) {
|
|
820
|
+
pushUniqueValue(recentPayloads, `${ga}: ${compactPayloadForNodeLabel(gaLastPayload[ga], 22)}`, 4)
|
|
821
|
+
}
|
|
822
|
+
})
|
|
820
823
|
if (activeGaCount > 0) activeAreaCount += 1
|
|
821
824
|
return Object.assign({}, area, {
|
|
822
825
|
activeGaCount,
|
|
@@ -931,7 +934,7 @@ const applyAreaOverridesToSnapshot = ({ snapshot, overrides, gaCatalog }) => {
|
|
|
931
934
|
if (!item) return
|
|
932
935
|
if (item.dpt) dptSet.add(item.dpt)
|
|
933
936
|
pushUniqueValue(sampleLabels, item.label, 4)
|
|
934
|
-
|
|
937
|
+
; (Array.isArray(item.tags) ? item.tags : []).forEach(tag => inferredTags.add(tag))
|
|
935
938
|
})
|
|
936
939
|
const customName = normalizeAreaText(override.name || overrideId.replace(/^custom:/, ''))
|
|
937
940
|
const isLlmGenerated = String(overrideId || '').startsWith('llm:')
|
|
@@ -990,7 +993,7 @@ const applyAreaOverridesToSnapshot = ({ snapshot, overrides, gaCatalog }) => {
|
|
|
990
993
|
if (!item) return
|
|
991
994
|
if (item.dpt) nextDptSet.add(item.dpt)
|
|
992
995
|
pushUniqueValue(nextLabelSet, item.label, 4)
|
|
993
|
-
|
|
996
|
+
; (Array.isArray(item.tags) ? item.tags : []).forEach(tag => inferredTags.add(tag))
|
|
994
997
|
})
|
|
995
998
|
dptList = Array.from(nextDptSet.values()).sort()
|
|
996
999
|
sampleGAs = filtered.slice(0, 6)
|
|
@@ -1091,11 +1094,11 @@ const mergeAreaProfiles = ({ customProfiles }) => {
|
|
|
1091
1094
|
DEFAULT_AREA_PROFILES.forEach((profile) => {
|
|
1092
1095
|
out.set(profile.id, Object.assign({}, profile))
|
|
1093
1096
|
})
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1097
|
+
; (Array.isArray(customProfiles) ? customProfiles : []).forEach((profile, index) => {
|
|
1098
|
+
const normalized = normalizeAreaProfilePayload(profile, `custom-${index + 1}`)
|
|
1099
|
+
if (!normalized.id) return
|
|
1100
|
+
out.set(normalized.id, normalized)
|
|
1101
|
+
})
|
|
1099
1102
|
return Array.from(out.values())
|
|
1100
1103
|
}
|
|
1101
1104
|
|
|
@@ -2245,6 +2248,184 @@ const deriveModelsUrlFromBaseUrl = (baseUrl) => {
|
|
|
2245
2248
|
}
|
|
2246
2249
|
}
|
|
2247
2250
|
|
|
2251
|
+
const OPENAI_COMPAT_DEFAULT_CHAT_URL = 'https://api.openai.com/v1/chat/completions'
|
|
2252
|
+
const OLLAMA_DEFAULT_CHAT_URL = 'http://localhost:11434/api/chat'
|
|
2253
|
+
|
|
2254
|
+
const normalizeUrlForCompare = (value) => {
|
|
2255
|
+
const raw = String(value || '').trim()
|
|
2256
|
+
if (!raw) return ''
|
|
2257
|
+
try {
|
|
2258
|
+
const u = new URL(raw)
|
|
2259
|
+
u.hash = ''
|
|
2260
|
+
u.search = ''
|
|
2261
|
+
u.pathname = String(u.pathname || '/').replace(/\/+$/, '')
|
|
2262
|
+
return u.toString().toLowerCase()
|
|
2263
|
+
} catch (error) {
|
|
2264
|
+
return raw.replace(/\/+$/, '').toLowerCase()
|
|
2265
|
+
}
|
|
2266
|
+
}
|
|
2267
|
+
|
|
2268
|
+
const isOpenAiDefaultChatUrl = (value) => {
|
|
2269
|
+
return normalizeUrlForCompare(value) === normalizeUrlForCompare(OPENAI_COMPAT_DEFAULT_CHAT_URL)
|
|
2270
|
+
}
|
|
2271
|
+
|
|
2272
|
+
const resolveOllamaChatUrl = (value) => {
|
|
2273
|
+
const raw = String(value || '').trim()
|
|
2274
|
+
if (!raw) return OLLAMA_DEFAULT_CHAT_URL
|
|
2275
|
+
if (isOpenAiDefaultChatUrl(raw)) return OLLAMA_DEFAULT_CHAT_URL
|
|
2276
|
+
return raw
|
|
2277
|
+
}
|
|
2278
|
+
|
|
2279
|
+
const isLikelyConnectionFailure = (error) => {
|
|
2280
|
+
const message = String(error && error.message ? error.message : '')
|
|
2281
|
+
const causeMessage = String(error && error.cause && error.cause.message ? error.cause.message : '')
|
|
2282
|
+
const merged = `${message} ${causeMessage}`.toLowerCase()
|
|
2283
|
+
return (
|
|
2284
|
+
merged.includes('fetch failed') ||
|
|
2285
|
+
merged.includes('econnrefused') ||
|
|
2286
|
+
merged.includes('enotfound') ||
|
|
2287
|
+
merged.includes('ehostunreach') ||
|
|
2288
|
+
merged.includes('network') ||
|
|
2289
|
+
merged.includes('socket') ||
|
|
2290
|
+
merged.includes('connect')
|
|
2291
|
+
)
|
|
2292
|
+
}
|
|
2293
|
+
|
|
2294
|
+
const decorateOllamaConnectionError = ({ error, url, action }) => {
|
|
2295
|
+
if (!isLikelyConnectionFailure(error)) return error
|
|
2296
|
+
const step = String(action || 'reach the API')
|
|
2297
|
+
const err = new Error(`Cannot reach Ollama at ${url} while trying to ${step}. Ensure Ollama is running (start the Ollama app or run "ollama serve"), then retry. If Node-RED runs in Docker, use host.docker.internal instead of localhost.`)
|
|
2298
|
+
err.status = 502
|
|
2299
|
+
return err
|
|
2300
|
+
}
|
|
2301
|
+
|
|
2302
|
+
const deriveOllamaApiUrl = (baseUrl, endpointPath = '/api/chat') => {
|
|
2303
|
+
const raw = resolveOllamaChatUrl(baseUrl)
|
|
2304
|
+
const normalizedEndpointPath = String(endpointPath || '/api/chat').startsWith('/') ? String(endpointPath || '/api/chat') : ('/' + String(endpointPath || '/api/chat'))
|
|
2305
|
+
try {
|
|
2306
|
+
const u = new URL(raw)
|
|
2307
|
+
if (/\/api\/(chat|generate|tags|pull)\/?$/.test(u.pathname)) {
|
|
2308
|
+
u.pathname = u.pathname.replace(/\/api\/(chat|generate|tags|pull)\/?$/, normalizedEndpointPath)
|
|
2309
|
+
} else {
|
|
2310
|
+
u.pathname = normalizedEndpointPath
|
|
2311
|
+
}
|
|
2312
|
+
return u.toString()
|
|
2313
|
+
} catch (error) {
|
|
2314
|
+
return OLLAMA_DEFAULT_CHAT_URL.replace(/\/api\/chat\/?$/, normalizedEndpointPath)
|
|
2315
|
+
}
|
|
2316
|
+
}
|
|
2317
|
+
|
|
2318
|
+
const delay = (ms) => new Promise(resolve => setTimeout(resolve, Math.max(0, Number(ms) || 0)))
|
|
2319
|
+
|
|
2320
|
+
const spawnDetached = ({ command, args = [] }) => {
|
|
2321
|
+
return new Promise((resolve, reject) => {
|
|
2322
|
+
let settled = false
|
|
2323
|
+
let child
|
|
2324
|
+
try {
|
|
2325
|
+
child = spawn(command, args, {
|
|
2326
|
+
detached: true,
|
|
2327
|
+
stdio: 'ignore',
|
|
2328
|
+
windowsHide: true
|
|
2329
|
+
})
|
|
2330
|
+
} catch (error) {
|
|
2331
|
+
reject(error)
|
|
2332
|
+
return
|
|
2333
|
+
}
|
|
2334
|
+
|
|
2335
|
+
child.on('error', (error) => {
|
|
2336
|
+
if (settled) return
|
|
2337
|
+
settled = true
|
|
2338
|
+
reject(error)
|
|
2339
|
+
})
|
|
2340
|
+
|
|
2341
|
+
child.unref()
|
|
2342
|
+
setTimeout(() => {
|
|
2343
|
+
if (settled) return
|
|
2344
|
+
settled = true
|
|
2345
|
+
resolve({ command, args, pid: child.pid || 0 })
|
|
2346
|
+
}, 400)
|
|
2347
|
+
})
|
|
2348
|
+
}
|
|
2349
|
+
|
|
2350
|
+
const getOllamaServeCandidates = () => {
|
|
2351
|
+
const base = [
|
|
2352
|
+
{ command: 'ollama', args: ['serve'] },
|
|
2353
|
+
{ command: '/usr/bin/ollama', args: ['serve'] },
|
|
2354
|
+
{ command: '/usr/local/bin/ollama', args: ['serve'] },
|
|
2355
|
+
{ command: '/opt/homebrew/bin/ollama', args: ['serve'] }
|
|
2356
|
+
]
|
|
2357
|
+
if (process.platform === 'darwin') {
|
|
2358
|
+
base.push({ command: '/Applications/Ollama.app/Contents/MacOS/Ollama', args: ['serve'] })
|
|
2359
|
+
}
|
|
2360
|
+
if (process.platform === 'win32') {
|
|
2361
|
+
base.unshift({ command: 'ollama.exe', args: ['serve'] })
|
|
2362
|
+
}
|
|
2363
|
+
const seen = new Set()
|
|
2364
|
+
return base.filter((entry) => {
|
|
2365
|
+
const key = `${entry.command} ${entry.args.join(' ')}`
|
|
2366
|
+
if (seen.has(key)) return false
|
|
2367
|
+
seen.add(key)
|
|
2368
|
+
return true
|
|
2369
|
+
})
|
|
2370
|
+
}
|
|
2371
|
+
|
|
2372
|
+
const waitForOllamaReady = async ({ baseUrl, timeoutMs = 20000 }) => {
|
|
2373
|
+
const tagsUrl = deriveOllamaApiUrl(baseUrl, '/api/tags')
|
|
2374
|
+
const stopAt = Date.now() + Math.max(1000, Number(timeoutMs) || 20000)
|
|
2375
|
+
let lastError = null
|
|
2376
|
+
while (Date.now() < stopAt) {
|
|
2377
|
+
try {
|
|
2378
|
+
const json = await getJson({ url: tagsUrl, timeoutMs: 1500 })
|
|
2379
|
+
return { tagsUrl, json }
|
|
2380
|
+
} catch (error) {
|
|
2381
|
+
lastError = error
|
|
2382
|
+
// eslint-disable-next-line no-await-in-loop
|
|
2383
|
+
await delay(800)
|
|
2384
|
+
}
|
|
2385
|
+
}
|
|
2386
|
+
throw lastError || new Error(`Timeout waiting for Ollama at ${tagsUrl}`)
|
|
2387
|
+
}
|
|
2388
|
+
|
|
2389
|
+
const ensureOllamaServerRunning = async ({ baseUrl, autoStart = false, timeoutMs = 22000 }) => {
|
|
2390
|
+
const resolvedBaseUrl = resolveOllamaChatUrl(baseUrl)
|
|
2391
|
+
const tagsUrl = deriveOllamaApiUrl(resolvedBaseUrl, '/api/tags')
|
|
2392
|
+
|
|
2393
|
+
try {
|
|
2394
|
+
const json = await getJson({ url: tagsUrl, timeoutMs: 1500 })
|
|
2395
|
+
return { started: false, tagsUrl, json, baseUrl: resolvedBaseUrl }
|
|
2396
|
+
} catch (probeError) {
|
|
2397
|
+
if (!autoStart) throw decorateOllamaConnectionError({ error: probeError, url: tagsUrl, action: 'load models' })
|
|
2398
|
+
}
|
|
2399
|
+
|
|
2400
|
+
const candidates = getOllamaServeCandidates()
|
|
2401
|
+
const attempted = []
|
|
2402
|
+
const deadline = Date.now() + Math.max(2000, Number(timeoutMs) || 22000)
|
|
2403
|
+
for (const candidate of candidates) {
|
|
2404
|
+
const remainingMs = deadline - Date.now()
|
|
2405
|
+
if (remainingMs <= 0) break
|
|
2406
|
+
try {
|
|
2407
|
+
// eslint-disable-next-line no-await-in-loop
|
|
2408
|
+
await spawnDetached(candidate)
|
|
2409
|
+
const waitBudget = Math.max(1500, Math.min(9000, remainingMs))
|
|
2410
|
+
// eslint-disable-next-line no-await-in-loop
|
|
2411
|
+
const ready = await waitForOllamaReady({ baseUrl: resolvedBaseUrl, timeoutMs: waitBudget })
|
|
2412
|
+
return {
|
|
2413
|
+
started: true,
|
|
2414
|
+
startedBy: candidate.command,
|
|
2415
|
+
tagsUrl: ready.tagsUrl,
|
|
2416
|
+
json: ready.json,
|
|
2417
|
+
baseUrl: resolvedBaseUrl
|
|
2418
|
+
}
|
|
2419
|
+
} catch (error) {
|
|
2420
|
+
attempted.push(`${candidate.command}: ${String(error && error.message ? error.message : error)}`)
|
|
2421
|
+
}
|
|
2422
|
+
}
|
|
2423
|
+
|
|
2424
|
+
const err = new Error(`Unable to auto-start Ollama at ${tagsUrl}. Tried: ${attempted.join(' | ') || 'no command candidates'}. Start Ollama manually or set a reachable base URL.`)
|
|
2425
|
+
err.status = 502
|
|
2426
|
+
throw err
|
|
2427
|
+
}
|
|
2428
|
+
|
|
2248
2429
|
const isProbablyChatModelId = (id) => {
|
|
2249
2430
|
const s = String(id || '').toLowerCase()
|
|
2250
2431
|
if (!s) return false
|
|
@@ -3235,6 +3416,7 @@ module.exports = function (RED) {
|
|
|
3235
3416
|
let provider = body.provider ? String(body.provider) : ''
|
|
3236
3417
|
let baseUrl = body.baseUrl ? String(body.baseUrl) : ''
|
|
3237
3418
|
let apiKey = sanitizeApiKey(body.apiKey || '')
|
|
3419
|
+
const autoStart = coerceBoolean(body.autoStart)
|
|
3238
3420
|
const includeAll = body.includeAll === true || body.includeAll === 'true'
|
|
3239
3421
|
|
|
3240
3422
|
const deployedNode = nodeId ? RED.nodes.getNode(nodeId) : null
|
|
@@ -3252,22 +3434,11 @@ module.exports = function (RED) {
|
|
|
3252
3434
|
provider = provider || 'openai_compat'
|
|
3253
3435
|
|
|
3254
3436
|
if (provider === 'ollama') {
|
|
3255
|
-
const
|
|
3256
|
-
|
|
3257
|
-
|
|
3258
|
-
try {
|
|
3259
|
-
const u = new URL(raw)
|
|
3260
|
-
if (/\/api\/chat\/?$/.test(u.pathname)) u.pathname = u.pathname.replace(/\/api\/chat\/?$/, '/api/tags')
|
|
3261
|
-
else if (/\/api\/generate\/?$/.test(u.pathname)) u.pathname = u.pathname.replace(/\/api\/generate\/?$/, '/api/tags')
|
|
3262
|
-
else if (!/\/api\/tags\/?$/.test(u.pathname)) u.pathname = '/api/tags'
|
|
3263
|
-
return u.toString()
|
|
3264
|
-
} catch (error) {
|
|
3265
|
-
return 'http://localhost:11434/api/tags'
|
|
3266
|
-
}
|
|
3267
|
-
})()
|
|
3268
|
-
const json = await getJson({ url: tagsUrl })
|
|
3437
|
+
const started = await ensureOllamaServerRunning({ baseUrl, autoStart, timeoutMs: 22000 })
|
|
3438
|
+
const tagsUrl = started.tagsUrl
|
|
3439
|
+
const json = started.json || await getJson({ url: tagsUrl })
|
|
3269
3440
|
const models = (json && Array.isArray(json.models)) ? json.models.map(m => m.name).filter(Boolean) : []
|
|
3270
|
-
res.json({ provider, baseUrl: tagsUrl, models })
|
|
3441
|
+
res.json({ provider, baseUrl: tagsUrl, models, ollamaStarted: !!started.started, startedBy: started.startedBy || '' })
|
|
3271
3442
|
return
|
|
3272
3443
|
}
|
|
3273
3444
|
|
|
@@ -3294,9 +3465,42 @@ module.exports = function (RED) {
|
|
|
3294
3465
|
res.status(error.status || 500).json({ error: error.message || String(error) })
|
|
3295
3466
|
}
|
|
3296
3467
|
})
|
|
3468
|
+
|
|
3469
|
+
RED.httpAdmin.post('/knxUltimateAI/ollama/pull', RED.auth.needsPermission('knxUltimate-config.write'), async (req, res) => {
|
|
3470
|
+
try {
|
|
3471
|
+
const body = req.body || {}
|
|
3472
|
+
const nodeId = body.nodeId ? String(body.nodeId) : ''
|
|
3473
|
+
let baseUrl = body.baseUrl ? String(body.baseUrl) : ''
|
|
3474
|
+
const model = String(body.model || '').trim() || 'llama3.1'
|
|
3475
|
+
|
|
3476
|
+
const deployedNode = nodeId ? RED.nodes.getNode(nodeId) : null
|
|
3477
|
+
if (deployedNode && deployedNode.type !== 'knxUltimateAI') {
|
|
3478
|
+
res.status(400).json({ error: 'Invalid nodeId' })
|
|
3479
|
+
return
|
|
3480
|
+
}
|
|
3481
|
+
|
|
3482
|
+
if (!baseUrl && deployedNode) baseUrl = deployedNode.llmBaseUrl || ''
|
|
3483
|
+
const started = await ensureOllamaServerRunning({ baseUrl, autoStart: true, timeoutMs: 26000 })
|
|
3484
|
+
const pullUrl = deriveOllamaApiUrl(baseUrl, '/api/pull')
|
|
3485
|
+
let json
|
|
3486
|
+
try {
|
|
3487
|
+
json = await postJson({
|
|
3488
|
+
url: pullUrl,
|
|
3489
|
+
body: { model, stream: false },
|
|
3490
|
+
timeoutMs: 1000 * 60 * 15
|
|
3491
|
+
})
|
|
3492
|
+
} catch (error) {
|
|
3493
|
+
throw decorateOllamaConnectionError({ error, url: pullUrl, action: `install model "${model}"` })
|
|
3494
|
+
}
|
|
3495
|
+
const status = String((json && (json.status || json.message)) || '').trim()
|
|
3496
|
+
res.json({ ok: true, model, pullUrl, status, ollamaStarted: !!started.started, startedBy: started.startedBy || '' })
|
|
3497
|
+
} catch (error) {
|
|
3498
|
+
res.status(error.status || 500).json({ error: error.message || String(error) })
|
|
3499
|
+
}
|
|
3500
|
+
})
|
|
3297
3501
|
}
|
|
3298
3502
|
|
|
3299
|
-
function knxUltimateAI(config) {
|
|
3503
|
+
function knxUltimateAI (config) {
|
|
3300
3504
|
RED.nodes.createNode(this, config)
|
|
3301
3505
|
const node = this
|
|
3302
3506
|
|
|
@@ -3344,6 +3548,9 @@ module.exports = function (RED) {
|
|
|
3344
3548
|
node.llmEnabled = config.llmEnabled !== undefined ? coerceBoolean(config.llmEnabled) : false
|
|
3345
3549
|
node.llmProvider = config.llmProvider || 'openai_compat'
|
|
3346
3550
|
node.llmBaseUrl = config.llmBaseUrl || 'https://api.openai.com/v1/chat/completions'
|
|
3551
|
+
if (node.llmProvider === 'ollama') {
|
|
3552
|
+
node.llmBaseUrl = resolveOllamaChatUrl(node.llmBaseUrl)
|
|
3553
|
+
}
|
|
3347
3554
|
// Prefer Node-RED credentials store, fallback to legacy config field (backward compatible)
|
|
3348
3555
|
node.llmApiKey = sanitizeApiKey((node.credentials && node.credentials.llmApiKey) ? node.credentials.llmApiKey : (config.llmApiKey || ''))
|
|
3349
3556
|
node.llmModel = config.llmModel || 'gpt-4o-mini'
|
|
@@ -3355,12 +3562,12 @@ module.exports = function (RED) {
|
|
|
3355
3562
|
node.llmIncludeRaw = config.llmIncludeRaw !== undefined ? coerceBoolean(config.llmIncludeRaw) : false
|
|
3356
3563
|
node.llmIncludeFlowContext = config.llmIncludeFlowContext !== undefined ? coerceBoolean(config.llmIncludeFlowContext) : true
|
|
3357
3564
|
node.llmMaxFlowNodesInPrompt = (config.llmMaxFlowNodesInPrompt === undefined || config.llmMaxFlowNodesInPrompt === '')
|
|
3358
|
-
?
|
|
3565
|
+
? 400
|
|
3359
3566
|
: Number(config.llmMaxFlowNodesInPrompt)
|
|
3360
3567
|
node.llmIncludeDocsSnippets = config.llmIncludeDocsSnippets !== undefined ? coerceBoolean(config.llmIncludeDocsSnippets) : true
|
|
3361
3568
|
node.llmDocsLanguage = config.llmDocsLanguage ? String(config.llmDocsLanguage) : 'it'
|
|
3362
3569
|
node.llmDocsMaxSnippets = (config.llmDocsMaxSnippets === undefined || config.llmDocsMaxSnippets === '') ? 5 : Number(config.llmDocsMaxSnippets)
|
|
3363
|
-
node.llmDocsMaxChars = (config.llmDocsMaxChars === undefined || config.llmDocsMaxChars === '') ?
|
|
3570
|
+
node.llmDocsMaxChars = (config.llmDocsMaxChars === undefined || config.llmDocsMaxChars === '') ? 60000 : Number(config.llmDocsMaxChars)
|
|
3364
3571
|
|
|
3365
3572
|
const pushStatus = (status) => {
|
|
3366
3573
|
if (!status) return
|
|
@@ -3454,11 +3661,15 @@ module.exports = function (RED) {
|
|
|
3454
3661
|
}
|
|
3455
3662
|
return false
|
|
3456
3663
|
}
|
|
3457
|
-
const repeatCandidate = (msg.knx && msg.knx.repeated !== undefined)
|
|
3458
|
-
|
|
3459
|
-
|
|
3460
|
-
|
|
3461
|
-
|
|
3664
|
+
const repeatCandidate = (msg.knx && msg.knx.repeated !== undefined)
|
|
3665
|
+
? msg.knx.repeated
|
|
3666
|
+
: (msg.knx && msg.knx.repeat !== undefined)
|
|
3667
|
+
? msg.knx.repeat
|
|
3668
|
+
: (msg.knx && msg.knx.isRepeated !== undefined)
|
|
3669
|
+
? msg.knx.isRepeated
|
|
3670
|
+
: (msg.repeated !== undefined)
|
|
3671
|
+
? msg.repeated
|
|
3672
|
+
: msg.repeat
|
|
3462
3673
|
const repeated = parseRepeatFlag(repeatCandidate)
|
|
3463
3674
|
return {
|
|
3464
3675
|
ts: nowMs(),
|
|
@@ -3884,11 +4095,11 @@ module.exports = function (RED) {
|
|
|
3884
4095
|
pruneGARateSeries(now)
|
|
3885
4096
|
const candidates = new Set()
|
|
3886
4097
|
; (topGAs || []).forEach(x => { if (x && x.ga) candidates.add(String(x.ga)) })
|
|
3887
|
-
|
|
3888
|
-
|
|
3889
|
-
|
|
3890
|
-
|
|
3891
|
-
|
|
4098
|
+
; (patterns || []).forEach(p => {
|
|
4099
|
+
if (p && p.from) candidates.add(String(p.from))
|
|
4100
|
+
if (p && p.to) candidates.add(String(p.to))
|
|
4101
|
+
})
|
|
4102
|
+
; (anomalyLifecycle || []).forEach(a => { if (a && a.ga) candidates.add(String(a.ga)) })
|
|
3892
4103
|
|
|
3893
4104
|
if (!candidates.size) {
|
|
3894
4105
|
const recent = Array.from(node._gaRateSeries.values())
|
|
@@ -4588,12 +4799,12 @@ module.exports = function (RED) {
|
|
|
4588
4799
|
'KNX bus summary (JSON):',
|
|
4589
4800
|
summaryText,
|
|
4590
4801
|
'',
|
|
4591
|
-
areasContext
|
|
4802
|
+
areasContext || '',
|
|
4592
4803
|
areasContext ? '' : '',
|
|
4593
4804
|
flowContext ? 'Node-RED context:' : '',
|
|
4594
|
-
flowContext
|
|
4805
|
+
flowContext || '',
|
|
4595
4806
|
flowContext ? '' : '',
|
|
4596
|
-
docsContext
|
|
4807
|
+
docsContext || '',
|
|
4597
4808
|
docsContext ? '' : '',
|
|
4598
4809
|
wantsSvgChart ? 'SVG output rules:' : '',
|
|
4599
4810
|
wantsSvgChart ? '- Return exactly one fenced SVG block using ```svg ... ```.' : '',
|
|
@@ -4745,8 +4956,7 @@ module.exports = function (RED) {
|
|
|
4745
4956
|
: (Array.isArray(current.profiles) ? current.profiles : []),
|
|
4746
4957
|
actuatorTests: partialConfig && Array.isArray(partialConfig.actuatorTests)
|
|
4747
4958
|
? partialConfig.actuatorTests
|
|
4748
|
-
: (Array.isArray(current.actuatorTests) ? current.actuatorTests : [])
|
|
4749
|
-
,
|
|
4959
|
+
: (Array.isArray(current.actuatorTests) ? current.actuatorTests : []),
|
|
4750
4960
|
testPlans: partialConfig && Array.isArray(partialConfig.testPlans)
|
|
4751
4961
|
? partialConfig.testPlans
|
|
4752
4962
|
: (Array.isArray(current.testPlans) ? current.testPlans : []),
|
|
@@ -5577,7 +5787,6 @@ module.exports = function (RED) {
|
|
|
5577
5787
|
})
|
|
5578
5788
|
}
|
|
5579
5789
|
|
|
5580
|
-
|
|
5581
5790
|
const executeWaitStep = async (stepPayload = {}) => {
|
|
5582
5791
|
const step = normalizeTestPlanStepPayload(stepPayload, stepPayload && stepPayload.id ? String(stepPayload.id) : 'wait-step')
|
|
5583
5792
|
const delayMs = Math.max(0, Number(step.delayMs || 0))
|
|
@@ -6385,16 +6594,16 @@ module.exports = function (RED) {
|
|
|
6385
6594
|
if (!node.llmApiKey && node.llmProvider !== 'ollama') {
|
|
6386
6595
|
throw new Error('Missing API key: paste only the OpenAI key (starts with sk-), without "Bearer"')
|
|
6387
6596
|
}
|
|
6388
|
-
|
|
6389
|
-
|
|
6390
|
-
|
|
6391
|
-
|
|
6392
|
-
|
|
6393
|
-
|
|
6394
|
-
|
|
6597
|
+
const maxTokensRaw = (maxTokensOverride !== null && maxTokensOverride !== undefined && maxTokensOverride !== '')
|
|
6598
|
+
? Number(maxTokensOverride)
|
|
6599
|
+
: Number(node.llmMaxTokens)
|
|
6600
|
+
const resolvedMaxTokens = Number.isFinite(maxTokensRaw) && maxTokensRaw > 0 ? Math.round(maxTokensRaw) : 10000
|
|
6601
|
+
const configuredTimeoutMs = Number(node.llmTimeoutMs)
|
|
6602
|
+
const resolvedTimeoutMs = Number.isFinite(configuredTimeoutMs) && configuredTimeoutMs > 0 ? Math.round(configuredTimeoutMs) : 30000
|
|
6603
|
+
const effectiveTimeoutMs = Math.max(120000, resolvedTimeoutMs)
|
|
6395
6604
|
|
|
6396
6605
|
if (node.llmProvider === 'ollama') {
|
|
6397
|
-
const url = node.llmBaseUrl
|
|
6606
|
+
const url = resolveOllamaChatUrl(node.llmBaseUrl)
|
|
6398
6607
|
const body = {
|
|
6399
6608
|
model: node.llmModel || 'llama3.1',
|
|
6400
6609
|
stream: false,
|
|
@@ -6406,7 +6615,17 @@ module.exports = function (RED) {
|
|
|
6406
6615
|
temperature: node.llmTemperature
|
|
6407
6616
|
}
|
|
6408
6617
|
}
|
|
6409
|
-
|
|
6618
|
+
let json
|
|
6619
|
+
try {
|
|
6620
|
+
json = await postJson({ url, body, timeoutMs: effectiveTimeoutMs })
|
|
6621
|
+
} catch (error) {
|
|
6622
|
+
if (isLikelyConnectionFailure(error)) {
|
|
6623
|
+
await ensureOllamaServerRunning({ baseUrl: url, autoStart: true, timeoutMs: 22000 })
|
|
6624
|
+
json = await postJson({ url, body, timeoutMs: effectiveTimeoutMs })
|
|
6625
|
+
} else {
|
|
6626
|
+
throw decorateOllamaConnectionError({ error, url, action: 'chat with the model' })
|
|
6627
|
+
}
|
|
6628
|
+
}
|
|
6410
6629
|
const content = json && json.message && typeof json.message.content === 'string' ? json.message.content : safeStringify(json)
|
|
6411
6630
|
return { provider: 'ollama', model: body.model, content, finishReason: String(json && json.done_reason ? json.done_reason : '') }
|
|
6412
6631
|
}
|
|
@@ -6443,10 +6662,10 @@ module.exports = function (RED) {
|
|
|
6443
6662
|
|
|
6444
6663
|
const isResponseFormatCompatibilityError = (message) => {
|
|
6445
6664
|
const msg = String(message || '')
|
|
6446
|
-
return msg.includes("Unsupported parameter: 'response_format'")
|
|
6447
|
-
|
|
6448
|
-
|
|
6449
|
-
|
|
6665
|
+
return msg.includes("Unsupported parameter: 'response_format'") ||
|
|
6666
|
+
msg.includes('Invalid schema for response_format') ||
|
|
6667
|
+
msg.includes('response_format') ||
|
|
6668
|
+
msg.includes('json_schema')
|
|
6450
6669
|
}
|
|
6451
6670
|
|
|
6452
6671
|
let json
|