import { Injectable, inject, signal } from '@angular/core'; import { ChatSessionService } from './chat-session.service'; import { ChatSession } from './chat-session'; import { ChatService, PendingFile } from './chat.service'; import { isOversize, oversizeMessage } from './upload-limits'; import { DispatchAnchor, GroupLiveService } from './group-live.service'; import { GroupChatService } from './group-chat.service'; import { GroupStoreService } from './group-store.service'; export interface GroupSendResult { ok: boolean; /** 没送达的成员名(已 @ 但发送失败) */ failedNames: string[]; /** 给用户看的提示(未 @、@ 了非成员等) */ error?: string; } /** * 群聊发送编排(2026-11-07) * * ## 为什么抽出来 * * 桌面与移动两个群聊页原来各写了一份 70 行的 `send()`,已经开始分叉 * (桌面有错误提示、移动没有)。加上附件后复杂度更高:要按成员逐个 * 先传文件再发文字、逐个记锚点。**只留一份**,两端页面只负责 UI 状态。 * * ## 顺序为什么是「先文件、后文字」 * * 容器把「本轮最后一条用户消息」当作这轮的任务描述。单聊的 * `uploadPending` 也是先传附件再发文字 —— 保持一致,避免容器把附件 * 当成「已经问过、不用再答」。 */ @Injectable({ providedIn: 'root' }) export class GroupSendService { private readonly chat = inject(ChatService); private readonly groups = inject(GroupChatService); private readonly groupLive = inject(GroupLiveService); private readonly store = inject(GroupStoreService); private readonly sessions = inject(ChatSessionService); /** 待发附件(与单聊同一套暂存语义:选完先停着,点发送才真正上传) */ private readonly _pendingFiles = signal([]); readonly pendingFiles = this._pendingFiles.asReadonly(); private readonly _uploading = signal(false); readonly uploading = this._uploading.asReadonly(); private readonly _uploadPct = signal(0); readonly uploadPct = this._uploadPct.asReadonly(); /** 附件问题(超限 / 上云失败),由群聊页展示 */ private readonly _error = signal(null); readonly error = this._error.asReadonly(); private readonly _sending = signal(false); readonly sending = this._sending.asReadonly(); /** 暂存文件(不发送);重复选择追加 */ stageFiles(files: FileList | File[]): void { const all = Array.from(files); if (all.length === 0) return; /* 大小拦截(2026-11-10):与单聊同规则,超限项不进暂存区 */ const list = all.filter((f) => !isOversize(f)); const rejected = all.filter((f) => isOversize(f)); if (rejected.length > 0) { this._error.set(rejected.map(oversizeMessage).join(';')); } if (list.length === 0) return; const added: PendingFile[] = list.map((file, i) => ({ id: `gpf-${Date.now()}-${i}-${Math.random().toString(36).slice(2, 6)}`, file, // 图片给本地预览;释放时机与单聊一致(移除 / 发送完成) previewUrl: file.type.startsWith('image/') ? URL.createObjectURL(file) : undefined, })); this._pendingFiles.update((cur) => [...cur, ...added]); } unstageFile(id: string): void { this._pendingFiles.update((cur) => { const hit = cur.find((f) => f.id === id); if (hit?.previewUrl) URL.revokeObjectURL(hit.previewUrl); return cur.filter((f) => f.id !== id); }); } /** 切群 / 离开页面时清空(并释放预览地址) */ clear(): void { for (const f of this._pendingFiles()) if (f.previewUrl) URL.revokeObjectURL(f.previewUrl); this._pendingFiles.set([]); this._uploadPct.set(0); } /** * 发一轮:用户消息先落库(乐观回显 + 回合锚点),再逐成员派发。 */ async send(session: ChatSession, text: string, memberName: (id: string) => string): Promise { const body = text.trim(); const files = this._pendingFiles(); if (!body && files.length === 0) return { ok: false, failedNames: [] }; if (this._sending()) return { ok: false, failedNames: [] }; // 🔴 先校验再发:不 @ 人 / @ 了非成员一律拦下(2026-11-05 用户裁决;附件同样适用) const plan = this.groups.plan(body, session); if (!plan.ok) { return { ok: false, failedNames: [], error: plan.reason === 'invalid-mention' ? `这些名字不在群里:${plan.invalidNames.join('、')}` : plan.reason === 'no-mention' ? '请 @ 要响应的容器' : '请在消息里说明要做什么', }; } this._sending.set(true); this._uploading.set(files.length > 0); try { // 用户消息先入群聊存储:它是回合锚点,后续回复都挂在它下面 /* * 🔴 sentAt 必须在写条目**之前**取(评审 2026-11-07): * 条目 id 是 `me-`,锚点时间若晚于它,补读窗口会偏一点点。 */ const sentAt = Date.now(); const turnId = this.store.appendMine(session.id, body, plan.targets, sentAt); /* * 🔴 同步推进会话元信息(2026-11-07 修复):群会话的标题/预览/时间 * 原来永远停在建群那一刻,导致左栏一直显示「暂无消息」、 * 移动端按更新时间排序时群聊永远沉底。 */ this.sessions.noteMessage(session.id, { text: body || `[${files.length} 个附件]`, ts: Date.now(), fromMe: true, }); const failed: string[] = []; const anchors: Record = {}; /* * ① 先上云盘(2026-11-10 补)。 * * 群聊此前**完全不上云**:`sendFileToAgent` 只发 Matrix, * 用户在大群里发的文件换台设备就没了,且没有任何提示。 * 用户裁决「文件都要确保自动上云」,所以这里补一次 S3 双写。 * * 放在成员循环**外面**:文件只有一份,按成员各传一遍是浪费 * (单聊上传之所以在每个房间发 Matrix,是因为 Matrix 侧必须一人一份)。 */ const cloudUrls: string[] = []; for (const pf of files) { try { const up = await this.chat.uploadToCloud(pf.file); cloudUrls.push(up.publicUrl); /* * 登记到文件台账(2026-11-10):群聊的 threadRoot 用群会话 id, * 这样群里传的文件也只在**这个群**的右侧栏出现,不串到单聊。 */ try { await this.chat.registerSessionFile(session.id, pf.file, up, plan.targets[0]); } catch (e) { this._error.set( `「${pf.file.name}」已存入云盘,但未记入群文件列表:${e instanceof Error ? e.message : '登记失败'}`, ); } } catch (e) { this._error.set( `「${pf.file.name}」未能存入云盘:${e instanceof Error ? e.message : '上传失败'}`, ); } } if (cloudUrls.length > 0) { this.sessions.addDeliverableUrl(session.id, ...cloudUrls); } // 串行发送:并发会触发 connectSession 互相 disconnect for (const id of plan.targets) { let baseline: string | null = null; let roomId: string | undefined; let lastEventId: string | undefined; // ① 附件先发(每个文件在该房间各上传一份) let fileOk = files.length === 0; for (const pf of files) { const r = await this.chat.sendFileToAgent(id, pf.file, { groupId: session.id, turnId, targetAgentId: id, }); if (!r.ok) { fileOk = false; break; } baseline = r.baselineEventId; roomId = r.roomId; lastEventId = r.eventId; /* * 用户附件条目只记一次(每个房间都会成功一次,但用户只发了一份)。 * store 侧按 name+size 去重,重复调用无副作用。 */ this.store.appendUserFile( session.id, turnId, { kind: 'file', name: pf.file.name, size: pf.file.size, mimeType: pf.file.type || 'application/octet-stream', url: pf.previewUrl ?? '', }, sentAt, r.eventId, ); this._uploadPct.set( Math.round(((files.indexOf(pf) + 1) / files.length) * 100), ); } if (!fileOk) { failed.push(memberName(id)); continue; } // ② 文字 if (body) { const r = await this.chat.sendToAgent(id, plan.body, { groupId: session.id, turnId, targetAgentId: id, }); if (!r.ok) { failed.push(memberName(id)); continue; } baseline = r.baselineEventId; roomId = r.roomId; lastEventId = r.eventId; } if (!roomId) { failed.push(memberName(id)); continue; } /* * 记录本轮锚点:发送前该房间最后一条事件。 * 补读据此判断「哪些事件是本轮之后的新回复」,不依赖时钟。 */ anchors[id] = { turnId, baselineEventId: baseline, sentAt }; // 供单聊过滤:派活之后的房间级回复不属于任何单聊会话 if (lastEventId) this.chat.noteDispatchAnchor(roomId, lastEventId); } this.groupLive.markSent(plan.targets, anchors); if (failed.length > 0) { // 发送失败的成员不该再挂着「等你回复」,否则 90 秒后显示「无响应」误导 const failedIds = plan.targets.filter((id) => failed.includes(memberName(id))); this.groupLive.dropAwaiting(failedIds); } // 🔴 发送后重订阅:connectSession 可能重建客户端,旧订阅会失效 await this.groupLive.resubscribe(session); this.clear(); /* * 重订阅后从 timeline 补读锚点之后的回复。 * 🔴 不 await:最长跑 135 秒,阻塞会让调用方以为「还没发出去」。 */ const okTargets = plan.targets.filter((id) => !failed.includes(memberName(id))); void this.groupLive.catchUp(session, okTargets, turnId, anchors).catch(() => {}); return { ok: failed.length === 0, failedNames: failed, error: failed.length > 0 ? `已发送,但 @${failed.join('、@')} 未送达,请稍后重试` : undefined, }; } finally { this._sending.set(false); this._uploading.set(false); } } }