node-red-contrib-knx-ultimate 6.3.13 → 6.3.15
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 +11 -0
- package/examples/KNX AI - Telegrambot Direct Chat.json +0 -1
- package/nodes/knxUltimateAI.html +1 -7
- package/nodes/knxUltimateAI.js +265 -29
- package/nodes/knxUltimateMatterBridge.html +19 -2
- package/nodes/knxUltimateMatterBridge.js +18 -3
- package/nodes/locales/de/knxUltimateAI.html +6 -6
- package/nodes/locales/de/knxUltimateAI.json +1 -3
- package/nodes/locales/de/knxUltimateMatterBridge.html +3 -1
- package/nodes/locales/de/knxUltimateMatterBridge.json +5 -1
- package/nodes/locales/en/knxUltimateAI.html +6 -6
- package/nodes/locales/en/knxUltimateAI.json +1 -3
- package/nodes/locales/en/knxUltimateMatterBridge.html +3 -1
- package/nodes/locales/en/knxUltimateMatterBridge.json +5 -1
- package/nodes/locales/es/knxUltimateAI.html +6 -6
- package/nodes/locales/es/knxUltimateAI.json +1 -3
- package/nodes/locales/es/knxUltimateMatterBridge.html +3 -1
- package/nodes/locales/es/knxUltimateMatterBridge.json +5 -1
- package/nodes/locales/fr/knxUltimateAI.html +6 -6
- package/nodes/locales/fr/knxUltimateAI.json +1 -3
- package/nodes/locales/fr/knxUltimateMatterBridge.html +3 -1
- package/nodes/locales/fr/knxUltimateMatterBridge.json +5 -1
- package/nodes/locales/it/knxUltimateAI.html +6 -6
- package/nodes/locales/it/knxUltimateAI.json +1 -3
- package/nodes/locales/it/knxUltimateMatterBridge.html +3 -1
- package/nodes/locales/it/knxUltimateMatterBridge.json +5 -1
- package/nodes/locales/zh-CN/knxUltimateAI.html +6 -6
- package/nodes/locales/zh-CN/knxUltimateAI.json +1 -3
- package/nodes/locales/zh-CN/knxUltimateMatterBridge.html +3 -1
- package/nodes/locales/zh-CN/knxUltimateMatterBridge.json +5 -1
- package/nodes/utils/knxAiChatContext.js +277 -0
- package/nodes/utils/knxAiHomeMemory.js +2 -2
- package/nodes/utils/matterBridgeDeviceFactory.mjs +130 -65
- package/package.json +1 -1
- package/resources/KNXAIChatAdapterMappings.js +89 -0
|
@@ -27,7 +27,9 @@ Cambiare il tipo dispositivo dopo l'abbinamento del bridge modifica la struttura
|
|
|
27
27
|
| Luce bianco dinamico | + GA comando/stato temperatura colore in Kelvin (DPT 7.600) |
|
|
28
28
|
| Tapparella / Tenda | Su/Giù (DPT 1.008), Stop (DPT 1.017), posizione % comando/stato (DPT 5.001), inversione posizione opzionale |
|
|
29
29
|
| Termostato (riscaldamento e/o raffrescamento) | GA temperatura attuale, GA comando/stato setpoint (DPT 9.001). Aggiungendo anche il GA comando/stato setpoint raffrescamento si espone anche la modalità Cool (doppio setpoint) |
|
|
30
|
+
| Condizionatore ambiente | Un unico dispositivo Matter che combina GA comando/stato On/Off (DPT 1.001), temperatura attuale e setpoint caldo/freddo (DPT 9.001), e velocità ventola % (DPT 5.001) |
|
|
30
31
|
| Ventola / VMC | GA comando/stato velocità % (DPT 5.001) |
|
|
32
|
+
| Serratura porta | GA comando blocco/sblocco e GA stato bloccato/sbloccato (DPT 1.001; `true` = bloccata) |
|
|
31
33
|
| Sensori (temperatura, umidità, luminosità, presenza, contatto) | Un GA di stato ciascuno |
|
|
32
34
|
| Rilevatore fumo/CO | GA stato allarme fumo + GA stato allarme CO opzionale (DPT 1.005): notifiche critiche sul telefono |
|
|
33
35
|
| Rilevatore allagamento | GA stato allagamento (DPT 1.005) |
|
|
@@ -45,7 +47,7 @@ Queste opzioni sono nascoste finché non servono al tipo selezionato. I disposit
|
|
|
45
47
|
|
|
46
48
|
Il selettore dei PIN si trova fuori dalle TAB dell'editor. Abilitandolo compare subito sotto una sezione contestuale **Input/output del flow**, con esempi copiabili filtrati per il tipo di dispositivo selezionato:
|
|
47
49
|
|
|
48
|
-
- **Input**: aggiorna lo stato Matter dal flow, senza passare dal bus KNX: `msg.payload = { function: "onoff", value: true }` (`function` è una tra `onoff`, `level`, `rgb`, `colortemp`, `position`, `temperature`, `humidity`, `illuminance`, `occupancy`, `contact`, `currenttemp`, `setpoint`, `fanspeed`, `smoke`, `co`, `leak`, `co2`, `rvcstate`, `rvcmode`). Utile per esporre ad Alexa & Co. valori calcolati nel flow (es. un sensore virtuale).
|
|
50
|
+
- **Input**: aggiorna lo stato Matter dal flow, senza passare dal bus KNX: `msg.payload = { function: "onoff", value: true }` (`function` è una tra `onoff`, `level`, `rgb`, `colortemp`, `position`, `temperature`, `humidity`, `illuminance`, `occupancy`, `contact`, `currenttemp`, `setpoint`, `coolingsetpoint`, `fanspeed`, `lock`, `smoke`, `co`, `leak`, `co2`, `rvcstate`, `rvcmode`). Utile per esporre ad Alexa & Co. valori calcolati nel flow (es. un sensore virtuale).
|
|
49
51
|
- **Output**: ogni comando ricevuto da un controller Matter viene inoltrato al flow: `msg.topic` = nome del dispositivo, `msg.payload` = valore, `msg.matter` = il comando grezzo. Un dispositivo senza GA di comando diventa un **dispositivo solo-flow**.
|
|
50
52
|
|
|
51
53
|
## Note
|
|
@@ -74,7 +74,9 @@
|
|
|
74
74
|
"fn_leak_ga": "GA stato allagamento",
|
|
75
75
|
"fn_co2_ga": "GA stato CO2 (ppm, DPT 9.008)",
|
|
76
76
|
"fn_fanspeed_cmd": "GA comando velocità ventola %",
|
|
77
|
-
"fn_fanspeed_status": "GA stato velocità ventola %"
|
|
77
|
+
"fn_fanspeed_status": "GA stato velocità ventola %",
|
|
78
|
+
"fn_lock_cmd": "GA comando blocco/sblocco",
|
|
79
|
+
"fn_lock_status": "GA stato bloccato/sbloccato"
|
|
78
80
|
},
|
|
79
81
|
"types": {
|
|
80
82
|
"onofflight": "Luce On/Off",
|
|
@@ -89,7 +91,9 @@
|
|
|
89
91
|
"contactsensor": "Sensore contatto",
|
|
90
92
|
"windowcovering": "Tapparella / Tenda",
|
|
91
93
|
"thermostat": "Termostato (riscaldamento)",
|
|
94
|
+
"roomairconditioner": "Condizionatore ambiente",
|
|
92
95
|
"fan": "Ventola / VMC",
|
|
96
|
+
"doorlock": "Serratura porta",
|
|
93
97
|
"smokecoalarm": "Rilevatore fumo/CO",
|
|
94
98
|
"waterleakdetector": "Rilevatore allagamento",
|
|
95
99
|
"airqualitysensor": "Sensore qualità aria (CO2)",
|
|
@@ -14,14 +14,14 @@
|
|
|
14
14
|
## 命令(输入)
|
|
15
15
|
发送 `msg.topic`:
|
|
16
16
|
- `summary`(或空):立即输出摘要
|
|
17
|
-
- `reset
|
|
17
|
+
- `reset`:清空内部历史、计数器、已学习的家庭记忆和所有持久聊天上下文;AI 教育保持不变
|
|
18
18
|
- `ask`:向已配置的 LLM 提问
|
|
19
19
|
- `confirm` / `cancel`:无需再次调用 LLM,即可确认或取消待处理的 KNX 命令
|
|
20
|
-
- `clear_chat
|
|
20
|
+
- `clear_chat`:清除当前会话的最近对话、持久指令和待处理命令
|
|
21
21
|
|
|
22
22
|
`ask` 的问题建议放在 `msg.prompt`,也可放在 `msg.payload`(字符串)或常见 Telegram 字段 `msg.payload.content` / `msg.payload.text`。
|
|
23
23
|
|
|
24
|
-
|
|
24
|
+
每个 Ask/聊天会话都会保留最近 8 轮对话和最多 20 条明确的长期指令,并按 `msg.knxAi.sessionId`、`msg.sessionId` 或检测到的 Telegram 聊天 ID 隔离。像“请记住不要在回答中使用 unknown 这个词”这样的请求会成为持久指令。使用相同存储的所有 KNX AI 节点实时共享此上下文,并在 Node-RED 重启后从 `knxultimatestorage/knxai/memory/knxai-chat-context.md` 重新加载。该文件采用原子写入,最多保存 50 个会话且不超过 512 KB。启用 KNX 控制后,将输出 3 连接到聊天发送节点,将输出 4 连接到配置为**通用模式**的 KNX Ultimate 节点。启用确认后,第一条回复会显示 GA、DPT 和 payload,但不会发送写入;同一会话必须在 5 分钟内回复“确认”或“取消”。新请求会替换旧的待处理计划。每条已确认命令都包含 `msg.destination`、`msg.dpt`、`msg.payload` 和 `msg.event = "GroupValue_Write"`。
|
|
25
25
|
对于 DPT 1.xxx 写入,AI 生成的安全等价值 `true`/`false`、`1`/`0` 和 `on`/`off` 会在本地校验和输出前统一转换为真正的布尔值。
|
|
26
26
|
|
|
27
27
|
### 最新 KNX 读取
|
|
@@ -35,12 +35,14 @@
|
|
|
35
35
|
|
|
36
36
|
随附的 **windkh/node-red-contrib-telegrambot** 预设遵循该包的 receiver/sender 消息约定。把 `telegram receiver` 直接连接到 KNX AI,并把输出 3 直接连接到 `telegram sender`。如需内联确认按钮,还要把配置为 `callback_query` 的 `telegram event` 连接到同一个 KNX AI 输入。输入映射会提取 `msg.payload.content`、`msg.payload.chatId` 和 Telegram 语言;输出映射会创建所需的 `msg.payload.chatId`、`type` 和 `content`,并在写入等待确认时从 `msg.knxAi.confirmationRequest` 添加 `options.reply_markup`。Telegram 包仍是独立的可选依赖项。
|
|
37
37
|
|
|
38
|
+
随附的 **RedBot / node-red-contrib-chatbot (Telegram)** 预设遵循 RedBot 的通用消息格式。将 `chatbot-telegram-receive` 直接连接到 KNX AI,并将输出 3 直接连接到 `chatbot-telegram-send`;无需单独的 callback 节点,因为 RedBot 会把内联按钮的 postback 转换成普通入站消息。输入映射读取 `transport`、`chatId`、`type`、`content` 和 Telegram 语言。输出映射保留 RedBot 的 `originalMessage`、`chat`、`api` 和 `client` 跟踪数据,然后发送 `message` payload,或发送带有确认 `postback` 操作的 `inline-buttons` payload。RedBot 仍是独立的可选依赖项。
|
|
39
|
+
|
|
38
40
|
## 主动家庭智能与有限记忆
|
|
39
41
|
**对话与家庭**中的**主动家庭与记忆**子部分以可选方式启用主动通知。节点会根据 ETS 层级、名称、角色和 DPT,使用意大利语、英语、德语、法语、西班牙语和中文术语,为卷帘、窗户、门、照明、温度、气候、占用和报警建立确定性的语义模型。首个主动检测器只监视可靠识别且非命令的卷帘/窗户/门状态。超过配置的打开时间且当前不在静默时段时,输出 3 会发送本地化消息,并设置 `msg.knxAi.type = "proactive_notification"`。节点绝不会主动使用输出 4,也不会自行修改 KNX;用户之后提出的任何操作仍须经过正常的校验与确认。
|
|
40
42
|
|
|
41
43
|
最近一次聊天会话会被记为主人,也可以通过**主要接收者 / 聊天 ID**明确指定。合成的 `msg.inputMessage` 会保留接收者,使 Telegram 适配器能够发送主动通知。冷却时间和每小时最多三条主动通知可防止消息泛滥。
|
|
42
44
|
|
|
43
|
-
|
|
45
|
+
共享的学习参考文件会在启动时从 `<userDir>/knxai/memory/knxai-home-memory.md` 加载,每 15 分钟以原子方式重写,并始终严格限制为 5 MB。最多保留 120 条重要观察、80 条聚合习惯、80 条通知和 300 个 ETS 语义对象,绝不会保存无限的原始报文流。较旧且优先级较低的项目会先被删除。**AI 教育**最多 16,000 个字符,并且始终来自节点配置:AI 可以将其作为权威指导读取,但不能修改或覆盖。如果已填写 AI 教育但 LLM 无法进行评估,候选通知会被抑制,以免违反用户指导。
|
|
44
46
|
|
|
45
47
|
## 实用配置示例
|
|
46
48
|
此示例创建一个简洁的助手:它会报告重要的长时间开启状态,但允许书房卷帘保持开启。
|
|
@@ -52,7 +54,6 @@
|
|
|
52
54
|
| **开启多久后通知** (`proactiveOpenMinutes`) | `120` | 开启两小时后评估是否需要通知。 |
|
|
53
55
|
| **静默时间开始 / 结束** | `23:00` / `07:00` | 夜间不会发送主动消息。 |
|
|
54
56
|
| **重复冷却时间** (`proactiveCooldownMinutes`) | `360` | 同一对象六小时内不会再次通知。 |
|
|
55
|
-
| **家庭记忆文件上限** (`homeMemoryMaxKb`) | `256` | 此节点的 Markdown 参考文件始终小于 256 KB。 |
|
|
56
57
|
|
|
57
58
|
**AI 教育** (`aiEducation`) 示例:
|
|
58
59
|
|
|
@@ -116,7 +117,6 @@ KNX AI 会自动监听 `GroupValue_Write`、`GroupValue_Response` 和 `GroupValu
|
|
|
116
117
|
- **静默时间开始 / 结束**:每天禁止主动消息的时间段。
|
|
117
118
|
- **AI 教育**:仅由用户管理的权威指导,AI 可以读取但永远不能修改。
|
|
118
119
|
- **重复冷却时间(分钟)**:同一对象再次通知前的最短间隔;默认 360 分钟。
|
|
119
|
-
- **家庭记忆文件上限(KB)**:64 到 1,024 KB 的硬限制;默认 256 KB。
|
|
120
120
|
- 如果启用了磁盘归档,**Ask** 默认会查询该归档:若问题里写了明确日期/时间范围就按其查询,否则默认查询最近 24 小时并补上当前 RAM 事件。
|
|
121
121
|
- **包含 Node-RED 项目清单**:在提示词中加入整个 Node-RED 项目的节点清单,不仅包含 KNX 节点,也包含 function/change/inject/template 等在内且带有 KNX 逻辑或组地址的有用节点。
|
|
122
122
|
- 相关的帮助、README 和示例片段始终会自动包含。
|
|
@@ -57,7 +57,6 @@
|
|
|
57
57
|
"proactiveCooldownMinutes": "重复通知冷却时间(分钟)",
|
|
58
58
|
"proactiveQuietStart": "静默时段开始",
|
|
59
59
|
"proactiveQuietEnd": "静默时段结束",
|
|
60
|
-
"homeMemoryMaxKb": "家庭记忆文件最大值(KB)",
|
|
61
60
|
"aiEducation": "AI 教育(由用户管理)",
|
|
62
61
|
"llmIncludeDocsSnippets": "Include documentation snippets (help/README/examples)",
|
|
63
62
|
"llmDocsLanguage": "Docs language"
|
|
@@ -89,8 +88,7 @@
|
|
|
89
88
|
"chatAdapterIntro": "选择预设即可插入输入和输出映射代码。列表从随包提供的聊天适配器文件加载;生成的代码仍可编辑。",
|
|
90
89
|
"chatAdapterCodeHelp": "映射同步运行。返回 msg 以继续,或不返回值以丢弃消息。错误会被捕获并报告,不会停止 Node-RED。",
|
|
91
90
|
"homeIntelligenceIntro": "节点会创建多语言 ETS 语义模型,并在可靠识别的卷帘、窗户或门长时间保持打开时主动通知聊天。它绝不会自行发送 KNX 命令。",
|
|
92
|
-
"aiEducationHelp": "只有用户可以编辑此部分。AI 会把它作为权威指导读取,但学习到的记忆绝不能覆盖它。最多 16,000 个字符。"
|
|
93
|
-
"homeMemoryLimitHelp": "Markdown 记忆每 15 分钟以原子方式重写,并始终限制在 64 到 1,024 KB。达到限制时会先删除较旧的观察,再删除习惯和语义对象。"
|
|
91
|
+
"aiEducationHelp": "只有用户可以编辑此部分。AI 会把它作为权威指导读取,但学习到的记忆绝不能覆盖它。最多 16,000 个字符。"
|
|
94
92
|
},
|
|
95
93
|
"placeholder": {
|
|
96
94
|
"llmBaseUrl": "https://api.openai.com/v1/chat/completions (or your compatible endpoint)",
|
|
@@ -27,7 +27,9 @@
|
|
|
27
27
|
| 色温灯 | + 色温命令/状态 GA,开尔文 (DPT 7.600) |
|
|
28
28
|
| 窗帘/卷帘 | 上/下 (DPT 1.008)、停止 (DPT 1.017)、位置 % 命令/状态 (DPT 5.001)、可选位置反转 |
|
|
29
29
|
| 温控器(采暖和/或制冷) | 当前温度 GA、设定点命令/状态 GA (DPT 9.001)。如再配置制冷设定点命令/状态 GA,则同时提供 Cool 模式(双设定点) |
|
|
30
|
+
| 房间空调 | 一个 Matter 设备组合开/关命令/状态 GA (DPT 1.001)、当前温度与采暖/制冷设定点 (DPT 9.001),以及风扇速度 % 命令/状态 GA (DPT 5.001) |
|
|
30
31
|
| 风扇/新风 | 速度 % 命令/状态 GA (DPT 5.001) |
|
|
32
|
+
| 门锁 | 锁定/解锁命令 GA 和锁定/解锁状态 GA (DPT 1.001;`true` = 已锁定) |
|
|
31
33
|
| 传感器(温度、湿度、光照、人体、门磁) | 各一个状态 GA |
|
|
32
34
|
| 烟雾/CO 报警器 | 烟雾报警状态 GA + 可选 CO 报警状态 GA (DPT 1.005):手机上的紧急通知 |
|
|
33
35
|
| 漏水探测器 | 漏水状态 GA (DPT 1.005) |
|
|
@@ -45,7 +47,7 @@
|
|
|
45
47
|
|
|
46
48
|
引脚选择器位于编辑器选项卡之外。启用后,其下方会显示与所选设备类型匹配的 **Flow 输入/输出** 区域,并提供可复制示例:
|
|
47
49
|
|
|
48
|
-
- **输入**:从 Flow 更新 Matter 状态,无需经过 KNX 总线:`msg.payload = { function: "onoff", value: true }`(`function` 为 `onoff`、`level`、`rgb`、`colortemp`、`position`、`temperature`、`humidity`、`illuminance`、`occupancy`、`contact`、`currenttemp`、`setpoint`、`fanspeed`、`smoke`、`co`、`leak`、`co2`、`rvcstate`、`rvcmode` 之一)。适合把 Flow 中计算的值(如虚拟传感器)公开给 Alexa 等。
|
|
50
|
+
- **输入**:从 Flow 更新 Matter 状态,无需经过 KNX 总线:`msg.payload = { function: "onoff", value: true }`(`function` 为 `onoff`、`level`、`rgb`、`colortemp`、`position`、`temperature`、`humidity`、`illuminance`、`occupancy`、`contact`、`currenttemp`、`setpoint`、`coolingsetpoint`、`fanspeed`、`lock`、`smoke`、`co`、`leak`、`co2`、`rvcstate`、`rvcmode` 之一)。适合把 Flow 中计算的值(如虚拟传感器)公开给 Alexa 等。
|
|
49
51
|
- **输出**:从 Matter 控制器收到的每个命令都转发到 Flow:`msg.topic` = 设备名称,`msg.payload` = 值,`msg.matter` = 原始命令。没有命令 GA 的设备成为**纯 Flow 设备**。
|
|
50
52
|
|
|
51
53
|
## 说明
|
|
@@ -74,7 +74,9 @@
|
|
|
74
74
|
"fn_leak_ga": "漏水状态 GA",
|
|
75
75
|
"fn_co2_ga": "CO2 状态 GA(ppm,DPT 9.008)",
|
|
76
76
|
"fn_fanspeed_cmd": "风扇速度 % 命令 GA",
|
|
77
|
-
"fn_fanspeed_status": "风扇速度 % 状态 GA"
|
|
77
|
+
"fn_fanspeed_status": "风扇速度 % 状态 GA",
|
|
78
|
+
"fn_lock_cmd": "锁定/解锁 命令 GA",
|
|
79
|
+
"fn_lock_status": "锁定/解锁 状态 GA"
|
|
78
80
|
},
|
|
79
81
|
"types": {
|
|
80
82
|
"onofflight": "开关灯",
|
|
@@ -89,7 +91,9 @@
|
|
|
89
91
|
"contactsensor": "门磁传感器",
|
|
90
92
|
"windowcovering": "窗帘 / 卷帘",
|
|
91
93
|
"thermostat": "温控器(采暖)",
|
|
94
|
+
"roomairconditioner": "房间空调",
|
|
92
95
|
"fan": "风扇 / 新风",
|
|
96
|
+
"doorlock": "门锁",
|
|
93
97
|
"smokecoalarm": "烟雾/CO 报警器",
|
|
94
98
|
"waterleakdetector": "漏水探测器",
|
|
95
99
|
"airqualitysensor": "空气质量传感器 (CO2)",
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
const CHAT_CONTEXT_VERSION = 1
|
|
2
|
+
const CHAT_CONTEXT_MAX_BYTES = 512 * 1024
|
|
3
|
+
const CHAT_CONTEXT_MAX_SESSIONS = 50
|
|
4
|
+
const CHAT_CONTEXT_MAX_TURNS_PER_SESSION = 8
|
|
5
|
+
const CHAT_CONTEXT_MAX_INSTRUCTIONS_PER_SESSION = 20
|
|
6
|
+
const CHAT_CONTEXT_MAX_QUESTION_CHARS = 4000
|
|
7
|
+
const CHAT_CONTEXT_MAX_REPLY_CHARS = 8000
|
|
8
|
+
const CHAT_CONTEXT_MAX_INSTRUCTION_CHARS = 2000
|
|
9
|
+
|
|
10
|
+
const clampText = (value, maxChars) => String(value === undefined || value === null ? '' : value)
|
|
11
|
+
.trim()
|
|
12
|
+
.slice(0, Math.max(0, Number(maxChars) || 0))
|
|
13
|
+
|
|
14
|
+
const normalizeSessionId = value => clampText(value || 'default', 160) || 'default'
|
|
15
|
+
|
|
16
|
+
const createEmptyKnxAiChatContext = () => ({
|
|
17
|
+
version: CHAT_CONTEXT_VERSION,
|
|
18
|
+
createdAt: new Date().toISOString(),
|
|
19
|
+
updatedAt: new Date().toISOString(),
|
|
20
|
+
sessions: []
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
const normalizeTurn = (turn) => {
|
|
24
|
+
if (!turn || typeof turn !== 'object' || Array.isArray(turn)) return null
|
|
25
|
+
const question = clampText(turn.question, CHAT_CONTEXT_MAX_QUESTION_CHARS)
|
|
26
|
+
const reply = clampText(turn.reply, CHAT_CONTEXT_MAX_REPLY_CHARS)
|
|
27
|
+
if (!question && !reply) return null
|
|
28
|
+
return {
|
|
29
|
+
at: clampText(turn.at || new Date().toISOString(), 64),
|
|
30
|
+
question,
|
|
31
|
+
reply
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const normalizeInstruction = (instruction) => {
|
|
36
|
+
if (!instruction || typeof instruction !== 'object' || Array.isArray(instruction)) return null
|
|
37
|
+
const text = clampText(instruction.text, CHAT_CONTEXT_MAX_INSTRUCTION_CHARS)
|
|
38
|
+
if (!text) return null
|
|
39
|
+
return {
|
|
40
|
+
at: clampText(instruction.at || new Date().toISOString(), 64),
|
|
41
|
+
text
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const normalizeSession = (session) => {
|
|
46
|
+
if (!session || typeof session !== 'object' || Array.isArray(session)) return null
|
|
47
|
+
const id = normalizeSessionId(session.id)
|
|
48
|
+
const turns = (Array.isArray(session.turns) ? session.turns : [])
|
|
49
|
+
.map(normalizeTurn)
|
|
50
|
+
.filter(Boolean)
|
|
51
|
+
.slice(-CHAT_CONTEXT_MAX_TURNS_PER_SESSION)
|
|
52
|
+
const instructions = (Array.isArray(session.instructions) ? session.instructions : [])
|
|
53
|
+
.map(normalizeInstruction)
|
|
54
|
+
.filter(Boolean)
|
|
55
|
+
.slice(-CHAT_CONTEXT_MAX_INSTRUCTIONS_PER_SESSION)
|
|
56
|
+
return {
|
|
57
|
+
id,
|
|
58
|
+
updatedAt: clampText(session.updatedAt || new Date().toISOString(), 64),
|
|
59
|
+
turns,
|
|
60
|
+
instructions
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const normalizeKnxAiChatContext = (value = {}) => {
|
|
65
|
+
const source = value && typeof value === 'object' && !Array.isArray(value) ? value : {}
|
|
66
|
+
const byId = new Map()
|
|
67
|
+
;(Array.isArray(source.sessions) ? source.sessions : []).forEach((item) => {
|
|
68
|
+
const session = normalizeSession(item)
|
|
69
|
+
if (!session) return
|
|
70
|
+
byId.delete(session.id)
|
|
71
|
+
byId.set(session.id, session)
|
|
72
|
+
})
|
|
73
|
+
return {
|
|
74
|
+
version: CHAT_CONTEXT_VERSION,
|
|
75
|
+
createdAt: clampText(source.createdAt || new Date().toISOString(), 64),
|
|
76
|
+
updatedAt: clampText(source.updatedAt || new Date().toISOString(), 64),
|
|
77
|
+
sessions: Array.from(byId.values()).slice(-CHAT_CONTEXT_MAX_SESSIONS)
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const findSession = (context, sessionId) => {
|
|
82
|
+
const id = normalizeSessionId(sessionId)
|
|
83
|
+
return normalizeKnxAiChatContext(context).sessions.find(session => session.id === id) || null
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const touchSession = (context, sessionId) => {
|
|
87
|
+
const target = normalizeKnxAiChatContext(context)
|
|
88
|
+
const id = normalizeSessionId(sessionId)
|
|
89
|
+
const existing = target.sessions.find(session => session.id === id)
|
|
90
|
+
const session = existing || { id, updatedAt: new Date().toISOString(), turns: [], instructions: [] }
|
|
91
|
+
target.sessions = target.sessions.filter(item => item.id !== id)
|
|
92
|
+
target.sessions.push(session)
|
|
93
|
+
target.sessions = target.sessions.slice(-CHAT_CONTEXT_MAX_SESSIONS)
|
|
94
|
+
target.updatedAt = new Date().toISOString()
|
|
95
|
+
session.updatedAt = target.updatedAt
|
|
96
|
+
return { target, session }
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const explicitInstructionPatterns = [
|
|
100
|
+
/^\s*(?:please[\s,]+)?remember\b/i,
|
|
101
|
+
/^\s*from now on\b/i,
|
|
102
|
+
/^\s*(?:please[\s,]+)?(?:always|never)\b/i,
|
|
103
|
+
/^\s*(?:per favore[\s,]+)?(?:ricordati|ricorda|ricordarsi|memorizza)\b/i,
|
|
104
|
+
/^\s*(?:d['’]ora in poi|da ora in poi)\b/i,
|
|
105
|
+
/^\s*(?:usa sempre|non usare mai|evita sempre)\b/i,
|
|
106
|
+
/^\s*(?:bitte[\s,]+)?(?:merk dir|erinnere dich)\b/i,
|
|
107
|
+
/^\s*(?:ab jetzt|von jetzt an)\b/i,
|
|
108
|
+
/^\s*(?:s['’]il te pla[iî]t[\s,]+)?(?:souviens-toi|rappelle-toi|m[eé]morise)\b/i,
|
|
109
|
+
/^\s*(?:dor[eé]navant|[àa] partir de maintenant)\b/i,
|
|
110
|
+
/^\s*(?:por favor[\s,]+)?(?:recuerda|memoriza)\b/i,
|
|
111
|
+
/^\s*(?:a partir de ahora|de ahora en adelante)\b/i,
|
|
112
|
+
/^\s*(?:请)?记住/,
|
|
113
|
+
/^\s*从现在开始/
|
|
114
|
+
]
|
|
115
|
+
|
|
116
|
+
const extractExplicitKnxAiChatInstruction = (question) => {
|
|
117
|
+
const text = clampText(question, CHAT_CONTEXT_MAX_INSTRUCTION_CHARS)
|
|
118
|
+
if (!text) return ''
|
|
119
|
+
return explicitInstructionPatterns.some(pattern => pattern.test(text)) ? text : ''
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const addKnxAiChatTurn = (context, { sessionId, question, reply, at } = {}) => {
|
|
123
|
+
const { target, session } = touchSession(context, sessionId)
|
|
124
|
+
const turn = normalizeTurn({ at, question, reply })
|
|
125
|
+
if (turn) session.turns.push(turn)
|
|
126
|
+
session.turns = session.turns.slice(-CHAT_CONTEXT_MAX_TURNS_PER_SESSION)
|
|
127
|
+
|
|
128
|
+
const explicitInstruction = extractExplicitKnxAiChatInstruction(question)
|
|
129
|
+
if (explicitInstruction) {
|
|
130
|
+
const normalized = explicitInstruction.toLocaleLowerCase()
|
|
131
|
+
session.instructions = session.instructions.filter(item => item.text.toLocaleLowerCase() !== normalized)
|
|
132
|
+
session.instructions.push({
|
|
133
|
+
at: clampText(at || new Date().toISOString(), 64),
|
|
134
|
+
text: explicitInstruction
|
|
135
|
+
})
|
|
136
|
+
session.instructions = session.instructions.slice(-CHAT_CONTEXT_MAX_INSTRUCTIONS_PER_SESSION)
|
|
137
|
+
}
|
|
138
|
+
return target
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const clearKnxAiChatSession = (context, sessionId) => {
|
|
142
|
+
const target = normalizeKnxAiChatContext(context)
|
|
143
|
+
const id = normalizeSessionId(sessionId)
|
|
144
|
+
target.sessions = target.sessions.filter(session => session.id !== id)
|
|
145
|
+
target.updatedAt = new Date().toISOString()
|
|
146
|
+
return target
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const getKnxAiChatSession = (context, sessionId) => {
|
|
150
|
+
const session = findSession(context, sessionId)
|
|
151
|
+
return session || {
|
|
152
|
+
id: normalizeSessionId(sessionId),
|
|
153
|
+
updatedAt: '',
|
|
154
|
+
turns: [],
|
|
155
|
+
instructions: []
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const buildKnxAiChatPromptContext = ({ context, sessionId, maxChars = 16000 } = {}) => {
|
|
160
|
+
const session = getKnxAiChatSession(context, sessionId)
|
|
161
|
+
if (!session.instructions.length && !session.turns.length) return ''
|
|
162
|
+
const lines = []
|
|
163
|
+
if (session.instructions.length) {
|
|
164
|
+
lines.push('PERSISTENT CHAT INSTRUCTIONS AND PREFERENCES (explicitly supplied by this user; follow them unless they conflict with safety or the KNX contract; newer entries override older conflicting entries):')
|
|
165
|
+
session.instructions.forEach(item => lines.push(`- ${item.text.replace(/\r?\n/g, ' ')}`))
|
|
166
|
+
}
|
|
167
|
+
if (session.turns.length) {
|
|
168
|
+
if (lines.length) lines.push('')
|
|
169
|
+
lines.push('RECENT CONVERSATION:')
|
|
170
|
+
session.turns.forEach((turn) => {
|
|
171
|
+
lines.push(`User: ${turn.question}`)
|
|
172
|
+
lines.push(`Assistant: ${turn.reply}`)
|
|
173
|
+
})
|
|
174
|
+
}
|
|
175
|
+
return lines.join('\n').slice(0, Math.max(1000, Number(maxChars) || 16000))
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const escapeMarkdownText = value => clampText(value, CHAT_CONTEXT_MAX_REPLY_CHARS)
|
|
179
|
+
.replace(/\r?\n/g, ' ')
|
|
180
|
+
.replace(/</g, '<')
|
|
181
|
+
.replace(/>/g, '>')
|
|
182
|
+
|
|
183
|
+
const renderKnxAiChatContext = (context) => {
|
|
184
|
+
const target = normalizeKnxAiChatContext(context)
|
|
185
|
+
target.updatedAt = new Date().toISOString()
|
|
186
|
+
const metadata = Buffer.from(JSON.stringify(target), 'utf8').toString('base64')
|
|
187
|
+
const lines = [
|
|
188
|
+
'<!-- KNX_AI_CHAT_CONTEXT_V1_BASE64',
|
|
189
|
+
metadata,
|
|
190
|
+
'KNX_AI_CHAT_CONTEXT_END -->',
|
|
191
|
+
'',
|
|
192
|
+
'# KNX AI Chat Context',
|
|
193
|
+
'',
|
|
194
|
+
`Updated: ${target.updatedAt}`,
|
|
195
|
+
'',
|
|
196
|
+
'This bounded file stores recent chat turns and explicit long-term instructions separately for each chat session.',
|
|
197
|
+
''
|
|
198
|
+
]
|
|
199
|
+
target.sessions.forEach((session) => {
|
|
200
|
+
lines.push(`## Session ${escapeMarkdownText(session.id)}`, '')
|
|
201
|
+
lines.push('### Persistent instructions', '')
|
|
202
|
+
if (!session.instructions.length) lines.push('_None._')
|
|
203
|
+
session.instructions.forEach(item => lines.push(`- ${escapeMarkdownText(item.at)} — ${escapeMarkdownText(item.text)}`))
|
|
204
|
+
lines.push('', '### Recent turns', '')
|
|
205
|
+
if (!session.turns.length) lines.push('_None._')
|
|
206
|
+
session.turns.forEach((turn) => {
|
|
207
|
+
lines.push(`- ${escapeMarkdownText(turn.at)} — **User:** ${escapeMarkdownText(turn.question)}`)
|
|
208
|
+
lines.push(` **Assistant:** ${escapeMarkdownText(turn.reply)}`)
|
|
209
|
+
})
|
|
210
|
+
lines.push('')
|
|
211
|
+
})
|
|
212
|
+
return { markdown: lines.join('\n'), context: target }
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const buildKnxAiChatContextMarkdown = ({ context, maxBytes = CHAT_CONTEXT_MAX_BYTES } = {}) => {
|
|
216
|
+
const targetBytes = Math.max(64 * 1024, Math.min(CHAT_CONTEXT_MAX_BYTES, Number(maxBytes) || CHAT_CONTEXT_MAX_BYTES))
|
|
217
|
+
let bounded = normalizeKnxAiChatContext(context)
|
|
218
|
+
let rendered = renderKnxAiChatContext(bounded)
|
|
219
|
+
while (Buffer.byteLength(rendered.markdown, 'utf8') > targetBytes) {
|
|
220
|
+
const sessionWithTurns = bounded.sessions.find(session => session.turns.length > 0)
|
|
221
|
+
if (sessionWithTurns) sessionWithTurns.turns.shift()
|
|
222
|
+
else if (bounded.sessions.length > 1) bounded.sessions.shift()
|
|
223
|
+
else {
|
|
224
|
+
const sessionWithInstructions = bounded.sessions.find(session => session.instructions.length > 0)
|
|
225
|
+
if (!sessionWithInstructions) break
|
|
226
|
+
sessionWithInstructions.instructions.shift()
|
|
227
|
+
}
|
|
228
|
+
rendered = renderKnxAiChatContext(bounded)
|
|
229
|
+
bounded = rendered.context
|
|
230
|
+
}
|
|
231
|
+
return {
|
|
232
|
+
markdown: rendered.markdown,
|
|
233
|
+
context: rendered.context,
|
|
234
|
+
bytes: Buffer.byteLength(rendered.markdown, 'utf8'),
|
|
235
|
+
maxBytes: targetBytes
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const parseKnxAiChatContextMarkdown = (markdown) => {
|
|
240
|
+
const text = String(markdown || '')
|
|
241
|
+
const match = text.match(/<!-- KNX_AI_CHAT_CONTEXT_V1_BASE64\s*\n([A-Za-z0-9+/=\r\n]+?)\nKNX_AI_CHAT_CONTEXT_END -->/)
|
|
242
|
+
if (!match) return createEmptyKnxAiChatContext()
|
|
243
|
+
try {
|
|
244
|
+
const metadata = Buffer.from(match[1].replace(/\s+/g, ''), 'base64').toString('utf8')
|
|
245
|
+
return normalizeKnxAiChatContext(JSON.parse(metadata))
|
|
246
|
+
} catch (error) {
|
|
247
|
+
return createEmptyKnxAiChatContext()
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const conversationMapFromKnxAiChatContext = (context) => {
|
|
252
|
+
const result = new Map()
|
|
253
|
+
normalizeKnxAiChatContext(context).sessions.forEach((session) => {
|
|
254
|
+
result.set(session.id, session.turns.map(turn => ({
|
|
255
|
+
question: turn.question,
|
|
256
|
+
reply: turn.reply
|
|
257
|
+
})))
|
|
258
|
+
})
|
|
259
|
+
return result
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
module.exports = {
|
|
263
|
+
CHAT_CONTEXT_MAX_BYTES,
|
|
264
|
+
CHAT_CONTEXT_MAX_INSTRUCTIONS_PER_SESSION,
|
|
265
|
+
CHAT_CONTEXT_MAX_SESSIONS,
|
|
266
|
+
CHAT_CONTEXT_MAX_TURNS_PER_SESSION,
|
|
267
|
+
addKnxAiChatTurn,
|
|
268
|
+
buildKnxAiChatContextMarkdown,
|
|
269
|
+
buildKnxAiChatPromptContext,
|
|
270
|
+
clearKnxAiChatSession,
|
|
271
|
+
conversationMapFromKnxAiChatContext,
|
|
272
|
+
createEmptyKnxAiChatContext,
|
|
273
|
+
extractExplicitKnxAiChatInstruction,
|
|
274
|
+
getKnxAiChatSession,
|
|
275
|
+
normalizeKnxAiChatContext,
|
|
276
|
+
parseKnxAiChatContextMarkdown
|
|
277
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
const HOME_MEMORY_VERSION = 1
|
|
2
2
|
const HOME_MEMORY_MIN_KB = 64
|
|
3
|
-
const HOME_MEMORY_MAX_KB =
|
|
4
|
-
const HOME_MEMORY_DEFAULT_KB =
|
|
3
|
+
const HOME_MEMORY_MAX_KB = 5120
|
|
4
|
+
const HOME_MEMORY_DEFAULT_KB = 5120
|
|
5
5
|
const HOME_MEMORY_MAX_EDUCATION_CHARS = 16000
|
|
6
6
|
const HOME_MEMORY_MAX_OBSERVATIONS = 120
|
|
7
7
|
const HOME_MEMORY_MAX_HABITS = 80
|