node-red-contrib-knx-ultimate 6.1.0 → 6.2.0
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 +16 -0
- package/examples/KNX AI - Conversational Control with Confirmation.json +374 -0
- package/examples/KNX AI - Telegrambot Direct Chat.json +221 -0
- package/examples/Matter Controller - Semantic Flow Input.json +248 -0
- package/nodes/commonFunctions.js +0 -174
- package/nodes/knxUltimateAI.html +254 -90
- package/nodes/knxUltimateAI.js +1286 -61
- package/nodes/knxUltimateMatterBridge.html +144 -11
- package/nodes/knxUltimateMatterControllerDevice.html +80 -55
- package/nodes/locales/de/knxUltimateAI.html +38 -2
- package/nodes/locales/de/knxUltimateAI.json +16 -3
- package/nodes/locales/de/knxUltimateMatterBridge.html +1 -1
- package/nodes/locales/de/knxUltimateMatterBridge.json +14 -0
- package/nodes/locales/de/knxUltimateMatterControllerDevice.html +2 -2
- package/nodes/locales/de/knxUltimateMatterControllerDevice.json +2 -1
- package/nodes/locales/en/knxUltimateAI.html +38 -2
- package/nodes/locales/en/knxUltimateAI.json +16 -3
- package/nodes/locales/en/knxUltimateMatterBridge.html +1 -1
- package/nodes/locales/en/knxUltimateMatterBridge.json +14 -0
- package/nodes/locales/en/knxUltimateMatterControllerDevice.html +2 -2
- package/nodes/locales/en/knxUltimateMatterControllerDevice.json +2 -1
- package/nodes/locales/es/knxUltimateAI.html +38 -2
- package/nodes/locales/es/knxUltimateAI.json +16 -3
- package/nodes/locales/es/knxUltimateMatterBridge.html +1 -1
- package/nodes/locales/es/knxUltimateMatterBridge.json +14 -0
- package/nodes/locales/es/knxUltimateMatterControllerDevice.html +2 -2
- package/nodes/locales/es/knxUltimateMatterControllerDevice.json +2 -1
- package/nodes/locales/fr/knxUltimateAI.html +38 -2
- package/nodes/locales/fr/knxUltimateAI.json +16 -3
- package/nodes/locales/fr/knxUltimateMatterBridge.html +1 -1
- package/nodes/locales/fr/knxUltimateMatterBridge.json +14 -0
- package/nodes/locales/fr/knxUltimateMatterControllerDevice.html +2 -2
- package/nodes/locales/fr/knxUltimateMatterControllerDevice.json +2 -1
- package/nodes/locales/it/knxUltimateAI.html +38 -2
- package/nodes/locales/it/knxUltimateAI.json +16 -3
- package/nodes/locales/it/knxUltimateMatterBridge.html +1 -1
- package/nodes/locales/it/knxUltimateMatterBridge.json +14 -0
- package/nodes/locales/it/knxUltimateMatterControllerDevice.html +2 -2
- package/nodes/locales/it/knxUltimateMatterControllerDevice.json +2 -1
- package/nodes/locales/zh-CN/knxUltimateAI.html +38 -2
- package/nodes/locales/zh-CN/knxUltimateAI.json +16 -3
- package/nodes/locales/zh-CN/knxUltimateMatterBridge.html +1 -1
- package/nodes/locales/zh-CN/knxUltimateMatterBridge.json +14 -0
- package/nodes/locales/zh-CN/knxUltimateMatterControllerDevice.html +2 -2
- package/nodes/locales/zh-CN/knxUltimateMatterControllerDevice.json +2 -1
- 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
|
@@ -738,6 +738,626 @@ const extractJsonFragmentFromText = (value) => {
|
|
|
738
738
|
throw new Error(`The LLM response did not contain valid JSON${preview ? ` (preview: ${preview})` : ''}`)
|
|
739
739
|
}
|
|
740
740
|
|
|
741
|
+
const parseKnxAiConversationResponse = (value) => {
|
|
742
|
+
const parsed = extractJsonFragmentFromText(value)
|
|
743
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
744
|
+
throw new Error('The LLM conversation response must be a JSON object')
|
|
745
|
+
}
|
|
746
|
+
const reply = String(parsed.reply !== undefined
|
|
747
|
+
? parsed.reply
|
|
748
|
+
: parsed.answer !== undefined
|
|
749
|
+
? parsed.answer
|
|
750
|
+
: parsed.text !== undefined
|
|
751
|
+
? parsed.text
|
|
752
|
+
: '').trim()
|
|
753
|
+
const commands = Array.isArray(parsed.commands)
|
|
754
|
+
? parsed.commands
|
|
755
|
+
: Array.isArray(parsed.actions)
|
|
756
|
+
? parsed.actions
|
|
757
|
+
: []
|
|
758
|
+
const language = String(parsed.language !== undefined
|
|
759
|
+
? parsed.language
|
|
760
|
+
: parsed.locale !== undefined
|
|
761
|
+
? parsed.locale
|
|
762
|
+
: '').trim()
|
|
763
|
+
return { reply, commands, language }
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
const extractKnxAiQuestion = (msg) => {
|
|
767
|
+
const source = msg && typeof msg === 'object' ? msg : {}
|
|
768
|
+
if (source.prompt !== undefined && source.prompt !== null) return String(source.prompt).trim()
|
|
769
|
+
if (typeof source.payload === 'string') return source.payload.trim()
|
|
770
|
+
if (source.payload && typeof source.payload === 'object') {
|
|
771
|
+
if (typeof source.payload.content === 'string') return source.payload.content.trim()
|
|
772
|
+
if (typeof source.payload.text === 'string') return source.payload.text.trim()
|
|
773
|
+
if (source.payload.message && typeof source.payload.message.text === 'string') return source.payload.message.text.trim()
|
|
774
|
+
}
|
|
775
|
+
if (source.originalMessage && typeof source.originalMessage === 'object') {
|
|
776
|
+
if (typeof source.originalMessage.text === 'string') return source.originalMessage.text.trim()
|
|
777
|
+
if (source.originalMessage.message && typeof source.originalMessage.message.text === 'string') return source.originalMessage.message.text.trim()
|
|
778
|
+
}
|
|
779
|
+
return source.payload === undefined ? '' : safeStringify(source.payload)
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
const resolveKnxAiSessionId = (msg) => {
|
|
783
|
+
const source = msg && typeof msg === 'object' ? msg : {}
|
|
784
|
+
const candidates = [
|
|
785
|
+
source.knxAi && source.knxAi.sessionId,
|
|
786
|
+
source.sessionId,
|
|
787
|
+
source.chatId,
|
|
788
|
+
source.payload && typeof source.payload === 'object' ? source.payload.chatId : '',
|
|
789
|
+
source.payload && source.payload.chat && typeof source.payload.chat === 'object' ? source.payload.chat.id : '',
|
|
790
|
+
source.originalMessage && source.originalMessage.chat ? source.originalMessage.chat.id : '',
|
|
791
|
+
source.originalMessage && source.originalMessage.message && source.originalMessage.message.chat
|
|
792
|
+
? source.originalMessage.message.chat.id
|
|
793
|
+
: ''
|
|
794
|
+
]
|
|
795
|
+
const hit = candidates.find(candidate => candidate !== undefined && candidate !== null && String(candidate).trim() !== '')
|
|
796
|
+
return String(hit === undefined ? 'default' : hit).trim().slice(0, 160) || 'default'
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
const classifyKnxAiConfirmation = ({ msg, question, topic } = {}) => {
|
|
800
|
+
const source = msg && typeof msg === 'object' ? msg : {}
|
|
801
|
+
if (source.knxAi && source.knxAi.confirm === true) return 'confirm'
|
|
802
|
+
if (source.knxAi && source.knxAi.confirm === false) return 'cancel'
|
|
803
|
+
const topicValue = String(topic || '').trim().toLowerCase()
|
|
804
|
+
if (topicValue === 'confirm') return 'confirm'
|
|
805
|
+
if (topicValue === 'cancel') return 'cancel'
|
|
806
|
+
const normalized = String(question || '')
|
|
807
|
+
.normalize('NFKD')
|
|
808
|
+
.replace(/[\u0300-\u036f]/g, '')
|
|
809
|
+
.trim()
|
|
810
|
+
.toLowerCase()
|
|
811
|
+
.replace(/[.!?。!?]+$/g, '')
|
|
812
|
+
.replace(/\s+/g, ' ')
|
|
813
|
+
const confirmations = new Set([
|
|
814
|
+
'confirm', 'confirmed', 'yes', 'y', 'ok', 'proceed',
|
|
815
|
+
'conferma', 'confermo', 'si', 'procedi',
|
|
816
|
+
'confirmer', 'confirme', 'oui',
|
|
817
|
+
'bestatigen', 'bestatige', 'ja',
|
|
818
|
+
'confirmar', 'confirmo', 'adelante',
|
|
819
|
+
'确认', '是'
|
|
820
|
+
])
|
|
821
|
+
const cancellations = new Set([
|
|
822
|
+
'cancel', 'cancelled', 'no', 'n', 'stop',
|
|
823
|
+
'annulla', 'annullo', 'no grazie',
|
|
824
|
+
'annuler', 'annule', 'non',
|
|
825
|
+
'abbrechen', 'abbruch', 'nein',
|
|
826
|
+
'cancelar', 'cancelo',
|
|
827
|
+
'取消', '否'
|
|
828
|
+
])
|
|
829
|
+
if (confirmations.has(normalized)) return 'confirm'
|
|
830
|
+
if (cancellations.has(normalized)) return 'cancel'
|
|
831
|
+
return 'none'
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
const detectKnxAiLanguageFromText = (value) => {
|
|
835
|
+
const raw = String(value || '').trim()
|
|
836
|
+
if (!raw) return ''
|
|
837
|
+
if (/[\u3400-\u9fff]/u.test(raw)) return 'zh'
|
|
838
|
+
const normalized = raw
|
|
839
|
+
.normalize('NFKD')
|
|
840
|
+
.replace(/[\u0300-\u036f]/g, '')
|
|
841
|
+
.toLowerCase()
|
|
842
|
+
const tokens = new Set(normalized.match(/[a-z]+/g) || [])
|
|
843
|
+
const dictionaries = {
|
|
844
|
+
it: ['accendi', 'spegni', 'luce', 'luci', 'soggiorno', 'cucina', 'apri', 'chiudi', 'alza', 'abbassa', 'tapparella', 'tapparelle'],
|
|
845
|
+
en: ['turn', 'switch', 'light', 'lights', 'living', 'room', 'kitchen', 'open', 'close', 'raise', 'lower', 'blind', 'blinds'],
|
|
846
|
+
de: ['schalte', 'licht', 'lichter', 'wohnzimmer', 'kuche', 'offne', 'schliesse', 'hoch', 'runter', 'rollladen'],
|
|
847
|
+
fr: ['allume', 'eteins', 'lumiere', 'lumieres', 'salon', 'cuisine', 'ouvre', 'ferme', 'monte', 'baisse', 'volet'],
|
|
848
|
+
es: ['enciende', 'apaga', 'luz', 'luces', 'salon', 'cocina', 'abre', 'cierra', 'sube', 'baja', 'persiana']
|
|
849
|
+
}
|
|
850
|
+
const scores = Object.entries(dictionaries).map(([language, words]) => ({
|
|
851
|
+
language,
|
|
852
|
+
score: words.reduce((total, word) => total + (tokens.has(word) ? 1 : 0), 0)
|
|
853
|
+
})).sort((a, b) => b.score - a.score)
|
|
854
|
+
if (!scores[0] || scores[0].score === 0) return ''
|
|
855
|
+
if (scores[1] && scores[0].score === scores[1].score) return ''
|
|
856
|
+
return scores[0].language
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
const resolveKnxAiLanguage = (msg, fallback = 'en', question = '', responseLanguage = '') => {
|
|
860
|
+
const source = msg && typeof msg === 'object' ? msg : {}
|
|
861
|
+
const explicitCandidates = [
|
|
862
|
+
source.knxAi && source.knxAi.language,
|
|
863
|
+
source.language,
|
|
864
|
+
source.payload && typeof source.payload === 'object' ? source.payload.language : ''
|
|
865
|
+
]
|
|
866
|
+
const inferredCandidates = [
|
|
867
|
+
responseLanguage,
|
|
868
|
+
detectKnxAiLanguageFromText(question),
|
|
869
|
+
source.originalMessage && source.originalMessage.from ? source.originalMessage.from.language_code : '',
|
|
870
|
+
source.originalMessage && source.originalMessage.message && source.originalMessage.message.from
|
|
871
|
+
? source.originalMessage.message.from.language_code
|
|
872
|
+
: ''
|
|
873
|
+
]
|
|
874
|
+
const supported = new Set(['en', 'it', 'de', 'fr', 'es', 'zh'])
|
|
875
|
+
for (const candidate of explicitCandidates.concat(inferredCandidates)) {
|
|
876
|
+
if (candidate === undefined || candidate === null || String(candidate).trim() === '') continue
|
|
877
|
+
const language = normalizeLanguageCode(candidate, '')
|
|
878
|
+
if (supported.has(language)) return language
|
|
879
|
+
}
|
|
880
|
+
const fallbackLanguage = normalizeLanguageCode(fallback, 'en')
|
|
881
|
+
return supported.has(fallbackLanguage) ? fallbackLanguage : 'en'
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
const getKnxAiConfirmationCopy = (language) => {
|
|
885
|
+
const copies = {
|
|
886
|
+
en: {
|
|
887
|
+
preview: 'KNX changes awaiting confirmation',
|
|
888
|
+
instruction: 'Reply exactly CONFIRM to proceed or CANCEL to discard them. The request expires in 5 minutes.',
|
|
889
|
+
confirmLabel: 'Confirm',
|
|
890
|
+
cancelLabel: 'Cancel',
|
|
891
|
+
confirmed: count => `Confirmed: ${count} KNX command(s) forwarded to the flow. Execution still requires KNX status feedback.`,
|
|
892
|
+
cancelled: 'Cancelled: no KNX command was sent.',
|
|
893
|
+
expired: 'The pending KNX command request has expired. Please repeat the original request.',
|
|
894
|
+
missing: 'There is no KNX command request awaiting confirmation.',
|
|
895
|
+
invalid: details => `The pending KNX commands are no longer valid and were not sent: ${details}.`
|
|
896
|
+
},
|
|
897
|
+
it: {
|
|
898
|
+
preview: 'Modifiche KNX in attesa di conferma',
|
|
899
|
+
instruction: 'Rispondi esattamente CONFERMA per procedere oppure ANNULLA per eliminarle. La richiesta scade tra 5 minuti.',
|
|
900
|
+
confirmLabel: 'Conferma',
|
|
901
|
+
cancelLabel: 'Annulla',
|
|
902
|
+
confirmed: count => `Confermato: ${count} comando/i KNX inoltrato/i al flow. L'esecuzione deve comunque essere verificata tramite lo stato KNX.`,
|
|
903
|
+
cancelled: 'Annullato: non è stato inviato alcun comando KNX.',
|
|
904
|
+
expired: 'La richiesta di comandi KNX è scaduta. Ripeti la richiesta originale.',
|
|
905
|
+
missing: 'Non ci sono comandi KNX in attesa di conferma.',
|
|
906
|
+
invalid: details => `I comandi KNX in attesa non sono più validi e non sono stati inviati: ${details}.`
|
|
907
|
+
},
|
|
908
|
+
de: {
|
|
909
|
+
preview: 'KNX-Änderungen warten auf Bestätigung',
|
|
910
|
+
instruction: 'Antworte genau mit BESTÄTIGEN oder ABBRECHEN. Die Anfrage läuft nach 5 Minuten ab.',
|
|
911
|
+
confirmLabel: 'Bestätigen',
|
|
912
|
+
cancelLabel: 'Abbrechen',
|
|
913
|
+
confirmed: count => `Bestätigt: ${count} KNX-Befehl(e) an den Flow weitergegeben. Die Ausführung muss über KNX-Statusfeedback geprüft werden.`,
|
|
914
|
+
cancelled: 'Abgebrochen: Es wurde kein KNX-Befehl gesendet.',
|
|
915
|
+
expired: 'Die ausstehende KNX-Anfrage ist abgelaufen. Bitte die ursprüngliche Anfrage wiederholen.',
|
|
916
|
+
missing: 'Es wartet keine KNX-Anfrage auf Bestätigung.',
|
|
917
|
+
invalid: details => `Die ausstehenden KNX-Befehle sind nicht mehr gültig und wurden nicht gesendet: ${details}.`
|
|
918
|
+
},
|
|
919
|
+
fr: {
|
|
920
|
+
preview: 'Modifications KNX en attente de confirmation',
|
|
921
|
+
instruction: 'Répondez exactement CONFIRMER pour continuer ou ANNULER pour abandonner. La demande expire dans 5 minutes.',
|
|
922
|
+
confirmLabel: 'Confirmer',
|
|
923
|
+
cancelLabel: 'Annuler',
|
|
924
|
+
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.`,
|
|
925
|
+
cancelled: 'Annulé : aucune commande KNX n’a été envoyée.',
|
|
926
|
+
expired: 'La demande de commandes KNX a expiré. Répétez la demande initiale.',
|
|
927
|
+
missing: 'Aucune commande KNX n’est en attente de confirmation.',
|
|
928
|
+
invalid: details => `Les commandes KNX en attente ne sont plus valides et n’ont pas été envoyées : ${details}.`
|
|
929
|
+
},
|
|
930
|
+
es: {
|
|
931
|
+
preview: 'Cambios KNX pendientes de confirmación',
|
|
932
|
+
instruction: 'Responde exactamente CONFIRMAR para continuar o CANCELAR para descartarlos. La solicitud caduca en 5 minutos.',
|
|
933
|
+
confirmLabel: 'Confirmar',
|
|
934
|
+
cancelLabel: 'Cancelar',
|
|
935
|
+
confirmed: count => `Confirmado: ${count} comando(s) KNX enviado(s) al flow. La ejecución aún debe verificarse mediante el estado KNX.`,
|
|
936
|
+
cancelled: 'Cancelado: no se envió ningún comando KNX.',
|
|
937
|
+
expired: 'La solicitud de comandos KNX ha caducado. Repite la solicitud original.',
|
|
938
|
+
missing: 'No hay comandos KNX pendientes de confirmación.',
|
|
939
|
+
invalid: details => `Los comandos KNX pendientes ya no son válidos y no se enviaron: ${details}.`
|
|
940
|
+
},
|
|
941
|
+
zh: {
|
|
942
|
+
preview: '等待确认的 KNX 更改',
|
|
943
|
+
instruction: '请准确回复“确认”以继续,或回复“取消”以放弃。请求将在 5 分钟后过期。',
|
|
944
|
+
confirmLabel: '确认',
|
|
945
|
+
cancelLabel: '取消',
|
|
946
|
+
confirmed: count => `已确认:${count} 条 KNX 命令已转发到 flow。仍需通过 KNX 状态反馈确认执行结果。`,
|
|
947
|
+
cancelled: '已取消:未发送任何 KNX 命令。',
|
|
948
|
+
expired: '待处理的 KNX 命令请求已过期,请重新发送原始请求。',
|
|
949
|
+
missing: '当前没有等待确认的 KNX 命令。',
|
|
950
|
+
invalid: details => `待处理的 KNX 命令已失效,未发送:${details}。`
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
return copies[language] || copies.en
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
const getKnxAiReadCopy = (language) => {
|
|
957
|
+
const copies = {
|
|
958
|
+
en: {
|
|
959
|
+
heading: 'Updated KNX readings',
|
|
960
|
+
noResponse: 'The KNX read request was sent, but no device replied within the timeout.',
|
|
961
|
+
partial: 'No response received for'
|
|
962
|
+
},
|
|
963
|
+
it: {
|
|
964
|
+
heading: 'Letture KNX aggiornate',
|
|
965
|
+
noResponse: 'La richiesta di lettura KNX è stata inviata, ma nessun dispositivo ha risposto entro il timeout.',
|
|
966
|
+
partial: 'Nessuna risposta ricevuta per'
|
|
967
|
+
},
|
|
968
|
+
de: {
|
|
969
|
+
heading: 'Aktualisierte KNX-Messwerte',
|
|
970
|
+
noResponse: 'Die KNX-Leseanfrage wurde gesendet, aber innerhalb des Timeouts hat kein Gerät geantwortet.',
|
|
971
|
+
partial: 'Keine Antwort erhalten für'
|
|
972
|
+
},
|
|
973
|
+
fr: {
|
|
974
|
+
heading: 'Lectures KNX actualisées',
|
|
975
|
+
noResponse: 'La demande de lecture KNX a été envoyée, mais aucun appareil n’a répondu avant l’expiration du délai.',
|
|
976
|
+
partial: 'Aucune réponse reçue pour'
|
|
977
|
+
},
|
|
978
|
+
es: {
|
|
979
|
+
heading: 'Lecturas KNX actualizadas',
|
|
980
|
+
noResponse: 'La solicitud de lectura KNX se envió, pero ningún dispositivo respondió antes de agotarse el tiempo.',
|
|
981
|
+
partial: 'No se recibió respuesta para'
|
|
982
|
+
},
|
|
983
|
+
zh: {
|
|
984
|
+
heading: '已更新的 KNX 读数',
|
|
985
|
+
noResponse: 'KNX 读取请求已发送,但在超时前没有设备响应。',
|
|
986
|
+
partial: '未收到响应'
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
return copies[language] || copies.en
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
const formatKnxAiReadResults = ({ operations, results, language }) => {
|
|
993
|
+
const reads = Array.isArray(operations) ? operations : []
|
|
994
|
+
const settled = Array.isArray(results) ? results : []
|
|
995
|
+
const copy = getKnxAiReadCopy(language)
|
|
996
|
+
const values = []
|
|
997
|
+
const missing = []
|
|
998
|
+
reads.forEach((operation, index) => {
|
|
999
|
+
const result = settled[index]
|
|
1000
|
+
const label = String(operation && (operation.label || operation.destination) ? (operation.label || operation.destination) : '').trim()
|
|
1001
|
+
if (!result || result.status !== 'fulfilled' || !result.value) {
|
|
1002
|
+
missing.push(label || String(operation && operation.destination ? operation.destination : '').trim())
|
|
1003
|
+
return
|
|
1004
|
+
}
|
|
1005
|
+
const telegram = result.value
|
|
1006
|
+
const rawValue = telegram.payload
|
|
1007
|
+
const value = rawValue && typeof rawValue === 'object' ? safeStringify(rawValue) : String(rawValue)
|
|
1008
|
+
const unit = String(telegram.payloadmeasureunit || '').trim()
|
|
1009
|
+
values.push(`- ${label || operation.destination}: ${value}${unit ? ` ${unit}` : ''}`)
|
|
1010
|
+
})
|
|
1011
|
+
if (values.length === 0) return copy.noResponse
|
|
1012
|
+
const lines = [`${copy.heading}:`, ...values]
|
|
1013
|
+
if (missing.length > 0) lines.push('', `${copy.partial}: ${missing.filter(Boolean).join(', ')}.`)
|
|
1014
|
+
return lines.join('\n')
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
const buildKnxAiConfirmationRequest = ({
|
|
1018
|
+
sessionId,
|
|
1019
|
+
expiresAt,
|
|
1020
|
+
commandCount,
|
|
1021
|
+
copy
|
|
1022
|
+
}) => {
|
|
1023
|
+
const resolvedSessionId = String(sessionId || 'default')
|
|
1024
|
+
const resolvedExpiresAt = Number(expiresAt || 0)
|
|
1025
|
+
const buildAction = ({ id, label, confirm }) => ({
|
|
1026
|
+
id,
|
|
1027
|
+
label,
|
|
1028
|
+
callbackData: id,
|
|
1029
|
+
message: {
|
|
1030
|
+
topic: id,
|
|
1031
|
+
knxAi: {
|
|
1032
|
+
confirm,
|
|
1033
|
+
sessionId: resolvedSessionId
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
})
|
|
1037
|
+
return {
|
|
1038
|
+
required: true,
|
|
1039
|
+
status: 'pending',
|
|
1040
|
+
sessionId: resolvedSessionId,
|
|
1041
|
+
expiresAt: resolvedExpiresAt,
|
|
1042
|
+
expiresAtIso: resolvedExpiresAt > 0 ? new Date(resolvedExpiresAt).toISOString() : '',
|
|
1043
|
+
commandCount: Math.max(0, Number(commandCount) || 0),
|
|
1044
|
+
actions: [
|
|
1045
|
+
buildAction({ id: 'confirm', label: copy.confirmLabel, confirm: true }),
|
|
1046
|
+
buildAction({ id: 'cancel', label: copy.cancelLabel, confirm: false })
|
|
1047
|
+
]
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
const cloneKnxAiInputMessage = (inputMessage, cloneMessage, onError) => {
|
|
1052
|
+
const source = inputMessage && typeof inputMessage === 'object' ? inputMessage : {}
|
|
1053
|
+
if (typeof cloneMessage === 'function') {
|
|
1054
|
+
try {
|
|
1055
|
+
const cloned = cloneMessage(source)
|
|
1056
|
+
if (cloned && typeof cloned === 'object') return cloned
|
|
1057
|
+
} catch (error) {
|
|
1058
|
+
try {
|
|
1059
|
+
if (typeof onError === 'function') onError(error)
|
|
1060
|
+
} catch (reportError) {
|
|
1061
|
+
// Reporting a clone error must never propagate to the Node-RED runtime.
|
|
1062
|
+
}
|
|
1063
|
+
// Fall back to a shallow copy if a custom message property cannot be cloned.
|
|
1064
|
+
}
|
|
1065
|
+
}
|
|
1066
|
+
try {
|
|
1067
|
+
return Object.assign({}, source)
|
|
1068
|
+
} catch (error) {
|
|
1069
|
+
try {
|
|
1070
|
+
if (typeof onError === 'function') onError(error)
|
|
1071
|
+
} catch (reportError) {
|
|
1072
|
+
// Reporting a clone error must never propagate to the Node-RED runtime.
|
|
1073
|
+
}
|
|
1074
|
+
return {}
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
const safeKnxAiSend = ({ outputs, send, onError }) => {
|
|
1079
|
+
try {
|
|
1080
|
+
if (typeof send !== 'function') throw new Error('KNX AI output sender is unavailable')
|
|
1081
|
+
send(outputs)
|
|
1082
|
+
return true
|
|
1083
|
+
} catch (error) {
|
|
1084
|
+
try {
|
|
1085
|
+
if (typeof onError === 'function') onError(error)
|
|
1086
|
+
} catch (reportError) {
|
|
1087
|
+
// Reporting an output error must never propagate to the Node-RED runtime.
|
|
1088
|
+
}
|
|
1089
|
+
return false
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
const compileKnxAiChatAdapter = ({ code, direction = 'chat' } = {}) => {
|
|
1094
|
+
const source = String(code || '').trim()
|
|
1095
|
+
if (!source) return null
|
|
1096
|
+
const adapterDirection = String(direction || 'chat').trim() || 'chat'
|
|
1097
|
+
try {
|
|
1098
|
+
return {
|
|
1099
|
+
direction: adapterDirection,
|
|
1100
|
+
run: new Function('msg', 'inputMessage', 'node', 'RED', `"use strict";\n${source}`) // eslint-disable-line no-new-func
|
|
1101
|
+
}
|
|
1102
|
+
} catch (error) {
|
|
1103
|
+
throw new Error(`Invalid KNX AI ${adapterDirection} adapter: ${error.message || error}`)
|
|
1104
|
+
}
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
const executeKnxAiChatAdapter = ({
|
|
1108
|
+
adapter,
|
|
1109
|
+
msg,
|
|
1110
|
+
inputMessage,
|
|
1111
|
+
node,
|
|
1112
|
+
RED
|
|
1113
|
+
} = {}) => {
|
|
1114
|
+
if (!adapter || typeof adapter.run !== 'function') return msg
|
|
1115
|
+
const result = adapter.run(msg, inputMessage, node, RED)
|
|
1116
|
+
if (result && typeof result.then === 'function') {
|
|
1117
|
+
throw new Error(`KNX AI ${adapter.direction || 'chat'} adapter must be synchronous`)
|
|
1118
|
+
}
|
|
1119
|
+
if (result === undefined || result === null) return null
|
|
1120
|
+
if (!result || typeof result !== 'object' || Array.isArray(result)) {
|
|
1121
|
+
throw new Error(`KNX AI ${adapter.direction || 'chat'} adapter must return msg or no value`)
|
|
1122
|
+
}
|
|
1123
|
+
return result
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
const buildKnxAiUniversalMessage = ({
|
|
1127
|
+
command,
|
|
1128
|
+
question,
|
|
1129
|
+
sessionId,
|
|
1130
|
+
confirmed,
|
|
1131
|
+
index,
|
|
1132
|
+
inputMessage
|
|
1133
|
+
}) => {
|
|
1134
|
+
const operation = command && typeof command === 'object' ? command : {}
|
|
1135
|
+
const event = operation.event === 'GroupValue_Read' ? 'GroupValue_Read' : 'GroupValue_Write'
|
|
1136
|
+
const outputMessage = {
|
|
1137
|
+
topic: operation.destination,
|
|
1138
|
+
destination: operation.destination,
|
|
1139
|
+
dpt: operation.dpt,
|
|
1140
|
+
payload: event === 'GroupValue_Read' ? '' : operation.payload,
|
|
1141
|
+
event,
|
|
1142
|
+
inputMessage,
|
|
1143
|
+
knxAi: {
|
|
1144
|
+
type: event === 'GroupValue_Read' ? 'knx_read' : 'knx_command',
|
|
1145
|
+
source: 'llm',
|
|
1146
|
+
question,
|
|
1147
|
+
sessionId,
|
|
1148
|
+
confirmed: event === 'GroupValue_Write' && confirmed === true,
|
|
1149
|
+
index,
|
|
1150
|
+
label: operation.label || '',
|
|
1151
|
+
reason: operation.reason || ''
|
|
1152
|
+
}
|
|
1153
|
+
}
|
|
1154
|
+
if (event === 'GroupValue_Read') outputMessage.readstatus = true
|
|
1155
|
+
return outputMessage
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
const formatKnxAiCommandPreview = ({ commands, copy }) => {
|
|
1159
|
+
const lines = (Array.isArray(commands) ? commands : []).map((command, index) => {
|
|
1160
|
+
const payload = typeof command.payload === 'string' ? command.payload : safeStringify(command.payload)
|
|
1161
|
+
return `${index + 1}. ${command.label || command.destination} — ${command.destination} / DPT ${command.dpt} → ${payload}`
|
|
1162
|
+
})
|
|
1163
|
+
return [
|
|
1164
|
+
copy.preview + ':',
|
|
1165
|
+
...lines,
|
|
1166
|
+
'',
|
|
1167
|
+
copy.instruction
|
|
1168
|
+
].join('\n')
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
const validateKnxAiPayloadForDpt = ({ dpt, payload }) => {
|
|
1172
|
+
const resolved = dptlib.resolve(dpt)
|
|
1173
|
+
const base = resolved && resolved.basetype ? resolved.basetype : {}
|
|
1174
|
+
const subtype = resolved && resolved.subtype ? resolved.subtype : {}
|
|
1175
|
+
const main = String(dpt || '').split('.')[0]
|
|
1176
|
+
|
|
1177
|
+
if (base.valuetype === 'composite' && (!payload || typeof payload !== 'object' || Array.isArray(payload))) {
|
|
1178
|
+
throw new Error(`DPT ${dpt} requires an object payload`)
|
|
1179
|
+
}
|
|
1180
|
+
if (main === '1' && typeof payload !== 'boolean') {
|
|
1181
|
+
throw new Error(`DPT ${dpt} requires a boolean payload`)
|
|
1182
|
+
}
|
|
1183
|
+
if (main === '3') {
|
|
1184
|
+
const direction = Number(payload && payload.decr_incr)
|
|
1185
|
+
const data = Number(payload && payload.data)
|
|
1186
|
+
if (![0, 1].includes(direction) || !Number.isInteger(data) || data < 0 || data > 7) {
|
|
1187
|
+
throw new Error(`DPT ${dpt} requires {decr_incr:0|1,data:0..7}`)
|
|
1188
|
+
}
|
|
1189
|
+
}
|
|
1190
|
+
if (main === '232') {
|
|
1191
|
+
const channels = ['red', 'green', 'blue'].map(key => Number(payload && payload[key]))
|
|
1192
|
+
if (channels.some(value => !Number.isInteger(value) || value < 0 || value > 255)) {
|
|
1193
|
+
throw new Error(`DPT ${dpt} requires {red,green,blue} values from 0 to 255`)
|
|
1194
|
+
}
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
if (typeof payload === 'number') {
|
|
1198
|
+
const candidateRange = Array.isArray(subtype.scalar_range) && subtype.scalar_range.length === 2
|
|
1199
|
+
? subtype.scalar_range
|
|
1200
|
+
: Array.isArray(subtype.range) && subtype.range.length === 2
|
|
1201
|
+
? subtype.range
|
|
1202
|
+
: Array.isArray(base.range) && base.range.length === 2
|
|
1203
|
+
? base.range
|
|
1204
|
+
: null
|
|
1205
|
+
if (candidateRange && Number.isFinite(Number(candidateRange[0])) && Number.isFinite(Number(candidateRange[1]))) {
|
|
1206
|
+
const min = Number(candidateRange[0])
|
|
1207
|
+
const max = Number(candidateRange[1])
|
|
1208
|
+
if (payload < min || payload > max) throw new Error(`DPT ${dpt} payload must be between ${min} and ${max}`)
|
|
1209
|
+
}
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
if (subtype.enc && typeof subtype.enc === 'object' && !Array.isArray(subtype.enc) && main !== '1') {
|
|
1213
|
+
const allowed = new Set(Object.keys(subtype.enc).map(key => String(key)))
|
|
1214
|
+
if (!allowed.has(String(payload))) throw new Error(`DPT ${dpt} payload is not an allowed enumerated value`)
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
if (resolved && typeof resolved.formatAPDU === 'function') resolved.formatAPDU(payload)
|
|
1218
|
+
return payload
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
const coerceKnxAiCommandPayload = (value, { dpt } = {}) => {
|
|
1222
|
+
const dptId = String(dpt || '').trim()
|
|
1223
|
+
const parsed = parseActuatorPayloadInput(value)
|
|
1224
|
+
const main = dptId.split('.')[0]
|
|
1225
|
+
if (main === '1') {
|
|
1226
|
+
if (parsed === true || parsed === false) return parsed
|
|
1227
|
+
if (parsed === 1) return true
|
|
1228
|
+
if (parsed === 0) return false
|
|
1229
|
+
const rawBoolean = typeof parsed === 'string' ? parsed.trim() : String(parsed)
|
|
1230
|
+
const normalized = rawBoolean.toLowerCase()
|
|
1231
|
+
if (['true', '1', 'on'].includes(normalized)) return true
|
|
1232
|
+
if (['false', '0', 'off'].includes(normalized)) return false
|
|
1233
|
+
const normalizedLabel = normalizeSignalText(rawBoolean)
|
|
1234
|
+
const booleanOption = getDptValueOptions(dptId).find(item => {
|
|
1235
|
+
return normalizeSignalText(item.label) === normalizedLabel
|
|
1236
|
+
})
|
|
1237
|
+
if (booleanOption) {
|
|
1238
|
+
const optionValue = parseActuatorPayloadInput(booleanOption.value)
|
|
1239
|
+
if (optionValue === true || optionValue === 1) return true
|
|
1240
|
+
if (optionValue === false || optionValue === 0) return false
|
|
1241
|
+
}
|
|
1242
|
+
throw new Error(`DPT ${dptId} payload must be true/false, 1/0, on/off, or an exact ETS value label`)
|
|
1243
|
+
}
|
|
1244
|
+
if (typeof parsed !== 'string') return parsed
|
|
1245
|
+
const raw = parsed.trim()
|
|
1246
|
+
if (main === '16') return raw
|
|
1247
|
+
const normalizedRaw = normalizeSignalText(raw)
|
|
1248
|
+
const option = getDptValueOptions(dptId).find(item => {
|
|
1249
|
+
return String(item.value) === raw || normalizeSignalText(item.label) === normalizedRaw
|
|
1250
|
+
})
|
|
1251
|
+
if (option) return parseActuatorPayloadInput(option.value)
|
|
1252
|
+
throw new Error(`DPT ${dptId} payload must be a typed JSON value or an exact ETS value label`)
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
const resolveKnxAiOperationEvent = (candidate) => {
|
|
1256
|
+
const item = candidate && typeof candidate === 'object' ? candidate : {}
|
|
1257
|
+
const raw = String(item.event || item.operation || item.action || '').trim().toLowerCase()
|
|
1258
|
+
if (['groupvalue_read', 'read', 'query', 'request_status'].includes(raw)) return 'GroupValue_Read'
|
|
1259
|
+
return 'GroupValue_Write'
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
const normalizeKnxAiCommandCandidates = ({
|
|
1263
|
+
commands,
|
|
1264
|
+
catalog,
|
|
1265
|
+
maxCommands = 5,
|
|
1266
|
+
maxReadCommands = 20,
|
|
1267
|
+
coercePayload = value => value
|
|
1268
|
+
} = {}) => {
|
|
1269
|
+
const sourceCommands = Array.isArray(commands) ? commands : []
|
|
1270
|
+
const safeCatalog = Array.isArray(catalog) ? catalog : []
|
|
1271
|
+
const catalogByGa = new Map(safeCatalog.map(item => [String(item && item.ga ? item.ga : '').trim(), item]))
|
|
1272
|
+
const accepted = []
|
|
1273
|
+
const rejected = []
|
|
1274
|
+
const writeLimit = Math.max(1, Math.min(20, Number(maxCommands) || 5))
|
|
1275
|
+
const readLimit = Math.max(1, Math.min(50, Number(maxReadCommands) || 20))
|
|
1276
|
+
const totalLimit = writeLimit + readLimit
|
|
1277
|
+
let acceptedWrites = 0
|
|
1278
|
+
let acceptedReads = 0
|
|
1279
|
+
let writeLimitReported = false
|
|
1280
|
+
let readLimitReported = false
|
|
1281
|
+
|
|
1282
|
+
sourceCommands.slice(0, totalLimit).forEach((candidate, index) => {
|
|
1283
|
+
try {
|
|
1284
|
+
const item = candidate && typeof candidate === 'object' ? candidate : {}
|
|
1285
|
+
const event = resolveKnxAiOperationEvent(item)
|
|
1286
|
+
const destination = String(item.destination || item.ga || item.groupAddress || item.address || '').trim()
|
|
1287
|
+
if (!destination) throw new Error('missing destination')
|
|
1288
|
+
const catalogItem = catalogByGa.get(destination)
|
|
1289
|
+
if (!catalogItem) throw new Error('destination is not present in the imported ETS catalog')
|
|
1290
|
+
if (event === 'GroupValue_Write' && String(catalogItem.role || '').trim().toLowerCase() !== 'command') {
|
|
1291
|
+
throw new Error('destination is not classified as a command group address')
|
|
1292
|
+
}
|
|
1293
|
+
const catalogDpt = String(catalogItem.dpt || '').trim()
|
|
1294
|
+
if (!catalogDpt) throw new Error('the ETS catalog has no DPT for this destination')
|
|
1295
|
+
const requestedDpt = String(item.dpt || '').trim()
|
|
1296
|
+
if (requestedDpt && requestedDpt !== catalogDpt) {
|
|
1297
|
+
throw new Error(`requested DPT ${requestedDpt} does not match ETS DPT ${catalogDpt}`)
|
|
1298
|
+
}
|
|
1299
|
+
if (event === 'GroupValue_Read') {
|
|
1300
|
+
if (acceptedReads >= readLimit) {
|
|
1301
|
+
if (readLimitReported) return
|
|
1302
|
+
readLimitReported = true
|
|
1303
|
+
throw new Error(`read limit exceeded (${readLimit})`)
|
|
1304
|
+
}
|
|
1305
|
+
acceptedReads += 1
|
|
1306
|
+
accepted.push({
|
|
1307
|
+
destination,
|
|
1308
|
+
dpt: catalogDpt,
|
|
1309
|
+
payload: '',
|
|
1310
|
+
readstatus: true,
|
|
1311
|
+
event,
|
|
1312
|
+
label: String(catalogItem.label || destination).trim(),
|
|
1313
|
+
reason: String(item.reason || '').trim(),
|
|
1314
|
+
sourceIndex: index
|
|
1315
|
+
})
|
|
1316
|
+
return
|
|
1317
|
+
}
|
|
1318
|
+
if (acceptedWrites >= writeLimit) {
|
|
1319
|
+
if (writeLimitReported) return
|
|
1320
|
+
writeLimitReported = true
|
|
1321
|
+
throw new Error(`command limit exceeded (${writeLimit})`)
|
|
1322
|
+
}
|
|
1323
|
+
const hasPayload = Object.prototype.hasOwnProperty.call(item, 'payload') || Object.prototype.hasOwnProperty.call(item, 'value')
|
|
1324
|
+
if (!hasPayload) throw new Error('missing payload')
|
|
1325
|
+
const rawPayload = Object.prototype.hasOwnProperty.call(item, 'payload') ? item.payload : item.value
|
|
1326
|
+
const payload = coercePayload(rawPayload, {
|
|
1327
|
+
dpt: catalogDpt,
|
|
1328
|
+
action: item.action,
|
|
1329
|
+
reason: item.reason,
|
|
1330
|
+
label: catalogItem.label,
|
|
1331
|
+
destination
|
|
1332
|
+
})
|
|
1333
|
+
validateKnxAiPayloadForDpt({ dpt: catalogDpt, payload })
|
|
1334
|
+
acceptedWrites += 1
|
|
1335
|
+
accepted.push({
|
|
1336
|
+
destination,
|
|
1337
|
+
dpt: catalogDpt,
|
|
1338
|
+
payload,
|
|
1339
|
+
event: 'GroupValue_Write',
|
|
1340
|
+
label: String(catalogItem.label || destination).trim(),
|
|
1341
|
+
reason: String(item.reason || '').trim(),
|
|
1342
|
+
sourceIndex: index
|
|
1343
|
+
})
|
|
1344
|
+
} catch (error) {
|
|
1345
|
+
rejected.push({
|
|
1346
|
+
sourceIndex: index,
|
|
1347
|
+
reason: error && error.message ? error.message : String(error)
|
|
1348
|
+
})
|
|
1349
|
+
}
|
|
1350
|
+
})
|
|
1351
|
+
|
|
1352
|
+
if (sourceCommands.length > totalLimit) {
|
|
1353
|
+
rejected.push({
|
|
1354
|
+
sourceIndex: totalLimit,
|
|
1355
|
+
reason: `operation limit exceeded (${totalLimit})`
|
|
1356
|
+
})
|
|
1357
|
+
}
|
|
1358
|
+
return { accepted, rejected }
|
|
1359
|
+
}
|
|
1360
|
+
|
|
741
1361
|
const normalizeValueForCompare = (value) => {
|
|
742
1362
|
if (value === undefined) return 'undefined'
|
|
743
1363
|
if (value === null) return 'null'
|
|
@@ -2939,6 +3559,8 @@ const ensureOllamaServerRunning = async ({ baseUrl, autoStart = false, timeoutMs
|
|
|
2939
3559
|
const isProbablyChatModelId = (id) => {
|
|
2940
3560
|
const s = String(id || '').toLowerCase()
|
|
2941
3561
|
if (!s) return false
|
|
3562
|
+
if (s === 'babbage-002' || s === 'davinci-002') return false
|
|
3563
|
+
if (s.includes('instruct')) return false
|
|
2942
3564
|
if (s.includes('embedding')) return false
|
|
2943
3565
|
if (s.includes('whisper')) return false
|
|
2944
3566
|
if (s.includes('tts')) return false
|
|
@@ -2947,6 +3569,109 @@ const isProbablyChatModelId = (id) => {
|
|
|
2947
3569
|
return true
|
|
2948
3570
|
}
|
|
2949
3571
|
|
|
3572
|
+
const isChatCompletionsModelError = (value) => {
|
|
3573
|
+
const message = String(value || '').toLowerCase()
|
|
3574
|
+
return message.includes('not a chat model') ||
|
|
3575
|
+
message.includes('not supported in the v1/chat/completions endpoint') ||
|
|
3576
|
+
message.includes('only compatible with the legacy completions endpoint') ||
|
|
3577
|
+
message.includes('not chat completions') ||
|
|
3578
|
+
message.includes('does not support chat completions')
|
|
3579
|
+
}
|
|
3580
|
+
|
|
3581
|
+
const decorateChatCompletionsModelError = ({ error, model, url }) => {
|
|
3582
|
+
const originalMessage = String(error && error.message ? error.message : error || '').trim()
|
|
3583
|
+
const selectedModel = String(model || '').trim() || '(empty)'
|
|
3584
|
+
const endpoint = String(url || '').trim() || OPENAI_COMPAT_DEFAULT_CHAT_URL
|
|
3585
|
+
const decorated = new Error(
|
|
3586
|
+
`Model "${selectedModel}" is not compatible with the configured Chat Completions endpoint (${endpoint}). ` +
|
|
3587
|
+
'Choose a chat-capable model, for example gpt-5.4 or gpt-4o-mini. ' +
|
|
3588
|
+
`Legacy /v1/completions models are not supported by KNX AI.${originalMessage ? ` Provider response: ${originalMessage}` : ''}`
|
|
3589
|
+
)
|
|
3590
|
+
if (error && error.status !== undefined) decorated.status = error.status
|
|
3591
|
+
if (error && error.response !== undefined) decorated.response = error.response
|
|
3592
|
+
return decorated
|
|
3593
|
+
}
|
|
3594
|
+
|
|
3595
|
+
const isUnsupportedTemperatureError = (value) => {
|
|
3596
|
+
const message = String(value || '').toLowerCase()
|
|
3597
|
+
return message.includes("unsupported value: 'temperature'") ||
|
|
3598
|
+
message.includes('unsupported parameter: temperature') ||
|
|
3599
|
+
(message.includes('temperature') && message.includes('only the default'))
|
|
3600
|
+
}
|
|
3601
|
+
|
|
3602
|
+
const isResponseFormatCompatibilityError = (value) => {
|
|
3603
|
+
const message = String(value || '')
|
|
3604
|
+
return message.includes("Unsupported parameter: 'response_format'") ||
|
|
3605
|
+
message.includes('Invalid schema for response_format') ||
|
|
3606
|
+
message.includes('response_format') ||
|
|
3607
|
+
message.includes('json_schema')
|
|
3608
|
+
}
|
|
3609
|
+
|
|
3610
|
+
const postOpenAiCompatibleChatWithFallbacks = async ({
|
|
3611
|
+
url,
|
|
3612
|
+
headers,
|
|
3613
|
+
body,
|
|
3614
|
+
timeoutMs,
|
|
3615
|
+
model,
|
|
3616
|
+
post = postJson
|
|
3617
|
+
}) => {
|
|
3618
|
+
let requestBody = Object.assign({}, body)
|
|
3619
|
+
let lastError = null
|
|
3620
|
+
const rejectedTokenParameters = new Set()
|
|
3621
|
+
const hasOwn = key => Object.prototype.hasOwnProperty.call(requestBody, key)
|
|
3622
|
+
|
|
3623
|
+
for (let attempt = 0; attempt < 6; attempt++) {
|
|
3624
|
+
try {
|
|
3625
|
+
return await post({ url, headers, body: requestBody, timeoutMs })
|
|
3626
|
+
} catch (error) {
|
|
3627
|
+
lastError = error
|
|
3628
|
+
const message = String(error && error.message ? error.message : '')
|
|
3629
|
+
|
|
3630
|
+
if (isChatCompletionsModelError(message)) {
|
|
3631
|
+
throw decorateChatCompletionsModelError({ error, model, url })
|
|
3632
|
+
}
|
|
3633
|
+
|
|
3634
|
+
if (isUnsupportedTemperatureError(message) && hasOwn('temperature')) {
|
|
3635
|
+
requestBody = Object.assign({}, requestBody)
|
|
3636
|
+
delete requestBody.temperature
|
|
3637
|
+
continue
|
|
3638
|
+
}
|
|
3639
|
+
|
|
3640
|
+
if (isResponseFormatCompatibilityError(message) && hasOwn('response_format')) {
|
|
3641
|
+
requestBody = Object.assign({}, requestBody)
|
|
3642
|
+
delete requestBody.response_format
|
|
3643
|
+
continue
|
|
3644
|
+
}
|
|
3645
|
+
|
|
3646
|
+
if (message.includes("Unsupported parameter: 'max_tokens'") && hasOwn('max_tokens')) {
|
|
3647
|
+
rejectedTokenParameters.add('max_tokens')
|
|
3648
|
+
const value = requestBody.max_tokens
|
|
3649
|
+
requestBody = Object.assign({}, requestBody)
|
|
3650
|
+
delete requestBody.max_tokens
|
|
3651
|
+
if (!rejectedTokenParameters.has('max_completion_tokens')) {
|
|
3652
|
+
requestBody.max_completion_tokens = value
|
|
3653
|
+
continue
|
|
3654
|
+
}
|
|
3655
|
+
}
|
|
3656
|
+
|
|
3657
|
+
if (message.includes("Unsupported parameter: 'max_completion_tokens'") && hasOwn('max_completion_tokens')) {
|
|
3658
|
+
rejectedTokenParameters.add('max_completion_tokens')
|
|
3659
|
+
const value = requestBody.max_completion_tokens
|
|
3660
|
+
requestBody = Object.assign({}, requestBody)
|
|
3661
|
+
delete requestBody.max_completion_tokens
|
|
3662
|
+
if (!rejectedTokenParameters.has('max_tokens')) {
|
|
3663
|
+
requestBody.max_tokens = value
|
|
3664
|
+
continue
|
|
3665
|
+
}
|
|
3666
|
+
}
|
|
3667
|
+
|
|
3668
|
+
throw error
|
|
3669
|
+
}
|
|
3670
|
+
}
|
|
3671
|
+
|
|
3672
|
+
throw lastError || new Error('OpenAI-compatible chat request failed after compatibility retries')
|
|
3673
|
+
}
|
|
3674
|
+
|
|
2950
3675
|
module.exports = function (RED) {
|
|
2951
3676
|
const flowGACache = { at: 0, set: new Set() }
|
|
2952
3677
|
const flowNodeCatalogCache = { at: 0, catalog: null }
|
|
@@ -4306,6 +5031,11 @@ module.exports = function (RED) {
|
|
|
4306
5031
|
node.llmDocsLanguage = config.llmDocsLanguage ? String(config.llmDocsLanguage) : 'it'
|
|
4307
5032
|
node.llmDocsMaxSnippets = (config.llmDocsMaxSnippets === undefined || config.llmDocsMaxSnippets === '') ? 5 : Number(config.llmDocsMaxSnippets)
|
|
4308
5033
|
node.llmDocsMaxChars = (config.llmDocsMaxChars === undefined || config.llmDocsMaxChars === '') ? 60000 : Number(config.llmDocsMaxChars)
|
|
5034
|
+
node.llmAllowKnxCommands = config.llmAllowKnxCommands !== undefined ? coerceBoolean(config.llmAllowKnxCommands) : false
|
|
5035
|
+
node.llmRequireCommandConfirmation = config.llmRequireCommandConfirmation !== undefined ? coerceBoolean(config.llmRequireCommandConfirmation) : true
|
|
5036
|
+
node.chatAdapterPreset = String(config.chatAdapterPreset || 'none')
|
|
5037
|
+
node.chatInputCode = String(config.chatInputCode || '')
|
|
5038
|
+
node.chatOutputCode = String(config.chatOutputCode || '')
|
|
4309
5039
|
|
|
4310
5040
|
const pushStatus = (status) => {
|
|
4311
5041
|
if (!status) return
|
|
@@ -4326,6 +5056,25 @@ module.exports = function (RED) {
|
|
|
4326
5056
|
pushStatus(status)
|
|
4327
5057
|
}
|
|
4328
5058
|
|
|
5059
|
+
const compileConfiguredChatAdapter = ({ code, direction }) => {
|
|
5060
|
+
try {
|
|
5061
|
+
return compileKnxAiChatAdapter({ code, direction })
|
|
5062
|
+
} catch (error) {
|
|
5063
|
+
try { node.sysLogger?.error(`knxUltimateAI ${direction} adapter compile error: ${error.message || error}`) } catch (e) { /* ignore */ }
|
|
5064
|
+
try { node.error(error) } catch (e) { /* ignore */ }
|
|
5065
|
+
return null
|
|
5066
|
+
}
|
|
5067
|
+
}
|
|
5068
|
+
|
|
5069
|
+
node._chatInputAdapter = compileConfiguredChatAdapter({
|
|
5070
|
+
code: node.chatInputCode,
|
|
5071
|
+
direction: 'chat input'
|
|
5072
|
+
})
|
|
5073
|
+
node._chatOutputAdapter = compileConfiguredChatAdapter({
|
|
5074
|
+
code: node.chatOutputCode,
|
|
5075
|
+
direction: 'chat output'
|
|
5076
|
+
})
|
|
5077
|
+
|
|
4329
5078
|
// Used to call the status update from the config node.
|
|
4330
5079
|
node.setNodeStatus = ({ fill, shape, text, payload, GA, dpt, devicename }) => {
|
|
4331
5080
|
try {
|
|
@@ -4357,6 +5106,8 @@ module.exports = function (RED) {
|
|
|
4357
5106
|
node._summaryRebuildTimer = null
|
|
4358
5107
|
node._anomalies = []
|
|
4359
5108
|
node._assistantLog = []
|
|
5109
|
+
node._conversationSessions = new Map()
|
|
5110
|
+
node._pendingKnxCommands = new Map()
|
|
4360
5111
|
node._flowContextCache = { at: 0, text: '' }
|
|
4361
5112
|
node._docsContextCache = { at: 0, question: '', text: '' }
|
|
4362
5113
|
node._areaSuggestionCache = { ref: null, snapshot: buildSuggestedAreasFromCsv([]) }
|
|
@@ -7603,47 +8354,15 @@ module.exports = function (RED) {
|
|
|
7603
8354
|
})
|
|
7604
8355
|
: baseBody
|
|
7605
8356
|
|
|
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
|
-
}
|
|
8357
|
+
// OpenAI-compatible providers differ on optional sampling, response-format,
|
|
8358
|
+
// and token-limit parameters. Retry only the rejected compatibility field.
|
|
8359
|
+
const json = await postOpenAiCompatibleChatWithFallbacks({
|
|
8360
|
+
url,
|
|
8361
|
+
headers,
|
|
8362
|
+
body: Object.assign({ max_tokens: resolvedMaxTokens }, schemaBody),
|
|
8363
|
+
timeoutMs: effectiveTimeoutMs,
|
|
8364
|
+
model: baseBody.model
|
|
8365
|
+
})
|
|
7647
8366
|
const content = extractOpenAICompatText(json) || buildOpenAICompatFallbackText(json)
|
|
7648
8367
|
const finishReason = String(json && json.choices && json.choices[0] && json.choices[0].finish_reason ? json.choices[0].finish_reason : '')
|
|
7649
8368
|
return { provider: 'openai_compat', model: baseBody.model, content, finishReason }
|
|
@@ -7844,6 +8563,303 @@ module.exports = function (RED) {
|
|
|
7844
8563
|
return Object.assign({}, ret, { content: finalContent, summary })
|
|
7845
8564
|
}
|
|
7846
8565
|
|
|
8566
|
+
const getConversationHistory = (sessionId) => {
|
|
8567
|
+
const key = String(sessionId || 'default')
|
|
8568
|
+
const history = node._conversationSessions.get(key)
|
|
8569
|
+
return Array.isArray(history) ? history.slice(-8) : []
|
|
8570
|
+
}
|
|
8571
|
+
|
|
8572
|
+
const rememberConversationTurn = ({ sessionId, question, reply }) => {
|
|
8573
|
+
const key = String(sessionId || 'default')
|
|
8574
|
+
const history = getConversationHistory(key)
|
|
8575
|
+
history.push({
|
|
8576
|
+
question: String(question || '').trim(),
|
|
8577
|
+
reply: String(reply || '').trim()
|
|
8578
|
+
})
|
|
8579
|
+
node._conversationSessions.delete(key)
|
|
8580
|
+
node._conversationSessions.set(key, history.slice(-8))
|
|
8581
|
+
while (node._conversationSessions.size > 50) {
|
|
8582
|
+
const oldestKey = node._conversationSessions.keys().next().value
|
|
8583
|
+
node._conversationSessions.delete(oldestKey)
|
|
8584
|
+
}
|
|
8585
|
+
}
|
|
8586
|
+
|
|
8587
|
+
const callConversationalLLM = async ({ question, sessionId, requireConfirmation = true }) => {
|
|
8588
|
+
const summary = rebuildCachedSummaryNow()
|
|
8589
|
+
const catalog = getGaCatalogSnapshot()
|
|
8590
|
+
const history = getConversationHistory(sessionId)
|
|
8591
|
+
const gaLimit = 600
|
|
8592
|
+
const gaLines = catalog.slice(0, gaLimit).map((item) => {
|
|
8593
|
+
const role = String(item && item.role ? item.role : 'neutral').trim()
|
|
8594
|
+
const dpt = String(item && item.dpt ? item.dpt : '').trim() || '?'
|
|
8595
|
+
const label = String(item && item.label ? item.label : item && item.ga ? item.ga : '').trim()
|
|
8596
|
+
const valueOptions = (Array.isArray(item && item.valueOptions) ? item.valueOptions : [])
|
|
8597
|
+
.slice(0, 20)
|
|
8598
|
+
.map(option => `${option.value}=${option.label}`)
|
|
8599
|
+
.join(', ')
|
|
8600
|
+
return `${item.ga} | dpt ${dpt} | role ${role} | ${label}${valueOptions ? ` | values ${valueOptions}` : ''}`
|
|
8601
|
+
})
|
|
8602
|
+
const conversationLines = history.flatMap(turn => [
|
|
8603
|
+
`User: ${turn.question}`,
|
|
8604
|
+
`Assistant: ${turn.reply}`
|
|
8605
|
+
])
|
|
8606
|
+
const analysisContext = buildLLMPrompt({ question, summary, compact: true })
|
|
8607
|
+
const systemPrompt = [
|
|
8608
|
+
node.llmSystemPrompt || 'You are a KNX building automation assistant.',
|
|
8609
|
+
'',
|
|
8610
|
+
'KNX CHAT AND CONTROL CONTRACT:',
|
|
8611
|
+
'- 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"}]}.',
|
|
8612
|
+
'- Use the same language as the user for reply and reason.',
|
|
8613
|
+
'- Set language to the ISO code matching the current user request: en, it, de, fr, es, or zh.',
|
|
8614
|
+
'- 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.',
|
|
8615
|
+
'- GroupValue_Read is allowed for exact status, neutral, or command objects in AVAILABLE KNX OBJECTS because it does not modify the bus state.',
|
|
8616
|
+
'- 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.',
|
|
8617
|
+
'- Create a GroupValue_Write only when the user clearly asks to control an actuator now.',
|
|
8618
|
+
'- Never invent, guess, transform, or substitute a group address or DPT.',
|
|
8619
|
+
'- A GroupValue_Write destination must appear in AVAILABLE KNX OBJECTS with role command. Status and neutral objects must never receive GroupValue_Write.',
|
|
8620
|
+
'- Copy the DPT exactly from AVAILABLE KNX OBJECTS.',
|
|
8621
|
+
'- 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.',
|
|
8622
|
+
'- Emit the smallest necessary operation set, in execution order, with at most 5 writes and 20 reads.',
|
|
8623
|
+
'- Do not claim that an action succeeded. Say that the command is being forwarded or prepared; real KNX feedback is separate.',
|
|
8624
|
+
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.' : '',
|
|
8625
|
+
'- If the request is ambiguous, unsafe, unsupported, or has no exact KNX object, ask a concise clarification and return no commands.'
|
|
8626
|
+
].filter(Boolean).join('\n')
|
|
8627
|
+
const userContent = [
|
|
8628
|
+
history.length ? 'RECENT CONVERSATION:' : '',
|
|
8629
|
+
history.length ? conversationLines.join('\n') : '',
|
|
8630
|
+
history.length ? '' : '',
|
|
8631
|
+
analysisContext,
|
|
8632
|
+
'',
|
|
8633
|
+
`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):`,
|
|
8634
|
+
gaLines.length ? gaLines.join('\n') : '(no ETS group addresses imported; return no commands)',
|
|
8635
|
+
'',
|
|
8636
|
+
'Return the JSON object now.'
|
|
8637
|
+
].join('\n')
|
|
8638
|
+
const configuredMaxTokens = Math.max(10000, Number(node.llmMaxTokens) || 0)
|
|
8639
|
+
const ret = await callLLMChat({
|
|
8640
|
+
systemPrompt,
|
|
8641
|
+
userContent,
|
|
8642
|
+
jsonSchema: {
|
|
8643
|
+
name: 'knx_ai_conversation',
|
|
8644
|
+
strict: true,
|
|
8645
|
+
schema: {
|
|
8646
|
+
type: 'object',
|
|
8647
|
+
additionalProperties: false,
|
|
8648
|
+
properties: {
|
|
8649
|
+
reply: { type: 'string' },
|
|
8650
|
+
language: { type: 'string', enum: ['en', 'it', 'de', 'fr', 'es', 'zh'] },
|
|
8651
|
+
commands: {
|
|
8652
|
+
type: 'array',
|
|
8653
|
+
maxItems: 25,
|
|
8654
|
+
items: {
|
|
8655
|
+
type: 'object',
|
|
8656
|
+
additionalProperties: false,
|
|
8657
|
+
properties: {
|
|
8658
|
+
event: { type: 'string', enum: ['GroupValue_Read', 'GroupValue_Write'] },
|
|
8659
|
+
destination: { type: 'string' },
|
|
8660
|
+
dpt: { type: 'string' },
|
|
8661
|
+
payload: {},
|
|
8662
|
+
reason: { type: 'string' }
|
|
8663
|
+
},
|
|
8664
|
+
required: ['event', 'destination', 'dpt', 'payload', 'reason']
|
|
8665
|
+
}
|
|
8666
|
+
}
|
|
8667
|
+
},
|
|
8668
|
+
required: ['reply', 'language', 'commands']
|
|
8669
|
+
}
|
|
8670
|
+
},
|
|
8671
|
+
maxTokensOverride: configuredMaxTokens
|
|
8672
|
+
})
|
|
8673
|
+
|
|
8674
|
+
let envelope
|
|
8675
|
+
try {
|
|
8676
|
+
envelope = parseKnxAiConversationResponse(ret.content)
|
|
8677
|
+
} catch (error) {
|
|
8678
|
+
return Object.assign({}, ret, {
|
|
8679
|
+
content: String(ret.content || '').trim() || 'The AI provider returned an empty response.',
|
|
8680
|
+
commands: [],
|
|
8681
|
+
rejectedCommands: [],
|
|
8682
|
+
summary,
|
|
8683
|
+
structuredOutputError: error.message || String(error)
|
|
8684
|
+
})
|
|
8685
|
+
}
|
|
8686
|
+
|
|
8687
|
+
const normalized = normalizeKnxAiCommandCandidates({
|
|
8688
|
+
commands: envelope.commands,
|
|
8689
|
+
catalog,
|
|
8690
|
+
maxCommands: 5,
|
|
8691
|
+
maxReadCommands: 20,
|
|
8692
|
+
coercePayload: (value, context) => coerceKnxAiCommandPayload(value, context)
|
|
8693
|
+
})
|
|
8694
|
+
let reply = envelope.reply || (normalized.accepted.length ? 'KNX command prepared.' : 'No response text was returned.')
|
|
8695
|
+
if (normalized.rejected.length) {
|
|
8696
|
+
const details = normalized.rejected.map(item => item.reason).join('; ')
|
|
8697
|
+
reply += `\n\nKNX command not sent: ${details}.`
|
|
8698
|
+
}
|
|
8699
|
+
return Object.assign({}, ret, {
|
|
8700
|
+
content: reply,
|
|
8701
|
+
language: envelope.language,
|
|
8702
|
+
commands: normalized.accepted,
|
|
8703
|
+
rejectedCommands: normalized.rejected,
|
|
8704
|
+
summary
|
|
8705
|
+
})
|
|
8706
|
+
}
|
|
8707
|
+
|
|
8708
|
+
const cloneInputMessage = inputMessage => cloneKnxAiInputMessage(
|
|
8709
|
+
inputMessage,
|
|
8710
|
+
RED.util && typeof RED.util.cloneMessage === 'function'
|
|
8711
|
+
? message => RED.util.cloneMessage(message)
|
|
8712
|
+
: null,
|
|
8713
|
+
error => {
|
|
8714
|
+
try { node.sysLogger?.warn(`knxUltimateAI input message clone warning: ${error.message || error}`) } catch (e) { /* ignore */ }
|
|
8715
|
+
}
|
|
8716
|
+
)
|
|
8717
|
+
|
|
8718
|
+
const adaptAssistantOutput = (value, inputMessage) => {
|
|
8719
|
+
if (!node._chatOutputAdapter || value === null || value === undefined) return value
|
|
8720
|
+
const adaptOne = message => executeKnxAiChatAdapter({
|
|
8721
|
+
adapter: node._chatOutputAdapter,
|
|
8722
|
+
msg: message,
|
|
8723
|
+
inputMessage,
|
|
8724
|
+
node,
|
|
8725
|
+
RED
|
|
8726
|
+
})
|
|
8727
|
+
try {
|
|
8728
|
+
if (Array.isArray(value)) {
|
|
8729
|
+
const adapted = value.map(adaptOne).filter(message => message !== null)
|
|
8730
|
+
return adapted.length ? adapted : null
|
|
8731
|
+
}
|
|
8732
|
+
return adaptOne(value)
|
|
8733
|
+
} catch (error) {
|
|
8734
|
+
try { node.sysLogger?.error(`knxUltimateAI chat output adapter error: ${error.message || error}`) } catch (e) { /* ignore */ }
|
|
8735
|
+
try { node.error(error, inputMessage) } catch (e) { /* ignore */ }
|
|
8736
|
+
try { updateStatus({ fill: 'red', shape: 'dot', text: `Chat output adapter error: ${error.message || error}` }) } catch (e) { /* ignore */ }
|
|
8737
|
+
return null
|
|
8738
|
+
}
|
|
8739
|
+
}
|
|
8740
|
+
|
|
8741
|
+
const sendKnxAiOutputs = (outputs, inputMessage) => {
|
|
8742
|
+
const preparedOutputs = Array.isArray(outputs) ? outputs.slice() : outputs
|
|
8743
|
+
if (Array.isArray(preparedOutputs) && preparedOutputs.length > 2) {
|
|
8744
|
+
preparedOutputs[2] = adaptAssistantOutput(preparedOutputs[2], inputMessage)
|
|
8745
|
+
}
|
|
8746
|
+
return safeKnxAiSend({
|
|
8747
|
+
outputs: preparedOutputs,
|
|
8748
|
+
send: messages => node.send(messages),
|
|
8749
|
+
onError: error => {
|
|
8750
|
+
try { node.sysLogger?.error(`knxUltimateAI output error: ${error.message || error}`) } catch (e) { /* ignore */ }
|
|
8751
|
+
try { node.error(error, inputMessage) } catch (e) { /* ignore */ }
|
|
8752
|
+
try { updateStatus({ fill: 'red', shape: 'dot', text: `AI output error: ${error.message || error}` }) } catch (e) { /* ignore */ }
|
|
8753
|
+
}
|
|
8754
|
+
})
|
|
8755
|
+
}
|
|
8756
|
+
|
|
8757
|
+
const buildKnxAiReplyMessage = ({ inputMessage, content, metadata = {}, summary }) => {
|
|
8758
|
+
const replyMessage = cloneInputMessage(inputMessage)
|
|
8759
|
+
replyMessage.topic = node.outputtopic
|
|
8760
|
+
replyMessage.payload = content
|
|
8761
|
+
replyMessage.knxAi = metadata
|
|
8762
|
+
replyMessage.inputMessage = cloneInputMessage(inputMessage)
|
|
8763
|
+
if (summary !== undefined) replyMessage.summary = summary
|
|
8764
|
+
return replyMessage
|
|
8765
|
+
}
|
|
8766
|
+
|
|
8767
|
+
const buildKnxAiCommandMessages = ({ commands, question, sessionId, confirmed, inputMessage }) => {
|
|
8768
|
+
return (Array.isArray(commands) ? commands : []).map((command, index) => buildKnxAiUniversalMessage({
|
|
8769
|
+
command,
|
|
8770
|
+
question,
|
|
8771
|
+
sessionId,
|
|
8772
|
+
confirmed,
|
|
8773
|
+
index,
|
|
8774
|
+
inputMessage: cloneInputMessage(inputMessage)
|
|
8775
|
+
}))
|
|
8776
|
+
}
|
|
8777
|
+
|
|
8778
|
+
const handleKnxAiConfirmationDecision = ({ msg, question, sessionId, decision }) => {
|
|
8779
|
+
const pending = node._pendingKnxCommands.get(sessionId)
|
|
8780
|
+
const language = pending && pending.language
|
|
8781
|
+
? pending.language
|
|
8782
|
+
: resolveKnxAiLanguage(msg, node.llmDocsLanguage || 'en', question)
|
|
8783
|
+
const copy = getKnxAiConfirmationCopy(language)
|
|
8784
|
+
if (!pending) {
|
|
8785
|
+
const reply = buildKnxAiReplyMessage({
|
|
8786
|
+
inputMessage: msg,
|
|
8787
|
+
content: copy.missing,
|
|
8788
|
+
metadata: { type: 'knx_confirmation_missing', sessionId }
|
|
8789
|
+
})
|
|
8790
|
+
sendKnxAiOutputs([null, null, reply, null], msg)
|
|
8791
|
+
return
|
|
8792
|
+
}
|
|
8793
|
+
node._pendingKnxCommands.delete(sessionId)
|
|
8794
|
+
if (Number(pending.expiresAt || 0) <= nowMs()) {
|
|
8795
|
+
const reply = buildKnxAiReplyMessage({
|
|
8796
|
+
inputMessage: msg,
|
|
8797
|
+
content: copy.expired,
|
|
8798
|
+
metadata: { type: 'knx_confirmation_expired', sessionId }
|
|
8799
|
+
})
|
|
8800
|
+
if (!sendKnxAiOutputs([null, null, reply, null], msg)) return
|
|
8801
|
+
updateStatus({ fill: 'grey', shape: 'dot', text: 'AI KNX confirmation expired' })
|
|
8802
|
+
return
|
|
8803
|
+
}
|
|
8804
|
+
if (decision === 'cancel') {
|
|
8805
|
+
const reply = buildKnxAiReplyMessage({
|
|
8806
|
+
inputMessage: msg,
|
|
8807
|
+
content: copy.cancelled,
|
|
8808
|
+
metadata: { type: 'knx_confirmation_cancelled', sessionId }
|
|
8809
|
+
})
|
|
8810
|
+
rememberConversationTurn({ sessionId, question: question || 'CANCEL', reply: copy.cancelled })
|
|
8811
|
+
if (!sendKnxAiOutputs([null, null, reply, null], msg)) return
|
|
8812
|
+
updateStatus({ fill: 'grey', shape: 'dot', text: 'AI KNX commands cancelled' })
|
|
8813
|
+
return
|
|
8814
|
+
}
|
|
8815
|
+
|
|
8816
|
+
const normalized = normalizeKnxAiCommandCandidates({
|
|
8817
|
+
commands: pending.commands,
|
|
8818
|
+
catalog: getGaCatalogSnapshot(),
|
|
8819
|
+
maxCommands: 5,
|
|
8820
|
+
coercePayload: (value, context) => coerceKnxAiCommandPayload(value, context)
|
|
8821
|
+
})
|
|
8822
|
+
if (normalized.rejected.length || !normalized.accepted.length) {
|
|
8823
|
+
const details = normalized.rejected.length
|
|
8824
|
+
? normalized.rejected.map(item => item.reason).join('; ')
|
|
8825
|
+
: 'no valid command remains'
|
|
8826
|
+
const content = copy.invalid(details)
|
|
8827
|
+
const reply = buildKnxAiReplyMessage({
|
|
8828
|
+
inputMessage: msg,
|
|
8829
|
+
content,
|
|
8830
|
+
metadata: {
|
|
8831
|
+
type: 'knx_confirmation_invalid',
|
|
8832
|
+
sessionId,
|
|
8833
|
+
rejectedCommands: normalized.rejected
|
|
8834
|
+
}
|
|
8835
|
+
})
|
|
8836
|
+
rememberConversationTurn({ sessionId, question: question || 'CONFIRM', reply: content })
|
|
8837
|
+
if (!sendKnxAiOutputs([null, null, reply, null], msg)) return
|
|
8838
|
+
updateStatus({ fill: 'red', shape: 'dot', text: 'AI KNX confirmation validation failed' })
|
|
8839
|
+
return
|
|
8840
|
+
}
|
|
8841
|
+
const commandMessages = buildKnxAiCommandMessages({
|
|
8842
|
+
commands: normalized.accepted,
|
|
8843
|
+
question: pending.question,
|
|
8844
|
+
sessionId,
|
|
8845
|
+
confirmed: true,
|
|
8846
|
+
inputMessage: msg
|
|
8847
|
+
})
|
|
8848
|
+
const content = copy.confirmed(commandMessages.length)
|
|
8849
|
+
const reply = buildKnxAiReplyMessage({
|
|
8850
|
+
inputMessage: msg,
|
|
8851
|
+
content,
|
|
8852
|
+
metadata: {
|
|
8853
|
+
type: 'knx_confirmation_accepted',
|
|
8854
|
+
sessionId,
|
|
8855
|
+
commandCount: commandMessages.length
|
|
8856
|
+
}
|
|
8857
|
+
})
|
|
8858
|
+
rememberConversationTurn({ sessionId, question: question || 'CONFIRM', reply: content })
|
|
8859
|
+
if (!sendKnxAiOutputs([null, null, reply, commandMessages], msg)) return
|
|
8860
|
+
updateStatus({ fill: 'green', shape: 'dot', text: `AI confirmed, ${commandMessages.length} KNX command(s)` })
|
|
8861
|
+
}
|
|
8862
|
+
|
|
7847
8863
|
const emitSummary = () => {
|
|
7848
8864
|
try {
|
|
7849
8865
|
const summary = rebuildCachedSummaryNow()
|
|
@@ -8156,6 +9172,8 @@ module.exports = function (RED) {
|
|
|
8156
9172
|
}]
|
|
8157
9173
|
node._lastSummary = null
|
|
8158
9174
|
node._lastSummaryAt = 0
|
|
9175
|
+
node._conversationSessions = new Map()
|
|
9176
|
+
node._pendingKnxCommands = new Map()
|
|
8159
9177
|
if (node._summaryRebuildTimer) {
|
|
8160
9178
|
clearTimeout(node._summaryRebuildTimer)
|
|
8161
9179
|
node._summaryRebuildTimer = null
|
|
@@ -8165,40 +9183,204 @@ module.exports = function (RED) {
|
|
|
8165
9183
|
return
|
|
8166
9184
|
}
|
|
8167
9185
|
|
|
9186
|
+
if (cmd === 'confirm' || cmd === 'cancel') {
|
|
9187
|
+
const question = extractKnxAiQuestion(msg)
|
|
9188
|
+
const sessionId = resolveKnxAiSessionId(msg)
|
|
9189
|
+
handleKnxAiConfirmationDecision({
|
|
9190
|
+
msg,
|
|
9191
|
+
question,
|
|
9192
|
+
sessionId,
|
|
9193
|
+
decision: cmd === 'confirm' ? 'confirm' : 'cancel'
|
|
9194
|
+
})
|
|
9195
|
+
return
|
|
9196
|
+
}
|
|
9197
|
+
|
|
8168
9198
|
if (cmd === 'summary' || cmd === 'stats' || cmd === 'top' || cmd === '') {
|
|
8169
9199
|
emitSummary()
|
|
8170
9200
|
return
|
|
8171
9201
|
}
|
|
8172
9202
|
|
|
8173
9203
|
if (cmd === 'ask') {
|
|
8174
|
-
const question = (msg
|
|
8175
|
-
|
|
8176
|
-
|
|
9204
|
+
const question = extractKnxAiQuestion(msg)
|
|
9205
|
+
const sessionId = resolveKnxAiSessionId(msg)
|
|
9206
|
+
if (!question) throw new Error('Missing question')
|
|
9207
|
+
const decision = classifyKnxAiConfirmation({ msg, question, topic: cmd })
|
|
9208
|
+
if (node._pendingKnxCommands.has(sessionId) && decision !== 'none') {
|
|
9209
|
+
handleKnxAiConfirmationDecision({ msg, question, sessionId, decision })
|
|
9210
|
+
return
|
|
9211
|
+
}
|
|
9212
|
+
// A new natural-language request replaces an older unconfirmed plan in
|
|
9213
|
+
// the same chat, preventing a later confirmation from acting on stale intent.
|
|
9214
|
+
node._pendingKnxCommands.delete(sessionId)
|
|
8177
9215
|
updateStatus({ fill: 'blue', shape: 'ring', text: 'AI thinking...' })
|
|
8178
9216
|
try {
|
|
8179
|
-
const ret =
|
|
8180
|
-
|
|
9217
|
+
const ret = node.llmAllowKnxCommands
|
|
9218
|
+
? await callConversationalLLM({
|
|
9219
|
+
question,
|
|
9220
|
+
sessionId,
|
|
9221
|
+
requireConfirmation: node.llmRequireCommandConfirmation
|
|
9222
|
+
})
|
|
9223
|
+
: await callLLM({ question })
|
|
9224
|
+
const preparedCommands = Array.isArray(ret.commands) ? ret.commands : []
|
|
9225
|
+
const readCommands = preparedCommands.filter(command => command && command.event === 'GroupValue_Read')
|
|
9226
|
+
const writeCommands = preparedCommands.filter(command => !command || command.event !== 'GroupValue_Read')
|
|
9227
|
+
const language = resolveKnxAiLanguage(msg, node.llmDocsLanguage || 'en', question, ret.language)
|
|
9228
|
+
const copy = getKnxAiConfirmationCopy(language)
|
|
9229
|
+
const awaitingConfirmation = node.llmAllowKnxCommands &&
|
|
9230
|
+
node.llmRequireCommandConfirmation &&
|
|
9231
|
+
writeCommands.length > 0
|
|
9232
|
+
let content = ret.content
|
|
9233
|
+
let commandsToEmit = preparedCommands
|
|
9234
|
+
let confirmationRequest = null
|
|
9235
|
+
if (awaitingConfirmation) {
|
|
9236
|
+
content = `${content}\n\n${formatKnxAiCommandPreview({ commands: writeCommands, copy })}`
|
|
9237
|
+
const expiresAt = nowMs() + (5 * 60 * 1000)
|
|
9238
|
+
node._pendingKnxCommands.set(sessionId, {
|
|
9239
|
+
question,
|
|
9240
|
+
commands: writeCommands,
|
|
9241
|
+
language,
|
|
9242
|
+
createdAt: nowMs(),
|
|
9243
|
+
expiresAt
|
|
9244
|
+
})
|
|
9245
|
+
confirmationRequest = buildKnxAiConfirmationRequest({
|
|
9246
|
+
sessionId,
|
|
9247
|
+
expiresAt,
|
|
9248
|
+
commandCount: writeCommands.length,
|
|
9249
|
+
copy
|
|
9250
|
+
})
|
|
9251
|
+
while (node._pendingKnxCommands.size > 50) {
|
|
9252
|
+
const oldestSessionId = node._pendingKnxCommands.keys().next().value
|
|
9253
|
+
node._pendingKnxCommands.delete(oldestSessionId)
|
|
9254
|
+
}
|
|
9255
|
+
commandsToEmit = readCommands
|
|
9256
|
+
}
|
|
9257
|
+
const commandMessages = buildKnxAiCommandMessages({
|
|
9258
|
+
commands: commandsToEmit,
|
|
9259
|
+
question,
|
|
9260
|
+
sessionId,
|
|
9261
|
+
confirmed: node.llmRequireCommandConfirmation !== true,
|
|
9262
|
+
inputMessage: msg
|
|
9263
|
+
})
|
|
9264
|
+
const emittedReadCommands = commandsToEmit.filter(command => command && command.event === 'GroupValue_Read')
|
|
9265
|
+
let readResults = []
|
|
9266
|
+
let readResultMetadata = []
|
|
9267
|
+
if (emittedReadCommands.length > 0) {
|
|
9268
|
+
const readStartedAt = nowMs()
|
|
9269
|
+
const readWaiters = emittedReadCommands.map(command => waitForTelegram({
|
|
9270
|
+
destination: command.destination,
|
|
9271
|
+
events: ['GroupValue_Response', 'GroupValue_Write'],
|
|
9272
|
+
minTs: readStartedAt,
|
|
9273
|
+
timeoutMs: 6000
|
|
9274
|
+
}))
|
|
9275
|
+
if (!sendKnxAiOutputs([null, null, null, commandMessages], msg)) return
|
|
9276
|
+
updateStatus({
|
|
9277
|
+
fill: 'blue',
|
|
9278
|
+
shape: 'ring',
|
|
9279
|
+
text: `AI waiting for ${emittedReadCommands.length} KNX read response(s)`
|
|
9280
|
+
})
|
|
9281
|
+
readResults = await Promise.allSettled(readWaiters)
|
|
9282
|
+
readResultMetadata = emittedReadCommands.map((command, index) => {
|
|
9283
|
+
const result = readResults[index]
|
|
9284
|
+
const telegram = result && result.status === 'fulfilled' ? result.value : null
|
|
9285
|
+
return {
|
|
9286
|
+
destination: command.destination,
|
|
9287
|
+
dpt: command.dpt,
|
|
9288
|
+
label: command.label || '',
|
|
9289
|
+
received: !!telegram,
|
|
9290
|
+
event: telegram ? telegram.event : '',
|
|
9291
|
+
payload: telegram ? telegram.payload : undefined,
|
|
9292
|
+
payloadmeasureunit: telegram ? telegram.payloadmeasureunit : ''
|
|
9293
|
+
}
|
|
9294
|
+
})
|
|
9295
|
+
const readReply = formatKnxAiReadResults({
|
|
9296
|
+
operations: emittedReadCommands,
|
|
9297
|
+
results: readResults,
|
|
9298
|
+
language
|
|
9299
|
+
})
|
|
9300
|
+
content = writeCommands.length > 0 ? `${readReply}\n\n${content}` : readReply
|
|
9301
|
+
}
|
|
9302
|
+
const assistantEntry = {
|
|
9303
|
+
at: new Date().toISOString(),
|
|
9304
|
+
question,
|
|
9305
|
+
content,
|
|
9306
|
+
provider: ret.provider,
|
|
9307
|
+
model: ret.model,
|
|
9308
|
+
sessionId,
|
|
9309
|
+
commandCount: writeCommands.length,
|
|
9310
|
+
readCount: readCommands.length,
|
|
9311
|
+
language,
|
|
9312
|
+
awaitingConfirmation,
|
|
9313
|
+
rejectedCommandCount: Array.isArray(ret.rejectedCommands) ? ret.rejectedCommands.length : 0
|
|
9314
|
+
}
|
|
9315
|
+
node._assistantLog.push(assistantEntry)
|
|
8181
9316
|
while (node._assistantLog.length > 50) node._assistantLog.shift()
|
|
8182
|
-
node.
|
|
8183
|
-
|
|
8184
|
-
|
|
8185
|
-
|
|
8186
|
-
|
|
8187
|
-
|
|
8188
|
-
|
|
9317
|
+
if (node.llmAllowKnxCommands) rememberConversationTurn({ sessionId, question, reply: content })
|
|
9318
|
+
const replyMessage = buildKnxAiReplyMessage({
|
|
9319
|
+
inputMessage: msg,
|
|
9320
|
+
content,
|
|
9321
|
+
metadata: {
|
|
9322
|
+
type: 'llm',
|
|
9323
|
+
provider: ret.provider,
|
|
9324
|
+
model: ret.model,
|
|
9325
|
+
question,
|
|
9326
|
+
sessionId,
|
|
9327
|
+
language,
|
|
9328
|
+
operationCount: preparedCommands.length,
|
|
9329
|
+
commandCount: writeCommands.length,
|
|
9330
|
+
readCount: readCommands.length,
|
|
9331
|
+
readResults: readResultMetadata,
|
|
9332
|
+
awaitingConfirmation,
|
|
9333
|
+
confirmationExpiresAt: confirmationRequest ? confirmationRequest.expiresAt : 0,
|
|
9334
|
+
confirmationRequest,
|
|
9335
|
+
rejectedCommands: Array.isArray(ret.rejectedCommands) ? ret.rejectedCommands : [],
|
|
9336
|
+
structuredOutputError: ret.structuredOutputError || ''
|
|
9337
|
+
},
|
|
9338
|
+
summary: emittedReadCommands.length > 0 ? rebuildCachedSummaryNow() : ret.summary
|
|
9339
|
+
})
|
|
9340
|
+
if (emittedReadCommands.length > 0) {
|
|
9341
|
+
if (!sendKnxAiOutputs([null, null, replyMessage, null], msg)) return
|
|
9342
|
+
} else if (!sendKnxAiOutputs([null, null, replyMessage, commandMessages.length ? commandMessages : null], msg)) {
|
|
9343
|
+
return
|
|
9344
|
+
}
|
|
9345
|
+
updateStatus({
|
|
9346
|
+
fill: awaitingConfirmation ? 'yellow' : 'green',
|
|
9347
|
+
shape: awaitingConfirmation ? 'ring' : 'dot',
|
|
9348
|
+
text: awaitingConfirmation
|
|
9349
|
+
? `AI waiting for confirmation (${writeCommands.length} KNX command(s))`
|
|
9350
|
+
: readCommands.length
|
|
9351
|
+
? `AI answer ready, ${readResultMetadata.filter(item => item.received).length}/${readCommands.length} KNX read(s) received`
|
|
9352
|
+
: commandMessages.length
|
|
9353
|
+
? `AI answer ready, ${commandMessages.length} KNX command(s)`
|
|
9354
|
+
: 'AI answer ready'
|
|
9355
|
+
})
|
|
8189
9356
|
} catch (error) {
|
|
8190
9357
|
node._assistantLog.push({ at: new Date().toISOString(), question, error: error.message || String(error) })
|
|
8191
9358
|
while (node._assistantLog.length > 50) node._assistantLog.shift()
|
|
8192
|
-
|
|
8193
|
-
|
|
8194
|
-
|
|
8195
|
-
|
|
8196
|
-
}
|
|
9359
|
+
const replyMessage = buildKnxAiReplyMessage({
|
|
9360
|
+
inputMessage: msg,
|
|
9361
|
+
content: { error: error.message || String(error) },
|
|
9362
|
+
metadata: { type: 'llm_error', question }
|
|
9363
|
+
})
|
|
9364
|
+
if (!sendKnxAiOutputs([null, null, replyMessage, null], msg)) return
|
|
8197
9365
|
updateStatus({ fill: 'red', shape: 'dot', text: `AI error: ${error.message || error}` })
|
|
8198
9366
|
}
|
|
8199
9367
|
return
|
|
8200
9368
|
}
|
|
8201
9369
|
|
|
9370
|
+
if (cmd === 'clear_chat') {
|
|
9371
|
+
const sessionId = resolveKnxAiSessionId(msg)
|
|
9372
|
+
node._conversationSessions.delete(sessionId)
|
|
9373
|
+
node._pendingKnxCommands.delete(sessionId)
|
|
9374
|
+
const replyMessage = buildKnxAiReplyMessage({
|
|
9375
|
+
inputMessage: msg,
|
|
9376
|
+
content: { ok: true, sessionId },
|
|
9377
|
+
metadata: { type: 'conversation_reset', sessionId }
|
|
9378
|
+
})
|
|
9379
|
+
if (!sendKnxAiOutputs([null, null, replyMessage, null], msg)) return
|
|
9380
|
+
updateStatus({ fill: 'grey', shape: 'dot', text: `AI chat cleared (${sessionId})` })
|
|
9381
|
+
return
|
|
9382
|
+
}
|
|
9383
|
+
|
|
8202
9384
|
if (cmd === 'run_profile') {
|
|
8203
9385
|
const areaId = msg.areaId !== undefined ? String(msg.areaId) : (msg.payload && msg.payload.areaId ? String(msg.payload.areaId) : '')
|
|
8204
9386
|
const profileId = msg.profileId !== undefined ? String(msg.profileId) : (msg.payload && msg.payload.profileId ? String(msg.payload.profileId) : '')
|
|
@@ -8249,7 +9431,7 @@ module.exports = function (RED) {
|
|
|
8249
9431
|
return
|
|
8250
9432
|
}
|
|
8251
9433
|
|
|
8252
|
-
node.warn(`knxUltimateAI: unknown command '${cmd}'. Supported: reset, summary, ask, run_profile, run_actuator_test, run_test_plan`)
|
|
9434
|
+
node.warn(`knxUltimateAI: unknown command '${cmd}'. Supported: reset, summary, ask, confirm, cancel, clear_chat, run_profile, run_actuator_test, run_test_plan`)
|
|
8253
9435
|
} catch (error) {
|
|
8254
9436
|
try { node.sysLogger?.error(`knxUltimateAI handleCommand error: ${error.message || error}`) } catch (e) { /* ignore */ }
|
|
8255
9437
|
try { node.error(error) } catch (e) { /* ignore */ }
|
|
@@ -8329,11 +9511,27 @@ module.exports = function (RED) {
|
|
|
8329
9511
|
|
|
8330
9512
|
node.on('input', function (msg) {
|
|
8331
9513
|
try {
|
|
8332
|
-
|
|
9514
|
+
let adaptedMessage = msg
|
|
9515
|
+
try {
|
|
9516
|
+
adaptedMessage = executeKnxAiChatAdapter({
|
|
9517
|
+
adapter: node._chatInputAdapter,
|
|
9518
|
+
msg,
|
|
9519
|
+
inputMessage: msg,
|
|
9520
|
+
node,
|
|
9521
|
+
RED
|
|
9522
|
+
})
|
|
9523
|
+
} catch (error) {
|
|
9524
|
+
try { node.sysLogger?.error(`knxUltimateAI chat input adapter error: ${error.message || error}`) } catch (e) { /* ignore */ }
|
|
9525
|
+
try { node.error(error, msg) } catch (e) { /* ignore */ }
|
|
9526
|
+
try { updateStatus({ fill: 'red', shape: 'dot', text: `Chat input adapter error: ${error.message || error}` }) } catch (e) { /* ignore */ }
|
|
9527
|
+
return
|
|
9528
|
+
}
|
|
9529
|
+
if (!adaptedMessage) return
|
|
9530
|
+
const p = handleCommand(adaptedMessage)
|
|
8333
9531
|
if (p && typeof p.catch === 'function') {
|
|
8334
9532
|
p.catch((error) => {
|
|
8335
9533
|
try { node.sysLogger?.error(`knxUltimateAI input error: ${error.message || error}`) } catch (e) { /* ignore */ }
|
|
8336
|
-
try { node.error(error) } catch (e) { /* ignore */ }
|
|
9534
|
+
try { node.error(error, adaptedMessage) } catch (e) { /* ignore */ }
|
|
8337
9535
|
})
|
|
8338
9536
|
}
|
|
8339
9537
|
} catch (error) {
|
|
@@ -8400,3 +9598,30 @@ module.exports = function (RED) {
|
|
|
8400
9598
|
}
|
|
8401
9599
|
})
|
|
8402
9600
|
}
|
|
9601
|
+
|
|
9602
|
+
module.exports.__test = {
|
|
9603
|
+
buildKnxAiConfirmationRequest,
|
|
9604
|
+
buildKnxAiUniversalMessage,
|
|
9605
|
+
classifyKnxAiConfirmation,
|
|
9606
|
+
cloneKnxAiInputMessage,
|
|
9607
|
+
compileKnxAiChatAdapter,
|
|
9608
|
+
coerceKnxAiCommandPayload,
|
|
9609
|
+
detectKnxAiLanguageFromText,
|
|
9610
|
+
executeKnxAiChatAdapter,
|
|
9611
|
+
extractKnxAiQuestion,
|
|
9612
|
+
formatKnxAiCommandPreview,
|
|
9613
|
+
formatKnxAiReadResults,
|
|
9614
|
+
getKnxAiConfirmationCopy,
|
|
9615
|
+
getKnxAiReadCopy,
|
|
9616
|
+
isChatCompletionsModelError,
|
|
9617
|
+
isProbablyChatModelId,
|
|
9618
|
+
isUnsupportedTemperatureError,
|
|
9619
|
+
normalizeKnxAiCommandCandidates,
|
|
9620
|
+
parseKnxAiConversationResponse,
|
|
9621
|
+
postOpenAiCompatibleChatWithFallbacks,
|
|
9622
|
+
resolveKnxAiLanguage,
|
|
9623
|
+
resolveKnxAiOperationEvent,
|
|
9624
|
+
resolveKnxAiSessionId,
|
|
9625
|
+
safeKnxAiSend,
|
|
9626
|
+
validateKnxAiPayloadForDpt
|
|
9627
|
+
}
|