dsh-qqchat 0.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 (89) hide show
  1. package/LICENSE +21 -0
  2. package/README.en.md +276 -0
  3. package/README.md +211 -0
  4. package/client-src/plugin.cts +15 -0
  5. package/client-src/settings.cts +216 -0
  6. package/client-src/shared.cts +104 -0
  7. package/client-src/workspace.cts +164 -0
  8. package/cordis.patch.yml +7 -0
  9. package/docs/ARCHITECTURE.en.md +346 -0
  10. package/docs/ARCHITECTURE.md +411 -0
  11. package/docs/DEVELOPMENT.md +69 -0
  12. package/docs/MEDIA_AND_QUOTES.md +644 -0
  13. package/docs/MEMORY.md +99 -0
  14. package/docs/MEMORY_CONTEXT.md +254 -0
  15. package/docs/QUOTES.md +355 -0
  16. package/docs/README.md +22 -0
  17. package/docs/SECURITY.md +62 -0
  18. package/docs/STREAMING.md +347 -0
  19. package/docs/assets//346/211/253/347/240/201/351/223/276/346/216/245.jpg +0 -0
  20. package/docs/assets//347/276/244/350/201/212/347/225/214/351/235/242.jpg +0 -0
  21. package/docs/assets//350/256/260/345/277/206/347/263/273/347/273/237.jpg +0 -0
  22. package/docs/assets//350/256/276/347/275/256/347/225/214/351/235/242.jpg +0 -0
  23. package/docs/devlog.md +24 -0
  24. package/lib/client.js +433 -0
  25. package/lib/commands/dispatch.d.ts +16 -0
  26. package/lib/commands/dispatch.js +48 -0
  27. package/lib/config.d.ts +3 -0
  28. package/lib/config.js +63 -0
  29. package/lib/gateway/api.d.ts +30 -0
  30. package/lib/gateway/api.js +202 -0
  31. package/lib/gateway/auth.d.ts +35 -0
  32. package/lib/gateway/auth.js +80 -0
  33. package/lib/gateway/crypto.d.ts +2 -0
  34. package/lib/gateway/crypto.js +17 -0
  35. package/lib/gateway/gateway.d.ts +32 -0
  36. package/lib/gateway/gateway.js +219 -0
  37. package/lib/gateway/normalize.d.ts +6 -0
  38. package/lib/gateway/normalize.js +248 -0
  39. package/lib/index.d.ts +14 -0
  40. package/lib/index.js +35 -0
  41. package/lib/media/image-tool.d.ts +4 -0
  42. package/lib/media/image-tool.js +62 -0
  43. package/lib/media/media-tools.d.ts +3 -0
  44. package/lib/media/media-tools.js +68 -0
  45. package/lib/media/store.d.ts +14 -0
  46. package/lib/media/store.js +176 -0
  47. package/lib/session/agent-bridge.d.ts +56 -0
  48. package/lib/session/agent-bridge.js +613 -0
  49. package/lib/session/memory-snapshot.d.ts +23 -0
  50. package/lib/session/memory-snapshot.js +27 -0
  51. package/lib/session/runtime.d.ts +41 -0
  52. package/lib/session/runtime.js +395 -0
  53. package/lib/shared/augmentations.d.ts +40 -0
  54. package/lib/shared/augmentations.js +1 -0
  55. package/lib/shared/logging.d.ts +14 -0
  56. package/lib/shared/logging.js +41 -0
  57. package/lib/storage/db.d.ts +82 -0
  58. package/lib/storage/db.js +669 -0
  59. package/lib/storage/memory.d.ts +50 -0
  60. package/lib/storage/memory.js +566 -0
  61. package/lib/transport/rpc.d.ts +18 -0
  62. package/lib/transport/rpc.js +318 -0
  63. package/lib/types.d.ts +416 -0
  64. package/lib/types.js +1 -0
  65. package/package.json +115 -0
  66. package/scripts/wrap-client.mjs +20 -0
  67. package/src/commands/dispatch.ts +75 -0
  68. package/src/config.ts +70 -0
  69. package/src/gateway/api.ts +260 -0
  70. package/src/gateway/auth.ts +99 -0
  71. package/src/gateway/crypto.ts +16 -0
  72. package/src/gateway/gateway.ts +207 -0
  73. package/src/gateway/normalize.ts +236 -0
  74. package/src/index.ts +46 -0
  75. package/src/media/image-tool.ts +62 -0
  76. package/src/media/media-tools.ts +66 -0
  77. package/src/media/store.ts +157 -0
  78. package/src/session/agent-bridge.ts +624 -0
  79. package/src/session/memory-snapshot.ts +41 -0
  80. package/src/session/runtime.ts +368 -0
  81. package/src/shared/augmentations.ts +45 -0
  82. package/src/shared/logging.ts +36 -0
  83. package/src/storage/db.ts +760 -0
  84. package/src/storage/memory.ts +581 -0
  85. package/src/transport/rpc.ts +316 -0
  86. package/src/types.ts +456 -0
  87. package/tsconfig.build.json +12 -0
  88. package/tsconfig.client.json +14 -0
  89. package/tsconfig.json +23 -0
package/docs/MEMORY.md ADDED
@@ -0,0 +1,99 @@
1
+ # 记忆系统
2
+
3
+ ## 易读概述
4
+
5
+ QQChat 的 SQLite 是 QQ 世界事实来源,DSH Session 只保存 Agent 实际经历过的 turn 和展示事件。记忆反思从 SQLite 读取消息,再写回 group/member scope。
6
+
7
+ 身份主键始终是 stable sender ID;昵称、群内称呼和历史昵称是展示或画像信息,不能替代身份主键。
8
+
9
+ ## Scope
10
+
11
+ ### Group scope
12
+
13
+ 群记忆按 `group` scope 保存:
14
+
15
+ - `profile`:群体画像、角色和结构
16
+ - `summary`:当前阶段的紧凑摘要
17
+ - `daily`:按日期组织的近期重要记录
18
+ - `memory`:长期决定、关系、项目、约定和反复话题
19
+
20
+ ### Member scope
21
+
22
+ 成员和私聊用户按 `member` scope 保存:
23
+
24
+ - `profile`:typed entries,包含 `type/text/ts`
25
+ - `pattern`:行为模式
26
+ - `summary`:成员摘要
27
+ - `daily`:该成员或私聊的按日期近期记录
28
+ - `memory`:成员长期记忆
29
+
30
+ 同一个 Bot 下,stable sender 可以跨群复用 member scope;群内关系和群级事件只能进入对应 group scope。
31
+
32
+ ## daily 格式
33
+
34
+ 同一天共用一个标题:
35
+
36
+ ```markdown
37
+ ## 2026-08-20
38
+ - 发生了第一件事
39
+ - 发生了第二件事
40
+ ```
41
+
42
+ 新消息追加到对应日期标题下,不应为每条消息重复创建日期标题。数据库启动时会把旧的逐条日期格式规范化为这种结构。
43
+
44
+ ## 反思触发
45
+
46
+ 默认配置:
47
+
48
+ - 空闲约 120 秒触发一次反思
49
+ - 未反思消息达到 20 条时立即触发
50
+ - 单次最多取 80 条未反思消息
51
+
52
+ 群聊和私聊分别维护反思游标和模型路由。私聊首次没有成功 Agent 路由时不能凭空创建反思请求;需要先完成一次正常 Agent 回复。
53
+
54
+ ## 压缩策略
55
+
56
+ 记忆如何进入 DSH Session 的生命周期、TTL 和 Session compact 处理,见[记忆系统上下文注入方案](./MEMORY_CONTEXT.md)。
57
+
58
+ 压缩规则与 Gugu 的 daily → memory 规则保持一致:
59
+
60
+ | Scope | 触发条数 | 保留最近 | 压缩到 |
61
+ | --- | ---: | ---: | --- |
62
+ | member | 100 | 50 | `memory` |
63
+ | group | 1000 | 500 | `memory` |
64
+
65
+ 压缩模型最多使用 `memoryCompressionMaxTokens=15000`。压缩输入包含已有 memory、相关 profile/pattern 和待压缩 daily。
66
+
67
+ 安全条件:
68
+
69
+ - 压缩输出必须非空。
70
+ - 输出必须保留待压缩记录涉及的日期。
71
+ - 模型失败、JSON 无效或校验失败时,保留原 daily,不截断、不删除。
72
+ - 只有 memory 成功写入且校验通过后,才删除已压缩的旧 daily,保留最近记录。
73
+
74
+ ## Agent context
75
+
76
+ 记忆上下文通过 DSH 官方 runtime-context snapshot 机制注入:快照使用
77
+ `@deepseek-ai/dsh-system-prompt` 来源标记。注入策略遵循“新 Session、TTL
78
+ 过期、Session compact 后刷新”的生命周期规则,连续对话只刷新对应群或当前
79
+ 成员的 TTL,不重复注入完整记忆。群持续活跃不会替潜水成员刷新成员 TTL。
80
+ 具体状态、hash、版本和失败处理见[记忆系统上下文注入方案](./MEMORY_CONTEXT.md)。
81
+ 当前 QQ 消息仍作为正常 user prompt 进入 turn。
82
+
83
+ 群聊 Agent turn 注入:
84
+
85
+ ```text
86
+ group profile/summary/memory/daily
87
+ + 当前 sender profile/pattern/summary
88
+ + stable sender ID
89
+ + group ID
90
+ ```
91
+
92
+ 私聊 Agent turn 注入对应 member scope:
93
+
94
+ ```text
95
+ member profile/pattern/summary/daily/memory
96
+ + stable sender ID
97
+ ```
98
+
99
+ 当前消息的 `senderName` 只用于展示;@消息会在写入 DSH 和记忆上下文前,优先解析为用户名。
@@ -0,0 +1,254 @@
1
+ # 记忆系统上下文注入方案
2
+
3
+ ## 1. 目标
4
+
5
+ QQChat 的记忆属于 DSH Session 上下文的一部分,不脱离 Session 单独维护一套模型上下文。
6
+
7
+ 上下文注入需要同时满足:
8
+
9
+ - Session 恢复后仍能获得正确的群、成员或私聊记忆;
10
+ - 连续对话时不重复写入完整记忆;
11
+ - Session 压缩后重新建立完整、可追溯的记忆基线;
12
+ - 记忆内容发生变化时能够在后续回合生效;
13
+ - 记忆注入不会无限增加 Session 的重复内容。
14
+
15
+ 本方案采用“完整快照 + 生命周期刷新 + 压缩后重建”的方式,不把记忆作为脱离 Session 的临时请求上下文。
16
+
17
+ ## 2. 记忆快照的范围
18
+
19
+ 快照按照 Session 的聊天范围生成:
20
+
21
+ ### 群聊 Session
22
+
23
+ - 群稳定 ID、群名;
24
+ - 群 profile、summary、daily、memory;
25
+ - 当前发言成员的稳定 ID、显示名;
26
+ - 当前成员 profile、pattern、summary、daily、memory;
27
+ - 必要的身份和归属约束。
28
+
29
+ ### 私聊 Session
30
+
31
+ - 用户稳定 ID、显示名;
32
+ - 成员 profile、pattern、summary、daily、memory。
33
+
34
+ 群聊快照只注入当前发言成员的成员记忆,不能把其他群成员的个人记忆混入当前回合。
35
+
36
+ 最近 QQ 消息属于 Session 的真实消息历史,不应在每轮记忆快照中重复复制。若需要补充历史,应由 DSH Session 历史和原生 compact 机制负责。
37
+
38
+ ## 3. 注入时机
39
+
40
+ 记忆快照不是每条消息都重新注入,而是在以下时机注入全量快照。
41
+
42
+ ### 3.1 新 Session 或首次进入
43
+
44
+ Session 没有有效记忆基线时,注入一次完整快照,并记录:
45
+
46
+ ```text
47
+ memoryVersion
48
+ memoryHash
49
+ lastInjectedAt
50
+ lastActiveAt
51
+ ```
52
+
53
+ ### 3.2 TTL 过期
54
+
55
+ 默认 TTL 为 30 分钟。TTL 按记忆作用域分别维护:群级记忆使用群级 TTL,当前发言成员的记忆使用成员级 TTL。连续对话期间,每条消息只刷新对应作用域的活跃时间,不重复注入完整记忆。
56
+
57
+ 当某个作用域距离上次使用或注入超过 TTL,下一条相关消息到达时:
58
+
59
+ 1. 重新读取 SQLite 中的最新记忆;
60
+ 2. 生成新的完整快照;
61
+ 3. 注入到当前 DSH Session;
62
+ 4. 更新对应作用域的 `memoryHash`、`memoryVersion` 和时间戳。
63
+
64
+ TTL 表示“记忆上下文的新鲜度”,不是记忆数据的删除时间。过期不会删除 SQLite 中的 profile、daily 或长期 memory。
65
+
66
+ ### 群活跃与成员活跃是两回事
67
+
68
+ 群一直有人聊天,不能替每个群成员刷新 TTL。例如:
69
+
70
+ ```text
71
+ 群里其他成员持续聊天
72
+ 群友 1 潜水超过 30 分钟
73
+ 群友 1 再次发言
74
+ ```
75
+
76
+ 群级记忆可以继续使用已有快照,但群友 1 的成员记忆已经过期。群友 1 再次发言时,应刷新当前成员快照,并重新组装:
77
+
78
+ ```text
79
+ 最新群级记忆
80
+ + 群友 1 的最新成员记忆
81
+ ```
82
+
83
+ 反过来,群友 1 持续发言不会自动刷新其他成员的成员 TTL。每个成员只在自己参与对话或其记忆被显式标记过期时刷新。
84
+
85
+ 推荐记录以下三类状态:
86
+
87
+ ```text
88
+ groupSnapshot:
89
+ hash
90
+ version
91
+ lastInjectedAt
92
+ lastActiveAt
93
+ stale
94
+
95
+ memberSnapshot[groupMemberId]:
96
+ hash
97
+ version
98
+ lastInjectedAt
99
+ lastActiveAt
100
+ stale
101
+
102
+ session:
103
+ compactedAt
104
+ memorySnapshotStale
105
+ ```
106
+
107
+ 一个群对应一个 DSH Session 时,runtime-context snapshot 仍然可以整体替换;TTL 只是决定何时需要重新组装该 Session 当前有效的“群级记忆 + 当前成员记忆”,而不是为每个成员创建独立 DSH Session。
108
+
109
+ ### 3.3 Session 压缩完成后
110
+
111
+ Session compact 会改变模型可见的历史上下文,因此压缩完成后需要重新建立记忆基线。
112
+
113
+ 推荐流程:
114
+
115
+ ```text
116
+ Session compact 完成
117
+ ↓
118
+ 标记 memory snapshot stale
119
+ ↓
120
+ 下一次 Agent 输出前或下一条 QQ 消息到达
121
+ ↓
122
+ 注入最新完整记忆快照
123
+ ↓
124
+ 清除 stale 标记并记录新版本
125
+ ```
126
+
127
+ 压缩后的第一次模型输出应能看到完整记忆快照。后续连续消息继续复用该快照,不重复注入。
128
+
129
+ ## 4. Hash 与版本判断
130
+
131
+ 不能只根据“本轮是否有新消息”决定是否注入。应分别判断 Session 状态和记忆状态:
132
+
133
+ ```text
134
+ 需要注入 =
135
+ 没有快照
136
+ 或群级快照已过期
137
+ 或当前成员快照已过期
138
+ 或 Session 刚完成 compact
139
+ 或群/当前成员的 memoryVersion / memoryHash 与 Session 记录不一致
140
+ ```
141
+
142
+ Hash 应由规范化后的结构化字段计算,而不是直接对不稳定的 Markdown 排版计算。建议按字段计算后再合并:
143
+
144
+ ```text
145
+ scope + profileHash + patternHash + summaryHash
146
+ + dailyHash + memoryHash + identityHash
147
+ ```
148
+
149
+ 这样可以避免无意义的空白、排序或格式变化导致重复注入。
150
+
151
+ ## 5. 记忆变化的处理
152
+
153
+ 记忆反思在后台更新 SQLite 后,不需要立即为每条消息重新注入完整快照。
154
+
155
+ 处理规则:
156
+
157
+ - 连续对话中发现群级记忆版本变化:只标记群级 pending/stale;
158
+ - 发现某个成员记忆版本变化:只标记该成员 pending/stale;
159
+ - 当前回合继续使用已有 Session 快照,避免在同一连续对话中反复插入;
160
+ - 对应 TTL 过期、Session compact 或下一次安全刷新点到达时,注入最新完整快照;
161
+ - 若某次记忆变化必须立即生效(例如权限、身份纠正),可以显式标记对应作用域和 Session stale,优先刷新。
162
+
163
+ 这样可以避免“后台反思每更新一次,Session 就追加一份完整成员记忆”。
164
+
165
+ ## 6. Session 中的表示
166
+
167
+ 快照应继续使用 DSH 官方 runtime-context snapshot 来源,不创建另一套 AgentLoop 或普通用户对话格式。
168
+
169
+ 快照内容建议带有明确的版本和生命周期标记:
170
+
171
+ ```text
172
+ [QQ 记忆上下文快照]
173
+ scope=group:123
174
+ memoryVersion=42
175
+ generatedAt=2026-08-21T00:00:00.000Z
176
+ expiresAt=2026-08-21T00:30:00.000Z
177
+
178
+ ...
179
+ ```
180
+
181
+ 相同 scope 的新快照应由 DSH 的 runtime-context projection 替换旧快照,而不是作为普通 user message 无限追加。Session 仍然保留 DSH 所需的事件和恢复信息,但模型上下文只使用当前有效快照。
182
+
183
+ ## 7. 与记忆压缩的关系
184
+
185
+ 这里的“记忆快照刷新”和“记忆系统的 daily → memory 压缩”是两个不同过程:
186
+
187
+ | 过程 | 作用 | 触发条件 |
188
+ | --- | --- | --- |
189
+ | 记忆反思/压缩 | 把 SQLite 中近期记录整理为 profile、summary、memory | 空闲、消息数量阈值、daily 压缩阈值 |
190
+ | Session 记忆快照 | 把当前已保存的记忆提供给 DSH Agent | 新 Session、TTL、Session compact、显式 stale |
191
+
192
+ 记忆压缩完成后,如果产生了新的 `memoryVersion`,当前 Session 不需要立即每轮重复注入;应在下一个记忆刷新点生成新的完整快照。
193
+
194
+ ## 8. 边界与失败处理
195
+
196
+ - 记忆系统关闭时,不注入快照,也不启动后台反思;已有 SQLite 数据不删除;
197
+ - SQLite 读取失败时,不写入空快照,保留上一次有效 Session 快照并记录日志;
198
+ - 快照生成失败时,不覆盖上一次有效快照;
199
+ - compact 状态未知时,宁可在下一条消息刷新一次,也不要每轮重复刷新;
200
+ - 群成员记忆必须按稳定 sender ID 查询,不能按昵称匹配;
201
+ - 快照中不得携带不可 JSON 序列化对象、文件句柄或媒体对象引用。
202
+
203
+ ## 9. 实施 TODO
204
+
205
+ ### Phase 0:基础能力与约束(已完成)
206
+
207
+ - [x] group/member/directed-chat 记忆 scope 分离;
208
+ - [x] profile、pattern、summary、daily、memory 文档分层;
209
+ - [x] daily 按日期归档,后台反思和 daily → memory 压缩;
210
+ - [x] 记忆系统设置开关,关闭时不删除已有数据;
211
+ - [x] 使用 DSH runtime-context snapshot 来源注入记忆;
212
+ - [x] 群成员记忆按 stable sender ID 隔离,避免按昵称归属。
213
+
214
+ ### Phase 1:Session 级快照状态(已完成)
215
+
216
+ - [x] 为每个 QQ Session 保存 memory snapshot 状态:hash、注入时间和 stale 标记;
217
+ - [x] 以群/当前成员组合快照实现群级和成员级上下文边界;
218
+ - [x] 从已有 Session 的 runtime-context 事件恢复快照 hash 和时间;
219
+ - [x] 使用规范化后的完整上下文计算 hash,避免内容未变化时重复注入。
220
+
221
+ ### Phase 2:TTL 与差异状态判断(已完成)
222
+
223
+ - [x] 将每轮无条件 `agent.inject()` 改为按 Session snapshot 状态判断;
224
+ - [x] 新 Session 或没有有效基线时注入完整快照;
225
+ - [x] 默认 30 分钟 TTL 过期后注入完整快照;
226
+ - [x] 当前群员上下文变化或记忆内容 hash 变化时重新组装快照;
227
+ - [x] 连续对话中只刷新缓存时间,不写入重复 runtime-context 事件。
228
+
229
+ ### Phase 3:Session compact 衔接(已完成)
230
+
231
+ - [x] 监听 DSH Session compact 完成事件;
232
+ - [x] compact 完成后将当前记忆快照标记为 stale;
233
+ - [x] 确保压缩后的下一条 QQ 消息重新注入完整记忆快照;
234
+ - [x] 避免 compact 后每个连续回合重复注入,只建立一次新的记忆基线。
235
+
236
+ ### Phase 4:上下文裁剪与验证(已完成)
237
+
238
+ - [x] 从记忆快照中移除已经存在于 DSH Session 的重复最近 QQ 消息;
239
+ - [x] 通过可控时间测试验证 TTL 未过期、TTL 到期、内容变化和 compact 后刷新;
240
+ - [x] 通过全量 typecheck、23 项测试和构建验证快照改动不破坏现有功能;
241
+ - [x] 利用 DSH runtime-context projection 保证模型侧只保留当前有效快照;
242
+ - [x] 验证快照读取失败路径不会覆盖已有有效上下文;
243
+ - [x] 重启 DSH Web 并完成运行时 HTTP smoke 检查;
244
+ - [x] 在真实持续 QQ 对话中做长期 Session 观察,确认跨多次 compact 后仍保持单份有效快照。
245
+
246
+ ## 10. 非目标
247
+
248
+ 本方案不做以下事情:
249
+
250
+ - 不把记忆移出 DSH Session;
251
+ - 不把每条记忆更新都编码成一长串模型可见 diff;
252
+ - 不修改 DSH Session 的追加式存储规则;
253
+ - 不删除 SQLite 中已经保存的记忆;
254
+ - 不让群成员记忆跨群泄漏。
package/docs/QUOTES.md ADDED
@@ -0,0 +1,355 @@
1
+ # QQ 引用消息方案
2
+
3
+ ## 易读概述
4
+
5
+ QQ 的引用事件不一定携带被引用消息的完整内容。尤其是 C2C 图片引用,当前事件可能只有 `ref_msg_idx`,没有正文、附件或可下载 URL。
6
+
7
+ 因此引用不能只依赖当前事件解析。dsh-qqchat 后续采用“当前事件优先、历史引用索引兜底”的方案:
8
+
9
+ ```text
10
+ 普通消息到达
11
+ -> 保存正文、消息 ID、msg_idx 和附件关系
12
+
13
+ 引用消息到达
14
+ -> 解析当前事件中的引用
15
+ -> 按 ref_msg_idx 查询历史引用索引
16
+ -> 复用已保存的正文和附件
17
+ -> 进入 DSH Agent、Session 和 Web
18
+ ```
19
+
20
+ 最终目标是让群聊、私聊、图片引用、文件引用和机器人消息引用使用同一套消息关系和权限边界。
21
+
22
+ ## 当前状态
23
+
24
+ 当前已经完成:
25
+
26
+ - `message_reference`、`reference`、`quote` 和 QQ 原生 `message_scene.ext` 的基础解析;
27
+ - 引用正文、发送人和附件的独立数据结构;
28
+ - SQLite 消息、附件和消息附件关系;
29
+ - 引用附件的 `quoted` 标记和已有附件复用;
30
+ - Session 范围的附件读取权限;
31
+ - DSH Web 中独立的引用块和引用附件展示。
32
+ - `msg_idx` / `ref_msg_idx` 的标准化和 SQLite 引用索引;
33
+ - 引用索引 7 天 TTL、过期清理和聊天范围隔离;
34
+ - 当前事件缺少引用内容时的历史正文回填;
35
+ - 已保存引用附件的 `attachmentId` 复用。
36
+
37
+ 当前仍缺少:
38
+
39
+ - 多次引用、过期引用和机器人消息引用的真实 QQ 验收;
40
+ - 网关重启后的真实 C2C 图片引用验收;
41
+ - 引用索引与当前 QQ 协议字段的更多兼容样本。
42
+
43
+ 详细的媒体、附件和 Web 边界见 [MEDIA_AND_QUOTES.md](MEDIA_AND_QUOTES.md)。
44
+
45
+ ## 设计原则
46
+
47
+ ### 当前事件优先,历史索引兜底
48
+
49
+ 引用解析顺序固定为:
50
+
51
+ 1. `message_reference`、`reference` 或 `quote` 中的结构化引用;
52
+ 2. `msg_elements` 中匹配 `ref_msg_idx` 的元素;
53
+ 3. SQLite 引用索引中的历史消息;
54
+ 4. 当前事件嵌套字段中的 URL 和附件信息作为最后兜底。
55
+
56
+ 无法确认引用关系时,不猜测、不把普通正文误当成引用,只保留当前消息。
57
+
58
+ ### 引用关系和消息正文分离
59
+
60
+ 引用正文不能直接拼进当前消息正文。消息模型保持:
61
+
62
+ ```ts
63
+ interface QQQuoteInput {
64
+ messageId?: string
65
+ senderId?: string
66
+ senderName?: string
67
+ text: string
68
+ attachments: QQAttachmentInput[]
69
+ }
70
+
71
+ interface QQNormalizedMessage {
72
+ messageId: string
73
+ text: string
74
+ quote?: QQQuoteInput
75
+ // QQ 协议元数据,供引用索引使用
76
+ msgIdx?: string
77
+ refMsgIdx?: string
78
+ }
79
+ ```
80
+
81
+ 这样可以同时满足:
82
+
83
+ - Agent 能明确区分“引用内容”和“当前问题”;
84
+ - DSH Web 能渲染独立引用块;
85
+ - 记忆系统不会把引用正文误认为当前发言;
86
+ - 当前消息仍然保持原始用户输入。
87
+
88
+ ### 使用稳定身份和聊天范围
89
+
90
+ 引用索引的 key 必须带上账号和聊天范围:
91
+
92
+ ```text
93
+ account_id:chat_type:chat_id:msg_idx
94
+ ```
95
+
96
+ 其中:
97
+
98
+ - 群聊的 `chat_id` 是 `group_openid`;
99
+ - 私聊的 `chat_id` 是对方稳定用户 ID;
100
+ - 账号 ID 用于隔离多个 QQ Bot;
101
+ - `msg_idx` 只作为同一聊天范围内的索引,不能单独使用。
102
+
103
+ 不能使用昵称、引用文本或文件名作为引用关系的唯一键。
104
+
105
+ ## 引用索引
106
+
107
+ ### 推荐存储
108
+
109
+ 使用现有 SQLite,不新增 JSONL 文件。建议增加:
110
+
111
+ ```text
112
+ qq_quote_index
113
+ id
114
+ account_id
115
+ chat_type c2c | group
116
+ chat_id
117
+ msg_idx
118
+ platform_message_id
119
+ sender_id
120
+ sender_name
121
+ content_summary 最多 200 字符
122
+ created_at
123
+ expires_at
124
+ ```
125
+
126
+ 唯一约束:
127
+
128
+ ```text
129
+ UNIQUE(account_id, chat_type, chat_id, msg_idx)
130
+ ```
131
+
132
+ 附件不在索引中复制二进制数据,也不重复保存附件记录。通过 `platform_message_id` 和 `message_attachments` 关联现有附件。
133
+
134
+ ### 为什么不直接复制咕咕的 JSONL
135
+
136
+ 咕咕的 JSONL 索引适合独立 Gateway 进程,但 dsh-qqchat 已经具备:
137
+
138
+ - SQLite 消息持久化;
139
+ - account、group、member 和 Session 关系;
140
+ - 附件生命周期和权限查询;
141
+ - 消息去重。
142
+
143
+ 直接使用 SQLite 可以避免两套持久化状态不一致,也能让引用附件继续经过现有 Session 权限检查。
144
+
145
+ ### TTL 和清理
146
+
147
+ 引用索引建议保留 7 天,与媒体附件 TTL 一致:
148
+
149
+ - 读取时检查 `expires_at`;
150
+ - 定期清除过期索引;
151
+ - 附件是否删除由附件表和物理文件生命周期决定;
152
+ - 删除索引不能直接删除附件,必须检查其他消息关系。
153
+
154
+ 索引只保存结构化元数据,不把 QQ 签名 URL 长期写入数据库、Session、日志或前端。
155
+
156
+ ## 入站处理流程
157
+
158
+ ### 普通消息
159
+
160
+ ```text
161
+ QQ DISPATCH
162
+ -> 解析 chat_type / chat_id / sender_id
163
+ -> 读取 msg_idx
164
+ -> 标准化正文和附件
165
+ -> 下载并保存允许接收的附件
166
+ -> 写入 messages 和 message_attachments
167
+ -> 写入 qq_quote_index
168
+ -> 根据群聊接收模式决定是否触发 Agent
169
+ ```
170
+
171
+ 普通消息必须先登记,后续引用才能恢复它的正文和附件。
172
+
173
+ ### 引用消息
174
+
175
+ ```text
176
+ QQ DISPATCH
177
+ -> 读取 ref_msg_idx
178
+ -> 尝试解析当前事件引用
179
+ -> 当前事件缺字段时查询 qq_quote_index
180
+ -> 根据 source message 复用附件关系
181
+ -> 标记引用附件 role=quoted
182
+ -> 保存 quote_json
183
+ -> 生成 Agent 上下文和 Web transcript
184
+ ```
185
+
186
+ 如果索引命中但附件已过期:
187
+
188
+ - 保留引用发送人和正文;
189
+ - 显示“引用附件已过期”或媒体摘要;
190
+ - 不返回原始签名 URL;
191
+ - 不让整轮 Agent 失败。
192
+
193
+ 私聊入站消息仍使用 DSH 原生 `user/message` 事件,以保持原生气泡和会话语义;引用元数据会作为结构化文本前缀保留在该事件中。群聊则使用 QQ 展示事件承载独立引用区域。当前消息附件和引用附件在展示层严格分离,但 Agent 输入和数据库权限范围仍可同时访问两者。
194
+
195
+ 引用区域不能以正文是否为空作为显示条件。纯图片、纯文件等没有文字正文的 QQ 消息仍应显示引用卡片和附件;反思输入也必须保留 `quote.senderId`,避免把被引用人的个人事实归到当前发言人。
196
+
197
+ ### 递归字段兜底
198
+
199
+ 不同 QQ 适配器的附件字段可能不同,最后兜底可以读取:
200
+
201
+ ```text
202
+ url
203
+ file_url
204
+ download_url
205
+ downloadUrl
206
+ href
207
+ file
208
+ image_url
209
+ origin_url
210
+ preview_url
211
+ ```
212
+
213
+ 这只用于当前事件解析,不能替代持久化引用索引,也不能绕过统一的 HTTPS、DNS、大小和超时校验。
214
+
215
+ ## 附件复用策略
216
+
217
+ ```text
218
+ 命中引用索引
219
+ -> 找到 source platform message
220
+ -> 按 source_file_id 查询现有附件
221
+ -> 附件有效:复用 storage_key / imageRef
222
+ -> 附件不存在但当前事件有 URL:通过媒体下载器重新获取
223
+ -> 两者都不存在:保留引用摘要,降级处理
224
+ ```
225
+
226
+ 引用关系提交前,源附件不能被清理。共享物理文件时:
227
+
228
+ - 新建消息附件关系;
229
+ - 不复制二进制文件;
230
+ - 延长源附件有效期;
231
+ - 清理前检查是否还有存活关系。
232
+
233
+ 群成员是否允许接收媒体仍由现有 `groupMembersCanReceiveMedia` 控制。关闭媒体接收时,不应因为后续引用而绕过权限下载原始文件。
234
+
235
+ ## Agent 和 Session
236
+
237
+ 触发 Agent 的引用消息继续使用 DSH 原生 `user/message`,引用信息作为同一条消息中的结构化上下文:
238
+
239
+ ```text
240
+ [引用消息]
241
+ 发送人:Alice
242
+ 消息 ID:...
243
+ 内容:请看看这张图
244
+ 附件:图片 image.png(attachment_id=...)
245
+
246
+ [当前消息]
247
+ 用户:这是什么意思?
248
+ ```
249
+
250
+ 静默消息继续使用 `qqchat/message`,事件 payload 只能包含 JSON 可序列化摘要:
251
+
252
+ ```json
253
+ {
254
+ "messageId": "db:123",
255
+ "quotedText": "原消息",
256
+ "quote": {
257
+ "messageId": "m-old",
258
+ "senderName": "Alice",
259
+ "attachments": [{ "id": "qqatt-1", "kind": "image", "quoted": true }]
260
+ }
261
+ }
262
+ ```
263
+
264
+ 禁止把 Buffer、Blob、文件句柄、SDK 原始对象或循环引用写入 Session event。
265
+
266
+ ## Web 展示
267
+
268
+ 引用继续使用独立样式,但不新建第二套 DSH 气泡或附件系统:
269
+
270
+ ```text
271
+ 引用块
272
+ ├─ 引用发送人
273
+ ├─ 引用正文
274
+ └─ 引用附件缩略图/摘要
275
+
276
+ 当前消息气泡
277
+ ├─ 当前正文
278
+ └─ 当前消息附件
279
+ ```
280
+
281
+ 当前 Client 已提供引用块和引用附件展示。后续优化只补充:
282
+
283
+ - 引用消息来源用户名;
284
+ - 引用失效和附件过期状态;
285
+ - 多附件的顺序和数量限制;
286
+ - 引用图片的原生 DSH 预览入口。
287
+
288
+ 不得把引用正文重新拼进 `content`,不得把 QQ 原始 URL 交给浏览器。
289
+
290
+ ## 机器人消息引用
291
+
292
+ 机器人自己的消息也应尽量登记到引用索引:
293
+
294
+ - QQ API 返回平台消息 ID 时,写入索引;
295
+ - 无法获得平台消息 ID 时,不伪造索引 key;
296
+ - 被引用时优先使用当前事件提供的结构化内容;
297
+ - 无法恢复时显示引用不可用,不影响当前消息处理。
298
+
299
+ 机器人消息引用不能使用本地 DSH Session event ID 代替 QQ 平台消息 ID,因为 QQ 的 `ref_msg_idx` 只在 QQ 协议范围内有意义。
300
+
301
+ ## 安全边界
302
+
303
+ - 索引按 account、chat type 和 chat ID 隔离;
304
+ - 引用附件必须重新通过当前 Session 的附件权限校验;
305
+ - 群附件不能通过同一成员的 C2C Session 读取;
306
+ - 不长期保存带签名的 QQ 下载 URL;
307
+ - 不把用户完整正文写入日志;
308
+ - 引用索引只能补全已收到、已登记的消息,不能凭空恢复 QQ 未提供的媒体;
309
+ - 下载仍使用统一媒体存储层的 HTTPS、DNS、重定向、大小和超时限制。
310
+
311
+ ## 分阶段实施
312
+
313
+ ### Phase 1:协议字段和索引契约(已完成)
314
+
315
+ - [x] 从 `message_scene.ext` 标准化 `msg_idx` 和 `ref_msg_idx`;
316
+ - [x] 为当前消息和引用关系增加内部类型;
317
+ - [x] 增加 normalize 测试覆盖群聊和 C2C;
318
+ - [x] 确认没有索引字段时的 message ID fallback 行为。
319
+
320
+ ### Phase 2:SQLite 引用索引(已完成)
321
+
322
+ - [x] 增加 `qq_quote_index` 表和 account/chat scope 唯一约束;
323
+ - [x] 普通消息入站时登记正文摘要和附件关系;
324
+ - [x] 增加 7 天 TTL 和清理;SQLite 索引不需要单独 JSONL compact;
325
+ - [x] 进程重启后从 SQLite 恢复索引。
326
+
327
+ ### Phase 3:引用回填和附件复用(基础能力已完成)
328
+
329
+ - [x] 当前事件缺少引用内容时查询索引;
330
+ - [x] 按 source message 和已保存的 attachment ID 复用附件;
331
+ - [x] 增加引用索引、范围隔离和过期测试;
332
+ - [ ] 完成连续引用、真实引用图片/文件和下载失败降级的集成测试。
333
+
334
+ ### Phase 4:真实 QQ 和 Web 验收
335
+
336
+ - [ ] 群聊引用文字和图片;
337
+ - [ ] 私聊引用文字和图片;
338
+ - [ ] 重启后引用仍能恢复;
339
+ - [ ] 机器人消息引用;
340
+ - [ ] 多附件和引用附件顺序;
341
+ - [ ] DSH Web 中引用块、缩略图、过期状态和权限拒绝;
342
+ - [ ] 真实 Session、account、群和成员跨范围读取验证。
343
+
344
+ ## 验收标准
345
+
346
+ - 普通消息没有引用时行为不变;
347
+ - 当前事件带引用内容时不依赖索引也能正常处理;
348
+ - 当前事件只有 `ref_msg_idx` 时,已登记消息可以恢复正文和附件;
349
+ - 引用附件不会重复下载或重复保存;
350
+ - 索引过期后不会返回旧附件;
351
+ - 网关重启后索引可恢复;
352
+ - 群和私聊之间不能越权读取引用附件;
353
+ - 引用解析失败不会导致 Agent turn 失败;
354
+ - Session event 始终 JSON 可序列化;
355
+ - Web 保持 DSH 原生消息和附件风格。