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.
Files changed (49) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/examples/KNX AI - Conversational Control with Confirmation.json +374 -0
  3. package/examples/KNX AI - Telegrambot Direct Chat.json +221 -0
  4. package/examples/Matter Controller - Semantic Flow Input.json +248 -0
  5. package/nodes/commonFunctions.js +0 -174
  6. package/nodes/knxUltimateAI.html +254 -90
  7. package/nodes/knxUltimateAI.js +1286 -61
  8. package/nodes/knxUltimateMatterBridge.html +144 -11
  9. package/nodes/knxUltimateMatterControllerDevice.html +80 -55
  10. package/nodes/locales/de/knxUltimateAI.html +38 -2
  11. package/nodes/locales/de/knxUltimateAI.json +16 -3
  12. package/nodes/locales/de/knxUltimateMatterBridge.html +1 -1
  13. package/nodes/locales/de/knxUltimateMatterBridge.json +14 -0
  14. package/nodes/locales/de/knxUltimateMatterControllerDevice.html +2 -2
  15. package/nodes/locales/de/knxUltimateMatterControllerDevice.json +2 -1
  16. package/nodes/locales/en/knxUltimateAI.html +38 -2
  17. package/nodes/locales/en/knxUltimateAI.json +16 -3
  18. package/nodes/locales/en/knxUltimateMatterBridge.html +1 -1
  19. package/nodes/locales/en/knxUltimateMatterBridge.json +14 -0
  20. package/nodes/locales/en/knxUltimateMatterControllerDevice.html +2 -2
  21. package/nodes/locales/en/knxUltimateMatterControllerDevice.json +2 -1
  22. package/nodes/locales/es/knxUltimateAI.html +38 -2
  23. package/nodes/locales/es/knxUltimateAI.json +16 -3
  24. package/nodes/locales/es/knxUltimateMatterBridge.html +1 -1
  25. package/nodes/locales/es/knxUltimateMatterBridge.json +14 -0
  26. package/nodes/locales/es/knxUltimateMatterControllerDevice.html +2 -2
  27. package/nodes/locales/es/knxUltimateMatterControllerDevice.json +2 -1
  28. package/nodes/locales/fr/knxUltimateAI.html +38 -2
  29. package/nodes/locales/fr/knxUltimateAI.json +16 -3
  30. package/nodes/locales/fr/knxUltimateMatterBridge.html +1 -1
  31. package/nodes/locales/fr/knxUltimateMatterBridge.json +14 -0
  32. package/nodes/locales/fr/knxUltimateMatterControllerDevice.html +2 -2
  33. package/nodes/locales/fr/knxUltimateMatterControllerDevice.json +2 -1
  34. package/nodes/locales/it/knxUltimateAI.html +38 -2
  35. package/nodes/locales/it/knxUltimateAI.json +16 -3
  36. package/nodes/locales/it/knxUltimateMatterBridge.html +1 -1
  37. package/nodes/locales/it/knxUltimateMatterBridge.json +14 -0
  38. package/nodes/locales/it/knxUltimateMatterControllerDevice.html +2 -2
  39. package/nodes/locales/it/knxUltimateMatterControllerDevice.json +2 -1
  40. package/nodes/locales/zh-CN/knxUltimateAI.html +38 -2
  41. package/nodes/locales/zh-CN/knxUltimateAI.json +16 -3
  42. package/nodes/locales/zh-CN/knxUltimateMatterBridge.html +1 -1
  43. package/nodes/locales/zh-CN/knxUltimateMatterBridge.json +14 -0
  44. package/nodes/locales/zh-CN/knxUltimateMatterControllerDevice.html +2 -2
  45. package/nodes/locales/zh-CN/knxUltimateMatterControllerDevice.json +2 -1
  46. package/nodes/utils/sysLogger.js +0 -109
  47. package/package.json +1 -2
  48. package/resources/KNXAIChatAdapterMappings.js +87 -0
  49. package/nodes/plugins/knxUltimateMonitor-sidebar-plugin.html +0 -922
@@ -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
- // Some OpenAI models (and some compatible gateways) require `max_completion_tokens` instead of `max_tokens`.
7607
- // Try with `max_tokens` first for broad compatibility, then fallback once if the server rejects it.
7608
- const bodyWithMaxTokens = Object.assign({ max_tokens: resolvedMaxTokens }, schemaBody)
7609
- const bodyWithMaxCompletionTokens = Object.assign({ max_completion_tokens: resolvedMaxTokens }, schemaBody)
7610
- const plainBodyWithMaxTokens = Object.assign({ max_tokens: resolvedMaxTokens }, baseBody)
7611
- const plainBodyWithMaxCompletionTokens = Object.assign({ max_completion_tokens: resolvedMaxTokens }, baseBody)
7612
-
7613
- const isResponseFormatCompatibilityError = (message) => {
7614
- const msg = String(message || '')
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.prompt !== undefined)
8175
- ? String(msg.prompt)
8176
- : (typeof msg.payload === 'string' ? msg.payload : safeStringify(msg.payload))
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 = await callLLM({ question })
8180
- node._assistantLog.push({ at: new Date().toISOString(), question, content: ret.content, provider: ret.provider, model: ret.model })
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.send([null, null, {
8183
- topic: node.outputtopic,
8184
- payload: ret.content,
8185
- knxAi: { type: 'llm', provider: ret.provider, model: ret.model, question },
8186
- summary: ret.summary
8187
- }])
8188
- updateStatus({ fill: 'green', shape: 'dot', text: 'AI answer ready' })
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
- node.send([null, null, {
8193
- topic: node.outputtopic,
8194
- payload: { error: error.message || String(error) },
8195
- knxAi: { type: 'llm_error', question }
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
- const p = handleCommand(msg)
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
+ }