group-send.service.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280
  1. import { Injectable, inject, signal } from '@angular/core';
  2. import { ChatSessionService } from './chat-session.service';
  3. import { ChatSession } from './chat-session';
  4. import { ChatService, PendingFile } from './chat.service';
  5. import { isOversize, oversizeMessage } from './upload-limits';
  6. import { DispatchAnchor, GroupLiveService } from './group-live.service';
  7. import { GroupChatService } from './group-chat.service';
  8. import { GroupStoreService } from './group-store.service';
  9. export interface GroupSendResult {
  10. ok: boolean;
  11. /** 没送达的成员名(已 @ 但发送失败) */
  12. failedNames: string[];
  13. /** 给用户看的提示(未 @、@ 了非成员等) */
  14. error?: string;
  15. }
  16. /**
  17. * 群聊发送编排(2026-11-07)
  18. *
  19. * ## 为什么抽出来
  20. *
  21. * 桌面与移动两个群聊页原来各写了一份 70 行的 `send()`,已经开始分叉
  22. * (桌面有错误提示、移动没有)。加上附件后复杂度更高:要按成员逐个
  23. * 先传文件再发文字、逐个记锚点。**只留一份**,两端页面只负责 UI 状态。
  24. *
  25. * ## 顺序为什么是「先文件、后文字」
  26. *
  27. * 容器把「本轮最后一条用户消息」当作这轮的任务描述。单聊的
  28. * `uploadPending` 也是先传附件再发文字 —— 保持一致,避免容器把附件
  29. * 当成「已经问过、不用再答」。
  30. */
  31. @Injectable({ providedIn: 'root' })
  32. export class GroupSendService {
  33. private readonly chat = inject(ChatService);
  34. private readonly groups = inject(GroupChatService);
  35. private readonly groupLive = inject(GroupLiveService);
  36. private readonly store = inject(GroupStoreService);
  37. private readonly sessions = inject(ChatSessionService);
  38. /** 待发附件(与单聊同一套暂存语义:选完先停着,点发送才真正上传) */
  39. private readonly _pendingFiles = signal<PendingFile[]>([]);
  40. readonly pendingFiles = this._pendingFiles.asReadonly();
  41. private readonly _uploading = signal(false);
  42. readonly uploading = this._uploading.asReadonly();
  43. private readonly _uploadPct = signal(0);
  44. readonly uploadPct = this._uploadPct.asReadonly();
  45. /** 附件问题(超限 / 上云失败),由群聊页展示 */
  46. private readonly _error = signal<string | null>(null);
  47. readonly error = this._error.asReadonly();
  48. private readonly _sending = signal(false);
  49. readonly sending = this._sending.asReadonly();
  50. /** 暂存文件(不发送);重复选择追加 */
  51. stageFiles(files: FileList | File[]): void {
  52. const all = Array.from(files);
  53. if (all.length === 0) return;
  54. /* 大小拦截(2026-11-10):与单聊同规则,超限项不进暂存区 */
  55. const list = all.filter((f) => !isOversize(f));
  56. const rejected = all.filter((f) => isOversize(f));
  57. if (rejected.length > 0) {
  58. this._error.set(rejected.map(oversizeMessage).join(';'));
  59. }
  60. if (list.length === 0) return;
  61. const added: PendingFile[] = list.map((file, i) => ({
  62. id: `gpf-${Date.now()}-${i}-${Math.random().toString(36).slice(2, 6)}`,
  63. file,
  64. // 图片给本地预览;释放时机与单聊一致(移除 / 发送完成)
  65. previewUrl: file.type.startsWith('image/') ? URL.createObjectURL(file) : undefined,
  66. }));
  67. this._pendingFiles.update((cur) => [...cur, ...added]);
  68. }
  69. unstageFile(id: string): void {
  70. this._pendingFiles.update((cur) => {
  71. const hit = cur.find((f) => f.id === id);
  72. if (hit?.previewUrl) URL.revokeObjectURL(hit.previewUrl);
  73. return cur.filter((f) => f.id !== id);
  74. });
  75. }
  76. /** 切群 / 离开页面时清空(并释放预览地址) */
  77. clear(): void {
  78. for (const f of this._pendingFiles()) if (f.previewUrl) URL.revokeObjectURL(f.previewUrl);
  79. this._pendingFiles.set([]);
  80. this._uploadPct.set(0);
  81. }
  82. /**
  83. * 发一轮:用户消息先落库(乐观回显 + 回合锚点),再逐成员派发。
  84. */
  85. async send(session: ChatSession, text: string, memberName: (id: string) => string): Promise<GroupSendResult> {
  86. const body = text.trim();
  87. const files = this._pendingFiles();
  88. if (!body && files.length === 0) return { ok: false, failedNames: [] };
  89. if (this._sending()) return { ok: false, failedNames: [] };
  90. // 🔴 先校验再发:不 @ 人 / @ 了非成员一律拦下(2026-11-05 用户裁决;附件同样适用)
  91. const plan = this.groups.plan(body, session);
  92. if (!plan.ok) {
  93. return {
  94. ok: false,
  95. failedNames: [],
  96. error:
  97. plan.reason === 'invalid-mention'
  98. ? `这些名字不在群里:${plan.invalidNames.join('、')}`
  99. : plan.reason === 'no-mention'
  100. ? '请 @ 要响应的容器'
  101. : '请在消息里说明要做什么',
  102. };
  103. }
  104. this._sending.set(true);
  105. this._uploading.set(files.length > 0);
  106. try {
  107. // 用户消息先入群聊存储:它是回合锚点,后续回复都挂在它下面
  108. /*
  109. * 🔴 sentAt 必须在写条目**之前**取(评审 2026-11-07):
  110. * 条目 id 是 `me-<at>`,锚点时间若晚于它,补读窗口会偏一点点。
  111. */
  112. const sentAt = Date.now();
  113. const turnId = this.store.appendMine(session.id, body, plan.targets, sentAt);
  114. /*
  115. * 🔴 同步推进会话元信息(2026-11-07 修复):群会话的标题/预览/时间
  116. * 原来永远停在建群那一刻,导致左栏一直显示「暂无消息」、
  117. * 移动端按更新时间排序时群聊永远沉底。
  118. */
  119. this.sessions.noteMessage(session.id, {
  120. text: body || `[${files.length} 个附件]`,
  121. ts: Date.now(),
  122. fromMe: true,
  123. });
  124. const failed: string[] = [];
  125. const anchors: Record<string, DispatchAnchor> = {};
  126. /*
  127. * ① 先上云盘(2026-11-10 补)。
  128. *
  129. * 群聊此前**完全不上云**:`sendFileToAgent` 只发 Matrix,
  130. * 用户在大群里发的文件换台设备就没了,且没有任何提示。
  131. * 用户裁决「文件都要确保自动上云」,所以这里补一次 S3 双写。
  132. *
  133. * 放在成员循环**外面**:文件只有一份,按成员各传一遍是浪费
  134. * (单聊上传之所以在每个房间发 Matrix,是因为 Matrix 侧必须一人一份)。
  135. */
  136. const cloudUrls: string[] = [];
  137. for (const pf of files) {
  138. try {
  139. const up = await this.chat.uploadToCloud(pf.file);
  140. cloudUrls.push(up.publicUrl);
  141. /*
  142. * 登记到文件台账(2026-11-10):群聊的 threadRoot 用群会话 id,
  143. * 这样群里传的文件也只在**这个群**的右侧栏出现,不串到单聊。
  144. */
  145. try {
  146. await this.chat.registerSessionFile(session.id, pf.file, up, plan.targets[0]);
  147. } catch (e) {
  148. this._error.set(
  149. `「${pf.file.name}」已存入云盘,但未记入群文件列表:${e instanceof Error ? e.message : '登记失败'}`,
  150. );
  151. }
  152. } catch (e) {
  153. this._error.set(
  154. `「${pf.file.name}」未能存入云盘:${e instanceof Error ? e.message : '上传失败'}`,
  155. );
  156. }
  157. }
  158. if (cloudUrls.length > 0) {
  159. this.sessions.addDeliverableUrl(session.id, ...cloudUrls);
  160. }
  161. // 串行发送:并发会触发 connectSession 互相 disconnect
  162. for (const id of plan.targets) {
  163. let baseline: string | null = null;
  164. let roomId: string | undefined;
  165. let lastEventId: string | undefined;
  166. // ① 附件先发(每个文件在该房间各上传一份)
  167. let fileOk = files.length === 0;
  168. for (const pf of files) {
  169. const r = await this.chat.sendFileToAgent(id, pf.file, {
  170. groupId: session.id,
  171. turnId,
  172. targetAgentId: id,
  173. });
  174. if (!r.ok) {
  175. fileOk = false;
  176. break;
  177. }
  178. baseline = r.baselineEventId;
  179. roomId = r.roomId;
  180. lastEventId = r.eventId;
  181. /*
  182. * 用户附件条目只记一次(每个房间都会成功一次,但用户只发了一份)。
  183. * store 侧按 name+size 去重,重复调用无副作用。
  184. */
  185. this.store.appendUserFile(
  186. session.id,
  187. turnId,
  188. {
  189. kind: 'file',
  190. name: pf.file.name,
  191. size: pf.file.size,
  192. mimeType: pf.file.type || 'application/octet-stream',
  193. url: pf.previewUrl ?? '',
  194. },
  195. sentAt,
  196. r.eventId,
  197. );
  198. this._uploadPct.set(
  199. Math.round(((files.indexOf(pf) + 1) / files.length) * 100),
  200. );
  201. }
  202. if (!fileOk) {
  203. failed.push(memberName(id));
  204. continue;
  205. }
  206. // ② 文字
  207. if (body) {
  208. const r = await this.chat.sendToAgent(id, plan.body, {
  209. groupId: session.id,
  210. turnId,
  211. targetAgentId: id,
  212. });
  213. if (!r.ok) {
  214. failed.push(memberName(id));
  215. continue;
  216. }
  217. baseline = r.baselineEventId;
  218. roomId = r.roomId;
  219. lastEventId = r.eventId;
  220. }
  221. if (!roomId) {
  222. failed.push(memberName(id));
  223. continue;
  224. }
  225. /*
  226. * 记录本轮锚点:发送前该房间最后一条事件。
  227. * 补读据此判断「哪些事件是本轮之后的新回复」,不依赖时钟。
  228. */
  229. anchors[id] = { turnId, baselineEventId: baseline, sentAt };
  230. // 供单聊过滤:派活之后的房间级回复不属于任何单聊会话
  231. if (lastEventId) this.chat.noteDispatchAnchor(roomId, lastEventId);
  232. }
  233. this.groupLive.markSent(plan.targets, anchors);
  234. if (failed.length > 0) {
  235. // 发送失败的成员不该再挂着「等你回复」,否则 90 秒后显示「无响应」误导
  236. const failedIds = plan.targets.filter((id) => failed.includes(memberName(id)));
  237. this.groupLive.dropAwaiting(failedIds);
  238. }
  239. // 🔴 发送后重订阅:connectSession 可能重建客户端,旧订阅会失效
  240. await this.groupLive.resubscribe(session);
  241. this.clear();
  242. /*
  243. * 重订阅后从 timeline 补读锚点之后的回复。
  244. * 🔴 不 await:最长跑 135 秒,阻塞会让调用方以为「还没发出去」。
  245. */
  246. const okTargets = plan.targets.filter((id) => !failed.includes(memberName(id)));
  247. void this.groupLive.catchUp(session, okTargets, turnId, anchors).catch(() => {});
  248. return {
  249. ok: failed.length === 0,
  250. failedNames: failed,
  251. error:
  252. failed.length > 0 ? `已发送,但 @${failed.join('、@')} 未送达,请稍后重试` : undefined,
  253. };
  254. } finally {
  255. this._sending.set(false);
  256. this._uploading.set(false);
  257. }
  258. }
  259. }