Переглянути джерело

feat(app): 工作组群聊 — 多容器协作可用

新增
- /desktop/groups:群列表 + 群会话 + 成员栏
- 群内 @ 单个 / @ 多个数字生命(UI 可多选)
- 左侧导航加入「工作组」入口

@ 的正确做法(实机验证)
Matrix 的 mention 是结构化的,只写 @名字 对方收不到信号。
sendMention/sendMentionMany 同时写 body / formatted_body / m.mentions.user_ids。
实测:@ 单个 3 秒响应;@ 两个两个都响应且互不干扰。

修掉的两个问题
- 群发现:用 is_direct 判断会把自建的 1:1 房间误判成群
  (实测列出 4 个,3 个是 DM)→ 改用「成员 ≥ 3」
- 发送者名:容器 Matrix 账号没设 displayName,回落到技术 ID
  → 优先用平台 FmodeAgent.botName

权限发现
用非主人账号在群里发指令,两个容器都拒绝,理由是「只听从主人」。
容器自查后指出该账号已在 MATRIX_ALLOWED_USERS 里 —— 说明拒绝发生在
白名单之上,是容器自身的判断。所以工作组成员目前必须是同一主人名下。

文档:多容器协作-群聊协议与App使用-20261022.md

Co-Authored-By: Claude Code <noreply@anthropic.com>
Huccct 1 тиждень тому
батько
коміт
99645bc3c1

+ 163 - 0
docs/03-interface/多容器协作-群聊协议与App使用-20261022.md

@@ -0,0 +1,163 @@
+# 多容器协作 · 群聊协议与 App 使用说明
+
+| 字段 | 值 |
+|---|---|
+| 日期 | 2026-10-22 |
+| 状态 | 已验证可用的部分已实现;未实现的部分明确标注 |
+| 依据 | 2026-10-21 ~ 22 的两轮 P0 实机验证 |
+
+---
+
+## 一、已验证的能力(都是实机跑出来的)
+
+| 能力 | 验证方式 | 结果 |
+|---|---|---|
+| 群里 @ 单个 agent | 发送结构化 mention | ✅ **3 秒响应** |
+| 群里 @ 多个 agent | 一条消息 @ 两个 | ✅ 两个都响应、互不干扰 |
+| 容器自动进群 | 建群时邀请 | ✅ 日志 `invited → joining → joined` |
+| 非主人指挥 | 用别的账号发指令 | ✅ **被拒绝**(见 §四) |
+| App 内 @ 并发送 | Playwright 点选 UI | ✅ 端到端通 |
+
+---
+
+## 二、App 内怎么用
+
+### 入口
+
+左侧导航 → **工作组** → 选择群
+
+### 发消息
+
+| 方式 | 效果 |
+|---|---|
+| 直接输入发送 | 群里所有 agent **都可能**响应(取决于 mention 配置)|
+| 点 `@` 按钮 → 选成员 → 发送 | **只有被 @ 的 agent 响应** ← 推荐 |
+| 点 `@` → 选多个 | 多个 agent **同时**收到指令 |
+
+### 界面说明
+
+| 区域 | 内容 |
+|---|---|
+| 左栏 | 群列表(只显示真正的群:成员 ≥ 3)|
+| 中间 | 群消息,**每条上方显示是谁发的**(`lj` / `lj-test`)|
+| 右栏 | 成员列表(含运行状态:执行中 / 空闲)|
+| 底部 | `@` 选择器 + 输入框 |
+
+---
+
+## 三、@ 的正确做法(关键)
+
+### 🔴 只写 `@名字` 是不够的
+
+Matrix 的 mention 是**结构化**的。只把 `@lj-test` 写进文本,对方**收不到 mention 信号**。
+
+必须在消息里同时带:
+
+```json
+{
+  "msgtype": "m.text",
+  "body": "@lj-test 请检查登录页",
+  "format": "org.matrix.custom.html",
+  "formatted_body": "<a href=\"https://matrix.to/#/@xxx:fmode.cn\">@lj-test</a> 请检查登录页",
+  "m.mentions": { "user_ids": ["@xxx:fmode.cn"] }
+}
+```
+
+三个字段缺一不可:
+
+| 字段 | 作用 |
+|---|---|
+| `body` | 纯文本兜底(老客户端显示) |
+| `formatted_body` | 富文本,可点击的 @ 链接 |
+| **`m.mentions.user_ids`** | **真正让对方识别「在叫我」的字段** |
+
+App 侧已封装:`MatrixClientService.sendMention()` / `sendMentionMany()`。
+
+### 为什么要 @ 才回
+
+容器的 `MATRIX_REQUIRE_MENTION=true`(默认):
+**群房间里不 @ 它,它就不回。**
+
+这是好事 —— 否则群里每句话所有 agent 都抢着回,立刻变成噪音。
+
+---
+
+## 四、权限模型(重要发现)
+
+### 容器**只认自己的主人**
+
+实测:用另一个账号在群里发「各自回复 MULTI-OK」,两个容器**都拒绝了**:
+
+> 「这条消息来自群里的另一位成员,不是我的主人。我只听从主人授权范围内的指令,不替其他人在群里传话。」
+
+### 这不是配置拦的
+
+容器自己查过 `.env` 后指出:`@k0nrevcfik` **已经在 `MATRIX_ALLOWED_USERS` 白名单里**,
+所以拒绝发生在**比白名单更上层**的判断 —— 容器对「谁有权指挥我」有独立判断。
+
+### 对多容器协作的影响
+
+```
+同一个主人的多个容器  → 可以互相协作 ✅
+不同主人的容器        → 一方拒绝另一方 ❌
+```
+
+**所以工作组的成员目前必须是同一主人名下的容器。**
+跨主人协作需要「主人授权」机制,属于未实现部分(见 §六)。
+
+---
+
+## 五、数据与实现
+
+### App 侧
+
+| 文件 | 职责 |
+|---|---|
+| `core/groups.service.ts` | 群列表、成员映射、发送(含 mention)|
+| `desktop/pages/groups/groups.ts` | 群聊页面 |
+| `core/matrix-client.service.ts` | `listGroupRooms()` / `sendMention()` / `sendMentionMany()` |
+
+### 群房间的识别
+
+```
+成员数 ≥ 3  → 群
+成员数 = 2  → DM
+```
+
+🔴 **不要用 `is_direct` 标记判断**:实测由主人自己建的 1:1 房间
+`getDMInviter()` 返回 `null`,会被误判成群(曾把 3 个 DM 当成了群)。
+
+### 成员名解析优先级
+
+```
+平台数据(FmodeAgent.botName)  →  优先,如 `lj-test`
+Matrix displayName              →  次选
+Matrix ID 本地部分              →  兜底(如 `zgdcmgnqcd`,用户看不懂)
+```
+
+实测容器的 Matrix 账号**没设 displayName**,如果只用 Matrix 数据,
+用户看到的是一串技术 ID。
+
+---
+
+## 六、未实现的部分
+
+| 项 | 现状 | 需要什么 |
+|---|---|---|
+| **在 App 里建群** | ❌ 只能通过 Matrix 客户端建 | 云函数 `fmode-workgroup`(服务端建房,见设计文档 §三)|
+| 服务端回环判定 | ⚠️ 仅前端 | 容器不看界面,需服务端告诉它该不该回 |
+| 跨主人协作 | ❌ 容器会拒绝 | 「主人授权某群成员」机制 |
+| 群成员变更 | ❌ | 同上,需服务端 |
+| 群消息分页 | ⚠️ 一次全读 | 消息量大时需要 |
+
+---
+
+## 七、故障速查
+
+| 现象 | 原因 | 解法 |
+|---|---|---|
+| @ 了但不回 | 没用结构化 mention | 用 `m.mentions`(§三)|
+| agent 在群里但列表看不到 | 成员映射不到平台账号 | 检查 `channels.matrixConfig.userId` |
+| 显示的是 `k0nrevcfik` 这类名字 | 平台数据没匹配上 | 检查该 agent 的 `channels.matrixConfig.userId` |
+| 两个 agent 疯狂互回 | 自动循环 | 已内置回环检测(连续+重复)|
+| 非主人发的指令被拒 | 容器自身权限判断 | 正常行为,不是 bug |

+ 16 - 0
src/app/components/domain/message-bubble/message-bubble.ts

@@ -28,6 +28,14 @@ import { ToolActivityComponent } from '../tool-activity/tool-activity';
     } @else {
       <div class="bubble-row" [class.own]="own()">
         <div class="bubble-col">
+          <!--
+            群聊:显示「是谁说的」。
+            DM 里对方只有一个,名字在头部已经写了,这里不重复。
+            由调用方用 showSender 控制是否显示。
+          -->
+          @if (showSender() && !own() && message().senderName) {
+            <span class="sender-name">{{ message().senderName }}</span>
+          }
           @if (message().file; as file) {
             <button type="button" class="file-card" (click)="preview.emit(file!)">
               <span class="file-icon" aria-hidden="true">
@@ -97,6 +105,12 @@ import { ToolActivityComponent } from '../tool-activity/tool-activity';
       .bubble-row.own {
         justify-content: flex-end;
       }
+      .sender-name {
+        padding-left: var(--sp-1);
+        font-size: var(--fs-11);
+        font-weight: 600;
+        color: var(--muted);
+      }
       .bubble-col {
         display: grid;
         gap: var(--sp-1);
@@ -253,6 +267,8 @@ import { ToolActivityComponent } from '../tool-activity/tool-activity';
 export class MessageBubbleComponent {
   readonly message = input.required<Message>();
   readonly own = input(false);
+  /** 是否显示发送者名(群聊为 true,DM 为 false) */
+  readonly showSender = input(false);
 
   readonly retry = output<string>();
   readonly openTask = output<string>();

+ 31 - 2
src/app/core/api/models.ts

@@ -141,11 +141,32 @@ export interface Agent {
 
 // ---------- 会话与消息 ----------
 
+/**
+ * 会话。
+ *
+ * ## 两种形态(2026-10-21 新增群聊)
+ *
+ * | kind | 说明 | 用到哪些字段 |
+ * |---|---|---|
+ * | `dm` | 用户 ↔ 单个数字生命 | `agentId` / `peer` |
+ * | `group` | 一个房间里有多个数字生命 | `members` / `name` |
+ *
+ * 🔴 群聊里**同一个房间里会有多个 agent 发言**,所以消息必须能标出「是谁说的」。
+ *    此前消息只分 `me` / `agent`,无法区分多个 agent(见 `Message.senderId`)。
+ */
 export interface Conversation {
   /** Matrix roomId(演示态为合成 id) */
   id: string;
-  agentId: string;
-  peer: Agent;
+  /** 会话形态;缺省视为 dm(兼容旧数据) */
+  kind?: 'dm' | 'group';
+  /** dm:对方的 agentId。群聊时为空 */
+  agentId?: string;
+  /** dm:对方 */
+  peer?: Agent;
+  /** group:群名称 */
+  name?: string;
+  /** group:房间内的 agent 成员(不含自己) */
+  members?: Agent[];
   lastMessagePreview?: string;
   lastMessageAt?: string;
   unread: number;
@@ -166,7 +187,15 @@ export interface FileCard {
 export interface Message {
   id: string;
   conversationId: string;
+  /**
+   * 发送方类别。
+   * 🔴 `agent` 不区分具体是哪个 agent —— 群聊里要结合 `senderId` / `senderName` 才能区分。
+   */
   sender: 'me' | 'agent';
+  /** 群聊:具体发送者的 Matrix ID(dm 下可省略) */
+  senderId?: string;
+  /** 群聊:展示用名字(agent 的 botName,或人类昵称) */
+  senderName?: string;
   kind: MessageKind;
   text?: string;
   file?: FileCard;

+ 99 - 0
src/app/core/groups.service.spec.ts

@@ -0,0 +1,99 @@
+import { TestBed } from '@angular/core/testing';
+import { Agent } from './api/models';
+import { FmodeApi } from './api/fmode-api';
+import { ChatService } from './chat.service';
+import { GroupsService } from './groups.service';
+import { LiveService } from './live.service';
+import { MatrixClientService } from './matrix-client.service';
+import { SessionService } from './session.service';
+
+/**
+ * 群聊成员映射。
+ *
+ * 核心回归:**群里的 agent 要能映射回平台 Agent**,
+ * 否则 UI 只能显示 Matrix ID(`k0nrevcfik`),用户不知道是谁。
+ */
+describe('GroupsService', () => {
+  const mxStub = {
+    listGroupRooms: jasmine.createSpy('listGroupRooms'),
+    storeVersion: jasmine.createSpy('storeVersion').and.returnValue(0),
+    syncState: jasmine.createSpy('syncState').and.returnValue('connected'),
+    sendText: jasmine.createSpy('sendText').and.returnValue(Promise.resolve({})),
+    sendMention: jasmine.createSpy('sendMention').and.returnValue(Promise.resolve({})),
+    sendMentionMany: jasmine.createSpy('sendMentionMany').and.returnValue(Promise.resolve({})),
+  };
+
+  let svc: GroupsService;
+  let live: LiveService;
+
+  function agent(id: string, mx?: string): Agent {
+    return {
+      id,
+      name: id,
+      status: 'idle',
+      matrixReady: true,
+      unread: 0,
+      channels: mx ? { matrix: true, matrixConfig: { userId: mx } } : undefined,
+    };
+  }
+
+  beforeEach(() => {
+    TestBed.configureTestingModule({
+      providers: [
+        GroupsService,
+        LiveService,
+        { provide: MatrixClientService, useValue: mxStub },
+        { provide: ChatService, useValue: { agentId: () => null, provisionInfo: null } },
+        { provide: SessionService, useValue: { me: () => null } },
+        // LiveService 注入 FmodeApi 取数;本测试直接操作其信号
+        { provide: FmodeApi, useValue: { fetchAgents: () => Promise.resolve([]) } },
+      ],
+    });
+    svc = TestBed.inject(GroupsService);
+    live = TestBed.inject(LiveService);
+  });
+
+  it('群成员 → 映射回平台 Agent', () => {
+    mxStub.listGroupRooms.and.returnValue([
+      { roomId: '!r1', name: '测试群', memberIds: ['@me:x', '@a:x', '@b:x'] },
+    ]);
+    (live as unknown as { _agents: { set(v: Agent[]): void } })._agents.set([
+      agent('lj', '@a:x'),
+      agent('lj-test', '@b:x'),
+    ]);
+    svc.refresh();
+
+    const members = (svc as unknown as { membersOf: () => (id: string) => Agent[] }).membersOf();
+    const list = members('!r1');
+    expect(list.map((a) => a.id)).toEqual(['lj', 'lj-test']);
+  });
+
+  it('🔴 找不到对应 agent 时不影响其他成员(不抛错、不返回 undefined)', () => {
+    mxStub.listGroupRooms.and.returnValue([
+      { roomId: '!r1', name: 't', memberIds: ['@me:x', '@unknown:x', '@a:x'] },
+    ]);
+    (live as unknown as { _agents: { set(v: Agent[]): void } })._agents.set([agent('lj', '@a:x')]);
+    svc.refresh();
+
+    const members = (svc as unknown as { membersOf: () => (id: string) => Agent[] }).membersOf();
+    expect(members('!r1').map((a) => a.id)).toEqual(['lj']);
+  });
+
+  it('未知房间 → 空数组(不是抛错)', () => {
+    const members = (svc as unknown as { membersOf: () => (id: string) => Agent[] }).membersOf();
+    expect(members('!nope')).toEqual([]);
+  });
+
+  it('只给有 Matrix 身份的 agent 建映射(没有的不进表)', () => {
+    mxStub.listGroupRooms.and.returnValue([
+      { roomId: '!r1', name: 't', memberIds: ['@a:x', '@none:x'] },
+    ]);
+    (live as unknown as { _agents: { set(v: Agent[]): void } })._agents.set([
+      agent('lj', '@a:x'),
+      agent('无绑定'),
+    ]);
+    svc.refresh();
+    const members = (svc as unknown as { membersOf: () => (id: string) => Agent[] }).membersOf();
+    expect(members('!r1').map((a) => a.id)).toEqual(['lj']);
+  });
+});

+ 132 - 0
src/app/core/groups.service.ts

@@ -0,0 +1,132 @@
+import { computed, effect, inject, Injectable, signal } from '@angular/core';
+import { Agent } from './api/models';
+import { ChatService } from './chat.service';
+import { LiveService } from './live.service';
+import { MatrixClientService } from './matrix-client.service';
+import { SessionService } from './session.service';
+
+/**
+ * 群聊(工作组会话)—— 管理「我参加了哪些群」以及群里的角色映射。
+ *
+ * ## 为什么需要这一层
+ *
+ * DM 会话里对方只有一个,`ChatService` 用「一个会话 = 一个 agent」就能跑通。
+ * 群聊里**同一个房间会有多个 agent 发言**,必须能回答:
+ *
+ * ```
+ * 这条消息是谁发的?
+ * 它是哪个 agent(要能点进它的详情/看它的状态)?
+ * 我要 @ 谁?
+ * ```
+ *
+ * 所以群聊不是「把多个 agent 塞进 DM」,而是**独立的会话形态**。
+ *
+ * ## 房间从哪里来(P0 已实测)
+ *
+ * ```
+ * 用户在 App/Element 里建群 → 邀请 agent
+ *   → 容器收到 invite **自动 join**(矩阵 SDK 已有的行为)
+ *   → 本服务从已 join 的房间中筛出「成员 > 2」的,作为群
+ * ```
+ *
+ * 🔴 建群动作本身不在这里 —— 它属于服务端(`fmode-workgroup`),
+ *    因为容器账号密码可推导,不能让客户端任意拉人(见设计文档 §三)。
+ */
+@Injectable({ providedIn: 'root' })
+export class GroupsService {
+  private readonly mx = inject(MatrixClientService);
+  private readonly live = inject(LiveService);
+  private readonly chat = inject(ChatService);
+  private readonly session = inject(SessionService);
+
+  /** 群列表(房间维度) */
+  private readonly _rooms = signal<{ roomId: string; name: string; memberIds: string[] }[]>([]);
+  readonly rooms = this._rooms.asReadonly();
+
+  /** 当前打开的群 */
+  private readonly _activeRoomId = signal<string | null>(null);
+  readonly activeRoomId = this._activeRoomId.asReadonly();
+
+  /**
+   * 群 → 成员 agent 映射。
+   *
+   * Matrix 只知道成员的 `@xxx:fmode.cn`,要映射回平台上的 agent,
+   * 需要 `FmodeMatrixBinding.agentMxId` 的对应关系(由 LiveService 的 channels 提供)。
+   */
+  readonly membersOf = computed(() => {
+    const agents = this.live.agents();
+    const mxToAgent = new Map<string, Agent>();
+    for (const a of agents) {
+      const mx = a.channels?.matrixConfig?.userId;
+      if (mx) mxToAgent.set(mx, a);
+    }
+    return (roomId: string): Agent[] => {
+      const room = this._rooms().find((r) => r.roomId === roomId);
+      if (!room) return [];
+      return room.memberIds
+        .map((id) => mxToAgent.get(id))
+        .filter((a): a is Agent => !!a && a.id !== this.chat.agentId());
+    };
+  });
+
+  constructor() {
+    /*
+     * 自动刷新:每次 sync 落地后重扫一次群列表。
+     *
+     * 依赖 `storeVersion`(MatrixClientService 每次 SYNCING 自增)——
+     * 否则新建/加入的群要等下次手动刷新才出现(实测:页面打开时列表为空,
+     * 因为 refresh() 从未被调用)。
+     */
+    effect(() => {
+      this.mx.storeVersion();
+      // 未连接时不扫,避免把空列表当成「没有群」
+      if (this.mx.syncState() !== 'connected') return;
+      this.refresh();
+    });
+  }
+
+  /** 刷新群列表(登录后 / 同步到新房间时调用) */
+  refresh(): void {
+    const rooms = this.mx.listGroupRooms();
+    this._rooms.set(rooms);
+  }
+
+  enter(roomId: string): void {
+    this._activeRoomId.set(roomId);
+  }
+
+  leave(): void {
+    this._activeRoomId.set(null);
+  }
+
+  /**
+   * 群消息 → 带上发送者名与所属 agent。
+   *
+   * dm 会话由 `ChatService` 处理;群聊走这里。
+   */
+  sendText(roomId: string, text: string): Promise<void> {
+    return this.mx.sendText(roomId, text).then(() => undefined);
+  }
+
+  /**
+   * @ 某个 agent(群内的单点指挥)。
+   *
+   * 🔴 必须带上 Matrix 的 `m.mentions` 结构 —— 光在文本里写 `@名字`
+   *    **不会**触发对方的通知与识别。P0 实测:只有真正的 mention 结构
+   *    才能让容器确认「这是在叫我」。
+   */
+  mentionIn(roomId: string, agent: Agent, text: string): Promise<void> {
+    const mx = agent.channels?.matrixConfig?.userId;
+    if (!mx) return Promise.resolve();
+    return this.mx.sendMention(roomId, mx, text).then(() => undefined);
+  }
+
+  /** 同时 @ 多个 agent(群内协作) */
+  mentionMany(roomId: string, agents: Agent[], text: string): Promise<void> {
+    const ids = agents
+      .map((a) => a.channels?.matrixConfig?.userId)
+      .filter((x): x is string => !!x);
+    if (ids.length === 0) return Promise.resolve();
+    return this.mx.sendMentionMany(roomId, ids, text).then(() => undefined);
+  }
+}

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

@@ -32,6 +32,8 @@ export interface MxMessage {
   roomId: string;
   eventId: string;
   sender: string;
+  /** 发送者显示名(群聊需要区分多个 agent;取不到时回落为 Matrix ID 的本地部分) */
+  senderName?: string;
   body: string;
   ts: number;
   kind: 'text' | 'notice' | 'emote';
@@ -56,6 +58,33 @@ function kindOf(msgtype: string | undefined): MxMessage['kind'] {
   return 'text';
 }
 
+/**
+ * 发送者的展示名。
+ *
+ * 优先用房间成员里的 displayName(群聊里每个 agent 都设了名字,如 `lj-test`);
+ * 取不到时回落到 Matrix ID 的本地部分(`@zgdcmgnqcd:fmode.cn` → `zgdcmgnqcd`),
+ * **不要**直接显示完整 ID —— 太长且对用户没有意义。
+ */
+function senderDisplayName(room: { getMember(id: string): { name?: string } | null } | undefined, sender: string): string {
+  try {
+    const m = room?.getMember(sender);
+    if (m?.name) return m.name;
+  } catch {
+    /* 房间还没就绪时 getMember 可能抛错 */
+  }
+  const local = sender.replace(/^@/, '').split(':')[0];
+  return local || sender;
+}
+
+/** HTML 转义(formatted_body 里插入用户内容必须先转义) */
+function escapeHtml(s: string): string {
+  return s
+    .replace(/&/g, '&amp;')
+    .replace(/</g, '&lt;')
+    .replace(/>/g, '&gt;')
+    .replace(/"/g, '&quot;');
+}
+
 const TEXT_MSGTYPES = ['m.text', 'm.notice', 'm.emote'];
 
 @Injectable({ providedIn: 'root' })
@@ -292,6 +321,57 @@ export class MatrixClientService implements OnDestroy {
     });
   }
 
+  /**
+   * 发一条 **@ 指定成员** 的消息。
+   *
+   * ## 🔴 为什么不能只在文本里写「@名字」
+   *
+   * Matrix 的 mention 是**结构化**的:消息内容里要有
+   * `m.mentions: { user_ids: [...] }`,客户端/机器人据此判断「这条是不是在叫我」。
+   * 只写文本 `@lj-test 你好` 对方**收不到任何 mention 信号**。
+   *
+   * P0 实测:容器靠 mention 判断是否需要回应(`MATRIX_REQUIRE_MENTION=true`),
+   * 所以这里必须同时做三件事:
+   *   ① 正文里插入可读的 `@名字`
+   *   ② `format: org.matrix.custom.html` + `formatted_body` 里放真正的链接
+   *   ③ `m.mentions.user_ids` 列出被 @ 的人
+   */
+  async sendMention(
+    roomId: string,
+    userId: string,
+    text: string,
+    displayName?: string,
+  ): Promise<{ event_id?: string }> {
+    return this.sendMentionMany(roomId, [userId], text, displayName ? [displayName] : undefined);
+  }
+
+  /** 同时 @ 多个成员(群内协作) */
+  async sendMentionMany(
+    roomId: string,
+    userIds: string[],
+    text: string,
+    displayNames?: string[],
+  ): Promise<{ event_id?: string }> {
+    const c = this.requireClient();
+    const sdk = await this.load();
+    const names = userIds.map((id, i) => displayNames?.[i] || id.replace(/^@/, '').split(':')[0]);
+    const prefix = names.map((n) => `@${n}`).join(' ');
+    const body = `${prefix} ${text}`;
+    const htmlPrefix = userIds
+      .map(
+        (id, i) =>
+          `<a href="https://matrix.to/#/${encodeURIComponent(id)}">${escapeHtml(names[i])}</a>`,
+      )
+      .join(' ');
+    return c.sendEvent(roomId, sdk.EventType.RoomMessage, {
+      msgtype: sdk.MsgType.Text,
+      body,
+      format: 'org.matrix.custom.html',
+      formatted_body: `${htmlPrefix} ${escapeHtml(text)}`,
+      'm.mentions': { user_ids: userIds },
+    } as never);
+  }
+
   /** 发文件卡片(`m.text` + JSON body,与容器侧解析约定一致) */
   async sendFileCard(
     roomId: string,
@@ -318,6 +398,7 @@ export class MatrixClientService implements OnDestroy {
         roomId,
         eventId: e.getId() ?? '',
         sender: e.getSender() ?? '',
+        senderName: senderDisplayName(room, e.getSender() ?? ''),
         body: (e.getContent()['body'] as string) ?? '',
         ts: e.getTs(),
         kind: kindOf(String(e.getContent()['msgtype'] ?? '')),
@@ -326,6 +407,39 @@ export class MatrixClientService implements OnDestroy {
       }));
   }
 
+  /**
+   * 列出**群房间**(房间里有多个成员,且不是 1:1 DM)。
+   *
+   * 群聊功能需要用这个发现「我参加了哪些工作组」。
+   * 判定:成员数 > 2 或房间未标记 is_direct。
+   */
+  listGroupRooms(): { roomId: string; name: string; memberIds: string[] }[] {
+    const c = this.client;
+    if (!c) return [];
+    const out: { roomId: string; name: string; memberIds: string[] }[] = [];
+    for (const room of c.getRooms()) {
+      if (room.getMyMembership() !== 'join') continue;
+      /*
+       * 🔴 群的判定是「成员 ≥ 3」,不是「不是 DM」。
+       *
+       * 实测:`getDMInviter()` 对我们由 owner 自己建的那些 1:1 房间返回 null
+       * (它们不是「别人发起的 DM」),于是 2 人房间全被当成了群
+       * (实测列出 4 个,其中 3 个是 DM)。
+       *
+       * 群必然 ≥ 3 人(我 + 至少两个 agent);DM 恒为 2 人。
+       * 用人数判断最稳,不依赖 is_direct 标记。
+       */
+      const members = room.getJoinedMemberCount();
+      if (members < 3) continue;
+      out.push({
+        roomId: room.roomId,
+        name: room.name || '(未命名群)',
+        memberIds: room.getJoinedMembers().map((m) => m.userId),
+      });
+    }
+    return out;
+  }
+
   /** 向上翻历史(AC-04.4) */
   async paginateBack(roomId: string, limit = 30): Promise<boolean> {
     const c = this.requireClient();

+ 12 - 0
src/app/desktop/desktop.routes.ts

@@ -21,6 +21,18 @@ export const desktopRoutes: Routes = [
         path: 'chat/:id',
         loadComponent: () => import('./pages/chat/chat').then((m) => m.DkChatPage),
       },
+      /*
+       * 工作组(群聊):多个数字生命在同一个房间里协作。
+       * 与 /chat 并列 —— 群聊不是「把多个 agent 塞进 DM」,是独立的会话形态。
+       */
+      {
+        path: 'groups',
+        loadComponent: () => import('./pages/groups/groups').then((m) => m.DkGroupPage),
+      },
+      {
+        path: 'groups/:id',
+        loadComponent: () => import('./pages/groups/groups').then((m) => m.DkGroupPage),
+      },
       {
         path: 'contacts',
         loadComponent: () => import('./pages/contacts/contacts').then((m) => m.DkContactsPage),

+ 704 - 0
src/app/desktop/pages/groups/groups.ts

@@ -0,0 +1,704 @@
+import {
+  ChangeDetectionStrategy,
+  Component,
+  computed,
+  effect,
+  ElementRef,
+  inject,
+  signal,
+  viewChild,
+} from '@angular/core';
+import { ActivatedRoute, Router } from '@angular/router';
+import { UiAlert, UiAvatar, UiButton, UiEmpty, UiSkeleton } from '../../../ui';
+import { MessageBubbleComponent } from '../../../components/domain/message-bubble/message-bubble';
+import { Agent, Message } from '../../../core/api/models';
+import { ChatService } from '../../../core/chat.service';
+import { DeviceService } from '../../../core/device.service';
+import { GroupsService } from '../../../core/groups.service';
+import { LiveService } from '../../../core/live.service';
+import { LoopGuardService } from '../../../core/loop-guard.service';
+import { MatrixClientService } from '../../../core/matrix-client.service';
+
+/**
+ * 桌面·群聊(多数字生命协作)
+ *
+ * ## 与单聊页的区别
+ *
+ * | | 单聊 `chat.ts` | 群聊(本页) |
+ * |---|---|---|
+ * | 消息归属 | `me` / `agent` 两态 | **每个 agent 都能发言**,要显示是谁 |
+ * | 右栏 | 单容器运行状态 + 成果 | **成员列表**(含各自状态) |
+ * | 输入 | 直接发 | 可 **@ 单个 / @ 多个** |
+ *
+ * ## 实测依据(2026-10-21 P0)
+ *
+ * - 群里 @ 单个 agent:3 秒响应
+ * - 群里 @ 多个 agent:两个都响应,互不干扰
+ * - **非主人发言会被拒绝**:容器只认自己的 owner
+ *   → 所以群成员必须是同一主人(或已授权的成员)
+ */
+@Component({
+  selector: 'app-dk-group-page',
+  changeDetection: ChangeDetectionStrategy.OnPush,
+  imports: [UiAlert, UiAvatar, UiButton, UiEmpty, UiSkeleton, MessageBubbleComponent],
+  template: `
+    <div class="group-layout">
+      <!-- 群列表 -->
+      <aside class="col list-col" aria-label="群聊列表">
+        <header class="col-head">
+          <h2>工作组</h2>
+          <span class="count">{{ groups.rooms().length }}</span>
+        </header>
+        <div class="list-scroll">
+          @if (groups.rooms().length === 0) {
+            <ui-empty
+              title="还没有工作组"
+              description="在工作组里可以由多名数字生命协作。需要公司管理员创建。"
+            />
+          } @else {
+            @for (r of groups.rooms(); track r.roomId) {
+              <button
+                type="button"
+                class="group-row"
+                [class.active]="r.roomId === roomId()"
+                (click)="open(r.roomId)"
+              >
+                <span class="group-icon" aria-hidden="true">
+                  <i class="fa-solid fa-users"></i>
+                </span>
+                <span class="group-meta">
+                  <strong class="ellipsis">{{ r.name }}</strong>
+                  <small class="ellipsis">{{ memberNames(r.roomId) }}</small>
+                </span>
+                @if (r.memberIds.length > 2) {
+                  <span class="badge">{{ r.memberIds.length - 1 }}</span>
+                }
+              </button>
+            }
+          }
+        </div>
+      </aside>
+
+      <!-- 群会话 -->
+      <section class="col main-col" aria-label="群消息">
+        @if (!roomId()) {
+          <div class="center-fill">
+            <ui-empty title="选择一个工作组" description="群内可以 @ 一个或多个数字生命。" />
+          </div>
+        } @else {
+          <header class="chat-head">
+            <span class="group-icon lg" aria-hidden="true">
+              <i class="fa-solid fa-users"></i>
+            </span>
+            <div class="chat-title">
+              <strong>{{ currentRoom()?.name }}</strong>
+              <small class="ellipsis">{{ members().length }} 名数字生命</small>
+            </div>
+            <ui-button
+              variant="ghost"
+              shape="circle"
+              icon="fa-solid fa-user-group"
+              ariaLabel="成员"
+              [class.on]="membersOpen()"
+              (click)="membersOpen.set(!membersOpen())"
+            />
+          </header>
+
+          <!-- 回环提示(群聊是高发场景:多个 agent 互相回) -->
+          @if (loop.paused()) {
+            <div class="banner-hold">
+              <ui-alert type="warning" title="检测到自动对话循环,已暂停">
+                <p class="loop-detail">{{ loop.pauseDetail() }}</p>
+                <ui-button variant="outline" size="sm" (click)="loop.resume()">继续</ui-button>
+              </ui-alert>
+            </div>
+          }
+
+          <div class="messages" #msgList (scroll)="onScroll()">
+            @if (loading()) {
+              <div class="skeleton-hold">
+                @for (i of [1, 2, 3]; track i) {
+                  <ui-skeleton variant="rect" height="56px" />
+                }
+              </div>
+            } @else if (messages().length === 0) {
+              <ui-empty title="工作组还没有消息" description="在下方 @ 一名或多名数字生命开始协作。" />
+            } @else {
+              @for (m of messages(); track m.id) {
+                <app-message-bubble
+                  [message]="m"
+                  [own]="m.sender === 'me'"
+                  [showSender]="true"
+                />
+              }
+            }
+          </div>
+
+          <!-- @ 选择器 -->
+          @if (mentionOpen()) {
+            <div class="mention-bar">
+              <span class="mention-hint">选择要 @ 的成员(可多选):</span>
+              <div class="mention-chips">
+                @for (a of members(); track a.id) {
+                  <button
+                    type="button"
+                    class="mention-chip"
+                    [class.on]="mentionTargets().has(a.id)"
+                    (click)="toggleMention(a)"
+                  >
+                    {{ a.name }}
+                  </button>
+                }
+              </div>
+            </div>
+          }
+
+          <div class="composer-hold">
+            @if (mentionTargets().size > 0) {
+              <div class="mention-preview">
+                <i class="fa-solid fa-at" aria-hidden="true"></i>
+                {{ mentionLabel() }}
+                <button type="button" class="clear" (click)="clearMention()" aria-label="取消 @">
+                  <i class="fa-solid fa-xmark" aria-hidden="true"></i>
+                </button>
+              </div>
+            }
+            <div class="composer-row">
+              <ui-button
+                variant="ghost"
+                shape="circle"
+                icon="fa-solid fa-at"
+                ariaLabel="@ 成员"
+                (click)="mentionOpen.set(!mentionOpen())"
+              />
+              <textarea
+                #input
+                class="input"
+                rows="1"
+                [placeholder]="mentionTargets().size ? '输入内容,回车发送' : '输入内容(可先点 @ 指定成员)'"
+                [value]="draft()"
+                (input)="onInput($event)"
+                (keydown)="onKeydown($event)"
+              ></textarea>
+              <ui-button
+                variant="primary"
+                shape="circle"
+                icon="fa-solid fa-paper-plane"
+                ariaLabel="发送"
+                [disabled]="!draft().trim() || sending()"
+                (click)="send()"
+              />
+            </div>
+          </div>
+        }
+      </section>
+
+      <!-- 成员栏 -->
+      @if (roomId() && membersOpen()) {
+        <aside class="col members-col" aria-label="群成员">
+          <header class="col-head">
+            <h2>成员</h2>
+            <ui-button
+              variant="ghost"
+              shape="circle"
+              icon="fa-solid fa-xmark"
+              ariaLabel="收起"
+              (click)="membersOpen.set(false)"
+            />
+          </header>
+          <div class="member-list">
+            @for (a of members(); track a.id) {
+              <div class="member-row">
+                <ui-avatar [name]="a.name" [size]="'sm'" />
+                <span class="member-meta">
+                  <strong class="ellipsis">{{ a.name }}</strong>
+                  <small>{{ a.status === 'running' ? '执行中' : a.status === 'idle' ? '空闲' : a.status }}</small>
+                </span>
+                <span class="dot" [class.busy]="a.status === 'running'" aria-hidden="true"></span>
+              </div>
+            }
+            @if (members().length === 0) {
+              <p class="empty-hint">该群的数字生命尚未绑定到平台账号。</p>
+            }
+          </div>
+        </aside>
+      }
+    </div>
+  `,
+  styles: [
+    `
+      :host {
+        display: block;
+        height: 100%;
+        min-height: 0;
+        overflow: hidden;
+      }
+      .group-layout {
+        display: grid;
+        grid-template-columns: var(--desk-list-w) minmax(0, 1fr);
+        height: 100%;
+        min-height: 0;
+      }
+      .group-layout:has(> .members-col) {
+        grid-template-columns: var(--desk-list-w) minmax(0, 1fr) 260px;
+      }
+      .col {
+        min-width: 0;
+        min-height: 0;
+        display: flex;
+        flex-direction: column;
+        background: var(--surface);
+      }
+      .list-col {
+        background: var(--surface-2);
+        border-right: 1px solid var(--edge);
+        overflow: hidden;
+      }
+      .members-col {
+        background: var(--rail-surface);
+        border-left: 1px solid var(--edge);
+      }
+      .col-head {
+        display: flex;
+        align-items: center;
+        justify-content: space-between;
+        gap: var(--sp-2);
+        padding: var(--sp-4);
+        border-bottom: 1px solid var(--edge);
+      }
+      .col-head h2 {
+        margin: 0;
+        font-size: var(--fs-15);
+        color: var(--ink);
+      }
+      .count {
+        font-size: var(--fs-11);
+        color: var(--muted);
+      }
+      .list-scroll {
+        flex: 1;
+        min-height: 0;
+        overflow-y: auto;
+        padding: var(--sp-2);
+      }
+      .group-row {
+        display: flex;
+        align-items: center;
+        gap: var(--sp-3);
+        width: 100%;
+        min-height: 56px;
+        padding: var(--sp-2) var(--sp-3);
+        border: 0;
+        border-radius: var(--r-md);
+        background: transparent;
+        text-align: left;
+        cursor: pointer;
+      }
+      .group-row:hover {
+        background: var(--surface-hover);
+      }
+      .group-row.active {
+        background: var(--surface);
+      }
+      .group-icon {
+        display: grid;
+        place-items: center;
+        width: 32px;
+        height: 32px;
+        flex-shrink: 0;
+        border-radius: var(--r-sm);
+        background: var(--primary-subtle);
+        color: var(--primary);
+      }
+      .group-icon.lg {
+        width: 40px;
+        height: 40px;
+      }
+      .group-meta {
+        display: grid;
+        gap: 2px;
+        flex: 1;
+        min-width: 0;
+      }
+      .group-meta strong {
+        font-size: var(--fs-15);
+        color: var(--ink);
+      }
+      .group-meta small {
+        font-size: var(--fs-12);
+        color: var(--muted);
+      }
+      .badge {
+        min-width: 18px;
+        padding: 0 5px;
+        border-radius: var(--r-tag);
+        background: var(--surface-active);
+        color: var(--ink-2);
+        font-size: var(--fs-11);
+        line-height: 18px;
+        text-align: center;
+      }
+      .center-fill {
+        flex: 1;
+        display: grid;
+        place-content: center;
+      }
+      .chat-head {
+        display: flex;
+        align-items: center;
+        gap: var(--sp-3);
+        padding: var(--sp-3) var(--sp-4);
+        border-bottom: 1px solid var(--edge);
+      }
+      .chat-title {
+        display: grid;
+        gap: 2px;
+        flex: 1;
+        min-width: 0;
+      }
+      .chat-title strong {
+        font-size: var(--fs-15);
+        color: var(--ink);
+      }
+      .chat-title small {
+        font-size: var(--fs-12);
+        color: var(--muted);
+      }
+      .banner-hold {
+        padding: var(--sp-3) var(--sp-4) 0;
+      }
+      .loop-detail {
+        margin: 0 0 var(--sp-2);
+        font-size: var(--fs-12);
+        color: var(--ink-2);
+      }
+      .messages {
+        flex: 1;
+        min-height: 0;
+        overflow-y: auto;
+        padding: var(--sp-4);
+        display: grid;
+        gap: var(--sp-3);
+        align-content: start;
+      }
+      .skeleton-hold {
+        display: grid;
+        gap: var(--sp-2);
+      }
+      .mention-bar {
+        padding: var(--sp-2) var(--sp-4);
+        border-top: 1px solid var(--edge);
+        background: var(--surface-2);
+      }
+      .mention-hint {
+        font-size: var(--fs-12);
+        color: var(--muted);
+      }
+      .mention-chips {
+        display: flex;
+        flex-wrap: wrap;
+        gap: var(--sp-2);
+        margin-top: var(--sp-2);
+      }
+      .mention-chip {
+        min-height: 28px;
+        padding: 0 var(--sp-3);
+        border: 1px solid var(--edge);
+        border-radius: var(--r-tag);
+        background: var(--surface);
+        color: var(--ink-2);
+        font-size: var(--fs-12);
+        cursor: pointer;
+      }
+      .mention-chip.on {
+        border-color: var(--primary);
+        background: var(--primary-subtle);
+        color: var(--primary);
+      }
+      .composer-hold {
+        border-top: 1px solid var(--edge);
+        padding: var(--sp-3) var(--sp-4);
+      }
+      .mention-preview {
+        display: inline-flex;
+        align-items: center;
+        gap: var(--sp-1);
+        padding: 2px var(--sp-2);
+        margin-bottom: var(--sp-2);
+        border-radius: var(--r-tag);
+        background: var(--primary-subtle);
+        color: var(--primary);
+        font-size: var(--fs-12);
+      }
+      .mention-preview .clear {
+        border: 0;
+        background: transparent;
+        color: inherit;
+        cursor: pointer;
+      }
+      .composer-row {
+        display: flex;
+        align-items: flex-end;
+        gap: var(--sp-2);
+      }
+      .input {
+        flex: 1;
+        min-height: 40px;
+        max-height: 160px;
+        padding: var(--sp-2) var(--sp-3);
+        border: 1px solid var(--edge);
+        border-radius: var(--r-md);
+        background: var(--surface);
+        color: var(--ink);
+        font: inherit;
+        resize: none;
+      }
+      .member-list {
+        flex: 1;
+        min-height: 0;
+        overflow-y: auto;
+        padding: var(--sp-3);
+        display: grid;
+        gap: var(--sp-2);
+        align-content: start;
+      }
+      .member-row {
+        display: flex;
+        align-items: center;
+        gap: var(--sp-2);
+        padding: var(--sp-2);
+        border-radius: var(--r-md);
+        background: var(--surface);
+      }
+      .member-meta {
+        display: grid;
+        gap: 1px;
+        flex: 1;
+        min-width: 0;
+      }
+      .member-meta strong {
+        font-size: var(--fs-13);
+        color: var(--ink);
+      }
+      .member-meta small {
+        font-size: var(--fs-11);
+        color: var(--muted);
+      }
+      .dot {
+        width: 6px;
+        height: 6px;
+        border-radius: 50%;
+        background: var(--edge-strong);
+        flex-shrink: 0;
+      }
+      .dot.busy {
+        background: var(--success);
+        animation: pulse-dot 1.4s var(--ease-standard) infinite;
+      }
+      @keyframes pulse-dot {
+        0%,
+        100% {
+          opacity: 1;
+        }
+        50% {
+          opacity: 0.35;
+        }
+      }
+      @media (prefers-reduced-motion: reduce) {
+        .dot.busy {
+          animation: none;
+        }
+      }
+      .empty-hint {
+        margin: 0;
+        font-size: var(--fs-12);
+        color: var(--muted);
+      }
+      .ellipsis {
+        overflow: hidden;
+        text-overflow: ellipsis;
+        white-space: nowrap;
+      }
+    `,
+  ],
+})
+export class DkGroupPage {
+  readonly groups = inject(GroupsService);
+  readonly loop = inject(LoopGuardService);
+  private readonly live = inject(LiveService);
+  private readonly chat = inject(ChatService);
+  private readonly mx = inject(MatrixClientService);
+  private readonly route = inject(ActivatedRoute);
+  private readonly router = inject(Router);
+  private readonly device = inject(DeviceService);
+
+  readonly roomId = signal<string | null>(null);
+  readonly messages = signal<Message[]>([]);
+  readonly loading = signal(false);
+  readonly sending = signal(false);
+  readonly draft = signal('');
+  readonly membersOpen = signal(true);
+  readonly mentionOpen = signal(false);
+  readonly mentionTargets = signal<Set<string>>(new Set());
+
+  private readonly messagesEl = viewChild<ElementRef<HTMLElement>>('msgList');
+
+  readonly currentRoom = computed(() =>
+    this.groups.rooms().find((r) => r.roomId === this.roomId()),
+  );
+
+  /** 群内 agent 成员(已映射回平台 Agent,能显示状态) */
+  readonly members = computed(() => {
+    const id = this.roomId();
+    return id ? this.groups.membersOf()(id) : [];
+  });
+
+  readonly mentionLabel = computed(() => {
+    const ids = this.mentionTargets();
+    return this.members()
+      .filter((a) => ids.has(a.id))
+      .map((a) => '@' + a.name)
+      .join(' ');
+  });
+
+  constructor() {
+    this.route.paramMap.subscribe((p) => {
+      const id = p.get('id');
+      if (id) void this.open(id);
+      else {
+        const first = this.groups.rooms()[0];
+        if (first) void this.open(first.roomId);
+      }
+    });
+
+    // 同步到新消息时重读群会话
+    effect(() => {
+      this.mx.storeVersion();
+      const rid = this.roomId();
+      if (!rid) return;
+      this.loadMessages(rid);
+    });
+  }
+
+  async open(roomId: string): Promise<void> {
+    this.roomId.set(roomId);
+    this.groups.enter(roomId);
+    this.mentionTargets.set(new Set());
+    this.loading.set(true);
+    // 路由同步(可直接分享链接)
+    void this.router.navigateByUrl(`/${this.device.mode()}/groups/${encodeURIComponent(roomId)}`, {
+      replaceUrl: this.route.snapshot.paramMap.get('id') === roomId,
+    });
+    this.loadMessages(roomId);
+    this.loading.set(false);
+    this.scrollToBottom();
+  }
+
+  memberNames(roomId: string): string {
+    const list = this.groups.membersOf()(roomId);
+    return list.length ? list.map((a) => a.name).join('、') : '暂无数字生命';
+  }
+
+  private loadMessages(roomId: string): void {
+    const my = this.chat.provisionInfo?.userMxId ?? '';
+    const raw = this.mx.timelineToMessages(roomId);
+    /*
+     * 🔴 发送者名字优先用**平台数据**(agent 的 botName),不用 Matrix 的 displayName。
+     *
+     * 实测:容器账号没有设置 Matrix displayName,`getMember().name` 为空,
+     * 于是回落到 Matrix ID(`k0nrevcfik` / `zgdcmgnqcd`)——
+     * 用户看到的是一串技术 ID,完全不知道是谁。
+     * 平台侧有 `botName`(`lj` / `lj-test`),这才是有意义的名字。
+     */
+    const nameByMx = new Map<string, string>();
+    for (const a of this.live.agents()) {
+      const mx = a.channels?.matrixConfig?.userId;
+      if (mx) nameByMx.set(mx, a.name);
+    }
+    this.messages.set(
+      raw
+        .filter((m) => !!m.body?.trim())
+        .map((m) => ({
+          id: m.eventId,
+          conversationId: roomId,
+          sender: m.sender === my ? ('me' as const) : ('agent' as const),
+          senderId: m.sender,
+          senderName:
+            m.sender === my ? '我' : (nameByMx.get(m.sender) ?? m.senderName ?? m.sender),
+          kind: m.kind,
+          text: m.body,
+          ts: new Date(m.ts).toISOString(),
+          state: 'sent' as const,
+        })),
+    );
+  }
+
+  toggleMention(a: Agent): void {
+    this.mentionTargets.update((s) => {
+      const next = new Set(s);
+      if (next.has(a.id)) next.delete(a.id);
+      else next.add(a.id);
+      return next;
+    });
+  }
+
+  clearMention(): void {
+    this.mentionTargets.set(new Set());
+  }
+
+  onInput(e: Event): void {
+    const el = e.target as HTMLTextAreaElement;
+    this.draft.set(el.value);
+    el.style.height = 'auto';
+    el.style.height = Math.min(el.scrollHeight, 160) + 'px';
+  }
+
+  onKeydown(e: KeyboardEvent): void {
+    if (e.key === 'Enter' && !e.shiftKey) {
+      e.preventDefault();
+      void this.send();
+    }
+  }
+
+  async send(): Promise<void> {
+    const text = this.draft().trim();
+    const rid = this.roomId();
+    if (!text || !rid || this.sending()) return;
+    const targets = this.members().filter((a) => this.mentionTargets().has(a.id));
+    this.sending.set(true);
+    try {
+      if (targets.length === 0) {
+        await this.mx.sendText(rid, text);
+      } else {
+        await this.mx.sendMentionMany(
+          rid,
+          targets.map((a) => a.channels?.matrixConfig?.userId ?? '').filter(Boolean),
+          text,
+          targets.map((a) => a.name),
+        );
+      }
+      this.draft.set('');
+      this.clearMention();
+      const input = document.querySelector<HTMLTextAreaElement>('.input');
+      if (input) {
+        input.value = '';
+        input.style.height = 'auto';
+      }
+      this.loadMessages(rid);
+      this.scrollToBottom();
+    } finally {
+      this.sending.set(false);
+    }
+  }
+
+  onScroll(): void {
+    // 预留:上滚加载历史(群聊消息量通常小于 DM,暂不做分页)
+  }
+
+  private scrollToBottom(): void {
+    requestAnimationFrame(() => {
+      const el = this.messagesEl()?.nativeElement;
+      if (el) el.scrollTop = el.scrollHeight;
+    });
+  }
+}

+ 2 - 0
src/app/desktop/shell/dk-nav/dk-nav.ts

@@ -364,6 +364,8 @@ export class DkNavComponent {
       label: '业务',
       items: [
         { path: '/chat', label: '会话', icon: 'fa-comment' },
+        // 工作组:多个数字生命在同一房间里协作(群聊)
+        { path: '/groups', label: '工作组', icon: 'fa-users' },
         { path: '/contacts', label: '通讯录', icon: 'fa-address-book' },
         { path: '/projects', label: '项目', icon: 'fa-folder' },
         { path: '/events', label: '事件', icon: 'fa-bolt' },