node-red-contrib-knx-ultimate 4.3.0 → 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.
@@ -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) return {
515
- raw: '',
516
- deviceLabel: '',
517
- mainGroup: '',
518
- middleGroup: '',
519
- hierarchyPath: ''
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
- const ts = new Date(String(gaLastSeenAt[ga] || '')).getTime()
812
- if (Number.isFinite(ts) && ts > 0) {
813
- lastSeenAtMs = Math.max(lastSeenAtMs, ts)
814
- if (ts >= activeCutoffMs) activeGaCount += 1
815
- }
816
- if (gaLastPayload[ga] !== undefined) {
817
- pushUniqueValue(recentPayloads, `${ga}: ${compactPayloadForNodeLabel(gaLastPayload[ga], 22)}`, 4)
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
- ; (Array.isArray(item.tags) ? item.tags : []).forEach(tag => inferredTags.add(tag))
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
- ; (Array.isArray(item.tags) ? item.tags : []).forEach(tag => inferredTags.add(tag))
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
- ; (Array.isArray(customProfiles) ? customProfiles : []).forEach((profile, index) => {
1095
- const normalized = normalizeAreaProfilePayload(profile, `custom-${index + 1}`)
1096
- if (!normalized.id) return
1097
- out.set(normalized.id, normalized)
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 tagsUrl = (() => {
3256
- const raw = String(baseUrl || '').trim()
3257
- if (!raw) return 'http://localhost:11434/api/tags'
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
- ? 80
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 === '') ? 3000 : Number(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) ? msg.knx.repeated
3458
- : (msg.knx && msg.knx.repeat !== undefined) ? msg.knx.repeat
3459
- : (msg.knx && msg.knx.isRepeated !== undefined) ? msg.knx.isRepeated
3460
- : (msg.repeated !== undefined) ? msg.repeated
3461
- : msg.repeat
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
- ; (patterns || []).forEach(p => {
3888
- if (p && p.from) candidates.add(String(p.from))
3889
- if (p && p.to) candidates.add(String(p.to))
3890
- })
3891
- ; (anomalyLifecycle || []).forEach(a => { if (a && a.ga) candidates.add(String(a.ga)) })
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 ? areasContext : '',
4802
+ areasContext || '',
4592
4803
  areasContext ? '' : '',
4593
4804
  flowContext ? 'Node-RED context:' : '',
4594
- flowContext ? flowContext : '',
4805
+ flowContext || '',
4595
4806
  flowContext ? '' : '',
4596
- docsContext ? 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
- const maxTokensRaw = (maxTokensOverride !== null && maxTokensOverride !== undefined && maxTokensOverride !== '')
6389
- ? Number(maxTokensOverride)
6390
- : Number(node.llmMaxTokens)
6391
- const resolvedMaxTokens = Number.isFinite(maxTokensRaw) && maxTokensRaw > 0 ? Math.round(maxTokensRaw) : 10000
6392
- const configuredTimeoutMs = Number(node.llmTimeoutMs)
6393
- const resolvedTimeoutMs = Number.isFinite(configuredTimeoutMs) && configuredTimeoutMs > 0 ? Math.round(configuredTimeoutMs) : 30000
6394
- const effectiveTimeoutMs = Math.max(120000, resolvedTimeoutMs)
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 || 'http://localhost:11434/api/chat'
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
- const json = await postJson({ url, body, timeoutMs: effectiveTimeoutMs })
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
- || msg.includes('Invalid schema for response_format')
6448
- || msg.includes('response_format')
6449
- || msg.includes('json_schema')
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