import { computed, inject, Injectable, signal } from '@angular/core'; import { ChatSession } from './chat-session'; import { ChatSessionService } from './chat-session.service'; import { LiveService } from './live.service'; import { Agent } from './api/models'; import { DispatchDecision, MentionMember, findUnknownMentions, mentionedIds, planDispatch, stripMentions, } from './group-mentions'; export type { DispatchDecision } from './group-mentions'; /** * 群聊(伪群聊)—— 2026-11-05 * * ## 为什么叫「伪」 * * 真群聊要求 N 个容器待在**同一个 Matrix 房间**里。但容器侧 * `MATRIX_REQUIRE_MENTION=false`,房间里任何消息所有容器都会抢答; * 且容器名互为前缀(`lj` vs `lj-test`)会导致 @ 误判。 * 2026-10-29 正是因为这两点把真群聊下线了。 * * 伪群聊换了个做法: * * ``` * 用户在群里发:@Nova @Orbit 各自汇报 * → App 拆成「各自汇报」分别发到 Nova 的 DM 和 Orbit 的 DM * → 两个容器在各自房间独立工作 * → App 把两边的回复聚合到群会话展示 * ``` * * ## 收益与代价 * * ✅ 不碰容器:天然不抢答、不回环(每个容器只在自己房间) * ✅ 并发天然隔离:两个容器根本看不到对方 * ❌ 容器之间无法互相 @ 派活(它们不在同一个房间) * * ## 数据模型 * * 仍按真群聊设计(`ChatSession.kind` / `memberIds`)—— * 将来容器侧改好,只换本服务的「分发」实现,UI 与数据都不用动。 */ @Injectable({ providedIn: 'root' }) export class GroupChatService { private readonly sessions = inject(ChatSessionService); private readonly live = inject(LiveService); /** 正在分发的群会话 id(按钮 loading) */ private readonly _dispatching = signal(null); readonly dispatching = this._dispatching.asReadonly(); /** 全部群会话 */ readonly groups = computed(() => this.sessions.all().filter((s) => s.kind === 'group'), ); /** 某群会话的成员容器(按 memberIds 顺序) */ membersOf(session: ChatSession): Agent[] { const ids = session.memberIds ?? []; return ids.map((id) => this.live.agentById(id)).filter((a): a is Agent => !!a); } /** 某群会话的成员名(标题用) */ memberNamesOf(session: ChatSession): string[] { return this.membersOf(session).map((a) => a.name); } /** * 生成群会话标题:成员名拼接(Q6 裁决)。 * * 例:`Nova、Orbit`。超过 3 个成员时显示「Nova、Orbit 等 4 个」。 */ titleFor(memberIds: string[]): string { const names = memberIds .map((id) => this.live.agentById(id)?.name) .filter((n): n is string => !!n); if (names.length === 0) return '群聊'; if (names.length <= 3) return names.join('、'); return `${names.slice(0, 2).join('、')} 等 ${names.length} 个`; } /** * 从消息正文里剥离 @ 片段(Q21 裁决)。 * * 用户在群里打「@Nova @Orbit 各自汇报」,分发时剥掉 @ 名字, * 每个容器只看到「各自汇报」—— 否则它会困惑「你在让我 @ 别人?」 * * 实现已收敛到 `group-mentions.ts`,与 `planDispatch` 共用同一套边界规则。 */ stripMentions(text: string, names: string[]): string { return stripMentions(text, names.map((name) => ({ id: name, name }))); } /** * 判断正文里 @ 了哪些成员。 */ mentionedIds(text: string, memberIds: string[]): string[] { return mentionedIds(text, this.membersOf2(memberIds)); } /** memberId → { id, name }(名字取不到的成员会被过滤,避免空名匹配一切) */ private membersOf2(memberIds: string[]): MentionMember[] { return memberIds .map((id) => ({ id, name: this.live.agentById(id)?.name ?? '' })) .filter((m) => !!m.name); } /** * 把消息分发给被 @ 的成员 —— **纯决策,不做任何兜底群发**。 * * 2026-11-05 用户裁决(本轮): * - 不 @ 任何人 → 拦下,提示「请 @ 要响应的容器」 * - @ 了不在群里的名字 → 拦下,标红该名字 * * 此前的「没 @ 就发给全部成员」已废止 —— 实测它是噪音的最大来源。 */ plan(text: string, session: ChatSession): DispatchDecision { return planDispatch(text, this.membersOf(session).map((a) => ({ id: a.id, name: a.name }))); } /** 正文里 @ 了、但不在群里的名字(页面实时标红用,发送前也能看到) */ unknownMentions(text: string, session: ChatSession): string[] { return findUnknownMentions( text, this.membersOf(session).map((a) => ({ id: a.id, name: a.name })), ); } /** * 创建群会话(本地)。 * * @param memberIds 至少 2 个容器(Q3 裁决) * @throws 成员不足 / 容器不可用时抛错 */ createGroup(memberIds: string[], workspaceId: string | null = null): ChatSession { /* * 🔴 保留用户选择的**原始顺序**(不能用 Set 去重后重排)。 * 用户按「先 A 后 B」选的,标题就该是「A、B」—— 顺序变了会让用户困惑 * 「我是不是选错人了」。 */ const seen = new Set(); const ids = memberIds.filter((id) => { if (!id || seen.has(id)) return false; seen.add(id); return true; }); if (ids.length < 2) throw new Error('群聊至少需要 2 个容器'); const unavailable = ids.filter((id) => { const a = this.live.agentById(id); return !a || !a.matrixReady; }); if (unavailable.length > 0) throw new Error('部分容器未接入,无法加入群聊'); /* * 群会话的房间与线程根:沿用首个成员的房间。 * * 🔴 伪群聊里「群会话」不是一个真实房间 —— 它只是个**展示容器**。 * 真正的收发发生在各成员的 DM 里(见 `plan()`)。 * `roomId` 记首成员房间,只为满足存量代码的必填字段与路由形态。 */ const first = ids[0]; const s = this.sessions.createGroup(`group-${Date.now()}`, ids, this.titleFor(ids), workspaceId); /* * 🔴 建群后**立即落库**(2026-11-05)。 * * 单聊的规则是「首次发消息才 commit」,但群聊的消息走 `sendToAgent` * (按成员分发),**不经过 ChatService 的 commit 链路** —— * 若不在这里落库,群会话永远只存在本地, * 刷新页面时被 `hydrateFromCloud` 的云端结果整体替换掉(实测)。 */ this.sessions.commit(s.id); return s; } }