| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280 |
- 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<PendingFile[]>([]);
- 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<string | null>(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<GroupSendResult> {
- 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-<at>`,锚点时间若晚于它,补读窗口会偏一点点。
- */
- 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<string, DispatchAnchor> = {};
- /*
- * ① 先上云盘(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);
- }
- }
- }
|