Procházet zdrojové kódy

feat(group): 群聊历史落 Matrix(方案 A)+ 移动端群聊分组 + 群聊附件上传

方案 B(建真实群房间)实测否决:容器对房间内不带 @ 的消息也会应答,
会导致一条群消息触发 N 个容器同时处理(scripts/probe-group-room.mjs)。

群聊落库(方案 A)
- 各成员房间的派活事件本就带 com.fmode.dispatch{groupId,turnId,targetAgentId},
  新增纯函数按「派活切段」从 N 个房间重建群聊条目,跨房间按 turnId/eventId 去重
- GroupMatrixService:等首次 sync → 串行确保房间 → 读 timeline(缺派活时只翻一轮)
  → 派生 → 与本地合并(同 id 本地优先)→ 写回
- turnId 改为可复现的 me-<毫秒>,不再依赖内存自增序号
- 旧数据用同文本 ±2s 匹配对齐 turnId,避免迁移后重复气泡

群聊附件
- ChatService 抽出 ensureAgentRoomFor、新增 sendFileToAgent;文件事件带 dispatch + session
- 新增 GroupSendService 统一桌面/移动的发送编排(先文件后文字、串行派发、失败目标记录)
- 顺带修复群会话预览/排序永远不更新(补 noteMessage)

移动端
- 消息页新增「群聊」分组(堆叠头像 + 成员数),单聊列表剔除群聊
- 群聊页路由参数改为流式订阅,切群即时刷新并释放上一个群的订阅
- 附件、贴底与桌面同逻辑

修复
- 读历史时不再对每个成员重连(会打断 /sync,导致群历史读不出来)
- 桌面 isImage 补文件名兜底,群聊里的图片不再显示成文件卡
- 评审发现的 5 个严重问题:空 timeline 覆盖、目标不一致串消息、
  文字+附件互相覆盖、blob 地址落盘、客户端不存在时跳连

验证:单测 1247 通过、类型检查、生产构建通过;
真实账号:新设备进入群聊重建 36 条消息/13 个回合,实时收发正常,群内附件单聊不可见。

Co-Authored-By: Claude Code <noreply@anthropic.com>
Huccct před 1 dnem
rodič
revize
0d4557e7fd

+ 75 - 0
docs/00-tracking/操作追踪.md

@@ -6401,3 +6401,78 @@ emoji 工具行豁免「孤立单步内联」规则(始终成组折叠,否
 - **核实修正**:上一轮记的「移动端不列群聊」表述不准 —— `mobile-chat-view` 已有 `groupSessions()`,
   是「消息」页模板没渲染该分组
 - **安全事项**:两份 `docs/03-interface/` 文档含明文 FMODE_SESSION_TOKEN(已在历史中),已列入待排期
+
+## 2026-11-07 · 群聊落 Matrix(方案 A)+ 移动端群聊分组 + 群聊附件上传
+
+### 用户要求
+
+1. 参考单聊逻辑,群聊数据能否存进 Matrix 并在前台加载渲染 —— 用户裁决按方案 A 实现
+2. 移动端需要群聊
+3. 群聊附件上传参考单聊逻辑
+4. 测试与线上效果一致
+
+### 方案 B 实测否决
+
+临时房间 + 两个容器,发一条**不带 @**、正文为「请不要回复」的消息 → 两个容器均在 5 秒内应答。
+容器对房间内任何消息都会作答,群房间方案会导致 N 倍抢答。脚本:`scripts/probe-group-room.mjs`。
+
+### 实现
+
+| 模块 | 文件 | 要点 |
+|---|---|---|
+| 重建纯函数 | `core/group-matrix-transcript.ts`(新) | 「派活切段」:带 `dispatch.groupId===本群` 的事件开段,段内非本人消息为回复;跨房间按 turnId / eventId 去重 |
+| 重建服务 | `core/group-matrix.service.ts`(新) | 等首次 sync → 串行确保房间 → 读 timeline(缺派活时只翻一轮)→ 派生 → 本地优先合并 → 写回 |
+| 存储 | `core/group-store.service.ts` | turnId 改 `me-<毫秒>`(可复现);新增 `appendUserFile` / `replaceFromMatrix`;裁剪只作用于落盘副本 |
+| 发送编排 | `core/group-send.service.ts`(新) | 桌面/移动共用;先文件后文字;补 `sessions.noteMessage`(修复群聊预览/排序永远不更新) |
+| 发送层 | `core/chat.service.ts` | 抽出 `ensureAgentRoomFor`、新增 `sendFileToAgent`(事件带 dispatch + session) |
+| Matrix 客户端 | `core/matrix-client.service.ts` | `sendFile` 支持 extra;新增 `synced()`;sync 完成时放行等待者 |
+| 桌面群聊页 | `desktop/pages/group-chat` | 进群重建历史、切换群释放旧订阅、待发附件条、用户文件卡、历史骨架 |
+| 移动端群聊页 | `mobile/pages/group-chat` | 路由参数改流(切群即时刷新)、切群释放、附件、贴底与桌面同逻辑 |
+| 移动端消息页 | `mobile/pages/messages` | 新增「群聊」分组(堆叠头像 + 成员数),单聊列表剔除群聊 |
+| 未读 | `core/mobile-chat-view.service.ts` | 群聊行未读恒为 0(原按首成员计数会串到单聊) |
+
+### 实测中发现并修复的问题
+
+1. **群历史一条都读不出来**:重建对每个成员调 `connectSession`,token 不同时重建客户端 → `/sync` 被打断 → timeline 为空。
+   改为读历史时 `skipConnect`(复用已连接的客户端)。
+2. **图片被显示成文件卡**:桌面 `isImage` 只测 mimeType,补文件名兜底(与移动端同口径)。
+3. 群聊发送后预览/排序不更新:补 `noteMessage`。
+
+### 验证
+
+| 项 | 结果 |
+|---|---|
+| 类型检查(app + spec) | ✅ |
+| 单元测试 | **1245 SUCCESS**(新增:派生 13 条、存储回归 3 条、发送层 2 条) |
+| 生产构建 | ✅ |
+| **跨设备**(全新浏览器、空 localStorage) | ✅ 进入群聊后 **23 条历史完整呈现**,含最新一轮问答 |
+| 刷新去重 | ✅ 用户消息无重复(仅同一容器不同回合的工具行文本相同,属正常) |
+| 群聊附件 | ✅ 待发条 → 发出 → 用户侧显示文件卡 → 容器收到 `m.image`;**单聊里看不到该附件** |
+| 移动端消息页 | ✅ 「群聊」分组可见,堆叠头像 + 「2 个容器」,可点进群聊页 |
+
+### 未验证 / 已知限制
+
+1. 群历史只保证每房间最近约 50 条 + 一轮分页,不做「加载更早」
+2. 群聊未读不统计;「清空群聊」只对本设备生效
+3. 移动端群聊无右栏;群消息撤回/编辑不做
+4. `agent-lj-test` 容器的开通接口报 `SDK登录失败: Object not found`(平台侧问题,非本次改动);页面会提示「部分成员历史未载入」
+5. 附件测试中曾出现一次「未送达」提示,复跑未复现,暂按瞬时网络/上传失败处理
+
+### 独立评审与修复(2026-11-07)
+
+评审员子代理(独立 prompt,只含技术栈事实 + 检查单 + 被评审物)结论:派生算法本身正确,问题集中在**接缝**——连接生命周期、持久化、并发写。
+
+| 严重度 | 问题 | 修复 |
+|---|---|---|
+| 严重 | `skipConnect` 在客户端不存在时仍跳过连接 → ensureRoom 抛错 → 空结果覆盖群历史 | 仅在已连接时跳过;未连接照常连 |
+| 严重 | `synced()` 超时后仍用空 timeline 覆盖 | 未同步完成直接返回,不写入,不标记 done(下次进入自动重试) |
+| 严重 | 派活 `targetAgentId` 与房间不一致时回复被算进群 | 不一致即切段,不认该段 |
+| 严重 | 「文字 + 附件」同回合时文件事件覆盖用户文字 | 已有文本用户消息时不再覆盖 |
+| 严重 | 附件预览用的是 `blob:` 地址且被写盘,刷新后裂图 | 落盘时清空 `blob:` 地址,刷新后由 Matrix 重建补真实地址 |
+| 重要 | 并发刷新、切群时骨架/警告串台 | 按群记录 refreshing/warning;同群并发复用同一 Promise |
+| 重要 | 派生与写回之间到达的实时回复被丢弃 | 写回前重新读本地再合并 |
+| 建议 | 排序不稳定、死代码、sentAt 取晚 | 稳定 tiebreak;删除死代码;sentAt 提前取 |
+
+新增回归测试 4 条(文字+附件、目标不一致、skipConnect 两种状态、存储去重),单测合计 **1247**。
+
+**评审后真实账号复验**:新设备进入群聊 → 36 条消息 / 13 个回合完整重建、无警告;再发一条 → 正常收到回复、无页面错误。

+ 8 - 3
docs/02-design/plans/2026-11-06-群聊落库与移动端待办.md

@@ -8,7 +8,12 @@
 
 ## 一、▶ 下一步优先事项(用户明确要求)
 
-### P0-1 群聊数据落 Matrix,前台加载渲染
+### P0-1 群聊数据落 Matrix,前台加载渲染 ✅ 已完成(方案 A,2026-11-07)
+
+> 方案 B 已实测否决(容器对房间内任何消息都会应答);详细设计见
+> `2026-11-07-群聊落库详细设计.md`。验收:全新浏览器进入群聊,23 条历史完整呈现。
+
+### P0-1(原始记录)群聊数据落 Matrix,前台加载渲染
 
 **诉求**:参考单聊的做法,让群聊消息存进 Matrix,换设备/清缓存后仍能加载出来。
 
@@ -40,7 +45,7 @@
 
 ---
 
-### P0-2 移动端「消息」页不显示群聊
+### P0-2 移动端「消息」页不显示群聊 ✅ 已完成(2026-11-07)
 
 **现状(已核实)**:`core/mobile-chat-view.service.ts` **已经产出** `groupSessions()` 分组,
 但 `mobile/pages/messages/messages.ts` 的模板只渲染了「工作区」与「全部会话」两种视图,
@@ -55,7 +60,7 @@
 
 ---
 
-### P0-3 群聊附件上传
+### P0-3 群聊附件上传 ✅ 已完成(2026-11-07)
 
 **现状**:桌面群聊页与移动群聊页的 `app-composer` **都没有接 `attach` 事件**,
 `sendToAgent()` 只发文本(`chat.service.ts:839`)。单聊的附件链路是

+ 94 - 0
docs/02-design/plans/2026-11-07-群聊落库详细设计.md

@@ -0,0 +1,94 @@
+# 群聊落 Matrix —— 详细设计(方案 A)
+
+> 上级文档:`2026-11-06-群聊落库与移动端待办.md`(P0-1)
+> 裁决:方案 B(建真实群房间)已实测否决,见下 §一。
+
+## 一、为什么不做方案 B
+
+2026-11-06 实测(`scripts/probe-group-room.mjs`):新建一个临时 Matrix 房间,
+邀请 `lj` 与 `app-lj-test-v1` 两个容器进房,随后发一条**不带 @** 的消息,
+正文写明「请不要回复」。
+
+结果:**两个容器都在 5 秒内应答**(回复内容均为「不回复」)。
+
+推论:容器对所在房间里的**任何**消息都会作答,不区分是否 @ 自己。
+若把群消息再往群房间投递一次,一条群消息会触发 N 个容器同时处理 ——
+浪费额度,且各容器会互相覆盖成果。让容器按 @ 过滤属于容器侧改动,本仓库无法完成。
+
+## 二、方案 A 的数据基础
+
+伪群聊的「派活消息」本来就落在 Matrix 里(`chat.service.ts#sendToAgent`):
+
+```json
+// 每个被 @ 的成员房间各一条,房间级(无 thread),文本相同(已剥离 @)
+{
+  "msgtype": "m.text",
+  "body": "帮我把这周的运营数据整理成一张对比表",
+  "com.fmode.dispatch": { "groupId": "group-<ts>", "turnId": "me-<ts>", "targetAgentId": "agent-lj" }
+}
+```
+
+容器的回复紧跟在派活之后、发在**另一个**成员房间(各自 DM)。
+因此:**只要知道每个成员房间的 timeline,就能把群聊完整重放出来。**
+
+## 三、重建算法(派生)
+
+### 3.1 单房间「派活切段」
+
+```
+按 ts 升序遍历该房间的 m.room.message:
+  A. 带 dispatch 且 groupId === 本群      → 开新段:turnId = dispatch.turnId,
+                                            targets += targetAgentId,用户消息 = body
+  B. 带 dispatch 但 groupId 是别的群      → 结束当前段(别的群的对话)
+  C. 带 sessionId 且 ≠ 当前 turnId        → 结束当前段(单聊附件)
+  D. 段内、非本人发送的消息               → 本 turn 的回复(有 media 则为文件卡)
+  E. 本人发送的非派活消息                 → 跳过(单聊里我自己发的内容不属于群)
+```
+
+> 为什么要「切段」而不是「按时间窗」:同一成员可能在两个群之间来回,
+> 时间窗判不准;而派活事件本身是天然分隔符。
+
+位置校验:用 `timelineIndex(roomId)` 比较 `派活事件下标 < 回复下标`;
+任一方缺失时退化为时间比较(容器离线补发等极端情况)。
+
+### 3.2 跨房间合并
+
+| 内容 | 去重键 | 冲突时 |
+|---|---|---|
+| 用户消息 | `turnId` | 取最早的一条(各房间都有同一份) |
+| 成员回复 | `eventId` | 保留先到 |
+| targets | `turnId` + `agentId` | 并集 |
+
+### 3.3 turnId 必须可复现
+
+原实现 `me-<ts>-<seq>`,`seq` 是**内存自增**——换设备后 seq 不同,重建出的 id 与本地不一致,合并会重复。
+改为 `me-<ts>`(同毫秒冲突追加 `-2`、`-3`)。
+
+对改动前产生的旧数据,用 `matchLocalTurn`(同文本、±2s)把 Matrix 侧条目对齐到本地 turnId。
+
+## 四、权威关系
+
+| 场景 | 规则 |
+|---|---|
+| 进入群聊 | `GroupMatrixService.refresh()` 从 Matrix 重建 → 与本地合并(**同 id 本地优先**)→ 覆盖 store |
+| 发送消息 | 本地先写(乐观回显),同时发 Matrix(派活带 dispatch) |
+| 实时回复 | `GroupLiveService` 照旧订阅各成员房间,写入同一 store |
+| localStorage | 降级为「乐观回显 + 离线缓存」,**不再按 500 条裁剪** |
+| 清空群聊 | 只隐藏本设备(Matrix 事件无法删除),文档披露 |
+
+## 五、边界
+
+| 场景 | 处理 |
+|---|---|
+| 成员不可用(provision 失败,如 `agent-lj-test` 报 `SDK登录失败`) | 跳过该房间,其余照常;页面提示「部分成员历史未载入」 |
+| 只派给了部分成员 | 以实际出现的派活合并 targets,不丢 turn |
+| 同一房间重复派活 | 以最后一条切段 |
+| 同 turn 多文件 | 同一文件名+大小只记一条 |
+| 演示态 | 跳过 refresh(无 Matrix 真源),保持本地行为 |
+
+## 六、已知限制
+
+1. 只保证每房间最近约 50 条(`initialSyncLimit`)+ **一轮**分页,不做「加载更早」
+2. 群聊未读不计(群会话 `agentId` 是首成员,直接计数会串到单聊)
+3. 「清空群聊」只对本设备生效
+4. 移动端群聊无右栏;群消息撤回/编辑不做

+ 163 - 0
scripts/probe-group-room.mjs

@@ -0,0 +1,163 @@
+#!/usr/bin/env node
+/**
+ * 方案 B 可行性实测:容器在「群房间」里会不会对**不带 @ 的消息**自动应答
+ *
+ * 背景:方案 B 想给每个伪群聊建一个真实 Matrix 房间,用户消息也往群里发一份
+ * (只作记录,前台按房间渲染)。风险是容器如果对房间里任何消息都作答,
+ * 就会「抢答」—— 一条群消息会触发 N 份后台任务。
+ *
+ * 用法(需真实账号凭据):
+ *   node scripts/probe-group-room.mjs              建临时房、静默发言、观察应答
+ *   node scripts/probe-group-room.mjs --keep        测完不删房(便于人工看)
+ *
+ * 凭据:读 `~/.fmode/config/user.json` 的 sessionToken(与 fmode-admin 同源)
+ *
+ * 判定:
+ *   · 静默期(NO_MENTION)内两个容器都没回 → 容器按 mention 过滤,方案 B 可行
+ *   · 任一容器回了 → 会抢答,方案 B 需要容器侧调整或退回方案 A
+ */
+import { readFileSync } from 'node:fs';
+import { homedir } from 'node:os';
+import { join } from 'node:path';
+
+const FUNCTIONS = 'https://server.fmode.cn/api/functions';
+const APP_ID = 'ncloudmaster';
+const FN_PROVISION = 'RP5Ht55NeX'; // provision 云函数
+const MEMBERS = ['agent-lj', 'agent-node-app-lj-test-v1'];
+const NO_MENTION_WAIT_MS = 90_000;
+
+function token() {
+  const p = join(homedir(), '.fmode', 'config', 'user.json');
+  try {
+    const t = JSON.parse(readFileSync(p, 'utf8'))?.sessionToken;
+    if (typeof t === 'string' && t.startsWith('r:')) return t;
+  } catch {
+    /* 落到下面的报错 */
+  }
+  throw new Error(`读不到 fmode 凭据:${p}`);
+}
+
+async function provision(t, agentId) {
+  const r = await fetch(FUNCTIONS, {
+    method: 'POST',
+    headers: {
+      'Content-Type': 'application/json',
+      'X-Parse-Application-Id': APP_ID,
+      'X-Parse-Session-Token': t,
+    },
+    body: JSON.stringify({ id: FN_PROVISION, token: t, params: { agentId } }),
+  });
+  const j = await r.json();
+  if (j.code !== 200 || !j.accessToken || !j.userMxId) {
+    throw new Error(`provision 失败(${agentId}):${j.mess ?? j.msg ?? j.code}`);
+  }
+  return j;
+}
+
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+
+async function main() {
+  const keep = process.argv.includes('--keep');
+  const t = token();
+
+  // ① 取自己的 Matrix 凭据(任一容器 provision 都会返回用户自己的身份)
+  const prov = await provision(t, MEMBERS[0]);
+  const mx = await import('matrix-js-sdk');
+  /*
+   * 🔴 不调 startClient(Node 下 SDK 的 sync filter 会拼成 `$userId` 占位符导致 M_BAD_JSON)。
+   *    只用 client 做「建房 / 开房 / 拉时间线」这类一次性请求,不常驻同步。
+   */
+  const client = mx.createClient({ baseUrl: prov.homeserver, accessToken: prov.accessToken, userId: prov.userMxId });
+  console.log('用户身份', prov.userMxId);
+
+  // ② 建临时群房间,把两个容器拉进来
+  const agentMxIds = [];
+  for (const id of MEMBERS) {
+    const p = await provision(t, id);
+    agentMxIds.push({ id, mxId: p.agentMxId, roomId: p.roomId });
+  }
+  const created = await client.createRoom({
+    invite: agentMxIds.map((a) => a.mxId),
+    name: `probe-no-mention-${Date.now()}`,
+    preset: mx.Preset.TrustedPrivateChat,
+  });
+  const roomId = created.room_id;
+  console.log('临时房间', roomId);
+  console.log('成员身份', agentMxIds.map((a) => `${a.id}=${a.mxId}`).join(', '));
+
+  // 等容器进房:直接查房间成员状态(不依赖 sync)
+  const hs = prov.homeserver.replace(/\/$/, '');
+  const auth = { Authorization: `Bearer ${prov.accessToken}` };
+  const membersOf = async (rid) => {
+    const r = await fetch(`${hs}/_matrix/client/v3/rooms/${encodeURIComponent(rid)}/joined_members`, { headers: auth });
+    return (await r.json()).joined ?? {};
+  };
+  for (let i = 0; i < 20; i++) {
+    await sleep(3000);
+    const joined = await membersOf(roomId).catch(() => ({}));
+    if (agentMxIds.every((a) => joined[a.mxId])) break;
+  }
+  {
+    const joined = await membersOf(roomId).catch(() => ({}));
+    console.log(
+      '进房情况',
+      agentMxIds.map((a) => `${a.id}:${joined[a.mxId] ? 'join' : '未进'}`).join(', '),
+    );
+  }
+
+  // ③ 不带 @ 发一句话,观察是否有人应答
+  const sent = await client.sendEvent(roomId, 'm.room.message', {
+    msgtype: 'm.text',
+    body: `[方案B探测 ${new Date().toISOString().slice(11, 19)}] 这是一条不带 @ 的测试消息,请不要回复。`,
+  });
+  const sentAt = Date.now();
+  console.log('已发送(无 @)', sent.event_id);
+  console.log(`观察 ${NO_MENTION_WAIT_MS / 1000}s 内是否有容器应答…`);
+
+  /*
+   * 轮询房间消息端点来「听」回复 —— 不用 sync,避免 SDK 的 filter 问题。
+   * 每 5 秒拉一次最新事件,挑出别人发的 m.room.message。
+   */
+  const replies = [];
+  const seen = new Set();
+  const deadline = Date.now() + NO_MENTION_WAIT_MS;
+  while (Date.now() < deadline) {
+    await sleep(5000);
+    const r = await fetch(
+      `${hs}/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/messages?dir=b&limit=30`,
+      { headers: auth },
+    );
+    const j = await r.json().catch(() => ({}));
+    for (const ev of j.chunk ?? []) {
+      if (ev.type !== 'm.room.message') continue;
+      if (ev.event_id === sent.event_id) continue;
+      if (ev.sender === prov.userMxId) continue;
+      if (seen.has(ev.event_id)) continue;
+      seen.add(ev.event_id);
+      replies.push({ sender: ev.sender, ts: Date.now() - sentAt });
+      console.log(`  ⚠️ ${Math.round((Date.now() - sentAt) / 1000)}s 收到应答 from ${ev.sender}: ${String(ev.content?.body ?? '').slice(0, 60)}`);
+    }
+  }
+
+  console.log('\n===== 结论 =====');
+  if (replies.length === 0) {
+    console.log('✅ 静默期内无应答 → 容器按 mention 过滤,方案 B 不会抢答');
+  } else {
+    console.log(`❌ 收到 ${replies.length} 条应答 → 容器会抢答,方案 B 有风险`);
+    for (const r of replies) console.log(`   - ${Math.round(r.ts / 1000)}s / ${r.sender} / ${r.body}`);
+  }
+
+  if (!keep) {
+    await client.leave(roomId).catch(() => {});
+    console.log('已离开临时房间(容器侧由平台回收成员关系)');
+  } else {
+    console.log('保留房间以便人工查看:', roomId);
+  }
+  client.stopClient();
+  process.exit(0);
+}
+
+main().catch((e) => {
+  console.error('探测失败:', e);
+  process.exit(1);
+});

+ 9 - 4
src/app/components/domain/message-bubble/message-bubble.ts

@@ -71,7 +71,7 @@ import { ToolActivityComponent } from '../tool-activity/tool-activity';
               看不到内容。点击仍然打开全图预览。
               加载失败时退回图标卡片,不出现裂图。
             -->
-            @if (isImage(file.mimeType) && !thumbBroken()) {
+            @if (isImage(file.mimeType, file.name) && !thumbBroken()) {
               <button
                 type="button"
                 class="thumb-card"
@@ -711,10 +711,15 @@ export class MessageBubbleComponent {
   readonly thumbBroken = signal(false);
 
 
-  /** 是否图片(按 mimeType 判断;无 mime 时看扩展名) */
-  isImage(mimeType: string): boolean {
+  /**
+   * 是否图片:mime 优先,**文件名兜底**(2026-11-07 与移动端 `m-msg` 对齐)。
+   *
+   * 群聊里用户发的附件,FileCard 的 mimeType 来自本地 `File.type`,
+   * 某些环境下为空串;只测 mime 会把图片显示成通用文件卡。
+   */
+  isImage(mimeType: string, name?: string): boolean {
     if (mimeType?.startsWith('image/')) return true;
-    return /\.(png|jpe?g|gif|webp|bmp|svg|avif)$/i.test(mimeType ?? '');
+    return /\.(png|jpe?g|gif|webp|bmp|svg|avif)$/i.test(name ?? mimeType ?? '');
   }
 
   readonly fmtSize = fmtSize;

+ 51 - 0
src/app/core/chat.service.spec.ts

@@ -85,6 +85,7 @@ describe('ChatService', () => {
       .and.returnValue(Promise.resolve({ event_id: '$f' })),
     setTyping: jasmine.createSpy('setTyping').and.returnValue(Promise.resolve()),
     paginateBack: jasmine.createSpy('paginateBack').and.returnValue(Promise.resolve(true)),
+    lastMessageEventId: jasmine.createSpy('lastMessageEventId').and.returnValue(null),
   };
 
   const PROV: MatrixProvision = {
@@ -454,6 +455,56 @@ describe('ChatService', () => {
     });
   });
 
+  describe('群聊支持(2026-11-07)', () => {
+    /*
+     * 🔴 回归:群聊重建历史要遍历 N 个成员房间。若每个成员都走
+     *    `connectSession`,token 不同时会 disconnect 重建客户端 →
+     *    正在进行的 `/sync` 被打断,timeline 永远是空的
+     *    (真实账号实测:群历史一条都读不出来)。
+     */
+    it('读历史时:已连接则不重连;未连接仍必须连(评审 2026-11-07)', async () => {
+      realMode();
+      apiStub.provisionMatrix.and.returnValue(Promise.resolve(PROV));
+      mxStub.ensureRoom.and.returnValue(Promise.resolve('!room1:fmode.cn'));
+      mxStub.connectSession.calls.reset();
+
+      // 未连接:skipConnect 也不能跳过,否则 ensureRoom 里 requireClient 会抛
+      (mxStub as unknown as { connected: boolean }).connected = false;
+      await svc.ensureAgentRoomFor('agent-node-lj-test', { skipConnect: true });
+      expect(mxStub.connectSession)
+        .withContext('客户端不存在时必须建立连接')
+        .toHaveBeenCalled();
+
+      // 已连接:跳过重连(重建会打断正在进行的 sync)
+      (mxStub as unknown as { connected: boolean }).connected = true;
+      mxStub.connectSession.calls.reset();
+      await svc.ensureAgentRoomFor('agent-node-lj-test', { skipConnect: true });
+      expect(mxStub.connectSession).not.toHaveBeenCalled();
+    });
+
+    it('群聊发附件:事件带 dispatch 与 session 标记', async () => {
+      realMode();
+      apiStub.provisionMatrix.and.returnValue(Promise.resolve(PROV));
+      mxStub.connectSession.and.returnValue(Promise.resolve());
+      mxStub.ensureRoom.and.returnValue(Promise.resolve('!room1:fmode.cn'));
+      mxStub.lastMessageEventId.and.returnValue('$base');
+      mxStub.sendFile.and.returnValue(Promise.resolve({ event_id: '$file' }));
+
+      const file = new File(['x'], 'a.png', { type: 'image/png' });
+      const r = await svc.sendFileToAgent('agent-node-lj-test', file, {
+        groupId: 'g1',
+        turnId: 'me-1',
+        targetAgentId: 'agent-node-lj-test',
+      });
+
+      expect(r.ok).withContext('发送应成功').toBe(true);
+      const args = mxStub.sendFile.calls.mostRecent().args;
+      const extra = args[5] as Record<string, unknown>;
+      expect((extra['com.fmode.dispatch'] as { groupId: string }).groupId).toBe('g1');
+      expect(extra['com.fmode.session']).toBe('me-1');
+    });
+  });
+
   describe('sendText(AC-04.2 / AC-04.8)', () => {
     beforeEach(async () => {
       realMode();

+ 76 - 8
src/app/core/chat.service.ts

@@ -844,14 +844,9 @@ export class ChatService {
     const body = text.trim();
     if (!body) return { ok: false, baselineEventId: null, error: '空消息' };
     try {
-      const prov = await this.api.provisionMatrix(agentId);
-      const agentMxId = prov.agentMxId ?? '';
-      if (!agentMxId) return { ok: false, baselineEventId: null, error: '容器未接入' };
-      await this.mx.connectSession(prov.accessToken, prov.userMxId ?? '', prov.deviceId);
-      const rid = await this.mx.ensureRoom(agentMxId, {
-        roomId: prov.roomId,
-        agentName: this.live.agentById(agentId)?.name,
-      });
+      const room = await this.ensureAgentRoomFor(agentId);
+      if (!room) return { ok: false, baselineEventId: null, error: '容器未接入' };
+      const rid = room;
       /*
        * 🔴 发送**前**捕获锚点(2026-11-06)。
        *
@@ -877,6 +872,79 @@ export class ChatService {
     }
   }
 
+  /**
+   * 确保「与该容器的 DM 房间」已就绪(provision → 连接 → 房间)。
+   *
+   * 2026-11-07 从 `sendToAgent` 抽出:群聊重建历史、群聊发附件都要用同一条
+   * 路径。**不要另写一份** —— provision 与 connectSession 的幂等语义
+   * (同 token 不重建客户端)是靠 `MatrixClientService` 内部保证的,
+   * 多写一份就会各自踩坑。
+   *
+   * @returns 房间 id;容器未接入或接入失败时 null(调用方决定是否中断)
+   */
+  async ensureAgentRoomFor(agentId: string, opts: { skipConnect?: boolean } = {}): Promise<string | null> {
+    const prov = await this.api.provisionMatrix(agentId);
+    const agentMxId = prov.agentMxId ?? '';
+    if (!agentMxId) return null;
+    /*
+     * 🔴 读历史时不要重连(2026-11-07)。
+     *
+     * `connectSession` 在 token 变化时会 **disconnect 重建客户端**,
+     * 而重建会中断正在进行的 `/sync` —— 群重建要遍历 N 个成员,
+     * 逐个重连的结果是 sync 永远完不成,读出来的 timeline 全是空的。
+     * 读历史只需要客户端已连接(消息收发本来就共用同一个用户身份)。
+     */
+    /*
+     * 🔴 skipConnect 只表示「**已连接时**不要重建」。客户端根本不存在时
+     *    仍然必须连 —— 否则 `ensureRoom` 里的 requireClient 抛错,
+     *    重建拿到一堆空房间,把群历史覆盖成空(评审 2026-11-07 发现)。
+     */
+    if (!opts.skipConnect || !this.mx.connected) {
+      await this.mx.connectSession(prov.accessToken, prov.userMxId ?? '', prov.deviceId);
+    }
+    this.roomAgentMap.set(agentId, prov.roomId ?? '');
+    return this.mx.ensureRoom(agentMxId, {
+      roomId: prov.roomId,
+      agentName: this.live.agentById(agentId)?.name,
+    });
+  }
+
+  /**
+   * 群聊发附件:把一个文件发进**某个**成员的 DM 房间(2026-11-07)。
+   *
+   * 调用方对每个目标成员各调一次(同一份文件会在每个房间各上传一份 ——
+   * Matrix 没有跨房间引用,这是平台约束)。
+   *
+   * 事件里带 `dispatch`(让重建能把它识别成本回合的用户内容)和
+   * `sessionId = turnId`(让单聊过滤规则把它挡在单聊之外)。
+   */
+  async sendFileToAgent(
+    agentId: string,
+    file: File,
+    meta: MxDispatchMeta,
+  ): Promise<DispatchResult> {
+    try {
+      const rid = await this.ensureAgentRoomFor(agentId);
+      if (!rid) return { ok: false, baselineEventId: null, error: '容器未接入' };
+      const baselineEventId = this.mx.lastMessageEventId(rid);
+      const res = await this.mx.sendFile(
+        rid,
+        file,
+        { name: file.name, size: file.size, mimeType: file.type || 'application/octet-stream' },
+        undefined,
+        undefined,
+        { [DISPATCH_KEY]: meta, [SESSION_KEY]: meta.turnId },
+      );
+      return { ok: true, eventId: res.event_id, baselineEventId, roomId: rid };
+    } catch (e) {
+      return {
+        ok: false,
+        baselineEventId: null,
+        error: e instanceof Error ? e.message : '附件发送失败',
+      };
+    }
+  }
+
   async sendText(text: string): Promise<void> {
     const body = text.trim();
     /*

+ 291 - 0
src/app/core/group-matrix-transcript.spec.ts

@@ -0,0 +1,291 @@
+import { MxMessage } from './matrix-client.service';
+import {
+  atFromTurnId,
+  deriveGroupEntries,
+  matchLocalTurn,
+  mergeGroupEntries,
+  turnIdAt,
+} from './group-matrix-transcript';
+import { GroupEntry } from './group-turns';
+
+const GROUP = 'group-1';
+const ME = '@xhvrjtmwqh:fmode.cn';
+const A = '@k0nrevcfik:fmode.cn'; // agent-lj
+const B = '@axmojmfphr:fmode.cn'; // app-lj-test-v1
+
+let seq = 0;
+const msg = (o: Partial<MxMessage> & { ts: number }): MxMessage => ({
+  roomId: '!r',
+  eventId: o.eventId ?? `$e${++seq}`,
+  sender: o.sender ?? A,
+  body: o.body ?? '',
+  kind: 'text',
+  live: false,
+  ...o,
+});
+
+const dispatch = (turnId: string, targetAgentId: string) => ({
+  groupId: GROUP,
+  turnId,
+  targetAgentId,
+});
+
+describe('group-matrix-transcript —— 群聊历史重建', () => {
+  beforeEach(() => {
+    seq = 0;
+  });
+
+  it('单成员:派活 → 回复 → 新派活 → 回复,切成两个回合', () => {
+    const d = deriveGroupEntries(
+      [
+        {
+          agentId: 'lj',
+          messages: [
+            msg({ ts: 1000, sender: ME, body: '第一句', dispatch: dispatch('me-1000', 'lj') }),
+            msg({ ts: 1100, body: '回一' }),
+            msg({ ts: 2000, sender: ME, body: '第二句', dispatch: dispatch('me-2000', 'lj') }),
+            msg({ ts: 2100, body: '回二' }),
+          ],
+        },
+      ],
+      { groupId: GROUP, myUserId: ME },
+    );
+    expect(d.entries.filter((e) => e.fromAgentId === null).length).toBe(2);
+    expect(d.entries.filter((e) => e.fromAgentId === 'lj').length).toBe(2);
+    expect(d.targets['me-1000']).toEqual(['lj']);
+  });
+
+  it('两成员同一回合:用户消息只出现一次,targets 是并集', () => {
+    const d = deriveGroupEntries(
+      [
+        {
+          agentId: 'lj',
+          messages: [
+            msg({ ts: 1000, sender: ME, body: '帮我看下', dispatch: dispatch('me-1000', 'lj') }),
+            msg({ ts: 1200, body: '好的(lj)' }),
+          ],
+        },
+        {
+          agentId: 'app-v1',
+          messages: [
+            msg({ ts: 1004, sender: ME, body: '帮我看下', dispatch: dispatch('me-1000', 'app-v1') }),
+            msg({ ts: 1300, sender: B, body: '好的(v1)' }),
+          ],
+        },
+      ],
+      { groupId: GROUP, myUserId: ME },
+    );
+    expect(d.entries.filter((e) => e.fromAgentId === null).length).toBe(1);
+    expect(d.targets['me-1000']).toEqual(['lj', 'app-v1']);
+    expect(d.entries.filter((e) => e.fromAgentId).map((e) => e.fromAgentId)).toEqual(['lj', 'app-v1']);
+  });
+
+  it('只有部分房间有该回合的派活:不丢回合,targets 以实际为准', () => {
+    const d = deriveGroupEntries(
+      [{ agentId: 'lj', messages: [msg({ ts: 1000, sender: ME, body: 'x', dispatch: dispatch('me-1000', 'lj') })] }],
+      { groupId: GROUP, myUserId: ME },
+    );
+    expect(d.entries.length).toBe(1);
+    expect(d.targets['me-1000']).toEqual(['lj']);
+  });
+
+  it('同一房间重复派活:以最后一条切段,且不重复回复', () => {
+    const d = deriveGroupEntries(
+      [
+        {
+          agentId: 'lj',
+          messages: [
+            msg({ ts: 1000, sender: ME, body: 'x', dispatch: dispatch('me-1000', 'lj') }),
+            msg({ ts: 1100, body: '第一次回复' }),
+            msg({ ts: 1200, sender: ME, body: 'y', dispatch: dispatch('me-1200', 'lj') }),
+            msg({ ts: 1300, body: '第二次回复' }),
+          ],
+        },
+      ],
+      { groupId: GROUP, myUserId: ME },
+    );
+    const reply = d.entries.filter((e) => e.fromAgentId);
+    expect(reply.map((e) => e.turnId)).toEqual(['me-1000', 'me-1200']);
+  });
+
+  it('别的群的派活会把段切断:之后的回复不归到本群', () => {
+    const d = deriveGroupEntries(
+      [
+        {
+          agentId: 'lj',
+          messages: [
+            msg({ ts: 1000, sender: ME, body: 'x', dispatch: dispatch('me-1000', 'lj') }),
+            msg({
+              ts: 1100,
+              sender: ME,
+              body: '别的群',
+              dispatch: { groupId: 'group-2', turnId: 'me-1100', targetAgentId: 'lj' },
+            }),
+            msg({ ts: 1200, body: '别的群的回复' }),
+          ],
+        },
+      ],
+      { groupId: GROUP, myUserId: ME },
+    );
+    expect(d.entries.filter((e) => e.fromAgentId)).toEqual([]);
+  });
+
+  it('单聊附件(带 sessionId)会切断段', () => {
+    const d = deriveGroupEntries(
+      [
+        {
+          agentId: 'lj',
+          messages: [
+            msg({ ts: 1000, sender: ME, body: 'x', dispatch: dispatch('me-1000', 'lj') }),
+            msg({ ts: 1100, sender: ME, body: '图', sessionId: '$approot_other' }),
+            msg({ ts: 1200, body: '单聊里的回复' }),
+          ],
+        },
+      ],
+      { groupId: GROUP, myUserId: ME },
+    );
+    expect(d.entries.filter((e) => e.fromAgentId)).toEqual([]);
+  });
+
+  it('附件回复生成文件卡(text 为空)', () => {
+    const d = deriveGroupEntries(
+      [
+        {
+          agentId: 'lj',
+          messages: [
+            msg({ ts: 1000, sender: ME, body: '出个表', dispatch: dispatch('me-1000', 'lj') }),
+            msg({
+              ts: 1100,
+              body: 'table.html',
+              media: { name: 'table.html', url: 'https://x/t.html', mimeType: 'text/html', size: 1234 },
+            }),
+          ],
+        },
+      ],
+      { groupId: GROUP, myUserId: ME },
+    );
+    const reply = d.entries.find((e) => e.fromAgentId)!;
+    expect(reply.message.file?.name).toBe('table.html');
+    expect(reply.message.text).toBeUndefined();
+  });
+
+  it('用户发附件:成为该回合的用户消息,多个房间的同名附件只记一次', () => {
+    const media = { name: 'a.png', url: 'https://x/a.png', mimeType: 'image/png', size: 10 };
+    const d = deriveGroupEntries(
+      [
+        {
+          agentId: 'lj',
+          messages: [msg({ ts: 1000, sender: ME, body: '', media, dispatch: dispatch('me-1000', 'lj') })],
+        },
+        {
+          agentId: 'app-v1',
+          messages: [msg({ ts: 1002, sender: ME, body: '', media, dispatch: dispatch('me-1000', 'app-v1') })],
+        },
+      ],
+      { groupId: GROUP, myUserId: ME },
+    );
+    const users = d.entries.filter((e) => e.fromAgentId === null);
+    expect(users.length).toBe(1);
+    expect(users[0].message.file?.name).toBe('a.png');
+  });
+
+  it('本人发的非派活消息不进入群聊', () => {
+    const d = deriveGroupEntries(
+      [
+        {
+          agentId: 'lj',
+          messages: [
+            msg({ ts: 1000, sender: ME, body: 'x', dispatch: dispatch('me-1000', 'lj') }),
+            msg({ ts: 1050, sender: ME, body: '我自己又补了一句' }),
+            msg({ ts: 1100, body: '回复' }),
+          ],
+        },
+      ],
+      { groupId: GROUP, myUserId: ME },
+    );
+    expect(d.entries.filter((e) => e.fromAgentId === null).length).toBe(1);
+    expect(d.entries.filter((e) => e.fromAgentId).length).toBe(1);
+  });
+
+  it('位置校验:回复排在派活之前时(离线补发错位)不算本回合', () => {
+    const dispMsg = msg({ ts: 2000, sender: ME, body: 'x', dispatch: dispatch('me-2000', 'lj') });
+    const early = msg({ ts: 2500, body: '时间更晚但下标更早' });
+    const index = new Map<string, number>([
+      [dispMsg.eventId, 5],
+      [early.eventId, 3],
+    ]);
+    const d = deriveGroupEntries([{ agentId: 'lj', messages: [dispMsg, early], index }], {
+      groupId: GROUP,
+      myUserId: ME,
+    });
+    expect(d.entries.filter((e) => e.fromAgentId)).toEqual([]);
+  });
+
+  it('🔴 文字 + 附件同一回合:文件不覆盖文字(评审 2026-11-07)', () => {
+    const media = { name: 'a.png', url: 'https://x/a.png', mimeType: 'image/png', size: 10 };
+    const d = deriveGroupEntries(
+      [
+        {
+          agentId: 'lj',
+          messages: [
+            msg({ ts: 1000, sender: ME, body: '看图', dispatch: dispatch('me-1000', 'lj') }),
+            msg({ ts: 1001, sender: ME, body: '', media, dispatch: dispatch('me-1000', 'lj') }),
+            msg({ ts: 1100, body: '看到了' }),
+          ],
+        },
+      ],
+      { groupId: GROUP, myUserId: ME },
+    );
+    const user = d.entries.find((e) => e.fromAgentId === null)!;
+    expect(user.message.text).withContext('文字不能被文件事件覆盖').toBe('看图');
+    expect(d.entries.filter((e) => e.fromAgentId).length).toBe(1);
+  });
+
+  it('🔴 派活的 targetAgentId 与房间不一致时不认该段(评审 2026-11-07)', () => {
+    const d = deriveGroupEntries(
+      [
+        {
+          agentId: 'lj',
+          messages: [
+            // 发给 app-v1 的派活误出现在 lj 的房间
+            msg({ ts: 1000, sender: ME, body: 'x', dispatch: dispatch('me-1000', 'app-v1') }),
+            msg({ ts: 1100, body: '不该归到群里' }),
+          ],
+        },
+      ],
+      { groupId: GROUP, myUserId: ME },
+    );
+    expect(d.entries).toEqual([]);
+  });
+
+  it('mergeGroupEntries:同 id 本地优先,其余由 Matrix 补齐', () => {
+    const local: GroupEntry[] = [
+      { id: 'me-1000', turnId: 'me-1000', fromAgentId: null, message: { text: '本地版本' } as never, at: 1000 },
+    ];
+    const fromMatrix: GroupEntry[] = [
+      { id: 'me-1000', turnId: 'me-1000', fromAgentId: null, message: { text: 'Matrix 版本' } as never, at: 1000 },
+      { id: 'me-2000', turnId: 'me-2000', fromAgentId: null, message: { text: '只有 Matrix' } as never, at: 2000 },
+    ];
+    const merged = mergeGroupEntries(local, fromMatrix);
+    expect(merged.length).toBe(2);
+    expect(merged[0].message.text).toBe('本地版本');
+  });
+
+  it('matchLocalTurn:窗口内同文本命中,窗口外不命中', () => {
+    const local: GroupEntry[] = [
+      { id: 'me-1000-1', turnId: 'me-1000-1', fromAgentId: null, message: { text: '你好' } as never, at: 1000 },
+    ];
+    expect(matchLocalTurn(local, '你好', 1500)).toBe('me-1000-1');
+    expect(matchLocalTurn(local, '你好', 9000)).toBeNull();
+    expect(matchLocalTurn(local, '别的话', 1000)).toBeNull();
+  });
+
+  it('turnIdAt:同毫秒冲突时追加序号;atFromTurnId 能反解', () => {
+    const a = turnIdAt(1000);
+    expect(a).toBe('me-1000');
+    expect(turnIdAt(1000, new Set([a]))).toBe('me-1000-2');
+    expect(atFromTurnId('me-1000')).toBe(1000);
+    expect(atFromTurnId('me-1000-3')).toBe(1000);
+    expect(atFromTurnId('$approot_x')).toBeNull();
+  });
+});

+ 298 - 0
src/app/core/group-matrix-transcript.ts

@@ -0,0 +1,298 @@
+/**
+ * 群聊历史重建 —— 纯函数层(2026-11-07)
+ *
+ * ## 为什么需要它
+ *
+ * 伪群聊没有自己的 Matrix 房间(见 `2026-11-07-群聊落库详细设计.md`):
+ * 用户发的每条群消息被拆成 N 份,分别发进**各成员的 DM 房间**,并且在这些
+ * 房间级消息的 content 里带 `com.fmode.dispatch = {groupId, turnId, targetAgentId}`。
+ *
+ * 所以群聊的完整历史**本来就在 Matrix 里**,只是分散在 N 个房间、和各自的
+ * 单聊历史混在一起。本文件的唯一职责是:从这些「混在一起的 timeline」里,
+ * 按派活事件切段,还原出「哪句话是谁发的、谁回了什么」。
+ *
+ * ## 为什么不放服务里
+ *
+ * 切段规则是纯逻辑,出错代价最高(串消息、丢消息)。做成纯函数后可以
+ * 用一屏 fixture 直接单测,不需要起 Angular、不需要连 Matrix。
+ */
+import { Message } from './api/models';
+import { GroupEntry } from './group-turns';
+import { MxMessage } from './matrix-client.service';
+
+/** 单个成员房间的输入 */
+export interface RoomInput {
+  /** 该房间对应的成员容器 id(回复归到谁名下) */
+  agentId: string;
+  /** Matrix 房间 id(仅服务层分页/诊断用,派生逻辑不读) */
+  roomId?: string;
+  /** 该房间的消息(时间正序;`timelineToMessages` 的输出即可) */
+  messages: MxMessage[];
+  /**
+   * `eventId → timeline 下标`(`matrix-client.service#timelineIndex`)。
+   *
+   * 用于给「派活 → 回复」加一层**顺序**校验:容器离线补发导致时间错位时,
+   * 仍能判断回复是在派活之后。缺失时退化为时间比较。
+   */
+  index?: ReadonlyMap<string, number>;
+}
+
+export interface DeriveOptions {
+  /** 本群会话 id(`ChatSession.id`) */
+  groupId: string;
+  /** 本人 Matrix id(`@xxx:fmode.cn`);空则不校验发送者 */
+  myUserId: string;
+}
+
+export interface DerivedGroup {
+  /** 全部条目(用户消息 + 成员回复),按时间升序;用户条目跨房间已去重 */
+  entries: GroupEntry[];
+  /** turnId → 本 turn 派给了哪些成员(并集,保持出现顺序) */
+  targets: Record<string, string[]>;
+}
+
+/** 用户消息的 id = turnId 的形态:`me-<毫秒>`,冲突时 `me-<毫秒>-2` */
+export function turnIdAt(at: number, taken: ReadonlySet<string> = new Set()): string {
+  const base = `me-${at}`;
+  if (!taken.has(base)) return base;
+  for (let i = 2; ; i++) {
+    const id = `${base}-${i}`;
+    if (!taken.has(id)) return id;
+  }
+}
+
+/** 由 turnId 反推时间戳;不是本项目的 id 形态返回 null */
+export function atFromTurnId(turnId: string): number | null {
+  const m = /^me-(\d+)(?:-\d+)?$/.exec(turnId);
+  if (!m) return null;
+  const n = Number(m[1]);
+  return Number.isFinite(n) ? n : null;
+}
+
+/**
+ * 把 Matrix 消息归一成回复用的 `Message`(口径与 `GroupLiveService.toMessage` 一致)。
+ *
+ * 有附件时只填 `file`、不填 `text` —— 与单聊一致,避免「文件名 + 卡片」重复显示。
+ */
+export function replyMessage(agentId: string, m: MxMessage): Message {
+  return {
+    id: m.eventId || `${m.ts}-${m.sender}`,
+    conversationId: agentId,
+    sender: 'agent',
+    senderId: m.sender,
+    senderName: m.senderName,
+    kind: m.kind,
+    text: m.media ? undefined : m.body,
+    file: m.media ? { kind: 'file' as const, ...m.media } : undefined,
+    ts: new Date(m.ts).toISOString(),
+    state: 'sent',
+  };
+}
+
+/**
+ * 同时刻条目的稳定排序:用户消息在前、回复在后,同组内按 id。
+ * 🔴 必须反对称(评审 2026-11-07):`a ? -1 : 1` 对两个回复都返回 -1,
+ *    排序结果随引擎实现漂移。
+ */
+function tiebreak(a: GroupEntry, b: GroupEntry): number {
+  const ua = a.fromAgentId === null ? 0 : 1;
+  const ub = b.fromAgentId === null ? 0 : 1;
+  return ua - ub || a.id.localeCompare(b.id);
+}
+
+/** 用户消息(派活)归一为 `Message` */
+export function userMessageOf(turnId: string, text: string, ts: number, file?: Message['file']): Message {
+  return {
+    id: turnId,
+    conversationId: 'group',
+    sender: 'me',
+    kind: 'text',
+    text: file ? undefined : text,
+    file,
+    ts: new Date(ts).toISOString(),
+    state: 'sent',
+  };
+}
+
+/**
+ * 从若干成员房间重建群聊条目。
+ *
+ * 规则(逐房间独立切段,再跨房间合并):
+ *   A. 带 `dispatch` 且 groupId === 本群 → 开新段(用户消息 / 用户附件)
+ *   B. 带 `dispatch` 但属于别的群        → 结束当前段
+ *   C. 带 `sessionId` 且不等于当前 turn  → 结束当前段(单聊附件)
+ *   D. 段内、非本人发送                 → 本 turn 的回复
+ *   E. 本人发送的非派活消息             → 跳过
+ */
+export function deriveGroupEntries(rooms: RoomInput[], opts: DeriveOptions): DerivedGroup {
+  /** turnId → 用户条目(跨房间取最早) */
+  const users = new Map<string, GroupEntry>();
+  /** turnId → 用户附件条目(同 turn 可能有多个文件;按 name+size 去重) */
+  const userFiles = new Map<string, GroupEntry[]>();
+  /** eventId → 回复条目 */
+  const replies = new Map<string, GroupEntry>();
+  const targets: Record<string, string[]> = {};
+
+  const addTarget = (turnId: string, agentId: string): void => {
+    const list = targets[turnId] ?? (targets[turnId] = []);
+    if (!list.includes(agentId)) list.push(agentId);
+  };
+
+  for (const room of rooms) {
+    const list = [...room.messages].sort((a, b) => a.ts - b.ts);
+    const index = room.index;
+    /** 当前所在段 */
+    let seg: { turnId: string; pos: number } | null = null;
+
+    const posOf = (eventId?: string): number => (eventId && index ? (index.get(eventId) ?? -1) : -1);
+    /** 回复是否在段落起点之后:下标都可用时比下标,否则比时间 */
+    const after = (m: MxMessage, ts: number, pos: number): boolean => {
+      const p = posOf(m.eventId);
+      if (p >= 0 && pos >= 0) return p > pos;
+      return m.ts >= ts;
+    };
+
+    for (const m of list) {
+      const d = m.dispatch;
+      if (d && d.groupId === opts.groupId) {
+        /*
+         * 🔴 派活的 targetAgentId 与房间主人不一致(评审 2026-11-07):
+         *    说明这条派活不是发给本房间容器的(脏数据 / 将来的广播),
+         *    本段内的消息不能算作本群回复 —— 直接切段,不认。
+         */
+        if (d.targetAgentId && d.targetAgentId !== room.agentId) {
+          seg = null;
+          continue;
+        }
+        const turnId = d.turnId || m.eventId;
+        seg = { turnId, pos: posOf(m.eventId) };
+        addTarget(turnId, d.targetAgentId || room.agentId);
+        const fromMe = !opts.myUserId || m.sender === opts.myUserId;
+        // 不是本人发的 dispatch(理论上不会出现)——只认派活关系,不当作内容
+        if (!fromMe) continue;
+        if (m.media) {
+          const cards = userFiles.get(turnId) ?? [];
+          const dup = cards.some(
+            (e) => e.message.file?.name === m.media!.name && e.message.file?.size === m.media!.size,
+          );
+          if (!dup) {
+            cards.push({
+              id: `${turnId}#${m.eventId}`,
+              turnId,
+              fromAgentId: null,
+              message: userMessageOf(turnId, m.body || m.media.name, m.ts, {
+                kind: 'file',
+                ...m.media,
+              }),
+              at: m.ts,
+            });
+            userFiles.set(turnId, cards);
+          }
+          /*
+           * 纯附件 turn:用户消息以文件本身为代表。
+           * 🔴 已有文本用户消息时**不能覆盖**(评审 2026-11-07):
+           *    「文字 + 附件」的回合里,文件事件与文本同 turnId,
+           *    覆盖会让本地保存的文字在下一次刷新后消失。
+           */
+          if (!users.has(turnId)) {
+            users.set(turnId, {
+              id: turnId,
+              turnId,
+              fromAgentId: null,
+              message: userMessageOf(turnId, m.body || m.media.name, m.ts, {
+                kind: 'file',
+                ...m.media,
+              }),
+              at: m.ts,
+            });
+          }
+        } else {
+          const cur = users.get(turnId);
+          if (!cur || m.ts < cur.at) {
+            users.set(turnId, {
+              id: turnId,
+              turnId,
+              fromAgentId: null,
+              message: userMessageOf(turnId, m.body, m.ts),
+              at: m.ts,
+            });
+          }
+        }
+        continue;
+      }
+      if (d) {
+        // 别的群的派活:切段
+        seg = null;
+        continue;
+      }
+      if (m.sessionId && (!seg || m.sessionId !== seg.turnId)) {
+        // 单聊附件:切段
+        seg = null;
+        continue;
+      }
+      if (!seg) continue;
+      if (opts.myUserId && m.sender === opts.myUserId) continue;
+      if (!after(m, users.get(seg.turnId)?.at ?? 0, seg.pos)) continue;
+      const id = m.eventId || `${m.ts}-${m.sender}`;
+      if (replies.has(id)) continue;
+      replies.set(id, {
+        id,
+        turnId: seg.turnId,
+        fromAgentId: room.agentId,
+        message: replyMessage(room.agentId, m),
+        at: m.ts,
+      });
+    }
+  }
+
+  const entries: GroupEntry[] = [...users.values(), ...replies.values()];
+  /*
+   * 额外文件条目只在「该回合有多于一个文件」时才追加:
+   * 单文件时用户气泡本身就带 `file`,再追加一条就是重复渲染。
+   */
+  for (const list of userFiles.values()) {
+    if (list.length <= 1) continue;
+    for (const e of list) if (e.id !== e.turnId) entries.push(e);
+  }
+  entries.sort((a, b) => a.at - b.at || tiebreak(a, b));
+  return { entries, targets };
+}
+
+/**
+ * 合并:**同 id 本地优先**,Matrix 补缺,按时间升序。
+ *
+ * 本地优先的理由:本地条目可能带刚发出的乐观状态(sending/failed),
+ * 而 Matrix 侧同一事件是 `sent` —— 以本地为准不会把「发送中」抹成已发送。
+ */
+export function mergeGroupEntries(local: GroupEntry[], fromMatrix: GroupEntry[]): GroupEntry[] {
+  const out = new Map<string, GroupEntry>();
+  for (const e of fromMatrix) out.set(e.id, e);
+  for (const e of local) out.set(e.id, e);
+  return [...out.values()].sort((a, b) => a.at - b.at || tiebreak(a, b));
+}
+
+/**
+ * 在 ±windowMs 内找「同文本」的本地用户条目,返回其 turnId。
+ *
+ * 用于兼容改动前产生的旧 id(`me-<ts>-<seq>`):Matrix 侧派活的
+ * `origin_server_ts` 与本地 `Date.now()` 通常相差几百毫秒,
+ * 窗口内同文本即可认定为同一回合。
+ */
+export function matchLocalTurn(
+  local: GroupEntry[],
+  text: string,
+  at: number,
+  windowMs = 2000,
+): string | null {
+  const t = text.trim();
+  if (!t) return null;
+  let best: { id: string; diff: number } | null = null;
+  for (const e of local) {
+    if (e.fromAgentId !== null) continue;
+    if ((e.message.text ?? '').trim() !== t) continue;
+    const diff = Math.abs(e.at - at);
+    if (diff > windowMs) continue;
+    if (!best || diff < best.diff) best = { id: e.turnId, diff };
+  }
+  return best?.id ?? null;
+}

+ 230 - 0
src/app/core/group-matrix.service.ts

@@ -0,0 +1,230 @@
+import { Injectable, inject, signal } from '@angular/core';
+import { environment } from '../../environments/environment';
+import { ChatService } from './chat.service';
+import { LiveService } from './live.service';
+import { ChatSession } from './chat-session';
+import { GroupEntry } from './group-turns';
+import { GroupStoreService } from './group-store.service';
+import {
+  RoomInput,
+  deriveGroupEntries,
+  matchLocalTurn,
+  mergeGroupEntries,
+} from './group-matrix-transcript';
+import { MatrixClientService } from './matrix-client.service';
+
+/**
+ * 群聊历史的 Matrix 重建(2026-11-07,方案 A)
+ *
+ * 设计见 `docs/02-design/plans/2026-11-07-群聊落库详细设计.md`。
+ *
+ * ## 一句话
+ *
+ * 群聊的每条用户消息,本来就被拆成 N 份发进各成员的 DM 房间,且带
+ * `com.fmode.dispatch`。这个服务把 N 个房间读一遍、按「派活切段」还原成
+ * 群聊条目,再与本地缓存合并(同 id 本地优先),写回 `GroupStoreService`。
+ *
+ * ## 为什么在页面层调而不是定时轮询
+ *
+ * 群聊只在**有人打开**时才有意义;进群时重建一次、之后靠实时流补即可。
+ * 定时轮询 N 个房间会产生持续的网络与 CPU 开销,收益为零。
+ */
+@Injectable({ providedIn: 'root' })
+export class GroupMatrixService {
+  private readonly mx = inject(MatrixClientService);
+  private readonly chat = inject(ChatService);
+  private readonly store = inject(GroupStoreService);
+  private readonly live = inject(LiveService);
+
+  /**
+   * 正在重建的群 id 集合(页面据此显示骨架而不是「空群」)。
+   *
+   * 🔴 按群记录而不是单个 id(评审 2026-11-07):切换群时上一个群的重建
+   *    仍在跑,用单值会被它清空,新群的骨架提前消失 / 警告串台。
+   */
+  private readonly _refreshing = signal<ReadonlySet<string>>(new Set());
+  /** 该群是否正在重建(页面骨架用) */
+  isRefreshing(sid: string): boolean {
+    return this._refreshing().has(sid);
+  }
+
+  /** 每个群最近一次重建的部分失败说明(如「部分成员历史未载入」) */
+  private readonly _warnings = signal<Record<string, string | null>>({});
+  /** 该群的警告文案(无则为 null) */
+  warningOf(sid: string): string | null {
+    return this._warnings()[sid] ?? null;
+  }
+
+  /** 已完成重建的群(同一会话不重复拉;失败时 **不加入**,下次进入自动重试) */
+  private readonly done = new Set<string>();
+
+  /** 在途重建(同一群并发调用复用同一个 Promise,评审 2026-11-07) */
+  private readonly inflight = new Map<string, Promise<void>>();
+
+  /** 强制下次进入时重新拉取(例如用户手动刷新) */
+  invalidate(sessionId?: string): void {
+    if (sessionId) this.done.delete(sessionId);
+    else this.done.clear();
+  }
+
+  /**
+   * 用 Matrix 重建该群并写回 store。
+   *
+   * 幂等:同一会话成功后不再重复(除非 `force`)。
+   */
+  async refresh(session: ChatSession, opts: { force?: boolean } = {}): Promise<void> {
+    const sid = session.id;
+    if (!opts.force && this.done.has(sid)) return;
+    const running = this.inflight.get(sid);
+    if (running) return running;
+    const task = this.refreshOnce(session, opts).finally(() => this.inflight.delete(sid));
+    this.inflight.set(sid, task);
+    return task;
+  }
+
+  private async refreshOnce(session: ChatSession, opts: { force?: boolean }): Promise<void> {
+    const sid = session.id;
+    /*
+     * 演示态没有 Matrix 真源(种子数据不在任何房间里),
+     * 重建只会把本地历史覆盖成空 —— 直接跳过。
+     */
+    if (environment.useMockData) {
+      this.done.add(sid);
+      return;
+    }
+    const members = session.memberIds ?? [];
+    if (members.length === 0) return;
+
+    this._refreshing.update((s) => new Set(s).add(sid));
+    this._warnings.update((m) => ({ ...m, [sid]: null }));
+    try {
+      /*
+       * ① 等初次同步:首次 `/sync` 前房间 timeline 是空的,
+       *    此时读出来会把群判成「没有任何消息」,覆盖掉本地缓存。
+       */
+      const synced = await this.mx.synced();
+      if (!synced) {
+        /*
+         * 🔴 同步没完成就**不能用这次结果覆盖**(评审 2026-11-07):
+         *    此时 timeline 往往为空,覆盖等于把本地历史清掉,
+         *    而且会把这个空结果写进 localStorage。
+         */
+        this._warnings.update((m) => ({ ...m, [sid]: '连接尚未就绪,群历史稍后重试' }));
+        return;
+      }
+
+      /*
+       * ② 串行确保房间就绪。🔴 不能并发:`connectSession` 对不同 token
+       *    会 disconnect 重建客户端,并发时后一个会把前一个的同步打断。
+       */
+      const rooms: RoomInput[] = [];
+      const failed: string[] = [];
+      for (const agentId of members) {
+        try {
+          const rid = await this.chat.ensureAgentRoomFor(agentId, { skipConnect: true });
+          if (!rid) {
+            failed.push(agentId);
+            continue;
+          }
+          rooms.push({ agentId, roomId: rid, messages: [], index: this.mx.timelineIndex(rid) });
+        } catch {
+          failed.push(agentId);
+        }
+      }
+
+      /*
+       * ③ 读 timeline;没读到本群派活的房间**只往前翻一轮**。
+       *    只翻一轮是刻意的:多轮分页会让进群等待时间随历史长度线性增长,
+       *    而群聊的主场景是「最近在聊什么」,不追求考古。
+       */
+      for (let round = 0; round < 2; round++) {
+        let anyMissing = false;
+        for (const room of rooms) {
+          const rid = room.roomId!;
+          const list = this.mx.timelineToMessages(rid);
+          const hasOurs = list.some((m) => m.dispatch?.groupId === sid);
+          if (!hasOurs && round === 0 && this.mx.paginationToken(rid) != null) {
+            anyMissing = true;
+            await this.mx.paginateBack(rid, 50).catch(() => false);
+            continue;
+          }
+          room.messages = this.mx.timelineToMessages(rid);
+          room.index = this.mx.timelineIndex(rid);
+        }
+        if (!anyMissing) break;
+        // 翻页后统一重读
+        for (const room of rooms) {
+          const rid = room.roomId!;
+          room.messages = this.mx.timelineToMessages(rid);
+          room.index = this.mx.timelineIndex(rid);
+        }
+      }
+      // ④ 派生 + 与本地合并(同 id 本地优先)
+      const derived = deriveGroupEntries(rooms, {
+        groupId: sid,
+        myUserId: this.mx.currentUserId(),
+      });
+      const aligned = this.alignTurnIds(sid, derived.entries);
+      /*
+       * 🔴 在写入那一刻重新读本地(评审 2026-11-07):
+       *    派生是异步的,期间实时流可能已经写进新回复;
+       *    用更早的快照覆盖会把它们丢掉。
+       */
+      const merged = mergeGroupEntries(this.store.entriesOf(sid), aligned);
+      this.store.replaceFromMatrix(sid, merged, derived.targets);
+
+      /*
+       * ⑤ 把已回放的事件标记为「已消费」:群聊页重订阅后,`catchUp`
+       *    会从锚点开始扫,不标记的话历史回复会被当成新回复再写一遍
+       *    (虽然有 id 去重,但会白白触发一轮「执行中」状态)。
+       */
+      for (const e of derived.entries) {
+        if (e.fromAgentId) this.store.markConsumed(sid, e.fromAgentId, e.id);
+      }
+
+      if (failed.length > 0) {
+        const names = failed.map((id) => this.live.agentById(id)?.name ?? id).join('、');
+        this._warnings.update((m) => ({ ...m, [sid]: `部分成员历史未载入(${names})` }));
+      }
+      this.done.add(sid);
+    } finally {
+      this._refreshing.update((s) => {
+        const next = new Set(s);
+        next.delete(sid);
+        return next;
+      });
+    }
+  }
+
+  /**
+   * 把 Matrix 侧用户条目的 turnId 对齐到本地旧 id。
+   *
+   * 改动前的本地 id 是 `me-<ts>-<seq>`(序号每次启动从 0 起),
+   * 与 Matrix 派活推导出的 `me-<ts>` 对不上 → 同一个问题会出现两条气泡。
+   * 窗口内同文本即认定为同一回合,回复的 turnId 一起改指向。
+   */
+  private alignTurnIds(sid: string, entries: GroupEntry[]): GroupEntry[] {
+    const local = this.store.entriesOf(sid);
+    if (local.length === 0) return entries;
+    const remap = new Map<string, string>();
+    for (const e of entries) {
+      if (e.fromAgentId !== null) continue;
+      const text = e.message.text ?? '';
+      const hit = matchLocalTurn(local, text, e.at);
+      if (hit && hit !== e.turnId) remap.set(e.turnId, hit);
+    }
+    if (remap.size === 0) return entries;
+    const seen = new Set<string>();
+    const out: GroupEntry[] = [];
+    for (const e of entries) {
+      const turnId = remap.get(e.turnId) ?? e.turnId;
+      // 用户条目被对齐后,与本地已有条目同 id —— 交给 merge 的「本地优先」
+      const item = turnId === e.turnId ? e : { ...e, turnId };
+      const key = item.fromAgentId === null ? item.turnId : item.id;
+      if (seen.has(key)) continue;
+      seen.add(key);
+      out.push(item);
+    }
+    return out;
+  }
+}

+ 232 - 0
src/app/core/group-send.service.ts

@@ -0,0 +1,232 @@
+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 { 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 _sending = signal(false);
+  readonly sending = this._sending.asReadonly();
+
+  /** 暂存文件(不发送);重复选择追加 */
+  stageFiles(files: FileList | File[]): void {
+    const list = Array.from(files);
+    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> = {};
+
+      // 串行发送:并发会触发 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);
+    }
+  }
+}

+ 43 - 2
src/app/core/group-store.service.spec.ts

@@ -104,14 +104,55 @@ describe('GroupStoreService', () => {
     expect(store.entriesOf('g1')[0].message.text).toBe('群一');
   });
 
-  it('超过上限时裁剪最旧的', () => {
+  it('超过上限时只裁剪**落盘副本**,内存表保持完整', () => {
+    /*
+     * 🔴 2026-11-07 语义变更:Matrix 是群历史真源,本地裁剪内存表会导致
+     *    「重建出来又被裁回去」。现在只在 persist 时裁写盘的那份。
+     */
     const t = Date.now();
     for (let i = 0; i < MAX_ENTRIES_PER_GROUP + 10; i++) {
       store.appendMine('g1', `m${i}`, ['a'], t + i);
     }
     const list = store.entriesOf('g1');
-    expect(list.length).toBe(MAX_ENTRIES_PER_GROUP);
+    expect(list.length).toBe(MAX_ENTRIES_PER_GROUP + 10);
     expect(list[list.length - 1].message.text).toBe(`m${MAX_ENTRIES_PER_GROUP + 9}`);
+    // 写盘的那份被裁剪
+    const raw = localStorage.getItem(
+      Object.keys(localStorage).find((k) => k.startsWith('fmode_group_entries_v1_'))!,
+    )!;
+    const saved = JSON.parse(raw).entries.g1.length;
+    expect(saved).toBe(MAX_ENTRIES_PER_GROUP);
+  });
+
+  it('turnId 可复现:同一毫秒只追加序号,不再是内存自增', () => {
+    const t = 1793952987639;
+    const a = store.appendMine('g1', '一', ['a'], t);
+    const b = store.appendMine('g1', '二', ['a'], t);
+    expect(a).toBe(`me-${t}`);
+    expect(b).toBe(`me-${t}-2`);
+  });
+
+  it('Matrix 重建写回:与本地同 id 的条目不会重复', () => {
+    const t = 1793952987639;
+    const id = store.appendMine('g1', '问题', ['a'], t);
+    const matrixEntries = [
+      { id, turnId: id, fromAgentId: null, message: { id, text: '问题' } as never, at: t },
+      { id: '$ev1', turnId: id, fromAgentId: 'a', message: { id: '$ev1' } as never, at: t + 100 },
+    ];
+    store.replaceFromMatrix('g1', [{ ...matrixEntries[0] }], { [id]: ['a'] });
+    store.appendReply('g1', 'a', matrixEntries[1].message, t + 100, id);
+    store.appendReply('g1', 'a', matrixEntries[1].message, t + 100, id);
+    const list = store.entriesOf('g1');
+    expect(list.filter((e) => e.fromAgentId === null).length).toBe(1);
+    expect(list.filter((e) => e.id === '$ev1').length).toBe(1);
+  });
+
+  it('appendUserFile 同名同大小只记一次', () => {
+    const id = store.appendMine('g1', '看图', ['a', 'b'], 1000);
+    const card = { kind: 'file' as const, name: 'a.png', size: 10, mimeType: 'image/png', url: 'blob:x' };
+    store.appendUserFile('g1', id, card, 1000, '$f1');
+    store.appendUserFile('g1', id, card, 1000, '$f2');
+    expect(store.entriesOf('g1').filter((e) => e.message.file).length).toBe(1);
   });
 
   it('clear 清空指定会话', () => {

+ 99 - 4
src/app/core/group-store.service.ts

@@ -1,6 +1,7 @@
 import { computed, inject, Injectable, signal } from '@angular/core';
 import { Message } from './api/models';
 import { GroupEntry } from './group-turns';
+import { turnIdAt } from './group-matrix-transcript';
 import { SessionService } from './session.service';
 
 /**
@@ -98,8 +99,15 @@ export class GroupStoreService {
     targets: string[],
     at = Date.now(),
   ): string {
+    /*
+     * 🔴 id 必须**可复现**(2026-11-07 群聊落 Matrix):`me-<毫秒>`。
+     *
+     * 原实现 `me-<毫秒>-<内存自增序号>`:序号每次启动从 0 开始,
+     * 换设备后同一条消息算出的 id 不同 —— 从 Matrix 重建出来的条目
+     * 与本地条目对不上,合并会变成两条一模一样的气泡。
+     */
     this.seq += 1;
-    const id = `me-${at}-${this.seq}`;
+    const id = turnIdAt(at, new Set(this.entriesOf(sessionId).map((e) => e.id)));
     const entry: GroupEntry = {
       id,
       turnId: id,
@@ -149,6 +157,70 @@ export class GroupStoreService {
     return resolved;
   }
 
+  /**
+   * 记录一次「用户在本回合发的附件」(2026-11-07)。
+   *
+   * 群聊发文件时,每个目标房间各有一条文件事件;这里只记**首条成功的那条**
+   * (按 name+size 去重),用户气泡上显示一份即可。
+   *
+   * @param eventId Matrix 文件事件 id;没有时回落到 turnId
+   */
+  appendUserFile(
+    sessionId: string,
+    turnId: string,
+    file: Message['file'],
+    at: number,
+    eventId?: string,
+  ): void {
+    if (!file) return;
+    const list = this.entriesOf(sessionId);
+    const dup = list.some(
+      (e) => e.message.file?.name === file.name && e.message.file?.size === file.size,
+    );
+    if (dup) return;
+    const entry: GroupEntry = {
+      id: eventId || `${turnId}#file-${at}`,
+      turnId,
+      fromAgentId: null,
+      at,
+      message: {
+        id: eventId || turnId,
+        conversationId: 'group',
+        sender: 'me',
+        kind: 'text',
+        file,
+        ts: new Date(at).toISOString(),
+        state: 'sent',
+      },
+    };
+    this.write(sessionId, [entry]);
+  }
+
+  /**
+   * 用 Matrix 重建结果覆盖某群(2026-11-07)。
+   *
+   * 调用方已完成「本地优先」合并;这里只负责落表 + 并集 targets + 落盘。
+   */
+  replaceFromMatrix(
+    sessionId: string,
+    entries: GroupEntry[],
+    targets: Record<string, string[]>,
+  ): void {
+    this._bySession.update((all) => ({
+      ...all,
+      [sessionId]: [...entries].sort((a, b) => a.at - b.at),
+    }));
+    this._targetsOf.update((m) => {
+      const next = { ...m };
+      for (const [turnId, list] of Object.entries(targets)) {
+        const cur = next[turnId] ?? [];
+        next[turnId] = [...cur, ...list.filter((x) => !cur.includes(x))];
+      }
+      return next;
+    });
+    this.persist();
+  }
+
   /** 清空某群的消息(用户手动清除 / 群解散) */
   clear(sessionId: string): void {
     this._bySession.update((all) => {
@@ -206,8 +278,19 @@ export class GroupStoreService {
   private write(sessionId: string, entries: GroupEntry[]): void {
     this._bySession.update((all) => {
       const cur = all[sessionId] ?? [];
-      const merged = [...cur, ...entries].sort((a, b) => a.at - b.at);
-      return { ...all, [sessionId]: trimByTurn(merged, MAX_ENTRIES_PER_GROUP) };
+      /*
+       * 按 id 去重(2026-11-07):Matrix 重建与实时流可能写同一条事件,
+       * 原来的纯追加会产生重复气泡。
+       */
+      const seen = new Set(cur.map((e) => e.id));
+      const merged = [...cur, ...entries.filter((e) => !seen.has(e.id))].sort(
+        (a, b) => a.at - b.at,
+      );
+      /*
+       * 🔴 不再按回合裁剪内存表:Matrix 才是历史的真源,本地裁掉等于
+       *    「刷新后重建出来又被裁回去」。只在写盘时裁(见 persist)。
+       */
+      return { ...all, [sessionId]: merged };
     });
     this.persist();
   }
@@ -233,7 +316,19 @@ export class GroupStoreService {
     try {
       const payload: Record<string, StoredEntry[]> = {};
       for (const [sid, list] of Object.entries(this._bySession())) {
-        payload[sid] = list.map((e) => ({ ...e, sessionId: sid }));
+        // 只裁**落盘副本**:内存表保持完整,会话内翻看不受影响
+        payload[sid] = trimByTurn(list, MAX_ENTRIES_PER_GROUP).map((e) => ({
+          ...e,
+          sessionId: sid,
+          /*
+           * 🔴 本地预览地址(`blob:`)不能落盘(评审 2026-11-07):
+           *    刷新后 blob 已失效,气泡会显示一张裂图 / 空卡。
+           *    落盘时去掉,刷新后由 Matrix 重建补上真实地址。
+           */
+          message: e.message.file?.url?.startsWith('blob:')
+            ? { ...e.message, file: { ...e.message.file, url: '' } }
+            : e.message,
+        }));
       }
       localStorage.setItem(
         key,

+ 20 - 2
src/app/core/group-turns.ts

@@ -51,6 +51,14 @@ export interface GroupTurn {
   targets: string[];
   /** 用户消息时间(ms)—— 空块是否该放弃的计时起点 */
   userMessageTs: number;
+  /**
+   * 同一回合里用户额外发的附件(2026-11-07)。
+   *
+   * 用户气泡只承载一条消息;群聊里「文字 + 多个文件」会产生多条用户条目,
+   * 第一个是文字(`userMessage`),其余文件收集在这里并列渲染。
+   * 没有时为空 —— 调用方无需判空。
+   */
+  userFiles?: Message[];
   /**
    * 是否已结束:所有目标成员都不再 typing,且静默超过 `quietMs`。
    *
@@ -61,7 +69,7 @@ export interface GroupTurn {
 
 /** 输入:一条群内条目(用户消息或成员回复) */
 export interface GroupEntry {
-  /** 全局唯一 id:用户消息 `me-<seq>`,回复为 Matrix eventId */
+  /** 全局唯一 id:用户消息 `me-<毫秒>`,回复为 Matrix eventId */
   id: string;
   /** 所属回合 = 用户消息 id */
   turnId: string;
@@ -125,7 +133,17 @@ export function buildGroupTurns({
    */
   for (const e of entries) {
     if (e.fromAgentId !== null) continue;
-    if (byTurn.has(e.turnId)) continue; // 重复的用户消息(回显)→ 只认第一条
+    if (byTurn.has(e.turnId)) {
+      /*
+       * 同一回合的第二条用户条目 = 本回合用户额外发的附件(2026-11-07)。
+       * 主气泡只认第一条(文本),附件挂到 userFiles 上并列渲染。
+       */
+      const t = byTurn.get(e.turnId)!;
+      if (e.message.file && e.id !== e.turnId) {
+        (t.userFiles ??= []).push(e.message);
+      }
+      continue; // 重复的用户消息(回显)→ 只认第一条
+    }
     byTurn.set(e.turnId, {
       id: e.turnId,
       userMessage: e.message,

+ 41 - 0
src/app/core/matrix-client.service.ts

@@ -273,6 +273,38 @@ export class MatrixClientService implements OnDestroy {
     return this.initialSyncDone;
   }
 
+  /** 等待初次同步完成的挂起者(sync 完成时统一放行) */
+  private syncWaiters: (() => void)[] = [];
+
+  /**
+   * 等到初次 `/sync` 完成(2026-11-07)。
+   *
+   * 🔴 群聊重建必须先等它:首次同步前各房间 timeline 是空的,
+   *    此时读出来的群历史会被判成「这个群没有任何消息」,覆盖掉本地缓存。
+   * 已同步则立即返回;断线且永不重连时由调用方的超时兜底。
+   */
+  async synced(timeoutMs = 15_000): Promise<boolean> {
+    if (this.initialSyncDone) return true;
+    return new Promise<boolean>((resolve) => {
+      let done = false;
+      const finish = (ok: boolean): void => {
+        if (done) return;
+        done = true;
+        const i = this.syncWaiters.indexOf(wake);
+        if (i >= 0) this.syncWaiters.splice(i, 1);
+        resolve(ok);
+      };
+      const wake = (): void => finish(true);
+      this.syncWaiters.push(wake);
+      setTimeout(() => finish(this.initialSyncDone), timeoutMs);
+    });
+  }
+
+  /** 当前登录的 Matrix 用户 id(实时消息判定「是不是我发的」用) */
+  currentUserId(): string {
+    return this._userId() || this.client?.getUserId() || '';
+  }
+
   /**
    * 全局消息流 —— **全项目唯一一处** `Room.timeline` 订阅。
    *
@@ -376,6 +408,8 @@ export class MatrixClientService implements OnDestroy {
       // 🔴 首个 SYNCING = 初次同步完成 → 之后的消息才算「实时」
       if (state === 'SYNCING') {
         this.initialSyncDone = true;
+        // 放行等待「初次同步完成」的调用方(群聊重建,2026-11-07)
+        for (const w of this.syncWaiters.splice(0)) w();
         /*
          * 🔴 通知「timeline 已就绪」。
          *
@@ -628,6 +662,12 @@ export class MatrixClientService implements OnDestroy {
     card: { name: string; size: number; mimeType: string },
     threadRootId?: string,
     lastEventId?: string,
+    /**
+     * App 私有字段(2026-11-07):群聊发附件时写入 `com.fmode.dispatch`,
+     * 让这条文件事件也能被「派活切段」识别为**用户在本 turn 发的内容**。
+     * 容器会忽略未知字段。
+     */
+    extra?: Record<string, unknown>,
   ): Promise<{ event_id?: string }> {
     const c = this.requireClient();
     const sdk = await this.load();
@@ -647,6 +687,7 @@ export class MatrixClientService implements OnDestroy {
         size: card.size,
       },
       ...(threadRootId ? { 'm.relates_to': buildThreadRelation(threadRootId, lastEventId) } : {}),
+      ...(extra ?? {}),
     };
     return c.sendEvent(roomId, sdk.EventType.RoomMessage, content as never);
   }

+ 5 - 1
src/app/core/mobile-chat-view.service.ts

@@ -144,7 +144,11 @@ export class MobileChatViewService {
     return {
       session: s,
       agent: this.agentMap().get(s.agentId),
-      unread: this.unread.countOf(s.agentId),
+      /*
+       * 群会话的 `agentId` 是**首成员**,直接按它取未读会把该成员的单聊未读
+       * 显示到群聊行上(2026-11-07)。群聊本身还没有未读体系 → 恒为 0。
+       */
+      unread: s.kind === 'group' ? 0 : this.unread.countOf(s.agentId),
     };
   }
 }

+ 171 - 76
src/app/desktop/pages/group-chat/group-chat.ts

@@ -1,11 +1,12 @@
 import {
   ChangeDetectionStrategy,
   Component,
+  effect,
+  inject,
+  untracked,
   ElementRef,
   OnDestroy,
   computed,
-  effect,
-  inject,
   signal,
   viewChild,
 } from '@angular/core';
@@ -18,6 +19,8 @@ import { ChatSessionService } from '../../../core/chat-session.service';
 import { GroupChatService } from '../../../core/group-chat.service';
 import { DispatchAnchor, GroupLiveService } from '../../../core/group-live.service';
 import { GroupStoreService } from '../../../core/group-store.service';
+import { GroupMatrixService } from '../../../core/group-matrix.service';
+import { GroupSendService } from '../../../core/group-send.service';
 import { LiveService } from '../../../core/live.service';
 import { GroupBlock, buildGroupTurns, blockDuration } from '../../../core/group-turns';
 import {
@@ -103,7 +106,21 @@ import { ComposerComponent } from '../../../components/domain/composer/composer'
         </ui-tooltip>
       </header>
 
+      @if (matrix.warningOf(session()?.id ?? ''); as warn) {
+        <div class="hist-warn" role="status">
+          <i class="fa-solid fa-circle-info" aria-hidden="true"></i>
+          {{ warn }}
+        </div>
+      }
       <div class="messages" #scroller (scroll)="onScroll()">
+        <!-- 正在从 Matrix 重建历史:显示骨架,不要闪一下「开始群聊」 -->
+        @if (matrix.isRefreshing(session()?.id ?? '') && turns().length === 0) {
+          <div class="skeleton-hold">
+            @for (i of [1, 2, 3]; track i) {
+              <ui-skeleton variant="rect" height="56px" />
+            }
+          </div>
+        }
         @for (t of turns(); track t.id) {
           <!--
             用户消息复用 app-message-bubble(2026-11-06):
@@ -112,6 +129,15 @@ import { ComposerComponent } from '../../../components/domain/composer/composer'
             日常页也是「同一个组件 + own 标记」,这里对齐。
           -->
           <app-message-bubble [message]="t.userMessage" [own]="true" />
+          <!--
+            本回合用户额外发的附件(2026-11-07):用户气泡只承载一条消息,
+            多个文件时其余文件跟在同一气泡下方(右对齐)。
+          -->
+          @for (f of t.userFiles ?? []; track f.id) {
+            <div class="user-files">
+              <app-message-bubble [message]="f" [own]="true" />
+            </div>
+          }
           <article class="turn">
             <div class="turn-head">
               @if (!t.closed) {
@@ -180,12 +206,49 @@ import { ComposerComponent } from '../../../components/domain/composer/composer'
       }
 
       <div class="composer-hold">
+        <!-- 待发附件条:与日常对话页同一形态(图片缩略卡 / 文件 chip + 移除) -->
+        @if (sender.pendingFiles().length > 0) {
+          <div class="pending-holds" role="list" aria-label="待发送附件">
+            @for (pf of sender.pendingFiles(); track pf.id) {
+              @if (pf.previewUrl) {
+                <div class="pending-card" role="listitem">
+                  <img [src]="pf.previewUrl" [alt]="pf.file.name" class="pending-img" />
+                  <button
+                    type="button"
+                    class="pending-x"
+                    [attr.aria-label]="'移除 ' + pf.file.name"
+                    (click)="sender.unstageFile(pf.id)"
+                  >
+                    <i class="fa-solid fa-xmark" aria-hidden="true"></i>
+                  </button>
+                </div>
+              } @else {
+                <span class="pending-chip" role="listitem">
+                  <i class="fa-solid fa-paperclip" aria-hidden="true"></i>
+                  <span class="pending-name ellipsis" [title]="pf.file.name">{{ pf.file.name }}</span>
+                  <button
+                    type="button"
+                    class="pending-x pending-x--inline"
+                    [attr.aria-label]="'移除 ' + pf.file.name"
+                    (click)="sender.unstageFile(pf.id)"
+                  >
+                    <i class="fa-solid fa-xmark" aria-hidden="true"></i>
+                  </button>
+                </span>
+              }
+            }
+          </div>
+        }
         <app-composer
           [draft]="draft()"
-          [sending]="sending()"
+          [sending]="sender.sending()"
+          [uploading]="sender.uploading()"
+          [pct]="sender.uploadPct()"
+          [hasAttachments]="sender.pendingFiles().length > 0"
           [placeholder]="'发消息,@ 需要响应的容器…'"
           (draftChange)="draft.set($event); sendError.set(null)"
           (send)="send($event)"
+          (attach)="sender.stageFiles($event)"
         />
       </div>
       </div>
@@ -437,6 +500,84 @@ import { ComposerComponent } from '../../../components/domain/composer/composer'
         background: var(--primary-subtle);
         border-color: var(--primary);
       }
+      /* 用户本回合额外附件:与用户气泡同一条右对齐轨道 */
+      .user-files {
+        display: flex;
+        justify-content: flex-end;
+      }
+      /* 历史未完整载入的提示(部分成员不可用):轻提示,不遮挡内容 */
+      .hist-warn {
+        flex: none;
+        display: flex;
+        align-items: center;
+        gap: var(--sp-2);
+        margin: 0 max(var(--sp-4), calc((100% - 768px) / 2)) var(--sp-2);
+        padding: var(--sp-1) var(--sp-3);
+        border-radius: var(--r-md);
+        background: var(--warning-subtle);
+        color: var(--warning);
+        font-size: var(--fs-12);
+      }
+      /* 待发附件条:与日常页同一形态 */
+      .pending-holds {
+        display: flex;
+        flex-wrap: wrap;
+        gap: var(--sp-2);
+        padding: var(--sp-2) 0;
+      }
+      .pending-card {
+        position: relative;
+        width: 132px;
+        height: 96px;
+        border-radius: var(--r-md);
+        overflow: hidden;
+        background: var(--surface-2);
+      }
+      .pending-img {
+        display: block;
+        width: 100%;
+        height: 100%;
+        object-fit: cover;
+      }
+      .pending-chip {
+        display: inline-flex;
+        align-items: center;
+        gap: var(--sp-2);
+        max-width: 220px;
+        padding: var(--sp-1) var(--sp-2);
+        border: 1px solid var(--edge);
+        border-radius: var(--r-md);
+        background: var(--surface-2);
+        font-size: var(--fs-12);
+        color: var(--ink-2);
+      }
+      .pending-name {
+        min-width: 0;
+      }
+      .pending-x {
+        position: absolute;
+        top: 4px;
+        right: 4px;
+        display: grid;
+        place-items: center;
+        width: 20px;
+        height: 20px;
+        border: 0;
+        border-radius: var(--r-tag);
+        background: color-mix(in srgb, var(--canvas) 60%, transparent);
+        color: var(--inverse);
+        cursor: pointer;
+      }
+      .pending-x--inline {
+        position: static;
+        background: transparent;
+        color: var(--faint);
+        border-radius: var(--r-sm);
+      }
+      .pending-x--inline:hover {
+        background: var(--surface-hover);
+        color: var(--ink);
+      }
       /* 与日常页完全一致:无分界线、无底色,输入卡片浮在画布上 */
       .composer-hold {
         flex: none;
@@ -497,6 +638,10 @@ export class DkGroupChatPage implements OnDestroy {
   private readonly groups = inject(GroupChatService);
   private readonly groupLive = inject(GroupLiveService);
   protected readonly store = inject(GroupStoreService);
+  /** 群聊历史 Matrix 重建(2026-11-07) */
+  protected readonly matrix = inject(GroupMatrixService);
+  /** 群聊发送编排(含附件) */
+  protected readonly sender = inject(GroupSendService);
   private readonly live = inject(LiveService);
   private readonly chat = inject(ChatService);
 
@@ -705,10 +850,28 @@ export class DkGroupChatPage implements OnDestroy {
     void this.live.ensureLoaded();
     // 本地没有该群时,去云端补拉一次
     void this.loadGroupSession();
+    /*
+     * 群切换(2026-11-07):组件在 `/group/:id` 之间会被复用。
+     * 切走时要释放上一个群的实时订阅,否则两个群的回复会同时写进来。
+     */
+    let prevGroup: string | null = null;
     effect(() => {
       const s = this.session();
-      if (s) void this.groupLive.subscribe(s);
+      if (!s) return;
+      if (prevGroup && prevGroup !== s.id) {
+        this.groupLive.release(prevGroup);
+        this.sender.clear();
+      }
+      prevGroup = s.id;
+      /*
+       * 先从 Matrix 重建历史(权威),再订阅实时。
+       * 🔴 重建要等初次 sync,必须 untracked 且不 await —— 页面先渲染本地缓存,
+       *    重建完成后 store 变化会自动刷新视图。
+       */
+      untracked(() => void this.matrix.refresh(s));
+      void this.groupLive.subscribe(s);
     });
+    if (typeof window !== 'undefined') this.matrix.invalidate();
     /*
      * 贴底(2026-11-06 修正):与日常页同一逻辑 —— 新内容到达后滚到底。
      *
@@ -850,78 +1013,10 @@ export class DkGroupChatPage implements OnDestroy {
 
   protected async send(text: string): Promise<void> {
     const s = this.session();
-    if (!s || this.sending()) return;
-
-    // 🔴 先校验再发:不 @ 人 / @ 了非成员一律拦下(2026-11-05 用户裁决)
-    const plan = this.groups.plan(text, s);
-    if (!plan.ok) {
-      this.sendError.set(
-        plan.reason === 'invalid-mention'
-          ? `这些名字不在群里:${plan.invalidNames.join('、')}`
-          : plan.reason === 'no-mention'
-            ? '请 @ 要响应的容器'
-            : '请在消息里说明要做什么',
-      );
-      return;
-    }
+    if (!s || this.sender.sending()) return;
     this.sendError.set(null);
-
-    this.sending.set(true);
-    try {
-      // 用户消息先入群聊存储:它是回合锚点,后续回复都挂在它下面
-      const turnId = this.store.appendMine(s.id, text.trim(), plan.targets);
-      // 发送时刻 = 补读的兜底基准(仅当锚点缺失时使用)
-      const sentAt = Date.now();
-
-      // 串行发送:并发会触发 connectSession 互相 disconnect(见移动端同注释)
-      const failed: string[] = [];
-      const anchors: Record<string, DispatchAnchor> = {};
-      for (const id of plan.targets) {
-        const r = await this.chat.sendToAgent(id, plan.body, {
-          groupId: s.id,
-          turnId,
-          targetAgentId: id,
-        });
-        if (!r.ok) {
-          failed.push(this.memberName(id));
-          continue;
-        }
-        /*
-         * 记录本轮锚点:发送前该房间最后一条事件。
-         * 补读据此判断「哪些事件是本轮之后的新回复」,不依赖时钟。
-         */
-        anchors[id] = {
-          turnId,
-          baselineEventId: r.baselineEventId,
-          sentAt,
-        };
-        // 供单聊过滤:派活之后的房间级回复不属于任何单聊会话
-        this.chat.noteDispatchAnchor(r.roomId ?? '', r.eventId);
-      }
-      this.groupLive.markSent(plan.targets, anchors);
-      // 发送失败的成员不该再挂着「等你回复」,否则 90 秒后显示「无响应」误导
-      if (failed.length > 0) {
-        this.groupLive.dropAwaiting(
-          plan.targets.filter((id) => failed.includes(this.memberName(id))),
-        );
-        this.sendError.set(`已发送,但 @${failed.join('、@')} 未送达,请稍后重试`);
-      }
-
-      // 🔴 发送后重订阅:connectSession 可能重建客户端,旧订阅会失效
-      const cur = this.session();
-      if (cur) await this.groupLive.resubscribe(cur);
-      this.draft.set('');
-      /*
-       * 重订阅后从 timeline 补读锚点之后的回复。
-       * 🔴 不 await:最长跑 135 秒,阻塞会让调用方以为「还没发出去」。
-       *    页面销毁后 catchUp 会自己感知退订并停止。
-       */
-      const after = this.session();
-      if (after) {
-        void this.groupLive.catchUp(after, plan.targets, turnId, anchors).catch(() => {});
-      }
-    } finally {
-      this.sending.set(false);
-    }
+    const r = await this.sender.send(s, text, (id) => this.memberName(id));
+    if (r.error) this.sendError.set(r.error);
+    if (r.ok) this.draft.set('');
   }
 }

+ 170 - 101
src/app/mobile/pages/group-chat/group-chat.ts

@@ -7,9 +7,12 @@ import {
   effect,
   inject,
   signal,
+  untracked,
   viewChild,
 } from '@angular/core';
 import { ActivatedRoute, Router } from '@angular/router';
+import { toSignal } from '@angular/core/rxjs-interop';
+import { map } from 'rxjs/operators';
 import { Agent, Message } from '../../../core/api/models';
 import { ChatService } from '../../../core/chat.service';
 import { ChatSessionService } from '../../../core/chat-session.service';
@@ -17,6 +20,8 @@ import { GroupChatService } from '../../../core/group-chat.service';
 import { LiveService } from '../../../core/live.service';
 import { DispatchAnchor, GroupLiveService } from '../../../core/group-live.service';
 import { GroupStoreService } from '../../../core/group-store.service';
+import { GroupMatrixService } from '../../../core/group-matrix.service';
+import { GroupSendService } from '../../../core/group-send.service';
 import { GroupBlock, blockDuration, buildGroupTurns } from '../../../core/group-turns';
 import { MergedMessage, mergeConsecutiveBodies } from '../../../core/message-merge';
 import {
@@ -115,9 +120,27 @@ type Tab = 'turn' | 'members';
           </div>
         }
 
-        <div class="m-chat-body" #scroller>
+        @if (matrix.warningOf(session()?.id ?? ''); as warn) {
+          <div class="m-hist-warn" role="status">{{ warn }}</div>
+        }
+        <div class="m-chat-body" #scroller (scroll)="onScroll()">
+          <!-- 正在从 Matrix 重建历史:显示骨架,不要闪一下「开始群聊」 -->
+          @if (matrix.isRefreshing(session()?.id ?? '') && turns().length === 0) {
+            <div class="m-hist-skel" aria-busy="true">
+              @for (i of [1, 2, 3]; track i) {
+                <div class="m-sk-line"></div>
+              }
+            </div>
+          }
           @for (t of turns(); track t.id) {
-            <div class="m-msg-me">{{ t.userMessage.text }}</div>
+            <!--
+              用户消息复用 app-m-msg(2026-11-07):与单聊同一套渲染,
+              有附件时显示文件卡而不是裸文本。
+            -->
+            <app-m-msg [message]="t.userMessage" />
+            @for (f of t.userFiles ?? []; track f.id) {
+              <app-m-msg [message]="f" />
+            }
 
             <article class="m-turn">
               <!-- 过程折叠:执行中自动展开,完成后收起(Q25-B) -->
@@ -204,13 +227,40 @@ type Tab = 'turn' | 'members';
         }
 
         <div class="m-composer-area">
+          <!-- 待发附件条:与移动端单聊同一形态 -->
+          @if (sender.pendingFiles().length > 0) {
+            <div class="m-pending" role="list" aria-label="待发送附件">
+              @for (pf of sender.pendingFiles(); track pf.id) {
+                <span class="m-pending-chip" role="listitem">
+                  @if (pf.previewUrl) {
+                    <img [src]="pf.previewUrl" [alt]="pf.file.name" class="m-pending-img" />
+                  } @else {
+                    <i class="fa-solid fa-paperclip" aria-hidden="true"></i>
+                  }
+                  <span class="ellipsis">{{ pf.file.name }}</span>
+                  <button
+                    type="button"
+                    class="m-pending-x"
+                    [attr.aria-label]="'移除 ' + pf.file.name"
+                    (click)="sender.unstageFile(pf.id)"
+                  >
+                    <i class="fa-solid fa-xmark" aria-hidden="true"></i>
+                  </button>
+                </span>
+              }
+            </div>
+          }
           <app-m-dock
             [draft]="draft()"
-            [sending]="sending()"
+            [sending]="sender.sending()"
+            [uploading]="sender.uploading()"
+            [pct]="sender.uploadPct()"
+            [hasAttachments]="sender.pendingFiles().length > 0"
             [target]="'群聊'"
             placeholder="发消息,@ 需要响应的容器…"
             (draftChange)="onDraft($event)"
             (send)="send($event)"
+            (attach)="sender.stageFiles($event)"
           />
         </div>
       }
@@ -290,16 +340,63 @@ type Tab = 'turn' | 'members';
         align-items: center;
         gap: 2px;
       }
-      .m-msg-me {
-        align-self: flex-end;
-        max-width: 82%;
-        padding: var(--sp-3) var(--sp-4);
-        border: 1px solid var(--edge);
-        border-radius: var(--r-lg) var(--r-sm) var(--r-lg) var(--r-lg);
+      /* 用户消息由 app-m-msg 渲染(2026-11-07),沿用其自身的己方/对方排布 */
+      /* 历史未完整载入的轻提示 */
+      .m-hist-warn {
+        flex: none;
+        margin: var(--sp-2) var(--mob-gutter) 0;
+        padding: var(--sp-2) var(--sp-3);
+        border-radius: var(--r-md);
+        background: var(--warning-subtle);
+        color: var(--warning);
+        font-size: var(--fs-12);
+      }
+      /* 历史重建中的骨架 */
+      .m-hist-skel {
+        display: grid;
+        gap: var(--sp-3);
+        padding: var(--sp-4) var(--mob-gutter);
+      }
+      .m-sk-line {
+        height: 48px;
+        border-radius: var(--r-md);
         background: var(--surface-2);
-        color: var(--ink);
-        white-space: pre-wrap;
-        word-break: break-word;
+      }
+      /* 待发附件条 */
+      .m-pending {
+        display: flex;
+        flex-wrap: wrap;
+        gap: var(--sp-2);
+        padding: var(--sp-2) var(--mob-gutter) 0;
+      }
+      .m-pending-chip {
+        display: inline-flex;
+        align-items: center;
+        gap: var(--sp-2);
+        max-width: 60vw;
+        padding: var(--sp-1) var(--sp-2);
+        border: 1px solid var(--edge);
+        border-radius: var(--r-md);
+        background: var(--surface);
+        font-size: var(--fs-12);
+        color: var(--ink-2);
+      }
+      .m-pending-img {
+        width: 28px;
+        height: 28px;
+        border-radius: var(--r-sm);
+        object-fit: cover;
+      }
+      .m-pending-x {
+        display: grid;
+        place-items: center;
+        width: 20px;
+        height: 20px;
+        border: 0;
+        border-radius: var(--r-tag);
+        background: transparent;
+        color: var(--faint);
+        font-size: var(--fs-11);
       }
       /* 回合:一次提问的所有输出合成一条(Q12) */
       .m-turn {
@@ -489,12 +586,15 @@ export class MbGroupChatPage implements OnDestroy {
   protected readonly chat = inject(ChatService);
   private readonly groupLive = inject(GroupLiveService);
   protected readonly store = inject(GroupStoreService);
+  /** 群聊历史 Matrix 重建(2026-11-07) */
+  protected readonly matrix = inject(GroupMatrixService);
+  /** 群聊发送编排(含附件) */
+  protected readonly sender = inject(GroupSendService);
 
   private readonly scroller = viewChild<ElementRef<HTMLDivElement>>('scroller');
 
   protected readonly tab = signal<Tab>('turn');
   protected readonly draft = signal('');
-  protected readonly sending = signal(false);
   /** 发送/输入校验的提示(不 @ 人、@ 了非成员) */
   protected readonly sendError = signal<string | null>(null);
   /** 每个回合的展开状态(覆盖默认的「执行中展开」) */
@@ -502,10 +602,18 @@ export class MbGroupChatPage implements OnDestroy {
   /** 时钟:驱动「静默超时」判定,每秒走一格 */
   private readonly now = signal(Date.now());
 
-  protected readonly session = computed(() => {
-    const id = this.route.snapshot.paramMap.get('groupId') ?? '';
-    return this.sessions.byId(id);
-  });
+  /**
+   * 当前群 id —— 订阅路由参数流(2026-11-07 与桌面端对齐)。
+   *
+   * 原来读 `route.snapshot`:从群 A 点到群 B 时组件被复用、构造函数不重跑,
+   * 标题/成员/消息全部停在上一个群。
+   */
+  private readonly groupId = toSignal(
+    this.route.paramMap.pipe(map((p) => p.get('groupId') ?? '')),
+    { initialValue: '' },
+  );
+
+  protected readonly session = computed(() => this.sessions.byId(this.groupId()));
   protected readonly members = computed<Agent[]>(() => {
     const s = this.session();
     return s ? this.groups.membersOf(s) : [];
@@ -593,30 +701,63 @@ export class MbGroupChatPage implements OnDestroy {
      *    深链接直接进群聊页时本地缓存可能是空的 → `session()` 为 undefined → 页面空。
      */
     void this.loadGroupSession();
-    // 订阅群成员的实时消息流(按成员房间,各自独立)
+    /*
+     * 订阅群成员的实时消息流(按成员房间,各自独立)。
+     * 切群(2026-11-07):先释放上一个群,再订阅新群 —— 否则两个群的回复会串。
+     */
+    let prevGroup: string | null = null;
     effect(() => {
       const s = this.session();
-      if (s) void this.groupLive.subscribe(s);
+      if (!s) return;
+      if (prevGroup && prevGroup !== s.id) {
+        this.groupLive.release(prevGroup);
+        this.sender.clear();
+      }
+      prevGroup = s.id;
+      // 先从 Matrix 重建历史(权威),不 await —— 页面先渲染本地缓存
+      untracked(() => void this.matrix.refresh(s));
+      void this.groupLive.subscribe(s);
     });
 
-    // 消息变化时滚到底
+    // 贴底:与桌面端同一套(两帧 + 120ms 兜底;用户上翻时不拽回)
     effect(() => {
       this.messages();
-      queueMicrotask(() => {
-        const el = this.scroller()?.nativeElement;
-        if (el) el.scrollTop = el.scrollHeight;
-      });
+      this.turns();
+      if (this.userScrolledAway) return;
+      this.stickToBottom();
+    });
+  }
+
+  /** 用户是否主动上翻(>160px 视为在看历史) */
+  private userScrolledAway = false;
+
+  protected onScroll(): void {
+    const el = this.scroller()?.nativeElement;
+    if (!el) return;
+    this.userScrolledAway = el.scrollHeight - el.scrollTop - el.clientHeight > 160;
+  }
+
+  private stickToBottom(): void {
+    const toBottom = (): void => {
+      const el = this.scroller()?.nativeElement;
+      if (el) el.scrollTop = el.scrollHeight;
+    };
+    toBottom();
+    requestAnimationFrame(() => {
+      toBottom();
+      setTimeout(toBottom, 120);
     });
   }
 
   ngOnDestroy(): void {
     const s = this.session();
     if (s) this.groupLive.release(s.id);
+    this.sender.clear();
   }
 
   /** 本地没有该群会话时,用候选 agentId 去云端补拉一次 */
   private async loadGroupSession(): Promise<void> {
-    const id = this.route.snapshot.paramMap.get('groupId') ?? '';
+    const id = this.groupId();
     if (!id) return;
     // 等容器列表就绪,才知道该按哪些 agentId 去取
     await this.live.ensureLoaded();
@@ -690,83 +831,11 @@ export class MbGroupChatPage implements OnDestroy {
 
   protected async send(text: string): Promise<void> {
     const s = this.session();
-    if (!s || this.sending()) return;
-
-    // 🔴 先校验再发:不 @ 人 / @ 了非成员一律拦下(2026-11-05 用户裁决)
-    const plan = this.groups.plan(text, s);
-    if (!plan.ok) {
-      this.sendError.set(
-        plan.reason === 'invalid-mention'
-          ? `这些名字不在群里:${plan.invalidNames.join('、')}`
-          : plan.reason === 'no-mention'
-            ? '请 @ 要响应的容器'
-            : '请在消息里说明要做什么',
-      );
-      return;
-    }
+    if (!s || this.sender.sending()) return;
     this.sendError.set(null);
-
-    this.sending.set(true);
-    try {
-      // 用户消息先入群聊存储:它是回合锚点,后续回复都挂在它下面
-      const turnId = this.store.appendMine(s.id, text.trim(), plan.targets);
-      // 发送时刻 = 补读的时间下限(详见 GroupLiveService.catchUp)
-      const sentAt = Date.now();
-
-      /*
-       * 🔴 **串行**发送,不能用 `Promise.allSettled` 并发(2026-11-05)。
-       *
-       * `sendToAgent` 内部会 `connectSession(该容器的 provision token)`,
-       * 而 `connectSession` 在 token 不同时会 `disconnect()` 重建客户端 ——
-       * 并发调用两个容器时,第二个会把第一个的连接拆掉,
-       * 导致先发的那个容器**收不到回复**(实测:消息发出去了,但没有回包)。
-       *
-       * 串行虽然慢一点,但稳定。多个容器的 provision 通常共用同一用户 token,
-       * 第二次会命中幂等短路,不会真的重建。
-       */
-      const failed: string[] = [];
-      const anchors: Record<string, DispatchAnchor> = {};
-      for (const id of plan.targets) {
-        const r = await this.chat.sendToAgent(id, plan.body, {
-          groupId: s.id,
-          turnId,
-          targetAgentId: id,
-        });
-        if (!r.ok) {
-          failed.push(this.memberName(id));
-          continue;
-        }
-        anchors[id] = { turnId, baselineEventId: r.baselineEventId, sentAt };
-        this.chat.noteDispatchAnchor(r.roomId ?? '', r.eventId);
-      }
-      this.groupLive.markSent(plan.targets, anchors);
-      // 发送失败的成员不该再挂着「等你回复」,否则 90 秒后显示「无响应」误导
-      if (failed.length > 0) {
-        this.groupLive.dropAwaiting(
-          plan.targets.filter((id) => failed.includes(this.memberName(id))),
-        );
-        this.sendError.set(`已发送,但 @${failed.join('、@')} 未送达,请稍后重试`);
-      }
-      /*
-       * 🔴 发送后**重新订阅**(2026-11-05,诊断确认必需)。
-       *
-       * `sendToAgent` 内部对每个容器调 `connectSession`,不同 device token 会
-       * `disconnect()` 重建 Matrix 客户端 —— 旧的 `onMessage` 订阅绑在已销毁的
-       * 实例上,从此收不到任何消息。
-       * 诊断实测:一次群聊发送触发 2 次 REBUILD、894 条事件进来但订阅全失效。
-       */
-      const cur = this.session();
-      if (cur) await this.groupLive.resubscribe(cur);
-      this.draft.set('');
-      /*
-       * 重订阅后从 timeline 补读水位之后的回复。
-       * 🔴 不 await:最长 60 秒,阻塞会让调用方以为「还没发出去」。
-       */
-      const after = this.session();
-      if (after) void this.groupLive.catchUp(after, plan.targets, turnId, anchors).catch(() => {});
-    } finally {
-      this.sending.set(false);
-    }
+    const r = await this.sender.send(s, text, (id) => this.memberName(id));
+    if (r.error) this.sendError.set(r.error);
+    if (r.ok) this.draft.set('');
   }
 
   protected pick(c: { id: string; name: string }): void {

+ 72 - 1
src/app/mobile/pages/messages/messages.ts

@@ -136,8 +136,44 @@ type MsgFilter = 'all' | 'unread' | 'workspace';
             icon="fa-solid fa-comments"
           />
         } @else {
+          <!--
+            群聊单独成组(2026-11-07):原来和单聊平铺在一起,只能靠标题区分;
+            这里给堆叠头像 + 成员数,点进去是群聊页。
+          -->
+          @if (groupRows().length > 0) {
+            <section class="m-ws-group" aria-label="群聊">
+              <header class="m-ws-head">
+                <i class="fa-solid fa-users" aria-hidden="true"></i>
+                <span class="m-ws-name">群聊</span>
+                <span class="m-ws-count">{{ groupRows().length }}</span>
+              </header>
+              <ul class="m-list">
+                @for (row of groupRows(); track row.session.id) {
+                  <li>
+                    <button type="button" class="m-row" (click)="openChat(row)" [attr.aria-label]="groupLabel(row)">
+                      <span class="m-avatar-wrap m-avatar-stack" aria-hidden="true">
+                        @for (n of memberNamesOf(row); track n) {
+                          <app-m-avatar [name]="n" />
+                        }
+                      </span>
+                      <span class="m-row-body">
+                        <span class="m-row-t1">{{ row.session.title }}</span>
+                        <span class="m-row-t2">
+                          {{ groupLabel(row) }} · {{ row.session.lastPreview || '暂无消息' }}
+                        </span>
+                      </span>
+                      <span class="m-row-side">
+                        <span class="m-row-time">{{ rel(row) }}</span>
+                      </span>
+                    </button>
+                  </li>
+                }
+              </ul>
+            </section>
+          }
+          @if (dailyRows().length > 0) {
           <ul class="m-list">
-            @for (row of rows(); track row.session.id) {
+            @for (row of dailyRows(); track row.session.id) {
               <li>
                 <button type="button" class="m-row" (click)="openChat(row)">
                   <span class="m-avatar-wrap">
@@ -161,6 +197,7 @@ type MsgFilter = 'all' | 'unread' | 'workspace';
               </li>
             }
           </ul>
+          }
         }
       </div>
     </div>
@@ -308,6 +345,14 @@ type MsgFilter = 'all' | 'unread' | 'workspace';
         position: relative;
         flex: none;
       }
+      /* 群聊行:成员头像叠放,暗示「这是一个群」而不是一个人(2026-11-07) */
+      .m-avatar-stack {
+        display: inline-flex;
+        align-items: center;
+      }
+      .m-avatar-stack > :not(:first-child) {
+        margin-left: -10px;
+      }
       .m-presence {
         position: absolute;
         right: 0;
@@ -431,6 +476,32 @@ export class MbMessagesPage {
 
   protected readonly rows = computed(() => this.view.filter(this.keyword(), this.filter()));
 
+  /** 「全部」视图里的群聊(单独成组,2026-11-07) */
+  protected readonly groupRows = computed(() =>
+    this.rows().filter((r) => r.session.kind === 'group'),
+  );
+
+  /** 「全部」视图里的单聊(群聊已在上方分组,避免重复) */
+  protected readonly dailyRows = computed(() =>
+    this.rows().filter((r) => r.session.kind !== 'group'),
+  );
+
+  /** 群成员名(堆叠头像用;取不到成员时退化为首成员名) */
+  protected memberNamesOf(row: SessionRow): string[] {
+    const ids = row.session.memberIds ?? [];
+    const names = ids
+      .map((id) => this.live.agentById(id)?.name)
+      .filter((n): n is string => !!n)
+      .slice(0, 3);
+    return names.length > 0 ? names : [row.agent?.name ?? '群'];
+  }
+
+  /** 群聊行副标题:成员数(与左栏口径一致) */
+  protected groupLabel(row: SessionRow): string {
+    const n = row.session.memberIds?.length ?? 0;
+    return n > 0 ? `${n} 个容器` : '群聊';
+  }
+
   /** 工作区分组(仅「工作区」筛选时用) */
   protected readonly workspaceGroups = computed(() => this.view.workspaceGroups(this.keyword()));