| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179 |
- 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<string | null>(null);
- readonly dispatching = this._dispatching.asReadonly();
- /** 全部群会话 */
- readonly groups = computed<ChatSession[]>(() =>
- 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<string>();
- 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;
- }
- }
|