group-chat.service.ts 6.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179
  1. import { computed, inject, Injectable, signal } from '@angular/core';
  2. import { ChatSession } from './chat-session';
  3. import { ChatSessionService } from './chat-session.service';
  4. import { LiveService } from './live.service';
  5. import { Agent } from './api/models';
  6. import {
  7. DispatchDecision,
  8. MentionMember,
  9. findUnknownMentions,
  10. mentionedIds,
  11. planDispatch,
  12. stripMentions,
  13. } from './group-mentions';
  14. export type { DispatchDecision } from './group-mentions';
  15. /**
  16. * 群聊(伪群聊)—— 2026-11-05
  17. *
  18. * ## 为什么叫「伪」
  19. *
  20. * 真群聊要求 N 个容器待在**同一个 Matrix 房间**里。但容器侧
  21. * `MATRIX_REQUIRE_MENTION=false`,房间里任何消息所有容器都会抢答;
  22. * 且容器名互为前缀(`lj` vs `lj-test`)会导致 @ 误判。
  23. * 2026-10-29 正是因为这两点把真群聊下线了。
  24. *
  25. * 伪群聊换了个做法:
  26. *
  27. * ```
  28. * 用户在群里发:@Nova @Orbit 各自汇报
  29. * → App 拆成「各自汇报」分别发到 Nova 的 DM 和 Orbit 的 DM
  30. * → 两个容器在各自房间独立工作
  31. * → App 把两边的回复聚合到群会话展示
  32. * ```
  33. *
  34. * ## 收益与代价
  35. *
  36. * ✅ 不碰容器:天然不抢答、不回环(每个容器只在自己房间)
  37. * ✅ 并发天然隔离:两个容器根本看不到对方
  38. * ❌ 容器之间无法互相 @ 派活(它们不在同一个房间)
  39. *
  40. * ## 数据模型
  41. *
  42. * 仍按真群聊设计(`ChatSession.kind` / `memberIds`)——
  43. * 将来容器侧改好,只换本服务的「分发」实现,UI 与数据都不用动。
  44. */
  45. @Injectable({ providedIn: 'root' })
  46. export class GroupChatService {
  47. private readonly sessions = inject(ChatSessionService);
  48. private readonly live = inject(LiveService);
  49. /** 正在分发的群会话 id(按钮 loading) */
  50. private readonly _dispatching = signal<string | null>(null);
  51. readonly dispatching = this._dispatching.asReadonly();
  52. /** 全部群会话 */
  53. readonly groups = computed<ChatSession[]>(() =>
  54. this.sessions.all().filter((s) => s.kind === 'group'),
  55. );
  56. /** 某群会话的成员容器(按 memberIds 顺序) */
  57. membersOf(session: ChatSession): Agent[] {
  58. const ids = session.memberIds ?? [];
  59. return ids.map((id) => this.live.agentById(id)).filter((a): a is Agent => !!a);
  60. }
  61. /** 某群会话的成员名(标题用) */
  62. memberNamesOf(session: ChatSession): string[] {
  63. return this.membersOf(session).map((a) => a.name);
  64. }
  65. /**
  66. * 生成群会话标题:成员名拼接(Q6 裁决)。
  67. *
  68. * 例:`Nova、Orbit`。超过 3 个成员时显示「Nova、Orbit 等 4 个」。
  69. */
  70. titleFor(memberIds: string[]): string {
  71. const names = memberIds
  72. .map((id) => this.live.agentById(id)?.name)
  73. .filter((n): n is string => !!n);
  74. if (names.length === 0) return '群聊';
  75. if (names.length <= 3) return names.join('、');
  76. return `${names.slice(0, 2).join('、')} 等 ${names.length} 个`;
  77. }
  78. /**
  79. * 从消息正文里剥离 @ 片段(Q21 裁决)。
  80. *
  81. * 用户在群里打「@Nova @Orbit 各自汇报」,分发时剥掉 @ 名字,
  82. * 每个容器只看到「各自汇报」—— 否则它会困惑「你在让我 @ 别人?」
  83. *
  84. * 实现已收敛到 `group-mentions.ts`,与 `planDispatch` 共用同一套边界规则。
  85. */
  86. stripMentions(text: string, names: string[]): string {
  87. return stripMentions(text, names.map((name) => ({ id: name, name })));
  88. }
  89. /**
  90. * 判断正文里 @ 了哪些成员。
  91. */
  92. mentionedIds(text: string, memberIds: string[]): string[] {
  93. return mentionedIds(text, this.membersOf2(memberIds));
  94. }
  95. /** memberId → { id, name }(名字取不到的成员会被过滤,避免空名匹配一切) */
  96. private membersOf2(memberIds: string[]): MentionMember[] {
  97. return memberIds
  98. .map((id) => ({ id, name: this.live.agentById(id)?.name ?? '' }))
  99. .filter((m) => !!m.name);
  100. }
  101. /**
  102. * 把消息分发给被 @ 的成员 —— **纯决策,不做任何兜底群发**。
  103. *
  104. * 2026-11-05 用户裁决(本轮):
  105. * - 不 @ 任何人 → 拦下,提示「请 @ 要响应的容器」
  106. * - @ 了不在群里的名字 → 拦下,标红该名字
  107. *
  108. * 此前的「没 @ 就发给全部成员」已废止 —— 实测它是噪音的最大来源。
  109. */
  110. plan(text: string, session: ChatSession): DispatchDecision {
  111. return planDispatch(text, this.membersOf(session).map((a) => ({ id: a.id, name: a.name })));
  112. }
  113. /** 正文里 @ 了、但不在群里的名字(页面实时标红用,发送前也能看到) */
  114. unknownMentions(text: string, session: ChatSession): string[] {
  115. return findUnknownMentions(
  116. text,
  117. this.membersOf(session).map((a) => ({ id: a.id, name: a.name })),
  118. );
  119. }
  120. /**
  121. * 创建群会话(本地)。
  122. *
  123. * @param memberIds 至少 2 个容器(Q3 裁决)
  124. * @throws 成员不足 / 容器不可用时抛错
  125. */
  126. createGroup(memberIds: string[], workspaceId: string | null = null): ChatSession {
  127. /*
  128. * 🔴 保留用户选择的**原始顺序**(不能用 Set 去重后重排)。
  129. * 用户按「先 A 后 B」选的,标题就该是「A、B」—— 顺序变了会让用户困惑
  130. * 「我是不是选错人了」。
  131. */
  132. const seen = new Set<string>();
  133. const ids = memberIds.filter((id) => {
  134. if (!id || seen.has(id)) return false;
  135. seen.add(id);
  136. return true;
  137. });
  138. if (ids.length < 2) throw new Error('群聊至少需要 2 个容器');
  139. const unavailable = ids.filter((id) => {
  140. const a = this.live.agentById(id);
  141. return !a || !a.matrixReady;
  142. });
  143. if (unavailable.length > 0) throw new Error('部分容器未接入,无法加入群聊');
  144. /*
  145. * 群会话的房间与线程根:沿用首个成员的房间。
  146. *
  147. * 🔴 伪群聊里「群会话」不是一个真实房间 —— 它只是个**展示容器**。
  148. * 真正的收发发生在各成员的 DM 里(见 `plan()`)。
  149. * `roomId` 记首成员房间,只为满足存量代码的必填字段与路由形态。
  150. */
  151. const first = ids[0];
  152. const s = this.sessions.createGroup(`group-${Date.now()}`, ids, this.titleFor(ids), workspaceId);
  153. /*
  154. * 🔴 建群后**立即落库**(2026-11-05)。
  155. *
  156. * 单聊的规则是「首次发消息才 commit」,但群聊的消息走 `sendToAgent`
  157. * (按成员分发),**不经过 ChatService 的 commit 链路** ——
  158. * 若不在这里落库,群会话永远只存在本地,
  159. * 刷新页面时被 `hydrateFromCloud` 的云端结果整体替换掉(实测)。
  160. */
  161. this.sessions.commit(s.id);
  162. return s;
  163. }
  164. }