group-store.service.ts 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451
  1. import { computed, inject, Injectable, signal } from '@angular/core';
  2. import { Message } from './api/models';
  3. import { GroupEntry } from './group-turns';
  4. import { turnIdAt } from './group-matrix-transcript';
  5. import { SessionService } from './session.service';
  6. import { ChatSessionService } from './chat-session.service';
  7. import { isUserUploadUrl } from './s3-link';
  8. /**
  9. * 群聊消息存储(2026-11-05 新增)
  10. *
  11. * ## 为什么需要独立存储
  12. *
  13. * 伪群聊的回复物理上落在**各容器的 DM 房间**里 —— 而那个房间里还装着
  14. * 用户与该容器的**全部单聊历史**。直接回读房间会让私聊内容串进群聊页。
  15. *
  16. * 实测(2026-11-05):刚进群聊页就能看到容器几天前的单聊长回复,
  17. * 还被合并成一个「已完成 · 189848s」的回合。
  18. *
  19. * 所以:群聊有自己的消息表,**只收群内发生的消息**,与 DM 房间彻底隔离。
  20. *
  21. * ## 什么能进群聊
  22. *
  23. * | 来源 | 能否进群 |
  24. * |---|---|
  25. * | 用户在群里发的消息 | ✅ |
  26. * | **订阅建立之后**实时到达的容器回复 | ✅ |
  27. * | `/sync` 初次回填的历史 | ❌ 那是单聊历史 |
  28. * | 向上翻页拿回的旧事件 | ❌ 同上报废(`live` 标志区分不了,见下) |
  29. *
  30. * 🔴 `live === true` 并不等于「刚到的」:matrix-js-sdk 的
  31. * `liveEvent: !toStartOfTimeline && ...`,向上翻页同样是 true。
  32. * 所以还要叠加**时间水位**:`ts > watermark` 才算新回复。
  33. *
  34. * ## 持久化
  35. *
  36. * 本地 localStorage,按用户隔离(与 `ChatSessionService` 同模式)。
  37. * 不上云:容器房间里有原件,云端再存一份会产生两份真相(见 ADR-007)。
  38. */
  39. /** 单会话最多保留的条目数,超出按时间裁剪 */
  40. export const MAX_ENTRIES_PER_GROUP = 500;
  41. /** localStorage 键前缀(与 chat-session 的 `fmode_chat_sessions_v2_` 同风格) */
  42. const STORAGE_PREFIX = 'fmode_group_entries_v1_';
  43. /** 一条用户群内消息的落库形态 */
  44. interface StoredEntry extends GroupEntry {
  45. /** 写入时的会话 id(冗余一份,便于按 key 存) */
  46. sessionId: string;
  47. }
  48. @Injectable({ providedIn: 'root' })
  49. export class GroupStoreService {
  50. private readonly session = inject(SessionService);
  51. /** 群会话的成果链接写入(2026-11-10,与单聊同口径) */
  52. private readonly sessions = inject(ChatSessionService);
  53. /** sessionId → 全部条目(按 at 升序) */
  54. private readonly _bySession = signal<Record<string, GroupEntry[]>>({});
  55. /** 自增序号:保证同一毫秒内的用户消息也有稳定 id */
  56. private seq = 0;
  57. /**
  58. * 是否已成功从磁盘载入过。
  59. *
  60. * 🔴 只有载入过才允许写盘:否则「未登录 → 写空表」会抹掉已有数据。
  61. */
  62. private loaded = false;
  63. /** ⚠️ 测试用:重置内存态(同时复位「已加载」标记) */
  64. resetForTest(): void {
  65. this._bySession.set({});
  66. this._targetsOf.set({});
  67. this.seq = 0;
  68. this.loaded = false;
  69. }
  70. /** 某会话的全部条目 */
  71. entriesOf(sessionId: string): GroupEntry[] {
  72. return this._bySession()[sessionId] ?? [];
  73. }
  74. /**
  75. * 该会话是否已有记录。
  76. *
  77. * 页面用它区分「真的没聊过」与「还没加载」——
  78. * 两者在 UI 上都显示空态,但语义不同。
  79. */
  80. hasAny = computed(() => Object.keys(this._bySession()).length > 0);
  81. // ---------- 写入 ----------
  82. /**
  83. * 记录一条用户群内消息。
  84. *
  85. * @returns 这条消息的回合 id(= 条目 id),后续容器回复都挂在它下面
  86. */
  87. appendMine(
  88. sessionId: string,
  89. text: string,
  90. targets: string[],
  91. at = Date.now(),
  92. ): string {
  93. /*
  94. * 🔴 id 必须**可复现**(2026-11-07 群聊落 Matrix):`me-<毫秒>`。
  95. *
  96. * 原实现 `me-<毫秒>-<内存自增序号>`:序号每次启动从 0 开始,
  97. * 换设备后同一条消息算出的 id 不同 —— 从 Matrix 重建出来的条目
  98. * 与本地条目对不上,合并会变成两条一模一样的气泡。
  99. */
  100. this.seq += 1;
  101. const id = turnIdAt(at, new Set(this.entriesOf(sessionId).map((e) => e.id)));
  102. const entry: GroupEntry = {
  103. id,
  104. turnId: id,
  105. fromAgentId: null,
  106. at,
  107. message: {
  108. id,
  109. conversationId: 'group',
  110. sender: 'me',
  111. kind: 'text',
  112. text,
  113. ts: new Date(at).toISOString(),
  114. state: 'sent',
  115. },
  116. };
  117. // 目标成员记在这里,供回合聚合渲染「已派给谁」
  118. this._targetsOf.update((m) => ({ ...m, [id]: targets }));
  119. this.write(sessionId, [entry]);
  120. return id;
  121. }
  122. /**
  123. * 记录一条容器回复。
  124. *
  125. * @param turnId 属于哪次提问。未知时归入该会话**最近一次**提问。
  126. * @returns 实际写入的 turnId;找不到任何回合时返回 null(丢弃)
  127. */
  128. appendReply(
  129. sessionId: string,
  130. agentId: string,
  131. msg: Message,
  132. at: number,
  133. turnId?: string,
  134. ): string | null {
  135. const list = this.entriesOf(sessionId);
  136. // 按 eventId 去重 —— 同一条 Matrix 事件只入群一次
  137. if (msg.id && list.some((e) => e.id === msg.id)) return null;
  138. const resolved =
  139. turnId ??
  140. [...list].reverse().find((e) => e.fromAgentId === null)?.turnId ??
  141. null;
  142. if (!resolved) return null;
  143. const entry: GroupEntry = { id: msg.id, turnId: resolved, fromAgentId: agentId, message: msg, at };
  144. this.write(sessionId, [entry]);
  145. /*
  146. * 记录成果链接到**群会话**(2026-11-10)。
  147. *
  148. * 原来群会话的 `deliverableUrls` 从来没人写,右栏「成果」只能退化成
  149. * 「按成员容器拉全量」—— 成员在别的单聊里产出的文件也会混进来。
  150. * 现在与单聊同口径:只认 agent 产出、且不是用户上传的文件。
  151. */
  152. const url = msg.file?.cloudUrl || (msg.file?.url?.startsWith('https://s3.') ? msg.file.url : undefined);
  153. if (url && !isUserUploadUrl(url)) this.sessions.addDeliverableUrl(sessionId, url);
  154. return resolved;
  155. }
  156. /**
  157. * 记录一次「用户在本回合发的附件」(2026-11-07)。
  158. *
  159. * 群聊发文件时,每个目标房间各有一条文件事件;这里只记**首条成功的那条**
  160. * (按 name+size 去重),用户气泡上显示一份即可。
  161. *
  162. * @param eventId Matrix 文件事件 id;没有时回落到 turnId
  163. */
  164. appendUserFile(
  165. sessionId: string,
  166. turnId: string,
  167. file: Message['file'],
  168. at: number,
  169. eventId?: string,
  170. ): void {
  171. if (!file) return;
  172. const list = this.entriesOf(sessionId);
  173. const dup = list.some(
  174. (e) => e.message.file?.name === file.name && e.message.file?.size === file.size,
  175. );
  176. if (dup) return;
  177. const entry: GroupEntry = {
  178. id: eventId || `${turnId}#file-${at}`,
  179. turnId,
  180. fromAgentId: null,
  181. at,
  182. message: {
  183. id: eventId || turnId,
  184. conversationId: 'group',
  185. sender: 'me',
  186. kind: 'text',
  187. file,
  188. ts: new Date(at).toISOString(),
  189. state: 'sent',
  190. },
  191. };
  192. this.write(sessionId, [entry]);
  193. }
  194. /**
  195. * 用 Matrix 重建结果合并进某群(2026-11-07,2026-11-08 改为**保守合并**)。
  196. *
  197. * 调用方已完成「本地优先」合并;这里只负责落表 + 并集 targets + 落盘。
  198. *
  199. * 🔴 原来是**整表替换**:`[sessionId]: [...entries]`。
  200. * Matrix 重建时如果少推导出条目(分页没翻到、`isNewReply` 过滤、
  201. * 房间一时读不到),本地已有的内容就被直接抹掉 ——
  202. * 用户看到的是「后续回复把之前的输出覆盖了」。
  203. * 现在改为:Matrix 结果为准,但**Matrix 没有的本地条目保留**。
  204. */
  205. replaceFromMatrix(
  206. sessionId: string,
  207. entries: GroupEntry[],
  208. targets: Record<string, string[]>,
  209. ): void {
  210. this._bySession.update((all) => {
  211. const local = all[sessionId] ?? [];
  212. const seen = new Set(entries.map((e) => e.id));
  213. // 本地有、Matrix 没有的条目:保留(不因为重建不全就丢内容)
  214. const kept = local.filter((e) => e.id && !seen.has(e.id));
  215. return {
  216. ...all,
  217. [sessionId]: [...entries, ...kept].sort((a, b) => a.at - b.at),
  218. };
  219. });
  220. this._targetsOf.update((m) => {
  221. const next = { ...m };
  222. for (const [turnId, list] of Object.entries(targets)) {
  223. const cur = next[turnId] ?? [];
  224. next[turnId] = [...cur, ...list.filter((x) => !cur.includes(x))];
  225. }
  226. return next;
  227. });
  228. this.persist();
  229. }
  230. /** 清空某群的消息(用户手动清除 / 群解散) */
  231. clear(sessionId: string): void {
  232. this._bySession.update((all) => {
  233. const next = { ...all };
  234. delete next[sessionId];
  235. return next;
  236. });
  237. this._targetsOf.update((m) => {
  238. const next = { ...m };
  239. for (const k of Object.keys(next)) if (next[k]?.length === 0) delete next[k];
  240. return next;
  241. });
  242. this.persist();
  243. }
  244. // ---------- 目标成员(回合聚合需要) ----------
  245. /**
  246. * 已消费的事件 id(`sessionId|agentId` → Set<eventId>)。
  247. *
  248. * 实时流与补读会并发收到同一条事件;`appendReply` 已按 eventId 去重,
  249. * 但补读还要据此跳过已处理的条目,避免每轮重复扫描。
  250. */
  251. private readonly _consumed = new Map<string, Set<string>>();
  252. private consumedKey(sessionId: string, agentId: string): string {
  253. return `${sessionId}|${agentId}`;
  254. }
  255. /** 该事件是否已被本轮消费 */
  256. isConsumed(sessionId: string, agentId: string, eventId: string): boolean {
  257. return this._consumed.get(this.consumedKey(sessionId, agentId))?.has(eventId) ?? false;
  258. }
  259. /** 标记已消费(内存态即可:补读最长 135 秒,刷新后从头重建索引不丢内容) */
  260. markConsumed(sessionId: string, agentId: string, eventId: string): void {
  261. const key = this.consumedKey(sessionId, agentId);
  262. let set = this._consumed.get(key);
  263. if (!set) {
  264. set = new Set();
  265. this._consumed.set(key, set);
  266. }
  267. set.add(eventId);
  268. }
  269. private readonly _targetsOf = signal<Record<string, string[]>>({});
  270. /** 某次提问派给了哪些成员 */
  271. targetsOf(turnId: string): string[] {
  272. return this._targetsOf()[turnId] ?? [];
  273. }
  274. // ---------- 内部 ----------
  275. private write(sessionId: string, entries: GroupEntry[]): void {
  276. this._bySession.update((all) => {
  277. const cur = all[sessionId] ?? [];
  278. /*
  279. * 按 id 去重(2026-11-07):Matrix 重建与实时流可能写同一条事件,
  280. * 原来的纯追加会产生重复气泡。
  281. */
  282. const seen = new Set(cur.map((e) => e.id));
  283. const merged = [...cur, ...entries.filter((e) => !seen.has(e.id))].sort(
  284. (a, b) => a.at - b.at,
  285. );
  286. /*
  287. * 🔴 不再按回合裁剪内存表:Matrix 才是历史的真源,本地裁掉等于
  288. * 「刷新后重建出来又被裁回去」。只在写盘时裁(见 persist)。
  289. */
  290. return { ...all, [sessionId]: merged };
  291. });
  292. this.persist();
  293. }
  294. private key(): string | null {
  295. const uid = this.session.me()?.id;
  296. return uid ? `${STORAGE_PREFIX}${uid}` : null;
  297. }
  298. private persist(): void {
  299. /*
  300. * 🔴 未加载完成时**绝不写盘**(2026-11-05 评审修复)。
  301. *
  302. * 时序陷阱:深链接进入群聊页时,`load()` 可能在登录态就绪前就被调用,
  303. * 此时 `key()` 为 null 直接返回;若之后 `persist()` 照常写,
  304. * 会把「空表」盖到已有数据上 —— 表现为刷新后群内历史全丢。
  305. * 所以:没成功加载过,就不允许覆盖。
  306. */
  307. if (!this.loaded) return;
  308. const key = this.key();
  309. // 未登录不落盘 —— 否则下一个账号会读到上一个账号的群聊
  310. if (!key) return;
  311. try {
  312. const payload: Record<string, StoredEntry[]> = {};
  313. for (const [sid, list] of Object.entries(this._bySession())) {
  314. // 只裁**落盘副本**:内存表保持完整,会话内翻看不受影响
  315. payload[sid] = trimByTurn(list, MAX_ENTRIES_PER_GROUP).map((e) => ({
  316. ...e,
  317. sessionId: sid,
  318. /*
  319. * 🔴 本地预览地址(`blob:`)不能落盘(评审 2026-11-07):
  320. * 刷新后 blob 已失效,气泡会显示一张裂图 / 空卡。
  321. * 落盘时去掉,刷新后由 Matrix 重建补上真实地址。
  322. */
  323. message: e.message.file?.url?.startsWith('blob:')
  324. ? { ...e.message, file: { ...e.message.file, url: '' } }
  325. : e.message,
  326. }));
  327. }
  328. localStorage.setItem(
  329. key,
  330. JSON.stringify({ entries: payload, targets: this._targetsOf() }),
  331. );
  332. } catch {
  333. /* 隐私模式 / 超限:只保留内存态 */
  334. }
  335. }
  336. /** 从 localStorage 载入(幂等;未登录时不置 `loaded`,等登录后再调) */
  337. load(): void {
  338. const key = this.key();
  339. if (!key) return;
  340. try {
  341. const raw = localStorage.getItem(key);
  342. // 没有历史数据也是「已载入」—— 允许后续正常写入新数据
  343. if (!raw) {
  344. this.loaded = true;
  345. return;
  346. }
  347. const parsed = JSON.parse(raw) as {
  348. entries?: Record<string, StoredEntry[]>;
  349. targets?: Record<string, string[]>;
  350. };
  351. const bySession: Record<string, GroupEntry[]> = {};
  352. for (const [sid, list] of Object.entries(parsed.entries ?? {})) {
  353. if (!Array.isArray(list)) continue;
  354. bySession[sid] = list
  355. .filter((e) => e && typeof e.at === 'number' && e.message && e.turnId)
  356. .map(({ sessionId: _sid, ...rest }) => rest)
  357. .sort((a, b) => a.at - b.at);
  358. }
  359. this._bySession.set(bySession);
  360. this._targetsOf.set(parsed.targets ?? {});
  361. this.loaded = true;
  362. } catch {
  363. // 坏数据当没有 —— 不能让一条脏 JSON 把群聊页整个卡死
  364. this._bySession.set({});
  365. this._targetsOf.set({});
  366. this.loaded = true;
  367. }
  368. }
  369. }
  370. /**
  371. * 按**回合**整组裁剪,保留最近的若干个回合。
  372. *
  373. * 🔴 不能按条数切(2026-11-05 评审修复):
  374. * 按时间切片会把某个回合的用户消息先裁掉、只留下它的回复,
  375. * 变成「孤儿回复」——`buildGroupTurns` 会丢弃它,表现为对话凭空断掉。
  376. * 整组裁掉才是干净的状态。
  377. */
  378. export function trimByTurn(list: GroupEntry[], max: number): GroupEntry[] {
  379. if (list.length <= max) return list;
  380. // 按 turnId 分组(保持首次出现顺序)
  381. const groups = new Map<string, GroupEntry[]>();
  382. for (const e of list) {
  383. const g = groups.get(e.turnId);
  384. if (g) g.push(e);
  385. else groups.set(e.turnId, [e]);
  386. }
  387. const keys = [...groups.keys()];
  388. const kept: GroupEntry[] = [];
  389. let count = 0;
  390. // 从最新回合往前留,直到超过上限为止
  391. for (let i = keys.length - 1; i >= 0; i--) {
  392. const g = groups.get(keys[i])!;
  393. if (count + g.length > max && kept.length > 0) break;
  394. kept.unshift(...g);
  395. count += g.length;
  396. }
  397. return kept;
  398. }
  399. /**
  400. * 判断一条实时事件是否算「群内新回复」的纯函数部分。
  401. *
  402. * `live` 单独不可靠(翻页拿到的事件 live 也是 true),
  403. * 必须叠加时间水位:只有不早于订阅建立时该房间最后一条事件的时间才算新。
  404. *
  405. * 🔴 边界用 `>=` 而不是 `>`(2026-11-05 评审修复):
  406. * 水位 = 订阅时房间最后一条的 ts。若容器恰好在水位那一毫秒回了消息,
  407. * 用 `>` 会把它判成历史丢掉 → 状态卡在「思考中」直到 90 秒超时。
  408. * 放宽到 `>=` 后可能重复收到同一条,但 `appendReply` 按 eventId 去重兜底。
  409. *
  410. * @param live Matrix 事件的 `live` 标志
  411. * @param ts 事件时间(ms)
  412. * @param watermark 订阅建立时该房间的时间水位(ms);0 表示没有水位约束
  413. */
  414. export function isFreshReply(live: boolean, ts: number, watermark: number): boolean {
  415. if (!live) return false;
  416. if (watermark <= 0) return true;
  417. return ts >= watermark;
  418. }