node-red-contrib-knx-ultimate 6.1.1 → 6.2.1
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 +17 -2
- package/examples/KNX AI - Conversational Control with Confirmation.json +374 -0
- package/examples/KNX AI - Telegrambot Direct Chat.json +239 -0
- package/nodes/commonFunctions.js +0 -174
- package/nodes/knxUltimateAI.html +359 -85
- package/nodes/knxUltimateAI.js +1762 -63
- package/nodes/locales/de/knxUltimateAI.html +80 -3
- package/nodes/locales/de/knxUltimateAI.json +39 -8
- package/nodes/locales/en/knxUltimateAI.html +84 -3
- package/nodes/locales/en/knxUltimateAI.json +39 -8
- package/nodes/locales/es/knxUltimateAI.html +80 -3
- package/nodes/locales/es/knxUltimateAI.json +39 -8
- package/nodes/locales/fr/knxUltimateAI.html +80 -3
- package/nodes/locales/fr/knxUltimateAI.json +39 -8
- package/nodes/locales/it/knxUltimateAI.html +84 -3
- package/nodes/locales/it/knxUltimateAI.json +39 -8
- package/nodes/locales/zh-CN/knxUltimateAI.html +80 -3
- package/nodes/locales/zh-CN/knxUltimateAI.json +39 -8
- package/nodes/utils/knxAiHomeMemory.js +507 -0
- package/nodes/utils/sysLogger.js +0 -109
- package/package.json +1 -2
- package/resources/KNXAIChatAdapterMappings.js +87 -0
- package/nodes/plugins/knxUltimateMonitor-sidebar-plugin.html +0 -922
package/nodes/knxUltimateAI.js
CHANGED
|
@@ -5,6 +5,23 @@ const fs = require('fs')
|
|
|
5
5
|
const path = require('path')
|
|
6
6
|
const { spawn } = require('child_process')
|
|
7
7
|
const { getRequestAccessToken, normalizeAuthFromAccessTokenQuery } = require('./utils/httpAdminAccessToken')
|
|
8
|
+
const {
|
|
9
|
+
HOME_MEMORY_MAX_EDUCATION_CHARS,
|
|
10
|
+
HOME_MEMORY_MAX_SEMANTIC_OBJECTS,
|
|
11
|
+
addBoundedKnxAiNotification,
|
|
12
|
+
addBoundedKnxAiObservation,
|
|
13
|
+
buildKnxAiHomeMemoryMarkdown,
|
|
14
|
+
buildKnxAiProactiveFallback,
|
|
15
|
+
clampHomeMemoryKb,
|
|
16
|
+
classifyKnxAiOpenState,
|
|
17
|
+
createEmptyKnxAiHomeMemory,
|
|
18
|
+
enrichKnxAiHomeCatalog,
|
|
19
|
+
isKnxAiQuietTime,
|
|
20
|
+
normalizeKnxAiHomeMemory,
|
|
21
|
+
normalizeHomeLanguage,
|
|
22
|
+
parseKnxAiHomeMemoryMarkdown,
|
|
23
|
+
updateKnxAiCoverHabit
|
|
24
|
+
} = require('./utils/knxAiHomeMemory')
|
|
8
25
|
let googleTranslateTTS = null
|
|
9
26
|
try {
|
|
10
27
|
googleTranslateTTS = require('google-translate-tts')
|
|
@@ -738,6 +755,626 @@ const extractJsonFragmentFromText = (value) => {
|
|
|
738
755
|
throw new Error(`The LLM response did not contain valid JSON${preview ? ` (preview: ${preview})` : ''}`)
|
|
739
756
|
}
|
|
740
757
|
|
|
758
|
+
const parseKnxAiConversationResponse = (value) => {
|
|
759
|
+
const parsed = extractJsonFragmentFromText(value)
|
|
760
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
761
|
+
throw new Error('The LLM conversation response must be a JSON object')
|
|
762
|
+
}
|
|
763
|
+
const reply = String(parsed.reply !== undefined
|
|
764
|
+
? parsed.reply
|
|
765
|
+
: parsed.answer !== undefined
|
|
766
|
+
? parsed.answer
|
|
767
|
+
: parsed.text !== undefined
|
|
768
|
+
? parsed.text
|
|
769
|
+
: '').trim()
|
|
770
|
+
const commands = Array.isArray(parsed.commands)
|
|
771
|
+
? parsed.commands
|
|
772
|
+
: Array.isArray(parsed.actions)
|
|
773
|
+
? parsed.actions
|
|
774
|
+
: []
|
|
775
|
+
const language = String(parsed.language !== undefined
|
|
776
|
+
? parsed.language
|
|
777
|
+
: parsed.locale !== undefined
|
|
778
|
+
? parsed.locale
|
|
779
|
+
: '').trim()
|
|
780
|
+
return { reply, commands, language }
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
const extractKnxAiQuestion = (msg) => {
|
|
784
|
+
const source = msg && typeof msg === 'object' ? msg : {}
|
|
785
|
+
if (source.prompt !== undefined && source.prompt !== null) return String(source.prompt).trim()
|
|
786
|
+
if (typeof source.payload === 'string') return source.payload.trim()
|
|
787
|
+
if (source.payload && typeof source.payload === 'object') {
|
|
788
|
+
if (typeof source.payload.content === 'string') return source.payload.content.trim()
|
|
789
|
+
if (typeof source.payload.text === 'string') return source.payload.text.trim()
|
|
790
|
+
if (source.payload.message && typeof source.payload.message.text === 'string') return source.payload.message.text.trim()
|
|
791
|
+
}
|
|
792
|
+
if (source.originalMessage && typeof source.originalMessage === 'object') {
|
|
793
|
+
if (typeof source.originalMessage.text === 'string') return source.originalMessage.text.trim()
|
|
794
|
+
if (source.originalMessage.message && typeof source.originalMessage.message.text === 'string') return source.originalMessage.message.text.trim()
|
|
795
|
+
}
|
|
796
|
+
return source.payload === undefined ? '' : safeStringify(source.payload)
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
const resolveKnxAiSessionId = (msg) => {
|
|
800
|
+
const source = msg && typeof msg === 'object' ? msg : {}
|
|
801
|
+
const candidates = [
|
|
802
|
+
source.knxAi && source.knxAi.sessionId,
|
|
803
|
+
source.sessionId,
|
|
804
|
+
source.chatId,
|
|
805
|
+
source.payload && typeof source.payload === 'object' ? source.payload.chatId : '',
|
|
806
|
+
source.payload && source.payload.chat && typeof source.payload.chat === 'object' ? source.payload.chat.id : '',
|
|
807
|
+
source.originalMessage && source.originalMessage.chat ? source.originalMessage.chat.id : '',
|
|
808
|
+
source.originalMessage && source.originalMessage.message && source.originalMessage.message.chat
|
|
809
|
+
? source.originalMessage.message.chat.id
|
|
810
|
+
: ''
|
|
811
|
+
]
|
|
812
|
+
const hit = candidates.find(candidate => candidate !== undefined && candidate !== null && String(candidate).trim() !== '')
|
|
813
|
+
return String(hit === undefined ? 'default' : hit).trim().slice(0, 160) || 'default'
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
const classifyKnxAiConfirmation = ({ msg, question, topic } = {}) => {
|
|
817
|
+
const source = msg && typeof msg === 'object' ? msg : {}
|
|
818
|
+
if (source.knxAi && source.knxAi.confirm === true) return 'confirm'
|
|
819
|
+
if (source.knxAi && source.knxAi.confirm === false) return 'cancel'
|
|
820
|
+
const topicValue = String(topic || '').trim().toLowerCase()
|
|
821
|
+
if (topicValue === 'confirm') return 'confirm'
|
|
822
|
+
if (topicValue === 'cancel') return 'cancel'
|
|
823
|
+
const normalized = String(question || '')
|
|
824
|
+
.normalize('NFKD')
|
|
825
|
+
.replace(/[\u0300-\u036f]/g, '')
|
|
826
|
+
.trim()
|
|
827
|
+
.toLowerCase()
|
|
828
|
+
.replace(/[.!?。!?]+$/g, '')
|
|
829
|
+
.replace(/\s+/g, ' ')
|
|
830
|
+
const confirmations = new Set([
|
|
831
|
+
'confirm', 'confirmed', 'yes', 'y', 'ok', 'proceed',
|
|
832
|
+
'conferma', 'confermo', 'si', 'procedi',
|
|
833
|
+
'confirmer', 'confirme', 'oui',
|
|
834
|
+
'bestatigen', 'bestatige', 'ja',
|
|
835
|
+
'confirmar', 'confirmo', 'adelante',
|
|
836
|
+
'确认', '是'
|
|
837
|
+
])
|
|
838
|
+
const cancellations = new Set([
|
|
839
|
+
'cancel', 'cancelled', 'no', 'n', 'stop',
|
|
840
|
+
'annulla', 'annullo', 'no grazie',
|
|
841
|
+
'annuler', 'annule', 'non',
|
|
842
|
+
'abbrechen', 'abbruch', 'nein',
|
|
843
|
+
'cancelar', 'cancelo',
|
|
844
|
+
'取消', '否'
|
|
845
|
+
])
|
|
846
|
+
if (confirmations.has(normalized)) return 'confirm'
|
|
847
|
+
if (cancellations.has(normalized)) return 'cancel'
|
|
848
|
+
return 'none'
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
const detectKnxAiLanguageFromText = (value) => {
|
|
852
|
+
const raw = String(value || '').trim()
|
|
853
|
+
if (!raw) return ''
|
|
854
|
+
if (/[\u3400-\u9fff]/u.test(raw)) return 'zh'
|
|
855
|
+
const normalized = raw
|
|
856
|
+
.normalize('NFKD')
|
|
857
|
+
.replace(/[\u0300-\u036f]/g, '')
|
|
858
|
+
.toLowerCase()
|
|
859
|
+
const tokens = new Set(normalized.match(/[a-z]+/g) || [])
|
|
860
|
+
const dictionaries = {
|
|
861
|
+
it: ['accendi', 'spegni', 'luce', 'luci', 'soggiorno', 'cucina', 'apri', 'chiudi', 'alza', 'abbassa', 'tapparella', 'tapparelle'],
|
|
862
|
+
en: ['turn', 'switch', 'light', 'lights', 'living', 'room', 'kitchen', 'open', 'close', 'raise', 'lower', 'blind', 'blinds'],
|
|
863
|
+
de: ['schalte', 'licht', 'lichter', 'wohnzimmer', 'kuche', 'offne', 'schliesse', 'hoch', 'runter', 'rollladen'],
|
|
864
|
+
fr: ['allume', 'eteins', 'lumiere', 'lumieres', 'salon', 'cuisine', 'ouvre', 'ferme', 'monte', 'baisse', 'volet'],
|
|
865
|
+
es: ['enciende', 'apaga', 'luz', 'luces', 'salon', 'cocina', 'abre', 'cierra', 'sube', 'baja', 'persiana']
|
|
866
|
+
}
|
|
867
|
+
const scores = Object.entries(dictionaries).map(([language, words]) => ({
|
|
868
|
+
language,
|
|
869
|
+
score: words.reduce((total, word) => total + (tokens.has(word) ? 1 : 0), 0)
|
|
870
|
+
})).sort((a, b) => b.score - a.score)
|
|
871
|
+
if (!scores[0] || scores[0].score === 0) return ''
|
|
872
|
+
if (scores[1] && scores[0].score === scores[1].score) return ''
|
|
873
|
+
return scores[0].language
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
const resolveKnxAiLanguage = (msg, fallback = 'en', question = '', responseLanguage = '') => {
|
|
877
|
+
const source = msg && typeof msg === 'object' ? msg : {}
|
|
878
|
+
const explicitCandidates = [
|
|
879
|
+
source.knxAi && source.knxAi.language,
|
|
880
|
+
source.language,
|
|
881
|
+
source.payload && typeof source.payload === 'object' ? source.payload.language : ''
|
|
882
|
+
]
|
|
883
|
+
const inferredCandidates = [
|
|
884
|
+
responseLanguage,
|
|
885
|
+
detectKnxAiLanguageFromText(question),
|
|
886
|
+
source.originalMessage && source.originalMessage.from ? source.originalMessage.from.language_code : '',
|
|
887
|
+
source.originalMessage && source.originalMessage.message && source.originalMessage.message.from
|
|
888
|
+
? source.originalMessage.message.from.language_code
|
|
889
|
+
: ''
|
|
890
|
+
]
|
|
891
|
+
const supported = new Set(['en', 'it', 'de', 'fr', 'es', 'zh'])
|
|
892
|
+
for (const candidate of explicitCandidates.concat(inferredCandidates)) {
|
|
893
|
+
if (candidate === undefined || candidate === null || String(candidate).trim() === '') continue
|
|
894
|
+
const language = normalizeLanguageCode(candidate, '')
|
|
895
|
+
if (supported.has(language)) return language
|
|
896
|
+
}
|
|
897
|
+
const fallbackLanguage = normalizeLanguageCode(fallback, 'en')
|
|
898
|
+
return supported.has(fallbackLanguage) ? fallbackLanguage : 'en'
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
const getKnxAiConfirmationCopy = (language) => {
|
|
902
|
+
const copies = {
|
|
903
|
+
en: {
|
|
904
|
+
preview: 'KNX changes awaiting confirmation',
|
|
905
|
+
instruction: 'Reply exactly CONFIRM to proceed or CANCEL to discard them. The request expires in 5 minutes.',
|
|
906
|
+
confirmLabel: 'Confirm',
|
|
907
|
+
cancelLabel: 'Cancel',
|
|
908
|
+
confirmed: count => `Confirmed: ${count} KNX command(s) forwarded to the flow. Execution still requires KNX status feedback.`,
|
|
909
|
+
cancelled: 'Cancelled: no KNX command was sent.',
|
|
910
|
+
expired: 'The pending KNX command request has expired. Please repeat the original request.',
|
|
911
|
+
missing: 'There is no KNX command request awaiting confirmation.',
|
|
912
|
+
invalid: details => `The pending KNX commands are no longer valid and were not sent: ${details}.`
|
|
913
|
+
},
|
|
914
|
+
it: {
|
|
915
|
+
preview: 'Modifiche KNX in attesa di conferma',
|
|
916
|
+
instruction: 'Rispondi esattamente CONFERMA per procedere oppure ANNULLA per eliminarle. La richiesta scade tra 5 minuti.',
|
|
917
|
+
confirmLabel: 'Conferma',
|
|
918
|
+
cancelLabel: 'Annulla',
|
|
919
|
+
confirmed: count => `Confermato: ${count} comando/i KNX inoltrato/i al flow. L'esecuzione deve comunque essere verificata tramite lo stato KNX.`,
|
|
920
|
+
cancelled: 'Annullato: non è stato inviato alcun comando KNX.',
|
|
921
|
+
expired: 'La richiesta di comandi KNX è scaduta. Ripeti la richiesta originale.',
|
|
922
|
+
missing: 'Non ci sono comandi KNX in attesa di conferma.',
|
|
923
|
+
invalid: details => `I comandi KNX in attesa non sono più validi e non sono stati inviati: ${details}.`
|
|
924
|
+
},
|
|
925
|
+
de: {
|
|
926
|
+
preview: 'KNX-Änderungen warten auf Bestätigung',
|
|
927
|
+
instruction: 'Antworte genau mit BESTÄTIGEN oder ABBRECHEN. Die Anfrage läuft nach 5 Minuten ab.',
|
|
928
|
+
confirmLabel: 'Bestätigen',
|
|
929
|
+
cancelLabel: 'Abbrechen',
|
|
930
|
+
confirmed: count => `Bestätigt: ${count} KNX-Befehl(e) an den Flow weitergegeben. Die Ausführung muss über KNX-Statusfeedback geprüft werden.`,
|
|
931
|
+
cancelled: 'Abgebrochen: Es wurde kein KNX-Befehl gesendet.',
|
|
932
|
+
expired: 'Die ausstehende KNX-Anfrage ist abgelaufen. Bitte die ursprüngliche Anfrage wiederholen.',
|
|
933
|
+
missing: 'Es wartet keine KNX-Anfrage auf Bestätigung.',
|
|
934
|
+
invalid: details => `Die ausstehenden KNX-Befehle sind nicht mehr gültig und wurden nicht gesendet: ${details}.`
|
|
935
|
+
},
|
|
936
|
+
fr: {
|
|
937
|
+
preview: 'Modifications KNX en attente de confirmation',
|
|
938
|
+
instruction: 'Répondez exactement CONFIRMER pour continuer ou ANNULER pour abandonner. La demande expire dans 5 minutes.',
|
|
939
|
+
confirmLabel: 'Confirmer',
|
|
940
|
+
cancelLabel: 'Annuler',
|
|
941
|
+
confirmed: count => `Confirmé : ${count} commande(s) KNX transmise(s) au flow. L'exécution doit encore être vérifiée par un retour d'état KNX.`,
|
|
942
|
+
cancelled: 'Annulé : aucune commande KNX n’a été envoyée.',
|
|
943
|
+
expired: 'La demande de commandes KNX a expiré. Répétez la demande initiale.',
|
|
944
|
+
missing: 'Aucune commande KNX n’est en attente de confirmation.',
|
|
945
|
+
invalid: details => `Les commandes KNX en attente ne sont plus valides et n’ont pas été envoyées : ${details}.`
|
|
946
|
+
},
|
|
947
|
+
es: {
|
|
948
|
+
preview: 'Cambios KNX pendientes de confirmación',
|
|
949
|
+
instruction: 'Responde exactamente CONFIRMAR para continuar o CANCELAR para descartarlos. La solicitud caduca en 5 minutos.',
|
|
950
|
+
confirmLabel: 'Confirmar',
|
|
951
|
+
cancelLabel: 'Cancelar',
|
|
952
|
+
confirmed: count => `Confirmado: ${count} comando(s) KNX enviado(s) al flow. La ejecución aún debe verificarse mediante el estado KNX.`,
|
|
953
|
+
cancelled: 'Cancelado: no se envió ningún comando KNX.',
|
|
954
|
+
expired: 'La solicitud de comandos KNX ha caducado. Repite la solicitud original.',
|
|
955
|
+
missing: 'No hay comandos KNX pendientes de confirmación.',
|
|
956
|
+
invalid: details => `Los comandos KNX pendientes ya no son válidos y no se enviaron: ${details}.`
|
|
957
|
+
},
|
|
958
|
+
zh: {
|
|
959
|
+
preview: '等待确认的 KNX 更改',
|
|
960
|
+
instruction: '请准确回复“确认”以继续,或回复“取消”以放弃。请求将在 5 分钟后过期。',
|
|
961
|
+
confirmLabel: '确认',
|
|
962
|
+
cancelLabel: '取消',
|
|
963
|
+
confirmed: count => `已确认:${count} 条 KNX 命令已转发到 flow。仍需通过 KNX 状态反馈确认执行结果。`,
|
|
964
|
+
cancelled: '已取消:未发送任何 KNX 命令。',
|
|
965
|
+
expired: '待处理的 KNX 命令请求已过期,请重新发送原始请求。',
|
|
966
|
+
missing: '当前没有等待确认的 KNX 命令。',
|
|
967
|
+
invalid: details => `待处理的 KNX 命令已失效,未发送:${details}。`
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
return copies[language] || copies.en
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
const getKnxAiReadCopy = (language) => {
|
|
974
|
+
const copies = {
|
|
975
|
+
en: {
|
|
976
|
+
heading: 'Updated KNX readings',
|
|
977
|
+
noResponse: 'The KNX read request was sent, but no device replied within the timeout.',
|
|
978
|
+
partial: 'No response received for'
|
|
979
|
+
},
|
|
980
|
+
it: {
|
|
981
|
+
heading: 'Letture KNX aggiornate',
|
|
982
|
+
noResponse: 'La richiesta di lettura KNX è stata inviata, ma nessun dispositivo ha risposto entro il timeout.',
|
|
983
|
+
partial: 'Nessuna risposta ricevuta per'
|
|
984
|
+
},
|
|
985
|
+
de: {
|
|
986
|
+
heading: 'Aktualisierte KNX-Messwerte',
|
|
987
|
+
noResponse: 'Die KNX-Leseanfrage wurde gesendet, aber innerhalb des Timeouts hat kein Gerät geantwortet.',
|
|
988
|
+
partial: 'Keine Antwort erhalten für'
|
|
989
|
+
},
|
|
990
|
+
fr: {
|
|
991
|
+
heading: 'Lectures KNX actualisées',
|
|
992
|
+
noResponse: 'La demande de lecture KNX a été envoyée, mais aucun appareil n’a répondu avant l’expiration du délai.',
|
|
993
|
+
partial: 'Aucune réponse reçue pour'
|
|
994
|
+
},
|
|
995
|
+
es: {
|
|
996
|
+
heading: 'Lecturas KNX actualizadas',
|
|
997
|
+
noResponse: 'La solicitud de lectura KNX se envió, pero ningún dispositivo respondió antes de agotarse el tiempo.',
|
|
998
|
+
partial: 'No se recibió respuesta para'
|
|
999
|
+
},
|
|
1000
|
+
zh: {
|
|
1001
|
+
heading: '已更新的 KNX 读数',
|
|
1002
|
+
noResponse: 'KNX 读取请求已发送,但在超时前没有设备响应。',
|
|
1003
|
+
partial: '未收到响应'
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
return copies[language] || copies.en
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
const formatKnxAiReadResults = ({ operations, results, language }) => {
|
|
1010
|
+
const reads = Array.isArray(operations) ? operations : []
|
|
1011
|
+
const settled = Array.isArray(results) ? results : []
|
|
1012
|
+
const copy = getKnxAiReadCopy(language)
|
|
1013
|
+
const values = []
|
|
1014
|
+
const missing = []
|
|
1015
|
+
reads.forEach((operation, index) => {
|
|
1016
|
+
const result = settled[index]
|
|
1017
|
+
const label = String(operation && (operation.label || operation.destination) ? (operation.label || operation.destination) : '').trim()
|
|
1018
|
+
if (!result || result.status !== 'fulfilled' || !result.value) {
|
|
1019
|
+
missing.push(label || String(operation && operation.destination ? operation.destination : '').trim())
|
|
1020
|
+
return
|
|
1021
|
+
}
|
|
1022
|
+
const telegram = result.value
|
|
1023
|
+
const rawValue = telegram.payload
|
|
1024
|
+
const value = rawValue && typeof rawValue === 'object' ? safeStringify(rawValue) : String(rawValue)
|
|
1025
|
+
const unit = String(telegram.payloadmeasureunit || '').trim()
|
|
1026
|
+
values.push(`- ${label || operation.destination}: ${value}${unit ? ` ${unit}` : ''}`)
|
|
1027
|
+
})
|
|
1028
|
+
if (values.length === 0) return copy.noResponse
|
|
1029
|
+
const lines = [`${copy.heading}:`, ...values]
|
|
1030
|
+
if (missing.length > 0) lines.push('', `${copy.partial}: ${missing.filter(Boolean).join(', ')}.`)
|
|
1031
|
+
return lines.join('\n')
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
const buildKnxAiConfirmationRequest = ({
|
|
1035
|
+
sessionId,
|
|
1036
|
+
expiresAt,
|
|
1037
|
+
commandCount,
|
|
1038
|
+
copy
|
|
1039
|
+
}) => {
|
|
1040
|
+
const resolvedSessionId = String(sessionId || 'default')
|
|
1041
|
+
const resolvedExpiresAt = Number(expiresAt || 0)
|
|
1042
|
+
const buildAction = ({ id, label, confirm }) => ({
|
|
1043
|
+
id,
|
|
1044
|
+
label,
|
|
1045
|
+
callbackData: id,
|
|
1046
|
+
message: {
|
|
1047
|
+
topic: id,
|
|
1048
|
+
knxAi: {
|
|
1049
|
+
confirm,
|
|
1050
|
+
sessionId: resolvedSessionId
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
})
|
|
1054
|
+
return {
|
|
1055
|
+
required: true,
|
|
1056
|
+
status: 'pending',
|
|
1057
|
+
sessionId: resolvedSessionId,
|
|
1058
|
+
expiresAt: resolvedExpiresAt,
|
|
1059
|
+
expiresAtIso: resolvedExpiresAt > 0 ? new Date(resolvedExpiresAt).toISOString() : '',
|
|
1060
|
+
commandCount: Math.max(0, Number(commandCount) || 0),
|
|
1061
|
+
actions: [
|
|
1062
|
+
buildAction({ id: 'confirm', label: copy.confirmLabel, confirm: true }),
|
|
1063
|
+
buildAction({ id: 'cancel', label: copy.cancelLabel, confirm: false })
|
|
1064
|
+
]
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
const cloneKnxAiInputMessage = (inputMessage, cloneMessage, onError) => {
|
|
1069
|
+
const source = inputMessage && typeof inputMessage === 'object' ? inputMessage : {}
|
|
1070
|
+
if (typeof cloneMessage === 'function') {
|
|
1071
|
+
try {
|
|
1072
|
+
const cloned = cloneMessage(source)
|
|
1073
|
+
if (cloned && typeof cloned === 'object') return cloned
|
|
1074
|
+
} catch (error) {
|
|
1075
|
+
try {
|
|
1076
|
+
if (typeof onError === 'function') onError(error)
|
|
1077
|
+
} catch (reportError) {
|
|
1078
|
+
// Reporting a clone error must never propagate to the Node-RED runtime.
|
|
1079
|
+
}
|
|
1080
|
+
// Fall back to a shallow copy if a custom message property cannot be cloned.
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
1083
|
+
try {
|
|
1084
|
+
return Object.assign({}, source)
|
|
1085
|
+
} catch (error) {
|
|
1086
|
+
try {
|
|
1087
|
+
if (typeof onError === 'function') onError(error)
|
|
1088
|
+
} catch (reportError) {
|
|
1089
|
+
// Reporting a clone error must never propagate to the Node-RED runtime.
|
|
1090
|
+
}
|
|
1091
|
+
return {}
|
|
1092
|
+
}
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
const safeKnxAiSend = ({ outputs, send, onError }) => {
|
|
1096
|
+
try {
|
|
1097
|
+
if (typeof send !== 'function') throw new Error('KNX AI output sender is unavailable')
|
|
1098
|
+
send(outputs)
|
|
1099
|
+
return true
|
|
1100
|
+
} catch (error) {
|
|
1101
|
+
try {
|
|
1102
|
+
if (typeof onError === 'function') onError(error)
|
|
1103
|
+
} catch (reportError) {
|
|
1104
|
+
// Reporting an output error must never propagate to the Node-RED runtime.
|
|
1105
|
+
}
|
|
1106
|
+
return false
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
const compileKnxAiChatAdapter = ({ code, direction = 'chat' } = {}) => {
|
|
1111
|
+
const source = String(code || '').trim()
|
|
1112
|
+
if (!source) return null
|
|
1113
|
+
const adapterDirection = String(direction || 'chat').trim() || 'chat'
|
|
1114
|
+
try {
|
|
1115
|
+
return {
|
|
1116
|
+
direction: adapterDirection,
|
|
1117
|
+
run: new Function('msg', 'inputMessage', 'node', 'RED', `"use strict";\n${source}`) // eslint-disable-line no-new-func
|
|
1118
|
+
}
|
|
1119
|
+
} catch (error) {
|
|
1120
|
+
throw new Error(`Invalid KNX AI ${adapterDirection} adapter: ${error.message || error}`)
|
|
1121
|
+
}
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
const executeKnxAiChatAdapter = ({
|
|
1125
|
+
adapter,
|
|
1126
|
+
msg,
|
|
1127
|
+
inputMessage,
|
|
1128
|
+
node,
|
|
1129
|
+
RED
|
|
1130
|
+
} = {}) => {
|
|
1131
|
+
if (!adapter || typeof adapter.run !== 'function') return msg
|
|
1132
|
+
const result = adapter.run(msg, inputMessage, node, RED)
|
|
1133
|
+
if (result && typeof result.then === 'function') {
|
|
1134
|
+
throw new Error(`KNX AI ${adapter.direction || 'chat'} adapter must be synchronous`)
|
|
1135
|
+
}
|
|
1136
|
+
if (result === undefined || result === null) return null
|
|
1137
|
+
if (!result || typeof result !== 'object' || Array.isArray(result)) {
|
|
1138
|
+
throw new Error(`KNX AI ${adapter.direction || 'chat'} adapter must return msg or no value`)
|
|
1139
|
+
}
|
|
1140
|
+
return result
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
const buildKnxAiUniversalMessage = ({
|
|
1144
|
+
command,
|
|
1145
|
+
question,
|
|
1146
|
+
sessionId,
|
|
1147
|
+
confirmed,
|
|
1148
|
+
index,
|
|
1149
|
+
inputMessage
|
|
1150
|
+
}) => {
|
|
1151
|
+
const operation = command && typeof command === 'object' ? command : {}
|
|
1152
|
+
const event = operation.event === 'GroupValue_Read' ? 'GroupValue_Read' : 'GroupValue_Write'
|
|
1153
|
+
const outputMessage = {
|
|
1154
|
+
topic: operation.destination,
|
|
1155
|
+
destination: operation.destination,
|
|
1156
|
+
dpt: operation.dpt,
|
|
1157
|
+
payload: event === 'GroupValue_Read' ? '' : operation.payload,
|
|
1158
|
+
event,
|
|
1159
|
+
inputMessage,
|
|
1160
|
+
knxAi: {
|
|
1161
|
+
type: event === 'GroupValue_Read' ? 'knx_read' : 'knx_command',
|
|
1162
|
+
source: 'llm',
|
|
1163
|
+
question,
|
|
1164
|
+
sessionId,
|
|
1165
|
+
confirmed: event === 'GroupValue_Write' && confirmed === true,
|
|
1166
|
+
index,
|
|
1167
|
+
label: operation.label || '',
|
|
1168
|
+
reason: operation.reason || ''
|
|
1169
|
+
}
|
|
1170
|
+
}
|
|
1171
|
+
if (event === 'GroupValue_Read') outputMessage.readstatus = true
|
|
1172
|
+
return outputMessage
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
const formatKnxAiCommandPreview = ({ commands, copy }) => {
|
|
1176
|
+
const lines = (Array.isArray(commands) ? commands : []).map((command, index) => {
|
|
1177
|
+
const payload = typeof command.payload === 'string' ? command.payload : safeStringify(command.payload)
|
|
1178
|
+
return `${index + 1}. ${command.label || command.destination} — ${command.destination} / DPT ${command.dpt} → ${payload}`
|
|
1179
|
+
})
|
|
1180
|
+
return [
|
|
1181
|
+
copy.preview + ':',
|
|
1182
|
+
...lines,
|
|
1183
|
+
'',
|
|
1184
|
+
copy.instruction
|
|
1185
|
+
].join('\n')
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
const validateKnxAiPayloadForDpt = ({ dpt, payload }) => {
|
|
1189
|
+
const resolved = dptlib.resolve(dpt)
|
|
1190
|
+
const base = resolved && resolved.basetype ? resolved.basetype : {}
|
|
1191
|
+
const subtype = resolved && resolved.subtype ? resolved.subtype : {}
|
|
1192
|
+
const main = String(dpt || '').split('.')[0]
|
|
1193
|
+
|
|
1194
|
+
if (base.valuetype === 'composite' && (!payload || typeof payload !== 'object' || Array.isArray(payload))) {
|
|
1195
|
+
throw new Error(`DPT ${dpt} requires an object payload`)
|
|
1196
|
+
}
|
|
1197
|
+
if (main === '1' && typeof payload !== 'boolean') {
|
|
1198
|
+
throw new Error(`DPT ${dpt} requires a boolean payload`)
|
|
1199
|
+
}
|
|
1200
|
+
if (main === '3') {
|
|
1201
|
+
const direction = Number(payload && payload.decr_incr)
|
|
1202
|
+
const data = Number(payload && payload.data)
|
|
1203
|
+
if (![0, 1].includes(direction) || !Number.isInteger(data) || data < 0 || data > 7) {
|
|
1204
|
+
throw new Error(`DPT ${dpt} requires {decr_incr:0|1,data:0..7}`)
|
|
1205
|
+
}
|
|
1206
|
+
}
|
|
1207
|
+
if (main === '232') {
|
|
1208
|
+
const channels = ['red', 'green', 'blue'].map(key => Number(payload && payload[key]))
|
|
1209
|
+
if (channels.some(value => !Number.isInteger(value) || value < 0 || value > 255)) {
|
|
1210
|
+
throw new Error(`DPT ${dpt} requires {red,green,blue} values from 0 to 255`)
|
|
1211
|
+
}
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
if (typeof payload === 'number') {
|
|
1215
|
+
const candidateRange = Array.isArray(subtype.scalar_range) && subtype.scalar_range.length === 2
|
|
1216
|
+
? subtype.scalar_range
|
|
1217
|
+
: Array.isArray(subtype.range) && subtype.range.length === 2
|
|
1218
|
+
? subtype.range
|
|
1219
|
+
: Array.isArray(base.range) && base.range.length === 2
|
|
1220
|
+
? base.range
|
|
1221
|
+
: null
|
|
1222
|
+
if (candidateRange && Number.isFinite(Number(candidateRange[0])) && Number.isFinite(Number(candidateRange[1]))) {
|
|
1223
|
+
const min = Number(candidateRange[0])
|
|
1224
|
+
const max = Number(candidateRange[1])
|
|
1225
|
+
if (payload < min || payload > max) throw new Error(`DPT ${dpt} payload must be between ${min} and ${max}`)
|
|
1226
|
+
}
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
if (subtype.enc && typeof subtype.enc === 'object' && !Array.isArray(subtype.enc) && main !== '1') {
|
|
1230
|
+
const allowed = new Set(Object.keys(subtype.enc).map(key => String(key)))
|
|
1231
|
+
if (!allowed.has(String(payload))) throw new Error(`DPT ${dpt} payload is not an allowed enumerated value`)
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
if (resolved && typeof resolved.formatAPDU === 'function') resolved.formatAPDU(payload)
|
|
1235
|
+
return payload
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
const coerceKnxAiCommandPayload = (value, { dpt } = {}) => {
|
|
1239
|
+
const dptId = String(dpt || '').trim()
|
|
1240
|
+
const parsed = parseActuatorPayloadInput(value)
|
|
1241
|
+
const main = dptId.split('.')[0]
|
|
1242
|
+
if (main === '1') {
|
|
1243
|
+
if (parsed === true || parsed === false) return parsed
|
|
1244
|
+
if (parsed === 1) return true
|
|
1245
|
+
if (parsed === 0) return false
|
|
1246
|
+
const rawBoolean = typeof parsed === 'string' ? parsed.trim() : String(parsed)
|
|
1247
|
+
const normalized = rawBoolean.toLowerCase()
|
|
1248
|
+
if (['true', '1', 'on'].includes(normalized)) return true
|
|
1249
|
+
if (['false', '0', 'off'].includes(normalized)) return false
|
|
1250
|
+
const normalizedLabel = normalizeSignalText(rawBoolean)
|
|
1251
|
+
const booleanOption = getDptValueOptions(dptId).find(item => {
|
|
1252
|
+
return normalizeSignalText(item.label) === normalizedLabel
|
|
1253
|
+
})
|
|
1254
|
+
if (booleanOption) {
|
|
1255
|
+
const optionValue = parseActuatorPayloadInput(booleanOption.value)
|
|
1256
|
+
if (optionValue === true || optionValue === 1) return true
|
|
1257
|
+
if (optionValue === false || optionValue === 0) return false
|
|
1258
|
+
}
|
|
1259
|
+
throw new Error(`DPT ${dptId} payload must be true/false, 1/0, on/off, or an exact ETS value label`)
|
|
1260
|
+
}
|
|
1261
|
+
if (typeof parsed !== 'string') return parsed
|
|
1262
|
+
const raw = parsed.trim()
|
|
1263
|
+
if (main === '16') return raw
|
|
1264
|
+
const normalizedRaw = normalizeSignalText(raw)
|
|
1265
|
+
const option = getDptValueOptions(dptId).find(item => {
|
|
1266
|
+
return String(item.value) === raw || normalizeSignalText(item.label) === normalizedRaw
|
|
1267
|
+
})
|
|
1268
|
+
if (option) return parseActuatorPayloadInput(option.value)
|
|
1269
|
+
throw new Error(`DPT ${dptId} payload must be a typed JSON value or an exact ETS value label`)
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
const resolveKnxAiOperationEvent = (candidate) => {
|
|
1273
|
+
const item = candidate && typeof candidate === 'object' ? candidate : {}
|
|
1274
|
+
const raw = String(item.event || item.operation || item.action || '').trim().toLowerCase()
|
|
1275
|
+
if (['groupvalue_read', 'read', 'query', 'request_status'].includes(raw)) return 'GroupValue_Read'
|
|
1276
|
+
return 'GroupValue_Write'
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
const normalizeKnxAiCommandCandidates = ({
|
|
1280
|
+
commands,
|
|
1281
|
+
catalog,
|
|
1282
|
+
maxCommands = 5,
|
|
1283
|
+
maxReadCommands = 20,
|
|
1284
|
+
coercePayload = value => value
|
|
1285
|
+
} = {}) => {
|
|
1286
|
+
const sourceCommands = Array.isArray(commands) ? commands : []
|
|
1287
|
+
const safeCatalog = Array.isArray(catalog) ? catalog : []
|
|
1288
|
+
const catalogByGa = new Map(safeCatalog.map(item => [String(item && item.ga ? item.ga : '').trim(), item]))
|
|
1289
|
+
const accepted = []
|
|
1290
|
+
const rejected = []
|
|
1291
|
+
const writeLimit = Math.max(1, Math.min(20, Number(maxCommands) || 5))
|
|
1292
|
+
const readLimit = Math.max(1, Math.min(50, Number(maxReadCommands) || 20))
|
|
1293
|
+
const totalLimit = writeLimit + readLimit
|
|
1294
|
+
let acceptedWrites = 0
|
|
1295
|
+
let acceptedReads = 0
|
|
1296
|
+
let writeLimitReported = false
|
|
1297
|
+
let readLimitReported = false
|
|
1298
|
+
|
|
1299
|
+
sourceCommands.slice(0, totalLimit).forEach((candidate, index) => {
|
|
1300
|
+
try {
|
|
1301
|
+
const item = candidate && typeof candidate === 'object' ? candidate : {}
|
|
1302
|
+
const event = resolveKnxAiOperationEvent(item)
|
|
1303
|
+
const destination = String(item.destination || item.ga || item.groupAddress || item.address || '').trim()
|
|
1304
|
+
if (!destination) throw new Error('missing destination')
|
|
1305
|
+
const catalogItem = catalogByGa.get(destination)
|
|
1306
|
+
if (!catalogItem) throw new Error('destination is not present in the imported ETS catalog')
|
|
1307
|
+
if (event === 'GroupValue_Write' && String(catalogItem.role || '').trim().toLowerCase() !== 'command') {
|
|
1308
|
+
throw new Error('destination is not classified as a command group address')
|
|
1309
|
+
}
|
|
1310
|
+
const catalogDpt = String(catalogItem.dpt || '').trim()
|
|
1311
|
+
if (!catalogDpt) throw new Error('the ETS catalog has no DPT for this destination')
|
|
1312
|
+
const requestedDpt = String(item.dpt || '').trim()
|
|
1313
|
+
if (requestedDpt && requestedDpt !== catalogDpt) {
|
|
1314
|
+
throw new Error(`requested DPT ${requestedDpt} does not match ETS DPT ${catalogDpt}`)
|
|
1315
|
+
}
|
|
1316
|
+
if (event === 'GroupValue_Read') {
|
|
1317
|
+
if (acceptedReads >= readLimit) {
|
|
1318
|
+
if (readLimitReported) return
|
|
1319
|
+
readLimitReported = true
|
|
1320
|
+
throw new Error(`read limit exceeded (${readLimit})`)
|
|
1321
|
+
}
|
|
1322
|
+
acceptedReads += 1
|
|
1323
|
+
accepted.push({
|
|
1324
|
+
destination,
|
|
1325
|
+
dpt: catalogDpt,
|
|
1326
|
+
payload: '',
|
|
1327
|
+
readstatus: true,
|
|
1328
|
+
event,
|
|
1329
|
+
label: String(catalogItem.label || destination).trim(),
|
|
1330
|
+
reason: String(item.reason || '').trim(),
|
|
1331
|
+
sourceIndex: index
|
|
1332
|
+
})
|
|
1333
|
+
return
|
|
1334
|
+
}
|
|
1335
|
+
if (acceptedWrites >= writeLimit) {
|
|
1336
|
+
if (writeLimitReported) return
|
|
1337
|
+
writeLimitReported = true
|
|
1338
|
+
throw new Error(`command limit exceeded (${writeLimit})`)
|
|
1339
|
+
}
|
|
1340
|
+
const hasPayload = Object.prototype.hasOwnProperty.call(item, 'payload') || Object.prototype.hasOwnProperty.call(item, 'value')
|
|
1341
|
+
if (!hasPayload) throw new Error('missing payload')
|
|
1342
|
+
const rawPayload = Object.prototype.hasOwnProperty.call(item, 'payload') ? item.payload : item.value
|
|
1343
|
+
const payload = coercePayload(rawPayload, {
|
|
1344
|
+
dpt: catalogDpt,
|
|
1345
|
+
action: item.action,
|
|
1346
|
+
reason: item.reason,
|
|
1347
|
+
label: catalogItem.label,
|
|
1348
|
+
destination
|
|
1349
|
+
})
|
|
1350
|
+
validateKnxAiPayloadForDpt({ dpt: catalogDpt, payload })
|
|
1351
|
+
acceptedWrites += 1
|
|
1352
|
+
accepted.push({
|
|
1353
|
+
destination,
|
|
1354
|
+
dpt: catalogDpt,
|
|
1355
|
+
payload,
|
|
1356
|
+
event: 'GroupValue_Write',
|
|
1357
|
+
label: String(catalogItem.label || destination).trim(),
|
|
1358
|
+
reason: String(item.reason || '').trim(),
|
|
1359
|
+
sourceIndex: index
|
|
1360
|
+
})
|
|
1361
|
+
} catch (error) {
|
|
1362
|
+
rejected.push({
|
|
1363
|
+
sourceIndex: index,
|
|
1364
|
+
reason: error && error.message ? error.message : String(error)
|
|
1365
|
+
})
|
|
1366
|
+
}
|
|
1367
|
+
})
|
|
1368
|
+
|
|
1369
|
+
if (sourceCommands.length > totalLimit) {
|
|
1370
|
+
rejected.push({
|
|
1371
|
+
sourceIndex: totalLimit,
|
|
1372
|
+
reason: `operation limit exceeded (${totalLimit})`
|
|
1373
|
+
})
|
|
1374
|
+
}
|
|
1375
|
+
return { accepted, rejected }
|
|
1376
|
+
}
|
|
1377
|
+
|
|
741
1378
|
const normalizeValueForCompare = (value) => {
|
|
742
1379
|
if (value === undefined) return 'undefined'
|
|
743
1380
|
if (value === null) return 'null'
|
|
@@ -2939,6 +3576,8 @@ const ensureOllamaServerRunning = async ({ baseUrl, autoStart = false, timeoutMs
|
|
|
2939
3576
|
const isProbablyChatModelId = (id) => {
|
|
2940
3577
|
const s = String(id || '').toLowerCase()
|
|
2941
3578
|
if (!s) return false
|
|
3579
|
+
if (s === 'babbage-002' || s === 'davinci-002') return false
|
|
3580
|
+
if (s.includes('instruct')) return false
|
|
2942
3581
|
if (s.includes('embedding')) return false
|
|
2943
3582
|
if (s.includes('whisper')) return false
|
|
2944
3583
|
if (s.includes('tts')) return false
|
|
@@ -2947,6 +3586,109 @@ const isProbablyChatModelId = (id) => {
|
|
|
2947
3586
|
return true
|
|
2948
3587
|
}
|
|
2949
3588
|
|
|
3589
|
+
const isChatCompletionsModelError = (value) => {
|
|
3590
|
+
const message = String(value || '').toLowerCase()
|
|
3591
|
+
return message.includes('not a chat model') ||
|
|
3592
|
+
message.includes('not supported in the v1/chat/completions endpoint') ||
|
|
3593
|
+
message.includes('only compatible with the legacy completions endpoint') ||
|
|
3594
|
+
message.includes('not chat completions') ||
|
|
3595
|
+
message.includes('does not support chat completions')
|
|
3596
|
+
}
|
|
3597
|
+
|
|
3598
|
+
const decorateChatCompletionsModelError = ({ error, model, url }) => {
|
|
3599
|
+
const originalMessage = String(error && error.message ? error.message : error || '').trim()
|
|
3600
|
+
const selectedModel = String(model || '').trim() || '(empty)'
|
|
3601
|
+
const endpoint = String(url || '').trim() || OPENAI_COMPAT_DEFAULT_CHAT_URL
|
|
3602
|
+
const decorated = new Error(
|
|
3603
|
+
`Model "${selectedModel}" is not compatible with the configured Chat Completions endpoint (${endpoint}). ` +
|
|
3604
|
+
'Choose a chat-capable model, for example gpt-5.4 or gpt-4o-mini. ' +
|
|
3605
|
+
`Legacy /v1/completions models are not supported by KNX AI.${originalMessage ? ` Provider response: ${originalMessage}` : ''}`
|
|
3606
|
+
)
|
|
3607
|
+
if (error && error.status !== undefined) decorated.status = error.status
|
|
3608
|
+
if (error && error.response !== undefined) decorated.response = error.response
|
|
3609
|
+
return decorated
|
|
3610
|
+
}
|
|
3611
|
+
|
|
3612
|
+
const isUnsupportedTemperatureError = (value) => {
|
|
3613
|
+
const message = String(value || '').toLowerCase()
|
|
3614
|
+
return message.includes("unsupported value: 'temperature'") ||
|
|
3615
|
+
message.includes('unsupported parameter: temperature') ||
|
|
3616
|
+
(message.includes('temperature') && message.includes('only the default'))
|
|
3617
|
+
}
|
|
3618
|
+
|
|
3619
|
+
const isResponseFormatCompatibilityError = (value) => {
|
|
3620
|
+
const message = String(value || '')
|
|
3621
|
+
return message.includes("Unsupported parameter: 'response_format'") ||
|
|
3622
|
+
message.includes('Invalid schema for response_format') ||
|
|
3623
|
+
message.includes('response_format') ||
|
|
3624
|
+
message.includes('json_schema')
|
|
3625
|
+
}
|
|
3626
|
+
|
|
3627
|
+
const postOpenAiCompatibleChatWithFallbacks = async ({
|
|
3628
|
+
url,
|
|
3629
|
+
headers,
|
|
3630
|
+
body,
|
|
3631
|
+
timeoutMs,
|
|
3632
|
+
model,
|
|
3633
|
+
post = postJson
|
|
3634
|
+
}) => {
|
|
3635
|
+
let requestBody = Object.assign({}, body)
|
|
3636
|
+
let lastError = null
|
|
3637
|
+
const rejectedTokenParameters = new Set()
|
|
3638
|
+
const hasOwn = key => Object.prototype.hasOwnProperty.call(requestBody, key)
|
|
3639
|
+
|
|
3640
|
+
for (let attempt = 0; attempt < 6; attempt++) {
|
|
3641
|
+
try {
|
|
3642
|
+
return await post({ url, headers, body: requestBody, timeoutMs })
|
|
3643
|
+
} catch (error) {
|
|
3644
|
+
lastError = error
|
|
3645
|
+
const message = String(error && error.message ? error.message : '')
|
|
3646
|
+
|
|
3647
|
+
if (isChatCompletionsModelError(message)) {
|
|
3648
|
+
throw decorateChatCompletionsModelError({ error, model, url })
|
|
3649
|
+
}
|
|
3650
|
+
|
|
3651
|
+
if (isUnsupportedTemperatureError(message) && hasOwn('temperature')) {
|
|
3652
|
+
requestBody = Object.assign({}, requestBody)
|
|
3653
|
+
delete requestBody.temperature
|
|
3654
|
+
continue
|
|
3655
|
+
}
|
|
3656
|
+
|
|
3657
|
+
if (isResponseFormatCompatibilityError(message) && hasOwn('response_format')) {
|
|
3658
|
+
requestBody = Object.assign({}, requestBody)
|
|
3659
|
+
delete requestBody.response_format
|
|
3660
|
+
continue
|
|
3661
|
+
}
|
|
3662
|
+
|
|
3663
|
+
if (message.includes("Unsupported parameter: 'max_tokens'") && hasOwn('max_tokens')) {
|
|
3664
|
+
rejectedTokenParameters.add('max_tokens')
|
|
3665
|
+
const value = requestBody.max_tokens
|
|
3666
|
+
requestBody = Object.assign({}, requestBody)
|
|
3667
|
+
delete requestBody.max_tokens
|
|
3668
|
+
if (!rejectedTokenParameters.has('max_completion_tokens')) {
|
|
3669
|
+
requestBody.max_completion_tokens = value
|
|
3670
|
+
continue
|
|
3671
|
+
}
|
|
3672
|
+
}
|
|
3673
|
+
|
|
3674
|
+
if (message.includes("Unsupported parameter: 'max_completion_tokens'") && hasOwn('max_completion_tokens')) {
|
|
3675
|
+
rejectedTokenParameters.add('max_completion_tokens')
|
|
3676
|
+
const value = requestBody.max_completion_tokens
|
|
3677
|
+
requestBody = Object.assign({}, requestBody)
|
|
3678
|
+
delete requestBody.max_completion_tokens
|
|
3679
|
+
if (!rejectedTokenParameters.has('max_tokens')) {
|
|
3680
|
+
requestBody.max_tokens = value
|
|
3681
|
+
continue
|
|
3682
|
+
}
|
|
3683
|
+
}
|
|
3684
|
+
|
|
3685
|
+
throw error
|
|
3686
|
+
}
|
|
3687
|
+
}
|
|
3688
|
+
|
|
3689
|
+
throw lastError || new Error('OpenAI-compatible chat request failed after compatibility retries')
|
|
3690
|
+
}
|
|
3691
|
+
|
|
2950
3692
|
module.exports = function (RED) {
|
|
2951
3693
|
const flowGACache = { at: 0, set: new Set() }
|
|
2952
3694
|
const flowNodeCatalogCache = { at: 0, catalog: null }
|
|
@@ -4306,6 +5048,19 @@ module.exports = function (RED) {
|
|
|
4306
5048
|
node.llmDocsLanguage = config.llmDocsLanguage ? String(config.llmDocsLanguage) : 'it'
|
|
4307
5049
|
node.llmDocsMaxSnippets = (config.llmDocsMaxSnippets === undefined || config.llmDocsMaxSnippets === '') ? 5 : Number(config.llmDocsMaxSnippets)
|
|
4308
5050
|
node.llmDocsMaxChars = (config.llmDocsMaxChars === undefined || config.llmDocsMaxChars === '') ? 60000 : Number(config.llmDocsMaxChars)
|
|
5051
|
+
node.llmAllowKnxCommands = config.llmAllowKnxCommands !== undefined ? coerceBoolean(config.llmAllowKnxCommands) : false
|
|
5052
|
+
node.llmRequireCommandConfirmation = config.llmRequireCommandConfirmation !== undefined ? coerceBoolean(config.llmRequireCommandConfirmation) : true
|
|
5053
|
+
node.chatAdapterPreset = String(config.chatAdapterPreset || 'none')
|
|
5054
|
+
node.chatInputCode = String(config.chatInputCode || '')
|
|
5055
|
+
node.chatOutputCode = String(config.chatOutputCode || '')
|
|
5056
|
+
node.proactiveEnabled = config.proactiveEnabled !== undefined ? coerceBoolean(config.proactiveEnabled) : false
|
|
5057
|
+
node.proactiveRecipient = String(config.proactiveRecipient || '').trim()
|
|
5058
|
+
node.proactiveOpenMinutes = Math.max(1, Math.min(1440, Number(config.proactiveOpenMinutes) || 120))
|
|
5059
|
+
node.proactiveCooldownMinutes = Math.max(5, Math.min(10080, Number(config.proactiveCooldownMinutes) || 360))
|
|
5060
|
+
node.proactiveQuietStart = String(config.proactiveQuietStart || '23:00').trim()
|
|
5061
|
+
node.proactiveQuietEnd = String(config.proactiveQuietEnd || '07:00').trim()
|
|
5062
|
+
node.homeMemoryMaxKb = clampHomeMemoryKb(config.homeMemoryMaxKb)
|
|
5063
|
+
node.aiEducation = String(config.aiEducation || '').slice(0, HOME_MEMORY_MAX_EDUCATION_CHARS)
|
|
4309
5064
|
|
|
4310
5065
|
const pushStatus = (status) => {
|
|
4311
5066
|
if (!status) return
|
|
@@ -4326,6 +5081,25 @@ module.exports = function (RED) {
|
|
|
4326
5081
|
pushStatus(status)
|
|
4327
5082
|
}
|
|
4328
5083
|
|
|
5084
|
+
const compileConfiguredChatAdapter = ({ code, direction }) => {
|
|
5085
|
+
try {
|
|
5086
|
+
return compileKnxAiChatAdapter({ code, direction })
|
|
5087
|
+
} catch (error) {
|
|
5088
|
+
try { node.sysLogger?.error(`knxUltimateAI ${direction} adapter compile error: ${error.message || error}`) } catch (e) { /* ignore */ }
|
|
5089
|
+
try { node.error(error) } catch (e) { /* ignore */ }
|
|
5090
|
+
return null
|
|
5091
|
+
}
|
|
5092
|
+
}
|
|
5093
|
+
|
|
5094
|
+
node._chatInputAdapter = compileConfiguredChatAdapter({
|
|
5095
|
+
code: node.chatInputCode,
|
|
5096
|
+
direction: 'chat input'
|
|
5097
|
+
})
|
|
5098
|
+
node._chatOutputAdapter = compileConfiguredChatAdapter({
|
|
5099
|
+
code: node.chatOutputCode,
|
|
5100
|
+
direction: 'chat output'
|
|
5101
|
+
})
|
|
5102
|
+
|
|
4329
5103
|
// Used to call the status update from the config node.
|
|
4330
5104
|
node.setNodeStatus = ({ fill, shape, text, payload, GA, dpt, devicename }) => {
|
|
4331
5105
|
try {
|
|
@@ -4357,6 +5131,8 @@ module.exports = function (RED) {
|
|
|
4357
5131
|
node._summaryRebuildTimer = null
|
|
4358
5132
|
node._anomalies = []
|
|
4359
5133
|
node._assistantLog = []
|
|
5134
|
+
node._conversationSessions = new Map()
|
|
5135
|
+
node._pendingKnxCommands = new Map()
|
|
4360
5136
|
node._flowContextCache = { at: 0, text: '' }
|
|
4361
5137
|
node._docsContextCache = { at: 0, question: '', text: '' }
|
|
4362
5138
|
node._areaSuggestionCache = { ref: null, snapshot: buildSuggestedAreasFromCsv([]) }
|
|
@@ -4371,6 +5147,16 @@ module.exports = function (RED) {
|
|
|
4371
5147
|
node._gaLabelCsvCache = { ref: null, map: {} }
|
|
4372
5148
|
node._busConnectionWatchTimer = null
|
|
4373
5149
|
node._historyDiskLastPruneAt = 0
|
|
5150
|
+
node._homeMemory = createEmptyKnxAiHomeMemory()
|
|
5151
|
+
node._homeMemoryWriteTimer = null
|
|
5152
|
+
node._homeMemoryPeriodicTimer = null
|
|
5153
|
+
node._proactiveCheckTimer = null
|
|
5154
|
+
node._proactiveStates = new Map()
|
|
5155
|
+
node._proactiveInFlight = new Set()
|
|
5156
|
+
node._proactiveGlobalSentAt = []
|
|
5157
|
+
node._homeCatalogByGa = null
|
|
5158
|
+
node._homeCatalogSnapshotRef = null
|
|
5159
|
+
node._closing = false
|
|
4374
5160
|
node._busConnectionState = (node.serverKNX && typeof node.serverKNX.linkStatus === 'string')
|
|
4375
5161
|
? String(node.serverKNX.linkStatus).toLowerCase()
|
|
4376
5162
|
: 'unknown'
|
|
@@ -5483,6 +6269,7 @@ module.exports = function (RED) {
|
|
|
5483
6269
|
const wantsFunctionNodeSourceContext = node.llmIncludeFlowContext && shouldIncludeFunctionNodeSourceContext(question)
|
|
5484
6270
|
const areasSnapshot = buildAreasSnapshot({ summary })
|
|
5485
6271
|
const areasContext = buildAreasPromptContext(areasSnapshot)
|
|
6272
|
+
const homeMemoryContext = getHomeMemoryPromptContext({ maxChars: compactMode ? 2200 : 6000 })
|
|
5486
6273
|
const summaryForPrompt = buildLlmSummarySnapshot(summary)
|
|
5487
6274
|
const summaryText = truncatePromptText(safeStringify(summaryForPrompt), compactMode ? 4000 : 10000)
|
|
5488
6275
|
const lines = recent.map(t => {
|
|
@@ -5564,6 +6351,8 @@ module.exports = function (RED) {
|
|
|
5564
6351
|
'',
|
|
5565
6352
|
areasContext || '',
|
|
5566
6353
|
areasContext ? '' : '',
|
|
6354
|
+
homeMemoryContext || '',
|
|
6355
|
+
homeMemoryContext ? '' : '',
|
|
5567
6356
|
flowContext ? 'Node-RED context:' : '',
|
|
5568
6357
|
flowContext || '',
|
|
5569
6358
|
flowContext ? '' : '',
|
|
@@ -5604,10 +6393,10 @@ module.exports = function (RED) {
|
|
|
5604
6393
|
if (node._gaCatalogCache && node._gaCatalogCache.ref === csv && node._gaCatalogCache.roleOverridesKey === roleOverridesKey && Array.isArray(node._gaCatalogCache.snapshot)) {
|
|
5605
6394
|
return node._gaCatalogCache.snapshot
|
|
5606
6395
|
}
|
|
5607
|
-
const snapshot = applyGaRoleOverridesToCatalog({
|
|
6396
|
+
const snapshot = enrichKnxAiHomeCatalog(applyGaRoleOverridesToCatalog({
|
|
5608
6397
|
catalog: buildGaCatalogFromCsv(csv),
|
|
5609
6398
|
roleOverrides
|
|
5610
|
-
})
|
|
6399
|
+
}))
|
|
5611
6400
|
node._gaCatalogCache = { ref: csv, roleOverridesKey, snapshot }
|
|
5612
6401
|
return snapshot
|
|
5613
6402
|
}
|
|
@@ -5635,6 +6424,147 @@ module.exports = function (RED) {
|
|
|
5635
6424
|
|
|
5636
6425
|
const getHistoryArchiveFile = (dayKey) => path.join(getHistoryArchiveDir(), `${String(dayKey || '').trim() || formatArchiveDayKey(Date.now())}.jsonl`)
|
|
5637
6426
|
|
|
6427
|
+
const getHomeMemoryFile = () => {
|
|
6428
|
+
const baseDir = (node.serverKNX && node.serverKNX.userDir)
|
|
6429
|
+
? node.serverKNX.userDir
|
|
6430
|
+
: path.join(RED.settings.userDir, 'knxultimatestorage')
|
|
6431
|
+
return path.join(baseDir, 'knxai', 'memory', `knxai-home-memory-${node.id}.md`)
|
|
6432
|
+
}
|
|
6433
|
+
|
|
6434
|
+
const cleanupHomeMemoryTempFiles = () => {
|
|
6435
|
+
try {
|
|
6436
|
+
const filePath = getHomeMemoryFile()
|
|
6437
|
+
const dirPath = path.dirname(filePath)
|
|
6438
|
+
if (!fs.existsSync(dirPath)) return
|
|
6439
|
+
const prefix = `${path.basename(filePath)}.tmp-`
|
|
6440
|
+
fs.readdirSync(dirPath)
|
|
6441
|
+
.filter(name => String(name).startsWith(prefix))
|
|
6442
|
+
.forEach(name => {
|
|
6443
|
+
try { fs.unlinkSync(path.join(dirPath, name)) } catch (error) { /* ignore */ }
|
|
6444
|
+
})
|
|
6445
|
+
} catch (error) {
|
|
6446
|
+
try { node.sysLogger?.warn(`KNX AI home memory temporary-file cleanup error: ${error.message || error}`) } catch (logError) { /* ignore */ }
|
|
6447
|
+
}
|
|
6448
|
+
}
|
|
6449
|
+
|
|
6450
|
+
const getHomeCatalogMap = () => {
|
|
6451
|
+
const snapshot = getGaCatalogSnapshot()
|
|
6452
|
+
if (node._homeCatalogSnapshotRef === snapshot && node._homeCatalogByGa instanceof Map) return node._homeCatalogByGa
|
|
6453
|
+
node._homeCatalogSnapshotRef = snapshot
|
|
6454
|
+
node._homeCatalogByGa = new Map(snapshot.map(item => [String(item && item.ga ? item.ga : '').trim(), item]))
|
|
6455
|
+
return node._homeCatalogByGa
|
|
6456
|
+
}
|
|
6457
|
+
|
|
6458
|
+
const synchronizeHomeMemorySemanticObjects = () => {
|
|
6459
|
+
const semanticObjects = getGaCatalogSnapshot()
|
|
6460
|
+
.filter(item => item && item.semantic && item.semantic.kind !== 'unknown')
|
|
6461
|
+
.sort((a, b) => Number(b.semantic.confidence || 0) - Number(a.semantic.confidence || 0))
|
|
6462
|
+
.slice(0, HOME_MEMORY_MAX_SEMANTIC_OBJECTS)
|
|
6463
|
+
.map(item => ({
|
|
6464
|
+
ga: item.ga,
|
|
6465
|
+
dpt: item.dpt,
|
|
6466
|
+
label: item.label || item.etsName || item.ga,
|
|
6467
|
+
kind: item.semantic.kind,
|
|
6468
|
+
area: item.semantic.area || '',
|
|
6469
|
+
role: item.role || 'neutral',
|
|
6470
|
+
confidence: Number(item.semantic.confidence || 0)
|
|
6471
|
+
}))
|
|
6472
|
+
node._homeMemory.semanticObjects = semanticObjects
|
|
6473
|
+
node._homeMemory.updatedAt = new Date().toISOString()
|
|
6474
|
+
}
|
|
6475
|
+
|
|
6476
|
+
const persistHomeMemoryNow = () => {
|
|
6477
|
+
try {
|
|
6478
|
+
synchronizeHomeMemorySemanticObjects()
|
|
6479
|
+
const rendered = buildKnxAiHomeMemoryMarkdown({
|
|
6480
|
+
memory: node._homeMemory,
|
|
6481
|
+
education: node.aiEducation,
|
|
6482
|
+
maxKb: node.homeMemoryMaxKb
|
|
6483
|
+
})
|
|
6484
|
+
node._homeMemory = rendered.memory
|
|
6485
|
+
const filePath = getHomeMemoryFile()
|
|
6486
|
+
const dirPath = path.dirname(filePath)
|
|
6487
|
+
if (!ensureDirectorySync(dirPath)) throw new Error(`Unable to create ${dirPath}`)
|
|
6488
|
+
const tempPath = `${filePath}.tmp-${process.pid}-${Date.now()}`
|
|
6489
|
+
try {
|
|
6490
|
+
fs.writeFileSync(tempPath, rendered.markdown, 'utf8')
|
|
6491
|
+
fs.renameSync(tempPath, filePath)
|
|
6492
|
+
} catch (error) {
|
|
6493
|
+
try { if (fs.existsSync(tempPath)) fs.unlinkSync(tempPath) } catch (cleanupError) { /* ignore */ }
|
|
6494
|
+
throw error
|
|
6495
|
+
}
|
|
6496
|
+
return {
|
|
6497
|
+
filePath,
|
|
6498
|
+
bytes: rendered.bytes,
|
|
6499
|
+
maxBytes: rendered.maxBytes
|
|
6500
|
+
}
|
|
6501
|
+
} catch (error) {
|
|
6502
|
+
try { node.sysLogger?.warn(`KNX AI home memory write error: ${error.message || error}`) } catch (logError) { /* ignore */ }
|
|
6503
|
+
return null
|
|
6504
|
+
}
|
|
6505
|
+
}
|
|
6506
|
+
|
|
6507
|
+
const scheduleHomeMemoryPersist = ({ immediate = false } = {}) => {
|
|
6508
|
+
if (node._homeMemoryWriteTimer) {
|
|
6509
|
+
clearTimeout(node._homeMemoryWriteTimer)
|
|
6510
|
+
node._homeMemoryWriteTimer = null
|
|
6511
|
+
}
|
|
6512
|
+
if (immediate) return persistHomeMemoryNow()
|
|
6513
|
+
node._homeMemoryWriteTimer = setTimeout(() => {
|
|
6514
|
+
node._homeMemoryWriteTimer = null
|
|
6515
|
+
persistHomeMemoryNow()
|
|
6516
|
+
}, 1500)
|
|
6517
|
+
return null
|
|
6518
|
+
}
|
|
6519
|
+
|
|
6520
|
+
const loadHomeMemoryFromDisk = () => {
|
|
6521
|
+
const filePath = getHomeMemoryFile()
|
|
6522
|
+
try {
|
|
6523
|
+
cleanupHomeMemoryTempFiles()
|
|
6524
|
+
if (!fs.existsSync(filePath)) {
|
|
6525
|
+
node._homeMemory = createEmptyKnxAiHomeMemory()
|
|
6526
|
+
return scheduleHomeMemoryPersist({ immediate: true })
|
|
6527
|
+
}
|
|
6528
|
+
const stat = fs.statSync(filePath)
|
|
6529
|
+
const absoluteReadLimit = 4 * 1024 * 1024
|
|
6530
|
+
if (Number(stat.size || 0) > absoluteReadLimit) {
|
|
6531
|
+
throw new Error(`memory file exceeds the safe read limit (${absoluteReadLimit} bytes)`)
|
|
6532
|
+
}
|
|
6533
|
+
node._homeMemory = normalizeKnxAiHomeMemory(parseKnxAiHomeMemoryMarkdown(fs.readFileSync(filePath, 'utf8')))
|
|
6534
|
+
return scheduleHomeMemoryPersist({ immediate: true })
|
|
6535
|
+
} catch (error) {
|
|
6536
|
+
node._homeMemory = createEmptyKnxAiHomeMemory()
|
|
6537
|
+
try { node.sysLogger?.warn(`KNX AI home memory load error: ${error.message || error}`) } catch (logError) { /* ignore */ }
|
|
6538
|
+
return scheduleHomeMemoryPersist({ immediate: true })
|
|
6539
|
+
}
|
|
6540
|
+
}
|
|
6541
|
+
|
|
6542
|
+
const getHomeMemoryPromptContext = ({ maxChars = 6000 } = {}) => {
|
|
6543
|
+
const memory = normalizeKnxAiHomeMemory(node._homeMemory)
|
|
6544
|
+
const education = String(node.aiEducation || '').trim().slice(0, HOME_MEMORY_MAX_EDUCATION_CHARS)
|
|
6545
|
+
const habitLines = memory.habits.slice(-20).map(item => {
|
|
6546
|
+
return `- ${item.label || item.ga}: average open ${Number(item.averageMinutes || 0).toFixed(1)} min (${Number(item.samples || 0)} samples), last ${Number(item.lastMinutes || 0).toFixed(1)} min`
|
|
6547
|
+
})
|
|
6548
|
+
const observationLines = memory.observations.slice(-20).map(item => {
|
|
6549
|
+
return `- ${item.at || ''} ${item.label || item.ga || ''}: ${item.event || item.value || item.type || ''}`
|
|
6550
|
+
})
|
|
6551
|
+
const educationContext = [
|
|
6552
|
+
'USER-MANAGED AI EDUCATION (authoritative; never rewrite or contradict it):',
|
|
6553
|
+
education || '(none)'
|
|
6554
|
+
].join('\n')
|
|
6555
|
+
const learnedContext = [
|
|
6556
|
+
'BOUNDED LEARNED HOME MEMORY:',
|
|
6557
|
+
habitLines.length ? habitLines.join('\n') : '(no stable habits learned yet)',
|
|
6558
|
+
observationLines.length ? `\nRecent significant observations:\n${observationLines.join('\n')}` : ''
|
|
6559
|
+
].join('\n')
|
|
6560
|
+
const targetChars = Math.max(500, Number(maxChars) || 6000)
|
|
6561
|
+
const remainingChars = Math.max(0, targetChars - educationContext.length - 2)
|
|
6562
|
+
return [
|
|
6563
|
+
educationContext,
|
|
6564
|
+
remainingChars > 0 ? truncatePromptText(learnedContext, remainingChars) : ''
|
|
6565
|
+
].filter(Boolean).join('\n\n')
|
|
6566
|
+
}
|
|
6567
|
+
|
|
5638
6568
|
const pruneHistoryArchiveFiles = ({ force = false } = {}) => {
|
|
5639
6569
|
if (node.historyStoreToDisk !== true) return
|
|
5640
6570
|
const retentionDays = Math.max(1, Math.round(Number.isFinite(Number(node.historyStoreRetentionDays)) ? Number(node.historyStoreRetentionDays) : 1))
|
|
@@ -7603,47 +8533,15 @@ module.exports = function (RED) {
|
|
|
7603
8533
|
})
|
|
7604
8534
|
: baseBody
|
|
7605
8535
|
|
|
7606
|
-
//
|
|
7607
|
-
//
|
|
7608
|
-
const
|
|
7609
|
-
|
|
7610
|
-
|
|
7611
|
-
|
|
7612
|
-
|
|
7613
|
-
|
|
7614
|
-
|
|
7615
|
-
return msg.includes("Unsupported parameter: 'response_format'") ||
|
|
7616
|
-
msg.includes('Invalid schema for response_format') ||
|
|
7617
|
-
msg.includes('response_format') ||
|
|
7618
|
-
msg.includes('json_schema')
|
|
7619
|
-
}
|
|
7620
|
-
|
|
7621
|
-
let json
|
|
7622
|
-
try {
|
|
7623
|
-
json = await postJson({ url, headers, body: bodyWithMaxTokens, timeoutMs: effectiveTimeoutMs })
|
|
7624
|
-
} catch (error) {
|
|
7625
|
-
const msg = (error && error.message) ? String(error.message) : ''
|
|
7626
|
-
if (isResponseFormatCompatibilityError(msg)) {
|
|
7627
|
-
try {
|
|
7628
|
-
json = await postJson({ url, headers, body: plainBodyWithMaxTokens, timeoutMs: effectiveTimeoutMs })
|
|
7629
|
-
} catch (innerError) {
|
|
7630
|
-
const innerMsg = (innerError && innerError.message) ? String(innerError.message) : ''
|
|
7631
|
-
if (innerMsg.includes("Unsupported parameter: 'max_tokens'") || innerMsg.includes('max_completion_tokens')) {
|
|
7632
|
-
json = await postJson({ url, headers, body: plainBodyWithMaxCompletionTokens, timeoutMs: effectiveTimeoutMs })
|
|
7633
|
-
} else if (innerMsg.includes("Unsupported parameter: 'max_completion_tokens'")) {
|
|
7634
|
-
json = await postJson({ url, headers, body: plainBodyWithMaxTokens, timeoutMs: effectiveTimeoutMs })
|
|
7635
|
-
} else {
|
|
7636
|
-
throw innerError
|
|
7637
|
-
}
|
|
7638
|
-
}
|
|
7639
|
-
} else if (msg.includes("Unsupported parameter: 'max_tokens'") || msg.includes('max_completion_tokens')) {
|
|
7640
|
-
json = await postJson({ url, headers, body: bodyWithMaxCompletionTokens, timeoutMs: effectiveTimeoutMs })
|
|
7641
|
-
} else if (msg.includes("Unsupported parameter: 'max_completion_tokens'")) {
|
|
7642
|
-
json = await postJson({ url, headers, body: bodyWithMaxTokens, timeoutMs: effectiveTimeoutMs })
|
|
7643
|
-
} else {
|
|
7644
|
-
throw error
|
|
7645
|
-
}
|
|
7646
|
-
}
|
|
8536
|
+
// OpenAI-compatible providers differ on optional sampling, response-format,
|
|
8537
|
+
// and token-limit parameters. Retry only the rejected compatibility field.
|
|
8538
|
+
const json = await postOpenAiCompatibleChatWithFallbacks({
|
|
8539
|
+
url,
|
|
8540
|
+
headers,
|
|
8541
|
+
body: Object.assign({ max_tokens: resolvedMaxTokens }, schemaBody),
|
|
8542
|
+
timeoutMs: effectiveTimeoutMs,
|
|
8543
|
+
model: baseBody.model
|
|
8544
|
+
})
|
|
7647
8545
|
const content = extractOpenAICompatText(json) || buildOpenAICompatFallbackText(json)
|
|
7648
8546
|
const finishReason = String(json && json.choices && json.choices[0] && json.choices[0].finish_reason ? json.choices[0].finish_reason : '')
|
|
7649
8547
|
return { provider: 'openai_compat', model: baseBody.model, content, finishReason }
|
|
@@ -7844,6 +8742,309 @@ module.exports = function (RED) {
|
|
|
7844
8742
|
return Object.assign({}, ret, { content: finalContent, summary })
|
|
7845
8743
|
}
|
|
7846
8744
|
|
|
8745
|
+
const getConversationHistory = (sessionId) => {
|
|
8746
|
+
const key = String(sessionId || 'default')
|
|
8747
|
+
const history = node._conversationSessions.get(key)
|
|
8748
|
+
return Array.isArray(history) ? history.slice(-8) : []
|
|
8749
|
+
}
|
|
8750
|
+
|
|
8751
|
+
const rememberConversationTurn = ({ sessionId, question, reply }) => {
|
|
8752
|
+
const key = String(sessionId || 'default')
|
|
8753
|
+
const history = getConversationHistory(key)
|
|
8754
|
+
history.push({
|
|
8755
|
+
question: String(question || '').trim(),
|
|
8756
|
+
reply: String(reply || '').trim()
|
|
8757
|
+
})
|
|
8758
|
+
node._conversationSessions.delete(key)
|
|
8759
|
+
node._conversationSessions.set(key, history.slice(-8))
|
|
8760
|
+
while (node._conversationSessions.size > 50) {
|
|
8761
|
+
const oldestKey = node._conversationSessions.keys().next().value
|
|
8762
|
+
node._conversationSessions.delete(oldestKey)
|
|
8763
|
+
}
|
|
8764
|
+
}
|
|
8765
|
+
|
|
8766
|
+
const callConversationalLLM = async ({ question, sessionId, requireConfirmation = true }) => {
|
|
8767
|
+
const summary = rebuildCachedSummaryNow()
|
|
8768
|
+
const catalog = getGaCatalogSnapshot()
|
|
8769
|
+
const history = getConversationHistory(sessionId)
|
|
8770
|
+
const gaLimit = 600
|
|
8771
|
+
const gaLines = catalog.slice(0, gaLimit).map((item) => {
|
|
8772
|
+
const role = String(item && item.role ? item.role : 'neutral').trim()
|
|
8773
|
+
const dpt = String(item && item.dpt ? item.dpt : '').trim() || '?'
|
|
8774
|
+
const label = String(item && item.label ? item.label : item && item.ga ? item.ga : '').trim()
|
|
8775
|
+
const semantic = item && item.semantic && typeof item.semantic === 'object' ? item.semantic : {}
|
|
8776
|
+
const valueOptions = (Array.isArray(item && item.valueOptions) ? item.valueOptions : [])
|
|
8777
|
+
.slice(0, 20)
|
|
8778
|
+
.map(option => `${option.value}=${option.label}`)
|
|
8779
|
+
.join(', ')
|
|
8780
|
+
const semanticText = semantic.kind && semantic.kind !== 'unknown'
|
|
8781
|
+
? ` | semantic ${semantic.kind}${semantic.area ? `/${semantic.area}` : ''} confidence=${Number(semantic.confidence || 0).toFixed(2)}`
|
|
8782
|
+
: ''
|
|
8783
|
+
return `${item.ga} | dpt ${dpt} | role ${role} | ${label}${semanticText}${valueOptions ? ` | values ${valueOptions}` : ''}`
|
|
8784
|
+
})
|
|
8785
|
+
const conversationLines = history.flatMap(turn => [
|
|
8786
|
+
`User: ${turn.question}`,
|
|
8787
|
+
`Assistant: ${turn.reply}`
|
|
8788
|
+
])
|
|
8789
|
+
const analysisContext = buildLLMPrompt({ question, summary, compact: true })
|
|
8790
|
+
const systemPrompt = [
|
|
8791
|
+
node.llmSystemPrompt || 'You are a KNX building automation assistant.',
|
|
8792
|
+
'',
|
|
8793
|
+
'KNX CHAT AND CONTROL CONTRACT:',
|
|
8794
|
+
'- Return only one JSON object with exactly this shape: {"reply":"text for the user","language":"it","commands":[{"event":"GroupValue_Read|GroupValue_Write","destination":"1/2/3","dpt":"1.001","payload":null,"reason":"short reason"}]}.',
|
|
8795
|
+
'- Use the same language as the user for reply and reason.',
|
|
8796
|
+
'- Set language to the ISO code matching the current user request: en, it, de, fr, es, or zh.',
|
|
8797
|
+
'- For an explicit request to refresh, read, query, or retrieve a current KNX state, create GroupValue_Read operations for the exact relevant objects. Use payload null for reads.',
|
|
8798
|
+
'- GroupValue_Read is allowed for exact status, neutral, or command objects in AVAILABLE KNX OBJECTS because it does not modify the bus state.',
|
|
8799
|
+
'- For a question that can be answered from recent data, return commands as an empty array. If current data is missing or the user explicitly asks for a fresh read, request it instead of claiming that read-only objects cannot be queried.',
|
|
8800
|
+
'- Create a GroupValue_Write only when the user clearly asks to control an actuator now.',
|
|
8801
|
+
'- Never invent, guess, transform, or substitute a group address or DPT.',
|
|
8802
|
+
'- A GroupValue_Write destination must appear in AVAILABLE KNX OBJECTS with role command. Status and neutral objects must never receive GroupValue_Write.',
|
|
8803
|
+
'- Copy the DPT exactly from AVAILABLE KNX OBJECTS.',
|
|
8804
|
+
'- For every DPT 1.xxx GroupValue_Write, use a JSON boolean payload: true to activate and false to deactivate. Do not use numeric 1/0 or quoted boolean strings.',
|
|
8805
|
+
'- Emit the smallest necessary operation set, in execution order, with at most 5 writes and 20 reads.',
|
|
8806
|
+
'- Do not claim that an action succeeded. Say that the command is being forwarded or prepared; real KNX feedback is separate.',
|
|
8807
|
+
requireConfirmation ? '- When GroupValue_Write operations are present, explain the proposed changes only. The node appends the exact localized confirmation instructions; do not invent different confirmation wording. Writes have not been sent yet. GroupValue_Read operations do not require confirmation.' : '',
|
|
8808
|
+
'- If the request is ambiguous, unsafe, unsupported, or has no exact KNX object, ask a concise clarification and return no commands.'
|
|
8809
|
+
].filter(Boolean).join('\n')
|
|
8810
|
+
const userContent = [
|
|
8811
|
+
history.length ? 'RECENT CONVERSATION:' : '',
|
|
8812
|
+
history.length ? conversationLines.join('\n') : '',
|
|
8813
|
+
history.length ? '' : '',
|
|
8814
|
+
getHomeMemoryPromptContext({ maxChars: 6000 }),
|
|
8815
|
+
'',
|
|
8816
|
+
analysisContext,
|
|
8817
|
+
'',
|
|
8818
|
+
`AVAILABLE KNX OBJECTS (showing ${Math.min(catalog.length, gaLimit)} of ${catalog.length}; every exact object may be read, but only role command may be written):`,
|
|
8819
|
+
gaLines.length ? gaLines.join('\n') : '(no ETS group addresses imported; return no commands)',
|
|
8820
|
+
'',
|
|
8821
|
+
'Return the JSON object now.'
|
|
8822
|
+
].join('\n')
|
|
8823
|
+
const configuredMaxTokens = Math.max(10000, Number(node.llmMaxTokens) || 0)
|
|
8824
|
+
const ret = await callLLMChat({
|
|
8825
|
+
systemPrompt,
|
|
8826
|
+
userContent,
|
|
8827
|
+
jsonSchema: {
|
|
8828
|
+
name: 'knx_ai_conversation',
|
|
8829
|
+
strict: true,
|
|
8830
|
+
schema: {
|
|
8831
|
+
type: 'object',
|
|
8832
|
+
additionalProperties: false,
|
|
8833
|
+
properties: {
|
|
8834
|
+
reply: { type: 'string' },
|
|
8835
|
+
language: { type: 'string', enum: ['en', 'it', 'de', 'fr', 'es', 'zh'] },
|
|
8836
|
+
commands: {
|
|
8837
|
+
type: 'array',
|
|
8838
|
+
maxItems: 25,
|
|
8839
|
+
items: {
|
|
8840
|
+
type: 'object',
|
|
8841
|
+
additionalProperties: false,
|
|
8842
|
+
properties: {
|
|
8843
|
+
event: { type: 'string', enum: ['GroupValue_Read', 'GroupValue_Write'] },
|
|
8844
|
+
destination: { type: 'string' },
|
|
8845
|
+
dpt: { type: 'string' },
|
|
8846
|
+
payload: {},
|
|
8847
|
+
reason: { type: 'string' }
|
|
8848
|
+
},
|
|
8849
|
+
required: ['event', 'destination', 'dpt', 'payload', 'reason']
|
|
8850
|
+
}
|
|
8851
|
+
}
|
|
8852
|
+
},
|
|
8853
|
+
required: ['reply', 'language', 'commands']
|
|
8854
|
+
}
|
|
8855
|
+
},
|
|
8856
|
+
maxTokensOverride: configuredMaxTokens
|
|
8857
|
+
})
|
|
8858
|
+
|
|
8859
|
+
let envelope
|
|
8860
|
+
try {
|
|
8861
|
+
envelope = parseKnxAiConversationResponse(ret.content)
|
|
8862
|
+
} catch (error) {
|
|
8863
|
+
return Object.assign({}, ret, {
|
|
8864
|
+
content: String(ret.content || '').trim() || 'The AI provider returned an empty response.',
|
|
8865
|
+
commands: [],
|
|
8866
|
+
rejectedCommands: [],
|
|
8867
|
+
summary,
|
|
8868
|
+
structuredOutputError: error.message || String(error)
|
|
8869
|
+
})
|
|
8870
|
+
}
|
|
8871
|
+
|
|
8872
|
+
const normalized = normalizeKnxAiCommandCandidates({
|
|
8873
|
+
commands: envelope.commands,
|
|
8874
|
+
catalog,
|
|
8875
|
+
maxCommands: 5,
|
|
8876
|
+
maxReadCommands: 20,
|
|
8877
|
+
coercePayload: (value, context) => coerceKnxAiCommandPayload(value, context)
|
|
8878
|
+
})
|
|
8879
|
+
let reply = envelope.reply || (normalized.accepted.length ? 'KNX command prepared.' : 'No response text was returned.')
|
|
8880
|
+
if (normalized.rejected.length) {
|
|
8881
|
+
const details = normalized.rejected.map(item => item.reason).join('; ')
|
|
8882
|
+
reply += `\n\nKNX command not sent: ${details}.`
|
|
8883
|
+
}
|
|
8884
|
+
return Object.assign({}, ret, {
|
|
8885
|
+
content: reply,
|
|
8886
|
+
language: envelope.language,
|
|
8887
|
+
commands: normalized.accepted,
|
|
8888
|
+
rejectedCommands: normalized.rejected,
|
|
8889
|
+
summary
|
|
8890
|
+
})
|
|
8891
|
+
}
|
|
8892
|
+
|
|
8893
|
+
const cloneInputMessage = inputMessage => cloneKnxAiInputMessage(
|
|
8894
|
+
inputMessage,
|
|
8895
|
+
RED.util && typeof RED.util.cloneMessage === 'function'
|
|
8896
|
+
? message => RED.util.cloneMessage(message)
|
|
8897
|
+
: null,
|
|
8898
|
+
error => {
|
|
8899
|
+
try { node.sysLogger?.warn(`knxUltimateAI input message clone warning: ${error.message || error}`) } catch (e) { /* ignore */ }
|
|
8900
|
+
}
|
|
8901
|
+
)
|
|
8902
|
+
|
|
8903
|
+
const adaptAssistantOutput = (value, inputMessage) => {
|
|
8904
|
+
if (!node._chatOutputAdapter || value === null || value === undefined) return value
|
|
8905
|
+
const adaptOne = message => executeKnxAiChatAdapter({
|
|
8906
|
+
adapter: node._chatOutputAdapter,
|
|
8907
|
+
msg: message,
|
|
8908
|
+
inputMessage,
|
|
8909
|
+
node,
|
|
8910
|
+
RED
|
|
8911
|
+
})
|
|
8912
|
+
try {
|
|
8913
|
+
if (Array.isArray(value)) {
|
|
8914
|
+
const adapted = value.map(adaptOne).filter(message => message !== null)
|
|
8915
|
+
return adapted.length ? adapted : null
|
|
8916
|
+
}
|
|
8917
|
+
return adaptOne(value)
|
|
8918
|
+
} catch (error) {
|
|
8919
|
+
try { node.sysLogger?.error(`knxUltimateAI chat output adapter error: ${error.message || error}`) } catch (e) { /* ignore */ }
|
|
8920
|
+
try { node.error(error, inputMessage) } catch (e) { /* ignore */ }
|
|
8921
|
+
try { updateStatus({ fill: 'red', shape: 'dot', text: `Chat output adapter error: ${error.message || error}` }) } catch (e) { /* ignore */ }
|
|
8922
|
+
return null
|
|
8923
|
+
}
|
|
8924
|
+
}
|
|
8925
|
+
|
|
8926
|
+
const sendKnxAiOutputs = (outputs, inputMessage) => {
|
|
8927
|
+
const preparedOutputs = Array.isArray(outputs) ? outputs.slice() : outputs
|
|
8928
|
+
if (Array.isArray(preparedOutputs) && preparedOutputs.length > 2) {
|
|
8929
|
+
preparedOutputs[2] = adaptAssistantOutput(preparedOutputs[2], inputMessage)
|
|
8930
|
+
}
|
|
8931
|
+
return safeKnxAiSend({
|
|
8932
|
+
outputs: preparedOutputs,
|
|
8933
|
+
send: messages => node.send(messages),
|
|
8934
|
+
onError: error => {
|
|
8935
|
+
try { node.sysLogger?.error(`knxUltimateAI output error: ${error.message || error}`) } catch (e) { /* ignore */ }
|
|
8936
|
+
try { node.error(error, inputMessage) } catch (e) { /* ignore */ }
|
|
8937
|
+
try { updateStatus({ fill: 'red', shape: 'dot', text: `AI output error: ${error.message || error}` }) } catch (e) { /* ignore */ }
|
|
8938
|
+
}
|
|
8939
|
+
})
|
|
8940
|
+
}
|
|
8941
|
+
|
|
8942
|
+
const buildKnxAiReplyMessage = ({ inputMessage, content, metadata = {}, summary }) => {
|
|
8943
|
+
const replyMessage = cloneInputMessage(inputMessage)
|
|
8944
|
+
replyMessage.topic = node.outputtopic
|
|
8945
|
+
replyMessage.payload = content
|
|
8946
|
+
replyMessage.knxAi = metadata
|
|
8947
|
+
replyMessage.inputMessage = cloneInputMessage(inputMessage)
|
|
8948
|
+
if (summary !== undefined) replyMessage.summary = summary
|
|
8949
|
+
return replyMessage
|
|
8950
|
+
}
|
|
8951
|
+
|
|
8952
|
+
const buildKnxAiCommandMessages = ({ commands, question, sessionId, confirmed, inputMessage }) => {
|
|
8953
|
+
return (Array.isArray(commands) ? commands : []).map((command, index) => buildKnxAiUniversalMessage({
|
|
8954
|
+
command,
|
|
8955
|
+
question,
|
|
8956
|
+
sessionId,
|
|
8957
|
+
confirmed,
|
|
8958
|
+
index,
|
|
8959
|
+
inputMessage: cloneInputMessage(inputMessage)
|
|
8960
|
+
}))
|
|
8961
|
+
}
|
|
8962
|
+
|
|
8963
|
+
const handleKnxAiConfirmationDecision = ({ msg, question, sessionId, decision }) => {
|
|
8964
|
+
const pending = node._pendingKnxCommands.get(sessionId)
|
|
8965
|
+
const language = pending && pending.language
|
|
8966
|
+
? pending.language
|
|
8967
|
+
: resolveKnxAiLanguage(msg, node.llmDocsLanguage || 'en', question)
|
|
8968
|
+
const copy = getKnxAiConfirmationCopy(language)
|
|
8969
|
+
if (!pending) {
|
|
8970
|
+
const reply = buildKnxAiReplyMessage({
|
|
8971
|
+
inputMessage: msg,
|
|
8972
|
+
content: copy.missing,
|
|
8973
|
+
metadata: { type: 'knx_confirmation_missing', sessionId }
|
|
8974
|
+
})
|
|
8975
|
+
sendKnxAiOutputs([null, null, reply, null], msg)
|
|
8976
|
+
return
|
|
8977
|
+
}
|
|
8978
|
+
node._pendingKnxCommands.delete(sessionId)
|
|
8979
|
+
if (Number(pending.expiresAt || 0) <= nowMs()) {
|
|
8980
|
+
const reply = buildKnxAiReplyMessage({
|
|
8981
|
+
inputMessage: msg,
|
|
8982
|
+
content: copy.expired,
|
|
8983
|
+
metadata: { type: 'knx_confirmation_expired', sessionId }
|
|
8984
|
+
})
|
|
8985
|
+
if (!sendKnxAiOutputs([null, null, reply, null], msg)) return
|
|
8986
|
+
updateStatus({ fill: 'grey', shape: 'dot', text: 'AI KNX confirmation expired' })
|
|
8987
|
+
return
|
|
8988
|
+
}
|
|
8989
|
+
if (decision === 'cancel') {
|
|
8990
|
+
const reply = buildKnxAiReplyMessage({
|
|
8991
|
+
inputMessage: msg,
|
|
8992
|
+
content: copy.cancelled,
|
|
8993
|
+
metadata: { type: 'knx_confirmation_cancelled', sessionId }
|
|
8994
|
+
})
|
|
8995
|
+
rememberConversationTurn({ sessionId, question: question || 'CANCEL', reply: copy.cancelled })
|
|
8996
|
+
if (!sendKnxAiOutputs([null, null, reply, null], msg)) return
|
|
8997
|
+
updateStatus({ fill: 'grey', shape: 'dot', text: 'AI KNX commands cancelled' })
|
|
8998
|
+
return
|
|
8999
|
+
}
|
|
9000
|
+
|
|
9001
|
+
const normalized = normalizeKnxAiCommandCandidates({
|
|
9002
|
+
commands: pending.commands,
|
|
9003
|
+
catalog: getGaCatalogSnapshot(),
|
|
9004
|
+
maxCommands: 5,
|
|
9005
|
+
coercePayload: (value, context) => coerceKnxAiCommandPayload(value, context)
|
|
9006
|
+
})
|
|
9007
|
+
if (normalized.rejected.length || !normalized.accepted.length) {
|
|
9008
|
+
const details = normalized.rejected.length
|
|
9009
|
+
? normalized.rejected.map(item => item.reason).join('; ')
|
|
9010
|
+
: 'no valid command remains'
|
|
9011
|
+
const content = copy.invalid(details)
|
|
9012
|
+
const reply = buildKnxAiReplyMessage({
|
|
9013
|
+
inputMessage: msg,
|
|
9014
|
+
content,
|
|
9015
|
+
metadata: {
|
|
9016
|
+
type: 'knx_confirmation_invalid',
|
|
9017
|
+
sessionId,
|
|
9018
|
+
rejectedCommands: normalized.rejected
|
|
9019
|
+
}
|
|
9020
|
+
})
|
|
9021
|
+
rememberConversationTurn({ sessionId, question: question || 'CONFIRM', reply: content })
|
|
9022
|
+
if (!sendKnxAiOutputs([null, null, reply, null], msg)) return
|
|
9023
|
+
updateStatus({ fill: 'red', shape: 'dot', text: 'AI KNX confirmation validation failed' })
|
|
9024
|
+
return
|
|
9025
|
+
}
|
|
9026
|
+
const commandMessages = buildKnxAiCommandMessages({
|
|
9027
|
+
commands: normalized.accepted,
|
|
9028
|
+
question: pending.question,
|
|
9029
|
+
sessionId,
|
|
9030
|
+
confirmed: true,
|
|
9031
|
+
inputMessage: msg
|
|
9032
|
+
})
|
|
9033
|
+
const content = copy.confirmed(commandMessages.length)
|
|
9034
|
+
const reply = buildKnxAiReplyMessage({
|
|
9035
|
+
inputMessage: msg,
|
|
9036
|
+
content,
|
|
9037
|
+
metadata: {
|
|
9038
|
+
type: 'knx_confirmation_accepted',
|
|
9039
|
+
sessionId,
|
|
9040
|
+
commandCount: commandMessages.length
|
|
9041
|
+
}
|
|
9042
|
+
})
|
|
9043
|
+
rememberConversationTurn({ sessionId, question: question || 'CONFIRM', reply: content })
|
|
9044
|
+
if (!sendKnxAiOutputs([null, null, reply, commandMessages], msg)) return
|
|
9045
|
+
updateStatus({ fill: 'green', shape: 'dot', text: `AI confirmed, ${commandMessages.length} KNX command(s)` })
|
|
9046
|
+
}
|
|
9047
|
+
|
|
7847
9048
|
const emitSummary = () => {
|
|
7848
9049
|
try {
|
|
7849
9050
|
const summary = rebuildCachedSummaryNow()
|
|
@@ -8120,6 +9321,267 @@ module.exports = function (RED) {
|
|
|
8120
9321
|
node._gaState.set(ga, state)
|
|
8121
9322
|
}
|
|
8122
9323
|
|
|
9324
|
+
const rememberHomeOwner = ({ sessionId, language } = {}) => {
|
|
9325
|
+
try {
|
|
9326
|
+
const normalizedSessionId = String(sessionId || '').trim()
|
|
9327
|
+
if (normalizedSessionId && normalizedSessionId !== 'default') {
|
|
9328
|
+
node._homeMemory.ownerSessionId = normalizedSessionId
|
|
9329
|
+
}
|
|
9330
|
+
if (language) node._homeMemory.ownerLanguage = normalizeHomeLanguage(language)
|
|
9331
|
+
scheduleHomeMemoryPersist()
|
|
9332
|
+
} catch (error) {
|
|
9333
|
+
try { node.sysLogger?.warn(`KNX AI home owner memory error: ${error.message || error}`) } catch (logError) { /* ignore */ }
|
|
9334
|
+
}
|
|
9335
|
+
}
|
|
9336
|
+
|
|
9337
|
+
const recordProactiveObservation = ({ catalogItem, telegram, event }) => {
|
|
9338
|
+
const semantic = catalogItem && catalogItem.semantic ? catalogItem.semantic : {}
|
|
9339
|
+
node._homeMemory = addBoundedKnxAiObservation(node._homeMemory, {
|
|
9340
|
+
at: new Date(Number(telegram.ts || nowMs())).toISOString(),
|
|
9341
|
+
type: 'semantic_state_change',
|
|
9342
|
+
event,
|
|
9343
|
+
ga: catalogItem.ga,
|
|
9344
|
+
dpt: catalogItem.dpt,
|
|
9345
|
+
label: catalogItem.label || telegram.devicename || catalogItem.ga,
|
|
9346
|
+
kind: semantic.kind || '',
|
|
9347
|
+
area: semantic.area || '',
|
|
9348
|
+
value: normalizeValueForCompare(telegram.payload)
|
|
9349
|
+
})
|
|
9350
|
+
scheduleHomeMemoryPersist()
|
|
9351
|
+
}
|
|
9352
|
+
|
|
9353
|
+
const processProactiveTelegram = (telegram) => {
|
|
9354
|
+
if (!telegram || !telegram.destination) return
|
|
9355
|
+
const catalogItem = getHomeCatalogMap().get(String(telegram.destination).trim())
|
|
9356
|
+
if (!catalogItem || !catalogItem.semantic) return
|
|
9357
|
+
const openState = classifyKnxAiOpenState({
|
|
9358
|
+
semantic: catalogItem.semantic,
|
|
9359
|
+
dpt: telegram.dpt || catalogItem.dpt,
|
|
9360
|
+
payload: telegram.payload,
|
|
9361
|
+
valueOptions: catalogItem.valueOptions
|
|
9362
|
+
})
|
|
9363
|
+
if (!openState || Number(openState.confidence || 0) < 0.7) return
|
|
9364
|
+
const ga = String(catalogItem.ga || telegram.destination).trim()
|
|
9365
|
+
const now = Number(telegram.ts || nowMs())
|
|
9366
|
+
const previous = node._proactiveStates.get(ga)
|
|
9367
|
+
if (openState.open) {
|
|
9368
|
+
if (previous && previous.open === true) {
|
|
9369
|
+
previous.lastSeenAt = now
|
|
9370
|
+
previous.value = openState.value
|
|
9371
|
+
node._proactiveStates.set(ga, previous)
|
|
9372
|
+
return
|
|
9373
|
+
}
|
|
9374
|
+
const lastNotification = normalizeKnxAiHomeMemory(node._homeMemory).notifications
|
|
9375
|
+
.filter(item => item && item.ga === ga)
|
|
9376
|
+
.sort((a, b) => String(a.at || '').localeCompare(String(b.at || '')))
|
|
9377
|
+
.pop()
|
|
9378
|
+
node._proactiveStates.set(ga, {
|
|
9379
|
+
ga,
|
|
9380
|
+
open: true,
|
|
9381
|
+
openedAt: now,
|
|
9382
|
+
lastSeenAt: now,
|
|
9383
|
+
lastSentAt: lastNotification ? Date.parse(lastNotification.at || '') || 0 : 0,
|
|
9384
|
+
value: openState.value,
|
|
9385
|
+
confidence: openState.confidence,
|
|
9386
|
+
catalogItem
|
|
9387
|
+
})
|
|
9388
|
+
recordProactiveObservation({
|
|
9389
|
+
catalogItem,
|
|
9390
|
+
telegram,
|
|
9391
|
+
event: openState.reason || 'opened'
|
|
9392
|
+
})
|
|
9393
|
+
return
|
|
9394
|
+
}
|
|
9395
|
+
if (previous && previous.open === true) {
|
|
9396
|
+
const durationMinutes = Math.max(0, (now - Number(previous.openedAt || now)) / 60000)
|
|
9397
|
+
node._homeMemory = updateKnxAiCoverHabit(node._homeMemory, {
|
|
9398
|
+
ga,
|
|
9399
|
+
label: catalogItem.label || ga,
|
|
9400
|
+
area: catalogItem.semantic.area || '',
|
|
9401
|
+
durationMinutes,
|
|
9402
|
+
at: new Date(now).toISOString()
|
|
9403
|
+
})
|
|
9404
|
+
recordProactiveObservation({
|
|
9405
|
+
catalogItem,
|
|
9406
|
+
telegram,
|
|
9407
|
+
event: `${openState.reason || 'closed'} after ${durationMinutes.toFixed(1)} minutes`
|
|
9408
|
+
})
|
|
9409
|
+
}
|
|
9410
|
+
node._proactiveStates.set(ga, {
|
|
9411
|
+
ga,
|
|
9412
|
+
open: false,
|
|
9413
|
+
openedAt: 0,
|
|
9414
|
+
lastSeenAt: now,
|
|
9415
|
+
lastSentAt: previous ? Number(previous.lastSentAt || 0) : 0,
|
|
9416
|
+
value: openState.value,
|
|
9417
|
+
confidence: openState.confidence,
|
|
9418
|
+
catalogItem
|
|
9419
|
+
})
|
|
9420
|
+
}
|
|
9421
|
+
|
|
9422
|
+
const createProactiveNotificationText = async ({ state, durationMinutes, language }) => {
|
|
9423
|
+
const label = state.catalogItem.label || state.ga
|
|
9424
|
+
const fallback = buildKnxAiProactiveFallback({ language, label, durationMinutes })
|
|
9425
|
+
const hasAuthoritativeEducation = String(node.aiEducation || '').trim() !== ''
|
|
9426
|
+
if (node.llmEnabled !== true) {
|
|
9427
|
+
return hasAuthoritativeEducation
|
|
9428
|
+
? { notify: false, content: '' }
|
|
9429
|
+
: { notify: true, content: fallback }
|
|
9430
|
+
}
|
|
9431
|
+
try {
|
|
9432
|
+
const ret = await callLLMChat({
|
|
9433
|
+
systemPrompt: [
|
|
9434
|
+
'You decide whether to send one concise proactive smart-home notification.',
|
|
9435
|
+
`Use language ${normalizeHomeLanguage(language)}.`,
|
|
9436
|
+
'Return JSON only with exactly: {"notify":boolean,"message":"text"}.',
|
|
9437
|
+
'Set notify=false when the authoritative user-managed AI Education says this condition is normal, allowed, unwanted, or should not generate a notification.',
|
|
9438
|
+
'When notify=false, set message to an empty string.',
|
|
9439
|
+
'Do not claim that a KNX command was sent or that an actuator changed.',
|
|
9440
|
+
'The message must not contain Markdown, lists, addresses, DPTs, or technical details.',
|
|
9441
|
+
'Explain the observed condition and end by asking whether the user wants help.',
|
|
9442
|
+
'The user-managed AI Education is authoritative.'
|
|
9443
|
+
].join('\n'),
|
|
9444
|
+
userContent: [
|
|
9445
|
+
getHomeMemoryPromptContext({ maxChars: 3500 }),
|
|
9446
|
+
'',
|
|
9447
|
+
`Observed object: ${label}`,
|
|
9448
|
+
`Semantic type: ${state.catalogItem.semantic.kind}`,
|
|
9449
|
+
`Semantic area: ${state.catalogItem.semantic.area || 'unknown'}`,
|
|
9450
|
+
`Condition duration: ${Math.max(1, Math.round(durationMinutes))} minutes`,
|
|
9451
|
+
'Return the JSON decision now.'
|
|
9452
|
+
].join('\n'),
|
|
9453
|
+
jsonSchema: {
|
|
9454
|
+
name: 'knx_ai_proactive_decision',
|
|
9455
|
+
strict: true,
|
|
9456
|
+
schema: {
|
|
9457
|
+
type: 'object',
|
|
9458
|
+
additionalProperties: false,
|
|
9459
|
+
properties: {
|
|
9460
|
+
notify: { type: 'boolean' },
|
|
9461
|
+
message: { type: 'string' }
|
|
9462
|
+
},
|
|
9463
|
+
required: ['notify', 'message']
|
|
9464
|
+
}
|
|
9465
|
+
},
|
|
9466
|
+
maxTokensOverride: 2000
|
|
9467
|
+
})
|
|
9468
|
+
const decision = extractJsonFragmentFromText(ret && ret.content)
|
|
9469
|
+
if (!decision || typeof decision !== 'object' || Array.isArray(decision) || typeof decision.notify !== 'boolean') {
|
|
9470
|
+
throw new Error('The proactive decision is not a valid JSON object')
|
|
9471
|
+
}
|
|
9472
|
+
if (decision.notify === false) return { notify: false, content: '' }
|
|
9473
|
+
const candidate = String(decision.message || '').trim()
|
|
9474
|
+
if (!candidate || candidate.length > 1200 || candidate.startsWith('{') || candidate.startsWith('```')) {
|
|
9475
|
+
return { notify: true, content: fallback }
|
|
9476
|
+
}
|
|
9477
|
+
return { notify: true, content: candidate }
|
|
9478
|
+
} catch (error) {
|
|
9479
|
+
try { node.sysLogger?.warn(`KNX AI proactive wording fallback: ${error.message || error}`) } catch (logError) { /* ignore */ }
|
|
9480
|
+
return hasAuthoritativeEducation
|
|
9481
|
+
? { notify: false, content: '' }
|
|
9482
|
+
: { notify: true, content: fallback }
|
|
9483
|
+
}
|
|
9484
|
+
}
|
|
9485
|
+
|
|
9486
|
+
const emitProactiveNotification = async ({ state, durationMinutes }) => {
|
|
9487
|
+
if (node._closing === true) return false
|
|
9488
|
+
const recipient = String(node.proactiveRecipient || node._homeMemory.ownerSessionId || '').trim()
|
|
9489
|
+
if (node.chatAdapterPreset === 'windkh-telegrambot' && !recipient) return false
|
|
9490
|
+
const language = normalizeHomeLanguage(node._homeMemory.ownerLanguage || node.llmDocsLanguage || 'en')
|
|
9491
|
+
const notification = await createProactiveNotificationText({ state, durationMinutes, language })
|
|
9492
|
+
if (!notification.notify) return 'suppressed'
|
|
9493
|
+
const content = notification.content
|
|
9494
|
+
if (node._closing === true) return false
|
|
9495
|
+
const syntheticInputMessage = {
|
|
9496
|
+
topic: 'proactive',
|
|
9497
|
+
payload: Object.assign({
|
|
9498
|
+
type: 'message',
|
|
9499
|
+
content: ''
|
|
9500
|
+
}, recipient ? { chatId: recipient } : {}),
|
|
9501
|
+
sessionId: recipient || 'proactive',
|
|
9502
|
+
language,
|
|
9503
|
+
knxAi: {
|
|
9504
|
+
type: 'proactive_observation',
|
|
9505
|
+
destination: state.ga
|
|
9506
|
+
}
|
|
9507
|
+
}
|
|
9508
|
+
const metadata = {
|
|
9509
|
+
type: 'proactive_notification',
|
|
9510
|
+
reason: 'open_too_long',
|
|
9511
|
+
destination: state.ga,
|
|
9512
|
+
dpt: state.catalogItem.dpt,
|
|
9513
|
+
label: state.catalogItem.label || state.ga,
|
|
9514
|
+
semantic: state.catalogItem.semantic,
|
|
9515
|
+
openedAt: new Date(state.openedAt).toISOString(),
|
|
9516
|
+
durationMinutes: Number(durationMinutes.toFixed(1)),
|
|
9517
|
+
recipient,
|
|
9518
|
+
sessionId: recipient || 'proactive',
|
|
9519
|
+
language,
|
|
9520
|
+
requiresConfirmationForCommands: true
|
|
9521
|
+
}
|
|
9522
|
+
const replyMessage = buildKnxAiReplyMessage({
|
|
9523
|
+
inputMessage: syntheticInputMessage,
|
|
9524
|
+
content,
|
|
9525
|
+
metadata
|
|
9526
|
+
})
|
|
9527
|
+
if (!sendKnxAiOutputs([null, null, replyMessage, null], syntheticInputMessage)) return false
|
|
9528
|
+
node._homeMemory = addBoundedKnxAiNotification(node._homeMemory, {
|
|
9529
|
+
at: new Date().toISOString(),
|
|
9530
|
+
type: 'proactive_notification',
|
|
9531
|
+
reason: 'open_too_long',
|
|
9532
|
+
ga: state.ga,
|
|
9533
|
+
dpt: state.catalogItem.dpt,
|
|
9534
|
+
label: state.catalogItem.label || state.ga,
|
|
9535
|
+
durationMinutes: Number(durationMinutes.toFixed(1)),
|
|
9536
|
+
recipient
|
|
9537
|
+
})
|
|
9538
|
+
rememberConversationTurn({
|
|
9539
|
+
sessionId: recipient || 'proactive',
|
|
9540
|
+
question: '[Proactive home observation]',
|
|
9541
|
+
reply: content
|
|
9542
|
+
})
|
|
9543
|
+
scheduleHomeMemoryPersist({ immediate: true })
|
|
9544
|
+
return true
|
|
9545
|
+
}
|
|
9546
|
+
|
|
9547
|
+
const checkProactiveHomeState = () => {
|
|
9548
|
+
if (node._closing === true || node.proactiveEnabled !== true) return
|
|
9549
|
+
if (isKnxAiQuietTime({
|
|
9550
|
+
date: new Date(),
|
|
9551
|
+
start: node.proactiveQuietStart,
|
|
9552
|
+
end: node.proactiveQuietEnd
|
|
9553
|
+
})) return
|
|
9554
|
+
const now = nowMs()
|
|
9555
|
+
const thresholdMs = node.proactiveOpenMinutes * 60 * 1000
|
|
9556
|
+
const cooldownMs = node.proactiveCooldownMinutes * 60 * 1000
|
|
9557
|
+
node._proactiveGlobalSentAt = node._proactiveGlobalSentAt.filter(ts => (now - ts) < (60 * 60 * 1000))
|
|
9558
|
+
if (node._proactiveGlobalSentAt.length >= 3) return
|
|
9559
|
+
const candidate = Array.from(node._proactiveStates.values())
|
|
9560
|
+
.filter(state => {
|
|
9561
|
+
if (!state || state.open !== true || node._proactiveInFlight.has(state.ga)) return false
|
|
9562
|
+
if ((now - Number(state.openedAt || now)) < thresholdMs) return false
|
|
9563
|
+
if (Number(state.lastSentAt || 0) > 0 && (now - Number(state.lastSentAt)) < cooldownMs) return false
|
|
9564
|
+
return true
|
|
9565
|
+
})
|
|
9566
|
+
.sort((a, b) => Number(a.openedAt || 0) - Number(b.openedAt || 0))[0]
|
|
9567
|
+
if (!candidate) return
|
|
9568
|
+
candidate.lastSentAt = now
|
|
9569
|
+
node._proactiveInFlight.add(candidate.ga)
|
|
9570
|
+
const durationMinutes = Math.max(1, (now - Number(candidate.openedAt || now)) / 60000)
|
|
9571
|
+
Promise.resolve(emitProactiveNotification({ state: candidate, durationMinutes }))
|
|
9572
|
+
.then(result => {
|
|
9573
|
+
if (result === true) node._proactiveGlobalSentAt.push(now)
|
|
9574
|
+
else if (result !== 'suppressed') candidate.lastSentAt = 0
|
|
9575
|
+
})
|
|
9576
|
+
.catch(error => {
|
|
9577
|
+
candidate.lastSentAt = 0
|
|
9578
|
+
try { node.sysLogger?.warn(`KNX AI proactive notification error: ${error.message || error}`) } catch (logError) { /* ignore */ }
|
|
9579
|
+
})
|
|
9580
|
+
.finally(() => {
|
|
9581
|
+
node._proactiveInFlight.delete(candidate.ga)
|
|
9582
|
+
})
|
|
9583
|
+
}
|
|
9584
|
+
|
|
8123
9585
|
// Called by knxUltimate-config.js
|
|
8124
9586
|
node.handleSend = (msg) => {
|
|
8125
9587
|
try {
|
|
@@ -8133,6 +9595,7 @@ module.exports = function (RED) {
|
|
|
8133
9595
|
trimHistory(now)
|
|
8134
9596
|
maybeEmitGAAnomalies(telegram)
|
|
8135
9597
|
maybeEmitOverallAnomaly(now)
|
|
9598
|
+
processProactiveTelegram(telegram)
|
|
8136
9599
|
scheduleRealtimeSummaryRebuild()
|
|
8137
9600
|
} catch (error) {
|
|
8138
9601
|
try { node.sysLogger?.error(`knxUltimateAI handleSend error: ${error.message || error}`) } catch (e) { /* ignore */ }
|
|
@@ -8156,6 +9619,13 @@ module.exports = function (RED) {
|
|
|
8156
9619
|
}]
|
|
8157
9620
|
node._lastSummary = null
|
|
8158
9621
|
node._lastSummaryAt = 0
|
|
9622
|
+
node._conversationSessions = new Map()
|
|
9623
|
+
node._pendingKnxCommands = new Map()
|
|
9624
|
+
node._homeMemory = createEmptyKnxAiHomeMemory()
|
|
9625
|
+
node._proactiveStates = new Map()
|
|
9626
|
+
node._proactiveInFlight = new Set()
|
|
9627
|
+
node._proactiveGlobalSentAt = []
|
|
9628
|
+
scheduleHomeMemoryPersist({ immediate: true })
|
|
8159
9629
|
if (node._summaryRebuildTimer) {
|
|
8160
9630
|
clearTimeout(node._summaryRebuildTimer)
|
|
8161
9631
|
node._summaryRebuildTimer = null
|
|
@@ -8165,40 +9635,205 @@ module.exports = function (RED) {
|
|
|
8165
9635
|
return
|
|
8166
9636
|
}
|
|
8167
9637
|
|
|
9638
|
+
if (cmd === 'confirm' || cmd === 'cancel') {
|
|
9639
|
+
const question = extractKnxAiQuestion(msg)
|
|
9640
|
+
const sessionId = resolveKnxAiSessionId(msg)
|
|
9641
|
+
handleKnxAiConfirmationDecision({
|
|
9642
|
+
msg,
|
|
9643
|
+
question,
|
|
9644
|
+
sessionId,
|
|
9645
|
+
decision: cmd === 'confirm' ? 'confirm' : 'cancel'
|
|
9646
|
+
})
|
|
9647
|
+
return
|
|
9648
|
+
}
|
|
9649
|
+
|
|
8168
9650
|
if (cmd === 'summary' || cmd === 'stats' || cmd === 'top' || cmd === '') {
|
|
8169
9651
|
emitSummary()
|
|
8170
9652
|
return
|
|
8171
9653
|
}
|
|
8172
9654
|
|
|
8173
9655
|
if (cmd === 'ask') {
|
|
8174
|
-
const question = (msg
|
|
8175
|
-
|
|
8176
|
-
|
|
9656
|
+
const question = extractKnxAiQuestion(msg)
|
|
9657
|
+
const sessionId = resolveKnxAiSessionId(msg)
|
|
9658
|
+
if (!question) throw new Error('Missing question')
|
|
9659
|
+
const decision = classifyKnxAiConfirmation({ msg, question, topic: cmd })
|
|
9660
|
+
if (node._pendingKnxCommands.has(sessionId) && decision !== 'none') {
|
|
9661
|
+
handleKnxAiConfirmationDecision({ msg, question, sessionId, decision })
|
|
9662
|
+
return
|
|
9663
|
+
}
|
|
9664
|
+
// A new natural-language request replaces an older unconfirmed plan in
|
|
9665
|
+
// the same chat, preventing a later confirmation from acting on stale intent.
|
|
9666
|
+
node._pendingKnxCommands.delete(sessionId)
|
|
8177
9667
|
updateStatus({ fill: 'blue', shape: 'ring', text: 'AI thinking...' })
|
|
8178
9668
|
try {
|
|
8179
|
-
const ret =
|
|
8180
|
-
|
|
9669
|
+
const ret = node.llmAllowKnxCommands
|
|
9670
|
+
? await callConversationalLLM({
|
|
9671
|
+
question,
|
|
9672
|
+
sessionId,
|
|
9673
|
+
requireConfirmation: node.llmRequireCommandConfirmation
|
|
9674
|
+
})
|
|
9675
|
+
: await callLLM({ question })
|
|
9676
|
+
const preparedCommands = Array.isArray(ret.commands) ? ret.commands : []
|
|
9677
|
+
const readCommands = preparedCommands.filter(command => command && command.event === 'GroupValue_Read')
|
|
9678
|
+
const writeCommands = preparedCommands.filter(command => !command || command.event !== 'GroupValue_Read')
|
|
9679
|
+
const language = resolveKnxAiLanguage(msg, node.llmDocsLanguage || 'en', question, ret.language)
|
|
9680
|
+
rememberHomeOwner({ sessionId, language })
|
|
9681
|
+
const copy = getKnxAiConfirmationCopy(language)
|
|
9682
|
+
const awaitingConfirmation = node.llmAllowKnxCommands &&
|
|
9683
|
+
node.llmRequireCommandConfirmation &&
|
|
9684
|
+
writeCommands.length > 0
|
|
9685
|
+
let content = ret.content
|
|
9686
|
+
let commandsToEmit = preparedCommands
|
|
9687
|
+
let confirmationRequest = null
|
|
9688
|
+
if (awaitingConfirmation) {
|
|
9689
|
+
content = `${content}\n\n${formatKnxAiCommandPreview({ commands: writeCommands, copy })}`
|
|
9690
|
+
const expiresAt = nowMs() + (5 * 60 * 1000)
|
|
9691
|
+
node._pendingKnxCommands.set(sessionId, {
|
|
9692
|
+
question,
|
|
9693
|
+
commands: writeCommands,
|
|
9694
|
+
language,
|
|
9695
|
+
createdAt: nowMs(),
|
|
9696
|
+
expiresAt
|
|
9697
|
+
})
|
|
9698
|
+
confirmationRequest = buildKnxAiConfirmationRequest({
|
|
9699
|
+
sessionId,
|
|
9700
|
+
expiresAt,
|
|
9701
|
+
commandCount: writeCommands.length,
|
|
9702
|
+
copy
|
|
9703
|
+
})
|
|
9704
|
+
while (node._pendingKnxCommands.size > 50) {
|
|
9705
|
+
const oldestSessionId = node._pendingKnxCommands.keys().next().value
|
|
9706
|
+
node._pendingKnxCommands.delete(oldestSessionId)
|
|
9707
|
+
}
|
|
9708
|
+
commandsToEmit = readCommands
|
|
9709
|
+
}
|
|
9710
|
+
const commandMessages = buildKnxAiCommandMessages({
|
|
9711
|
+
commands: commandsToEmit,
|
|
9712
|
+
question,
|
|
9713
|
+
sessionId,
|
|
9714
|
+
confirmed: node.llmRequireCommandConfirmation !== true,
|
|
9715
|
+
inputMessage: msg
|
|
9716
|
+
})
|
|
9717
|
+
const emittedReadCommands = commandsToEmit.filter(command => command && command.event === 'GroupValue_Read')
|
|
9718
|
+
let readResults = []
|
|
9719
|
+
let readResultMetadata = []
|
|
9720
|
+
if (emittedReadCommands.length > 0) {
|
|
9721
|
+
const readStartedAt = nowMs()
|
|
9722
|
+
const readWaiters = emittedReadCommands.map(command => waitForTelegram({
|
|
9723
|
+
destination: command.destination,
|
|
9724
|
+
events: ['GroupValue_Response', 'GroupValue_Write'],
|
|
9725
|
+
minTs: readStartedAt,
|
|
9726
|
+
timeoutMs: 6000
|
|
9727
|
+
}))
|
|
9728
|
+
if (!sendKnxAiOutputs([null, null, null, commandMessages], msg)) return
|
|
9729
|
+
updateStatus({
|
|
9730
|
+
fill: 'blue',
|
|
9731
|
+
shape: 'ring',
|
|
9732
|
+
text: `AI waiting for ${emittedReadCommands.length} KNX read response(s)`
|
|
9733
|
+
})
|
|
9734
|
+
readResults = await Promise.allSettled(readWaiters)
|
|
9735
|
+
readResultMetadata = emittedReadCommands.map((command, index) => {
|
|
9736
|
+
const result = readResults[index]
|
|
9737
|
+
const telegram = result && result.status === 'fulfilled' ? result.value : null
|
|
9738
|
+
return {
|
|
9739
|
+
destination: command.destination,
|
|
9740
|
+
dpt: command.dpt,
|
|
9741
|
+
label: command.label || '',
|
|
9742
|
+
received: !!telegram,
|
|
9743
|
+
event: telegram ? telegram.event : '',
|
|
9744
|
+
payload: telegram ? telegram.payload : undefined,
|
|
9745
|
+
payloadmeasureunit: telegram ? telegram.payloadmeasureunit : ''
|
|
9746
|
+
}
|
|
9747
|
+
})
|
|
9748
|
+
const readReply = formatKnxAiReadResults({
|
|
9749
|
+
operations: emittedReadCommands,
|
|
9750
|
+
results: readResults,
|
|
9751
|
+
language
|
|
9752
|
+
})
|
|
9753
|
+
content = writeCommands.length > 0 ? `${readReply}\n\n${content}` : readReply
|
|
9754
|
+
}
|
|
9755
|
+
const assistantEntry = {
|
|
9756
|
+
at: new Date().toISOString(),
|
|
9757
|
+
question,
|
|
9758
|
+
content,
|
|
9759
|
+
provider: ret.provider,
|
|
9760
|
+
model: ret.model,
|
|
9761
|
+
sessionId,
|
|
9762
|
+
commandCount: writeCommands.length,
|
|
9763
|
+
readCount: readCommands.length,
|
|
9764
|
+
language,
|
|
9765
|
+
awaitingConfirmation,
|
|
9766
|
+
rejectedCommandCount: Array.isArray(ret.rejectedCommands) ? ret.rejectedCommands.length : 0
|
|
9767
|
+
}
|
|
9768
|
+
node._assistantLog.push(assistantEntry)
|
|
8181
9769
|
while (node._assistantLog.length > 50) node._assistantLog.shift()
|
|
8182
|
-
node.
|
|
8183
|
-
|
|
8184
|
-
|
|
8185
|
-
|
|
8186
|
-
|
|
8187
|
-
|
|
8188
|
-
|
|
9770
|
+
if (node.llmAllowKnxCommands) rememberConversationTurn({ sessionId, question, reply: content })
|
|
9771
|
+
const replyMessage = buildKnxAiReplyMessage({
|
|
9772
|
+
inputMessage: msg,
|
|
9773
|
+
content,
|
|
9774
|
+
metadata: {
|
|
9775
|
+
type: 'llm',
|
|
9776
|
+
provider: ret.provider,
|
|
9777
|
+
model: ret.model,
|
|
9778
|
+
question,
|
|
9779
|
+
sessionId,
|
|
9780
|
+
language,
|
|
9781
|
+
operationCount: preparedCommands.length,
|
|
9782
|
+
commandCount: writeCommands.length,
|
|
9783
|
+
readCount: readCommands.length,
|
|
9784
|
+
readResults: readResultMetadata,
|
|
9785
|
+
awaitingConfirmation,
|
|
9786
|
+
confirmationExpiresAt: confirmationRequest ? confirmationRequest.expiresAt : 0,
|
|
9787
|
+
confirmationRequest,
|
|
9788
|
+
rejectedCommands: Array.isArray(ret.rejectedCommands) ? ret.rejectedCommands : [],
|
|
9789
|
+
structuredOutputError: ret.structuredOutputError || ''
|
|
9790
|
+
},
|
|
9791
|
+
summary: emittedReadCommands.length > 0 ? rebuildCachedSummaryNow() : ret.summary
|
|
9792
|
+
})
|
|
9793
|
+
if (emittedReadCommands.length > 0) {
|
|
9794
|
+
if (!sendKnxAiOutputs([null, null, replyMessage, null], msg)) return
|
|
9795
|
+
} else if (!sendKnxAiOutputs([null, null, replyMessage, commandMessages.length ? commandMessages : null], msg)) {
|
|
9796
|
+
return
|
|
9797
|
+
}
|
|
9798
|
+
updateStatus({
|
|
9799
|
+
fill: awaitingConfirmation ? 'yellow' : 'green',
|
|
9800
|
+
shape: awaitingConfirmation ? 'ring' : 'dot',
|
|
9801
|
+
text: awaitingConfirmation
|
|
9802
|
+
? `AI waiting for confirmation (${writeCommands.length} KNX command(s))`
|
|
9803
|
+
: readCommands.length
|
|
9804
|
+
? `AI answer ready, ${readResultMetadata.filter(item => item.received).length}/${readCommands.length} KNX read(s) received`
|
|
9805
|
+
: commandMessages.length
|
|
9806
|
+
? `AI answer ready, ${commandMessages.length} KNX command(s)`
|
|
9807
|
+
: 'AI answer ready'
|
|
9808
|
+
})
|
|
8189
9809
|
} catch (error) {
|
|
8190
9810
|
node._assistantLog.push({ at: new Date().toISOString(), question, error: error.message || String(error) })
|
|
8191
9811
|
while (node._assistantLog.length > 50) node._assistantLog.shift()
|
|
8192
|
-
|
|
8193
|
-
|
|
8194
|
-
|
|
8195
|
-
|
|
8196
|
-
}
|
|
9812
|
+
const replyMessage = buildKnxAiReplyMessage({
|
|
9813
|
+
inputMessage: msg,
|
|
9814
|
+
content: { error: error.message || String(error) },
|
|
9815
|
+
metadata: { type: 'llm_error', question }
|
|
9816
|
+
})
|
|
9817
|
+
if (!sendKnxAiOutputs([null, null, replyMessage, null], msg)) return
|
|
8197
9818
|
updateStatus({ fill: 'red', shape: 'dot', text: `AI error: ${error.message || error}` })
|
|
8198
9819
|
}
|
|
8199
9820
|
return
|
|
8200
9821
|
}
|
|
8201
9822
|
|
|
9823
|
+
if (cmd === 'clear_chat') {
|
|
9824
|
+
const sessionId = resolveKnxAiSessionId(msg)
|
|
9825
|
+
node._conversationSessions.delete(sessionId)
|
|
9826
|
+
node._pendingKnxCommands.delete(sessionId)
|
|
9827
|
+
const replyMessage = buildKnxAiReplyMessage({
|
|
9828
|
+
inputMessage: msg,
|
|
9829
|
+
content: { ok: true, sessionId },
|
|
9830
|
+
metadata: { type: 'conversation_reset', sessionId }
|
|
9831
|
+
})
|
|
9832
|
+
if (!sendKnxAiOutputs([null, null, replyMessage, null], msg)) return
|
|
9833
|
+
updateStatus({ fill: 'grey', shape: 'dot', text: `AI chat cleared (${sessionId})` })
|
|
9834
|
+
return
|
|
9835
|
+
}
|
|
9836
|
+
|
|
8202
9837
|
if (cmd === 'run_profile') {
|
|
8203
9838
|
const areaId = msg.areaId !== undefined ? String(msg.areaId) : (msg.payload && msg.payload.areaId ? String(msg.payload.areaId) : '')
|
|
8204
9839
|
const profileId = msg.profileId !== undefined ? String(msg.profileId) : (msg.payload && msg.payload.profileId ? String(msg.payload.profileId) : '')
|
|
@@ -8249,7 +9884,7 @@ module.exports = function (RED) {
|
|
|
8249
9884
|
return
|
|
8250
9885
|
}
|
|
8251
9886
|
|
|
8252
|
-
node.warn(`knxUltimateAI: unknown command '${cmd}'. Supported: reset, summary, ask, run_profile, run_actuator_test, run_test_plan`)
|
|
9887
|
+
node.warn(`knxUltimateAI: unknown command '${cmd}'. Supported: reset, summary, ask, confirm, cancel, clear_chat, run_profile, run_actuator_test, run_test_plan`)
|
|
8253
9888
|
} catch (error) {
|
|
8254
9889
|
try { node.sysLogger?.error(`knxUltimateAI handleCommand error: ${error.message || error}`) } catch (e) { /* ignore */ }
|
|
8255
9890
|
try { node.error(error) } catch (e) { /* ignore */ }
|
|
@@ -8329,11 +9964,27 @@ module.exports = function (RED) {
|
|
|
8329
9964
|
|
|
8330
9965
|
node.on('input', function (msg) {
|
|
8331
9966
|
try {
|
|
8332
|
-
|
|
9967
|
+
let adaptedMessage = msg
|
|
9968
|
+
try {
|
|
9969
|
+
adaptedMessage = executeKnxAiChatAdapter({
|
|
9970
|
+
adapter: node._chatInputAdapter,
|
|
9971
|
+
msg,
|
|
9972
|
+
inputMessage: msg,
|
|
9973
|
+
node,
|
|
9974
|
+
RED
|
|
9975
|
+
})
|
|
9976
|
+
} catch (error) {
|
|
9977
|
+
try { node.sysLogger?.error(`knxUltimateAI chat input adapter error: ${error.message || error}`) } catch (e) { /* ignore */ }
|
|
9978
|
+
try { node.error(error, msg) } catch (e) { /* ignore */ }
|
|
9979
|
+
try { updateStatus({ fill: 'red', shape: 'dot', text: `Chat input adapter error: ${error.message || error}` }) } catch (e) { /* ignore */ }
|
|
9980
|
+
return
|
|
9981
|
+
}
|
|
9982
|
+
if (!adaptedMessage) return
|
|
9983
|
+
const p = handleCommand(adaptedMessage)
|
|
8333
9984
|
if (p && typeof p.catch === 'function') {
|
|
8334
9985
|
p.catch((error) => {
|
|
8335
9986
|
try { node.sysLogger?.error(`knxUltimateAI input error: ${error.message || error}`) } catch (e) { /* ignore */ }
|
|
8336
|
-
try { node.error(error) } catch (e) { /* ignore */ }
|
|
9987
|
+
try { node.error(error, adaptedMessage) } catch (e) { /* ignore */ }
|
|
8337
9988
|
})
|
|
8338
9989
|
}
|
|
8339
9990
|
} catch (error) {
|
|
@@ -8344,8 +9995,16 @@ module.exports = function (RED) {
|
|
|
8344
9995
|
|
|
8345
9996
|
node.on('close', function (done) {
|
|
8346
9997
|
try {
|
|
9998
|
+
node._closing = true
|
|
8347
9999
|
if (node._timerEmit) clearInterval(node._timerEmit)
|
|
8348
10000
|
if (node._busConnectionWatchTimer) clearInterval(node._busConnectionWatchTimer)
|
|
10001
|
+
if (node._homeMemoryPeriodicTimer) clearInterval(node._homeMemoryPeriodicTimer)
|
|
10002
|
+
if (node._proactiveCheckTimer) clearInterval(node._proactiveCheckTimer)
|
|
10003
|
+
if (node._homeMemoryWriteTimer) {
|
|
10004
|
+
clearTimeout(node._homeMemoryWriteTimer)
|
|
10005
|
+
node._homeMemoryWriteTimer = null
|
|
10006
|
+
}
|
|
10007
|
+
persistHomeMemoryNow()
|
|
8349
10008
|
if (node._summaryRebuildTimer) {
|
|
8350
10009
|
clearTimeout(node._summaryRebuildTimer)
|
|
8351
10010
|
node._summaryRebuildTimer = null
|
|
@@ -8381,10 +10040,23 @@ module.exports = function (RED) {
|
|
|
8381
10040
|
try {
|
|
8382
10041
|
pruneHistoryArchiveFiles({ force: true })
|
|
8383
10042
|
loadRecentHistoryFromDisk()
|
|
10043
|
+
loadHomeMemoryFromDisk()
|
|
8384
10044
|
} catch (error) {
|
|
8385
10045
|
node.sysLogger?.warn(`KNX AI history startup error: ${error.message || error}`)
|
|
8386
10046
|
}
|
|
8387
10047
|
|
|
10048
|
+
if (node._homeMemoryPeriodicTimer) clearInterval(node._homeMemoryPeriodicTimer)
|
|
10049
|
+
node._homeMemoryPeriodicTimer = setInterval(() => {
|
|
10050
|
+
try { persistHomeMemoryNow() } catch (error) { /* persistHomeMemoryNow already guards */ }
|
|
10051
|
+
}, 15 * 60 * 1000)
|
|
10052
|
+
|
|
10053
|
+
if (node._proactiveCheckTimer) clearInterval(node._proactiveCheckTimer)
|
|
10054
|
+
node._proactiveCheckTimer = setInterval(() => {
|
|
10055
|
+
try { checkProactiveHomeState() } catch (error) {
|
|
10056
|
+
try { node.sysLogger?.warn(`KNX AI proactive check error: ${error.message || error}`) } catch (logError) { /* ignore */ }
|
|
10057
|
+
}
|
|
10058
|
+
}, 30 * 1000)
|
|
10059
|
+
|
|
8388
10060
|
if (node._busConnectionWatchTimer) clearInterval(node._busConnectionWatchTimer)
|
|
8389
10061
|
node._busConnectionWatchTimer = setInterval(() => {
|
|
8390
10062
|
pollBusConnectionStatus()
|
|
@@ -8400,3 +10072,30 @@ module.exports = function (RED) {
|
|
|
8400
10072
|
}
|
|
8401
10073
|
})
|
|
8402
10074
|
}
|
|
10075
|
+
|
|
10076
|
+
module.exports.__test = {
|
|
10077
|
+
buildKnxAiConfirmationRequest,
|
|
10078
|
+
buildKnxAiUniversalMessage,
|
|
10079
|
+
classifyKnxAiConfirmation,
|
|
10080
|
+
cloneKnxAiInputMessage,
|
|
10081
|
+
compileKnxAiChatAdapter,
|
|
10082
|
+
coerceKnxAiCommandPayload,
|
|
10083
|
+
detectKnxAiLanguageFromText,
|
|
10084
|
+
executeKnxAiChatAdapter,
|
|
10085
|
+
extractKnxAiQuestion,
|
|
10086
|
+
formatKnxAiCommandPreview,
|
|
10087
|
+
formatKnxAiReadResults,
|
|
10088
|
+
getKnxAiConfirmationCopy,
|
|
10089
|
+
getKnxAiReadCopy,
|
|
10090
|
+
isChatCompletionsModelError,
|
|
10091
|
+
isProbablyChatModelId,
|
|
10092
|
+
isUnsupportedTemperatureError,
|
|
10093
|
+
normalizeKnxAiCommandCandidates,
|
|
10094
|
+
parseKnxAiConversationResponse,
|
|
10095
|
+
postOpenAiCompatibleChatWithFallbacks,
|
|
10096
|
+
resolveKnxAiLanguage,
|
|
10097
|
+
resolveKnxAiOperationEvent,
|
|
10098
|
+
resolveKnxAiSessionId,
|
|
10099
|
+
safeKnxAiSend,
|
|
10100
|
+
validateKnxAiPayloadForDpt
|
|
10101
|
+
}
|