Răsfoiți Sursa

feat: 状态展示统一 + 文件台账群隔离 + 跨域代理 + 群聊入口隐藏

- 统一在线判定到 agent-liveness(心跳 80 分钟口径)
- 文件台账加 groupId,容器产出按派发锚点归群
- 新增 fmode-file-read 云函数代理(绕过 S3 CORS)
- 修复回合日志吞正文、计时读数倒退、超时前空白
- 群聊入口(PC + 移动端)隐藏,源码保留

Co-Authored-By: Claude Code <noreply@anthropic.com>
Huccct 6 ore în urmă
părinte
comite
7949d16dc6
73 a modificat fișierele cu 5812 adăugiri și 1053 ștergeri
  1. 1 0
      .gitignore
  2. 0 0
      cloud/backup/THXAOGKiPK-fmode-file-hub-live-20261111-before-v0.2.0.json
  3. 2 0
      cloud/backup/xlosi3m46q-fmodeagent-status-live-20261111-before-v0.0.9.json
  4. 32 0
      cloud/functions/README.md
  5. 281 6
      cloud/functions/fmode-file-hub.js
  6. 166 0
      cloud/functions/fmode-file-read.js
  7. 624 0
      cloud/functions/fmodeagent-status.js
  8. 295 0
      docs/00-tracking/操作追踪.md
  9. 9 1
      docs/03-interface/api/API-契约.md
  10. 218 0
      docs/03-interface/lj-test容器完整流程-20261111.md
  11. 200 0
      docs/03-interface/容器侧标准包-基线采集-20261111.md
  12. 266 0
      docs/03-interface/容器自述与一致性核对-提示词-20261111.md
  13. 11 3
      src/app/components/domain/assistant-turn/assistant-turn.spec.ts
  14. 23 7
      src/app/components/domain/assistant-turn/assistant-turn.ts
  15. 35 2
      src/app/components/domain/composer/composer.ts
  16. 0 100
      src/app/components/domain/container-dot/container-dot.ts
  17. 7 1
      src/app/components/domain/new-session-modal/new-session-modal.ts
  18. 32 2
      src/app/components/domain/session-rail/session-rail.ts
  19. 13 5
      src/app/components/domain/session-tree/session-tree.ts
  20. 73 0
      src/app/components/domain/status-badge/status-badge.spec.ts
  21. 37 5
      src/app/components/domain/status-badge/status-badge.ts
  22. 39 3
      src/app/components/feedback/alert-host/alert-host.ts
  23. 9 1
      src/app/components/preview/file-preview/file-preview.spec.ts
  24. 79 9
      src/app/components/preview/file-preview/file-preview.ts
  25. 214 0
      src/app/core/agent-liveness.spec.ts
  26. 239 0
      src/app/core/agent-liveness.ts
  27. 27 8
      src/app/core/agent-state.ts
  28. 51 3
      src/app/core/alert.service.ts
  29. 22 0
      src/app/core/api/fmode-api.ts
  30. 22 0
      src/app/core/api/mock-api.ts
  31. 30 0
      src/app/core/api/models.ts
  32. 33 0
      src/app/core/api/parse-api.spec.ts
  33. 86 28
      src/app/core/api/parse-api.ts
  34. 339 4
      src/app/core/assistant-turn.spec.ts
  35. 341 48
      src/app/core/assistant-turn.ts
  36. 12 3
      src/app/core/chat-session.spec.ts
  37. 11 1
      src/app/core/chat-session.ts
  38. 132 12
      src/app/core/chat.service.ts
  39. 131 2
      src/app/core/deliverables.service.spec.ts
  40. 121 12
      src/app/core/deliverables.service.ts
  41. 4 0
      src/app/core/device.service.ts
  42. 5 0
      src/app/core/format.spec.ts
  43. 7 2
      src/app/core/format.ts
  44. 53 9
      src/app/core/group-send.service.ts
  45. 16 0
      src/app/core/live.service.ts
  46. 119 0
      src/app/core/message-segments.ts
  47. 44 7
      src/app/core/nav-menu.service.spec.ts
  48. 24 4
      src/app/core/nav-menu.service.ts
  49. 32 0
      src/app/core/s3-link.spec.ts
  50. 75 0
      src/app/core/s3-link.ts
  51. 30 0
      src/app/core/upload-limits.ts
  52. 108 0
      src/app/core/upload-state.spec.ts
  53. 122 0
      src/app/core/upload-state.ts
  54. 33 30
      src/app/desktop/desktop.routes.ts
  55. 26 2
      src/app/desktop/pages/agent/agent.ts
  56. 254 57
      src/app/desktop/pages/chat/chat.ts
  57. 11 0
      src/app/desktop/pages/contacts/contacts.ts
  58. 110 26
      src/app/desktop/pages/group-chat/group-chat.ts
  59. 111 55
      src/app/desktop/pages/workbench/workbench.ts
  60. 22 4
      src/app/desktop/shell/dk-nav/dk-nav.ts
  61. 9 4
      src/app/desktop/shell/dk-top-nav/dk-top-nav.ts
  62. 28 0
      src/app/mobile/components/m-dock/m-dock.ts
  63. 11 16
      src/app/mobile/components/m-drawer/m-drawer.ts
  64. 20 10
      src/app/mobile/components/m-life-card/m-life-card.ts
  65. 7 1
      src/app/mobile/components/m-new-chat/m-new-chat.ts
  66. 37 15
      src/app/mobile/mobile.routes.ts
  67. 0 444
      src/app/mobile/pages/admin/admin.ts
  68. 11 1
      src/app/mobile/pages/admin/users/users.ts
  69. 9 0
      src/app/mobile/pages/agent/agent.ts
  70. 128 23
      src/app/mobile/pages/chat/chat.ts
  71. 15 3
      src/app/mobile/pages/contacts/contacts.ts
  72. 19 9
      src/app/mobile/pages/group-chat/group-chat.ts
  73. 49 65
      src/app/mobile/pages/workbench/workbench.ts

+ 1 - 0
.gitignore

@@ -88,3 +88,4 @@ public/pdf.worker.js
 # 临时调试产物(截图 / 抓取快照 / 参考素材,开发期本地用)
 .tmp-*/
 .tmp-*
+.dev-server.log

Fișier diff suprimat deoarece este prea mare
+ 0 - 0
cloud/backup/THXAOGKiPK-fmode-file-hub-live-20261111-before-v0.2.0.json


Fișier diff suprimat deoarece este prea mare
+ 2 - 0
cloud/backup/xlosi3m46q-fmodeagent-status-live-20261111-before-v0.0.9.json


+ 32 - 0
cloud/functions/README.md

@@ -8,6 +8,38 @@
 | 文件 | Function.objectId | name | path | version |
 |---|---|---|---|---|
 | `fmodeagent-chatthreads.js` | `oR5O67i1Cm` | fmodeagent-chatthreads | /fmodeagent/chatthreads | **0.1.6(与线上一致,2026-11-06 对齐)** |
+| `fmodeagent-status.js` | `xlosi3m46q` | fmodeagent-status | /fmodeagent/status | **v0.0.9(余额口径修复,2026-11-11 已上线)** |
+| `fmode-file-hub.js` | `THXAOGKiPK` | fmode-file-hub | /fmodeagent/file-hub | **v0.2.0(群隔离,2026-11-11 已上线)** |
+| `fmode-file-read.js` | `tocXyQjyh6` | fmode-file-read | /fmodeagent/file-read | **v0.1.0(云盘文本代理,2026-11-11 新建)** |
+
+> **fmode-file-read v0.1.0(2026-11-11 新建)**
+> 云盘 `s3.fmode.cn` 是 public-read 但**没配 CORS**,浏览器 fetch 必被拦,
+> `.md` / `.txt` 在页内渲染不出来。本函数在服务端取正文再回传,
+> 浏览器只调本域(已配 `ACAO:*`)。三条安全约束:只放行云盘域名(防 SSRF)、
+> 必须有效 session、2MB 上限。
+> 实测:读取 `w-test.md` 返回 `{"code":200,"text":"测试\n"}`;
+> 非云盘域名 → 400;无 token → 401。
+
+> **fmode-file-hub v0.2.0(2026-11-11 群隔离)**
+> 伪群聊下容器只在自己的 DM 房间工作、回报文件时**不知道群**。
+> 新增字段 `groupId` / `groupIdSource` / `prevThreadRoot`,新增 action
+> `listByGroup` / `claimToGroup`。App 在派活前写一条**派发锚点**
+> (`mimeType = application/x-fmode-anchor`),容器产出登记时服务端按
+> 「同 user + 同 agentId + 同 roomId + 30 分钟内」反查归属。
+> **多群同时派活时不认(宁可漏,不归错)**;命中后 `threadRoot` 一并改写为群 id,
+> 保证一条文件只出现在一个页签。
+> 回滚:`cloud/backup/THXAOGKiPK-fmode-file-hub-live-20261111-before-v0.2.0.json`。
+
+> **v0.0.9 说明(2026-11-11)**
+> 余额口径修复:旧实现用 `fmodeApiToken` 调 `api.fmode.cn/api/usage/token/` 取 `total_available` 再自行换汇,
+> 而该令牌是 `unlimited_quota: true` → 线上 App 显示 **-76.12**,Studio 充值页实际 **948.09**。
+> 新版改为与 Studio 同源:`POST /api/fmode/billing/query`(`data.balance.availableCny`)优先,
+> `POST /api/fmode/newapi/user`(`data.user.balance`)回落;不再自行换算;`unlimited_quota` 令牌被明确拒绝。
+> 缓存键改为**按用户 objectId**(`__FM9_BAL_CACHE`)、TTL **60s**(原 300s 且不区分用户)。
+> 上线前已本地仿真:新旧实现分别对真实接口取数 → 新实现 948.09 ✅ / 旧实现 -76.12 ❌。
+> **回滚**:`cloud/backup/xlosi3m46q-fmodeagent-status-live-20261111-before-v0.0.9.json`(改前线上全文),
+> `PUT {code: <该文件 code>}` 即回到改前状态。
+> 本文件以**线上当前代码**为基线(线上比 `-v0.1.2.patched.js` 新,别拿旧备份覆盖)。
 
 > **v0.1.6 说明(2026-11-06)**
 > 群会话需要在会话目录里区分出来,靠两个字段:`kind`(`dm`/`group`)与 `memberIds`(成员容器 id)。

+ 281 - 6
cloud/functions/fmode-file-hub.js

@@ -1,4 +1,19 @@
-// fmode-file-hub v0.1.0 — 文件台账(按会话隔离)(表: AgentDeliverable)
+// fmode-file-hub v0.2.0 — 文件台账(按会话 / 按群隔离)(表: AgentDeliverable)
+//
+// ## v0.2.0(2026-11-11):群隔离
+//
+// 伪群聊架构下(App 把群消息拆成 N 条私聊发给各成员容器),容器只在自己的
+// DM 房间工作,**它不知道文件属于哪个群**。所以「归群」只能由 App 侧决定:
+//
+//   · 用户上传 → 写入时直接带 `groupId`(App 知道当前是哪个群)
+//   · 容器产出 → 靠「派发锚点」反查(后续版本),或 App 惰性补归并
+//
+// 新增字段:`groupId`(所属群;'' = 不属于任何群)、`groupIdSource`、`prevThreadRoot`
+// 新增 action:`listByGroup` / `claimToGroup`
+//
+// 🔴 `threadRoot` 语义不变(单聊 = 单聊会话 id,群 = 群 id)。
+//    `claimToGroup` 会把记录**搬**进群(threadRoot 一并改写),
+//    保证一条文件只出现在一个页签 —— 这就是「不串台」的落地方式。
 //
 // ## 为什么新建而不是改老函数
 //
@@ -34,6 +49,18 @@ var LEN = { threadRoot: 200, roomId: 200, agentId: 120, url: 1000, name: 200, ke
 /** 单个文件大小上限(与 App 侧 upload-limits.ts 的 20MB 保持一致) */
 var MAX_FILE_BYTES = 20 * 1024 * 1024;
 
+/**
+ * 「派发锚点」的有效窗口(v0.2.0)。
+ *
+ * App 发群消息时为每个目标容器记一条锚点,容器产出的文件在**锚点之后
+ * 这个窗口内**才认它属于那个群。超过窗口不认 —— 宁可漏,不归错
+ * (用户裁决 2026-11-11)。
+ */
+var GROUP_ANCHOR_TTL_MS = 30 * 60 * 1000;
+
+/** 锚点记录的识别标记(App 写入时用的 mimeType) */
+var ANCHOR_MIME = 'application/x-fmode-anchor';
+
 function _ok(response, data) {
   var body = { code: 200 };
   if (data) for (var k in data) body[k] = data[k];
@@ -79,11 +106,92 @@ async function _getUser(t) {
   }
 }
 
+/**
+ * 反查容器产出的文件属于哪个群(v0.2.0)。
+ *
+ * ## 为什么需要
+ *
+ * 伪群聊下容器只在自己的 DM 房间工作,回报文件时**不知道群**。
+ * App 在派活前为每个目标容器记了一条锚点(`uploadedBy:'agent'` +
+ * `ANCHOR_MIME`),锚点上带着 `groupId` / `agentId` / `roomId`。
+ * 容器产出到达时,按「同 user + 同 agentId + 同 roomId + 时间在锚点之后」匹配。
+ *
+ * ## 宁可漏,不归错(用户裁决)
+ *
+ * - 有多条候选锚点 → **不认**(同时被多个群派活的典型场景,认了就会串台)
+ * - 锚点太旧(超出 TTL)→ 不认
+ * - 匹配不到 → 返回 '',文件照常登记,只是不归群
+ *
+ * @returns {{ groupId: string }} 命中返回群 id,否则空串
+ */
+async function _resolveGroup(user, agentId, roomId) {
+  if (!agentId) return '';
+
+  var q = new Parse.Query(CLASS_NAME);
+  q.equalTo('user', user);
+  q.equalTo('agentId', agentId);
+  q.equalTo('uploadedBy', 'agent');
+  q.equalTo('mimeType', ANCHOR_MIME);
+  q.greaterThan('deliveredAt', new Date(Date.now() - GROUP_ANCHOR_TTL_MS));
+  q.descending('deliveredAt');
+  q.limit(10);
+
+  var rows;
+  try {
+    rows = await q.find({ useMasterKey: true });
+  } catch (e) {
+    if (!_isMissingColumn(e)) throw e;
+    await _ensureColumns();
+    rows = await q.find({ useMasterKey: true });
+  }
+  if (rows.length === 0) return '';
+
+  /*
+   * 🔴 唯一性要求:窗口内必须只有**同一个群**的锚点。
+   *
+   * 同一容器被群 G 和群 H 先后派活 → 有两条锚点指向不同群 →
+   * 无法判断这个文件是谁的 → 不认。这是「不串台」的底线,
+   * 代价是这种并发场景下文件不归群(仍会出现在单聊里)。
+   */
+  var groups = new Set();
+  for (var i = 0; i < rows.length; i++) {
+    var g = rows[i].get('groupId') || '';
+    if (g) groups.add(g);
+  }
+  if (groups.size !== 1) return '';
+  return groups.values().next().value || '';
+}
+
 /** 云盘链接只接受本平台域名,防止登记任意外链冒充成果 */
 function _isCloudUrl(u) {
   return /^https:\/\/(s3|file-cloud)\.fmode\.cn\//i.test(u);
 }
 
+/**
+ * 校验 `groupId` 确实是**本用户的群会话**(v0.2.0)。
+ *
+ * 🔴 为什么必须校验:`groupId` 是隔离边界。若不校验,客户端可以传任意字符串,
+ *    把文件"归"到一个不存在的群或别人的群 —— 隔离就成了摆设。
+ *
+ * 会话目录在 `FmodeChatThread` 表,`kind === 'group'` 才是群。
+ * 查不到 / 不是群 / 不是本人 → 一律拒绝。
+ */
+async function _isOwnGroup(user, groupId) {
+  if (!groupId) return false;
+  try {
+    var q = new Parse.Query('FmodeChatThread');
+    q.equalTo('user', user);
+    q.equalTo('objectId', groupId);
+    var row = await q.first({ useMasterKey: true });
+    if (!row) return false;
+    var kind = String(row.get('kind') || '').toLowerCase();
+    return kind === 'group';
+  } catch (e) {
+    /* 表不存在或查询失败 → 保守拒绝,宁可登记成"不归群" */
+    return false;
+  }
+}
+
 /**
  * 确保 class 上存在某个列(2026-11-10)。
  *
@@ -112,6 +220,10 @@ async function _ensureColumns() {
   probe.set('mimeType', '');
   probe.set('storageKey', '');
   probe.set('roomId', '');
+  /* v0.2.0(2026-11-11):群隔离字段 */
+  probe.set('groupId', '');
+  probe.set('groupIdSource', '');
+  probe.set('scope', '');
   await probe.save(null, { useMasterKey: true });
   // 探测对象必须销毁,否则会在成果列表里留下一条空记录
   await probe.destroy({ useMasterKey: true });
@@ -143,6 +255,9 @@ function _toFile(r) {
   return {
     id: r.id,
     threadRoot: r.get('threadRoot') || '',
+    /* v0.2.0:所属群会话 id;'' = 不属于任何群 */
+    groupId: r.get('groupId') || '',
+    groupIdSource: r.get('groupIdSource') || '',
     agentId: r.get('agentId') || '',
     roomId: r.get('roomId') || '',
     // 文件信息优先取新字段,回退到 artifacts[0](两者写入时保持一致)
@@ -159,8 +274,22 @@ function _toFile(r) {
   };
 }
 
-// ==================== actions ====================
+/**
+ * 过滤掉**派发锚点**记录(v0.2.0)。
+ *
+ * 锚点是 App 在派活前写的内部记录(`mimeType = ANCHOR_MIME`),
+ * 只用来反查文件归属,**不是文件**,不能出现在任何列表里。
+ */
+function _toFiles(rows) {
+  var out = [];
+  for (var i = 0; i < rows.length; i++) {
+    if (rows[i].get('mimeType') === ANCHOR_MIME) continue;
+    out.push(_toFile(rows[i]));
+  }
+  return out;
+}
 
+// ==================== actions ====================
 /**
  * registerFile —— 登记一条文件记录
  *
@@ -184,6 +313,27 @@ async function opRegisterFile(user, response) {
   var uploadedBy = p.uploadedBy === 'user' ? 'user' : 'agent';
   var agentId = _str(p.agentId, LEN.agentId);
 
+  /*
+   * 群归属(v0.2.0,2026-11-11)。
+   *
+   * 传了 `groupId` 就必须是本用户的群,否则拒绝 —— 见 `_isOwnGroup`。
+   * 没传或校验不过 → `groupId` 留空,按原来的「只按会话」处理,
+   * 不影响存量调用方。
+   */
+  var groupId = _str(p.groupId, LEN.threadRoot);
+  if (groupId) {
+    var ok = await _isOwnGroup(user, groupId);
+    if (!ok) return _fail(response, 400, 'groupId must be your own group session');
+  } else if (uploadedBy === 'agent' && _str(p.mimeType, LEN.mime) !== ANCHOR_MIME) {
+    /*
+     * 容器产出没带 groupId → 反查派发锚点(v0.2.0)。
+     *
+     * 用户上传(uploadedBy='user')不做反查:App 知道群,直接传就行。
+     * 锚点记录本身(ANCHOR_MIME)也跳过,否则会自己匹配自己。
+     */
+    groupId = await _resolveGroup(user, agentId, _str(p.roomId, LEN.roomId));
+  }
+
   /*
    * 幂等:同一会话内同一 URL 只存一条。
    *
@@ -207,7 +357,23 @@ async function opRegisterFile(user, response) {
 
   var obj = new Parse.Object(CLASS_NAME);
   obj.set('user', user);
-  obj.set('threadRoot', threadRoot);
+  /*
+   * 🔴 反查命中的群文件,`threadRoot` **一并改写为群 id**(v0.2.0)。
+   *
+   * 这是「不串台」的落地:一条文件只能属于一个页签。
+   * 若仍保留 DM thread,单聊 `listBySession(dm)` 也会返回它 ——
+   * 同一个文件在单聊和群里各出现一次。
+   *
+   * 原始的 DM thread 存进 `prevThreadRoot`,出问题可据此还原。
+   */
+  var resolvedByAnchor = !p.groupId && !!groupId;
+  obj.set('threadRoot', resolvedByAnchor ? groupId : threadRoot);
+  if (resolvedByAnchor) obj.set('prevThreadRoot', threadRoot);
+  obj.set('groupId', groupId);
+  obj.set(
+    'groupIdSource',
+    groupId ? (resolvedByAnchor ? 'anchor' : _str(p.groupIdSource, 20) || 'app') : '',
+  );
   obj.set('roomId', _str(p.roomId, LEN.roomId));
   obj.set('agentId', agentId);
   obj.set('uploadedBy', uploadedBy);
@@ -294,10 +460,11 @@ async function opListBySession(user, response) {
     await _ensureColumns();
     rows = await q.find({ useMasterKey: true });
   }
+  var list = _toFiles(rows);
   return _ok(response, {
     threadRoot: threadRoot,
-    files: rows.map(_toFile),
-    count: rows.length,
+    files: list,
+    count: list.length,
   });
 }
 
@@ -316,7 +483,112 @@ async function opListByAgent(user, response) {
   q.limit(Math.min(Number(p.limit) || DEFAULT_LIMIT, MAX_LIMIT));
 
   var rows = await q.find({ useMasterKey: true });
-  return _ok(response, { files: rows.map(_toFile), count: rows.length });
+  var list = _toFiles(rows);
+  return _ok(response, { files: list, count: list.length });
+}
+
+/**
+ * listByGroup —— 按**群**拉文件(v0.2.0,2026-11-11)
+ *
+ * ## 为什么不能只用 threadRoot
+ *
+ * 群里的文件有两种来源:
+ *   · **用户上传**:写入时 `threadRoot = groupId`(历史数据也只有 threadRoot)
+ *   · **容器产出**:容器只知道 DM thread,由服务端反查/补归并后
+ *     `threadRoot` 改写为群 id、同时补 `groupId`
+ *
+ * 查询用 `groupId == g OR threadRoot == g`,两条来源都能覆盖,
+ * 也让**没有 groupId 的存量群内上传记录**照样查得到(无需数据迁移)。
+ */
+async function opListByGroup(user, response) {
+  var p = params || {};
+  var groupId = _str(p.groupId, LEN.threadRoot);
+  if (!groupId) return _fail(response, 400, 'groupId required');
+
+  var byGroup = new Parse.Query(CLASS_NAME);
+  byGroup.equalTo('user', user);
+  byGroup.equalTo('groupId', groupId);
+
+  var byThread = new Parse.Query(CLASS_NAME);
+  byThread.equalTo('user', user);
+  byThread.equalTo('threadRoot', groupId);
+
+  var q = Parse.Query.or(byGroup, byThread);
+  if (p.before) {
+    var before = new Date(Number(p.before));
+    if (!isNaN(before.getTime())) q.lessThan('deliveredAt', before);
+  }
+  q.descending('deliveredAt');
+  q.limit(Math.min(Number(p.limit) || DEFAULT_LIMIT, MAX_LIMIT));
+
+  var rows;
+  try {
+    rows = await q.find({ useMasterKey: true });
+  } catch (e) {
+    if (!_isMissingColumn(e)) throw e;
+    await _ensureColumns();
+    rows = await q.find({ useMasterKey: true });
+  }
+  var list = _toFiles(rows);
+  return _ok(response, { groupId: groupId, files: list, count: list.length });
+}
+
+/**
+ * claimToGroup —— 惰性补归并(v0.2.0,2026-11-11)
+ *
+ * 有些容器产出登记时没能确定归属群(`groupId=''`)。群页面打开时,
+ * App 把「有把握属于本群」的记录 id 传进来,这里把它们**搬**到群上:
+ * 补 `groupId`、把 `threadRoot` 改写为群 id。
+ *
+ * 🔴 改写 threadRoot 是「互斥」的落地:一条文件只能出现在一个页签 ——
+ *    搬进群之后,单聊 `listBySession(单聊sid)` 自然查不到它了。
+ *
+ * 安全:只允许操作**本人**的、`uploadedBy='agent'`(容器产出)、
+ *      且当前 `groupId` 为空的记录。用户上传的文件不允许被搬。
+ */
+async function opClaimToGroup(user, response) {
+  var p = params || {};
+  var groupId = _str(p.groupId, LEN.threadRoot);
+  var ids = Array.isArray(p.ids) ? p.ids.filter((x) => typeof x === 'string' && x) : [];
+  if (!groupId) return _fail(response, 400, 'groupId required');
+  if (ids.length === 0) return _fail(response, 400, 'ids required');
+  if (!(await _isOwnGroup(user, groupId))) {
+    return _fail(response, 400, 'groupId must be your own group session');
+  }
+
+  var claimed = 0;
+  var skipped = 0;
+  for (var i = 0; i < ids.length && i < 100; i++) {
+    var obj;
+    try {
+      var q = new Parse.Query(CLASS_NAME);
+      q.equalTo('user', user);
+      obj = await q.get(ids[i], { useMasterKey: true });
+    } catch (e) {
+      skipped++;
+      continue;
+    }
+    if (!obj) {
+      skipped++;
+      continue;
+    }
+    if (obj.get('uploadedBy') !== 'agent' || obj.get('groupId')) {
+      skipped++;
+      continue;
+    }
+    try {
+      /* 留一份原 threadRoot,便于出问题时还原 */
+      if (!obj.get('prevThreadRoot')) obj.set('prevThreadRoot', obj.get('threadRoot') || '');
+      obj.set('groupId', groupId);
+      obj.set('groupIdSource', 'claim');
+      obj.set('threadRoot', groupId);
+      await obj.save(null, { useMasterKey: true });
+      claimed++;
+    } catch (e) {
+      skipped++;
+    }
+  }
+  return _ok(response, { groupId: groupId, claimed: claimed, skipped: skipped });
 }
 
 // ==================== 入口 ====================
@@ -334,6 +606,9 @@ function handler(request, response) {
       if (act === 'registerFile') return await opRegisterFile(user, response);
       if (act === 'listBySession') return await opListBySession(user, response);
       if (act === 'listByAgent') return await opListByAgent(user, response);
+      /* v0.2.0(2026-11-11):群隔离 */
+      if (act === 'listByGroup') return await opListByGroup(user, response);
+      if (act === 'claimToGroup') return await opClaimToGroup(user, response);
       return _fail(response, 400, 'unknown action: ' + act);
     } catch (e) {
       /*

+ 166 - 0
cloud/functions/fmode-file-read.js

@@ -0,0 +1,166 @@
+// fmode-file-read v0.1.0 — 文件正文代理(读云盘文本)
+//
+// ## 为什么需要它
+//
+// 云盘 s3.fmode.cn 是 **public-read**(curl 能拿到 200 和内容),但**完全没配 CORS**
+// (实测响应里没有任何 Access-Control-* 头)。浏览器里页面脚本 fetch 它
+// 必然被拦,界面只能显示「加载失败:Failed to fetch」——
+// 用户点开容器交付的 .md / .txt 什么都看不到。
+//
+// 浏览器**顶层导航**(新窗口打开)不受 CORS 限制,那是兜底;
+// 但要在**页内渲染 Markdown** 就必须拿到文本,只能服务端代取。
+//
+// 本函数:服务端 fetch 云盘文件 → 原样返回文本。浏览器只调
+// server.fmode.cn/api/functions(该域已配 ACAO:*),不直接碰 S3。
+//
+// ## 安全约束(三条缺一不可)
+//
+//   ① **SSRF 防护**:只放行 s3.fmode.cn / file-cloud.fmode.cn 两个域名,
+//      与 fmode-file-hub 的 _isCloudUrl 同口径。否则这个接口会变成
+//      "随便读内网地址"的开放代理。
+//   ② **鉴权**:必须带有效 session token(与 fmode-file-hub 同做法)。
+//   ③ **大小上限**:文本预览用不了大文件,超过 MAX_TEXT_BYTES 直接拒绝,
+//      防止一个 20MB 的二进制把函数内存打爆。
+//
+// ## 调用
+//
+//   POST /api/functions {token, id:<Function objectId>, params:{action:'readText', url}}
+//   返回 {code:200, text, truncated, bytes}
+//
+// ## 注意
+//
+//   · 平台约定:必须显式 response.status(200).json(...),不能靠自动返回
+//   · 云盘对象是 public-read,所以服务端 fetch **不需要**带签名
+
+var MAX_TEXT_BYTES = 2 * 1024 * 1024; // 2MB:文本预览足够,再大就不是"预览"了
+var FETCH_TIMEOUT_MS = 15000;
+
+function _ok(response, data) {
+  var body = { code: 200 };
+  if (data) for (var k in data) body[k] = data[k];
+  response.status(200).json(body);
+}
+
+function _fail(response, code, msg) {
+  response.status(200).json({ code: code, msg: msg });
+}
+
+function _str(v, max) {
+  if (v === undefined || v === null) return '';
+  var s = String(v).trim();
+  return max && s.length > max ? s.slice(0, max) : s;
+}
+
+function _sessionToken() {
+  // 与 fmodeagent-status / fmode-file-hub 同口径:请求头优先(平台实测只保留请求头)
+  if (request.headers && request.headers['x-parse-session-token']) {
+    return request.headers['x-parse-session-token'];
+  }
+  if (params && params.token) return params.token;
+  if (request.body && request.body.token) return request.body.token;
+  return null;
+}
+
+async function _getUser(t) {
+  if (!t) return null;
+  if (request.user && request.user.id) return request.user;
+  try {
+    var q = new Parse.Query(Parse.Session);
+    q.equalTo('sessionToken', t);
+    q.include('user');
+    var s = await q.first({ useMasterKey: true });
+    return s ? s.get('user') : null;
+  } catch (e) {
+    return null;
+  }
+}
+
+/** 与 fmode-file-hub 的 _isCloudUrl 同口径:只认本平台云盘域名 */
+function _isCloudUrl(u) {
+  return /^https:\/\/(s3|file-cloud)\.fmode\.cn\//i.test(u);
+}
+
+/** 带超时的 fetch(平台支持全局 fetch;不加超时的话慢文件会拖死函数) */
+async function _fetchWithTimeout(url) {
+  var controller = typeof AbortController === 'function' ? new AbortController() : null;
+  var timer = controller ? setTimeout(function () { controller.abort(); }, FETCH_TIMEOUT_MS) : null;
+  try {
+    return await fetch(url, controller ? { signal: controller.signal } : undefined);
+  } finally {
+    if (timer) clearTimeout(timer);
+  }
+}
+
+/**
+ * readText —— 读云盘文本文件
+ *
+ * params: { url }
+ */
+async function opReadText(user, response) {
+  var p = params || {};
+  var url = _str(p.url, 2000);
+
+  if (!url) return _fail(response, 400, 'url required');
+  /* ① SSRF:只放行本平台云盘域名 */
+  if (!_isCloudUrl(url)) return _fail(response, 400, 'only cloud storage urls are allowed');
+
+  var res;
+  try {
+    res = await _fetchWithTimeout(url);
+  } catch (e) {
+    return _fail(response, 502, 'fetch failed: ' + (e && e.message ? e.message : 'unknown'));
+  }
+  if (!res.ok) return _fail(response, res.status === 404 ? 404 : 502, 'upstream ' + res.status);
+
+  /* ③ 大小上限:先看 Content-Length,没有就按实际读取量截断 */
+  var len = Number(res.headers.get('content-length'));
+  if (isFinite(len) && len > MAX_TEXT_BYTES) {
+    return _fail(response, 413, 'file too large for text preview');
+  }
+
+  var buf;
+  try {
+    buf = await res.arrayBuffer();
+  } catch (e) {
+    return _fail(response, 502, 'read failed');
+  }
+
+  var truncated = false;
+  var bytes = buf.byteLength;
+  if (bytes > MAX_TEXT_BYTES) {
+    buf = buf.slice(0, MAX_TEXT_BYTES);
+    bytes = MAX_TEXT_BYTES;
+    truncated = true;
+  }
+
+  /*
+   * 只返回文本。二进制(图片/压缩包/PDF)不该走这个接口 ——
+   * 交给原地址直接展示即可,这里如实告知而不是返回一堆乱码。
+   */
+  var text;
+  try {
+    text = new TextDecoder('utf-8', { fatal: true }).decode(new Uint8Array(buf));
+  } catch (e) {
+    return _fail(response, 415, 'not a utf-8 text file');
+  }
+
+  return _ok(response, { text: text, truncated: truncated, bytes: bytes });
+}
+
+// ==================== 入口 ====================
+
+function handler(request, response) {
+  (async function () {
+    var token = _sessionToken();
+    if (!token) return _fail(response, 401, 'unauthorized');
+
+    var user = await _getUser(token);
+    if (!user) return _fail(response, 401, 'invalid session');
+
+    var action = _str(params && params.action, 40) || 'readText';
+    if (action === 'readText') return opReadText(user, response);
+    return _fail(response, 400, 'unknown action: ' + action);
+  })().catch(function (e) {
+    _fail(response, 500, 'server error: ' + (e && e.message ? e.message : 'unknown'));
+  });
+}

+ 624 - 0
cloud/functions/fmodeagent-status.js

@@ -0,0 +1,624 @@
+// FmodeAgent 状态上报云函数 (v2 路由云函数化 · 免重启)
+// 调用: POST /api/functions {token, path:"/fmodeagent/status", params:{...}}
+// params.op = 'update' | 'query'(默认) | 'meta'; 亦可按入参自动判别
+// update: {agentId, field, value, companyId?}  白名单字段级更新+lastField记录
+// query:  {scope: mine|company|all, agentId?, fields?, companyId?}
+// 响应格式与旧 REST /api/fmodeagent/status 完全一致(看板无缝切换)
+//
+// ===== v0.0.3 (round7 2026-09-06) 变更说明 =====
+// 1. UPDATABLE 白名单扩列: 新增 botName/botAvatar/ownerName/ownerMobile/balance/channels/extra
+//    (reporter v3 采集: 机器人身份/主人身份/new-api余额/通道开关/其余原始数据JSON)
+// 2. params.companyId 显式规则(放宽写明, 版本注释): 三者之一可写 ——
+//    ① 系统超管(FMODE_AGENT_SUPERADMIN)  ② 用户无公司(userCompany 为空)  ③ 用户属于该公司(companyId===用户公司)
+//    其余契约(四级ACL/三视角/meta/owner归属校验)不变
+//
+// ===== v0.0.4 (round8 2026-09-06) 变更说明 (只增不改, v0.0.3 全部 ACL/白名单逻辑保留) =====
+// 1. 主人信息以记录的 user 指针为唯一真相: 查询侧 q.include('user'),
+//    ownerName = user.name || user.nickname || user.realName
+//    ownerMobile = user.mobile || user.mobilePhoneNumber
+//    ownerObjectId 一并返回(每条 agent JSON 附 userObjectId)
+//    说明: reporter 侧 ownerName/ownerMobile 仍按上报原样保留(容器 token 失效时为空,
+//    云函数以 user 指针实时兜底, 看板取值不依赖上报态)
+// 2. balance 改为主人 token 实时计算: user 对象上直接取 fmodeApiToken||newapiToken,
+//    调 GET https://api.fmode.cn/v1/dashboard/billing/subscription + /usage (Bearer 该 key),
+//    返回 {hardLimitUsd, usedUsd, remainUsd, remainRmb=remainUsd*7.2}
+//    安全约定: key 绝不入库(不 set 到任何 Parse 对象) / 绝不进日志 / 仅函数内存瞬时使用
+//    失败或无 token → balance=null(看板显「未配置」)
+// 3. botName = 记录.botName || channels.wecomBotId 前 8 位兜底 || agentId
+//    botAvatar = 记录.botAvatar || DEFAULT_BOT_AVATAR
+// 4. 查询响应附 balanceSource('owner_token'|'none') 便于运维定位
+//
+// ===== v0.0.5 (round9/platform-v5 2026-09-07) 变更说明 (只增不改, v0.0.4 全部 ACL/白名单逻辑保留) =====
+// 1. 查询条目透传 reporter v6 上报的通道配置: channels.matrixConfig={homeserver,userId,serverName}
+//    与 channels.wecomConfig={botId,corpName} 原样附到 agent JSON (看板/App 悬浮展示)
+// 2. 余额回退: 云函数实时余额失败(无 fmodeApiToken)时, 回退 reporter v6① sessionToken 通道
+//    上报的 {remainCny,usedCny,totalCalls,source:'session'}; balanceSource 透传
+//    'session'|'apikey'|'owner_token'|'none' —— 无 key 容器也能显示余额
+// 3. UPDATABLE 白名单已含 channels/balance, 无需扩列; 生产 parse 禁重启, 本函数经
+//    Parse REST PUT /parse/classes/Function/xlosi3m46q 热更
+// ===== v0.0.6 (TASK-matrix-v3 P2a 2026-09-08) 变更说明 (只增不改) =====
+// 1. UPDATABLE/FIELDS 白名单扩列: sessionStatus (reporter v8 探活上报:
+//    valid|expired|unknown — FMODE_SESSION_TOKEN 调 /parse/users/me, 看板/App 健康灯)
+// ===== v0.0.7 (TASK-matrix-v4 P1/P4e 2026-09-08) 变更说明 (只增不改) =====
+// 1. FIELDS 白名单扩列: gender (male/female/unknown, reporter v9 上报, App TTS 声线用)
+// 2. botAvatar 兜底升级: 记录值 > BotRegistry(by channels.wecomBotId) > DEFAULT;
+//    reporter v9 起 cron 注入的默认飞马 logo 不再直传覆盖, 注册表头像可透出
+// ===== v0.0.8 (TASK-matrix-v42 P1 2026-09-09) 变更说明 (只增不改) =====
+// 1. 余额口径 ¥化 (任务书 P1: balanceCny 权威字段):
+//    _fetchBalance 主通道 sk- 原返回 {hardLimitUsd,usedUsd,remainUsd,remainRmb=USD*7.2}
+//    (new-api subscription hard_limit 是 100000000 占位值 → remainUsd 无意义)。
+//    改调 newapi GET https://api.fmode.cn/api/usage/token/ (Bearer sk-) →
+//    data.total_available = quota → remainCny = quota / 500000 * rate
+//    (QUOTA_PER_UNIT=500000 与 api.fmode.cn/api/status quota_per_unit 一致;
+//     rate 取 options.USDExchangeRate, 默认 6.8 — future-server enterprise-billing-service
+//     quotaToCny 同公式; 平台网关 /api/status price=7.3 做上限参考不采用)
+//    兼容: total_available 为负(unlimited+已超)也如实上报; usedCny=total_used/500000*rate
+// 2. 余额兜底链 (查询侧): owner_token(sk-) 实时 → reporter session 通道 remainCny →
+//    旧 remainRmb → null(未配置)。App 端 balanceText 只认 remainCny → ¥xx.xx
+//    (v3 旧 apikey 通道的 remainRmb 由 _balanceFromReport 升级换算 remainCny)
+var CLASS_NAME = 'FmodeAgent';
+var SUPER_ROLE = 'FMODE_AGENT_SUPERADMIN';
+var FIELDS = ['status','currentTask','currentAction','progress','name','containerRef','host','heartbeatAt',
+              'botName','botAvatar','gender','ownerName','ownerMobile','balance','channels','extra','sessionStatus',
+              // v0.1.0: 用量两字段 (只增不删, 规则 17)
+              'usage','usageSessions'];
+// ===== v0.1.0 (fmode-app 一期) 新增: token 用量 =====
+// 白名单与容器侧 USAGE_FIELDS 成对维护 (校验: node scripts/check-reporter-fields.mjs)
+var USAGE_FIELDS = ['sessionId','title','model','messageCount','toolCallCount',
+                    'inputTokens','outputTokens','cacheReadTokens','reasoningTokens',
+                    'estimatedCostUsd','costStatus','startedAt'];
+var DEFAULT_BOT_AVATAR = 'https://app.fmode.cn/logo/feima.ico';
+var BOT_REGISTRY = 'BotRegistry';
+var BILLING_BASE = 'https://api.fmode.cn/v1/dashboard/billing';
+var NEWAPI_TOKEN_USAGE = 'https://api.fmode.cn/api/usage/token/'; // v0.0.8: quota 权威来源
+var QUOTA_PER_UNIT = 500000;   // v0.0.8: api.fmode.cn/api/status quota_per_unit 同值
+var USD2RMB = 7.2;
+var USD2RMB_DEFAULT = 6.8;     // v0.0.8: options.USDExchangeRate 缺省 (future-server 同默认)
+// v0.0.9 (2026-11-11): 用户余额权威源 —— 与 App Studio 充值页 /dev/studio/balance 同源
+var PLATFORM_API = 'https://server.fmode.cn/api/fmode';
+function _fail(code, mess) { response.status(code).json({ code: code, mess: mess }); }
+function _ok(data) { response.status(200).json(Object.assign({ code: 200 }, data)); }
+
+function _sessionToken() {
+  /*
+   * 🔴 2026-10-31 修复:平台不再把 token 塞进 request.body,只保留在
+   *    X-Parse-Session-Token 请求头里。原实现读 params.sessionToken 与
+   *    request.body.token 都拿不到 → 恒返回 null → 所有请求 401
+   *    (实测:同一 token 在 /parse/users/me 返回 200,在本函数返回 401)。
+   *    这里加上请求头来源(只增不改,旧调用方式保持有效)。
+   */
+  var h = request && request.headers ? request.headers : {};
+  if (h['x-parse-session-token']) return h['x-parse-session-token'];
+  if (params && params.sessionToken) return params.sessionToken;
+  if (request && request.body && request.body.token) return request.body.token;
+  return null;
+}
+async function _getUser(t) {
+  if (!t) return null;
+  if (request.user && request.user.id && !(params && params.sessionToken)) return request.user;
+  try {
+    var q = new Parse.Query(Parse.Session);
+    q.equalTo('sessionToken', t);
+    var s = await q.first({ useMasterKey: true });
+    return s ? s.get('user') : null;
+  } catch (e) { return null; }
+}
+async function _roleUserIds(name) {
+  try {
+    var q = new Parse.Query(Parse.Role);
+    q.equalTo('name', name);
+    var role = await q.first({ useMasterKey: true });
+    if (!role) return [];
+    var us = await role.getUsers().query().find({ useMasterKey: true });
+    return us.map(function (u) { return u.id; });
+  } catch (e) { return []; }
+}
+async function _userCompany(u) {
+  try {
+    var ptr = u.get('company');
+    if (!ptr) return null;
+    var name = '';
+    try { var c = await ptr.fetch({ useMasterKey: true }); name = (c && c.get('name')) || ''; } catch (_) {}
+    return { id: ptr.id, name: name, pointer: ptr };
+  } catch (e) { return null; }
+}
+function _buildACL(userId, companyId) {
+  var acl = new Parse.ACL();
+  acl.setPublicReadAccess(false); acl.setPublicWriteAccess(false);
+  acl.setReadAccess(userId, true); acl.setWriteAccess(userId, true);
+  acl.setRoleReadAccess(SUPER_ROLE, true); acl.setRoleWriteAccess(SUPER_ROLE, true);
+  if (companyId) {
+    acl.setRoleReadAccess(companyId + '_Admin', true);
+    acl.setRoleWriteAccess(companyId + '_Admin', true);
+    acl.setRoleReadAccess(companyId + '_Public', true);
+  }
+  return acl;
+}
+async function _findOrRegister(agentId, user, company, acl) {
+  var q = new Parse.Query(CLASS_NAME);
+  q.equalTo('agentId', agentId);
+  var obj = await q.first({ useMasterKey: true });
+  if (!obj) {
+    var A = Parse.Object.extend(CLASS_NAME);
+    obj = new A();
+    obj.set('agentId', agentId);
+    obj.set('user', user);
+    if (company && company.pointer) obj.set('company', company.pointer);
+    obj.setACL(acl);
+    await obj.save(null, { useMasterKey: true });
+  }
+  return obj;
+}
+function _agentJSON(a, names) {
+  var o = a.toJSON();
+  o.heartbeatAgeSec = a.updatedAt ? Math.floor((Date.now() - a.updatedAt.getTime()) / 1000) : null;
+  var c = a.get('company');
+  if (c) o.company = { objectId: c.id, name: (names && names[c.id]) || '' };
+  return o;
+}
+async function _companyNames(list) {
+  var ids = [], seen = {};
+  list.forEach(function (a) { var c = a.get('company'); if (c && !seen[c.id]) { seen[c.id] = 1; ids.push(c.id); } });
+  var map = {};
+  for (var i = 0; i < ids.length; i++) {
+    try { var c = await new Parse.Query('Company').get(ids[i], { useMasterKey: true }); map[ids[i]] = (c && c.get('name')) || ''; }
+    catch (_) { map[ids[i]] = ''; }
+  }
+  return map;
+}
+
+// ===== v0.0.4: 主人信息以 user 指针为唯一真相 =====
+function _ownerFromUser(u) {
+  if (!u) return { ownerName: '', ownerMobile: '', userObjectId: null };
+  return {
+    ownerName: u.get('name') || u.get('nickname') || u.get('realName') || '',
+    ownerMobile: u.get('mobile') || u.get('mobilePhoneNumber') || '',
+    userObjectId: u.id
+  };
+}
+
+// ===== v0.0.4: 主人 token 余额 (key 只进内存, 不入库不入日志) =====
+// 执行器用 new Function(context={Parse,request,response,params,user}) 包装, 作用域内无 require;
+// Deno 运行时有全局 fetch, 故用 fetch + AbortController
+// ===== v0.0.8 余额主通道: sk- → newapi /api/usage/token/ → quota → ¥ (任务书 P1 balanceCny 口径)
+// 旧 subscription+usage 通道保留为 fallback (hard_limit_usd=100000000 是占位 → remainUsd 无意义)
+function _httpsGetJson(url, headers, timeoutMs) {
+  return new Promise(function (resolve) {
+    try {
+      if (typeof fetch !== 'function') { resolve(null); return; }
+      var ctrl = typeof AbortController === 'function' ? new AbortController() : null;
+      var t = ctrl ? setTimeout(function () { try { ctrl.abort(); } catch (e) {} }, timeoutMs || 8000) : null;
+      fetch(url, { headers: headers || {}, signal: ctrl ? ctrl.signal : undefined })
+        .then(function (res) { return res.text(); })
+        .then(function (txt) {
+          if (t) clearTimeout(t);
+          try { resolve(JSON.parse(txt)); } catch (e) { resolve(null); }
+        })
+        .catch(function () { if (t) clearTimeout(t); resolve(null); });
+    } catch (e) { resolve(null); }
+  });
+}
+async function _fetchBalance(user, sessionToken) {
+  try {
+    if (!user) return { balance: null, source: 'none' };
+    var uid = user.id || '';
+    /*
+     * v0.0.9 (2026-11-11) 余额口径修复
+     *
+     * 🔴 旧实现(v0.0.8)用 fmodeApiToken 调 /api/usage/token/ 取 total_available 再自行换汇。
+     *    该 token 实际是 unlimited_quota:true 的无限额度令牌, total_available 不是用户余额
+     *    → 线上实测 App 显示 -76.12, 而 Studio 充值页是 948.09。
+     *
+     * 新口径(与 Studio /dev/studio/balance 同源, 不再自行换算):
+     *   ① /api/fmode/billing/query {include:['balance']} → data.balance.availableCny / usedCny
+     *   ② 失败回落 /api/fmode/newapi/user {sessionToken} → data.user.balance / used_balance
+     * 缓存: 键含用户 objectId(多账号隔离) + TTL 60s(充值后 1 分钟内可见)。
+     */
+    var cache = null;
+    try { cache = globalThis.__FM9_BAL_CACHE = globalThis.__FM9_BAL_CACHE || {}; } catch (e) { cache = {}; }
+    var ck = 'u:' + uid;
+    var hit = cache[ck];
+    if (hit && (Date.now() - hit.ts) < 60000) {
+      return { balance: hit.balance, source: 'owner_token' };
+    }
+    var hdr = { 'Content-Type': 'application/json' };
+    if (sessionToken) hdr['Authorization'] = 'Bearer ' + sessionToken;
+
+    var balanceCny = null;
+    // ① 平台 billing(权威)
+    if (sessionToken) {
+      var bq = await _httpsPostJson(PLATFORM_API + '/billing/query', hdr, { include: ['balance'] });
+      var bb = bq && bq.data ? bq.data.balance : null;
+      if (bb && typeof bb.availableCny === 'number') {
+        balanceCny = {
+          remainCny: Math.round(bb.availableCny * 100) / 100,
+          usedCny: typeof bb.usedCny === 'number' ? Math.round(bb.usedCny * 100) / 100 : undefined,
+          totalCalls: null,
+          quota: typeof bb.availableQuota === 'number' ? bb.availableQuota : null,
+          source: 'owner_token'
+        };
+      }
+    }
+    // ② 回落: newapi 用户信息(Studio 同源)
+    if (!balanceCny && sessionToken) {
+      var nu = await _httpsPostJson(PLATFORM_API + '/newapi/user', hdr, { sessionToken: sessionToken });
+      var uo = nu && nu.data ? nu.data.user : null;
+      if (uo && typeof uo.balance === 'number') {
+        balanceCny = {
+          remainCny: Math.round(uo.balance * 100) / 100,
+          usedCny: typeof uo.used_balance === 'number' ? Math.round(uo.used_balance * 100) / 100 : undefined,
+          totalCalls: parseInt(uo.request_count, 10) || null,
+          quota: parseInt(uo.remaining_quota, 10) || null,
+          source: 'owner_token'
+        };
+      }
+    }
+    if (balanceCny) {
+      try { cache[ck] = { ts: Date.now(), balance: balanceCny }; } catch (e) {}
+      return { balance: balanceCny, source: 'owner_token' };
+    }
+    // ③ 最后回落: 旧令牌通道(仅在无 sessionToken 时发生), 保留但不再优先
+    var key = user.get('fmodeApiToken') || user.get('newapiToken') || '';
+    if (!key) return { balance: null, source: 'none' };
+    var tu = await _httpsGetJson(NEWAPI_TOKEN_USAGE, { Authorization: 'Bearer ' + key });
+    /* unlimited_quota 令牌的 total_available 不是用户余额, 明确拒绝(旧 bug 根因) */
+    if (tu && tu.data && tu.data.total_available != null && !tu.data.unlimited_quota) {
+      var legacy = {
+        remainCny: Math.round(tu.data.total_available / QUOTA_PER_UNIT * USD2RMB_DEFAULT * 100) / 100,
+        usedCny: Math.round((parseFloat(tu.data.total_used) || 0) / QUOTA_PER_UNIT * USD2RMB_DEFAULT * 100) / 100,
+        totalCalls: parseInt(tu.data.request_count, 10) || null,
+        quota: Math.round(parseFloat(tu.data.total_available) || 0),
+        source: 'owner_token'
+      };
+      try { cache[ck] = { ts: Date.now(), balance: legacy }; } catch (e) {}
+      return { balance: legacy, source: 'owner_token' };
+    }
+    return { balance: null, source: 'none' };
+  } catch (e) { return { balance: null, source: 'none' }; }
+}
+
+/** POST JSON 并在失败/非 JSON 时返回 null(与 _httpsGetJson 同风格) */
+function _httpsPostJson(url, headers, body, timeoutMs) {
+  return new Promise(function (resolve) {
+    try {
+      if (typeof fetch !== 'function') { resolve(null); return; }
+      var ctrl = typeof AbortController === 'function' ? new AbortController() : null;
+      var t = ctrl ? setTimeout(function () { try { ctrl.abort(); } catch (e) {} }, timeoutMs || 8000) : null;
+      fetch(url, { method: 'POST', headers: headers || {}, body: JSON.stringify(body || {}), signal: ctrl ? ctrl.signal : undefined })
+        .then(function (res) { return res.text(); })
+        .then(function (txt) {
+          if (t) clearTimeout(t);
+          try { resolve(JSON.parse(txt)); } catch (e) { resolve(null); }
+        })
+        .catch(function () { if (t) clearTimeout(t); resolve(null); });
+    } catch (e) { resolve(null); }
+  });
+}
+
+// 简单字符串哈希(仅作缓存键, 不存原 token)
+function _keyHash(s) {
+  var h = 5381;
+  for (var i = 0; i < s.length; i++) { h = ((h << 5) + h + s.charCodeAt(i)) >>> 0; }
+  return 'k' + h.toString(36);
+}
+
+// ===== v0.0.4: 查询条目增强 (user 指针主人 + 余额 + botName/botAvatar 兜底) =====
+async function _agentJSONv4(a, names, sessionToken) {
+  var o = _agentJSON(a, names);
+  var u = a.get('user'); // 查询时 include('user')
+  var own = _ownerFromUser(u);
+  o.ownerName = own.ownerName || o.ownerName || '';
+  o.ownerMobile = own.ownerMobile || o.ownerMobile || '';
+  o.userObjectId = own.userObjectId;
+  var bal = await _fetchBalance(u, sessionToken);
+  o.balance = bal.balance; // 主人 token 实时计算, 覆盖 reporter 旧值(契约: 云函数为准)
+  o.balanceSource = bal.source;
+  var ch = {};
+  try { ch = typeof a.get('channels') === 'string' ? JSON.parse(a.get('channels') || '{}') : (a.get('channels') || {}); } catch (e) { ch = {}; }
+  o.botName = a.get('botName') || (ch && ch.wecomBotId ? String(ch.wecomBotId).slice(0, 8) : '') || o.agentId || '';
+  // v0.0.7: 头像链 记录值(排除默认飞马) > BotRegistry(by wecomBotId) > DEFAULT
+  var recAvatar = a.get('botAvatar');
+  if (recAvatar === DEFAULT_BOT_AVATAR) recAvatar = null;
+  var gender = a.get('gender');
+  if (recAvatar || !gender) {
+    try {
+      var BotReg = Parse.Object.extend(BOT_REGISTRY);
+      var q = new Parse.Query(BotReg);
+      if (ch && ch.wecomBotId) q.equalTo('botid', String(ch.wecomBotId));
+      else { q.equalTo('agentId', o.agentId || ''); }
+      q.limit(1);
+      var reg = await q.first({ useMasterKey: true });
+      if (reg) {
+        if (!recAvatar && reg.get('avatar')) recAvatar = reg.get('avatar');
+        if (!gender && reg.get('gender')) gender = reg.get('gender');
+      }
+    } catch (e) { /* BotRegistry 不可用不阻塞查询 */ }
+  }
+  o.botAvatar = recAvatar || DEFAULT_BOT_AVATAR;
+  o.gender = gender || 'unknown';
+  return o;
+}
+
+// ===== v0.0.5 (round9/platform-v5 2026-09-07) 通道配置透传 =====
+// reporter v6③ 采集: channels.matrix 真活检测(GET {MATRIX_HOMESERVER}/_matrix/client/versions)
+//   + channels.matrixConfig={homeserver,userId,serverName} + channels.wecomConfig={botId,corpName}
+//   + balance={remainCny,usedCny,totalCalls,source:'session'}(reporter v6① sessionToken 通道)
+// 查询条目透传规则(只增不改, v0.0.4 全部逻辑保留):
+//   1) _balanceFromReport: 云函数实时余额失败(balance=null)时回退 reporter 上报的
+//      {remainCny,usedCny,totalCalls,source} —— 无 fmodeApiToken 主人的容器也能显示余额;
+//      balanceSource 透传 'session'|'apikey'|'owner_token'|'none' 便于运维定位
+//   2) channels.matrixConfig / channels.wecomConfig 原样透传到 agent JSON
+//      (看板/App 悬浮显示具体配置参数)
+function _parseCh(a) {
+  try {
+    var raw = a.get('channels');
+    return typeof raw === 'string' ? JSON.parse(raw || '{}') : (raw || {});
+  } catch (e) { return {}; }
+}
+function _balanceFromReport(a, ch) {
+  try {
+    var raw = a.get('balance');
+    var b = typeof raw === 'string' ? JSON.parse(raw || 'null') : raw;
+    if (!b || typeof b !== 'object') return null;
+    if (b.remainCny === undefined && b.remainCny === null && b.remainRmb === undefined) return null;
+    // v0.0.8 (P1): 只认 remainCny 权威; 旧 remainRmb(USD*7.2) 形态如实换算透传 (App 只显 ¥)
+    // remainRmb ≥ 7e8 = hard_limit 占位假大数 → 视为无记账 (null)
+    var remainCny = b.remainCny !== undefined && b.remainCny !== null ? b.remainCny
+      : (b.remainRmb !== undefined && b.remainRmb !== null ? (Math.abs(b.remainRmb) >= 7e8 ? null : b.remainRmb) : null);
+    if (remainCny === null && b.remainCny === undefined) return null;
+    return {
+      remainCny: remainCny,
+      usedCny: b.usedCny !== undefined ? b.usedCny : (b.usedUsd !== undefined ? b.usedUsd : null),
+      totalCalls: b.totalCalls !== undefined ? b.totalCalls : null,
+      source: b.source || 'none'
+    };
+  } catch (e) { return null; }
+}
+async function _agentJSONv5(a, names, sessionToken) {
+  var o = await _agentJSONv4(a, names, sessionToken);
+  var ch = _parseCh(a);
+  if (ch && typeof ch === 'object') {
+    if (ch.matrixConfig && typeof ch.matrixConfig === 'object') o.matrixConfig = ch.matrixConfig;
+    if (ch.wecomConfig && typeof ch.wecomConfig === 'object') o.wecomConfig = ch.wecomConfig;
+  }
+  if (!o.balance && o.balanceSource !== 'owner_token') {
+    var rep = _balanceFromReport(a, ch);
+    if (rep) { o.balance = rep; o.balanceSource = rep.source; }
+  }
+  return o;
+}
+
+// ===== v0.1.0 (fmode-app 一期): 用量落库 =====
+// 存储决策: 落在 FmodeAgent 表的 usage 字段 (JSON 字符串), **不另建表**。
+//   理由: 用量是「分身快照属性」(每个分身一份当前用量摘要), 符合 rules/05 规则 17
+//         「字段只增不删」; 前端实况面板可直接消费, 无需联表。
+// 字段级更新该字段需在 FIELDS 白名单内, 故 usage 也加入 FIELDS (见上)。
+function _pickUsage(list) {
+  var out = [];
+  if (!list || !list.length) return out;
+  for (var i = 0; i < list.length; i++) {
+    var src = list[i] || {};
+    var o = {};
+    for (var j = 0; j < USAGE_FIELDS.length; j++) {
+      var k = USAGE_FIELDS[j];
+      if (src[k] !== undefined) o[k] = src[k];
+    }
+    if (o.sessionId) out.push(o);
+  }
+  return out;
+}
+
+// 汇总: 供前端/看板直接读「总数」, 免去前端遍历
+function _sumUsage(list) {
+  var s = { sessionCount: list.length, inputTokens: 0, outputTokens: 0,
+            cacheReadTokens: 0, reasoningTokens: 0, totalTokens: 0,
+            estimatedCostUsd: 0, toolCallCount: 0, messageCount: 0 };
+  for (var i = 0; i < list.length; i++) {
+    var u = list[i];
+    s.inputTokens       += (parseFloat(u.inputTokens) || 0);
+    s.outputTokens      += (parseFloat(u.outputTokens) || 0);
+    s.cacheReadTokens   += (parseFloat(u.cacheReadTokens) || 0);
+    s.reasoningTokens   += (parseFloat(u.reasoningTokens) || 0);
+    s.estimatedCostUsd  += (parseFloat(u.estimatedCostUsd) || 0);
+    s.toolCallCount     += (parseFloat(u.toolCallCount) || 0);
+    s.messageCount      += (parseFloat(u.messageCount) || 0);
+  }
+  s.totalTokens = s.inputTokens + s.outputTokens;
+  s.estimatedCostUsd = Math.round(s.estimatedCostUsd * 10000) / 10000;
+  s.updatedAt = new Date().toISOString();
+  return s;
+}
+
+async function handler(request, response) {
+  try {
+    var op = (params && (params.op || params.action)) || null;
+    var user = await _getUser(_sessionToken());
+    if (!user) return _fail(401, 'SessionToken 无效或已过期');
+    var superIds = await _roleUserIds(SUPER_ROLE);
+    var superAdmin = superIds.indexOf(user.id) >= 0;
+    var userCompany = await _userCompany(user);
+
+    // ---- meta 自检(兼容独立 meta 函数, 本函数亦可 params.op='meta') ----
+    if (op === 'meta') {
+      var qm = new Parse.Query(CLASS_NAME); qm.equalTo('user', user);
+      var mineCount = await qm.count({ useMasterKey: true });
+      var companyCount = 0;
+      if (userCompany) {
+        var qc = new Parse.Query(CLASS_NAME); qc.equalTo('company', userCompany.pointer);
+        companyCount = await qc.count({ useMasterKey: true });
+      }
+      return _ok({
+        user: { objectId: user.id, username: user.get('username') },
+        isSuperAdmin: superAdmin,
+        company: userCompany ? { objectId: userCompany.id, name: userCompany.name } : null,
+        agentCount: { mine: mineCount, company: companyCount },
+        superAdminMembers: superIds.length
+      });
+    }
+
+    // ---- 字段级更新(POST /status 语义) ----
+    if (op === 'update' || (params && params.agentId && params.field !== undefined && params.field !== null)) {
+      var agentId = params.agentId, field = params.field, value = params.value, companyId = params.companyId;
+      if (!agentId) return _fail(400, '缺少 agentId');
+      if (!field) return _fail(400, '缺少 field(要更新的字段名)');
+      if (FIELDS.indexOf(field) < 0) return _fail(400, '字段 ' + field + ' 不允许通过该接口更新(白名单: ' + FIELDS.join(',') + ')');
+      // v0.0.3 companyId 显式规则(三者之一可写, 放宽并写明):
+      //   ① 超管  ② 用户无公司  ③ 用户属于该公司(companyId===用户公司id, 即下方"不符"分支之外的情形)
+      var company = userCompany;
+      if (companyId && (!userCompany || companyId !== userCompany.id)) {
+        var allowed = superAdmin || !userCompany || (userCompany && userCompany.id === companyId);
+        if (!allowed) return _fail(403, 'companyId(' + companyId + ') 与用户归属公司(' + (userCompany && userCompany.id) + ')不符且非超管(允许: 超管/无公司用户/本公司)');
+        try {
+          var comp = await new Parse.Query('Company').get(companyId, { useMasterKey: true });
+          company = { id: comp.id, name: (comp && comp.get('name')) || '', pointer: comp };
+        } catch (e) { return _fail(400, 'companyId 无效: ' + companyId); }
+      }
+      var acl0 = _buildACL(user.id, company && company.id);
+      var agent = await _findOrRegister(agentId, user, company, acl0);
+      var owner = agent.get('user');
+      if (!((owner && owner.id === user.id) || superAdmin)) return _fail(403, '无权更新该 Agent(归属校验失败)');
+      // 四级 ACL 围绕"记录归属人"构造(超管代写时保留原 owner 本人 RW, 不被写入者顶替)
+      var acl = _buildACL(owner ? owner.id : user.id, company && company.id);
+      agent.set(field, value);
+      agent.set('lastField', field);
+      agent.set('lastValue', typeof value === 'string' ? value : JSON.stringify(value));
+      if (company && company.pointer) agent.set('company', company.pointer);
+      agent.setACL(acl);
+      agent.set('updatedAtClient', new Date());
+      await agent.save(null, { useMasterKey: true });
+      return _ok({
+        agentId: agentId, field: field, objectId: agent.id,
+        company: company ? { objectId: company.id, name: company.name } : null,
+        superAdmin: superAdmin
+      });
+    }
+
+    // ---- v0.1.0 用量落库 (POST /status 语义, op='usage') ----
+    // 权限: 与 update 分支同口径 —— 归属校验(本人或超管) + company 三规则
+    if (op === 'usage' && params && Array.isArray(params.usage)) {
+      var uAgentId = params.agentId;
+      if (!uAgentId) return _fail(400, '缺少 agentId');
+      var uCompanyId = params.companyId;
+      var uCompany = userCompany;
+      if (uCompanyId && (!userCompany || uCompanyId !== userCompany.id)) {
+        var uAllowed = superAdmin || !userCompany || (userCompany && userCompany.id === uCompanyId);
+        if (!uAllowed) return _fail(403, 'companyId 与用户归属公司不符且非超管');
+        try {
+          var uComp = await new Parse.Query('Company').get(uCompanyId, { useMasterKey: true });
+          uCompany = { id: uComp.id, name: (uComp && uComp.get('name')) || '', pointer: uComp };
+        } catch (e) { return _fail(400, 'companyId 无效: ' + uCompanyId); }
+      }
+      var uAcl = _buildACL(user.id, uCompany && uCompany.id);
+      var uAgent = await _findOrRegister(uAgentId, user, uCompany, uAcl);
+      var uOwner = uAgent.get('user');
+      if (!((uOwner && uOwner.id === user.id) || superAdmin)) return _fail(403, '无权更新该 Agent(归属校验失败)');
+      var picked = _pickUsage(params.usage);
+      var summary = _sumUsage(picked);
+      uAgent.set('usage', JSON.stringify(summary));          // 汇总(前端直读)
+      uAgent.set('usageSessions', JSON.stringify(picked));   // 明细(按会话)
+      if (uCompany && uCompany.pointer) uAgent.set('company', uCompany.pointer);
+      uAgent.setACL(_buildACL(uOwner ? uOwner.id : user.id, uCompany && uCompany.id));
+      uAgent.set('updatedAtClient', new Date());
+      await uAgent.save(null, { useMasterKey: true });
+      return _ok({
+        agentId: uAgentId, objectId: uAgent.id,
+        sessionCount: picked.length, totalTokens: summary.totalTokens,
+        company: uCompany ? { objectId: uCompany.id, name: uCompany.name } : null
+      });
+    }
+
+    // ---- 查询(GET /status 语义, scope 三视角) ----
+    var _companyScopeUser = null; // v0.1.1 FALLBACK: 公司视角时记住当前用户,用于补自己的容器
+    var scope = (params && params.scope) || 'mine';
+    var qAgentId = params && params.agentId;
+    var q = new Parse.Query(CLASS_NAME);
+    if (scope === 'all') {
+      if (!superAdmin) return _fail(403, 'scope=all 仅系统超管可用');
+      // v0.1.2 ②: 超管可按 companyId 过滤(多账套切换用)
+      if (params && params.companyId) {
+        var _allComp;
+        try {
+          _allComp = await new Parse.Query('Company').get(params.companyId, { useMasterKey: true });
+        } catch (_ace) {
+          // v0.1.2 ③: 非法 companyId 明确报错(此前被静默忽略,返回全量)
+          return _fail(400, 'companyId 无效: ' + params.companyId);
+        }
+        q.equalTo('company', _allComp);
+      }
+    } else if (scope === 'company') {
+      var cid = (params && params.companyId) || (userCompany && userCompany.id);
+      if (cid && userCompany && cid !== userCompany.id && !superAdmin) return _fail(403, '无权查看其他公司账套(' + cid + ')');
+      if (!cid) return _fail(400, '无法确定公司账套(用户无 company 且未传 companyId)');
+      var scopeCompany;
+      try {
+        scopeCompany = await new Parse.Query('Company').get(cid, { useMasterKey: true });
+      } catch (_sce) {
+        return _fail(400, 'companyId 无效: ' + cid);
+      }
+      q.equalTo('company', scopeCompany);
+      // v0.1.1 FALLBACK: 标记「公司视角」,稍后补上「缺 company 但属于自己的容器」
+      _companyScopeUser = user;
+    } else {
+      q.equalTo('user', user);
+    }
+    if (qAgentId) q.equalTo('agentId', qAgentId);
+    if (params && params.fields) q.containedIn('status', String(params.fields).split(','));
+    q.include('user'); // v0.0.4: 主人信息以记录 user 指针为唯一真相
+    // v0.1.2 ①: 分页 —— 传了 limit/skip 才生效,不传保持原行为(1000 条)
+    // limit 上限 500 防拖库
+    var _limit = parseInt(params && params.limit, 10);
+    var _skip = parseInt(params && params.skip, 10);
+    var _paged = (_limit > 0);
+    if (_paged) q.limit(Math.min(_limit, 500));
+    else q.limit(1000);
+    if (_skip > 0) { try { q.skip(_skip); } catch (_ske) { return _fail(400, 'skip 参数不受支持'); } }
+    q.descending('updatedAt');
+    var _total = 0;
+    if (_paged || _skip > 0) {
+      // 🔴 count 是 Parse 标准 API;若环境不支持则降级为「本页条数」(不影响分页主流程)
+      try { _total = await q.count({ useMasterKey: true }); } catch (_tce) { _total = -1; }
+    }
+    var list = await q.find({ useMasterKey: true });
+    // v0.1.1 FALLBACK: 公司视角下,补上「company 为空但属于自己」的容器
+    //   兜底语义(用户裁决):缺 company 的容器**只有主人能看到**,同公司看不到
+    //   实现:额外查一次 user = 我 且 company 为空的记录,去重合并
+    if (_companyScopeUser) {
+      try {
+        var _selfQ = new Parse.Query(CLASS_NAME);
+        _selfQ.equalTo('user', _companyScopeUser);
+        _selfQ.include('user');
+        _selfQ.limit(500);
+        var _rawSelf = await _selfQ.find({ useMasterKey: true });
+        // 只用原版验证过的 API:查出来后过滤掉「已有 company」的(那些已在主查询里)
+        var _selfList = [];
+        for (var _rk = 0; _rk < _rawSelf.length; _rk++) {
+          if (!_rawSelf[_rk].get('company')) _selfList.push(_rawSelf[_rk]);
+        }
+        var _seen = {};
+        for (var _si = 0; _si < list.length; _si++) _seen[list[_si].id] = 1;
+        for (var _sj = 0; _sj < _selfList.length; _sj++) {
+          if (!_seen[_selfList[_sj].id]) list.push(_selfList[_sj]);
+        }
+      } catch (_se) { /* 兜底失败不影响主查询结果 */ }
+    }
+    var requestToken = _sessionToken();
+    var names = await _companyNames(list);
+    var agents = [];
+    for (var i = 0; i < list.length; i++) {
+      agents.push(await _agentJSONv5(list[i], names, requestToken)); // v0.0.5: v4 为主 + 通道配置透传/上报余额回退
+    }
+    if (qAgentId) {
+      if (!agents.length) return _fail(404, 'Agent 不存在或不在该视角范围内');
+      return _ok({ agent: agents[0], scope: scope, isSuperAdmin: superAdmin, company: userCompany ? { objectId: userCompany.id, name: userCompany.name } : null });
+    }
+    return _ok({
+      count: agents.length, agents: agents, scope: scope,
+      // v0.1.2 ①: 分页时返回分页前的总数(不传 limit/skip 时为 0,表示未分页)
+      total: _total,
+      isSuperAdmin: superAdmin,
+      company: userCompany ? { objectId: userCompany.id, name: userCompany.name } : null
+    });
+  } catch (e) {
+    return _fail(500, (e && e.message) || String(e));
+  }
+}

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

@@ -7368,3 +7368,298 @@ tr '\0' '\n' < /proc/$(pgrep -f 'hermes gateway run')/environ | grep REQUIRE_MEN
 | **测试调整** | `device.service.spec` 的「跨端提示」整组按裁决重写为「纯自动识别(视口优先)」5 条;计时用例统一走 `elapsedSeconds()`;附件用例断言改为「本地先插」 |
 | **验证** | `tsc --noEmit`(app + spec)✅ / `npm run build` ✅ / `npm test` **1522 SUCCESS**(合并前本项目基线 1466) |
 | **未验证项** | 浏览器双端实测(自动跳端、附件气泡是否出现重复气泡)需在 `agent-node-lj-test` 容器上人工确认 |
+
+---
+
+## 2026-11-11 · 工作台下线 + 余额口径核实 + lj-test 流程梳理
+
+| 项 | 内容 |
+|---|---|
+| **触发** | 用户要求:① 隐藏左侧路由栏「工作台」并禁止进入 `/desktop/workbench` ② 核实工作台数据同步是否准确(剩余额度显示不对)③ 梳理 lj-test 容器完整流程 |
+| **工作台隐藏范围(用户裁决)** | 三处入口全隐藏 + 禁 URL:桌面左栏 `dk-nav`、桌面顶栏 `dk-top-nav`、移动底栏 `mobile-shell`(4→3 Tab)、`NAV_CATALOG` 移除 `workbench`(不设 `defaultHidden`——否则用户仍能在「自定义菜单」打开) |
+| **禁 URL 方式** | `desktop.routes.ts` / `mobile.routes.ts` 的 `workbench` 改为 `redirectTo`(chat / messages),旧链接不留白屏;页面组件源码保留,恢复方式写在 `dk-nav.ts` 同处注释 |
+| **🔴 余额错因(实测)** | Studio 充值页 `/dev/studio/balance` = **948.09**(`newapi/user` 与 `billing/query` 两个接口一致);App 显示 **-76.12**。<br>· 云函数 `xlosi3m46q` 的 `_fetchBalance()` 取的是 `GET api.fmode.cn/api/usage/token/` 的 `total_available`,再 `÷500000×6.8` 自行换汇<br>· 该 token 实际是 **`unlimited_quota: true`** 的无限额度令牌,`total_available` 不是用户余额 → 取数维度错<br>· `usedCny 257` 与平台一致,错的只有 remain<br>· 缓存为进程级、5 分钟 TTL、不区分登录用户 → 充值后最长 5 分钟不刷新 |
+| **「每个容器余额一样」不是 bug** | 容器账单挂在主人账号上,实测 5 个容器全部 -76.12;UI 改为标注「账号余额」+ 悬浮说明(含已用金额) |
+| **App 侧改动** | `models.ts` `Balance` 增 `usedCny`/`sharedAccount`;`parse-api.ts mapAgent` 映射 `usedCny`、`source==='owner_token'` 标 `sharedAccount`;`format.ts fmtBalance` 负数显示为 `-¥ 76.12`(如实显示不截零);桌面工作台余额按钮改「账号余额」+ `ui-tooltip`;移动端充值说明弹窗讲清余额归属 |
+| **测试** | `format.spec` +1(负数);`parse-api.spec` +2(owner_token 共用标记、容器自报不标共用)。全量 **1484 SUCCESS**;`tsc` app/spec 均通过 |
+| **浏览器实测** | `/desktop/workbench` → `/desktop/chat`;`/workbench` → `/desktop/chat`;`/mobile/workbench` → `/mobile/messages`;桌面左栏无「工作台」;移动底栏 = 会话/通讯录/我的 |
+| **lj-test 实测(只读)** | `heartbeatAt` 停在 2026-11-11T00:38Z(≈9 小时前),`updatedAt` 未在跳 → **上报已停**;`host` 为 11-07 旧值;`usage` = 6,707,397 tokens / 1470 calls / 60 sessions;`currentTask=""` 无残留 |
+| **文档** | 新增 `docs/03-interface/lj-test容器完整流程-20261111.md`(v0.1,含上报/收发/文件上云/Token 与余额口径/复现验收清单;容器内部脚本与 cron 待容器只读回填,文中已附采集指令) |
+| **待做(用户已同意连云函数一起修)** | 云函数 `xlosi3m46q` 余额源改为 `billing/query`(`availableCny`)优先、`newapi/user`(`balance`)回落;不再用 unlimited token 的 `total_available`;缓存键加用户 objectId、TTL ≤60s。<br>**本轮未执行**:读写 `Function` 表需要 `PARSE_MASTER_KEY`,本机未配置(实测 400 `Permission denied for action get on class Function`)——需用户提供后再做,且须先 GET 线上函数本体以线上为准修改 |
+| **未验证项(披露)** | ① 云函数修复未做(缺 master key)② 余额界面在登录态下的目视确认:工作台已下线,仅代码/单测验证 ③ lj-test 内部脚本、cron、artifact drain 是否存在 —— 待容器回填 |
+
+### 2026-11-11(续)· 云函数 xlosi3m46q 余额口径修复(v0.0.9,已上线)
+
+| 项 | 内容 |
+|---|---|
+| **触发** | 用户提供 Parse Master-Key,要求连云函数一起修(此前 App 显示 -76.12,充值页 948.09) |
+| **改前** | 用 Master-Key GET 线上 `Function/xlosi3m46q` 全文(27,707 字节),**以线上为准**改,不推本地旧备份 `-v0.1.2.patched.js` |
+| **改了什么(只动余额)** | `_fetchBalance(user, sessionToken)` 重写:① `POST /api/fmode/billing/query {include:['balance']}` → `data.balance.availableCny` / `usedCny` ② 回落 `POST /api/fmode/newapi/user {sessionToken}` → `data.user.balance` ③ 旧 `usage/token` 通道仅保留在「无会话令牌」场景,且 `unlimited_quota:true` 明确拒绝(旧 bug 根因)④ 缓存改 `__FM9_BAL_CACHE`、键含用户 objectId、TTL 300s→**60s**。新增 `_httpsPostJson`(与 `_httpsGetJson` 同风格)。`_agentJSONv4/v5` 透传 `sessionToken`,查询入口取一次 `_sessionToken()` |
+| **未动** | 白名单、写入分支、ACL、查询范围、usage/usageSessions、provision 等全部保持原样 |
+| **上线前仿真** | 直接从新代码里抽出 `_fetchBalance` 对真实接口运行:新实现 **948.09 / used 257** ✅;同一逻辑喂旧实现得 -76.12 ❌ |
+| **发布** | `PUT /parse/classes/Function/xlosi3m46q` → 200 `updatedAt 2026-11-11T01:30:11Z` |
+| **回读验证** | `xlosi3m46q list`:5 个容器 `balance.remainCny` 全部 **948.09**、`usedCny 257`、`balanceSource=owner_token` |
+| **源码与回滚** | 新源码 `cloud/functions/fmodeagent-status.js`;改前线上全文 `cloud/backup/xlosi3m46q-fmodeagent-status-live-20261111-before-v0.0.9.json`(`PUT {code}` 即回滚);`cloud/functions/README.md` 已登记 |
+| **文档** | `API-契约.md` 补 balance 口径;`lj-test容器完整流程` 升 v0.2 |
+| **未验证(披露)** | ① 60s 缓存与充值后 1 分钟内刷新——**未做真实充值验证**(不能拿真钱测)② 无会话令牌时的降级路径未在线上触发过 ③ App 界面未在登录态目视确认(工作台已下线,靠单测覆盖映射层) |
+
+### 2026-11-11(续二)· 容器侧「标准包」基线调研 + 采集指令
+
+| 项 | 内容 |
+|---|---|
+| **触发** | 用户:lj-test 是本机已优化并测试完成的容器,要把它的逻辑复用到其他容器;先要它汇报自己的流程,再分析、再改。 |
+| **调研(只读,两个子代理)** | ① 容器侧全部协议盘点(上报/会话/收发/文件/Token/多租户/已废弃项)② lj-test 与兄弟容器的实测证据、时间线、缺陷闭环 |
+| **🔴 关键结论** | 本仓库**没有 lj-test 的容器侧源码副本**,只有文档转述;且文档之间 **9 处自相矛盾**(生效上报器是 `status-report.py` 还是 `plugin-active-reporter`、心跳 30s 还是 1h±15m、改 env 要重启还是重建、图文合并变量二选一、artifact-drain 存在与否、REQUIRE_MENTION、usage 上报方式、currentSessions 是否对得上 thread root、agentId 用表值还是 hostname) |
+| **因此** | 暂不直接写「复用到其他容器的指南」——照现有文档复刻会把矛盾一起复制。先产出**只读采集指令**,拿 lj-test 原文定死 9 条矛盾,再固化「标准包 v1.0」 |
+| **产出** | 🆕 `docs/03-interface/容器侧标准包-基线采集-20261111.md`(八组采集命令 + 纪律 + 汇报格式 + 回填区 + 回传后的动作) |
+| **对已有文档的修正线索** | 上一版 `lj-test容器完整流程` 中「artifact-drain 在 lj-test 缺失」一句存疑——更可能是 `app-lj-test-v1` 的情况被误记;本轮不擅改,等采集结果定论 |
+| **待做** | ① 用户把采集指令发给 lj-test ② 回填后产「标准包 v1.0」③ 新建/既有容器两份施工单 ④ 修订与矛盾相关的旧文档 |
+| **未验证** | 全部容器内部事实仍待 lj-test 回传;本轮无代码改动,未跑测试 |
+
+---
+
+## 2026-11-11(续)· 合并线上 origin/master(2d6c1dd)· 本地 18 文件以本地为主
+
+| 项 | 内容 |
+|---|---|
+| **触发** | 用户要求拉取线上最新代码后重启本地供测试 |
+| **线上新增** | `4c4d592..2d6c1dd` 共 4 个提交:文件台账按会话隔离(新云函数 `THXAOGKiPK` `fmode-file-hub`)+ 端识别改为纯自动(去掉手动切端/提示栏)|
+| **合并方式** | `git stash -u` → `git pull --ff-only` → `git stash pop`;**用户裁决:本地 18 个改动以本地为主,其余一律取线上** |
+| **冲突** | 仅 `操作追踪.md` 1 处(两段互不相关的 11-11 记录),两端全保留;**5 个重叠代码文件(parse-api / models / device.service / mobile-shell / parse-api.spec)git 自动合并成功,无冲突标记** |
+| **合并后校验** | 见下方测试结果(本轮补录)|
+
+---
+
+## 2026-11-11(续四)· 会话显示与文件链路修复 + 导航调整(P0~P6)
+
+> 用户实测后提出 11 个问题,合并为 6 个可独立提交的批次。计划见 `~/.claude/plans/witty-singing-bachman.md`。
+
+### 用户裁决(本轮)
+
+| # | 裁决 |
+|---|---|
+| 1 | **工作台要保留**,恢复入口与路由 |
+| 2 | 「项目、任务、事件、成果」**入口和路由一起关** |
+| 3 | 文件/成果**保持两个页签、严格互斥** |
+
+### 根因与修法
+
+| # | 问题 | 根因 | 修法 |
+|---|---|---|---|
+| P0-1 | 导航改错了地方 | `dk-nav.ts` / `dk-top-nav.ts` 是**死代码**(`desktop-shell.ts` 引用 `SessionTreeComponent`),真正菜单源是 `NAV_CATALOG` | 改 `nav-menu.service.ts`;两个死代码文件加醒目注释防再踩 |
+| P0-2 | 项目/任务/事件/成果要关 | 原为 `loadComponent` | `NAV_CATALOG` 删除 4 项(非 `defaultHidden`,避免用户自行恢复)+ 双端路由 `redirectTo` |
+| P0-3 | 深链会跳空 | `goTask`/`goArtifact` 跳独立页面 | 改为**打开右栏并高亮定位**(`railHighlight` + `openRailAt`);移动端 `sheetTab` 同理 |
+| P1 | 文件/成果重复 | ①`fromAgentFiles` 漏 `isUserUploadUrl` 过滤 ②**更关键**:台账那路不区分 `uploadedBy` | 补过滤 + 新增 `partitionSessionFiles()`(`uploadedBy` 为主、URL 形态兜底)+ 双端 `_dbFiles` 拆成 user/agent 两份 |
+| P2-1 | 超限无提示 | 写进 `_error`,而 `_error` 只在消息区 error 空态渲染 | 新增 `notifyOversize()` 接 `AlertService` |
+| P2-2 | 进度恒 0% | 按「每完成一个文件 +100/N」,且 `uploadFile()` 没传 `onProgress`(该回调早已存在) | 新增 `core/upload-state.ts`(`overallPct` 按字节加权 + 阶段机),接真实字节进度 |
+| P3 | 思考时不显示时间 | `waitText()` 只输出文案,锚点 `startAt` 未渲染 | `waitText` 加可选第 5 参,输出「已送达 09:51 · 正在思考…(12s)」 |
+| P4 | 停住后不提示/不继续回复 | `isTurnTimedOut` 要求 `!hasOutput`,回了一部分就永远不触发 | 新增 `isTurnStalled()`(静止 60s)与超时判据互补;提示带「重新发送」动作(回填草稿,不自动发) |
+| P6 | 新会话看到别的会话的东西 | 「任务」按 `agentId` 全量(任务本就属容器) | 标题改为「{容器名} 的全部任务」,不假装按会话过滤 |
+
+### 验证
+
+- `tsc --noEmit` app ✅ / spec ✅
+- 全量 `npm test` **1554 SUCCESS**(基线 1525,+29 条新用例)
+- `npm run build`(见下方)
+- 单测改动:`deliverables.service.spec` +4、`nav-menu.service.spec` +2(并把失效样本改为 `workbench`)、`assistant-turn.spec` +9、新增 `upload-state.spec` +14
+
+### 未验证项(披露)
+
+1. **P5「日志覆盖正文」未做** —— 需真实容器输出做快照对比,计划中列为单独批次。
+2. S3 直传的真实字节进度需浏览器实测(需 CORS 放行 `onprogress`)。
+3. Matrix 是否把用户附件回显为 `sender==='agent'` 需实测(影响 P1 兜底判据)。
+4. 卡住检测三种情形(停容器 / 断网 / 容器卡死)需在 `agent-node-lj-test` 上实测。
+
+### 已知缺口(本轮不改,已记录)
+
+- 群聊 `sendFileToAgent` 只发 Matrix、不上 S3,与单聊「先 S3 再 Matrix」不一致 → 群聊文件不进台账。属独立缺陷。
+
+---
+
+## 2026-11-11(续五)· 状态展示统一 + 日志覆盖正文 + 移动端企业管理下线
+
+> 用户第二轮实测后提出 9 个问题。核心不是零散 bug,而是**同一概念有多套判据**。
+
+### 根因
+
+判定「容器在线」的函数原来有 **6 个**,各有各的口径:
+
+| 函数 | 判据 | 问题 |
+|---|---|---|
+| `agentPresence()` | 只看 Matrix 通道 | **完全不看心跳**,45 天没心跳也算在线 |
+| `resolveAgentState()` | status + 通道 + 心跳 80 分钟 | 主流,但自己拼了一套 |
+| `agentLayers()` | 三层 | 设计最完整,**零 UI 引用** |
+| `leaderHealth()` | 心跳 **10 分钟** | 与 80 分钟口径冲突 |
+| `availabilityOf()` | 只看 status | 不判心跳 |
+| `containerStateOf()` | 复制 `agentPresence` | 零引用 |
+
+而 PC 工作台卡片和会话页头的 `status-badge` **直接用原始 `a.status` + 云端 `statusText`**,完全不判心跳 —— 这就是「未连接的容器显示状态有问题」和「仍有 1 个会话执行中」(云端文案原文直出)的来源。
+
+### 用户裁决
+
+1. 日志进折叠区,**正文完整保留**(不关日志)
+2. 状态**按页面分层**:通讯录/工作台只要在线/离线;会话页额外显示本会话相位
+3. 工作台**不需要**显示「容器是否在执行」
+4. 未连接的容器**默认显示离线**
+5. 离线容器**无法进入会话**,只提示不给入口
+6. 删除移动端 `/mobile/admin`
+7. **彻底收敛**旧判定
+
+### 改动
+
+| 批次 | 内容 |
+|---|---|
+| E | `/mobile/admin` → `redirectTo: admin/users`;删 `MbAdminPage`(444 行);成员管理返回改 `history.back()`;抽屉移除「企业管理」 |
+| A | 新增 `core/agent-liveness.ts`(`livenessOf` / `sessionPresence` / `blockedHint`,**唯一出口**);`LiveService.fetchedAt`(心跳年龄漂移校正);`message-segments.isProseLine` / `isCommandLine` |
+| B1 | `splitIntoParts` 不再整条丢弃,一律走 `divideText` 逐行拆 —— **修掉「正文被日志吞掉」** |
+| B2 | `TurnBlockView.waiting` 与 `live` 解耦 —— **修掉「超时前 155 秒空白」** |
+| B3 | `status-badge` 改用 `livenessOf`,**不再读云端 `statusText`** |
+| C | 工作台双端两态(在线/离线)+ 「当前任务」排除上报器自指;通讯录与设置页「进入会话」拦截 |
+| D | `resolveAgentState` 内部改走 `livenessOf`;删 `container-dot`;群聊 @ 候选、移动副标题统一 |
+
+### 验证
+
+- `tsc` app + spec ✅
+- 全量 `npm test` **1602 SUCCESS**(基线 1554,+48 条用例)
+- `npm run build` ✅
+- 新增/修改单测:`agent-liveness.spec`(31)、`status-badge.spec`(6)、`assistant-turn.spec`(+11,含「日志不得吞正文」5 条与「静默期指示」5 条)
+
+### 未验证项(披露)
+
+1. **心跳字段需实测核对**:`heartbeatAgeSec` 若与实际心跳时间不符,整个在线判定都不准。上线前应在浏览器控制台打印各容器 `{status, heartbeatAgeSec, matrixReady, channelState}`。
+2. 日志修复需用 `agent-node-lj-test` 的**真实历史消息**做前后对比(单测用的是构造数据)。
+3. 通讯录拦截需实测「把容器停掉后点进去」的表现。
+4. 静默期等待提示需实测 0~180 秒的连续表现。
+
+### 附带修正
+
+- 开发过程中发现并修正三处自身引入的问题:`m-drawer` 注释里的反引号提前终止模板字符串;`linkOf` 把 `disconnected` 落进 `unknown` 导致「明确断开」被判在线;`BROKEN`(stopped/offline)判定要排在心跳之前。
+
+---
+
+## 2026-11-11(续六)· 余额跳转修复 + 容器设置返回按钮
+
+| 项 | 内容 |
+|---|---|
+| **触发** | ① 工作台点余额无反应 ② 容器设置页缺返回按钮 |
+| **余额根因** | `showBalance()` 只在 `a.balance?.pageUrl` 有值时跳转,而云函数 `xlosi3m46q` **根本不返回 `pageUrl`**(只返回 remainCny/usedCny/source)→ 点击走空分支,静默无响应 |
+| **余额修法** | 双端工作台加 `BALANCE_URL = https://app.fmode.cn/dev/studio/balance` 兜底(与云函数取余额的 `billing/query` 同源,金额口径一致);有 `pageUrl` 时仍优先尊重 |
+| **移动端** | `recharge()` 原来弹「没有独立充值入口」,同样堵死用户;改为直接打开充值页 |
+| **返回按钮** | `agent.ts` 的 `app-page` 补 `backText="返回"` + `(back)="close()"`;`close()` 从「固定跳通讯录」改为 `history.back()`(用户可能从左栏/工作台进来),无历史才回落通讯录 |
+| **移动端** | `app-m-app-bar [back]` 本来就有,无需改 |
+| **验证** | `tsc` ✅ / `npm test` **1602 SUCCESS** / dev server 重启 |
+
+### 日志覆盖正文:验证结果
+
+用真实容器回复的四种形态验证拆分(模拟 `divideText` 逻辑):
+
+| 输入 | 正文 | 过程区 | 结论 |
+|---|---|---|---|
+| 正文 + `**已上云(可直接下载)**` + URL | 三行全在 | 无 | ✅ |
+| 2 行 `🐍 Running code…` + 1 行加粗结论 | 加粗结论在 | 2 行 | ✅ 旧实现会整条吞掉 |
+| 工具行 + 「下载链接如下:」 | 引导句在 | 2 行 | ✅ |
+| 纯工具 | 空 | 2 行 | ✅ 不会被塞进正文 |
+
+URL 不会重复显示:`chat.service.ts:2349` 的 `stripLinks` 会把已提取的链接从正文剥掉,只在文件卡片里出现。
+
+**仍需实测**:以上是用复刻的逻辑验证,真实链路上还要在 `agent-node-lj-test` 发一条产出文件的请求,确认渲染层表现一致。
+
+---
+
+## 2026-11-11(续七)· 群聊文件台账按群隔离 + 跨域代理 + 右栏统一
+
+### 用户问题
+1. 群聊文件/成果存储逻辑是否合理?能否准确隔离?
+2. 跨域能否用其它方法读取?
+3. 群聊与单聊右栏是否一致(功能 + 配色)?
+4. 工作区新建会话是否一致?
+5. 成员管理加载态。
+
+### 结论
+
+| 问题 | 结论 |
+|---|---|
+| 群聊隔离 | **threadRoot 骨架是对的**(服务端 `user + threadRoot` 双条件,群 A 不串群 B),但实现有 4 处真问题 |
+| 跨域 | **纯前端"存到本地"做不到**(浏览器不允许页面脚本写盘);顶层导航与新窗口可用;页内渲染需服务端代理 |
+| 右栏一致性 | 功能基本一致,**配色确实不同**(单聊 `--rail-surface` 灰蓝 / 群聊白),群聊还缺深链高亮与「最近动态」 |
+| 工作区新建 | 路径一致,但**群聊从工作区建时 workspaceId 被静默丢弃** |
+| 成员管理加载态 | **有意设计**(标题/工具栏固定,只列表进 loading),三页同构,用户确认不改 |
+
+### 改动
+
+| 批次 | 内容 |
+|---|---|
+| C1 | 群聊右栏:补 `--rail-surface` 背景、`highlightId` 深链高亮、状态页 `pulses` |
+| C2 | `groupOf()` 工作区优先于群聊(原来 ws 被丢弃);`openTask` 改为右栏定位 |
+| C3 | 新建云函数 `fmode-file-read`(`tocXyQjyh6`)+ `readText` 走代理;预览失败给「在新窗口打开」 |
+| B1 | 云函数 `fmode-file-hub` v0.2.0:`groupId`/`groupIdSource`/`prevThreadRoot` 字段、`listByGroup`、`claimToGroup`、派发锚点反查 |
+| B2 | App API:`FileEntry.groupId`、`SessionFile.groupId`、`fetchFilesByGroup`、`claimFilesToGroup` |
+| B3 | 群内上传写 `groupId`;记**派发锚点**;删除 `addDeliverableUrl`(用户文件不进成果) |
+| B4 | 群右栏双端改为 `loadSplitForGroup` + 台账优先 + `fileIdentity` 去重 |
+| B5 | 云函数反查:容器产出按锚点归群,`threadRoot` 改写为群 id(互斥保证) |
+| B7 | 群聊 S3 失败阻断 Matrix(与单聊对齐) |
+
+### 端到端验证(云函数侧,已实测)
+
+| 场景 | 结果 |
+|---|---|
+| 群内上传带 groupId | ✅ `listByGroup` 查到 |
+| 容器产出(只有 DM thread)+ 有锚点 | ✅ 归到群 `NG1DbuCOSL`,`groupIdSource='anchor'`,`threadRoot` 改写,`prevThreadRoot` 保留原值 |
+| 同一文件在单聊 `listBySession(DM thread)` | ✅ **返回空**(已搬进群,不串台) |
+| 无锚点的容器产出 | ✅ `groupId=''`,保持 DM 归属(宁可漏不归错) |
+| 锚点本身出现在列表 | ✅ 已被 `_toFiles` 过滤掉 |
+| 伪造 groupId | ✅ 400 `groupId must be your own group session` |
+
+测试数据已清理。
+
+### 未做的
+- **B6 惰性补归并**:B5 的锚点反查已覆盖主路径,盲目补归并反而有归错风险,本轮不做。
+- 群聊与单聊的**布局差异**(grid vs flex、拖宽手柄样式):属观感细节,未动。
+
+### 验证汇总
+- `tsc` app + spec ✅
+- `npm test` **1629 SUCCESS**(基线 1624,+5 群隔离用例)
+- `npm run build` ✅
+
+---
+
+## 2026-11-11(续八)· 群聊发消息失败 + 计时口径修复
+
+### 用户实测三问题
+
+| # | 现象 | 根因 | 修法 |
+|---|---|---|---|
+| 1 | 群聊 @ 容器后报「已发送,但 @lj-test 未送达」,消息随后消失 | **我上一轮加的派发锚点**里调了 `ensureAgentRoomFor()`(provision + ensureRoom),与下面正式发送的房间解析**重复且竞争** → `roomId` 为空 → 判失败 | 锚点不再预先解析房间,只登记 `agentId`;云函数反查改为「按 agentId + 时间窗口」匹配 |
+| 2 | 单聊正文已输出完,仍显示「输出中」到 56 秒 | `typingAlive` 窗口是 `quietMs * 2`(50 秒);容器结束后**不发 `m.typing:false`**,typing 一直为真 | 收紧为 `quietMs`(25 秒),与静默窗口对齐 |
+| 3 | 读数从进行中的 56s 掉回 20s | 进行中用 `now - startAt`(含等待),结束后切成 `updatedAt - startAt`(只到末条 agent 消息)→ 口径不一致 | 结束态也以 `startAt` 为锚,定格到 `min(now, updatedAt)` |
+
+### 验证
+- `tsc` app ✅ / `npm test` **1629 SUCCESS** / 云函数 `fmode-file-hub` 升 v0.2.1(语法检查通过)
+- 用户需实测:群聊 @ 不再报未送达、单聊「输出中」及时收敛、读数不倒退
+
+### 教训
+第 1 条是我自己引入的回归:为「容器产出归群」加锚点时,多调了一次房间解析。
+**凡是在已有链路旁另起一次副作用调用(尤其是 provision / ensureRoom 这类有外部状态的),
+都要先确认它与原链路是否重复。**
+
+---
+
+## 2026-11-11(续九)· 群聊入口整体隐藏(PC + 移动端)
+
+| 项 | 内容 |
+|---|---|
+| **触发** | 用户:「把群聊和新建群聊都隐藏一下,因为效果不太好」(PC + 移动端)|
+| **隐藏范围** | ① PC 左栏「群聊」分组(含「+新建群聊」与空态引导)② 移动端抽屉群聊分组 ③ 双端 `group/:groupId` 路由 |
+| **做法** | 分组用 `@if (false)` 包裹(**不是删除**,便于恢复);路由改 `redirectTo`(旧链接不白屏)|
+| **保留** | 群聊页源码 `pages/group-chat/`、后端能力、`GroupStore` / `GroupLive` 全部保留 |
+| **恢复方式** | 三处注释里都写明了改哪一行,见 `session-tree.ts` / `m-drawer.ts` / `desktop.routes.ts` |
+
+### 验证
+- `tsc` ✅ / `npm test` **1629 SUCCESS** / `npm run build` ✅
+
+### 说明
+群聊之前报的「已发送,但 @xxx 未送达」**未解决**(根因未定位,
+已加 `[group-send]` 调试日志)。入口隐藏后该问题暂时不可达;
+若将来恢复群聊,需要先解决它。
+
+---

+ 9 - 1
docs/03-interface/api/API-契约.md

@@ -94,9 +94,17 @@ Body:
   wecomConfig?: { botId?: string; corpName?: string } }
 
 // balance(JSON 字符串;兼容旧键 remainRmb)
-{ remainCny?: number; remainRmb?: number; pageUrl?: string }
+{ remainCny?: number; usedCny?: number; remainRmb?: number; pageUrl?: string;
+  totalCalls?: number | null; quota?: number | null; source?: 'owner_token' }
 ```
 
+> **余额口径(v0.0.9,2026-11-11)**:`source === 'owner_token'` 表示该值是**主人账号**余额
+> (容器账单挂在主人账号,同一主人的所有容器是同一个数 → App 标「账号余额」)。
+> 云函数取值链:`POST /api/fmode/billing/query` 的 `data.balance.availableCny` →
+> `POST /api/fmode/newapi/user` 的 `data.user.balance` →(仅无会话令牌时)旧令牌通道。
+> **不再**用 `api.fmode.cn/api/usage/token/` 的 `total_available`(`unlimited_quota` 令牌下该值不是余额,
+> 曾导致 App 显示 -76.12 而充值页为 948.09)。缓存按用户 objectId 隔离、TTL 60s。
+
 > 🔴 **死字段(禁止消费)**:`tasks[]` / `runningTasks[]` / `currentTasks[]` ——
 > 上报链路**从未实现**,永远为空。v2 任务数据改由 `AgentTask` 域承载(FR-07b)。
 

+ 218 - 0
docs/03-interface/lj-test容器完整流程-20261111.md

@@ -0,0 +1,218 @@
+# lj-test 容器完整流程清单(2026-11-11)
+
+| 字段 | 值 |
+|---|---|
+| 版本 | v0.2(云端口径已修复并实测;容器内部证据仍待回填) |
+| 对象容器 | `agent-node-lj-test`(显示名 `lj-test`,Matrix `@zgdcmgnqcd:fmode.cn`) |
+| 用途 | 把 lj-test 当前跑通的链路**逐条拆开**,作为在其他容器复现的操作单 |
+| 来源 | ① 平台实测回读(本日,只读) ② 本项目既有文档(引用处标文件名) ③ **待容器回填**(标 ❓) |
+
+> **怎么读本文**:带 ✅ 的条目是本日平台回读确认的;标 ❓ 的条目只能由容器自己回答,
+> 第七节给出了一段**只读采集指令**,发给 lj-test 即可回填。**不要凭本文猜容器内部路径。**
+
+---
+
+## 一、容器身份与绑定
+
+| 项 | 值 | 来源 |
+|---|---|---|
+| `agentId` | `agent-node-lj-test` | `xlosi3m46q` 回读 |
+| 显示名 | `lj-test` | 同上 |
+| 主人 | `19170119836`(用户9836) | 同上 `ownerMobile` |
+| Matrix 身份 | `@zgdcmgnqcd:fmode.cn`(homeserver `https://matrix.fmode.cn`) | `channels` 回读 |
+| 通道 | `matrix: true`、`wecom: true`(BotID `aibF7SNq4qV2dNwHypwp-…`) | `channels` 回读 |
+| `FmodeAgent` 记录 | 存在(`updatedAt` 在跳) | 同上 |
+
+**绑定链**(平台侧,人做):
+
+1. `FmodeAgent` 表先有 `agentId` 记录(**不会**由 provision 自动建号,实测 `RP5Ht55NeX` 对不存在的 id 返回 `SDK登录失败: Object not found.`)
+2. 调 provision `POST /api/functions {id:"RP5Ht55NeX", params:{agentId}}` → 建 Matrix 用户 + DM 房间 + 绑定表
+3. 把 provision 结果写进容器 `.env`,重启 gateway
+
+**体检命令**(本仓库,只读):
+
+```bash
+node scripts/bind-container.mjs status agent-node-lj-test   # 缺哪一步、下一步做什么
+node scripts/bind-container.mjs list                        # 名下全部容器 + 可聊天状态
+```
+
+依据:`新容器接入Matrix-端到端流程-20261103.md`。
+
+---
+
+## 二、上报流程(状态回写 FmodeAgent)
+
+### 2.1 协议
+
+```
+POST https://server.fmode.cn/api/functions
+{ "id": "xlosi3m46q",
+  "token": "<主人 sessionToken>",
+  "params": { "agentId": "...", "field": "<字段名>", "value": <值> } }
+```
+
+- **一次只报一个字段**,不存在整包提交
+- 云函数按白名单校验,白名单外直接 400:`status currentTask currentAction progress name containerRef host heartbeatAt botName botAvatar gender ownerName ownerMobile balance channels extra sessionStatus usage usageSessions`
+- 对象/数组字段(`host` / `channels` / `balance` / `usage`)发**紧凑 JSON 字符串**
+- `heartbeatAt` 必须 `{"__type":"Date","iso":"..."}`
+- 容器内做**变化检测**:只报变化字段;无变化时每 `HEARTBEAT_SECS`(默认 30s)单独报一次 `heartbeatAt`
+- `currentTask` 任务结束必须**显式报空串**,否则残留(变化检测会判「没变」)
+
+依据:`reporter字段规范-20261020.md`、`reporter部署方案-20261020.md`。
+
+### 2.2 lj-test 当前实测状态(✅ 2026-11-11 回读)
+
+| 字段 | 值 | 判读 |
+|---|---|---|
+| `heartbeatAt` | `2026-11-11T00:38:09Z` | 心跳停在约 9 小时前 |
+| `updatedAt` | `2026-11-11T00:37:49Z`(与心跳同步) | **没在更新** —— 上报已停 |
+| `host` | `cpuPercent 6.8`、`memPercent 46.5`、`diskPercent 44`、`gateway: running`、`probedAt 2026-11-07T22:42` | host 是 11-07 的旧值 |
+| `usage` | `{"totalTokens":6707397,"inputTokens":5913155,"outputTokens":794242,"apiCalls":1470,"sessions":60}` | 有值,但同步于上报停止前 |
+| `status` | `idle` | — |
+| `currentTask` | `""` | 无残留 ✅ |
+| `extra` | `{"currentSessions": [], "extraUpdatedAt": "2026-11-11T08:38:09+08:00"}` | 空会话表 |
+
+> ⚠️ **结论:lj-test 的上报链路当前是停的**(最后成功上报 ≈ 11-11 08:38 北京时间)。
+> 对照项 `agent-node-app-lj-test-v1` 的 `heartbeatAt` 停在 11-10 02:16,同为旧值。
+> 排查顺序:容器是否在跑 → reporter 进程/cron 是否在 → token 是否过期(`balance` 失败但 channels 正常是 token 过期的典型指纹)。
+
+### 2.3 上报字段的含义与来源(容器侧)
+
+| 字段 | 来源 | 备注 |
+|---|---|---|
+| `host` | `top -bn1` 取 us+sy 真实占用(`cpuPercentSource:"top"`)、`/proc/meminfo`、`df` | CPU 不是 `load1/核数*100`,那是近似降级 |
+| `channels` | 本地探测:官方 node `platforms.matrix.state=="connected"` 且 mxid 存在 | 不能只凭 env 声明 |
+| `balance` | ❓(容器自报的旧写法,已被云函数覆盖) | 见第五节:**云函数现在用主人 token 实时取,容器报的不生效** |
+| `usage` | Hermes `state.db`(`mode=ro`)聚合 `sessions` 表 | 打不开就跳过,不阻断其他字段 |
+| `heartbeatAt` | 每次上报刷新 | — |
+
+---
+
+## 三、消息收发流程
+
+```
+用户(App / 企微)
+   │  Matrix DM 或群房
+   ▼
+容器内 gateway(Matrix SDK client)
+   │ 入房消息 → 判定是否该响应(DM 都响应;群按 REQUIRE_MENTION / 名称前缀)
+   ▼
+容器处理(模型调用、工具执行)
+   │ 产出:文本 / 图片 / 文件 / 卡片
+   ▼
+回发 Matrix(消息体统一为 m.room.message;图文合并为单条)
+   │
+   ▼
+App 端 MatrixSessionService 收到 → ChatThread 落库(云函数 oR5O67i1Cm)
+```
+
+要点:
+
+- **入站机制**:`容器入站机制与图文合并规范-20261110.md`(图文合并、待处理队列、避免多容器重复应答)
+- **群会话区分**:ChatThread 的 `kind`(`dm`/`group`)+ `memberIds`;不带 `kind` 的后续 upsert **不得**把群降级成单聊(`fmodeagent-chatthreads.js` v0.1.6)
+- **多会话**:容器把每个会话的运行态写进 `extra.currentSessions`(`{sessionKey,status,turnStartAt}`),App 据此按会话显示「执行中」,而不是整容器一个状态
+- **回执失败**:App 侧 `ChatThread` 写失败不影响 Matrix 本身;排查要看云函数 `oR5O67i1Cm` 返回
+
+---
+
+## 四、文件上传云盘流程
+
+```
+用户发文件 → 容器落地本地 → 读取/处理
+   ├─【必须】上传云盘(OBS)拿公网链接
+   ▼
+App 之后所有查看/下载/成果展示一律走云盘链接
+```
+
+| 步骤 | 接口 | 备注 |
+|---|---|---|
+| ① 申请上传地址 | 云函数 `AlP56LCKFm` | 返回 OBS 预签名 URL |
+| ② 上传对象 | `PUT <预签名 URL>` | 容器直传,不经 App |
+| ③ 回写公网链接 | 消息体 / 成果清单里带公网 URL | App 侧 `s3-link.ts` 负责把 OBS 内部地址转公网域名 |
+| ④ 成果清单 | 文件交付协议 v2(结构化清单) | commit `cc58e96`;成果按会话归档 |
+
+**lj-test 现状缺口**(❓ 需回填核实):`容器侧改造-文件与成果统一上云-20261109.md` 实测
+`/opt/data/git-repos/agent-status-reporter/scripts/artifact-drain.py` **不存在**,cron 里也没有 artifact 项;
+11 月容器重部署时漏掉了这一步。**新容器复现时这是最容易漏的一环**。
+
+> 常见错法:把文件地址报给云函数 `AlP56LCKFm` 当成「状态上报」—— 那是上传 OBS,数据**不进** FmodeAgent 表。
+
+---
+
+## 五、Token 计算与余额流程(本次专项)
+
+### 5.1 两条互不相同的口径
+
+| 口径 | 数据源 | 单位 | 谁在用 |
+|---|---|---|---|
+| **容器用量** | 容器 `state.db` 聚合 → 上报 `usage` | tokens(input/output) | App 工作台「Token」列;lj-test 实测 6,707,397 tokens / 1470 次调用 / 60 会话 |
+| **平台账号用量** | `POST /api/fmode/newapi/usage`(`Authorization: Bearer <sessionToken>`) | 计费额度 quota + 人民币 | Studio「用量分析」;本次实测 7 天 1908 次调用、`usedCny 66.19`、prompt 8.84M / completion 0.75M / cache 70.55M |
+| **账号余额** | `POST /api/fmode/billing/query` `{include:["balance"]}` → `availableCny`;或 `POST /api/fmode/newapi/user` `{sessionToken}` → `data.user.balance` | 人民币 | **Studio 充值页 948.09**(两者一致) |
+| ~~token 计费~~ | `GET https://api.fmode.cn/api/usage/token/` | quota(500000 = 1 USD) | ❌ **不能当余额**:该 token 是 `unlimited_quota: true` 的无限额度令牌,`total_available` 不是用户余额 |
+
+### 5.2 余额事件(2026-11-11)
+
+- Studio 充值页:**948.09**
+- App(工作台 / 详情):**-76.12**
+- 根因:云函数 `xlosi3m46q` 的 `_fetchBalance()` 走的是 `usage/token` 的 `total_available`,再 `÷500000 × 6.8` 自行换汇;
+  该 token 限额字段是无限占位,换算结果无意义。且缓存是**进程级、按 key 哈希、5 分钟 TTL**,不区分登录用户,充值后最长 5 分钟不刷新。
+- App 侧对策(本次已落地):余额标记 `sharedAccount`(`source=owner_token`)、补 `usedCny`、负数如实显示为 `-¥76.12`、UI 标注「账号余额」。
+- 云函数侧修复(**已于 2026-11-11 上线,v0.0.9**):取值链改为 `billing/query` → `newapi/user`,缓存按用户隔离、TTL 60s;线上回读 5 个容器全部为 **948.09**。详见 `cloud/functions/README.md` 与 `API-契约.md`。
+
+### 5.3 每个容器余额相同是正常的
+
+容器账单挂在**主人账号**上,同一主人的所有容器报的是同一份余额(本次实测 5 个容器全是 `-76.12`)。
+所以「每张卡片显示同一个数」**不是 bug**,是口径问题;UI 必须标「账号余额」。
+
+---
+
+## 六、其他流程速查
+
+| 流程 | 机制 | 依据 |
+|---|---|---|
+| 心跳判活 | `heartbeatAt` 三级回落:`heartbeatAt` → `host.probedAt` → `updatedAtClient.iso` | `parse-api.ts normalizeHeartbeat` |
+| 会话目录上报 | 容器上报会话(标题/工作区/`kind`)→ `ChatThread` upsert | `容器侧改动-会话目录上报-20261028.md` |
+| 软删 | `isDeleted=true`,前端不硬删 | 规则 6 |
+| 多租户 | `company: Pointer<Company>` | 规则 6 |
+| 登录凭据 | 主人 `sessionToken`;容器持久令牌见 `如何查看登录凭据-20261031.md` | — |
+
+---
+
+## 七、待补:发给 lj-test 的只读采集指令
+
+> 目的:回填本文所有 ❓。**全程只读**:只列文件、读配置、看 cron,**不修改、不重启、不上报**。
+> 把这段原样发给 lj-test,让它按 1–8 逐条回答。
+
+```text
+请对你自己做一次只读体检,逐条回答,不要执行任何写操作(不改文件、不重启、不上报):
+
+1. 进程:ps aux | grep -Ei 'gateway|hermes|status-report|matrix' 的完整输出(脱敏 token 后)
+2. cron:crontab -l;以及 /etc/cron.d/ 下所有文件的完整内容
+3. 上报脚本:ls -l /opt/data/git-repos/agent-status-reporter/scripts/;
+   若有 fmode-status-report.sh / status-report.py,贴出文件头 30 行(脱敏)
+4. 最近上报:tail -n 30 上报日志(文件名你自己找),说明最后一次成功上报的时间与字段
+5. 云盘搬运:是否存在 artifact-drain.py / 任何把文件上传 OBS 的脚本?在哪?是否在 cron 里?
+6. Token 与余额:你本地是怎么取 token、怎么调余额接口的?
+   调的是哪个 URL、带什么头、对返回结构的哪个字段取值?贴出最近一次返回(脱敏 token,只留结构)
+7. 会话上报:哪个脚本把会话写进 ChatThread?调用哪个云函数 id?频率?
+8. 入站:gateway 收到群消息时的过滤规则写在哪(文件 + 行)?REQUIRE_MENTION 当前是什么值?
+
+每一条如果「不存在/没在跑」就明确说不存在,不要推测。
+```
+
+回填后本文件升为 v1.0,并把 lj-test 的实测值作为「基线」写入第二节与第四、五节。
+
+---
+
+## 八、在其他容器复现的验收清单
+
+新容器跑通以下全部条件才算接入完成(**全部只读回读**,不用容器自述):
+
+- [ ] `xlosi3m46q` 能查到该 `agentId`,且 `channels.matrix === true`
+- [ ] `heartbeatAt` 在 60s 内
+- [ ] `host.cpuPercent`、`host.memPercent` 有值且 `probedAt` 是新的
+- [ ] `usage.totalTokens` 有增长(跑一次对话后)
+- [ ] App 里能收到它回的消息,`ChatThread` 有该会话记录
+- [ ] 发一个文件给它,它能回一条带**公网可访问** URL 的消息
+- [ ] 工作台/详情页它显示「账号余额」+ 与 Studio 充值页一致
+- [ ] `status` 与 `currentTask` 在任务结束后回到 `idle` / `""`

+ 200 - 0
docs/03-interface/容器侧标准包-基线采集-20261111.md

@@ -0,0 +1,200 @@
+# 容器侧「标准包」基线采集 · 发给 lj-test 的只读指令(2026-11-11)
+
+> **用途**:lj-test 是目前唯一被实测跑通的容器。本文档的目标不是「描述它」,
+> 而是拿到它的**原文**,把散落在十几份文档里的矛盾一次性消掉,固化成一份可复制到其他容器的「标准包」。
+>
+> **怎么用**:把下面「零、粘贴区」以下的内容**整段**发给 lj-test。
+> 它回传后,把结果填进第五节,再把本文件升级为「标准包 v1.0」,其他容器照标准包装。
+
+---
+
+## 一、为什么要采(背景与已知矛盾)
+
+本仓库对 lj-test 的容器侧描述**没有源码副本**,只有文档转述,且已知以下自相矛盾处。
+这些问题如果不拿原文定死,复制到别的容器时会被一并放大:
+
+| # | 矛盾点 | 说法 A | 说法 B | 影响 |
+|---|---|---|---|---|
+| 1 | 真正生效的上报器 | `status-report.py`(复刻版) | `plugin-active-reporter/__init__.py`(插件钩子) | **改错文件等于没改** |
+| 2 | 上报节奏 | 字段规范:变化检测 + 30s 心跳 | 现状总览:1 小时 ± 15 分钟抖动 | App 的 80/120 分钟阈值依赖它 |
+| 3 | 改 env 后怎么生效 | 操作手册:`s6-svc -r` 重启 gateway 即可 | 绑定文档:必须重建容器(docker run 时固化) | 改了不生效,且无人察觉 |
+| 4 | 图文合并用哪个变量 | `FMODE_MEDIA_MERGE_MS` | `HERMES_MATRIX_TEXT_BATCH_DELAY_SECONDS` | 两套写法只能留一个 |
+| 5 | artifact-drain 是否存在 | 缺(`容器侧改造-文件与成果统一上云-20261109.md`) | 在、cron enabled、跑过 9963 次 | 可能是把 `app-lj-test-v1` 的情况误记到 lj-test |
+| 6 | `REQUIRE_MENTION` | 群聊必须 `true` | lj-test 文件已改 `false` 但进程仍 `true` | 群聊是回还是不回 |
+| 7 | `usage` 上报方式 | 摘要字符串 `{"totalTokens",...}` | `op:'usage'` 传 `usageSessions[]` 明细 | 明细不来,用量分析只能看汇总 |
+| 8 | `extra.currentSessions` | 已上报(空数组) | `sessionKey` 能否对上 App 的 thread root 未验证 | 对不上则按会话执行态整段失效 |
+| 9 | `agentId` | `FmodeAgent.agentId` | 曾有人用 Docker 短 hostname | 用错 → 上报进不了自己的记录 |
+
+**采集目标**:每条矛盾拿一份**原文证据**(文件里那几行),一次性定死。
+
+---
+
+## 二、禁止事项(发给容器时原样保留)
+
+```
+【纪律 · 必须遵守】
+1. 本轮只读:只允许 cat / ls / grep / ps / sqlite3 只读查询。
+   禁止:写文件、改 .env、重启服务、重启容器、调用任何写接口、上报任何字段。
+2. 每个结论必须贴原文或原始输出,禁止写「已配置好」「应该可以」。
+3. 拿不到就明确写「不存在 / 读不到 / 无权限」,禁止推测、禁止补全。
+4. 不打印任何 token / 密码全文 —— 只保留前 6 位 + 「…」。
+5. 不要为了回答而新建任何文件或脚本。
+```
+
+---
+
+## 三、采集清单(八组,逐组回答)
+
+### 第 1 组 · 上报器到底是谁在跑
+
+```bash
+# 1.1 进程级:谁在真正发请求
+ps aux | grep -Ei 'status-report|reporter|plugin' | grep -v grep
+# 1.2 插件是否存在、是否在加载列表里
+ls -l /opt/data/plugins/ 2>/dev/null
+grep -n -A20 'plugins' /opt/data/config.yaml 2>/dev/null | head -40
+# 1.3 旧脚本的情况
+ls -l /opt/data/git-repos/agent-status-reporter/scripts/ 2>/dev/null
+wc -l /opt/data/git-repos/agent-status-reporter/scripts/status-report.py 2>/dev/null
+```
+
+回答:生效的是哪一个?把生效那一个的**文件头 40 行**贴出来(含 import 与常量定义)。
+把**上报请求那一段**贴出来(搜索 `xlosi3m46q`、`post_field`、`requests.post`、`/api/functions` 附近 20 行)。
+
+### 第 2 组 · 节奏与触发方式
+
+```bash
+# 2.1 Hermes cron 任务清单
+find /opt/data -name 'jobs.json' -maxdepth 3 2>/dev/null -exec cat {} \;
+# 2.2 系统 cron(若有)
+crontab -l 2>/dev/null; ls -l /etc/cron.d/ 2>/dev/null && cat /etc/cron.d/* 2>/dev/null
+```
+
+回答:
+- 上报是**定时**(周期多少)还是**事件驱动**(哪些钩子)?两者是否并存?
+- 钩子名与函数名贴原文(例如 `pre_llm_call` / `post_llm_call` / heartbeat 分支)。
+- 上报失败有没有重试队列?贴代码。
+
+### 第 3 组 · env 与实际生效值
+
+```bash
+# 3.1 .env 里与 fmode / matrix / 上报相关的行(值脱敏)
+grep -E '^(MATRIX_|AGENT_|FMODE_|WECOM_|HERMES_MATRIX_)' /opt/data/.env 2>/dev/null
+# 3.2 进程实际读到的值(区分 .env 与 /proc 差异)
+for p in $(pgrep -f 'hermes|gateway' | head -3); do echo "== $p"; tr '\0' '\n' < /proc/$p/environ | grep -E '^(MATRIX_|AGENT_|FMODE_|HERMES_MATRIX_)' | sed -E 's/(TOKEN|PASSWORD|ACCESS_TOKEN)=(.{6}).*/\1=\2…/'; done
+```
+
+回答:`.env` 与进程实际值的**差异项**逐条列出(这是矛盾 #3、#6 的判据)。
+另外说明:这台容器的 `/opt/data` 是不是宿主 bind mount?(`mount | grep /opt/data`)
+
+### 第 4 组 · 入站过滤与图文合并
+
+```bash
+grep -n 'REQUIRE_MENTION\|_require_mention\|is_dm\|mention' /opt/hermes/*/adapter.py 2>/dev/null | head -30
+grep -rn 'TEXT_BATCH_DELAY\|MEDIA_MERGE\|_handle_media_message' /opt/hermes/ /opt/data/ 2>/dev/null | head -20
+```
+
+回答:
+- DM 与群聊分别怎么判定是否响应?贴判定代码的 10 行。
+- **媒体消息是否走与文本相同的缓冲**?如果你们已经改过,贴改动前后对比。
+- 图文合并窗口变量的**确切名字与当前值**。
+- 是否有 👀 / ✅ / redaction 回执?贴实现位置。
+
+### 第 5 组 · 文件上传云盘(成果链路)
+
+```bash
+ls -l /opt/data/git-repos/agent-status-reporter/scripts/artifact-drain.py 2>/dev/null
+find /opt/data -name '*artifact*' -maxdepth 4 2>/dev/null
+grep -rn 'pendingArtifacts\|ackArtifact\|enqueueArtifact\|lfYlgU7SkK' /opt/data/ 2>/dev/null | head -20
+grep -rn 'fmode:files' /opt/data/skills/ 2>/dev/null | head -10
+```
+
+回答:
+- 成果上云走的是哪条:① `enqueueArtifact → pendingArtifacts → PUT → ackArtifact` ② 容器自己调 `lfYlgU7SkK report` ③ 别的?
+- 贴 drain 脚本的关键函数原文(取任务 / 上传 / 回执三段)。
+- 回复里的结构化清单 `<!--fmode:files [...]-->` 是否由容器主动追加?贴生成代码。
+- 对象键路径规则(`deliverables/<agentId>/<YYYYMM>/...`)是在哪里拼的?
+
+### 第 6 组 · Token 与余额
+
+```bash
+grep -rn 'FMODE_SESSION_TOKEN\|sessionToken\|billing/query\|newapi/user\|usage/token' /opt/data/plugins /opt/data/git-repos 2>/dev/null | head -20
+```
+
+回答:
+- token 从哪来、多久可能过期、过期后容器怎么表现(是否停整个上报)?
+- 是否还在自己算 `balance`?(2026-11-11 起云函数已改为服务端用主人 token 取,容器报的会被覆盖)
+- 有没有 token 自愈/失败队列?贴代码。
+
+### 第 7 组 · 会话状态
+
+```bash
+sqlite3 "file:/opt/data/state.db?mode=ro" ".tables" 2>/dev/null
+sqlite3 "file:/opt/data/state.db?mode=ro" "select session_key,chat_id,thread_id,last_activity_at from sessions where chat_id like '!%' limit 5;" 2>/dev/null
+grep -rn 'currentSessions\|session_id\|sessionKey' /opt/data/plugins 2>/dev/null | head -20
+```
+
+回答:
+- `extra.currentSessions` 由谁写、何时写、`turnStartAt` 是什么单位(毫秒?秒?)
+- `sessionKey` 的确切构成(贴 `build_session_key` 原文)
+- **最关键的验证**:拿你最近一次在 App 里对话,`state.db` 里对应行的 `thread_id` 与 App 侧的 root event id(`$approot_...`)**是否一致**?贴 2 个对照例子(可脱敏)
+
+### 第 8 组 · 你那边的已知遗留
+
+不需要命令,直接回答:
+- 你们目前已知没修的问题清单(例如上报停止、图文打断、`REQUIRE_MENTION` 改后未重启、两条绑定/房间割裂)
+- 上一次成功上报的时间(以你们本地日志为准)与失败原因
+
+---
+
+## 四、汇报格式(要求容器按此结构回帖)
+
+```
+=== SECTION 1 · 上报器 ===
+生效文件: <路径>
+原文(1-40 行): ...
+发送函数原文: ...
+
+=== SECTION 2 · 节奏 ===
+...
+
+=== SECTION 8 · 遗留 ===
+...
+
+=== CONFLICTS ===
+# 对第二节表中 1-9 每条,写「A 对 / B 对 / 都不对」+ 一句证据
+1. ...
+```
+
+---
+
+## 五、回填区(容器回传后填写,再据此产「标准包 v1.0」)
+
+| # | 矛盾点 | 结论(A/B/新) | 原文证据位置 | 生效日期 |
+|---|---|---|---|---|
+| 1 | 生效上报器 | ❓ | | |
+| 2 | 上报节奏 | ❓ | | |
+| 3 | env 生效方式 | ❓ | | |
+| 4 | 图文合并变量 | ❓ | | |
+| 5 | artifact-drain | ❓ | | |
+| 6 | REQUIRE_MENTION | ❓ | | |
+| 7 | usage 上报方式 | ❓ | | |
+| 8 | currentSessions | ❓ | | |
+| 9 | agentId 来源 | ❓ | | |
+
+---
+
+## 六、回传后我会做什么
+
+1. 把第 1–5 组的**原文**整理成「容器侧标准包」目录结构(脚本 + cron + env 模板 + 验收脚本)。
+2. 基于矛盾结论,**修订**现有文档(`reporter字段规范`、`容器入站机制与图文合并规范`、`容器侧配置清单` 等)中过时的说法,避免下一台容器照旧的错版本装。
+3. 产出两份施工单:**新建容器**(从零装标准包)与**既有容器**(增量对齐),每步带验收命令。
+4. 把本节回填区填满,本文档升 v1.0。
+
+---
+
+## 附:本次采集**不做**的事
+
+- 不采集 App 前端字段映射(已在代码里,`parse-api.ts` 为准)
+- 不采集 Matrix 服务端配置(平台侧)
+- 不采集容器业务内容(对话数据、工作产物)

+ 266 - 0
docs/03-interface/容器自述与一致性核对-提示词-20261111.md

@@ -0,0 +1,266 @@
+# 下发容器:流程自述 + 一致性核对(可直接复制)
+
+> **版本** v1.1 · 2026-11-11
+> **用途**:让 `agent-node-lj-test`(**唯一被测容器**,已在本机优化并实测通过)
+> **自己输出**它当前真实在跑的逻辑(原文,不许猜),再与 App 端约定逐条核对。
+> 目的是把 lj-test 这套跑通的东西**固化成可复制的标准**,供其他容器对齐。
+>
+> ⚠️ **只针对 lj-test**。其他容器(含 `agent-node-app-lj-test-v1`)均未优化,不作为参照,也不在本轮范围内。
+>
+> **怎么用**:把「━━ 以下整段复制 ━━」之后的全部内容发给 lj-test。
+
+━━━━━━━━━━━━━━━━ 以下整段复制 ━━━━━━━━━━━━━━━━
+
+# 任务:自述你的运行逻辑,并与 App 端约定逐条核对
+
+你是本次唯一的参照对象 —— 你已经在本机跑通并被实测通过,其他容器都要照着你对齐。
+所以你要做两件事:**① 原文输出**你当前真实在跑的逻辑;**② 对照第三节的 App 约定,逐条判定「一致 / 不一致 / 没做」**。
+
+这不是让你「证明自己配置正确」,而是让你**把自己完整暴露出来**,好让我原样复制到别的容器上。
+**你越诚实,复制到其他容器就越不会出问题。**
+
+## 铁律(违反任何一条,本次输出作废)
+
+1. **只读**:只允许 `cat / ls / grep / head / tail / ps / sqlite3 (mode=ro)`。
+   禁止写文件、改 `.env`、重启服务、重启容器、调用任何写接口、上报任何字段。
+2. **原文优先**:每条结论必须附**文件路径 + 行号 + 原文片段**(每段 ≤15 行)。
+   禁止写「已配置」「应该可以」「正常情况下」。
+3. **不许猜**:读不到就写 `NOT_FOUND` / `NO_PERMISSION` / `NOT_RUNNING`。**推测等于错误答案。**
+4. **不许美化**:如果你知道某功能没做完、是空壳、是硬编码,直接说出来。
+5. **脱敏**:token / 密码 / secret 只留前 6 位 + `…`,其余全打码。
+6. **不许新建文件或脚本**来帮我检查。
+
+---
+
+## 第 0 组 · 先回答「你现在活着吗」(这是第一优先,其他都排在后面)
+
+平台侧刚回读到的:你的 `heartbeatAgeSec ≈ 3802`(约 63 分钟),`updatedAt` 停在 `2026-11-11T00:37:49Z`,`lastField=extra`。
+也就是说**平台认为你已经一小时没上报了**。先解释这件事,再谈其他:
+
+```bash
+date -Is
+ps aux | grep -Ei 'status-report|reporter|plugin|hermes|gateway' | grep -v grep
+find /opt/data -maxdepth 3 -name 'jobs.json' -exec cat {} \; 2>/dev/null
+ls -lt /opt/data/logs/ 2>/dev/null | head -20
+```
+
+回答:
+
+1. 你**现在**还在上报吗?(是/否)
+2. 如果停了:**从什么时候开始停的**?日志里最后几条报错原文贴出来。
+3. 停的原因是什么?(脚本崩了 / cron 被 pause / token 过期 / gateway 重启 / 别的原因)——**贴证据,不要猜**。
+4. 你最近一次成功上报的**本地时间**与**上报的字段**是什么?
+5. 上报失败时你的行为是什么?(静默跳过 / 退出整个循环 / 重试)贴代码。
+
+> 如果这条答不清,后面五条链路都是「历史状态」,无法作为复制标准。**这条必须答到能复现的程度。**
+
+---
+
+## 第一节 · 身份确认(答错后面全白做)
+
+| 项 | 你的实际值 |
+|---|---|
+| `AGENT_ID` 环境变量的值 | |
+| 你上报时实际发送的 `agentId` 参数值 | |
+| 你上报时使用的云函数 id | |
+| 你的 Matrix 用户 id | |
+| 你的 Matrix homeserver | |
+| 你当前加入的房间 id 列表 | |
+| 容器 hostname / Docker 短 id | |
+
+> 🔴 特别说明:`agentId` 必须是平台 `FmodeAgent` 表里的值,**不是** Docker hostname。
+> 如果你用的是 hostname,直接说「是」,不要改,我会在分析里处理。
+
+---
+
+## 第二节 · 我要求的五条链路,逐条自述
+
+每条都按 **「做什么 → 在哪做(文件:行)→ 原文 → 什么时候触发 → 失败会怎样」** 五段回答。
+
+### 链路 A · 状态上报
+
+```bash
+ps aux | grep -Ei 'status-report|reporter|plugin' | grep -v grep
+ls -l /opt/data/plugins/ 2>/dev/null
+grep -n -B2 -A20 'plugins' /opt/data/config.yaml 2>/dev/null | head -50
+find /opt/data -maxdepth 3 -name 'jobs.json' -exec cat {} \; 2>/dev/null
+ls -l /opt/data/git-repos/agent-status-reporter/scripts/ 2>/dev/null
+```
+
+必须回答:
+
+1. **真正在发请求的是哪个文件**(完整路径)?旧脚本 `status-report.py` 是否还参与运行?(是/否 + 证据)
+2. 上报是**定时**还是**事件驱动**?周期多少?触发点在哪几行?
+3. 贴出**发送请求的那个函数原文**(搜索 `xlosi3m46q`、`/api/functions`、`post_field` 附近 20 行)。
+4. 报哪些字段?**逐个**列出,并说明每个字段的**值从哪来**(命令 / sqlite 查询 / 环境变量 / 自算)。
+   特别注意:`cpuPercent` 是怎么来的?`status` 是读实时状态还是写死?`currentTask` 结束时有没有显式报空串?
+5. 变化检测怎么做?无变化时是否只报心跳?心跳间隔多少秒?
+6. 失败(网络 / 401 / 400)怎么处理?有没有重试队列?贴代码。
+7. 贴出你**最近 20 行上报日志**,并给出**最后一次成功上报的本地时间**。
+
+### 链路 B · 消息收发
+
+1. 入站:收到一条 Matrix 消息后,**在哪一行**决定「回 / 不回」?DM 与群聊分别的判定规则是什么?贴代码。
+2. 群聊的提及判定是精确匹配还是子串/正则?贴出匹配那行。容器名能否与别的容器互为前缀?(例如 `lj` / `lj-test`)
+3. 文本消息与图片/文件消息:**是否进入同一个缓冲**?缓冲窗口变量名与当前值是什么?函数名与行号?
+4. 回执:有没有 👀 / ✅ / 删除提示(redaction)?在哪里发的?是不是每一轮都发?
+5. 回复发到哪个房间 / 哪个 thread?长回复怎么切分?
+6. 你自己**如何去重**(同一个 Matrix event 被投递两次时)?
+
+### 链路 C · 会话状态
+
+```bash
+sqlite3 "file:/opt/data/state.db?mode=ro" ".tables" 2>/dev/null
+sqlite3 "file:/opt/data/state.db?mode=ro" ".schema sessions" 2>/dev/null
+sqlite3 "file:/opt/data/state.db?mode=ro" "select session_key,chat_id,thread_id,last_activity_at from sessions where chat_id like '!%' order by last_activity_at desc limit 5;" 2>/dev/null
+grep -rn 'currentSessions\|session_id\|turnStartAt' /opt/data/plugins /opt/data/git-repos 2>/dev/null | head -20
+```
+
+1. `session_key` 由什么拼成?贴出拼接函数的原文。
+2. 你是否往 `extra.currentSessions` 写东西?写的时机、字段、`turnStartAt` 的**单位**(毫秒?秒?ISO?)。贴代码。
+3. **关键**:拿你最近 2 次在 App 里的对话,把 `state.db` 里对应行的 `thread_id` 贴出来(可脱敏后半段),
+   同时说明 App 发来的消息里 `m.relates_to.event_id`(thread root)**是否等于** `thread_id`。
+   如果没法确认就说「无法确认」,不要猜。
+
+### 链路 D · 文件上云
+
+```bash
+ls -l /opt/data/git-repos/agent-status-reporter/scripts/artifact-drain.py 2>/dev/null
+grep -rn 'pendingArtifacts\|ackArtifact\|enqueueArtifact\|lfYlgU7SkK\|AlP56LCKFm' /opt/data/ 2>/dev/null | head -20
+grep -rn 'fmode:files' /opt/data/skills/ 2>/dev/null | head -10
+```
+
+1. 你产出文件后,处在哪一步把它变成公网链接?贴出「取上传地址 → PUT → 回执」三段代码。
+2. 你用的是哪条链路:`enqueueArtifact → pendingArtifacts → PUT → ackArtifact`?还是自己调 `lfYlgU7SkK report`?还是别的?
+3. 对象键路径怎么拼?(贴代码,例如 `deliverables/<agentId>/<YYYYMM>/...`)
+4. 回复文本里有没有追加 `<!--fmode:files [...]-->` 区块?由哪段代码生成?贴原文。
+5. 用户发来的文件(`/user/.../chat/...`)你会不会重复上传成成果?怎么排除?
+6. **实测**:随便挑一个你最近上传成功的成果链接,贴出 `curl -sI <url> | head -3` 的原始输出。
+
+### 链路 E · Token 与账号
+
+1. token 从哪来(环境变量?文件?)?多久会过期?过期后你的表现是什么(整体停报?只 balance 降级?)贴代码。
+2. 你现在是否还**自己计算** `balance`?怎么算的?贴代码。
+3. 有没有 token 自愈或失败重放机制?
+
+---
+
+## 第三节 · 与 App 端约定逐条核对
+
+下面每一条都是 App 当前**真实消费**的行为(出自 App 端代码,不是推测)。
+请对每条给出:**一致 / 不一致 / 没做 / 不确定**,并附你的证据。
+
+| # | App 端约定 | 你的判定 | 证据(文件:行) |
+|---|---|---|---|
+| 1 | `host.cpuPercent` 必须是 0–100 的真实占用率(来自 `top`),不是 load average | | |
+| 2 | `host` / `channels` / `balance` / `extra` / `usage` 必须是**紧凑 JSON 字符串**,不是对象 | | |
+| 3 | `channels` 必须是**对象**(`matrix`, `matrixConfig.userId`, `wecom`…),不能是数组 | | |
+| 4 | `matrix` 为 true 的依据是**真实探测**(platforms.matrix.state=connected 且有 mxid),不是读了 env 就报 true | | |
+| 5 | `heartbeatAt` 必须包成 `{"__type":"Date","iso":"..."}` | | |
+| 6 | 任务结束必须**显式上报 `currentTask=""`**,否则 App 会一直显示有任务 | | |
+| 7 | `status` 不能包含上报器自身活动(如 "periodic status reporter"),App 会把这种识别成空闲 | | |
+| 8 | `extra.currentSessions[].sessionKey` 必须**包含** App 的会话 id(thread root),否则按会话的执行态会失效 | | |
+| 9 | 图片 + 文字应**合成一轮**处理(App 先传文件、后发文字,间隔可能 1–3 秒) | | |
+| 10 | 群聊只响应被 @ 的消息;DM 不受 @ 限制 | | |
+| 11 | 回复必须落在**同一个 thread**(`m.relates_to.rel_type='m.thread'`) | | |
+| 12 | 产出文件必须**先上传拿公网链接**再回复;App 只认 http(s) 链接 | | |
+| 13 | 成果回复末尾追加 `<!--fmode:files [{"name","url","size"}]-->` 结构化清单 | | |
+| 14 | 不响应 `currentTask` 里的上报器自言自语;不把工具过程消息当成正式回复 | | |
+| 15 | `balance` 已由服务端用主人 token 覆盖,容器**不需要**再算,报了也会被丢弃 | | |
+| 16 | `status` 只能是 `running` / `idle` / `error` / `offline` / `stopped` 五个值之一,**不能自造别的值** | | |
+| 17 | `usage` 的字段名必须能被 App 识别:`totalTokens`、`inputTokens`、`outputTokens`(或 `promptTokens`/`completionTokens`);用别的名字 App 显示为空 | | |
+| 18 | `extra.currentSessions` 为空数组时 App 视为「没有会话级数据」,回落到全局 `status`;**不是**「全部空闲」 | | |
+
+---
+
+## 第四节 · 汇报格式(严格按此结构,便于我逐项比对)
+
+```
+=== IDENTITY ===
+AGENT_ID=...  SENT_AGENT_ID=...  FN=...  MXID=...  ROOMS=...
+
+=== A · 状态上报 ===
+生效文件: ...            旧脚本参与运行: 是/否
+节奏: 定时(xx秒) / 事件驱动(xxx)
+发送函数原文: (贴)
+字段清单: 字段名 → 值来源(逐行)
+失败处理: ...
+
+=== B · 消息收发 ===
+...
+=== C · 会话状态 ===
+...
+=== D · 文件上云 ===
+...
+=== E · Token 与账号 ===
+...
+
+=== CONFORMANCE ===
+1. 一致  证据: file:line
+2. 不一致 证据: file:line  实际: ...  期望: ...
+...
+
+=== SELF-KNOWN-GAPS ===
+(你自己知道的、没做完或不对的地方,逐条,不要美化)
+```
+
+---
+
+## 第五节 · 最后三条
+
+1. 上面这些链路里,**哪一条你认为最不可靠**?为什么?
+2. 如果明天要在一台**全新的容器**上复刻你这套逻辑,你需要带走哪些文件、哪些 cron、哪些 env?列成清单。
+3. 你现在有没有任何**定时任务 / 后台进程**是我上面没问到的?(如实列出)
+
+---
+
+## 第六节 · 导出可复制的「标准包」(本轮最重要)
+
+其他容器要照着你对齐,所以你必须把**能直接复制的东西**导出来。仍然**只读**,只允许 `cat`(内容直接贴在回复里,不要新建文件):
+
+| 要导出 | 命令 | 贴出方式 |
+|---|---|---|
+| 上报器(生效的那个)全文 | `cat <路径>` | 完整贴出,超过 300 行就分段贴并在末尾写 `...TRUNCATED` |
+| 上报 wrapper / cron 的 shell 脚本 | `cat` | 完整 |
+| Hermes `jobs.json` 中的相关条目 | `cat /opt/data/jobs.json` | 只贴相关条目 + 注明完整路径 |
+| `artifact-drain.py`(若存在)全文 | `cat` | 完整 |
+| `.env` 模板(值脱敏) | `grep -E '^(MATRIX_\|AGENT_\|FMODE_\|WECOM_\|HERMES_)' /opt/data/.env` | 全部键 + 脱敏值 |
+| `config.yaml` 里 plugins 段 | `sed -n '1,80p' /opt/data/config.yaml` | 只贴 plugins 与 platforms 相关 |
+| 成果清单技能文件 | `cat /opt/data/skills/fmode-storage/SKILL.md` | 完整(若是它生成的 fmode:files 区块) |
+
+导完后回答两个问题:
+
+- 这些文件里,**哪些是改过的**(与官方原版不同)?在文件头加个注释标一下你改过哪里也行 —— 但你**不许真的改文件**,只在回复里说明「我改过 XXX」。
+- 如果别的容器只有原版、没有你这些改动,**哪些功能会失效**?逐条列。
+
+━━━━━━━━━━━━━━━━ 复制到此结束 ━━━━━━━━━━━━━━━━
+
+---
+
+## 下发者备注(不发给容器)
+
+- 本文件对应的 App 端事实来源:`src/app/core/parse-api.ts`(字段映射)、`src/app/core/agent-state.ts`(三层状态)、`src/app/core/declared-files.ts`(成果清单)、`cloud/functions/fmodeagent-status.js`(上报云函数)、`cloud/functions/fmodeagent-chatthreads.js`(会话目录)。
+- 容器回传后:把「CONFORMANCE」表里**不一致 / 没做**的项直接作为施工单。
+- 已知需用原文裁决的历史矛盾见 `容器侧标准包-基线采集-20261111.md` 第一节表格。
+
+## 本日已从平台侧确认的 lj-test 现状(下发者自用,不代表结论)
+
+只用来对照容器的答复,**不能代替**它的自述:
+
+| 项 | 平台回读值 | 含义 |
+|---|---|---|
+| `status` | `idle` | 合法值 |
+| `heartbeatAt` | `2026-11-11T00:38:09Z`;`heartbeatAgeSec≈3802` | **约 63 分钟未上报**(同期其他容器 16–46 秒) |
+| `updatedAt` | `2026-11-11T00:37:49Z` | 停在同一时刻,确认是它自己的链路停了 |
+| `lastField` | `extra` | 最后一次写的是 extra |
+| `extra` | `{"currentSessions": [], "extraUpdatedAt": "2026-11-11T08:38:09+08:00"}` | 结构对了,但 `currentSessions` **始终为空** → 会话级执行态从未写进去 |
+| `usage` | `{"totalTokens": 6707397, "inputTokens": 5913155, "outputTokens": 794242, "apiCalls": 1470, "sessions": 60}` | 旧写法;缺 `sessionCount` / `cacheReadTokens` / `reasoningTokens` / `estimatedCostUsd` / `toolCallCount` |
+| `host` | `{"cpuPercent": 6.8, "cpuPercentSource": "top", … "probedAt": "2026-11-07T22:42:48+08:00"}` | 格式合规,但 `probedAt` 是 11-07 的旧值 |
+| `currentTask` | `""` | 无残留 ✅ |
+| `balance` | `948.09`(服务端 v0.0.9 覆盖) | 与 Studio 充值页一致 ✅ |
+
+**据此预判的待核实点**(等它自述确认,不要先下结论):
+1. 为什么停(第 0 组)
+2. `currentSessions` 为什么一直是空——是没实现,还是写了但清得太快
+3. `usage` 是否要升级到新字段集(不升级 App 的「Token」列仍能显示,但用量明细缺项)
+4. `host.probedAt` 停在 11-07 说明 host 采集可能也是停的,或变化检测从未触发重报

+ 11 - 3
src/app/components/domain/assistant-turn/assistant-turn.spec.ts

@@ -42,6 +42,7 @@ function turn(over: Partial<AssistantTurn> = {}): AssistantTurn {
         body: [{ kind: 'text', key: 'a1', message: { ...msg('a1', '正文'), mergedIds: ['a1'] } }],
         files: [],
         live: false,
+        waiting: false,
         empty: false,
         merged: { key: 'a1', text: '正文', message: msg('a1', '正文') },
         waitingSince: 0,
@@ -294,10 +295,14 @@ describe('AssistantTurnComponent', () => {
     expect(fixture.nativeElement.querySelector('.head-phase')?.textContent).toContain('输出中');
   });
 
-  it('🔴 只有过程、没有交付 → 显示「已结束 · 无交付」而不是「已完成」', () => {
+  it('🔴 只有过程、没有交付 → 显示「已结束 · 仅过程记录」而不是「已完成」', () => {
+    /*
+     * 2026-11-11 改文案:「无交付」听起来像失败,实际是「做了事,
+     * 但没有可下载的产物」。断言的核心没变 —— **不能说「已完成」**。
+     */
     render(turn({ phase: 'progressOnly', live: false }));
     const txt = fixture.nativeElement.querySelector('.head-phase')?.textContent ?? '';
-    expect(txt).toContain('无交付');
+    expect(txt).toContain('仅过程记录');
     expect(txt).not.toContain('已完成');
   });
 
@@ -326,9 +331,12 @@ describe('AssistantTurnComponent', () => {
     });
 
     it('🔴 已结束:读数定格,推进时钟不再变化', () => {
-      const t = turn({ live: false });
+      const t = turn({ live: false, stepCount: 1 });
       Object.assign(t.blocks[0], { live: false, startAt: T, updatedAt: T + 7000 });
       render(t);
+      /* 头部胶囊只在时钟推进后刷新(每秒时钟驱动),先对齐时钟再断言 */
+      clock().set(T + 7000);
+      fixture.detectChanges();
 
       expect(headTime()).toBe('7s');
       clock().set(T + 60_000);

+ 23 - 7
src/app/components/domain/assistant-turn/assistant-turn.ts

@@ -442,8 +442,14 @@ export class AssistantTurnComponent implements OnDestroy {
       case 'done':
         return '已完成';
       case 'progressOnly':
-        // 容器干了一轮活,但没产出可交付内容(2026-11-10)
-        return '已结束 · 无交付';
+        /*
+         * 容器干了一轮活,但没产出可交付内容(2026-11-10 新增)。
+         *
+         * 2026-11-11 改文案:「无交付」听起来像失败,实际是「做了事,
+         * 但没有可下载的产物」——比如只跑了工具、或只回了过程日志。
+         * 配合 `live` 的长任务宽限(60 秒),这一档只在真的停住时才出现。
+         */
+        return '已结束 · 仅过程记录';
       case 'failed':
         return '失败';
       default:
@@ -495,7 +501,12 @@ export class AssistantTurnComponent implements OnDestroy {
      *    只在 live 时起表,结束即清,不常驻。
      */
     effect(() => {
-      if (!this.turn().live) return;
+      /*
+       * 2026-11-11:静默期(`live` 已转 false 但仍在等回复)也要走表 ——
+       * 否则「已送达,等待回复…(Ns)」的秒数会冻住,看起来像卡死。
+       */
+      const t = this.turn();
+      if (!t.live && !t.blocks.some((b) => b.waiting)) return;
       const id = setInterval(() => this.now.set(Date.now()), 1000);
       return () => clearInterval(id);
     });
@@ -686,8 +697,11 @@ export class AssistantTurnComponent implements OnDestroy {
        * 🔴 **已结束且什么都没有**的块不渲染占位(合并口径,2026-11-10):
        * 空占位只属于「还在等待 / 还在输出」。超时由全局提示告知,
        * 会话内不留一个转圈的「未响应」残留。
+       *
+       * 2026-11-11 补充 `b.waiting`:`live` 在 25 秒静默后就会转 false,
+       * 但用户仍在等(要到 180 秒才算超时)—— 这中间不能让界面一片空白。
        */
-      if (!src && !b.live) continue;
+      if (!src && !b.live && !b.waiting) continue;
 
       let message: Message | null = null;
       let streaming = false;
@@ -760,7 +774,9 @@ export class AssistantTurnComponent implements OnDestroy {
     for (const b of this.turn().blocks) {
       const hasOutput = b.body.length > 0 || b.steps.length > 0;
       const elapsed = b.waitingSince > 0 ? Math.max(0, now - b.waitingSince) : 0;
-      const text = waitText(elapsed, hasOutput, b.live, b.empty);
+      /* 传 b.startAt:等待文案带「09:51 已送达」,用户能看出已经等了多久(2026-11-11) */
+      /* `live || waiting` 都算「还在进行」:静默期也不能让等待提示消失(2026-11-11) */
+      const text = waitText(elapsed, hasOutput, b.live || b.waiting, b.empty, b.startAt);
       if (text) out[b.key] = text;
     }
     return out;
@@ -789,8 +805,8 @@ export class AssistantTurnComponent implements OnDestroy {
   protected readonly thinking = computed(() => {
     const t = this.turn();
     if (t.merged) return '';
-    // 只要还有任何块会渲染成段(有内容或 live),就交给段级提示
-    const anySeg = t.blocks.some((b) => b.merged || b.live || b.empty);
+    // 只要还有任何块会渲染成段(有内容 / live / 仍在等),就交给段级提示
+    const anySeg = t.blocks.some((b) => b.merged || b.live || b.waiting || b.empty);
     if (anySeg) return '';
     const b = t.blocks[0];
     if (!b) return '';

+ 35 - 2
src/app/components/domain/composer/composer.ts

@@ -11,6 +11,7 @@ import {
   viewChild,
 } from '@angular/core';
 import { UiProgress, UiSpinner } from '../../../ui';
+import { UploadPhase } from '../../../core/upload-state';
 
 /**
  * 消息输入栏 —— 自建(《组件规范》§四-2)
@@ -113,8 +114,15 @@ import { UiProgress, UiSpinner } from '../../../ui';
     @if (uploading()) {
       <div class="upload" role="status" aria-live="polite">
         <i class="fa-solid fa-arrow-up-from-bracket" aria-hidden="true"></i>
-        <span>正在上传… {{ pct() }}%</span>
-        <ui-progress [value]="pct()" />
+        <!--
+          分阶段文案(2026-11-11):原来只有「正在上传… 0%」,
+          而 0% 会一直挂到文件整个发完 —— 用户以为卡死了。
+          现在按真实阶段说话:只有云盘直传有百分比,登记/发送阶段如实说在干什么。
+        -->
+        <span>{{ uploadLabel() }}</span>
+        @if (showBar()) {
+          <ui-progress [value]="pct()" />
+        }
       </div>
     }
   `,
@@ -303,6 +311,31 @@ export class ComposerComponent {
   readonly sending = input(false);
   readonly uploading = input(false);
   readonly pct = input(0);
+  /**
+   * 整批上传阶段(2026-11-11)。`uploading()` 为真时据此切文案。
+   *
+   * 默认 `'uploading'`:老调用点没传 stage 时保持「上传中 N%」的旧表现。
+   */
+  readonly stage = input<UploadPhase>('uploading');
+
+  /** 输入框下方那一条的文案 */
+  protected readonly uploadLabel = computed(() => {
+    switch (this.stage()) {
+      case 'registering':
+        return '上传完成,正在登记…';
+      case 'sending':
+        return '正在发送给容器…';
+      case 'done':
+        return '已发送';
+      case 'failed':
+        return '部分附件上传失败';
+      default:
+        return `正在上传… ${this.pct()}%`;
+    }
+  });
+
+  /** 只有云盘直传阶段有真实百分比,其余阶段显示进度条反而误导 */
+  protected readonly showBar = computed(() => this.stage() === 'uploading');
   /*
    * 占位文案只讲「要做什么」,快捷键说明放在工具条里 ——
    * 原来占位符把「Enter 发送 / Shift+Enter 换行」也写进去了,

+ 0 - 100
src/app/components/domain/container-dot/container-dot.ts

@@ -1,100 +0,0 @@
-import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core';
-import { Agent, SemanticTone, agentStatusTone } from '../../../core/api/models';
-
-/**
- * 容器在线状态点 —— **唯一渲染点**
- *
- * ## 为什么需要(用户实测反馈)
- *
- * App 列表里显示 4 个「可对话」的容器,但**实测只有 lj-test 的容器在线**
- * (芯葶/兔爷/雨飏 有绑定记录但容器侧 Matrix 通道没跑)。
- * 用户点进去只看到「一直连接中」,无从判断是**自己网络问题**还是**对方容器离线**。
- *
- * 故在**列表行内**就给出状态,而不是进去才发现。
- *
- * ## 状态口径(唯一实现,禁止各处另算)
- *
- * | 状态 | 判定 | 含义 |
- * |---|---|---|
- * | `online` | 已有绑定 + 容器上报 `channelState==='connected'` 或 `listenerOk` | 可真对话 |
- * | `connecting` | 已有绑定,但容器状态未知/未上报 | 能连上 Matrix,但可能无人应答 |
- * | `offline` | 无绑定(`matrixReady` 为假) | 不可对话 |
- *
- * 🔴 颜色**同时**配文字/`aria-label`,不只靠颜色传达(FR-13 无障碍)。
- */
-export type ContainerState = 'online' | 'connecting' | 'offline';
-
-/** 从 Agent 判定容器状态(全项目唯一实现) */
-export function containerStateOf(a: Agent | undefined): ContainerState {
-  if (!a || !a.matrixReady) return 'offline';
-  const cfg = a.channels?.matrixConfig as
-    | { channelState?: string; listenerOk?: boolean; channelDetail?: { matrixState?: string; gatewayState?: string } }
-    | undefined;
-  const state = cfg?.channelState ?? cfg?.channelDetail?.matrixState;
-  if (state === 'connected' || cfg?.listenerOk) return 'online';
-  // 有绑定但容器未上报状态 → 能连 Matrix,但对方可能不在线
-  return 'connecting';
-}
-
-/** 状态 → 语义色(复用 models 的单轨映射,不另造) */
-export function containerTone(s: ContainerState): SemanticTone {
-  switch (s) {
-    case 'online':
-      return 'success';
-    case 'connecting':
-      return 'warning';
-    default:
-      return 'neutral';
-  }
-}
-
-/** 状态 → 中文文案 */
-export const CONTAINER_STATE_TEXT: Record<ContainerState, string> = {
-  online: '在线',
-  connecting: '状态未知',
-  offline: '未接入',
-};
-
-@Component({
-  selector: 'app-container-dot',
-  changeDetection: ChangeDetectionStrategy.OnPush,
-  template: `
-    <span
-      class="dot"
-      [class]="'dot--' + tone()"
-      [attr.title]="label()"
-      [attr.aria-label]="label()"
-      role="img"
-    ></span>
-  `,
-  styles: [
-    `
-      :host {
-        display: inline-flex;
-        flex-shrink: 0;
-      }
-      .dot {
-        width: 8px;
-        height: 8px;
-        border-radius: 50%;
-        background: var(--muted);
-      }
-      .dot--success {
-        background: var(--success);
-      }
-      .dot--warning {
-        background: var(--warning);
-      }
-      .dot--neutral {
-        background: var(--edge-strong);
-      }
-    `,
-  ],
-})
-export class ContainerDotComponent {
-  /** 容器状态(由调用方用 `containerStateOf(agent)` 得出) */
-  readonly state = input.required<ContainerState>();
-
-  readonly tone = computed(() => containerTone(this.state()));
-  readonly label = computed(() => `容器${CONTAINER_STATE_TEXT[this.state()]}`);
-}

+ 7 - 1
src/app/components/domain/new-session-modal/new-session-modal.ts

@@ -27,7 +27,11 @@ import { resolveAgentState } from '../../../core/agent-state';
         「{{ targetName() }}」里。
       </p>
 
-      <!-- 分组:单聊(默认)/ 群聊 —— 与移动端同一套语义(2026-11-05) -->
+      <!--
+        分组:单聊(默认)/ 群聊 —— 与移动端同一套语义(2026-11-05)
+        🔒 2026-11-11 用户裁决:群聊标签隐藏(与左栏群聊分组一起下线)。
+        恢复:把下面的 @if (false) 改回 @if (true)。
+      -->
       <div class="mode-tabs" role="tablist" aria-label="会话类型">
         <button
           type="button"
@@ -38,6 +42,7 @@ import { resolveAgentState } from '../../../core/agent-state';
         >
           单聊
         </button>
+        @if (false) {
         <button
           type="button"
           role="tab"
@@ -47,6 +52,7 @@ import { resolveAgentState } from '../../../core/agent-state';
         >
           群聊
         </button>
+        }
       </div>
 
       <div class="pick-list" role="listbox" aria-label="选择容器">

+ 32 - 2
src/app/components/domain/session-rail/session-rail.ts

@@ -180,7 +180,12 @@ export interface RailRuntime {
                   <p class="rail-empty">{{ tasksEmpty() }}</p>
                 } @else {
                   @for (t of tasks(); track t.id) {
-                    <button type="button" class="rail-item task" (click)="openTask.emit(t.id)">
+                    <button
+                      type="button"
+                      class="rail-item task"
+                      [class.hl]="highlightId() === t.id"
+                      (click)="openTask.emit(t.id)"
+                    >
                       <span class="file-icon task-ico" aria-hidden="true">
                         <i class="fa-solid fa-list-check"></i>
                       </span>
@@ -239,7 +244,12 @@ export interface RailRuntime {
                   <p class="rail-empty">{{ resultsEmpty() }}</p>
                 } @else {
                   @for (d of deliverables(); track d.id) {
-                    <button type="button" class="rail-item" (click)="previewDeliverable(d)">
+                    <button
+                      type="button"
+                      class="rail-item"
+                      [class.hl]="highlightId() === d.id"
+                      (click)="previewDeliverable(d)"
+                    >
                       <span class="file-icon" aria-hidden="true">
                         <i class="fa-solid" [class]="iconByType(d.fileType)"></i>
                       </span>
@@ -534,6 +544,14 @@ export interface RailRuntime {
       .rail-item:hover {
         background: var(--surface-hover);
       }
+      /*
+       * 深链定位高亮(2026-11-11):从聊天里点任务/成果后,右栏打开并定位到这一条。
+       * 只描边 + 淡底,不改变布局尺寸(避免定位时列表跳动)。
+       */
+      .rail-item.hl {
+        border-color: var(--primary);
+        background: color-mix(in srgb, var(--primary) 8%, transparent);
+      }
       .rail-item > i {
         flex-shrink: 0;
         color: var(--faint);
@@ -584,6 +602,18 @@ export class SessionRailComponent {
   readonly deliverables = input<Deliverable[]>([]);
   readonly runtimes = input<RailRuntime[]>([]);
 
+  /**
+   * 需要在列表里**高亮定位**的条目 id(2026-11-11)。
+   *
+   * 背景:「任务 / 成果」两个独立页面的路由已关闭,聊天里的深链
+   * (消息里的任务引用、成果链接)改为**打开右栏并定位**。
+   * 右栏只有切页签还不够 —— 列表几十条时用户仍找不到刚才点的是哪条,
+   * 所以传入 id,命中的那条加高亮描边并滚进可视区。
+   *
+   * 宿主在打开后延时清空,避免长期挂着高亮。
+   */
+  readonly highlightId = input<string | null>(null);
+
   /** 任务归属展示(群聊用容器名;单聊传空则显示任务 id) */
   readonly ownerNameOf = input<(agentId?: string) => string>(() => '');
 

+ 13 - 5
src/app/components/domain/session-tree/session-tree.ts

@@ -313,12 +313,19 @@ export interface NewSessionRequest {
 
         <!-- ========== 群聊(2026-11-05):夹在工作区与日常之间 ========== -->
         <!--
-          群聊分组头与「工作区」同构(2026-11-06 用户要求):
-          标题在左、计数靠右、最右侧「+」新建群聊。此前是「大标题 + 计数字」,
-          和上下两个分组头排版不一致,看起来像另一套组件。
-          群聊不归属工作区,也没有「移动到工作区」,所以不必做可折叠 ——
-          折叠箭头留给有下一级归属的「日常对话」。
+          🔒 2026-11-11 用户裁决:**群聊入口整体隐藏**(PC + 移动端)。
+
+          用户原话:「把群聊和新建群聊都隐藏一下,因为这个效果不是太好」。
+          隐藏范围:左栏分组、分组里的「+ 新建群聊」、空态的「新建群聊」引导。
+
+          恢复时:把下面 if (false) 改回 if (true)(或直接删掉这层),
+          并同步恢复:
+            · mobile/components/m-drawer/m-drawer.ts 的同名分组
+            · desktop.routes.ts / mobile.routes.ts 的 group/:groupId 若也要重新开放
+
+          群聊页源码(pages/group-chat/)与后端能力完整保留,不做删除。
         -->
+        @if (false) {
         <section class="sect sect--group">
           <header class="sect-head">
             <span class="sect-lead" aria-hidden="true">
@@ -380,6 +387,7 @@ export interface NewSessionRequest {
             }
           </div>
         </section>
+        }
 
         <!-- ========== 日常对话(默认展开、可折叠、与工作区平分剩余高度) ========== -->
         <section class="sect" [class.grow]="dailyOpen()">

+ 73 - 0
src/app/components/domain/status-badge/status-badge.spec.ts

@@ -0,0 +1,73 @@
+import { TestBed } from '@angular/core/testing';
+import { Agent } from '../../../core/api/models';
+import { StatusBadgeComponent } from './status-badge';
+
+/**
+ * 状态徽标 —— 工作台卡片与会话页头共用的**唯一渲染点**。
+ *
+ * 2026-11-11 用户实测:「没有连接上的容器这个显示状态有问题」。
+ * 根因是组件直接用 `agent.status` + 云端 `statusText`,完全不看心跳:
+ * 45 天没心跳的容器照样显示绿色「正在执行」,云端文案原样印上去。
+ */
+describe('StatusBadgeComponent(容器状态)', () => {
+  function agent(over: Partial<Agent> = {}): Agent {
+    return {
+      id: 'a1',
+      name: '容器',
+      status: 'running',
+      matrixReady: true,
+      heartbeatAgeSec: 60,
+      channels: { matrixConfig: { channelState: 'connected' } },
+      ...over,
+    } as Agent;
+  }
+
+  function render(a: Agent) {
+    const f = TestBed.createComponent(StatusBadgeComponent);
+    f.componentRef.setInput('agent', a);
+    f.detectChanges();
+    return f.nativeElement.textContent.trim();
+  }
+
+  beforeEach(async () => {
+    await TestBed.configureTestingModule({ imports: [StatusBadgeComponent] }).compileComponents();
+  });
+
+  it('心跳新鲜 → 在线', () => {
+    expect(render(agent())).toBe('在线');
+  });
+
+  it('🔴 心跳 45 天的「running」容器 → 离线,不再显示「正在执行」', () => {
+    expect(render(agent({ status: 'running', heartbeatAgeSec: 45 * 24 * 3600 }))).toBe('离线');
+  });
+
+  it('🔴 云端 statusText 不得原样露出', () => {
+    /*
+     * 用户截图里徽标显示「仍有 1 个会话执行中」——
+     * 那是云函数 `statusText` 的原文,被组件直接打印了。
+     */
+    const text = render(agent({ statusText: '仍有 1 个会话执行中' }));
+    expect(text).withContext('不能再泄漏云端原文').not.toBe('仍有 1 个会话执行中');
+    expect(text).toBe('在线');
+  });
+
+  it('idle + 心跳过期 → 离线(不是黄色「空闲」)', () => {
+    expect(render(agent({ status: 'idle', heartbeatAgeSec: 300 * 60 }))).toBe('离线');
+  });
+
+  it('未接入(matrixReady=false)→ 未接入', () => {
+    expect(render(agent({ matrixReady: false }))).toBe('未接入');
+  });
+
+  it('容器已停止 → 已停止', () => {
+    expect(render(agent({ status: 'stopped' }))).toBe('已停止');
+  });
+
+  it('显式传 view 时以它为准(会话页传 sessionPresence)', () => {
+    const f = TestBed.createComponent(StatusBadgeComponent);
+    f.componentRef.setInput('agent', agent());
+    f.componentRef.setInput('view', { text: '正在回复…', tone: 'success' });
+    f.detectChanges();
+    expect(f.nativeElement.textContent.trim()).toBe('正在回复…');
+  });
+});

+ 37 - 5
src/app/components/domain/status-badge/status-badge.ts

@@ -1,6 +1,7 @@
 import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core';
 import { UiBadge } from '../../../ui';
-import { Agent, SemanticTone, Task, TaskStatus, agentStatusTone, taskStatusTone } from '../../../core/api/models';
+import { Agent, SemanticTone, Task, TaskStatus, taskStatusTone } from '../../../core/api/models';
+import { livenessOf } from '../../../core/agent-liveness';
 
 /**
  * 状态徽标 —— 语义色与文案的**唯一渲染点**
@@ -69,6 +70,13 @@ import { Agent, SemanticTone, Task, TaskStatus, agentStatusTone, taskStatusTone
 export class StatusBadgeComponent {
   /** 数字生命(传入 Agent 即可,文案与语义色由服务层统一给出) */
   readonly agent = input<Agent | undefined>(undefined);
+  /**
+   * 已判定好的状态(2026-11-11)。
+   *
+   * 会话页用它传 `sessionPresence()` 的结果(在线 + 本会话相位),
+   * 优先级高于 `agent`。列表页不传,走内部的 `agentLiveness`。
+   */
+  readonly view = input<{ text: string; tone: SemanticTone } | undefined>(undefined);
   /** 任务(传入 Task 即可) */
   readonly task = input<Task | undefined>(undefined);
   /** 直接指定(如「只读」等中性徽标) */
@@ -83,17 +91,41 @@ export class StatusBadgeComponent {
 
   readonly tone = computed<SemanticTone>(() => this.toneOverride() ?? this.baseTone());
 
-  private readonly baseTone = computed<SemanticTone>(() => {
+  /**
+   * 容器在线判定(2026-11-11 收敛)。
+   *
+   * 🔴 原来这里是 `agentStatusTone(a.status)` + `a.statusText ?? 中文表`:
+   *    直接用**云端上报的原始 status**,完全不看心跳。
+   *
+   *    后果(用户实测):45 天没心跳的容器照样显示绿色「正在执行」;
+   *    `statusText` 是云函数原样返回的字符串,「仍有 1 个会话执行中」
+   *    这种句子会被直接印在徽标上。
+   *
+   *    现在统一走 `livenessOf()`(心跳 80 分钟 + Matrix 通道),
+   *    工作台与会话页头共用同一个判据。
+   */
+  private readonly agentView = computed(() => {
     const a = this.agent();
-    if (a) return agentStatusTone(a.status);
+    if (!a) return undefined;
+    const v = livenessOf(a);
+    return { text: v.text, tone: v.tone };
+  });
+
+  private readonly baseTone = computed<SemanticTone>(() => {
+    const v = this.view();
+    if (v) return v.tone;
+    const av = this.agentView();
+    if (av) return av.tone;
     const task = this.task();
     if (task) return taskStatusTone(task.status);
     return 'neutral';
   });
 
   readonly label = computed(() => {
-    const a = this.agent();
-    if (a) return a.statusText ?? this.agentStatusLabel(a.status);
+    const v = this.view();
+    if (v) return v.text;
+    const av = this.agentView();
+    if (av) return av.text;
     const task = this.task();
     if (task) return this.taskStatusLabel(task.status);
     return this.text();

+ 39 - 3
src/app/components/feedback/alert-host/alert-host.ts

@@ -28,9 +28,20 @@ import { AlertService } from '../../../core/alert.service';
                 <p class="alert-detail">{{ a.detail }}</p>
               }
             </div>
-            <button type="button" class="alert-close" (click)="svc.dismiss(a.id)">
-              我知道了
-            </button>
+            <div class="alert-actions">
+              <!--
+                操作按钮(2026-11-11):容器卡住时给「重新发送」,
+                不能只让用户看到一句「没响应」就完事。
+              -->
+              @if (a.action; as act) {
+                <button type="button" class="alert-act" (click)="svc.runAction(a)">
+                  {{ act.label }}
+                </button>
+              }
+              <button type="button" class="alert-close" (click)="svc.dismiss(a.id)">
+                我知道了
+              </button>
+            </div>
           </div>
         }
       </div>
@@ -128,6 +139,31 @@ import { AlertService } from '../../../core/alert.service';
       background: var(--surface-hover);
     }
 
+    /* 操作按钮与「我知道了」并排(2026-11-11):主操作在左、次操作在右 */
+    .alert-actions {
+      display: flex;
+      flex-shrink: 0;
+      align-items: center;
+      gap: var(--sp-2);
+    }
+
+    .alert-act {
+      min-height: 44px;
+      padding: 0 var(--sp-3);
+      border: 1px solid var(--primary);
+      border-radius: var(--r-md);
+      background: var(--primary);
+      color: var(--inverse);
+      font-size: var(--fs-sm);
+      cursor: pointer;
+      white-space: nowrap;
+      transition: opacity var(--motion-fast) var(--ease-standard);
+    }
+
+    .alert-act:hover {
+      opacity: 0.9;
+    }
+
     @keyframes alert-in {
       from {
         opacity: 0;

+ 9 - 1
src/app/components/preview/file-preview/file-preview.spec.ts

@@ -1,5 +1,6 @@
 import { TestBed } from '@angular/core/testing';
 import { FilePreviewComponent } from './file-preview';
+import { FmodeApi } from '../../../core/api/fmode-api';
 
 /**
  * 预览外壳单测 —— 形态切换 / 缩放 / Esc 语义。
@@ -22,7 +23,14 @@ describe('FilePreviewComponent', () => {
   }
 
   beforeEach(async () => {
-    await TestBed.configureTestingModule({ imports: [FilePreviewComponent] }).compileComponents();
+    await TestBed.configureTestingModule({
+      imports: [FilePreviewComponent],
+      /*
+       * 2026-11-11:组件新增 `FmodeApi` 依赖(云盘文本走服务端代理,因为云盘没配 CORS)。
+       * 本组用例只测外壳形态/缩放,不碰文件读取,给一个最小桩即可。
+       */
+      providers: [{ provide: FmodeApi, useValue: { readText: async () => '' } }],
+    }).compileComponents();
   });
 
   it('inline=true 打开时为 rail 形态', () => {

+ 79 - 9
src/app/components/preview/file-preview/file-preview.ts

@@ -17,6 +17,8 @@ import { PreviewKind, extOf, previewKindOf } from '../file-kind';
 import { UiTooltip } from '../../../ui';
 import { inject } from '@angular/core';
 import { MatrixClientService } from '../../../core/matrix-client.service';
+import { FmodeApi } from '../../../core/api/fmode-api';
+import { isCloudUrl } from '../../../core/s3-link';
 import { downloadAs, needsBlobPreview, toBlobUrl } from '../../../core/attachment-fetch';
 
 /**
@@ -179,10 +181,22 @@ export type PreviewPresentation = 'rail' | 'full' | 'modal';
                       <p>加载中…</p>
                     </div>
                   } @else if (textState() === 'error') {
+                    <!--
+                      CORS 兜底出口(2026-11-11):
+                      S3 未配 Access-Control-Allow-Origin,页面内 fetch 必失败。
+                      但**顶层导航**(新窗口打开)不受 CORS 限制 —— 那条路一定能看到原文。
+                      原来这里只有一个「重试」按钮,重试必然再失败,用户被堵死。
+                    -->
                     <div class="fp-state">
                       <i class="fa-solid fa-triangle-exclamation" aria-hidden="true"></i>
                       <p class="err">{{ textError() }}</p>
-                      <button type="button" class="fp-btn" (click)="reloadText()">重试</button>
+                      <p class="fp-hint">可以直接在新窗口打开原文查看。</p>
+                      <div class="fp-actions">
+                        <button type="button" class="fp-btn primary" (click)="openInTab()">
+                          在新窗口打开
+                        </button>
+                        <button type="button" class="fp-btn" (click)="reloadText()">重试</button>
+                      </div>
                     </div>
                   } @else {
                     <div class="fp-paper" [style.--chat-read-size.px]="docFont()">
@@ -200,7 +214,13 @@ export type PreviewPresentation = 'rail' | 'full' | 'modal';
                     <div class="fp-state">
                       <i class="fa-solid fa-triangle-exclamation" aria-hidden="true"></i>
                       <p class="err">{{ textError() }}</p>
-                      <button type="button" class="fp-btn" (click)="reloadText()">重试</button>
+                      <p class="fp-hint">可以直接在新窗口打开原文查看。</p>
+                      <div class="fp-actions">
+                        <button type="button" class="fp-btn primary" (click)="openInTab()">
+                          在新窗口打开
+                        </button>
+                        <button type="button" class="fp-btn" (click)="reloadText()">重试</button>
+                      </div>
                     </div>
                   } @else if (!text()) {
                     <div class="fp-state">
@@ -663,6 +683,23 @@ export type PreviewPresentation = 'rail' | 'full' | 'modal';
         text-decoration: none;
         cursor: pointer;
       }
+      /* CORS 兜底出口(2026-11-11):主操作高亮,让用户知道该点哪个 */
+      .fp-btn.primary {
+        border-color: var(--primary);
+        background: var(--primary);
+        color: var(--inverse);
+      }
+      .fp-actions {
+        display: flex;
+        flex-wrap: wrap;
+        gap: var(--sp-2);
+        justify-content: center;
+      }
+      .fp-hint {
+        margin: 0;
+        color: var(--muted);
+        font-size: var(--fs-12);
+      }
       .fp-btn--primary {
         border-color: transparent;
         background: var(--primary);
@@ -883,6 +920,8 @@ export type PreviewPresentation = 'rail' | 'full' | 'modal';
 export class FilePreviewComponent {
   /** Matrix 客户端(「新标签打开」需要带 access_token 的直链) */
   private readonly mx = inject(MatrixClientService);
+  /** 云盘文件走服务端代理(云盘没配 CORS,见 fetchText 注释) */
+  private readonly api = inject(FmodeApi);
 
   /** 是否打开 */
   readonly open = input<boolean>(false);
@@ -1330,16 +1369,24 @@ export class FilePreviewComponent {
     this.textState.set('loading');
     this.textError.set('');
     try {
-      /* Matrix 媒体接口需鉴权:带 access_token 才能读到内容(否则 401 → 空白) */
-      const res = await fetch(this.mx.withAccessToken(target));
-      if (!res.ok) throw new Error(`HTTP ${res.status}`);
-      const raw = await res.text();
+      /*
+       * 🔴 云盘地址走**服务端代理**(2026-11-11 用户裁决)。
+       *
+       * s3.fmode.cn 是 public-read 但**没配 CORS**,页面内 fetch 必被拦
+       * (「加载失败:Failed to fetch」),.md / .txt 永远渲染不出来。
+       * 云函数 `fmode-file-read` 在服务端取正文再回传,浏览器不直接碰 S3。
+       *
+       * Matrix 媒体(`mxc://` 转换后的地址)本来就带 ACAO:*,直连即可。
+       */
+      const raw = isCloudUrl(target)
+        ? await this.api.readText(target)
+        : await fetchTextDirect(this.mx.withAccessToken(target));
       const LIMIT = 500_000;
       this.text.set(raw.length > LIMIT ? raw.slice(0, LIMIT) + '\n\n…(内容过长,已截断)' : raw);
       this.textState.set('ready');
     } catch (e) {
       this.textState.set('error');
-      this.textError.set(e instanceof Error ? `加载失败:${e.message}` : '加载失败:无法读取该文件');
+      this.textError.set(describeTextError(e));
     }
   }
 
@@ -1365,8 +1412,31 @@ export class FilePreviewComponent {
  * 找出真正可滚动的元素(PDF 用 .pdf-scroll、表格用 .sheet-scroll、其余用 .fp-body)。
  * 判据:内容高度明显超出可视高度,或样式上本身就是 overflow:auto/scroll。
  */
-function findScroller(root: HTMLElement): HTMLElement {
-  const candidates: HTMLElement[] = [];
+/** 直连取文本(只用于 Matrix 媒体等带 CORS 头的地址) */
+async function fetchTextDirect(url: string): Promise<string> {
+  const res = await fetch(url);
+  if (!res.ok) throw new Error(`HTTP ${res.status}`);
+  return res.text();
+}
+
+/**
+ * fetch 失败 → 给用户看得懂的说法(2026-11-11)。
+ *
+ * 原来直接把 JS 异常信息拼进去,用户看到「加载失败:Failed to fetch」——
+ * 既不知道发生了什么,也不知道该干什么。
+ *
+ * `Failed to fetch` 在浏览器里**几乎总是 CORS 或网络**,
+ * 而 S3 当前确实没配 `Access-Control-Allow-Origin`(docs 多处记录)。
+ */
+function describeTextError(e: unknown): string {
+  const msg = e instanceof Error ? e.message : String(e ?? '');
+  if (/failed to fetch|networkerror|cors/i.test(msg)) {
+    return '无法在页内读取该文件(跨域限制),可点「在新窗口打开」直接查看。';
+  }
+  return msg ? `加载失败:${msg}` : '加载失败:无法读取该文件';
+}
+
+function findScroller(root: HTMLElement): HTMLElement {  const candidates: HTMLElement[] = [];
   root.querySelectorAll<HTMLElement>('.pdf-scroll, .sheet-scroll').forEach((el) => candidates.push(el));
   for (const el of candidates) {
     if (el.scrollHeight > el.clientHeight + 4) return el;

+ 214 - 0
src/app/core/agent-liveness.spec.ts

@@ -0,0 +1,214 @@
+import { Agent } from './api/models';
+import {
+  blockedHint,
+  HEARTBEAT_DEAD_SEC,
+  HEARTBEAT_STALE_SEC,
+  heartbeatAgeSec,
+  isReporterSelfTalk,
+  livenessOf,
+  onlineLabel,
+  sessionPresence,
+} from './agent-liveness';
+
+/**
+ * 容器在线状态的**唯一判定**(2026-11-11 收敛)。
+ *
+ * 背景:收敛前项目里有 6 套判定,同一个容器在不同页面结论相反。
+ * 用户裁决:**80 分钟(1h20m)内不算离线** —— 容器 1 小时上报一次 + 0~15 分钟错峰。
+ */
+describe('agent-liveness', () => {
+  const MIN = 60;
+
+  /** 造一个容器,默认是健康的在线态 */
+  function agent(over: Partial<Agent> = {}): Agent {
+    return {
+      id: 'a1',
+      name: '测试容器',
+      status: 'idle',
+      matrixReady: true,
+      heartbeatAgeSec: 10 * MIN,
+      channels: { matrixConfig: { channelState: 'connected' } },
+      ...over,
+    } as Agent;
+  }
+
+  describe('心跳阈值边界(80 分钟)', () => {
+    it('79 分钟 → 在线', () => {
+      expect(livenessOf(agent({ heartbeatAgeSec: 79 * MIN })).online).toBeTrue();
+    });
+
+    it('🔴 恰好 80 分钟 → 仍算在线(用户明确 1h20m 内不算离线)', () => {
+      const v = livenessOf(agent({ heartbeatAgeSec: HEARTBEAT_STALE_SEC }));
+      expect(v.online).withContext('边界值不能判成离线').toBeTrue();
+      expect(v.stale).withContext('80 分钟是容差上限,不是「可疑」').toBeFalse();
+    });
+
+    it('81 分钟且容器说自己空闲 → 离线(错过一轮且没有在干活的证据)', () => {
+      const v = livenessOf(agent({ heartbeatAgeSec: 81 * MIN, status: 'idle' }));
+      expect(v.online).toBeFalse();
+      expect(v.offlineBecause).toBe('heartbeat-expired');
+    });
+
+    it('🔴 81 分钟但容器正在跑真实任务 → 算在线(容忍错过一轮上报)', () => {
+      const v = livenessOf(
+        agent({ heartbeatAgeSec: 81 * MIN, status: 'running', currentTask: '生成周报' }),
+      );
+      expect(v.online).withContext('干到一半的容器标离线是噪音').toBeTrue();
+      expect(v.stale).withContext('要标记为推断,文案上区分').toBeTrue();
+    });
+
+    it('81 分钟 + running 但只是上报器自指 → 不算在干活,仍判离线', () => {
+      const v = livenessOf(
+        agent({ heartbeatAgeSec: 81 * MIN, status: 'running', currentTask: 'periodic status reporter' }),
+      );
+      expect(v.online).toBeFalse();
+    });
+
+    it('超过 160 分钟 → 一定离线,即使在跑任务', () => {
+      const v = livenessOf(
+        agent({ heartbeatAgeSec: HEARTBEAT_DEAD_SEC + MIN, status: 'running', currentTask: '干活' }),
+      );
+      expect(v.online).toBeFalse();
+      expect(v.offlineBecause).toBe('heartbeat-expired');
+    });
+  });
+
+  describe('心跳年龄校正(拉取时刻漂移)', () => {
+    it('🔴 加上「距拉取已过去的时间」,避免最长 15 秒的低估', () => {
+      const now = Date.now();
+      const fetchedAt = now - 10_000; // 10 秒前拉的
+      const a = agent({ heartbeatAgeSec: 100 });
+      expect(heartbeatAgeSec(a, now, fetchedAt)).toBe(110);
+    });
+
+    it('不传 fetchedAt → 退回原值(兼容既有调用)', () => {
+      expect(heartbeatAgeSec(agent({ heartbeatAgeSec: 100 }), Date.now())).toBe(100);
+    });
+
+    it('没有 heartbeatAgeSec 时回落到 heartbeatAt', () => {
+      const now = Date.now();
+      const at = new Date(now - 120_000).toISOString();
+      const a = agent({ heartbeatAgeSec: undefined, heartbeatAt: at });
+      expect(heartbeatAgeSec(a, now)).toBeCloseTo(120, 0);
+    });
+
+    it('两个字段都没有 → null', () => {
+      expect(heartbeatAgeSec(agent({ heartbeatAgeSec: undefined, heartbeatAt: undefined }), Date.now())).toBeNull();
+    });
+  });
+
+  describe('接入态(用户反馈:没连上的容器要显示离线)', () => {
+    it('🔴 没有 Matrix 绑定 → 未接入,即使心跳新鲜', () => {
+      const v = livenessOf(agent({ matrixReady: false, heartbeatAgeSec: 10 }));
+      expect(v.online).toBeFalse();
+      expect(v.offlineBecause).toBe('no-binding');
+      expect(v.text).toBe('未接入');
+    });
+
+    it('有绑定但通道状态未知 → 交给心跳判(能发心跳就说明活着)', () => {
+      const v = livenessOf(agent({ channels: { matrixConfig: {} }, heartbeatAgeSec: 10 * MIN }));
+      expect(v.online).toBeTrue();
+    });
+
+    it('通道明确断开但心跳新鲜 → 离线,原因是「通道断开」', () => {
+      const v = livenessOf(
+        agent({ channels: { matrixConfig: { channelState: 'disconnected' } }, heartbeatAgeSec: 10 * MIN }),
+      );
+      expect(v.online).toBeFalse();
+      expect(v.offlineBecause).toBe('matrix-down');
+    });
+
+    it('没有心跳字段的旧数据 → 回落到接入态,不误判离线', () => {
+      expect(livenessOf(agent({ heartbeatAgeSec: undefined })).online).toBeTrue();
+    });
+  });
+
+  describe('容器自报状态', () => {
+    it('status=error(心跳新鲜)→ 离线,原因归到通道', () => {
+      const v = livenessOf(agent({ status: 'error' }));
+      expect(v.online).toBeFalse();
+      expect(v.tone).toBe('danger');
+    });
+
+    it('status=stopped → 已停止', () => {
+      const v = livenessOf(agent({ status: 'stopped' }));
+      expect(v.text).toBe('已停止');
+    });
+
+    it('null / undefined 容器 → 未接入,不抛错', () => {
+      expect(livenessOf(null).online).toBeFalse();
+      expect(livenessOf(undefined).text).toBe('未接入');
+    });
+  });
+
+  describe('上报器自指识别', () => {
+    it('currentTask 是 reporter 心跳 → 判为自指', () => {
+      expect(isReporterSelfTalk(agent({ status: 'running', currentTask: 'periodic status reporter' }))).toBeTrue();
+    });
+
+    it('有真实任务描述 → 不是自指', () => {
+      expect(isReporterSelfTalk(agent({ status: 'running', currentTask: '生成周报' }))).toBeFalse();
+    });
+
+    it('无任务但 action 是心跳 → 自指', () => {
+      expect(isReporterSelfTalk(agent({ status: 'running', currentAction: 'reporter heartbeat @ 09:00' }))).toBeTrue();
+    });
+  });
+
+  describe('onlineLabel(列表页两态文案)', () => {
+    it('在线 → 在线 / success', () => {
+      expect(onlineLabel(livenessOf(agent()))).toEqual({ text: '在线', tone: 'success' });
+    });
+
+    it('推断在线(stale)→ 仍显示在线,但用 warning 色提示不太确定', () => {
+      const v = livenessOf(agent({ heartbeatAgeSec: 81 * MIN, status: 'running', currentTask: '干活' }));
+      expect(onlineLabel(v).text).toBe('在线');
+      expect(onlineLabel(v).tone).toBe('warning');
+    });
+
+    it('离线 → 沿用具体原因文案', () => {
+      expect(onlineLabel(livenessOf(agent({ heartbeatAgeSec: 300 * MIN }))).text).toBe('离线');
+    });
+  });
+
+  describe('sessionPresence(会话页:在线状态 + 本会话相位)', () => {
+    it('在线 + 正在回复 → 正在回复…', () => {
+      expect(sessionPresence(agent(), 'replying')).toEqual({ text: '正在回复…', tone: 'success', busy: true });
+    });
+
+    it('在线 + 思考中 → 思考中…', () => {
+      expect(sessionPresence(agent(), 'thinking').text).toBe('思考中…');
+    });
+
+    it('在线 + 无相位 → 在线', () => {
+      expect(sessionPresence(agent()).text).toBe('在线');
+    });
+
+    it('🔴 离线优先于相位:容器断了不能还说「正在回复」', () => {
+      const offline = agent({ heartbeatAgeSec: 300 * MIN });
+      const p = sessionPresence(offline, 'replying');
+      expect(p.text).not.toBe('正在回复…');
+      expect(p.busy).toBeFalse();
+    });
+  });
+
+  describe('blockedHint(拦截提示)', () => {
+    it('在线 → 不提示', () => {
+      expect(blockedHint(livenessOf(agent()))).toBeNull();
+    });
+
+    it('未接入 → 告诉用户先接入', () => {
+      const hint = blockedHint(livenessOf(agent({ matrixReady: false })));
+      expect(hint).toContain('接入');
+    });
+
+    it('心跳过期 → 说明超过 80 分钟没上报', () => {
+      const hint = blockedHint(livenessOf(agent({ heartbeatAgeSec: 300 * MIN })));
+      expect(hint).toContain('80 分钟');
+    });
+
+    it('已停止 → 提示重新启动', () => {
+      expect(blockedHint(livenessOf(agent({ status: 'stopped' })))).toContain('停止');
+    });
+  });
+});

+ 239 - 0
src/app/core/agent-liveness.ts

@@ -0,0 +1,239 @@
+import { Agent, AgentStatus, SemanticTone } from './api/models';
+
+/**
+ * 容器「在不在线」的**唯一判定**(2026-11-11 收敛)。
+ *
+ * ## 为什么要新建这个文件
+ *
+ * 收敛前项目里有 **6 套**判定,同一个容器在不同页面结论相反(用户实测):
+ *
+ * | 函数 | 原判据 | 问题 |
+ * |---|---|---|
+ * | `agentPresence` | 只看 Matrix 通道 | 完全不看心跳,45 天没心跳也算「在线」 |
+ * | `resolveAgentState` | status + 通道 + 心跳 | 主流,但会把心跳 80~120 分钟的容器判成「状态未同步」 |
+ * | `agentLayers` | 三层 | 设计最完整,但**零 UI 引用** |
+ * | `leaderHealth` | 心跳 **10 分钟** | 与 80 分钟口径冲突 |
+ * | `availabilityOf` | 只看 status | 不判心跳 |
+ * | `containerStateOf` | 复制 `agentPresence` | 零引用 |
+ *
+ * 各页面各取一半,于是「PC 说离线、手机说正在执行」。
+ * 现在**所有**页面只调用本文件的函数,按场景取文案。
+ *
+ * ## 判据(用户裁决 2026-11-11)
+ *
+ * 容器上报周期 = **1 小时 + 0~15 分钟随机错峰**,所以:
+ *   · 心跳 ≤ 80 分钟            → 在线
+ *   · 80 ~ 160 分钟且容器说自己有真实任务 → 容忍错过一轮,仍算在线(标记 stale)
+ *   · > 160 分钟 / 明确 stop / 无 Matrix 绑定 → 离线
+ *
+ * 用户原话:「没有超过 1h20 分钟都不算离线」。
+ */
+
+/**
+ * 在线判定阈值:**80 分钟**(1 小时上报 + 15 分钟错峰 + 余量)。
+ *
+ * 🔴 全项目**唯一**的心跳阈值常量,`agent-state.ts` / `leader-health.ts`
+ *    都必须引用它,不要再各写一个(那正是这次要消灭的问题)。
+ */
+export const HEARTBEAT_STALE_SEC = 80 * 60;
+
+/** 超过这个时长视为确定掉线(容忍错过完整一轮) */
+export const HEARTBEAT_DEAD_SEC = 160 * 60;
+
+/** 容器「断」的原因 —— 决定文案与用户该做什么 */
+export type OfflineReason =
+  | 'unknown-agent' // 没拿到容器数据
+  | 'no-binding' // 没有 Matrix 绑定 = 没接入
+  | 'matrix-down' // 有绑定但通道明确不可用
+  | 'heartbeat-expired' // 心跳过期
+  | 'stopped'; // 容器自报已停止
+
+export interface LivenessView {
+  online: boolean;
+  /** 80~160 分钟、靠「它在忙」推断仍在线:文案要能说明是推断 */
+  stale: boolean;
+  /** 为 null 表示在线 */
+  offlineBecause: OfflineReason | null;
+  text: string;
+  tone: SemanticTone;
+}
+
+const BROKEN: ReadonlySet<AgentStatus> = new Set<AgentStatus>(['error', 'stopped', 'offline']);
+
+/** 上报器自指的特征(容器硬编码 `status:running` + `reporter heartbeat`) */
+const REPORTER_SELF_TALK = /reporter heartbeat|periodic status reporter|status-report\.py/i;
+
+/**
+ * 这条「running」是不是上报器在说自己的事。
+ *
+ * 实测量到 `currentTask = "periodic status reporter (xlosi3m46q)"`,
+ * 若不排除,没在干活的容器会显示「正在执行」。
+ */
+export function isReporterSelfTalk(a: Agent): boolean {
+  const task = a.currentTask?.trim() ?? '';
+  const action = a.currentAction?.trim() ?? '';
+  if (task && REPORTER_SELF_TALK.test(task)) return true;
+  // 有真实任务描述 → 是业务动作,不降级
+  if (task) return false;
+  return REPORTER_SELF_TALK.test(action);
+}
+
+/**
+ * 心跳距今秒数。
+ *
+ * 优先用云函数直出的 `heartbeatAgeSec`(实测可靠);
+ * 缺失时回落到 `heartbeatAt` 与当前时间求差;都没有返回 null(旧数据)。
+ *
+ * @param fetchedAt 该容器数据**拉取的时刻**(`LiveService.fetchedAt`)。
+ *
+ * 🔴 为什么要传它(2026-11-11):`heartbeatAgeSec` 是**响应那一刻**的年龄。
+ *    `LiveService` 每 15 秒轮询一次,两次之间字段是冻结的 ——
+ *    直接用它判「是否超过 80 分钟」,真实年龄最多被低估 15 秒。
+ *    加上「距拉取已过去的时间」才是当前真实年龄。
+ *    不传则退化为旧行为(误差 ≤15 秒),不影响既有调用。
+ */
+export function heartbeatAgeSec(a: Agent, now: number, fetchedAt = 0): number | null {
+  const driftSec = fetchedAt > 0 ? Math.max(0, (now - fetchedAt) / 1000) : 0;
+  if (typeof a.heartbeatAgeSec === 'number' && Number.isFinite(a.heartbeatAgeSec)) {
+    return a.heartbeatAgeSec + driftSec;
+  }
+  if (!a.heartbeatAt) return null;
+  const t = Date.parse(a.heartbeatAt);
+  return Number.isFinite(t) ? Math.max(0, (now - t) / 1000) : null;
+}
+
+/** Matrix 通道状态:ready / down / unknown */
+function linkOf(a: Agent): 'ready' | 'down' | 'unknown' {
+  if (!a.matrixReady) return 'down';
+  const cfg = a.channels?.matrixConfig;
+  const state = cfg?.channelState ?? cfg?.channelDetail?.matrixState;
+  if (state === 'connected' || cfg?.listenerOk) return 'ready';
+  /*
+   * 🔴 明确上报了「非 connected」的状态 → 通道是断的(2026-11-11)。
+   *
+   * 用户反馈「没有连接上的容器默认显示离线」:`channelState: 'disconnected'`
+   * 这类明确值必须判离线,不能因为有 `matrixReady` 就当作可用。
+   * 只有**状态缺失**(undefined)才走 unknown —— 那是有绑定但没上报,
+   * 交给心跳判断。
+   */
+  if (state != null && state !== '') return 'down';
+  return 'unknown';
+}
+
+/**
+ * 判定一个容器是否在线。
+ *
+ * @param now       当前时刻
+ * @param fetchedAt 容器数据拉取时刻(`LiveService.fetchedAt`),用于校正心跳年龄漂移
+ */
+export function livenessOf(
+  a: Agent | null | undefined,
+  now = Date.now(),
+  fetchedAt = 0,
+): LivenessView {  if (!a) {
+    return { online: false, stale: false, offlineBecause: 'unknown-agent', text: '未接入', tone: 'neutral' };
+  }
+  // 没有 Matrix 绑定 = 从未接入,这是最先要告诉用户的
+  if (!a.matrixReady) {
+    return { online: false, stale: false, offlineBecause: 'no-binding', text: '未接入', tone: 'neutral' };
+  }
+
+  /*
+   * 容器**明确说自己停了** → 一定离线(2026-11-11)。
+   *
+   * 🔴 必须排在心跳判断之前:`stopped` / `offline` 是容器的主动表态,
+   *    比「心跳还新鲜」更有说服力。放后面的话,刚停掉但心跳尚未过期的容器
+   *    会被判成在线,用户点进去发现发不出消息。
+   */
+  if (BROKEN.has(a.status)) {
+    return {
+      online: false,
+      stale: false,
+      offlineBecause: a.status === 'stopped' ? 'stopped' : 'matrix-down',
+      text: a.status === 'stopped' ? '已停止' : '异常',
+      tone: 'danger',
+    };
+  }
+
+  const age = heartbeatAgeSec(a, now, fetchedAt);
+  if (age === null) {
+    /* 旧数据没有心跳字段:按接入态判(不新造判据,也不误判离线) */
+    return { online: true, stale: false, offlineBecause: null, text: '在线', tone: 'success' };
+  }
+
+  if (age > HEARTBEAT_DEAD_SEC) {
+    return { online: false, stale: false, offlineBecause: 'heartbeat-expired', text: '离线', tone: 'danger' };
+  }
+
+  const link = linkOf(a);
+  const busyEvidence =
+    a.status === 'running' && !isReporterSelfTalk(a) && !!(a.currentTask?.trim() || a.currentAction?.trim());
+
+  if (age <= HEARTBEAT_STALE_SEC) {
+    if (link === 'down') {
+      /* 心跳新鲜但通道明说不可用:能发消息但可能没人接 */
+      return { online: false, stale: false, offlineBecause: 'matrix-down', text: '通道断开', tone: 'warning' };
+    }
+    return { online: true, stale: false, offlineBecause: null, text: '在线', tone: 'success' };
+  }
+
+  /*
+   * 80~160 分钟:错过了一轮上报。
+   * 若容器同时说自己正在干真实任务,倾向「它还活着,只是没来得及上报」——
+   * 把干到一半的容器标离线是纯噪音。
+   */
+  if (busyEvidence && link !== 'down') {
+    return { online: true, stale: true, offlineBecause: null, text: '在线', tone: 'warning' };
+  }
+  return { online: false, stale: false, offlineBecause: 'heartbeat-expired', text: '离线', tone: 'danger' };
+}
+
+/** 列表页(通讯录 / 工作台)的两态文案 */
+export function onlineLabel(v: LivenessView): { text: string; tone: SemanticTone } {
+  if (v.online) return { text: '在线', tone: v.stale ? 'warning' : 'success' };
+  return { text: v.text, tone: v.tone };
+}
+
+/** 会话页的「本会话相位」(容器按会话上报时才有) */
+export type SessionPhase = 'thinking' | 'replying';
+
+/**
+ * 会话页状态 = 容器在线状态 **叠加** 本会话实时相位。
+ *
+ * 用户裁决(2026-11-11):「按页面分层」——列表页只要在线/离线,
+ * 会话页才额外显示「正在回复 / 思考中」,因为那是用户正在等的反馈。
+ *
+ * 🔴 离线**优先于**相位:容器都联系不上了,说「正在回复」是假的。
+ */
+export function sessionPresence(
+  a: Agent | null | undefined,
+  phase?: SessionPhase,
+  now = Date.now(),
+): { text: string; tone: SemanticTone; busy: boolean } {
+  const v = livenessOf(a, now);
+  if (!v.online) return { text: v.text, tone: v.tone, busy: false };
+  if (phase === 'replying') return { text: '正在回复…', tone: 'success', busy: true };
+  if (phase === 'thinking') return { text: '思考中…', tone: 'warning', busy: true };
+  return { text: '在线', tone: 'success', busy: false };
+}
+
+/**
+ * 离线时给用户的**下一步提示**;在线返回 null。
+ *
+ * 文案要落到「用户能做什么」,而不是重复「离线」两个字。
+ */
+export function blockedHint(v: LivenessView): string | null {
+  if (v.online) return null;
+  switch (v.offlineBecause) {
+    case 'no-binding':
+      return '该数字生命还没有接入,暂时无法进入会话。请先在容器侧完成接入。';
+    case 'matrix-down':
+      return '该数字生命的消息通道没有连接上,暂时无法进入会话。请先在容器侧确认通道已启动。';
+    case 'heartbeat-expired':
+      return '该数字生命已离线(超过 80 分钟没有上报心跳),暂时无法进入会话。请先接入后再试。';
+    case 'stopped':
+      return '该数字生命已停止运行,暂时无法进入会话。请先在容器侧重新启动。';
+    default:
+      return '该数字生命当前不可用,暂时无法进入会话。';
+  }
+}

+ 27 - 8
src/app/core/agent-state.ts

@@ -1,4 +1,5 @@
-import { Agent, AgentSessionState, AgentStatus, SemanticTone, agentPresence } from './api/models';
+import { Agent, AgentSessionState, AgentStatus, SemanticTone } from './api/models';
+import { livenessOf } from './agent-liveness';
 import { MemberPhase } from './group-live.service';
 
 /**
@@ -100,19 +101,30 @@ function heartbeatStale(a: Agent, now: number): boolean {
 export function resolveAgentState({ agent, phase, now }: AgentStateInput): AgentStateView {
   const ts = now ?? Date.now();
   /*
-   * ① 不可用优先 —— 不看 phase,避免「已挂掉仍显示正在回复」。
+   * ① 不在线优先 —— 判据收敛到 `livenessOf()`(2026-11-11)。
+   *
+   * 🔴 原来这里自己拼了一套:`agentPresence() === 'offline'`
+   *    || `BROKEN.has(status)` || `heartbeatStale()`。
+   *    而工作台/通讯录/会话页头已经改成 `livenessOf`,两套并存就会
+   *    「同一容器左栏说离线、工作台说在线」。现在统一走它。
    *
    * 顺序有讲究:先判 `error`(明确的故障,要给用户「异常」而非笼统的「离线」),
-   * 再判接入离线、心跳过期与 stopped。`agentPresence()` 对 `matrixReady=false`
-   * 返回 offline,而 matrixReady 为 false 的容器 status 往往也是 error ——
-   * 若不先判 error,故障就被降级成「离线」,用户分不清是网络问题还是容器挂了。
+   * 再判其余离线原因。
    */
   if (!agent) return { state: 'offline', text: '离线', tone: 'danger', busy: false };
   if (agent.status === 'error') {
     return { state: 'error', text: '异常', tone: 'danger', busy: false };
   }
-  if (agentPresence(agent) === 'offline' || BROKEN.has(agent.status) || heartbeatStale(agent, ts)) {
-    return { state: 'offline', text: '离线', tone: 'danger', busy: false };
+  const live = livenessOf(agent, ts);
+  if (!live.online) {
+    /*
+     * 未接入与离线在列表页是同一种「不能用」,统一成 offline 视图。
+     *
+     * 🔴 色调固定 `danger`(不跟 `live.tone` 走):`livenessOf` 对「未接入」
+     *    给的是 `neutral`(列表里灰色更克制),但 `resolveAgentState` 的调用方
+     *    (新建弹窗、会话列表)把它当作**不可用**的强提示,必须是红色。
+     */
+    return { state: 'offline', text: live.text, tone: 'danger', busy: false };
   }
 
   // ② 本回合实时态
@@ -258,6 +270,11 @@ function heartbeatAgeSec(a: Agent, now: number): number | null {
 /**
  * 算三层状态。
  *
+ * @deprecated 2026-11-11 收敛:**不要在新代码里用**。
+ *   在线判定统一走 `agent-liveness.ts` 的 `livenessOf()`。
+ *   本函数保留仅为兼容既有单测;它内部的 link 判断已改为引用 `livenessOf`,
+ *   所以不会再和主口径打架。
+ *
  * @param now 注入当前时间,便于单测
  */
 export function agentLayers(agent: Agent | null | undefined, now = Date.now()): AgentLayers {
@@ -279,7 +296,9 @@ export function agentLayers(agent: Agent | null | undefined, now = Date.now()):
     !isReporterSelfTalk(agent) &&
     !!(agent.currentTask?.trim() || agent.currentAction?.trim());
   const aliveEff: AgentLayers['alive'] = alive === 'stale' && busyHint ? 'alive' : alive;
-  const link: AgentLayers['link'] = agentPresence(agent) === 'offline' ? 'unready' : 'ready';
+  const link: AgentLayers['link'] = livenessOf(agent, now).online === false && agent.matrixReady === false
+    ? 'unready'
+    : 'ready';
 
   /*
    * busy 只认「容器在跑且不是说自己的事」。

+ 51 - 3
src/app/core/alert.service.ts

@@ -37,6 +37,16 @@ export interface AppAlert {
    * 否则用户会觉得「关不掉」。
    */
   stateDriven?: boolean;
+  /**
+   * 提示条上的**操作按钮**(2026-11-11)。
+   *
+   * 用途:容器卡住时不能让用户只看到一句「没响应」就完事 ——
+   * 给出「重新发送」,把刚才那条消息放回输入框,用户点一下就能重发。
+   *
+   * 只放**低风险、可撤销**的动作(回填草稿),不放会直接产生副作用的
+   * (比如「立即重发」会重复扣费)。
+   */
+  action?: { label: string };
 }
 
 @Injectable({ providedIn: 'root' })
@@ -73,8 +83,15 @@ export class AlertService {
    * 状态型提示(2026-11-10):条件成立就确保在,条件消失就撤掉。
    *
    * @param active 当前条件是否成立
+   * @param action 可选的**操作按钮**(2026-11-11)。注册时带一次即可;
+   *               动作的执行由宿主通过 `onAction(title)` 订阅后自行分发。
    */
-  syncState(title: string, active: boolean, detail?: string): void {
+  syncState(
+    title: string,
+    active: boolean,
+    detail?: string,
+    action?: { label: string },
+  ): void {
     if (active) {
       /*
        * 用户刚关掉 → 本轮不再弹(否则每秒都会重新出现,关不掉)。
@@ -84,12 +101,23 @@ export class AlertService {
       const exists = this.alerts().some((a) => a.title === title);
       if (exists) {
         this.alerts.update((list) =>
-          list.map((a) => (a.title === title && a.detail !== detail ? { ...a, detail } : a)),
+          list.map((a) =>
+            a.title === title && (a.detail !== detail || a.action?.label !== action?.label)
+              ? { ...a, detail, ...(action ? { action } : {}) }
+              : a,
+          ),
         );
       } else {
         this.alerts.update((list) => [
           ...list,
-          { id: this.nextId++, title, detail, tone: 'error', stateDriven: true },
+          {
+            id: this.nextId++,
+            title,
+            detail,
+            tone: 'error',
+            stateDriven: true,
+            ...(action ? { action } : {}),
+          },
         ]);
       }
       return;
@@ -99,6 +127,26 @@ export class AlertService {
     this.alerts.update((list) => list.filter((a) => a.title !== title));
   }
 
+  /**
+   * 执行提示条上的操作按钮(2026-11-11)。
+   *
+   * 实际动作由**宿主页面**注册(`onAction(handler)`),因为只有页面
+   * 知道「重新发送」该填哪条草稿。服务本身保持无业务依赖。
+   */
+  private actionHandler: ((alert: AppAlert) => void) | null = null;
+
+  onAction(handler: (alert: AppAlert) => void): void {
+    this.actionHandler = handler;
+  }
+
+  /** 由提示条组件在用户点按钮时调用 */
+  runAction(alert: AppAlert): void {
+    if (!alert.action) return;
+    this.actionHandler?.(alert);
+    /* 点完就撤 —— 留着会让人以为还没执行 */
+    this.dismiss(alert.id);
+  }
+
   /** 用户点关闭 */
   dismiss(id: number): void {
     const hit = this.alerts().find((a) => a.id === id);

+ 22 - 0
src/app/core/api/fmode-api.ts

@@ -154,6 +154,28 @@ export abstract class FmodeApi {
    */
   abstract fetchFilesBySession(threadRoot: string, limit?: number): Promise<SessionFile[]>;
 
+  /**
+   * 按群拉文件台账(2026-11-11,v0.2.0)。
+   *
+   * 服务端同时匹配 `groupId` 与 `threadRoot`,兼容没有 `groupId` 的存量记录。
+   */
+  abstract fetchFilesByGroup(groupId: string, limit?: number): Promise<SessionFile[]>;
+
+  /**
+   * 把容器产出补归并到群(2026-11-11,v0.2.0)。
+   *
+   * 服务端会改写 `threadRoot` 为群 id —— 让一条文件只属于一个页签。
+   */
+  abstract claimFilesToGroup(groupId: string, ids: string[]): Promise<{ claimed: number }>;
+
+  /**
+   * 读云盘文本文件正文(2026-11-11)。
+   *
+   * 云盘没配 CORS,浏览器直连必失败 → 由云函数 `fmode-file-read` 服务端代取。
+   * 只支持 UTF-8 文本;二进制、超大文件会抛错,调用方应降级为「新窗口打开」。
+   */
+  abstract readText(url: string): Promise<string>;
+
   // ---------- 任务看板(FR-07b) ----------
 
   /**

+ 22 - 0
src/app/core/api/mock-api.ts

@@ -189,6 +189,28 @@ export class MockApi extends FmodeApi {
     return (this.mockFiles.get(threadRoot) ?? []).slice(0, limit);
   }
 
+  /** 演示态按群拉(2026-11-11):与 threadRoot 同口径 */
+  async fetchFilesByGroup(groupId: string, limit = 200): Promise<SessionFile[]> {
+    return (this.mockFiles.get(groupId) ?? []).slice(0, limit);
+  }
+
+  /** 演示态不需要补归并(没有 DM thread 与群的两套归属) */
+  async claimFilesToGroup(): Promise<{ claimed: number }> {
+    return { claimed: 0 };
+  }
+
+  /**
+   * 演示态读文件正文(2026-11-11)。
+   *
+   * 演示环境的文件是本地 blob,本来就能直读;这里保持接口一致,
+   * 避免预览组件在演示态下走「服务未部署」的降级路径。
+   */
+  async readText(url: string): Promise<string> {
+    const res = await fetch(url);
+    if (!res.ok) throw new Error(`HTTP ${res.status}`);
+    return res.text();
+  }
+
   // ---------- 会话目录(演示态:内存存储,行为与云端一致) ----------
 
   /**

+ 30 - 0
src/app/core/api/models.ts

@@ -26,8 +26,17 @@ export interface HostMetrics {
 export interface Balance {
   /** 服务端换算后的余额(元)。映射层负责归一:兼容旧键 remainRmb 后统一写入此处 */
   remainCny?: number;
+  /** 已用金额(元)。云函数一直有给,映射层此前丢弃(2026-11-11 补) */
+  usedCny?: number;
   /** 业务再算页面外链(FR-07 AC-07.4) */
   pageUrl?: string;
+  /**
+   * 是否为**主人账号**的共用余额(2026-11-11)。
+   *
+   * 🔴 容器账单挂在主人账号上,同一主人的所有容器报的是**同一个**数,
+   *    不是「本容器剩余额度」。UI 必须据此标注,不能让用户误读。
+   */
+  sharedAccount?: boolean;
 }
 
 export interface ChannelConfig {
@@ -329,6 +338,15 @@ export interface FileEntry {
   uploadedBy: 'user' | 'agent';
   /** 容器内的原始路径(仅容器产出时有) */
   source?: string;
+  /**
+   * 所属群会话 id(2026-11-11,v0.2.0)。
+   *
+   * 伪群聊下容器只知道 DM 房间,所以只有 **用户上传** 能直接带这个值;
+   * 容器产出靠 App 锚点反查或惰性补归并补上。缺省 = 不属于任何群(单聊)。
+   */
+  groupId?: string;
+  /** 群归属的来源:'app'(直接告知)/ 'anchor'(派发锚点匹配)/ 'claim'(补归并) */
+  groupIdSource?: string;
 }
 
 /** 从文件台账拉回的一条记录(服务端已归一化) */
@@ -347,6 +365,8 @@ export interface SessionFile {
   status: string;
   /** epoch 毫秒 */
   deliveredAt: number;
+  /** 所属群会话 id(2026-11-11,v0.2.0);'' = 不属于任何群 */
+  groupId?: string;
 }
 
 export interface Deliverable {
@@ -367,6 +387,16 @@ export interface Deliverable {
   fileType?: string;
   filename?: string;
   size?: string;
+  /**
+   * 文件字节数(2026-11-11)。
+   *
+   * 🔴 与上面 `size?: string` 分工不同:`size` 是**展示字符串**("4.7 KB"),
+   *    不可逆、不能参与去重;这里是**原始字节数**,用于:
+   *      · 右栏「文件」显示大小
+   *      · 两路数据(台账 / 消息流)按「名字+大小」去重
+   *    未知时缺省(不是 0)——0 会被当成「真的 0 字节」。
+   */
+  sizeBytes?: number;
   version?: string;
 }
 

+ 33 - 0
src/app/core/api/parse-api.spec.ts

@@ -163,6 +163,39 @@ describe('ParseApi', () => {
       expect(a.balance?.remainCny).toBe(86.4);
     });
 
+    it('owner_token 余额标记为共用账号,并保留 usedCny(2026-11-11)', async () => {
+      stubFetch([
+        {
+          body: {
+            code: 200,
+            agents: [
+              {
+                ...RAW,
+                balance: '{"remainCny":948.09,"usedCny":257,"source":"owner_token"}',
+              },
+            ],
+          },
+        },
+      ]);
+      const [a] = await api.fetchAgents();
+      expect(a.balance?.remainCny).toBe(948.09);
+      expect(a.balance?.usedCny).toBe(257);
+      expect(a.balance?.sharedAccount).toBeTrue();
+    });
+
+    it('容器自报余额(非 owner_token)不标共用', async () => {
+      stubFetch([
+        {
+          body: {
+            code: 200,
+            agents: [{ ...RAW, balance: '{"remainCny":12,"source":"session"}' }],
+          },
+        },
+      ]);
+      const [a] = await api.fetchAgents();
+      expect(a.balance?.sharedAccount).toBeUndefined();
+    });
+
     it('id 优先取 agentId,缺失时回落 objectId', async () => {
       stubFetch([
         { body: { code: 200, agents: [{ ...RAW, agentId: undefined, objectId: 'FALLBACK' }] } },

+ 86 - 28
src/app/core/api/parse-api.ts

@@ -1,5 +1,6 @@
 import { environment } from '../../../environments/environment';
 import { tsOf } from '../time';
+import { prettifyStorageKey } from '../s3-link';
 import { Scope } from '../acl';
 import { FmodeApi } from './fmode-api';
 import {
@@ -82,6 +83,18 @@ const FN = {
    * 用户裁决:现有云函数一行不动,新建独立函数走新接口。
    */
   fileHub: 'THXAOGKiPK',
+  /**
+   * 文件正文代理(2026-11-11 新建,Function.name = fmode-file-read)。
+   *
+   * 🔴 为什么要它:云盘 s3.fmode.cn 是 public-read 但**没配 CORS**,
+   *    浏览器 fetch 必被拦(「加载失败:Failed to fetch」),
+   *    .md / .txt 在页内根本渲染不出来。
+   *    由服务端代取文本,浏览器只调本域(已配 ACAO:*)。
+   *
+   * ⚠️ 已部署(2026-11-11,`tocXyQjyh6`)。未部署时此 ID 为空,`readText` 会抛错,
+   *    调用方回落到「在新窗口打开」。
+   */
+  fileRead: 'tocXyQjyh6',
   /**
    * 组织成员管理(2026-11-10 新建,Function.name = fmodeagent-users)。
    *
@@ -532,6 +545,61 @@ export class ParseApi extends FmodeApi {
     return { id: d.id, duplicated: d.duplicated };
   }
 
+  /**
+   * 读云盘文本文件正文(2026-11-11)。
+   *
+   * 走云函数 `fmode-file-read` 代取 —— 云盘没配 CORS,浏览器直连必失败。
+   *
+   * @throws 未部署 / 非文本 / 过大 / 网络失败时抛错,调用方自行降级(新窗口打开)
+   */
+  async readText(url: string): Promise<string> {
+    if (!FN.fileRead) throw new Error('文件读取服务未部署');
+    const d = await this.callFunction<{ code: number; text?: string; msg?: string }>(FN.fileRead, {
+      action: 'readText',
+      url,
+    });
+    if (d.code !== 200 || typeof d.text !== 'string') {
+      throw new Error(d.msg ?? '读取失败');
+    }
+    return d.text;
+  }
+
+  /**
+   * 按**群**拉文件台账(2026-11-11,v0.2.0)。
+   *
+   * 服务端同时匹配 `groupId` 与 `threadRoot` —— 后者保证**没有 groupId 的
+   * 存量群内上传记录**也能查到(无需数据迁移)。
+   */
+  async fetchFilesByGroup(groupId: string, limit = 200): Promise<SessionFile[]> {
+    if (!groupId) return [];
+    const d = await this.callFunction<{ code: number; files?: unknown[] }>(FN.fileHub, {
+      action: 'listByGroup',
+      groupId,
+      limit,
+    });
+    if (d.code !== 200) throw new Error(pickMessage(d) ?? '群文件加载失败');
+    return (d.files ?? []).map((x) => x as SessionFile);
+  }
+
+  /**
+   * 把容器产出**补归并**到群(2026-11-11,v0.2.0)。
+   *
+   * 服务端会把 `threadRoot` 一并改写为群 id —— 这是「不串台」的关键:
+   * 搬进群之后单聊就查不到了,一条文件只出现在一个页签。
+   *
+   * 只接受「本人 + 容器产出 + 当前未归群」的记录,其余静默跳过。
+   */
+  async claimFilesToGroup(groupId: string, ids: string[]): Promise<{ claimed: number }> {
+    if (!groupId || ids.length === 0) return { claimed: 0 };
+    const d = await this.callFunction<{ code: number; claimed?: number; msg?: string }>(FN.fileHub, {
+      action: 'claimToGroup',
+      groupId,
+      ids: ids.slice(0, 100),
+    });
+    if (d.code !== 200) throw new Error(pickMessage(d) ?? '归并失败');
+    return { claimed: d.claimed ?? 0 };
+  }
+
   async fetchFilesBySession(threadRoot: string, limit = 100): Promise<SessionFile[]> {
     const d = await this.callFunction<{ code: number; files?: unknown[] }>(FN.fileHub, {
       action: 'listBySession',
@@ -938,9 +1006,14 @@ function normalizeHeartbeat(raw: Record<string, unknown>, host: unknown): string
 /** 云函数原始记录 → `Agent` 领域模型(映射只在此处发生) */
 function mapAgent(raw: Record<string, unknown>): Agent {
   const channels = normalizeChannels(raw['channels']);
-  const balance = parseJsonLoose<{ remainCny?: number; remainRmb?: number; cny?: number; pageUrl?: string }>(
-    raw['balance'],
-  );
+  const balance = parseJsonLoose<{
+    remainCny?: number;
+    remainRmb?: number;
+    cny?: number;
+    usedCny?: number;
+    pageUrl?: string;
+    source?: string;
+  }>(raw['balance']);
   const host = normalizeHost(raw['host']);
   const heartbeatAt = normalizeHeartbeat(raw, raw['host']);
   const user = raw['user'] as Record<string, unknown> | undefined;
@@ -999,7 +1072,13 @@ function mapAgent(raw: Record<string, unknown>): Agent {
       ? {
           // 映射层归一:兼容旧键 remainRmb,以及容器复刻脚本发的 cny
           remainCny: balance.remainCny ?? balance.remainRmb ?? balance.cny,
+          usedCny: typeof balance.usedCny === 'number' ? balance.usedCny : undefined,
           pageUrl: balance.pageUrl,
+          /*
+           * `source: 'owner_token'` = 云函数用**主人账号的令牌**实时取的余额。
+           * 容器账单挂在主人账号上 → 同一主人的所有容器报同一个数,UI 要标注「账号余额」。
+           */
+          sharedAccount: balance.source === 'owner_token' ? true : undefined,
         }
       : undefined,
     channels: channels
@@ -1067,33 +1146,12 @@ function mapAgent(raw: Record<string, unknown>): Agent {
 }
 
 /**
- * 从存储键里还原一个「像文件名」的展示名(2026-11-10)。
- *
- * 存储键由服务端生成,常见形态:
- *   `deliverables/<agentId>/202611/<did>/0-mwa2gx2gog-weekly-report.md`
- *   `deliverables/<agentId>/202611/<did>/1757631234567-3f2a.md`(雪花/随机段)
+ * 从存储键里还原一个「像文件名」的展示名。
  *
- * 规则:取末段 → 去掉前导「序号-随机串-」前缀 → 若剩余仍是纯随机串则保持原样。
- * 目的只是让 UI 不显示成 `0-mwa2gx2gog-weekly-report.md` 这种带内部前缀的样子。
+ * 实现已移到 `core/s3-link.ts`(消息卡片也要用,而 `chat.service`
+ * 不能反向依赖本文件)。这里重新导出,保持既有调用点不变。
  */
-function prettifyStorageKey(keyOrUrl: string): string {
-  if (!keyOrUrl) return '';
-  const path = keyOrUrl.split(/[?#]/)[0];
-  const seg = path.split('/').filter(Boolean).pop() ?? '';
-  if (!seg) return '';
-  let name = seg;
-  try {
-    name = decodeURIComponent(seg);
-  } catch {
-    /* 编码坏了就用原样 */
-  }
-  /*
-   * 剥前导 `<数字>-<随机串>-`:服务端在存储键前面加的去重前缀。
-   * 只剥「数字-字母数字-」这两段,后面的原样保留(可能已经是中文名)。
-   */
-  name = name.replace(/^\d+-[a-z0-9]{6,}-/i, '');
-  return name || seg;
-}
+export { prettifyStorageKey };
 
 /** 成果原始记录 → `Deliverable` 领域模型 */
 function mapDeliverable(raw: Record<string, unknown>): Deliverable {

+ 339 - 4
src/app/core/assistant-turn.spec.ts

@@ -3,7 +3,9 @@ import { Message } from './api/models';
 import { Turn } from './turn-aggregator';
 import {
   elapsedSeconds,
+  isTurnStalled,
   isTurnTimedOut,
+  STALL_MS,
   TURN_TIMEOUT_MS,
   turnsFromDm,
   turnsFromGroup,
@@ -90,7 +92,12 @@ describe('turnsFromDm(单聊)', () => {
 
     expect(t.blocks.length).toBe(1);
     expect(t.blocks[0].agentId).toBeNull();
-    expect(t.blocks[0].key).toBe('agent');
+    /*
+     * 2026-11-11:key 从固定 'agent' 改为 `agent:<首条消息 id>`。
+     * 固定值在按轮次拆块时会撞车(NG0955 + 读数互相覆盖),
+     * 现在每块以首条消息 id 区分,单块时也保持一致以便模板 track 稳定。
+     */
+    expect(t.blocks[0].key).toBe('agent:a1');
   });
 
   it('正文留在下半区,不被误判为过程', () => {
@@ -139,6 +146,141 @@ describe('turnsFromDm(单聊)', () => {
     expect(t.blocks[0].body.length).toBe(0);
   });
 
+  /*
+   * 2026-11-11 用户实测:容器的工具日志把正文吃掉了。
+   *
+   * 原实现里 `isToolActivityMessage` 是「工具行严格过半即整条算工具活动」,
+   * 命中后整条 `continue`,同一条消息里的中文结论**永久消失**。
+   * 这组用例是那个缺陷的回归保护 —— 修复前它们会全部失败。
+   */
+  describe('🔴 工具日志不得吞掉正文(2026-11-11)', () => {
+    /** 取块里所有正文文本 */
+    const bodyTextOf = (b: { body: { kind: string; message?: { text?: string } }[] }): string =>
+      b.body
+        .filter((x) => x.kind === 'text')
+        .map((x) => x.message?.text ?? '')
+        .join('\n');
+
+    it('2 行工具 + 1 行 markdown 结论 → 结论必须留在正文', () => {
+      /*
+       * 真实形态(用户截图):容器先回一段正文,再用加了粗的标题引出下载链接,
+       * 中间夹着工具行。工具行占 2/3,旧实现会整条吞掉。
+       */
+      const text =
+        '**已上云(可直接下载)**\n' +
+        '🐍 Running code…\n' +
+        '🐍 Running code…';
+      const [t] = turnsFromDm([dmTurn([msg('a1', 'agent', text)])], 0, false);
+      const b = t.blocks[0];
+
+      expect(bodyTextOf(b)).withContext('结论被工具日志吞掉了').toContain('已上云');
+      expect(b.steps.length).withContext('工具行仍应进过程区').toBe(2);
+    });
+
+    it('工具行 + 「下载链接如下:」→ 冒号结尾的引导句留在正文', () => {
+      const text = '下载链接如下:\n💻 bash: ls -la\n💻 bash: cat x';
+      const [t] = turnsFromDm([dmTurn([msg('a1', 'agent', text)])], 0, false);
+
+      expect(bodyTextOf(t.blocks[0])).toContain('下载链接如下');
+    });
+
+    it('中文短结论(不足 6 个汉字)只要带句号就留在正文', () => {
+      const text = '已完成。\n💻 bash\n💻 bash';
+      const [t] = turnsFromDm([dmTurn([msg('a1', 'agent', text)])], 0, false);
+
+      expect(bodyTextOf(t.blocks[0])).toContain('已完成');
+    });
+
+    it('纯工具消息 → 正文为空(不能反过来把日志塞进正文)', () => {
+      const text = '💻 bash\n✍️ Writing /opt/data/x.html';
+      const [t] = turnsFromDm([dmTurn([msg('a1', 'agent', text)])], 0, false);
+
+      expect(t.blocks[0].body.length).toBe(0);
+      expect(t.blocks[0].steps.length).toBeGreaterThan(0);
+    });
+
+    it('裸命令行(无 emoji 无 ·)进过程区,不暴露在正文里', () => {
+      const text = 'cd /opt/data && node run.js\nsudo apt-get install -y ffmpeg';
+      const [t] = turnsFromDm([dmTurn([msg('a1', 'agent', text)])], 0, false);
+
+      expect(bodyTextOf(t.blocks[0])).withContext('命令不该出现在正文').not.toContain('apt-get');
+      expect(t.blocks[0].steps.length).toBeGreaterThan(0);
+    });
+  });
+
+  /*
+   * 2026-11-11 用户实测:界面出现黑色空代码块 +「复制」按钮。
+   *
+   * 根因是我的 `isCommandLine` 把**闭合围栏** ``` 当成命令挪进了过程区,
+   * 正文只剩开围栏 → markdown 解析出空 <pre>。
+   */
+  describe('🔴 代码围栏必须成对保留(2026-11-11)', () => {
+    const bodyTextOf = (b: { body: { kind: string; message?: { text?: string } }[] }): string =>
+      b.body
+        .filter((x) => x.kind === 'text')
+        .map((x) => x.message?.text ?? '')
+        .join('\n');
+
+    it('围栏成对出现,不被拆散', () => {
+      const text = '结果如下:\n```bash\nnpm test\n全部通过\n```\n以上。';
+      const [t] = turnsFromDm([dmTurn([msg('a1', 'agent', text)])], 0, false);
+      const body = bodyTextOf(t.blocks[0]);
+
+      const fences = (body.match(/```/g) ?? []).length;
+      expect(fences).withContext('开闭围栏要成对,否则会渲染出空代码块').toBe(2);
+      expect(body).withContext('围栏内容要留着').toContain('npm test');
+      expect(body).withContext('围栏后的正文要留着').toContain('以上');
+    });
+
+    it('围栏内的命令**不进**过程区(它是代码,不是执行记录)', () => {
+      const text = '```bash\ncd /opt/data && node x.js\n```';
+      const [t] = turnsFromDm([dmTurn([msg('a1', 'agent', text)])], 0, false);
+
+      expect(t.blocks[0].steps.length).withContext('代码块里的命令不该被当步骤').toBe(0);
+      expect(bodyTextOf(t.blocks[0])).toContain('cd /opt/data');
+    });
+
+    it('未闭合围栏 → 后续内容仍留在正文,不吞', () => {
+      const text = '看这里:\n```\n一些内容\n没有闭合';
+      const [t] = turnsFromDm([dmTurn([msg('a1', 'agent', text)])], 0, false);
+      const body = bodyTextOf(t.blocks[0]);
+
+      expect(body).toContain('一些内容');
+      expect(body).toContain('没有闭合');
+    });
+  });
+
+  /*
+   * 2026-11-11 用户实测:`isCommandLine` 第一版把「不含汉字的行」几乎全吞了,
+   * 英文正文、表格、URL、日期、列表项都从正文消失。
+   */
+  describe('🔴 不含汉字的正文不能被当成命令(2026-11-11)', () => {
+    const bodyTextOf = (b: { body: { kind: string; message?: { text?: string } }[] }): string =>
+      b.body
+        .filter((x) => x.kind === 'text')
+        .map((x) => x.message?.text ?? '')
+        .join('\n');
+
+    const cases: [string, string][] = [
+      ['英文正文', 'Done. All good.'],
+      ['表格分隔行', '|---|---|'],
+      ['英文列表项', '- English item'],
+      ['日期时间', '2026-11-11 09:51'],
+      ['URL 行', 'https://s3.fmode.cn/a/b.md'],
+      ['文件大小描述', 'test-20MB.bin 20.0 MB'],
+      ['markdown 标题', '## Results'],
+    ];
+
+    for (const [label, line] of cases) {
+      it(`${label}:\`${line}\` 留在正文`, () => {
+        const text = `${line}\n🐍 Running code…\n🐍 Running code…`;
+        const [t] = turnsFromDm([dmTurn([msg('a1', 'agent', text)])], 0, false);
+
+        expect(bodyTextOf(t.blocks[0])).withContext(`${label} 被当成命令吞掉了`).toContain(line);
+      });
+    }
+  });
+
   it('正在输出时 live 为真,且不受 closed 影响时按静默窗口判断', () => {
     const [open] = turnsFromDm(
       [dmTurn([msg('a1', 'agent', '输出中', 1000)], { closed: false })],
@@ -408,6 +550,35 @@ describe('waitText(等待 / 输出占位文案)', () => {
   it('🔴 已结束且什么也没有 → 不再显示「未响应」气泡(2026-11-10 改为全局提示)', () => {
     expect(waitText(30_000, false, false, true)).toBe('');
   });
+
+  /*
+   * 2026-11-11 用户反馈:「思考的时候没有开始显示时间」。
+   * 只显示「正在思考…」时,用户分不清是刚发出还是已经卡了很久。
+   */
+  describe('带起始时刻(2026-11-11)', () => {
+    /** 本地时间 09:51 → epoch ms(用本地时区构造,避免跑测试的机器时区不同) */
+    const at0951 = new Date(2026, 10, 11, 9, 51, 0).getTime();
+
+    it('8s 内 → 带时刻的「正在思考」', () => {
+      expect(waitText(3_000, false, true, false, at0951)).toBe('已送达 09:51 · 正在思考…(3s)');
+    });
+
+    it('超过 8s → 带时刻的「已送达,等待回复」', () => {
+      expect(waitText(30_000, false, true, false, at0951)).toBe('09:51 已送达,等待回复…(30s)');
+    });
+
+    it('不传 startAt(老调用点)→ 文案与旧版一致,不出现时刻', () => {
+      expect(waitText(0, false, true, false)).toBe('正在思考…');
+      expect(waitText(8_000, false, true, false)).toBe('已送达,等待回复…(8s)');
+      expect(waitText(8_000, false, true, false, 0)).withContext('0 = 无锚点').toBe(
+        '已送达,等待回复…(8s)',
+      );
+    });
+
+    it('已结束 → 即使传了时刻也不显示', () => {
+      expect(waitText(30_000, true, false, false, at0951)).toBe('');
+    });
+  });
 });
 
 /*
@@ -422,10 +593,24 @@ describe('phaseOf(交付口径)', () => {
     expect(t.phase).toBe('done');
   });
 
-  it('🔴 只有过程步骤、没有正文 → progressOnly', () => {
+  it('🔴 只有过程步骤、没有正文 → 宽限期内是「输出中」,超过宽限才是 progressOnly', () => {
+    /*
+     * 2026-11-11 语义调整:容器跑工具时**不产生消息**,
+     * 20~25 秒空档就足以让人以为它停了(用户实测「已结束 · 无交付」)。
+     *
+     * 现在分两段:
+     *   · 距最后活动 < 60s(STALL_MS)→ 仍在干活,`replying`
+     *   · 超过 60s → 确实停住了,`progressOnly`(交给全局提示)
+     */
     const progress = ['🐍 Running code from hermes_tools import terminal r=t...', '🐍 Running code from hermes_tools import search_files...'].join('\n');
-    const [t] = turnsFromDm([dmTurn([msg('a1', 'agent', progress, 1000)], { userMessage: user() })], T0 + 60_000, false);
-    expect(t.phase).toBe('progressOnly');
+    const turnOf = (): Turn =>
+      dmTurn([msg('a1', 'agent', progress, 1000)], { userMessage: user() });
+
+    const [running] = turnsFromDm([turnOf()], T0 + 30_000, false);
+    expect(running.phase).withContext('跑工具的正常空档仍算输出中').toBe('replying');
+
+    const [stuck] = turnsFromDm([turnOf()], T0 + 90_000, false);
+    expect(stuck.phase).withContext('静止超过 60 秒才算结束').toBe('progressOnly');
   });
 
   it('什么都没有 → idle', () => {
@@ -458,6 +643,79 @@ describe('isTurnTimedOut', () => {
   });
 });
 
+/*
+ * 2026-11-11 用户实测:容器先回一句、跑两步工具,然后**再也没下文**。
+ * 界面停在「已结束」什么都不说,用户只能干等。
+ *
+ * 原因是 `isTurnTimedOut` 要求 `!hasOutput`,回了一部分就永远不触发。
+ */
+describe('isTurnStalled(卡住检测)', () => {  it('有输出、已停止、静止超过 60s → 判为卡住', () => {
+    expect(isTurnStalled(STALL_MS, true, false)).toBe(true);
+    expect(isTurnStalled(5 * 60_000, true, false)).toBe(true);
+  });
+
+  it('静止不足 60s → 不打扰(容器跑工具常有空档)', () => {
+    expect(isTurnStalled(59_000, true, false)).toBe(false);
+  });
+
+  it('🔴 仍在 live → 属于正常思考,不判卡住', () => {
+    expect(isTurnStalled(10 * 60_000, true, true)).toBe(false);
+  });
+
+  it('🔴 一个字都没回 → 交给 isTurnTimedOut,两个判据不重叠', () => {
+    expect(isTurnStalled(10 * 60_000, false, false)).toBeFalse();
+  });
+
+  it('与 isTurnTimedOut 互补:同一时刻不会两个都为真', () => {
+    const waitMs = 5 * 60_000;
+    expect(isTurnTimedOut(waitMs, true, false)).withContext('有输出 → 超时判据不成立').toBeFalse();
+    expect(isTurnStalled(waitMs, true, false)).withContext('静止 → 卡住判据成立').toBeTrue();
+
+    expect(isTurnTimedOut(waitMs, false, false)).withContext('无输出 → 超时成立').toBeTrue();
+    expect(isTurnStalled(waitMs, false, false)).withContext('无输出 → 卡住不成立').toBeFalse();
+  });
+});
+
+/*
+ * 2026-11-11 用户实测:「运行了 2 步 · Writing ... 9s · 已结束 · 无交付」,
+ * 而容器其实还在跑(20 秒空档就足以让 live 转 false)。
+ */
+describe('🔴 长任务宽限:有过程步骤时不应过早判已结束(2026-11-11)', () => {
+  /** 有工具步骤、但没有正文的回合 */
+  const workingTurn = (): Turn =>
+    dmTurn([msg('a1', 'agent', '💻 bash\n✍️ Writing /opt/data/x.md', 2000)], {
+      userMessage: msg('u1', 'me', '干活', 0),
+      closed: true, // 聚合层已在静默 25s 后收尾
+    });
+
+  it('前置检查:该回合确实有过程步骤', () => {
+    const [t] = turnsFromDm([workingTurn()], T0 + 42_000, false);
+    expect(t.blocks[0].steps.length).withContext('没有步骤的话下面的宽限测试就无意义').toBeGreaterThan(0);
+  });
+
+  it('静止 40 秒(超过 25s 静默窗口但未到 60s)→ 仍算进行中', () => {
+    const [t] = turnsFromDm([workingTurn()], T0 + 42_000, false);
+    expect(t.blocks[0].live).withContext('跑工具时的正常空档,不能判结束').toBeTrue();
+    expect(t.phase).withContext('不能说「已结束」').toBe('replying');
+  });
+
+  it('静止 70 秒(超过 60s 宽限)→ 才判已结束', () => {
+    const [t] = turnsFromDm([workingTurn()], T0 + 72_000, false);
+    expect(t.blocks[0].live).toBeFalse();
+    expect(t.phase).toBe('progressOnly');
+  });
+
+  it('没有任何步骤 → 仍按原静默窗口收尾(宽限只给真在干活的)', () => {
+    const idleTurn = (): Turn =>
+      dmTurn([msg('a1', 'agent', '💬 说明', 2000)], {
+        userMessage: msg('u1', 'me', '干嘛', 0),
+        closed: true,
+      });
+    const [t] = turnsFromDm([idleTurn()], T0 + 42_000, false);
+    expect(t.blocks[0].live).withContext('没有步骤说明没在干活,按原窗口收起').toBeFalse();
+  });
+});
+
 /*
  * 🔴 2026-11-10 用户实测:容器不回时界面永远挂「已送达,等待回复…(253s)」,
  *    超时全局提示永远不弹。根因是 `awaitingFirstReply` 让 `live` 恒为真。
@@ -499,6 +757,51 @@ describe('未回复回合的超时收尾', () => {
     const [t] = turnsFromDm([t0], T0 + TURN_TIMEOUT_MS + 5_000, false);
     expect(t.blocks[0].body.length).toBeGreaterThan(0);
   });
+
+  /*
+   * 2026-11-11 用户实测:「超时 3 分钟会提醒,但这段时间聊天区域的加载动画
+   * 已经消失了」—— 25 秒静默后 `live` 转 false,到 180 秒之间界面一片空白。
+   *
+   * `waiting` 就是补这段空白的:与 `live` 解耦,一直持续到超时。
+   */
+  describe('🔴 静默期仍需「在等」的指示(2026-11-11)', () => {    it('等待中(未超时)→ waiting 为真,卡片不会空', () => {
+      const [t] = turnsFromDm([emptyTurn()], T0 + 100_000, false);
+      expect(t.blocks[0].waiting).withContext('仍在等回复').toBeTrue();
+      expect(t.blocks[0].empty).withContext('有 waiting 就不算空').toBeFalse();
+    });
+
+    it('🔴 回合被提前收尾(closed)→ live 转 false,但 waiting 仍撑着提示', () => {
+      /*
+       * 这是真正的空白窗口:`turn-aggregator` 在静默超过 QUIET_MS 后把回合置 closed,
+       * `live` 立刻变 false,但用户还在等(180 秒才超时)。
+       * 没有 waiting 的话,这一段就是纯空白。
+       */
+      const closedTurn = (): Turn => dmTurn([], { userMessage: userMsg(), closed: true });
+      const [t] = turnsFromDm([closedTurn()], T0 + 100_000, false);
+      expect(t.blocks[0].live).withContext('回合已收尾').toBeFalse();
+      expect(t.blocks[0].waiting).withContext('未超时,仍要给提示').toBeTrue();
+    });
+
+    it('等待 100 秒时文案仍可读且秒数在走', () => {
+      const [t] = turnsFromDm([emptyTurn()], T0 + 100_000, false);
+      const text = waitText(100_000, false, t.blocks[0].live || t.blocks[0].waiting, t.blocks[0].empty);
+      expect(text).withContext('不能再返回空串').not.toBe('');
+      expect(text).toContain('100s');
+    });
+
+    it('超过 3 分钟 → waiting 转 false,交给全局提示', () => {
+      const [t] = turnsFromDm([emptyTurn()], T0 + TURN_TIMEOUT_MS + 5_000, false);
+      expect(t.blocks[0].waiting).toBeFalse();
+      expect(t.blocks[0].empty).withContext('此时该由全屏提示接手').toBeTrue();
+    });
+
+    it('容器已经开始输出 → waiting 为假(交给正常的输出态)', () => {
+      const reply = msg('a1', 'agent', '这是回复。', 2_000);
+      const t0 = dmTurn([reply], { userMessage: userMsg(), closed: false });
+      const [t] = turnsFromDm([t0], T0 + 30_000, false);
+      expect(t.blocks[0].waiting).toBeFalse();
+    });
+  });
 });
 
 /**
@@ -522,6 +825,38 @@ describe('分轮后的用时读数', () => {
     expect(t.blocks.length).toBe(2);
   });
 
+  /*
+   * 2026-11-11 用户实测:控制台刷
+   * `NG0955: duplicated keys... key "agent" at index "0" and "1"`,
+   * 且段头读数在 39s / 9s / 43s 之间乱跳。
+   *
+   * 根因:单聊每块的 key 都是固定 'agent',而 `segTime`/`waitLabels`/`targetsOf`
+   * 都以 key 为字典键 → 后一轮覆盖前一轮。
+   */
+  it('🔴 多块 key 必须唯一(否则 NG0955 + 读数互相覆盖)', () => {
+    const [t] = turnsFromDm([twoRounds()], T0 + 33_000, false);
+    const keys = t.blocks.map((b) => b.key);
+    expect(new Set(keys).size).withContext(`重复的 key: ${keys.join(', ')}`).toBe(keys.length);
+  });
+
+  /*
+   * 单次提问时不该因为「消息间隔 > 20 秒」被切成多轮 ——
+   * 容器跑长工具时本来就不产生消息,误切会让部分块先「结束」。
+   */
+  it('🔴 只有一次提问 → 即使消息间隔大也只有 1 块', () => {
+    const oneAsk = (): Turn =>
+      dmTurn([msg('a1', 'agent', '第一轮', 2000), msg('a2', 'agent', '第二轮', 32_000)], {
+        rounds: [
+          [msg('a1', 'agent', '第一轮', 2000)],
+          [msg('a2', 'agent', '第二轮', 32_000)],
+        ],
+        userAskTimes: [T0 - 1000],
+        closed: false,
+      });
+    const [t] = turnsFromDm([oneAsk()], T0 + 33_000, false);
+    expect(t.blocks.length).withContext('单次提问不拆轮').toBe(1);
+  });
+
   it('🔴 各轮读秒锚点取「不晚于本轮首条消息的最后一个用户提问」', () => {
     const [t] = turnsFromDm([twoRounds()], T0 + 33_000, false);
     // 第一轮:锚点 = 第一次提问;第二轮:锚点 = 第二次提问

+ 341 - 48
src/app/core/assistant-turn.ts

@@ -1,7 +1,14 @@
 import { FileCard, Message } from './api/models';
 import { GroupTurn } from './group-turns';
 import { mergeConsecutiveBodies, MergedMessage } from './message-merge';
-import { isToolActivityMessage, ProcessStep, splitMessage } from './message-segments';
+import {
+  isCommandLine,
+  isProseLine,
+  isToolActivityMessage,
+  ProcessStep,
+  splitMessage,
+} from './message-segments';
+import { fmtTime } from './time';
 import { Turn } from './turn-aggregator';
 
 /**
@@ -79,6 +86,14 @@ export interface TurnBlockView {
   files: { key: string; file: FileCard }[];
   /** 本块是否仍在输出 */
   live: boolean;
+  /**
+   * 是否仍在**等第一口气**(2026-11-11)。
+   *
+   * 与 `live` 解耦:`live` 受 25 秒静默窗口约束会提前转 false,
+   * 而用户要一直等到 180 秒超时才该失去等待指示。
+   * 渲染层应把 `live || waiting` 当作「还在进行中」。
+   */
+  waiting: boolean;
   /** 本块是否无内容且未在输出(群聊未响应) */
   empty: boolean;
   /**
@@ -149,6 +164,17 @@ export interface AssistantTurn {
 /** 构建一块所需的原料(由单聊/群聊两种输入各转一份) */
 interface BlockSource {
   agentId: string | null;
+  /**
+   * 显式块 key(2026-11-11)。
+   *
+   * 单聊按轮次拆块后,每块 `agentId` 都是 null,若仍用 `agentId ?? 'agent'`
+   * 生成 key,多块就会**全部叫 'agent'** → 模板 `@for track` 报 NG0955,
+   * 且 `segTime`/`waitLabels`/`targetsOf` 以 key 为字典键,后一轮覆盖前一轮,
+   * 读数在 39s / 9s / 43s 之间乱跳(用户实测)。
+   *
+   * 拆轮时由调用方传 `agent:<首条消息 id>`;不传则回落到旧逻辑。
+   */
+  key?: string;
   messages: Message[];
   startedAt: number;
   updatedAt: number;
@@ -184,13 +210,27 @@ function splitIntoParts(messages: Message[]): {
 
   for (const m of messages) {
     const text = m.text ?? '';
+    if (!text.trim()) continue;
 
-    // ① 整条都是终端活动 → 过程区,不进正文
-    if (text && isToolActivityMessage(text) && !proseOf(text)) {
-      steps.push(...stepsOf(text));
-      continue;
-    }
-    if (text.trim()) bodies.push(m);
+    /*
+     * 🔴 一律按行拆分,不再「整条判成工具活动就丢弃」(2026-11-11 修复)。
+     *
+     * 原实现:
+     *   if (isToolActivityMessage(text) && !proseOf(text)) {
+     *     steps.push(...stepsOf(text));   // stepsOf 只取 process 块
+     *     continue;                        // ← 同条消息里的正文永远消失
+     *   }
+     *
+     * 后果(用户实测):容器回一段混排消息,比如
+     *   「有的,共 69 行…」 + `**已上云(可直接下载)**` + 两行 `🐍 Running code…`
+     * 工具行严格过半 → 整条进过程区 → 那句结论和文件名**再也看不到**。
+     *
+     * 现在交给 `divideText` 逐行判:工具行 / 命令行进过程区,
+     * 其余(含结论、markdown 加粗、列表)留在正文。
+     */
+    const { steps: s, text: kept } = divideText(text);
+    steps.push(...s);
+    if (kept.trim()) bodies.push(kept.trim() === text.trim() ? m : { ...m, text: kept.trim() });
   }
 
   /*
@@ -255,31 +295,102 @@ function stepsOf(text: string): ProcessStep[] {
   const steps: ProcessStep[] = [];
   const keep: string[] = [];
 
-  for (const b of splitMessage(text)) {
-    if (b.kind === 'process') {
-      /*
-       * 🔴 `splitMessage` 也会把「行首 emoji + 成句中文」解析成 process 块
-       *    (`💾 自我升级小结:…了。` 实测如此)。这类是**结论**不是过程,
-       *    交还正文,否则折叠区里会出现半句话。
-       */
-      const prose = b.steps.filter((s) => proseOf(s.label));
-      const tools = b.steps.filter((s) => !proseOf(s.label));
-      steps.push(...tools);
-      for (const p of prose) keep.push(p.label);
+  /*
+   * 🔴 先按代码围栏切段,再在段内逐行拆(2026-11-11 修复空代码块)。
+   *
+   * 原来直接对整条文本逐行判:闭合围栏 ``` 被 `isCommandLine` 当成命令
+   * 挪进过程区,正文里只剩开围栏 → markdown 解析出一个**空 `<pre>`**,
+   * 叠加 `md-render` 的「复制」按钮,界面上就是一个黑色空块。
+   *
+   * 现在围栏内的所有行**原样进正文**,不参与任何命令行/步骤判定。
+   */
+  for (const piece of splitByFences(text)) {
+    if (piece.fence) {
+      keep.push(...piece.lines);
       continue;
     }
-    if (b.kind !== 'text') continue;
+    for (const b of splitMessage(piece.lines.join('\n'))) {
+      if (b.kind === 'process') {
+        /*
+         * 🔴 `splitMessage` 也会把「行首 emoji + 成句中文」解析成 process 块
+         *    (`💾 自我升级小结:…了。` 实测如此)。这类是**结论**不是过程,
+         *    交还正文,否则折叠区里会出现半句话。
+         */
+        const prose = b.steps.filter((s) => isProseLine(s.label));
+        const tools = b.steps.filter((s) => !isProseLine(s.label));
+        steps.push(...tools);
+        for (const p of prose) keep.push(p.label);
+        continue;
+      }
+      if (b.kind !== 'text') continue;
 
-    for (const line of b.text.split('\n')) {
-      const inline = inlineStepOf(line);
-      if (inline && !proseOf(line)) steps.push(inline);
-      else keep.push(line);
+      for (const line of b.text.split('\n')) {
+        const inline = inlineStepOf(line);
+        if (inline && !isProseLine(line)) {
+          steps.push(inline);
+          continue;
+        }
+        /*
+         * 裸命令行(无 emoji、无 `·` 前缀)也要进过程区(2026-11-11)。
+         * 容器会把 `cd /opt/data && node x.js` 直接贴出来 ——
+         * 它既不是正文也不带工具标记,光靠 `inlineStepOf` 识别不到。
+         */
+        if (isCommandLine(line)) {
+          steps.push({ kind: 'tool', label: line.trim() });
+          continue;
+        }
+        keep.push(line);
+      }
     }
   }
 
   return { steps, text: keep.join('\n').replace(/\n{3,}/g, '\n\n') };
 }
 
+/**
+ * 按**成对**代码围栏切成若干段(2026-11-11)。
+ *
+ * - 围栏行本身与其内部内容 → `fence: true` 段,调用方应整段当正文
+ * - 围栏外 → `fence: false` 段,调用方可继续逐行拆
+ * - **未闭合**的围栏延续到文末,整段当正文(宁可多显示代码,不要吞内容)
+ */
+function splitByFences(text: string): { fence: boolean; lines: string[] }[] {
+  const FENCE = /^\s*(`{3,}|~{3,})/;
+  const out: { fence: boolean; lines: string[] }[] = [];
+  let cur: { fence: boolean; lines: string[] } = { fence: false, lines: [] };
+  let open: string | null = null;
+
+  const flush = (): void => {
+    if (cur.lines.length > 0) out.push(cur);
+  };
+
+  for (const line of text.split('\n')) {
+    const fm = FENCE.exec(line);
+    if (fm) {
+      const mark = fm[1][0];
+      if (open === null) {
+        /* 开围栏:把围栏行放进一个新的 fence 段 */
+        flush();
+        cur = { fence: true, lines: [line] };
+        open = mark;
+      } else if (mark === open) {
+        /* 闭围栏:收尾当前 fence 段 */
+        cur.lines.push(line);
+        flush();
+        cur = { fence: false, lines: [] };
+        open = null;
+      } else {
+        /* 围栏内出现另一种记号 → 当内容 */
+        cur.lines.push(line);
+      }
+      continue;
+    }
+    cur.lines.push(line);
+  }
+  flush();
+  return out;
+}
+
 /** 行首 `· 工具名` 形态 → 一步;否则 null */
 const INLINE_STEP = /^\s*[·•]\s*([a-zA-Z][\w.\-]{0,40})(?:\s*[::]?\s*(.+))?$/;
 
@@ -358,6 +469,17 @@ export const TURN_TIMEOUT_MS = 180_000;
 
 const DEFAULT_QUIET_MS = 25_000;
 
+/**
+ * 「卡住」门槛:**最后一条输出之后**静止多久算异常。
+ *
+ * 取 60 秒:容器跑一轮工具调用常有 10~30 秒空档,
+ * 1 分钟毫无动静基本可以认定停下了,同时不至于误报。
+ *
+ * 🔴 位置必须在 `toBlockView` **之前**:它用这个常量做「长任务宽限」,
+ *    而模块级 `const` 是从上往下求值的,放到后面会触发 TDZ 报错。
+ */
+export const STALL_MS = 60_000;
+
 function toBlockView(
   src: BlockSource,
   now: number,
@@ -393,7 +515,18 @@ function toBlockView(
    * 所以 typing 也受静默窗口约束:已经很久没有新内容,就不再信它。
    */
   const idleFor = now - src.updatedAt;
-  const typingAlive = src.typing && idleFor < quietMs * 2;
+  /*
+   * 🔴 typing 窗口收紧为 `quietMs`(2026-11-11 用户实测)。
+   *
+   * 原来是 `quietMs * 2`(50 秒):容器明明已经把正文输出完、也不再发新消息,
+   * 界面却要继续挂 50 秒「输出中」,用户以为卡住了(截图:20s 那次读数,
+   * 实际等了 56 秒才变「已完成」)。
+   *
+   * 容器结束后**不会发 `m.typing:false`**,所以 typing 迟早要过期;
+   * 既然过期后由「静默窗口」接管(同样的 quietMs),就没必要给 typing 双倍宽限 ——
+   * 两个窗口对齐后,正文一停、读数也就跟着停,不会多挂 25 秒。
+   */
+  const typingAlive = src.typing && idleFor < quietMs;
   /*
    * 等待超时收尾(2026-11-10,用户裁决保留):
    *
@@ -404,8 +537,68 @@ function toBlockView(
    */
   const waitingTooLong =
     awaitingFirstReply && waitingSince > 0 && now - waitingSince >= TURN_TIMEOUT_MS;
+  /*
+   * 🔴 长任务宽限(2026-11-11 用户实测修复)。
+   *
+   * 用户看到「运行了 2 步 · Writing ... 9s · 已结束 · 无交付」,
+   * 而容器其实还在跑。
+   *
+   * 根因:`idleFor` 用的是**最后一条 agent 消息**的时间,而容器跑长工具时
+   * 恰恰不产生消息 —— 20~25 秒空档就足以让 `live` 转 false,
+   * 于是「有过程步骤、但没正文」的块被判成 `progressOnly`(已结束)。
+   *
+   * 现在:**已有过程步骤**说明容器真的在干活,宽限到 `STALL_MS`(60 秒)。
+   * 超过 60 秒仍无动静,就交给 `isTurnStalled` 的全局提示(「容器已停止响应」),
+   * 两个信号同一常量、互补而不冲突。
+   */
+  const hasSteps = steps.length > 0;
+  const graceMs = hasSteps ? Math.max(quietMs, STALL_MS) : quietMs;
+  /*
+   * 🔴 `src.closed` 在有步骤时也要让路。
+   *
+   * `closed` 是聚合层在静默 `QUIET_MS` 后打的「这轮不再有新消息」标记 ——
+   * 它恰恰会在容器跑长工具时打上(跑工具不产生消息)。
+   * 若这里仍然 `!src.closed`,宽限就永远用不上,用户照样看到「已结束」。
+   *
+   * 所以:**没有步骤时**照旧尊重 closed(正常收尾);
+   *        **有步骤时**以时间为准(宽限到 STALL_MS)。
+   */
+  const closedBlocks = !hasSteps && src.closed;
   const live =
-    !src.closed && !waitingTooLong && (awaitingFirstReply || typingAlive || idleFor < quietMs);
+    !closedBlocks && !waitingTooLong && (awaitingFirstReply || typingAlive || idleFor < graceMs);
+  /*
+   * 「还在等回复」——与 `live` **解耦**(2026-11-11 用户实测修复)。
+   *
+   * ## 为什么要独立于 live
+   *
+   * `live` 回答的是「是不是在流式输出」,它受 25 秒静默窗口约束
+   * (`QUIET_MS`)—— 容器不回 typing 事件,不这样收尾回合就永远不结束。
+   *
+   * 但用户看到的是:发完消息,等 25 秒,界面**变成一片空白**
+   * (段落被跳过、等待文案返回空串),然后一直空白到第 180 秒才弹超时提示。
+   * 中间这 155 秒里没有任何「我在等」的指示(用户实测反馈)。
+   *
+   * `waiting` 回答的是「用户还在等第一口气」:只要还没超时,就该一直有提示。
+   */
+  /*
+   * 「还在等第一口气」(2026-11-11)—— 与 `live` / `closed` 都解耦。
+   *
+   * 判据只看三件事:
+   *   ① 用户确实发过消息(`waitingSince > 0`)
+   *   ② 容器**一条 agent 消息都没回**(`src.messages` 为空)
+   *   ③ 还没到 180 秒超时
+   *
+   * 🔴 为什么不能复用 `awaitingFirstReply`:它在 `turnsFromDm` 里带了
+   *    `!t.closed`,而 `closed` 是聚合层在 25 秒静默后打的标记,远早于用户的
+   *    超时判定。复用它会让「回合收尾 → live=false → waiting=false」同时成立,
+   *    界面正好掉进那段纯空白(用户实测反馈的正是这个)。
+   *
+   * 🔴 为什么不能写成 `!src.closed`:`toBlockView` 拿不到 `t.closed`,
+   *    而且上一条的理由说明这个标记本来就不该参与用户等待的语义。
+   */
+  const waiting =
+    awaitingFirstReply && src.messages.length === 0 && waitingSince > 0 && !waitingTooLong;
+
   /*
    * 用时读数(2026-11-10 合并口径):
    *
@@ -420,7 +613,7 @@ function toBlockView(
   const startAt = waitingSince > 0 ? waitingSince : src.startedAt;
 
   return {
-    key: src.agentId ?? 'agent',
+    key: src.key ?? src.agentId ?? 'agent',
     agentId: src.agentId,
     /** 容器真实处理耗时(首条 → 末条 agent 消息),结束才有意义 */
     seconds:
@@ -434,7 +627,8 @@ function toBlockView(
     body,
     files,
     live,
-    empty: !live && body.length === 0 && steps.length === 0,
+    waiting,
+    empty: !live && !waiting && body.length === 0 && steps.length === 0,
     merged: mergeBodies([{ body }]),
     waitingSince,
     waitMs: waitingSince > 0 ? Math.max(0, now - waitingSince) : 0,
@@ -486,13 +680,26 @@ function phaseOf(blocks: TurnBlockView[], closed: boolean): TurnPhase {
 export function elapsedSeconds(block: TurnBlockView, now: number): number {
   if (block.startAt <= 0) return 0;
   /*
-   * 结束态 = 用户发问 → 末条 agent 消息(体感用时)。
-   * `block.seconds` 是「首条 → 末条 agent」,起点不同,不可与之相加。
+   * 🔴 结束态与进行中必须**同口径**,否则读数会倒退(2026-11-11 用户实测)。
+   *
+   * 进行中显示 `now - startAt` —— 这是用户体感的等待时长,包含容器思考的空档。
+   * 原来结束后切成 `updatedAt - startAt`(末条 agent 消息时刻),
+   * 但 `updatedAt` 只到「最后一条 agent 消息」,比用户实际等的短:
+   * 用户等了 56 秒,一结束读数瞬间掉回 20 秒,看起来像计时坏了。
+   *
+   * 现在:结束时**定格在「末条活动 + 一个收尾宽限」与「当前时刻」的较小值**。
+   *   · 宽限覆盖「容器发完最后一条消息 → 界面感知到结束」之间的几百毫秒
+   *   · 取 `min(now, …)` 保证不会超过用户真正等待的时间
+   *   · 结束后时钟已停,读数不再变化(定格语义保留)
    */
   if (!block.live) {
-    return block.updatedAt > block.startAt
-      ? Math.max(0, Math.floor((block.updatedAt - block.startAt) / 1000))
-      : block.seconds;
+    if (block.updatedAt <= block.startAt) return block.seconds;
+    /*
+     * 定格到「末条活动时刻」。`now` 只用来兜住时钟尚未推进的边界,
+     * 不额外加宽限 —— 宽限值是拍脑袋的,会让读数与测试/实际都对不上。
+     */
+    const endAt = Math.min(now, block.updatedAt);
+    return Math.max(0, Math.floor((endAt - block.startAt) / 1000));
   }
   return Math.max(0, Math.floor((now - block.startAt) / 1000));
 }
@@ -521,13 +728,31 @@ export function elapsedSeconds(block: TurnBlockView, now: number): number {
  * @param live 是否仍在进行中
  * @param empty 是否已结束且什么也没有
  */
-export function waitText(waitMs: number, hasOutput: boolean, live: boolean, empty: boolean): string {
+export function waitText(
+  waitMs: number,
+  hasOutput: boolean,
+  live: boolean,
+  empty: boolean,
+  /**
+   * 锚点时刻(epoch ms):用户发出这条问话的时刻(2026-11-11)。
+   *
+   * 传了就带上起始时刻,让用户知道「从几点开始等的」——
+   * 只显示「正在思考…」时,用户分不清是刚发出还是已经卡了五分钟,
+   * 也不知道该不该刷新。0 / 缺省 = 不显示时刻(保持旧行为,老调用点不受影响)。
+   */
+  startAt = 0,
+): string {
   // 已结束:无论有没有内容,都不再留等待占位(超时提示交给全局 toast)
   if (!live) return '';
   const sec = Math.max(0, Math.round(waitMs / 1000));
+  const at = startAt > 0 ? `${fmtTime(startAt)} ` : '';
   if (hasOutput) return sec > 0 ? `正在输出…(${sec}s)` : '正在输出…';
-  if (waitMs < WAIT_HINT_MS) return '正在思考…';
-  return sec > 0 ? `已送达,等待回复…(${sec}s)` : '已送达,等待回复…';
+  if (waitMs < WAIT_HINT_MS) {
+    return at ? `已送达 ${at}· 正在思考…(${sec}s)` : '正在思考…';
+  }
+  return sec > 0
+    ? `${at ? `${at}已送达` : '已送达'},等待回复…(${sec}s)`
+    : '已送达,等待回复…';
 }
 
 /** 超过这个时长就明确告诉用户「发出了、在等」,不再只是「正在思考」 */
@@ -552,6 +777,41 @@ export function isTurnTimedOut(
   return !live && !hasOutput && waitMs >= timeoutMs;
 }
 
+/**
+ * 判定「容器卡住了」(2026-11-11)。
+ *
+ * ## 与 `isTurnTimedOut` 的分工
+ *
+ * | 情况 | 判据 | 函数 |
+ * |---|---|---|
+ * | 发了之后**一个字都没回**,超过 3 分钟 | `!live && !hasOutput && wait >= 180s` | `isTurnTimedOut` |
+ * | 回了**一部分**就停住,不再有任何新增 | `hasOutput && 静止 >= 60s` | `isTurnStalled` |
+ *
+ * 🔴 为什么必须有第二个:容器常常先回一句「我先看一下这个文件」、
+ *    跑了两步工具,然后就再没有下文了(用户实测截图 09:51 之后一片空白)。
+ *    这时 `hasOutput === true`,`isTurnTimedOut` **永远返回 false** ——
+ *    界面停在「已结束」但没有任何提示,用户只能干等。
+ *
+ * 这里的「静止」用**最后一条消息到现在的时长**,不是用户发问时长:
+ * 容器跑长任务时应该一直在产生工具活动,持续 1 分钟毫无动静即为异常。
+ *
+ * @param idleMs   距本回合**最后一次** agent 活动的毫秒数
+ * @param hasOutput agent 是否已经有正文或步骤
+ * @param live     回合是否仍在进行(还在 live 说明 Quartz 静默窗口还没过)
+ */
+export function isTurnStalled(
+  idleMs: number,
+  hasOutput: boolean,
+  live: boolean,
+  stallMs = STALL_MS,
+): boolean {
+  /* 还在 live = 静默窗口内,属于「正常思考中」,不打扰 */
+  if (live) return false;
+  /* 一个字都没回的由 `isTurnTimedOut` 负责,两处不重叠 */
+  if (!hasOutput) return false;
+  return idleMs >= stallMs;
+}
+
 /* ------------------------------------------------------------------ */
 /* 两种输入 → 统一的回合视图                                           */
 /* ------------------------------------------------------------------ */
@@ -630,28 +890,48 @@ export function turnsFromDm(
 ): AssistantTurn[] {
   return turns.map((t) => {
     /*
-     * 「已发出、容器还没回一个字」——这是发送后立刻可见的状态(2026-11-09)。
+     * 🔴 拆成两个概念(2026-11-11):
+     *
+     * `awaitingFirstReply`(live 用):要求 `!t.closed` —— 回合静默收尾后
+     *    `live` 该转 false,否则卡片永远不收尾(2026-11-10 的裁决,保留)。
+     *
+     * `pendingReply`(waiting 用):**不看 closed** —— `closed` 是聚合层在
+     *    25 秒静默后打的标记,远早于用户的 180 秒超时。若它也否认「还在等」,
+     *    界面从第 25 秒起就是一片空白,直到第 180 秒才弹提示(用户实测反馈)。
      *
-     * 判据:有用户消息、没有 agent 消息、回合还没结束。
-     * 注意要用 `t.userMessage` 的时间做锚点,不能用 `t.updatedAt`
-     * (后者在无 agent 消息时等于用户消息时间,但语义上不是「最后活动」)。
+     * 所以「有用户提问 + 容器一条都没回 + 没超时」就是还在等,与 closed 无关。
      */
-    const awaitingFirstReply = !!t.userMessage && t.blocks.length === 0 && !t.closed;
+    const hasUserAsk = !!t.userMessage;
+    const noAgentReply = t.blocks.length === 0;
+    const awaitingFirstReply = hasUserAsk && noAgentReply && !t.closed;
+    const pendingReply = hasUserAsk && noAgentReply;
     const userAsk = t.userMessage ? Date.parse(t.userMessage.ts) || 0 : 0;
     /*
-     * 🔴 按「轮次」拆块(2026-11-10)。
+     * 🔴 按「轮次」拆块(2026-11-10)—— **收窄触发条件**(2026-11-11)。
      *
-     * 容器对「图 + 文字」会各跑一轮,两轮在消息流里没有分界标记。
-     * 每轮单独成块 → 各自独立判断 live、独立计时、占位提示只出现在
-     * 「正在跑的那一轮」下面,不再挂到上一轮已答完的正文后面。
+     * 拆轮原本是为了「图 + 文字各跑一轮时分别计时」。
+     * 但它按「相邻消息间隔 > 20 秒」(`ROUND_GAP_MS`)切分,而容器跑一个
+     * 长工具时**本来就不产生消息** —— 于是普通长任务被误切成 2~4 轮,
+     * 每轮各自判断 live,出现「有的块已完成、有的块还在跑」的混乱观感,
+     * 且所有块 key 都叫 `agent` 导致 NG0955。
+     *
+     * 现在只在**确实有多个用户提问**(图文各发一条)时才拆轮;
+     * 单次提问不论间隔多久都只出 1 块。
      */
-    const rounds = t.rounds && t.rounds.length > 0 ? t.rounds : t.blocks.length > 0 ? [t.blocks] : [];
+    const askCount = (t.userAskTimes ?? []).filter((a) => a > 0).length;
+    const canSplit = askCount > 1 && (t.rounds?.length ?? 0) > 1;
+    const rounds: Message[][] = canSplit
+      ? t.rounds!
+      : t.blocks.length > 0
+        ? [t.blocks]
+        : [];
     /*
      * 没有 agent 消息时也要有**一个**占位块,否则「已发出、等回复」的标志
      * (`awaitingFirstReply`)无处挂载,卡片会整张消失。
      */
+    const usedKeys = new Set<string>();
     const blocks = rounds.length
-      ? rounds.map((msgs) => {
+      ? rounds.map((msgs, idx) => {
           const times = msgs.map((m) => Date.parse(m.ts) || 0).filter((n) => n > 0);
           const first = times.length ? Math.min(...times) : 0;
           const last = times.length ? Math.max(...times) : 0;
@@ -662,9 +942,14 @@ export function turnsFromDm(
            */
           const asks = (t.userAskTimes ?? []).filter((a) => a > 0 && (first === 0 || a <= first));
           const startAt = asks.length ? Math.max(...asks) : first || userAsk;
+          /* key 必须唯一:多块共用 'agent' 会触发 NG0955 且让读数互相覆盖 */
+          const base = `agent:${msgs[0]?.id ?? `r${idx}`}`;
+          const key = usedKeys.has(base) ? `${base}:${idx}` : base;
+          usedKeys.add(key);
           return toBlockView(
             {
               agentId: null,
+              key,
               messages: msgs,
               startedAt: first,
               updatedAt: last,
@@ -679,11 +964,19 @@ export function turnsFromDm(
         })
       : [
           toBlockView(
-            { agentId: null, messages: [], startedAt: 0, updatedAt: 0, typing, closed: t.closed },
+            {
+              agentId: null,
+              key: 'agent:wait',
+              messages: [],
+              startedAt: 0,
+              updatedAt: 0,
+              typing,
+              closed: t.closed,
+            },
             now,
             agentIdle ? 0 : quietMs,
             userAsk,
-            awaitingFirstReply,
+            pendingReply,
           ),
         ];
     const live = blocks.some((b) => b.live);

+ 12 - 3
src/app/core/chat-session.spec.ts

@@ -267,10 +267,19 @@ describe('deriveTaskTitle(会话名提炼)', () => {
  * 根因:归属只看 `workspaceId`,而群聊的 `workspaceId` 恒为 null。
  */
 describe('groupOf / groupKey(归档回到原分组)', () => {
-  it('群聊归群聊分组,即使 workspaceId 有值', () => {
+  it('群聊:没指定工作区 → 归群聊分组', () => {
     expect(groupOf({ kind: 'group', workspaceId: null })).toEqual({ kind: 'group' });
-    expect(groupOf({ kind: 'group', workspaceId: 'ws1' })).withContext('群聊不归工作区').toEqual({
-      kind: 'group',
+  });
+
+  /*
+   * 2026-11-11 修正:原来 `kind === 'group'` 无条件归群聊,
+   * 导致「从工作区新建群聊」时写进去的 workspaceId 被静默丢弃 ——
+   * 用户在工作区点的「+」,会话却跑到「群聊」分组。
+   */
+  it('🔴 群聊指定了工作区 → 归工作区(用户放哪就在哪)', () => {
+    expect(groupOf({ kind: 'group', workspaceId: 'ws1' })).withContext('工作区优先于群聊').toEqual({
+      kind: 'workspace',
+      id: 'ws1',
     });
   });
 

+ 11 - 1
src/app/core/chat-session.ts

@@ -310,8 +310,18 @@ export type SessionGroup =
   | { kind: 'daily' };
 
 export function groupOf(s: Pick<ChatSession, 'kind' | 'workspaceId'>): SessionGroup {
-  if (s.kind === 'group') return { kind: 'group' };
+  /*
+   * 🔴 工作区优先于群聊(2026-11-11 修正)。
+   *
+   * 原实现 `kind === 'group'` 无条件归群聊,于是「从工作区里新建群聊」
+   * 时写进去的 `workspaceId` **被静默丢弃** —— 用户明明在工作区点的「+」,
+   * 会话却出现在底部的「群聊」分组里,看起来像没生效。
+   *
+   * 现在的语义:**用户把它放进哪个工作区,它就在哪个工作区**;
+   * 只有没指定工作区的群聊才归「群聊」分组。
+   */
   if (s.workspaceId) return { kind: 'workspace', id: s.workspaceId };
+  if (s.kind === 'group') return { kind: 'group' };
   return { kind: 'daily' };
 }
 

+ 132 - 12
src/app/core/chat.service.ts

@@ -12,8 +12,10 @@ import {
   MatrixProvision,
 } from './api/models';
 import { DEMO_MESSAGES } from './api/demo-data';
-import { extractDeliveredLinks, extractS3Links, isUserUploadUrl, stripLinks } from './s3-link';
-import { isOversize, oversizeMessage } from './upload-limits';
+import { AlertService } from './alert.service';
+import { extractDeliveredLinks, extractS3Links, isUserUploadUrl, prettifyStorageKey, stripLinks } from './s3-link';
+import { isOversize, notifyOversize, oversizeMessage } from './upload-limits';
+import { batchPhase, FileProgress, overallPct, UploadPhase } from './upload-state';
 import { environment } from '../../environments/environment';
 import { LiveService } from './live.service';
 import { SessionService } from './session.service';
@@ -59,6 +61,13 @@ export interface PendingFile {
   id: string;
   file: File;
   previewUrl?: string;
+  /**
+   * 上传进度与阶段(2026-11-11)。
+   *
+   * 由 `uploadPending()` 在三个阶段间推进,UI 据此显示
+   * 「上传中 42%」/「登记中…」/「发送中…」。未开始为 `undefined`。
+   */
+  progress?: FileProgress;
 }
 
 /**
@@ -88,6 +97,11 @@ export class ChatService {
   private readonly mx = inject(MatrixClientService);
   private readonly unread = inject(UnreadService);
   private readonly pulse = inject(TaskPulseService);
+  /**
+   * 全局提示(2026-11-11)。用于「上传超限」这类**用户必须立刻知道**的反馈。
+   * `_error` 只在消息区错误空态渲染,正常会话里等于静默,不能作为唯一出口。
+   */
+  private readonly alerts = inject(AlertService);
   /**
    * 会话列表服务(公开只读入口)。
    *
@@ -129,7 +143,16 @@ export class ChatService {
    *    所以这里只持有 File 对象,不上传、不发消息 —— 发送时才落地。
    */
   private readonly _pendingFiles = signal<PendingFile[]>([]);
+  /**
+   * 上传总进度 0-100(2026-11-11 改为**按字节加权实时计算**)。
+   *
+   * 原来是个手动 `set` 的信号,只在「一个文件全部走完」时跳 `(i+1)/N*100`,
+   * 单个大文件期间恒为 0。现在由各文件自己的 `progress` 派生,
+   * 传 20MB 文件时能看到 1% → 50% → 99% 连续推进。
+   */
   private readonly _uploadPct = signal(0);
+  /** 整批当前主导阶段(决定输入框下方那一条显示什么) */
+  private readonly _uploadPhase = signal<UploadPhase>('idle');
   private readonly _draft = signal('');
   private readonly _peerTyping = signal(false);
 
@@ -193,6 +216,8 @@ export class ChatService {
   /** 待发送附件(界面据此渲染缩略条与移除按钮) */
   readonly pendingFiles = this._pendingFiles.asReadonly();
   readonly uploadPct = this._uploadPct.asReadonly();
+  /** 整批上传阶段(输入框下方那条进度条据此切文案,2026-11-11) */
+  readonly uploadPhase = this._uploadPhase.asReadonly();
   readonly draft = this._draft.asReadonly();
   /** 对方正在输入(真实态由 typing 事件驱动) */
   readonly peerTyping = this._peerTyping.asReadonly();
@@ -1508,16 +1533,26 @@ export class ChatService {
    * 单聊与群聊共用:`threadRoot` 传会话 id —— 单聊是对话 id,群聊是群会话 id,
    * 这样右侧栏按会话查时互不串台。
    */
+  /**
+   * 把已上传云盘的文件登记进文件台账(2026-11-10,2026-11-11 加群归属)。
+   *
+   * @param opts.groupId 群会话 id —— 群内上传时**必须**传,
+   *   否则群右栏刷新后看不到自己发的文件(只按 threadRoot 查不到)。
+   * @param opts.roomId  显式指定房间;群聊不能沿用单聊的 `this.roomId`
+   *   (那是单聊 ChatService 的当前房间,群语境下不可靠)。
+   */
   async registerSessionFile(
     threadRoot: string,
     file: File,
     up: { key: string; publicUrl: string },
     agentId?: string,
+    opts: { groupId?: string; roomId?: string } = {},
   ): Promise<void> {
     await this.api.registerFile({
       threadRoot,
       agentId,
-      roomId: this.roomId ?? undefined,
+      roomId: opts.roomId ?? this.roomId ?? undefined,
+      groupId: opts.groupId,
       name: file.name,
       url: up.publicUrl,
       key: up.key,
@@ -1527,6 +1562,45 @@ export class ChatService {
     });
   }
 
+  /**
+   * 记一条**派发锚点**(2026-11-11,群聊文件归群的依据)。
+   *
+   * 伪群聊下容器只在自己的 DM 房间工作,回报文件时不带群信息。
+   * App 在派活那一刻知道「这条消息是替群 G 发给容器 A 的」,
+   * 于是先落一条零字节的锚点记录:`threadRoot = 群 id`、`groupId = 群 id`、
+   * `uploadedBy: 'agent'`(用 agent 侧标记,避免与用户上传混淆)。
+   *
+   * 之后容器产出文件时,服务端按「同 user + 同 roomId + 同 agentId +
+   * 时间晚于锚点且在窗口内」反查归属 —— 见 `fmode-file-hub` 的说明。
+   *
+   * 失败不阻断派活:锚点只是「提高归属准确率」的辅助信息,
+   * 没有它文件仍会登记,只是不归群(宁可漏,不归错)。
+   */
+  async registerGroupAnchor(
+    groupId: string,
+    agentId: string,
+    roomId: string | null | undefined,
+    turnId: string,
+  ): Promise<void> {
+    const anchorUrl = `https://s3.fmode.cn/__anchor__/${encodeURIComponent(groupId)}/${Date.now()}-${encodeURIComponent(turnId)}`;
+    try {
+      await this.api.registerFile({
+        threadRoot: groupId,
+        groupId,
+        groupIdSource: 'anchor',
+        agentId,
+        roomId: roomId ?? undefined,
+        name: '__anchor__',
+        url: anchorUrl,
+        mimeType: 'application/x-fmode-anchor',
+        size: 0,
+        uploadedBy: 'agent',
+      } as never);
+    } catch {
+      /* 锚点失败不影响派活 */
+    }
+  }
+
   /**
    * 选择附件(**只暂存,不上传、不发送**)。
    *
@@ -1544,6 +1618,15 @@ export class ChatService {
     const list = all.filter((f) => !isOversize(f));
     const rejected = all.filter((f) => isOversize(f));
     if (rejected.length > 0) {
+      /*
+       * 🔴 走**全局提示**(2026-11-11 修复)。
+       *
+       * 原来只写 `_error`,而 `_error` 仅在「消息区整体 error 空态」渲染 ——
+       * 正常会话里用户完全看不到,文件选进来一按发送就消失,毫无解释。
+       * 全局提示挂在根节点,任何页面都看得见、可手动关。
+       * `_error` 保留写入,供 messages 区兜底展示。
+       */
+      notifyOversize(this.alerts, rejected);
       this._error.set(rejected.map(oversizeMessage).join(';'));
     }
     if (list.length === 0) return;
@@ -1576,6 +1659,22 @@ export class ChatService {
     this._pendingFiles.set([]);
   }
 
+  /**
+   * 更新单个附件的阶段/进度,并重算整批的百分比与主导阶段(2026-11-11)。
+   *
+   * 进度与阶段**都从各文件自己的状态派生**,不再手动 set 一个总数 ——
+   * 这样多文件并发/串行、某个文件失败时,总体进度都是自洽的。
+   */
+  private setFileProgress(id: string, progress: Omit<FileProgress, 'id'>): void {
+    const next: FileProgress = { id, ...progress };
+    this._pendingFiles.update((cur) =>
+      cur.map((f) => (f.id === id ? { ...f, progress: next } : f)),
+    );
+    const items = this._pendingFiles();
+    this._uploadPct.set(overallPct(items.map((f) => ({ progress: f.progress, size: f.file.size }))));
+    this._uploadPhase.set(batchPhase(items.map((f) => ({ progress: f.progress }))));
+  }
+
   /**
    * 发送待发附件(内部使用)。
    *
@@ -1587,6 +1686,7 @@ export class ChatService {
 
     this._uploading.set(true);
     this._uploadPct.set(0);
+    this._uploadPhase.set('idle');
     let ok = 0;
     let fail = 0;
     /** 上传失败的暂存项 id(保留在待发区供重试) */
@@ -1599,6 +1699,7 @@ export class ChatService {
          * 或调用方绕过 stageFiles。超限项按失败处理,留在待发区让用户移除。
          */
         if (isOversize(file)) {
+          notifyOversize(this.alerts, [file]);
           this._error.set(oversizeMessage(file));
           fail++;
           failedIds.add(pending[i].id);
@@ -1632,8 +1733,11 @@ export class ChatService {
           if (!this.roomId) throw new Error('会话未就绪');
           const sid = this._sessionId();
 
-          /* ① 云盘:失败即中止本文件(不发送) */
-          const up = await this.api.uploadFile(file);
+          /* ① 云盘:失败即中止本文件(不发送)。传 onProgress 拿**真实字节进度**(2026-11-11) */
+          this.setFileProgress(pending[i].id, { phase: 'uploading', pct: 0 });
+          const up = await this.api.uploadFile(file, (pct) => {
+            this.setFileProgress(pending[i].id, { phase: 'uploading', pct });
+          });
           const cloudUrl = up.publicUrl;
           /*
            * `key` 是云盘里的稳定对象标识,`publicUrl` 只是它的投影。
@@ -1651,6 +1755,7 @@ export class ChatService {
            */
           if (sid) {
             try {
+              this.setFileProgress(pending[i].id, { phase: 'registering', pct: 100 });
               await this.api.registerFile({
                 threadRoot: sid,
                 agentId: this._agentId() ?? undefined,
@@ -1670,18 +1775,14 @@ export class ChatService {
           }
 
           /* ② Matrix:供容器读取 */
+          this.setFileProgress(pending[i].id, { phase: 'sending', pct: 100 });
           await this.mx.sendFile(
             this.roomId,
             file,
             { name: file.name, size: file.size, mimeType },
             sid ?? undefined,
           );
-
-          /*
-           * 上传进度按文件粒度推进:云盘这一步已经完成,Matrix 发送紧随其后,
-           * 期间用户看到的是输入框进度,不是空白。
-           */
-          this._uploadPct.set(Math.round(((i + 1) / pending.length) * 100));
+          this.setFileProgress(pending[i].id, { phase: 'done', pct: 100 });
           /*
            * 云盘地址记进 `_cloudUrls`:回读的 `m.image` 只有 `mxc://`,
            * 没这张表就补不回 `FileCard.cloudUrl`(2026-11-10 合并修复)。
@@ -1729,11 +1830,22 @@ export class ChatService {
           this._error.set(
             `「${file.name}」发送失败:${e instanceof Error ? e.message : '上传失败'}`,
           );
+          /*
+           * 失败项标成 `failed`:胶囊上显示「上传失败」并保留 ✕ 让用户移除。
+           * 不在这里弹全局提示 —— 失败项本身就在输入框上方可见,
+           * 再弹一条只是噪音;真正需要全局提示的是「根本没进暂存区」的超限。
+           */
+          this.setFileProgress(pending[i].id, {
+            phase: 'failed',
+            pct: 0,
+            error: e instanceof Error ? e.message : '上传失败',
+          });
         }
       }
     } finally {
       this._uploading.set(false);
       this._uploadPct.set(0);
+      this._uploadPhase.set('idle');
       /*
        * 🔴 按 id 精确保留失败项(2026-11-03 修复)。
        *
@@ -2401,7 +2513,15 @@ export class ChatService {
 
     return {
       kind: 'file',
-      name: seg,
+      /*
+       * 显示名去掉存储键的前缀(2026-11-11)。
+       *
+       * 云盘键形如 `0-mwbkfsjsbv-team-weekly-template.md`,前面的
+       * 「序号-随机串-」是服务端去重用的,用户不该看到 ——
+       * 之前直接拿 URL 末段当名字,卡片显示成 `0-mwbkfsjsbv-team-...`。
+       * 与成果页同一套处理(`prettifyStorageKey`)。
+       */
+      name: prettifyStorageKey(seg) || seg,
       /*
        * 云盘链接不带文件大小。用 -1 表示「未知」,界面据此不显示「0 B」
        * (0 B 会让人以为文件是空的,实测用户困惑)。

+ 131 - 2
src/app/core/deliverables.service.spec.ts

@@ -1,7 +1,25 @@
 import { TestBed } from '@angular/core/testing';
-import { Deliverable, DeliverableGroup } from './api/models';
+import { Deliverable, DeliverableGroup, SessionFile } from './api/models';
 import { FmodeApi } from './api/fmode-api';
-import { DeliverablesService } from './deliverables.service';
+import { DeliverablesService, partitionSessionFiles } from './deliverables.service';
+
+/** 造一条文件台账记录,只填本组用例关心的字段 */
+function sessionFile(over: Partial<SessionFile> & { id: string }): SessionFile {
+  return {
+    threadRoot: 'T',
+    agentId: '',
+    roomId: '',
+    name: 'f',
+    size: 0,
+    mimeType: '',
+    url: '',
+    storageKey: '',
+    uploadedBy: 'agent',
+    status: 'ok',
+    deliveredAt: 0,
+    ...over,
+  };
+}
 
 /**
  * 成果服务。
@@ -253,5 +271,116 @@ describe('DeliverablesService', () => {
       expect(out[0].filename).withContext('要有文件名,否则右栏显示「暂无附件」').toBe('b.txt');
     });
   });
+
+  /*
+   * 2026-11-11 用户裁决「文件 / 成果严格互斥」。
+   * 实测现象:我上传的 test-20MB.bin 在两个页签各出现一次。
+   */
+  describe('文件 / 成果互斥(2026-11-11)', () => {
+    it('🔴 fromAgentFiles 挡掉用户上传的文件', () => {
+      const out = svc.fromAgentFiles([
+        { name: 'test-20MB.bin', url: 'https://s3.fmode.cn/user/u/chat/202611/abc.bin' },
+      ]);
+      expect(out.length).withContext('用户上传不属于容器成果').toBe(0);
+    });
+
+    it('容器产出的文件照常计入', () => {
+      const out = svc.fromAgentFiles([
+        { name: '周报.md', url: 'https://s3.fmode.cn/deliverables/a/202611/x/week.md' },
+      ]);
+      expect(out.length).toBe(1);
+      expect(out[0].title).toBe('周报.md');
+    });
+
+    it('partitionSessionFiles 按 uploadedBy 分流(权威判据)', () => {
+      const { userFiles, agentFiles } = partitionSessionFiles([
+        sessionFile({ id: '1', uploadedBy: 'user', url: 'https://s3.fmode.cn/a/x.bin' }),
+        sessionFile({ id: '2', uploadedBy: 'agent', url: 'https://s3.fmode.cn/b/y.md' }),
+      ]);
+      expect(userFiles.map((f) => f.id)).toEqual(['1']);
+      expect(agentFiles.map((f) => f.id)).toEqual(['2']);
+    });
+
+    it('uploadedBy 缺失时用 URL 形态兜底', () => {
+      const { userFiles, agentFiles } = partitionSessionFiles([
+        sessionFile({ id: '1', uploadedBy: 'agent', url: 'https://s3.fmode.cn/user/u/chat/z.txt' }),
+        sessionFile({ id: '2', uploadedBy: 'agent', url: 'https://s3.fmode.cn/d/z.md' }),
+      ]);
+      expect(userFiles.map((f) => f.id)).withContext('前缀是用户上传目录').toEqual(['1']);
+      expect(agentFiles.map((f) => f.id)).toEqual(['2']);
+    });
+  });
 });
 
+
+/**
+ * 群聊文件台账(2026-11-11,v0.2.0)。
+ *
+ * 伪群聊下容器只在自己的 DM 房间工作,回报文件时不带群信息。
+ * 「归群」由 App 决定:用户上传直接带 groupId;容器产出靠派发锚点反查。
+ * 这里只测**读侧分流**,写入与反查在云函数侧(已用 curl 端到端验证)。
+ */
+describe('loadSplitForGroup(群聊按群分流)', () => {
+  const groupStub = {
+    fetchFilesByGroup: jasmine.createSpy('fetchFilesByGroup'),
+    claimFilesToGroup: jasmine.createSpy('claimFilesToGroup'),
+  };
+  let gsvc: DeliverablesService;
+
+  beforeEach(() => {
+    TestBed.resetTestingModule();
+    TestBed.configureTestingModule({
+      providers: [DeliverablesService, { provide: FmodeApi, useValue: groupStub }],
+    });
+    gsvc = TestBed.inject(DeliverablesService);
+    groupStub.fetchFilesByGroup.calls.reset();
+    groupStub.claimFilesToGroup.calls.reset();
+  });
+
+  it('🔴 用户上传与容器产物按 uploadedBy 分流(与单聊同口径)', async () => {
+    groupStub.fetchFilesByGroup.and.returnValue(
+      Promise.resolve([
+        sessionFile({ id: '1', groupId: 'G', uploadedBy: 'user', name: '我发的.txt' }),
+        sessionFile({ id: '2', groupId: 'G', uploadedBy: 'agent', name: '容器产出.md' }),
+      ]),
+    );
+
+    const { userFiles, agentFiles } = await gsvc.loadSplitForGroup('G');
+
+    expect(userFiles.map((d) => d.title)).toEqual(['我发的.txt']);
+    expect(agentFiles.map((d) => d.title)).toEqual(['容器产出.md']);
+  });
+
+  it('空 groupId 不发起请求(避免查成全局)', async () => {
+    const r = await gsvc.loadSplitForGroup('');
+    expect(r.userFiles).toEqual([]);
+    expect(groupStub.fetchFilesByGroup).not.toHaveBeenCalled();
+  });
+
+  it('带出 sizeBytes,右栏才能显示大小', async () => {
+    groupStub.fetchFilesByGroup.and.returnValue(
+      Promise.resolve([sessionFile({ id: '1', groupId: 'G', uploadedBy: 'user', name: 'a.txt', size: 2048 })]),
+    );
+    const { userFiles } = await gsvc.loadSplitForGroup('G');
+    expect(userFiles[0].sizeBytes).toBe(2048);
+  });
+
+  it('大小未知(-1)不写 sizeBytes(避免显示成 0 B)', async () => {
+    groupStub.fetchFilesByGroup.and.returnValue(
+      Promise.resolve([sessionFile({ id: '1', groupId: 'G', uploadedBy: 'user', name: 'a.txt', size: -1 })]),
+    );
+    const { userFiles } = await gsvc.loadSplitForGroup('G');
+    expect(userFiles[0].sizeBytes).toBeUndefined();
+  });
+
+  it('防御:混入其它群的记录会被挡掉', async () => {
+    groupStub.fetchFilesByGroup.and.returnValue(
+      Promise.resolve([
+        sessionFile({ id: '1', groupId: 'G', uploadedBy: 'user', name: '本群.txt' }),
+        sessionFile({ id: '2', groupId: 'OTHER', uploadedBy: 'user', name: '别的群.txt' }),
+      ]),
+    );
+    const { userFiles } = await gsvc.loadSplitForGroup('G');
+    expect(userFiles.map((d) => d.title)).toEqual(['本群.txt']);
+  });
+});

+ 121 - 12
src/app/core/deliverables.service.ts

@@ -1,4 +1,5 @@
 import { ArtifactSyncService } from './artifact-sync.service';
+import { environment } from '../../environments/environment';
 import { computed, inject, Injectable, signal } from '@angular/core';
 import { FmodeApi } from './api/fmode-api';
 import {
@@ -145,8 +146,8 @@ export class DeliverablesService {
         const failed = [...this.artifactSync.errors().values()];
         if (failed.length > 0) {
           this.alerts.show(`${failed.length} 个成果未能上云`, failed[0]);
-        } else if (queued > 0) {
-          // 成功入队不打扰用户,只留痕便于排查
+        } else if (queued > 0 && !environment.production) {
+          // 成功入队不打扰用户,只留痕便于排查(**仅开发态**,线上不打印)
           console.info(`[成果上云] 已入队 ${queued} 个附件`);
         }
       });
@@ -199,6 +200,77 @@ export class DeliverablesService {
     return files.map(toDeliverable);
   }
 
+  /**
+   * 按会话拉台账并**按来源分流**(2026-11-11 用户裁决「严格互斥」)。
+   *
+   * ## 为什么必须分流
+   *
+   * 用户上传的文件**也会登记进台账**(`uploadedBy:'user'`,见
+   * `chat.service.ts` 的 `registerFile` 调用)。而台账这一路
+   * (`loadForSession` → 右栏「成果」的 `fromDb`)此前不区分来源,
+   * 于是「我发的 test-20MB.bin」同时出现在「文件」和「成果」两个页签。
+   *
+   * 只补 `fromAgentFiles` 的 URL 过滤修不掉这条 —— 台账那条路压根不看 URL。
+   *
+   * ## 判据:`uploadedBy` 为主,URL 形态兜底
+   *
+   * `uploadedBy` 是**服务端写入时的权威标记**,云盘前缀变了也不受影响;
+   * URL 形态(`/user/<id>/chat/`)只用于兜住 `uploadedBy` 缺失的老记录。
+   */
+  async loadSplitForSession(
+    threadRoot: string,
+  ): Promise<{ userFiles: Deliverable[]; agentFiles: Deliverable[] }> {
+    if (!threadRoot) return { userFiles: [], agentFiles: [] };
+    const files = await this.api.fetchFilesBySession(threadRoot);
+    /*
+     * 防御:只认本会话的记录。
+     * 服务端 `listBySession` 已按 `user` + `threadRoot` 双条件查
+     * (`fmode-file-hub.js` 的 `opListBySession`),这里是 App 侧兜底 ——
+     * 万一回包混入其它会话的数据,也不能让它串进当前右栏。
+     */
+    const mine = files.filter((f) => !f.threadRoot || f.threadRoot === threadRoot);
+    const { userFiles, agentFiles } = partitionSessionFiles(mine);
+    return { userFiles: userFiles.map(toDeliverable), agentFiles: agentFiles.map(toDeliverable) };
+  }
+
+  /**
+   * 按**群**拉台账并分流(2026-11-11,与单聊 `loadSplitForSession` 同口径)。
+   *
+   * 伪群聊下文件有两种来源,服务端 `listByGroup` 用
+   * `groupId == g OR threadRoot == g` 一并覆盖,这里只管分流。
+   */
+  async loadSplitForGroup(
+    groupId: string,
+  ): Promise<{ userFiles: Deliverable[]; agentFiles: Deliverable[] }> {
+    if (!groupId) return { userFiles: [], agentFiles: [] };
+    const files = await this.api.fetchFilesByGroup(groupId);
+    /* 防御:只认本群的记录(服务端已按 user + 群过滤,这是 App 侧兜底) */
+    const mine = files.filter((f) => !f.groupId || f.groupId === groupId);
+    const { userFiles, agentFiles } = partitionSessionFiles(mine);
+    return { userFiles: userFiles.map(toDeliverable), agentFiles: agentFiles.map(toDeliverable) };
+  }
+
+  /**
+   * 把「有把握属于本群」的容器产出补归并到群(2026-11-11)。
+   *
+   * 只在**确定**时才调用:`ids` 由调用方按「本群成员的 DM thread +
+   * 时间落在派发窗口内」筛过。服务端还会再校验一次(本人 + 容器产出 +
+   * 当前未归群),失败静默跳过 —— 宁可漏,不归错。
+   *
+   * 🔴 服务端会把 `threadRoot` 一并改写为群 id,所以搬进群之后
+   *    单聊就查不到这条了 —— 这是「一条文件只属于一个页签」的保证。
+   */
+  async claimToGroup(groupId: string, ids: string[]): Promise<number> {
+    if (!groupId || ids.length === 0) return 0;
+    try {
+      const r = await this.api.claimFilesToGroup(groupId, ids);
+      return r.claimed;
+    } catch {
+      /* 补归并失败不影响展示:这些文件仍按原归属显示 */
+      return 0;
+    }
+  }
+
   bySessionUrls(urls: readonly string[] | undefined): Deliverable[] {
     if (!urls || urls.length === 0) return [];
     const out: Deliverable[] = [];
@@ -247,16 +319,27 @@ export class DeliverablesService {
    * @param files 本会话里 `sender === 'agent'` 的附件
    */
   fromAgentFiles(files: readonly { name: string; url: string }[]): Deliverable[] {
-    return files.map((f) => ({
-      id: `msg:${f.url}`,
-      agentId: '',
-      title: f.name,
-      filename: f.name,
-      artifacts: [{ name: f.name, url: f.url, type: '' }],
-      tags: [],
-      deliveredAt: '',
-      state: 'delivered' as const,
-    }));
+    return files
+      /*
+       * 🔴 用户自己上传的文件不得计入成果(2026-11-11 修复)。
+       *
+       * 与 `bySessionUrls` 同口径:那一处早就挡了,这里漏了 ——
+       * 结果我上传的文件在「文件」和「成果」两个页签各出现一次。
+       *
+       * 走这条路的场景:容器回复里贴了用户文件的 URL,
+       * 或 Matrix 把用户附件以容器身份回显 —— 两种都会被判成 `sender='agent'`。
+       */
+      .filter((f) => !isUserUploadUrl(f.url))
+      .map((f) => ({
+        id: `msg:${f.url}`,
+        agentId: '',
+        title: f.name,
+        filename: f.name,
+        artifacts: [{ name: f.name, url: f.url, type: '' }],
+        tags: [],
+        deliveredAt: '',
+        state: 'delivered' as const,
+      }));
   }
 
   byId(id: string): Deliverable | undefined {
@@ -296,6 +379,30 @@ export class DeliverablesService {
   }
 }
 
+/**
+ * 台账记录按来源分流(2026-11-11 用户裁决「文件 / 成果严格互斥」)。
+ *
+ * - `userFiles` → 右栏「文件」页签(我发给容器的)
+ * - `agentFiles` → 右栏「成果」页签(容器产出的)
+ *
+ * 判据:`uploadedBy` 为**权威**(服务端写入时打的标,不随云盘前缀变化),
+ *      `isUserUploadUrl` 只兜 `uploadedBy` 缺失/异常的老记录。
+ *
+ * 纯函数、不依赖 Angular,便于单测覆盖分流边界。
+ */
+export function partitionSessionFiles(files: readonly SessionFile[]): {
+  userFiles: SessionFile[];
+  agentFiles: SessionFile[];
+} {
+  const userFiles: SessionFile[] = [];
+  const agentFiles: SessionFile[] = [];
+  for (const f of files) {
+    const isUser = f.uploadedBy === 'user' || isUserUploadUrl(f.url);
+    (isUser ? userFiles : agentFiles).push(f);
+  }
+  return { userFiles, agentFiles };
+}
+
 /**
  * 文件台账记录 → 右栏可渲染的 `Deliverable`。
  *
@@ -309,6 +416,8 @@ function toDeliverable(f: SessionFile): Deliverable {
     agentId: f.agentId,
     title: f.name,
     filename: f.name,
+    /* 原始字节数,供右栏显示与两路去重(2026-11-11);未知时缺省,不写 0 */
+    ...(f.size > 0 ? { sizeBytes: f.size } : {}),
     summary: f.uploadedBy === 'user' ? '用户上传' : '容器产出',
     artifacts: [
       {

+ 4 - 0
src/app/core/device.service.ts

@@ -203,6 +203,10 @@ export class DeviceService {
       case 'call':
         // FR-11:通话暂不实现,落详情页(由详情页提示「暂未开放」)
         return this.pathWithId('/agent', id, qs);
+      /*
+       * 工作台 2026-11-11 下线:这里仍产出 `/desktop/workbench`,
+       * 由各端路由的 redirectTo 接到会话页(chat / messages),归一逻辑本身不必特判。
+       */
       case 'workbench':
       case 'deliverables':
       case 'mine':

+ 5 - 0
src/app/core/format.spec.ts

@@ -54,6 +54,11 @@ describe('format 纯函数', () => {
       expect(fmtBalance(undefined)).toBe('—');
       expect(fmtBalance({})).toBe('—');
     });
+
+    it('负值如实显示为 -¥(不截断为 0,2026-11-11 余额口径事件)', () => {
+      expect(fmtBalance({ remainCny: -76.12 })).toBe('-¥ 76.12');
+      expect(fmtBalance({ remainCny: -0.5 })).toBe('-¥ 0.50');
+    });
   });
 
   describe('normProgress(契约 §六-5:百分比 or 比例语义推断)', () => {

+ 7 - 2
src/app/core/format.ts

@@ -36,10 +36,15 @@ export function fmtTokens(n?: number): string {
   return `${(n / 1_000_000).toFixed(1)}M`;
 }
 
-/** 余额 → `¥ 86.40`;无值 → `—` */
+/**
+ * 余额 → `¥ 86.40`;负数 → `-¥ 76.12`;无值 → `—`
+ *
+ * 🔴 负值如实显示、**不截断为 0**:截零会把「口径取数错了」掩盖成「刚好用完」,
+ *    2026-11-11 的余额 -76.12 事件就是靠如实显示才定位到云函数取数源错误。
+ */
 export function fmtBalance(b?: Balance): string {
   const v = b?.remainCny;
-  return v == null ? '—' : `¥ ${v.toFixed(2)}`;
+  return v == null ? '—' : `${v < 0 ? '-' : ''}¥ ${Math.abs(v).toFixed(2)}`;
 }
 
 /** 百分比 → `62%`;入参可能是 0~1 的比例或 0~100 的百分数(云函数语义待核) */

+ 53 - 9
src/app/core/group-send.service.ts

@@ -148,30 +148,74 @@ export class GroupSendService {
        * 放在成员循环**外面**:文件只有一份,按成员各传一遍是浪费
        * (单聊上传之所以在每个房间发 Matrix,是因为 Matrix 侧必须一人一份)。
        */
-      const cloudUrls: string[] = [];
+      /*
+       * 🔴 S3 失败要**阻断** Matrix 发送(2026-11-11,与单聊对齐)。
+       *
+       * 原来失败只写 `_error` 就继续发 Matrix —— 结果是「消息发出去了、
+       * 云盘上却没有」:换台设备打开就是坏文件,而且没有任何补救入口。
+       * 单聊早就改成「云盘存不上就不发」(`chat.service.ts` 的 uploadPending),
+       * 群聊这里漏了同一条约束。
+       */
+      let cloudFailed = false;
       for (const pf of files) {
         try {
           const up = await this.chat.uploadToCloud(pf.file);
-          cloudUrls.push(up.publicUrl);
           /*
-           * 登记到文件台账(2026-11-10):群聊的 threadRoot 用群会话 id,
-           * 这样群里传的文件也只在**这个群**的右侧栏出现,不串到单聊。
+           * 登记到文件台账(2026-11-10,2026-11-11 补群归属)。
+           *
+           * 🔴 `groupId` 必须传:群右栏按 `groupId` / `threadRoot` 查,
+           *    不传的话刷新后就看不到自己发的文件(消息流只有 Matrix 副本)。
+           *
+           * `agentId` / `roomId` 不传:这是**用户上传**,不属于任何单个容器;
+           * 之前传 `plan.targets[0]` 和单聊的 `this.roomId`,语义都是错的,
+           * 还会让「按容器聚合」被误导。
            */
           try {
-            await this.chat.registerSessionFile(session.id, pf.file, up, plan.targets[0]);
+            await this.chat.registerSessionFile(session.id, pf.file, up, undefined, {
+              groupId: session.id,
+            });
           } catch (e) {
             this._error.set(
               `「${pf.file.name}」已存入云盘,但未记入群文件列表:${e instanceof Error ? e.message : '登记失败'}`,
             );
           }
         } catch (e) {
+          cloudFailed = true;
           this._error.set(
-            `「${pf.file.name}」未能存入云盘:${e instanceof Error ? e.message : '上传失败'}`,
+            `「${pf.file.name}」未能存入云盘,本条消息未发送:${e instanceof Error ? e.message : '上传失败'}`,
           );
         }
       }
-      if (cloudUrls.length > 0) {
-        this.sessions.addDeliverableUrl(session.id, ...cloudUrls);
+      if (cloudFailed) return { ok: false, failedNames: files.map((f) => f.file.name) };
+      /*
+       * 🔴 不再写 `addDeliverableUrl`(2026-11-11 删除)。
+       *
+       * 那是把**用户上传**的云盘地址塞进群会话的成果列表 ——
+       * 单聊明确不做这一步(`uploadPending` 只认 sender==='agent'),
+       * 群聊这里却写了,于是同一个文件同时出现在「文件」和「成果」两个页签。
+       * 用户上传的权威记录在台账(`uploadedBy:'user'`),不需要这一路。
+       */
+
+      /*
+       * 记**派发锚点**(2026-11-11)。
+       *
+       * 伪群聊下容器只在自己的 DM 房间工作、回报文件时不带群信息。
+       * 这里在派活之前为每个目标容器落一条锚点,服务端据此反查
+       * 「这条 DM 房间在这个时间点之后的产出属于群 G」。
+       *
+       * 🔴 锚点**不在这里 resolve 房间**(2026-11-11 修复)。
+       *
+       *    原来这里先 `ensureAgentRoomFor()` 再登记,等于在正式发送之外
+       *    又做了一次「provision + ensureRoom」——`ensureRoom` 会创建/查找房间,
+       *    与下面正式发送的房间解析产生竞争,实测导致 `roomId` 为空 →
+       *    报「已发送,但 @xxx 未送达」→ 消息随后被当作失败收掉。
+       *
+       *    现在只登记 `agentId`,`roomId` 留空 —— 服务端的反查本来就用
+       *    「同 agentId + 时间窗口」,房间不是必需条件。
+       *    房间解析交给下面的正式发送(那里本来就有一份,且带正确的错误处理)。
+       */
+      for (const id of plan.targets) {
+        await this.chat.registerGroupAnchor(session.id, id, null, turnId);
       }
 
       // 串行发送:并发会触发 connectSession 互相 disconnect
@@ -229,7 +273,7 @@ export class GroupSendService {
             targetAgentId: id,
           });
           if (!r.ok) {
-              failed.push(memberName(id));
+            failed.push(memberName(id));
             continue;
           }
           baseline = r.baselineEventId;

+ 16 - 0
src/app/core/live.service.ts

@@ -28,6 +28,20 @@ export class LiveService {
   private readonly _lastUpdated = signal<string | null>(null);
   /** 轮询轮次(数据未变也自增,供下游做快照对比) */
   private readonly _tick = signal(0);
+  /**
+   * 最近一次拉取成功的时刻(epoch ms,0 = 还没拉过)。
+   *
+   * 🔴 为什么需要(2026-11-11):云函数直出的 `heartbeatAgeSec` 是
+   *    **响应那一刻**的年龄。本服务每 15 秒轮询一次,两次拉取之间
+   *    `heartbeatAgeSec` 是冻结的 —— 直接拿它判「是否超过 80 分钟」,
+   *    真实年龄会被低估最多 15 秒。
+   *
+   *    判定在线时应使用 `heartbeatAgeSec + (now - fetchedAt)`,
+   *    见 `agent-liveness.ts` 中 `livenessOf` 的 `now` 参数说明。
+   */
+  private readonly _fetchedAt = signal(0);
+
+  readonly fetchedAt = this._fetchedAt.asReadonly();
 
   readonly tick = this._tick.asReadonly();
 
@@ -119,6 +133,8 @@ export class LiveService {
       // 区分「空」与「就绪」:空列表是合法但需显示空态的状态(AC-03.4 禁止用空态掩盖失败)
       this._state.set(agents.length === 0 ? 'empty' : 'ready');
       this._lastUpdated.set(new Date().toISOString());
+      /* 记录拉取时刻:心跳年龄判定要以此为基准向后推算(见 _fetchedAt 注释) */
+      this._fetchedAt.set(Date.now());
     } catch (e) {
       if (my !== this.seq) return;
       this._state.set('error');

+ 119 - 0
src/app/core/message-segments.ts

@@ -209,11 +209,47 @@ export function toolActivitySummary(text: string): string {
   return first.replace(/^[*·•-]\s*/, '').slice(0, 60);
 }
 
+/**
+ * 代码围栏标记(``` 或 ~~~,允许前面有缩进)。
+ *
+ * 只认「行首(可带空格)+ 至少三个反引号/波浪号」。
+ */
+const FENCE = /^\s*(`{3,}|~{3,})/;
+
 /** 把一条消息正文拆成段 */
 export function parseSegments(text: string): Segment[] {
   const out: Segment[] = [];
+  /*
+   * 🔴 围栏状态(2026-11-11 修复)。
+   *
+   * 原实现逐行调 `asProcessStep`,**没有围栏概念**:
+   * 闭合围栏 ``` 既不是 `·`/`•` 开头、也不在工具 emoji 集合里,
+   * 于是被当成普通正文 —— 看起来 harmless,但下游 `divideText`
+   * 会把它当命令挪走,导致代码块只剩半截(空 `<pre>` + 「复制」按钮)。
+   *
+   * 现在在这唯一的入口处维护围栏状态:
+   *   · 围栏行本身 → 永远是正文(绝不当步骤)
+   *   · 围栏**内部**的内容 → 永远是正文(代码里的 `· bash` 不是步骤)
+   *
+   * 改这里所有 `splitMessage` 调用方自动受益(消息气泡、回合卡、移动端)。
+   */
+  let fence: string | null = null;
   for (const raw of text.split(/\r?\n/)) {
     const line = raw.trimEnd();
+    const fm = FENCE.exec(line);
+    if (fm) {
+      /* 同种围栏切换开/关;不同种(``` 里出现 ~~~)当内容处理 */
+      const mark = fm[1][0];
+      if (fence === null) fence = mark;
+      else if (fence === mark) fence = null;
+      out.push({ kind: 'text', text: line });
+      continue;
+    }
+    if (fence !== null) {
+      /* 围栏内:空行也要原样保留,否则代码里的空行会被吃掉 */
+      out.push({ kind: 'text', text: line });
+      continue;
+    }
     if (line.trim() === '') {
       out.push({ kind: 'blank' });
       continue;
@@ -362,3 +398,86 @@ export function hasProcess(m: Pick<Message, 'text'>): boolean {
   if (!m.text) return false;
   return splitMessage(m.text).some((b) => b.kind === 'process');
 }
+
+/**
+ * 这一行是**正文**还是过程(2026-11-11)。
+ *
+ * ## 为什么需要行级判据
+ *
+ * 原先只用整条消息的 `isToolActivityMessage`(工具行严格过半即整条算工具活动)
+ * + 整条级的 `proseOf`(要求「句末标点 + 汉字≥6」)。
+ * 两者组合后会**吞掉正文**:容器回一段混排消息,
+ * `**已上云(可直接下载)**`(以 `*`/`)` 结尾,不被 `proseOf` 保护,
+ * 工具行又恰好过半 → 整条进过程区,这句结论**永久消失**(用户实测)。
+ *
+ * 行级判定把「这一行像不像人话」和「这一行像不像命令」分开看,
+ * 只要一行是正常的叙述,它就会留在正文里。
+ *
+ * ## 判据(命中任意一条即正文)
+ *
+ * 1. 以中文句末/连接标点结尾:`。!?;:` —— 还有标点就说明话没被命令吃掉
+ * 2. 是 markdown 结构:`**加粗**` / `# 标题` / `> 引用` / `- 列表` / `1. 序号` / 表格
+ *    (容器写结论时常用这些,`**已上云**` 就是这一类)
+ * 3. 汉字占比够高(≥6 个汉字且中文占字符数 40% 以上)
+ */
+export function isProseLine(line: string): boolean {
+  const t = line.trim();
+  if (!t) return false;
+  if (/[。!?;:]$/.test(t)) return true;
+  if (/^[*_`#>|]|^[-*]\s|^\d+[.)、]/.test(t)) return true;
+  const han = t.match(/[一-龥]/g)?.length ?? 0;
+  return han >= 6 && han / t.length > 0.4;
+}
+
+/**
+ * 这一行像不像**裸命令**(2026-11-11)。
+ *
+ * 容器会直接贴命令(`cd /opt/data && node x.js`),既没有 emoji 也没有 `·` 前缀。
+ * 如果只按 `isProseLine` 判,这类行会被留在正文里,把执行过程曝光给用户。
+ *
+ * 判据:不含汉字 + 以常见命令字符开头 + 长度合理。
+ * 宁可漏判(留在正文里,用户能看到一行命令)也不要误杀正文。
+ */
+/**
+ * 正文特征:命中任意一条就**绝不是**命令行。
+ *
+ * 2026-11-11 修正:原实现只看「有没有汉字」,于是
+ * `Done. All good.`、`|---|---|`(表格)、URL、`2026-11-11 09:51`、
+ * `- English item` 这些**没有汉字但显然是正文**的行全被当成命令吞掉,
+ * 用户看到的是「正文变少了」。
+ */
+const PROSE_HINT =
+  /[一-龥,。!?;:、()“”‘’]|\|{2,}|^\||^#{1,6}\s|^\s*[-*+]\s+[A-Za-z]|^\s*\d+\.\s|https?:\/\//;
+
+/**
+ * 强命令特征:至少命中一条才**可能**是命令行。
+ *
+ * 用「已知命令名开头」白名单而不是「字符集合」——后者太宽。
+ * 只列容器真的会跑的命令,宁可漏判(命令留在正文里,用户看得见)
+ * 也不要误杀(正文消失,无处找回)。
+ */
+const CMD_HINT: readonly RegExp[] = [
+  /^(?:sudo\s+)?(?:cd|ls|cat|grep|sed|awk|node|npm|npx|python3?|pip3?|curl|wget|git|docker|kubectl|ffmpeg|chmod|chown|mkdir|rm|cp|mv|tar|unzip|bash|sh|find|head|tail|echo|export|apt(?:-get)?|yum|systemctl|ssh|scp|rsync|env|date|df|du|ps|kill|jq)\b/,
+  /* 环境变量前缀:`FOO=bar some-cmd` */
+  /^[A-Za-z_][A-Za-z0-9_]*=\S+/,
+  /* 相对路径脚本:`./run.sh` */
+  /^\.\/\S+/,
+];
+
+/**
+ * 这一行像不像**裸命令**(2026-11-11 重写)。
+ *
+ * 容器会直接贴命令(`cd /opt/data && node x.js`),既没有 emoji 也没有 `·` 前缀,
+ * 光靠 `inlineStepOf` 识别不到,会留在正文里把执行过程曝光给用户。
+ *
+ * 但也不能像第一版那样「字符全在白名单内就算命令」—— 那会把所有
+ * 不含汉字的正常英文正文吞掉。现在要求:**没有正文特征 + 命中已知命令**。
+ */
+export function isCommandLine(line: string): boolean {
+  const t = line.trim();
+  if (!t || t.length > 200) return false;
+  if (PROSE_HINT.test(t)) return false;
+  /* 文件大小描述(`test-20MB.bin 20.0 MB`)是正文,不是命令 */
+  if (/\b(?:MB|KB|GB)\b/.test(t) && /\s/.test(t)) return false;
+  return CMD_HINT.some((re) => re.test(t));
+}

+ 44 - 7
src/app/core/nav-menu.service.spec.ts

@@ -29,12 +29,17 @@ describe('NavMenuService(一级菜单自定义)', () => {
   });
 
   it('隐藏后从可见列表消失,但仍保留在总列表里(可以再打开)', () => {
-    svc.toggleHidden('events');
-    expect(svc.visible().some((i) => i.key === 'events')).toBeFalse();
-    expect(svc.items().some((i) => i.key === 'events')).toBeTrue();
+    /*
+     * 2026-11-11:原来用 'events' 当样本,该 key 已随「项目/任务/事件/成果
+     * 入口下线」从目录移除。这里换用目录里仍存在的 'workbench' ——
+     * 本用例要验的是「隐藏后可再打开」的机制,与具体是哪个菜单项无关。
+     */
+    svc.toggleHidden('workbench');
+    expect(svc.visible().some((i) => i.key === 'workbench')).toBeFalse();
+    expect(svc.items().some((i) => i.key === 'workbench')).toBeTrue();
 
-    svc.toggleHidden('events');
-    expect(svc.visible().some((i) => i.key === 'events')).toBeTrue();
+    svc.toggleHidden('workbench');
+    expect(svc.visible().some((i) => i.key === 'workbench')).toBeTrue();
   });
 
   it('恢复默认:顺序与显隐都回到初始', () => {
@@ -77,17 +82,49 @@ describe('NavMenuService(一级菜单自定义)', () => {
   });
 
   it('已删除的 key 被忽略,缺失的新 key 追加到末尾', () => {
+    /*
+     * 2026-11-11:样本从 'projects' 换成目录里仍有的 'workbench'。
+     * 「目录里没有的 key 会被丢弃」这条由下一条用例专门覆盖。
+     */
     localStorage.setItem(
       'fmode_nav_u1',
       JSON.stringify([
-        { key: 'projects', hidden: false },
+        { key: 'workbench', hidden: false },
         { key: 'ghost-item', hidden: false },
       ]),
     );
     svc.reload();
     const keys = svc.items().map((i) => i.key);
     expect(keys.includes('ghost-item')).toBeFalse();
-    expect(keys[0]).toBe('projects');
+    expect(keys[0]).toBe('workbench');
     expect(keys.length).toBe(NAV_CATALOG.length);
   });
+
+  /*
+   * 2026-11-11 用户裁决:项目 / 任务 / 事件 / 成果 四个入口下线。
+   * 要求是「入口关死」—— 老用户 localStorage 里存过的这几项必须失效,
+   * 且用户不能像 `defaultHidden` 那样在「自定义菜单」里把它打开。
+   */
+  it('🔴 已下线的四个入口不在目录里,老缓存也无法恢复', () => {
+    const removed = ['projects', 'tasks', 'events', 'deliverables'];
+    for (const key of removed) {
+      expect(NAV_CATALOG.some((c) => c.key === key)).withContext(`${key} 不应在目录中`).toBeFalse();
+    }
+
+    // 模拟老用户:localStorage 里仍存着这四项(且是「可见」状态)
+    localStorage.setItem(
+      'fmode_nav_u1',
+      JSON.stringify(removed.map((key) => ({ key, hidden: false }))),
+    );
+    svc.reload();
+    const keys = svc.items().map((i) => i.key);
+    for (const key of removed) {
+      expect(keys.includes(key)).withContext(`${key} 老缓存必须被丢弃`).toBeFalse();
+    }
+    expect(keys.length).withContext('不会因此让左栏变空').toBe(NAV_CATALOG.length);
+  });
+
+  it('工作台在目录里(2026-11-11 恢复)', () => {
+    expect(NAV_CATALOG.some((c) => c.key === 'workbench')).toBeTrue();
+  });
 });

+ 24 - 4
src/app/core/nav-menu.service.ts

@@ -25,11 +25,31 @@ export interface NavItemConfig {
  */
 export const NAV_CATALOG: NavItemConfig[] = [
   { key: 'new-chat', path: null, label: '新建对话', icon: 'fa-pen-to-square' },
-  { key: 'projects', path: '/projects', label: '项目', icon: 'fa-folder' },
-  { key: 'tasks', path: '/tasks', label: '任务', icon: 'fa-list-check' },
   { key: 'contacts', path: '/contacts', label: '通讯录', icon: 'fa-address-book' },
-  { key: 'events', path: '/events', label: '事件', icon: 'fa-bolt' },
-  { key: 'deliverables', path: '/deliverables', label: '成果', icon: 'fa-box' },
+  /*
+   * 🔒 2026-11-11 用户裁决:项目 / 任务 / 事件 / 成果 四个入口下线。
+   *
+   * 从目录里**整体移除**,而不是加 `defaultHidden` —— 后者只是默认收起,
+   * 用户仍能在「更多 → 自定义菜单栏」里自己打开,等于入口没关死。
+   * 目录里没有的 key 会被 `items()` 的 `known.has` 过滤掉
+   * (`nav-menu.service.ts` 下方 `items` 的实现),老用户 localStorage
+   * 里残留的这几项也随之失效,不需要迁移。
+   *
+   * 对应路由已同步改为 `redirectTo`(desktop → chat / mobile → messages),
+   * 页面组件源码保留,恢复方式写在各自路由文件的注释里。
+   *
+   * 原定义(恢复时取消注释即可):
+   *   { key: 'projects', path: '/projects', label: '项目', icon: 'fa-folder' },
+   *   { key: 'tasks', path: '/tasks', label: '任务', icon: 'fa-list-check' },
+   *   { key: 'events', path: '/events', label: '事件', icon: 'fa-bolt' },
+   *   { key: 'deliverables', path: '/deliverables', label: '成果', icon: 'fa-box' },
+   */
+  /**
+   * 工作台(2026-11-11 恢复)。
+   *
+   * 此前因余额取数口径错误下线;云函数 `xlosi3m46q` v0.0.9 已修正
+   * (改用 `billing/query` 的 `availableCny`),页面数据恢复可信,重新开放。
+   */
   { key: 'workbench', path: '/workbench', label: '工作台', icon: 'fa-table-cells-large' },
   /**
    * 成员管理(2026-11-10)。

+ 32 - 0
src/app/core/s3-link.spec.ts

@@ -1,6 +1,7 @@
 import {
   extractDeliveredLinks,
   extractS3Links,
+  fileIdentity,
   hasDeliveryBlock,
   isUserUploadUrl,
   stripLinks,
@@ -143,3 +144,34 @@ describe('isUserUploadUrl', () => {
     expect(isUserUploadUrl('not-a-url')).toBeFalse();
   });
 });
+
+/**
+ * 文件身份(2026-11-11)。
+ *
+ * 用户实测:发一个文件,右栏「文件 2」里出现两个同名条目。
+ * 根因是两路数据比 URL —— 台账存 S3 地址、消息流是 `mxc://`,永远不相等。
+ */
+describe('fileIdentity', () => {
+  it('同名同大小 → 同一个身份(两路能合并)', () => {
+    expect(fileIdentity('test.bin', 2048)).toBe(fileIdentity('test.bin', 2048));
+  });
+
+  it('同名不同大小 → 不同身份', () => {
+    expect(fileIdentity('test.bin', 2048)).not.toBe(fileIdentity('test.bin', 4096));
+  });
+
+  it('大小未知时退化为只比名字', () => {
+    expect(fileIdentity('test.bin', 0)).toBe(fileIdentity('test.bin', undefined));
+    expect(fileIdentity('test.bin', -1)).toBe(fileIdentity('test.bin'));
+  });
+
+  it('大小写与 URL 编码归一', () => {
+    expect(fileIdentity('Test.BIN', 100)).toBe(fileIdentity('test.bin', 100));
+    expect(fileIdentity('%E5%91%A8%E6%8A%A5.md', 100)).toBe(fileIdentity('周报.md', 100));
+  });
+
+  it('空名字不抛错', () => {
+    expect(fileIdentity(undefined)).toBe('');
+    expect(fileIdentity(null)).toBe('');
+  });
+});

+ 75 - 0
src/app/core/s3-link.ts

@@ -18,6 +18,20 @@
 /** 云盘域名白名单(与 API 契约里的 OBS 公网前缀一致) */
 const CLOUD_HOSTS = ['s3.fmode.cn', 'file-cloud.fmode.cn'];
 
+/**
+ * 这个地址是不是本平台云盘(2026-11-11)。
+ *
+ * 云盘没配 CORS,页面内 `fetch` 必失败 —— 预览组件据此改走云函数代理。
+ */
+export function isCloudUrl(url: string | undefined | null): boolean {
+  if (!url) return false;
+  try {
+    return CLOUD_HOSTS.includes(new URL(url).host.toLowerCase());
+  } catch {
+    return false;
+  }
+}
+
 /** URL 里常见的中英文边界符,遇到即截断 */
 const TRAILING_JUNK = /[)\]>,,。;;、"'<>)】」》]+$/;
 
@@ -110,6 +124,67 @@ export function extractS3Links(text: string | undefined | null): string[] {
  *
  * @returns true = 用户上传的文件,**不得**计入成果
  */
+/**
+ * 从存储键里还原一个「像文件名」的展示名(2026-11-10,2026-11-11 移到本文件)。
+ *
+ * 存储键由服务端生成,常见形态:
+ *   `deliverables/<agentId>/202611/<did>/0-mwa2gx2gog-weekly-report.md`
+ *   `deliverables/<agentId>/202611/<did>/1757631234567-3f2a.md`(雪花/随机段)
+ *
+ * 规则:取末段 → 去掉前导「序号-随机串-」前缀 → 若剩余仍是纯随机串则保持原样。
+ * 目的只是让 UI 不显示成 `0-mwa2gx2gog-weekly-report.md` 这种带内部前缀的样子。
+ *
+ * 🔴 放这里而不是 `api/parse-api.ts`:消息里的文件卡片(`chat.service.ts`)
+ *    也要用它,而 `chat.service` 不能反向依赖 `parse-api`(会成环)。
+ */
+export function prettifyStorageKey(keyOrUrl: string): string {
+  if (!keyOrUrl) return '';
+  const path = keyOrUrl.split(/[?#]/)[0];
+  const seg = path.split('/').filter(Boolean).pop() ?? '';
+  if (!seg) return '';
+  let name = seg;
+  try {
+    name = decodeURIComponent(seg);
+  } catch {
+    /* 编码坏了就用原样 */
+  }
+  /*
+   * 剥前导 `<数字>-<随机串>-`:服务端在存储键前面加的去重前缀。
+   * 只剥「数字-字母数字-」这两段,后面的原样保留(可能已经是中文名)。
+   */
+  name = name.replace(/^\d+-[a-z0-9]{6,}-/i, '');
+  return name || seg;
+}
+
+/**
+ * 同一个文件的「身份」(2026-11-11)。
+ *
+ * ## 为什么不能比 URL
+ *
+ * 用户发一个文件会同时进两条路:
+ *   · **文件台账**(云函数 `fmode-file-hub`)→ 存的是 S3 地址
+ *     `https://s3.fmode.cn/user/<uid>/chat/202611/<ts>-<name>`
+ *   · **Matrix 消息流** → 回读出来的 `f.url` 是 `mxc://...`(云盘地址在 `cloudUrl`)
+ *
+ * 两者**永远不相等**,所以按 URL 去重会在右栏出现两条同名文件
+ * (用户实测:发一个 test-20MB.bin,「文件 2」里两条一模一样)。
+ *
+ * ## 判据
+ *
+ * 以「文件名 + 大小」为身份。大小未知(0 / 缺省)时退化为只看文件名 ——
+ * 宁可少合并也不要漏显示。
+ */
+export function fileIdentity(name: string | undefined | null, size?: number): string {
+  let n = (name ?? '').trim().toLowerCase();
+  try {
+    n = decodeURIComponent(n);
+  } catch {
+    /* 编码坏了就用原串 */
+  }
+  const s = typeof size === 'number' && size > 0 ? size : 0;
+  return s > 0 ? `${s}|${n}` : n;
+}
+
 export function isUserUploadUrl(url: string | undefined | null): boolean {
   if (!url) return false;
   let u: URL;

+ 30 - 0
src/app/core/upload-limits.ts

@@ -39,3 +39,33 @@ export function isOversize(file: { size: number }): boolean {
 export function oversizeMessage(file: { name: string; size: number }): string {
   return `「${file.name}」${formatBytes(file.size)},超过单个附件上限 ${formatBytes(MAX_UPLOAD_BYTES)},未添加`;
 }
+
+/** 超限提示的标题(去重键;同一轮多个文件只弹一条) */
+export const OVERSIZE_TITLE = '文件超过大小限制';
+
+/**
+ * 把超限文件**如实告知用户**(2026-11-11 修复)。
+ *
+ * ## 为什么必须走全局提示
+ *
+ * 原实现把超限文案写进 `ChatService._error`,而 `_error` 只在
+ * 「消息区整体 error 空态」时才渲染 —— 正常会话里用户**完全看不到**:
+ * 文件选进来了、一按发送就消失,没有任何解释(用户实测反馈)。
+ *
+ * 全局提示挂在根节点,无论当前在哪个页面都看得见,且可手动关闭。
+ *
+ * 不直接依赖 `AlertService` 类型,只取它需要的一个方法 ——
+ * 避免 `upload-limits`(纯工具)反向依赖 Angular 服务。
+ */
+export function notifyOversize(
+  alerts: { show(title: string, detail?: string, tone?: 'error' | 'warning'): void },
+  files: readonly { name: string; size: number }[],
+): boolean {
+  if (files.length === 0) return false;
+  const detail =
+    files.length === 1
+      ? `${oversizeMessage(files[0])}。请换一个不超过 ${formatBytes(MAX_UPLOAD_BYTES)} 的文件。`
+      : `${files.map(oversizeMessage).join(';')}。单个附件上限 ${formatBytes(MAX_UPLOAD_BYTES)}。`;
+  alerts.show(OVERSIZE_TITLE, detail, 'warning');
+  return true;
+}

+ 108 - 0
src/app/core/upload-state.spec.ts

@@ -0,0 +1,108 @@
+import {
+  batchPhase,
+  FileProgress,
+  hasPercent,
+  overallPct,
+  phaseText,
+  UploadPhase,
+} from './upload-state';
+
+/**
+ * 附件上传阶段与进度(2026-11-11)。
+ *
+ * 背景:用户实测传 20MB 文件时进度条**全程停在 0%** ——
+ * 原实现按「每完成一个文件 +100/N」推进,单个大文件期间没有任何变化。
+ * 这组用例锁住「按字节加权」与「阶段文案」两条口径。
+ */
+describe('upload-state', () => {
+  const prog = (phase: UploadPhase, pct = 0, id = 'a'): FileProgress => ({ id, phase, pct });
+
+  describe('overallPct(按字节加权)', () => {
+    it('单个大文件传一半 → 50%,不是 0%', () => {
+      expect(overallPct([{ progress: prog('uploading', 50), size: 20 * 1024 * 1024 }])).toBe(50);
+    });
+
+    it('🔴 大小悬殊时以字节为准,不被小文件数量带偏', () => {
+      const big = { progress: prog('uploading', 50, 'big'), size: 20 * 1024 * 1024 };
+      const small = { progress: prog('done', 100, 'small'), size: 10 * 1024 };
+      // 大文件占 99.95% 的字节,它才传一半 → 总体约 50%
+      expect(overallPct([big, small])).toBe(50);
+    });
+
+    it('全部完成 → 100%', () => {
+      expect(
+        overallPct([
+          { progress: prog('done', 100, 'a'), size: 100 },
+          { progress: prog('done', 100, 'b'), size: 200 },
+        ]),
+      ).toBe(100);
+    });
+
+    it('未开始(无 progress)→ 0%', () => {
+      expect(overallPct([{ size: 100 }])).toBe(0);
+    });
+
+    it('失败的文件不推进进度', () => {
+      expect(overallPct([{ progress: prog('failed'), size: 100 }])).toBe(0);
+    });
+
+    it('空列表 → 0%(不除零)', () => {
+      expect(overallPct([])).toBe(0);
+    });
+
+    it('全 0 字节 → 退化为按文件数,不恒为 0', () => {
+      expect(
+        overallPct([
+          { progress: prog('done', 100, 'a'), size: 0 },
+          { progress: prog('uploading', 0, 'b'), size: 0 },
+        ]),
+      ).toBe(50);
+    });
+
+    it('非法百分比被夹到 0-100', () => {
+      expect(overallPct([{ progress: prog('uploading', 150), size: 100 }])).toBe(100);
+      expect(overallPct([{ progress: prog('uploading', -10), size: 100 }])).toBe(0);
+      expect(overallPct([{ progress: prog('uploading', NaN), size: 100 }])).toBe(0);
+    });
+  });
+
+  describe('batchPhase(主导阶段)', () => {
+    it('还有文件在传 → uploading 优先', () => {
+      expect(
+        batchPhase([{ progress: prog('sending', 100, 'a') }, { progress: prog('uploading', 30, 'b') }]),
+      ).toBe('uploading');
+    });
+
+    it('传完了只剩登记 → registering', () => {
+      expect(
+        batchPhase([{ progress: prog('done', 100, 'a') }, { progress: prog('registering', 100, 'b') }]),
+      ).toBe('registering');
+    });
+
+    it('全部完成 → done', () => {
+      expect(batchPhase([{ progress: prog('done', 100, 'a') }])).toBe('done');
+    });
+
+    it('没有任何进度 → idle', () => {
+      expect(batchPhase([{}])).toBe('idle');
+      expect(batchPhase([])).toBe('idle');
+    });
+  });
+
+  describe('phaseText(单文件文案)', () => {
+    it('各阶段说法互不相同,用户能看出在干什么', () => {
+      expect(phaseText(prog('idle'))).toBe('待发送');
+      expect(phaseText(prog('uploading', 42))).toBe('上传中 42%');
+      expect(phaseText(prog('registering'))).toBe('登记中…');
+      expect(phaseText(prog('sending'))).toBe('发送中…');
+      expect(phaseText(prog('done'))).toBe('已发送');
+      expect(phaseText(prog('failed'))).toBe('上传失败');
+    });
+  });
+
+  it('只有云盘直传阶段有百分比(Matrix 上传无进度回调)', () => {
+    expect(hasPercent('uploading')).toBeTrue();
+    expect(hasPercent('registering')).withContext('不能假装有进度').toBeFalse();
+    expect(hasPercent('sending')).toBeFalse();
+  });
+});

+ 122 - 0
src/app/core/upload-state.ts

@@ -0,0 +1,122 @@
+/**
+ * 附件上传的**阶段与进度**模型(2026-11-11)。
+ *
+ * ## 为什么要单独一层
+ *
+ * 用户实测:传一个 20MB 文件时,输入框下方的进度条**全程停在 0%**,
+ * 直到最后突然消失 —— 完全看不出系统在干什么。
+ *
+ * 根因是两条:
+ *   ① 进度按「每完成一个文件 +100/N」推进,**单个大文件期间永远是 0**
+ *   ② S3 直传的逐字节回调早就有(`parse-api.ts` 的 `putWithProgress`),
+ *      但 `chat.service.ts` 调 `uploadFile()` 时没把 `onProgress` 传进去
+ *
+ * 这里定义纯数据 + 纯函数,单聊、群聊、双端胶囊共用同一套口径,
+ * 不在各页面各写一份判断(那是这套代码反复漂移的主要来源)。
+ *
+ * ## 三个阶段
+ *
+ * | 阶段 | 含义 | 有百分比吗 |
+ * |---|---|---|
+ * | `uploading` | 正在传到云盘(S3/OBS 直传) | ✅ 有,逐字节 |
+ * | `registering` | 传完了,登记进文件台账 | ❌ 无 |
+ * | `sending` | 登记完,发给 Matrix 供容器读取 | ❌ 无 |
+ *
+ * 🔴 「发送中」没有百分比是**故意**的:`matrix-js-sdk` 的 `uploadContent`
+ *    不暴露进度回调。假装有进度(比如卡在 99%)比如实说「正在发送」更糟。
+ */
+
+/** 单个附件的上传阶段 */
+export type UploadPhase = 'idle' | 'uploading' | 'registering' | 'sending' | 'done' | 'failed';
+
+/** 单个附件的进度状态 */
+export interface FileProgress {
+  /** 对应 `PendingFile.id` */
+  id: string;
+  phase: UploadPhase;
+  /** 云盘直传的字节进度 0-100;非 `uploading` 阶段无意义 */
+  pct: number;
+  /** 失败原因(仅 `phase === 'failed'`) */
+  error?: string;
+}
+
+/** 该阶段是否已经有确定的百分比(决定 UI 是否显示「42%」) */
+export function hasPercent(phase: UploadPhase): boolean {
+  return phase === 'uploading';
+}
+
+/**
+ * 单文件的状态文案。
+ *
+ * 刻意不用「上传中」统称三个阶段 —— 用户看到「登记中」「发送中」
+ * 才知道为什么卡在这一步、还要不要等。
+ */
+export function phaseText(p: FileProgress): string {
+  switch (p.phase) {
+    case 'uploading':
+      return `上传中 ${Math.round(p.pct)}%`;
+    case 'registering':
+      return '登记中…';
+    case 'sending':
+      return '发送中…';
+    case 'done':
+      return '已发送';
+    case 'failed':
+      return '上传失败';
+    default:
+      return '待发送';
+  }
+}
+
+/**
+ * 整批附件的总体进度 0-100,**按字节加权**。
+ *
+ * 为什么按字节而不是按文件数:一个 20MB + 一个 10KB 时,按文件数算
+ * 大文件传完前一直是 0%,正是用户反馈的现象。按字节加权,
+ * 大文件传一半就是 50%,符合直觉。
+ *
+ * 已完成的文件按全部字节计入;当前文件按其真实百分比计入。
+ */
+export function overallPct(
+  items: readonly { progress?: FileProgress; size: number }[],
+): number {
+  if (items.length === 0) return 0;
+  let total = 0;
+  let done = 0;
+  for (const it of items) {
+    const bytes = Math.max(0, it.size);
+    total += bytes;
+    const p = it.progress;
+    if (!p) continue;
+    if (p.phase === 'done') done += bytes;
+    else if (p.phase === 'uploading') done += (bytes * clampPct(p.pct)) / 100;
+  }
+  /* 全部是 0 字节文件时退化为按文件数,避免除零后恒为 0 */
+  if (total === 0) {
+    const finished = items.filter((i) => i.progress?.phase === 'done').length;
+    return Math.round((finished / items.length) * 100);
+  }
+  return Math.round((done / total) * 100);
+}
+
+/**
+ * 整批的**主导阶段**(决定输入框下方那一条显示什么)。
+ *
+ * 取「还在进行中最靠后的那个文件的阶段」:
+ * 只要还有文件在传就显示「上传中」,都传完只剩登记/发送时显示对应阶段。
+ */
+export function batchPhase(items: readonly { progress?: FileProgress }[]): UploadPhase {
+  const active = items.map((i) => i.progress?.phase).filter((p): p is UploadPhase => !!p);
+  if (active.length === 0) return 'idle';
+  if (active.includes('uploading')) return 'uploading';
+  if (active.includes('registering')) return 'registering';
+  if (active.includes('sending')) return 'sending';
+  if (active.every((p) => p === 'done')) return 'done';
+  if (active.includes('failed')) return 'failed';
+  return 'idle';
+}
+
+function clampPct(n: number): number {
+  if (!Number.isFinite(n)) return 0;
+  return Math.min(100, Math.max(0, n));
+}

+ 33 - 30
src/app/desktop/desktop.routes.ts

@@ -22,11 +22,15 @@ export const desktopRoutes: Routes = [
         path: 'chat/:id',
         loadComponent: () => import('./pages/chat/chat').then((m) => m.DkChatPage),
       },
-      /* 群聊(伪群聊,2026-11-05):独立页面 */
-      {
-        path: 'group/:groupId',
-        loadComponent: () => import('./pages/group-chat/group-chat').then((m) => m.DkGroupChatPage),
-      },
+      /*
+       * 🔒 群聊(2026-11-11 用户裁决:入口整体隐藏,PC + 移动端)。
+       *
+       * 改重定向而非删路由:旧链接/书签不会落到白屏,统一回会话页。
+       * 页面源码 `pages/group-chat/` 完整保留,恢复时换回注释里的 loadComponent,
+       * 并同步恢复 `session-tree.ts` 与移动端 `m-drawer.ts` 的群聊分组。
+       */
+      { path: 'group/:groupId', redirectTo: 'chat', pathMatch: 'full' },
+      // { path: 'group/:groupId', loadComponent: () => import('./pages/group-chat/group-chat').then((m) => m.DkGroupChatPage) },
       /*
        * 多会话:同一容器下的某条对话(`:sessionId` = 会话线程根)。
        * 与 `/chat/:id` 并列 —— 不带 sessionId 时打开该容器最近一条对话。
@@ -65,26 +69,30 @@ export const desktopRoutes: Routes = [
         path: 'contacts',
         loadComponent: () => import('./pages/contacts/contacts').then((m) => m.DkContactsPage),
       },
-      {
-        path: 'projects',
-        loadComponent: () => import('./pages/projects/projects').then((m) => m.DkProjectsPage),
-      },
-      {
-        path: 'projects/:id',
-        loadComponent: () => import('./pages/projects/projects').then((m) => m.DkProjectsPage),
-      },
-      {
-        path: 'events',
-        loadComponent: () => import('./pages/events/events').then((m) => m.DkEventsPage),
-      },
+      /*
+       * 🔒 2026-11-11 用户裁决:项目 / 任务 / 事件 / 成果 四个入口与路由一起关闭。
+       *
+       * 改重定向而非删除路由:旧链接、书签、外部深链不会落到白屏,统一回会话页。
+       * 页面组件源码保留(`pages/<name>/`),恢复时把下面每条的
+       * `redirectTo` 换回注释里那行 `loadComponent` 即可,同时在
+       * `core/nav-menu.service.ts` 的 `NAV_CATALOG` 恢复对应菜单项。
+       */
+      { path: 'projects', redirectTo: 'chat', pathMatch: 'full' },
+      // { path: 'projects', loadComponent: () => import('./pages/projects/projects').then((m) => m.DkProjectsPage) },
+      { path: 'projects/:id', redirectTo: 'chat', pathMatch: 'full' },
+      // { path: 'projects/:id', loadComponent: () => import('./pages/projects/projects').then((m) => m.DkProjectsPage) },
+      { path: 'events', redirectTo: 'chat', pathMatch: 'full' },
+      // { path: 'events', loadComponent: () => import('./pages/events/events').then((m) => m.DkEventsPage) },
+      /*
+       * 工作台(2026-11-11 恢复)。
+       * 云函数 `xlosi3m46q` v0.0.9 修正余额取数口径后重新开放。
+       */
       {
         path: 'workbench',
         loadComponent: () => import('./pages/workbench/workbench').then((m) => m.DkWorkbenchPage),
       },
-      {
-        path: 'tasks',
-        loadComponent: () => import('./pages/tasks/tasks').then((m) => m.DkTasksPage),
-      },
+      { path: 'tasks', redirectTo: 'chat', pathMatch: 'full' },
+      // { path: 'tasks', loadComponent: () => import('./pages/tasks/tasks').then((m) => m.DkTasksPage) },
       {
         /*
          * 组织成员管理(2026-11-10)。
@@ -96,15 +104,10 @@ export const desktopRoutes: Routes = [
         canActivate: [adminGuard],
         loadComponent: () => import('./pages/admin/users/users').then((m) => m.AdminUsersPage),
       },
-      {
-        path: 'tasks/:id',
-        loadComponent: () => import('./pages/tasks/tasks').then((m) => m.DkTasksPage),
-      },
-      {
-        path: 'deliverables',
-        loadComponent: () =>
-          import('./pages/deliverables/deliverables').then((m) => m.DkDeliverablesPage),
-      },
+      { path: 'tasks/:id', redirectTo: 'chat', pathMatch: 'full' },
+      // { path: 'tasks/:id', loadComponent: () => import('./pages/tasks/tasks').then((m) => m.DkTasksPage) },
+      { path: 'deliverables', redirectTo: 'chat', pathMatch: 'full' },
+      // { path: 'deliverables', loadComponent: () => import('./pages/deliverables/deliverables').then((m) => m.DkDeliverablesPage) },
       /*
        * 容器设置页(2026-11-04 恢复)。
        *

+ 26 - 2
src/app/desktop/pages/agent/agent.ts

@@ -4,6 +4,8 @@ import { ActivatedRoute, Router } from '@angular/router';
 import { UiButton, UiCard, UiEmpty, UiSkeleton } from '../../../ui';
 import { PageComponent } from '../../../components/shell/page/page';
 import { Agent } from '../../../core/api/models';
+import { AlertService } from '../../../core/alert.service';
+import { blockedHint, livenessOf } from '../../../core/agent-liveness';
 import { AgentRenameService } from '../../../core/agent-rename.service';
 import { LiveService } from '../../../core/live.service';
 import { PromptModalService } from '../../../components/domain/prompt-modal/prompt-modal.service';
@@ -36,7 +38,12 @@ import { PromptModalService } from '../../../components/domain/prompt-modal/prom
       <!-- 嵌入模式(dialog 内):不渲染页面外壳 -->
       <ng-container *ngTemplateOutlet="body" />
     } @else {
-      <app-page title="容器设置" subtitle="重命名这个容器,同公司的同事都会看到新名称。">
+      <app-page
+        title="容器设置"
+        subtitle="重命名这个容器,同公司的同事都会看到新名称。"
+        backText="返回"
+        (back)="close()"
+      >
         <ng-container *ngTemplateOutlet="body" />
       </app-page>
     }
@@ -165,6 +172,7 @@ import { PromptModalService } from '../../../components/domain/prompt-modal/prom
 export class DkAgentPage implements OnInit {
   private readonly route = inject(ActivatedRoute);
   private readonly router = inject(Router);
+  private readonly alert = inject(AlertService);
   private readonly prompt = inject(PromptModalService);
   protected readonly live = inject(LiveService);
   protected readonly renameSvc = inject(AgentRenameService);
@@ -215,8 +223,16 @@ export class DkAgentPage implements OnInit {
    *
    * 在该容器下**新建一条对话**,默认落在「日常对话」
    * (`/chat/:id/new` 不带 ws 参数),与「新建对话」弹层选中该容器等价。
+   *
+   * 🔴 2026-11-11:未接入 / 离线的容器**拦下**并提示先接入。
+   *    否则用户能进到一个永远发不出消息的会话里,只会更困惑。
    */
   protected enterChat(agent: Agent): void {
+    const hint = blockedHint(livenessOf(agent));
+    if (hint) {
+      this.alert.show('暂时无法进入会话', hint, 'warning');
+      return;
+    }
     void this.router.navigateByUrl(`/desktop/chat/${agent.id}/new`);
   }
 
@@ -225,7 +241,15 @@ export class DkAgentPage implements OnInit {
   }
 
   /** 嵌入模式的关闭(宿主 dialog 自己处理关闭按钮,这里保留以备扩展) */
+  /**
+   * 返回上一级(2026-11-11 用户要求加返回按钮)。
+   *
+   * 原实现固定跳通讯录,但用户可能是从**左栏会话树**或**工作台**点进来的,
+   * 固定跳转会把他的浏览位置弄丢。优先 `history.back()`,
+   * 没有历史(直接输 URL 打开)才回落到通讯录。
+   */
   protected close(): void {
-    void this.router.navigateByUrl('/desktop/contacts');
+    if (history.length > 1) history.back();
+    else void this.router.navigateByUrl('/desktop/contacts');
   }
 }

+ 254 - 57
src/app/desktop/pages/chat/chat.ts

@@ -30,7 +30,13 @@ import { NewSessionModalService } from '../../../components/domain/new-session-m
 import { ImageLightboxComponent } from '../../../components/preview/image-lightbox/image-lightbox';
 import { previewKindOf, type PreviewKind } from '../../../components/preview/file-kind';
 import { PresenceBadgeComponent } from '../../../components/domain/presence-badge/presence-badge';
-import { Agent, AgentPresence, agentPresence } from '../../../core/api/models';
+import { Agent } from '../../../core/api/models';
+import {
+  blockedHint,
+  LivenessView,
+  livenessOf,
+  sessionPresence,
+} from '../../../core/agent-liveness';
 import { resolveAgentState } from '../../../core/agent-state';
 import { StatusBadgeComponent } from '../../../components/domain/status-badge/status-badge';
 import {
@@ -39,6 +45,9 @@ import {
   SessionRailComponent,
 } from '../../../components/domain/session-rail/session-rail';
 import { ChatService } from '../../../core/chat.service';
+import { formatBytes } from '../../../core/upload-limits';
+import { fileIdentity } from '../../../core/s3-link';
+import { FileProgress, phaseText } from '../../../core/upload-state';
 import { Deliverable, TASK_STATUS_TEXT } from '../../../core/api/models';
 import { DeliverablesService } from '../../../core/deliverables.service';
 import { DeviceService } from '../../../core/device.service';
@@ -58,7 +67,14 @@ import { MatrixSessionService } from '../../../core/matrix-session.service';
 import { WorkspaceService } from '../../../core/workspace.service';
 import { AgentModalService } from '../../../components/domain/agent-modal/agent-modal.service';
 import { buildTurns, QUIET_MS } from '../../../core/turn-aggregator';
-import { AssistantTurn, isTurnTimedOut, TURN_TIMEOUT_MS, turnsFromDm } from '../../../core/assistant-turn';
+import {
+  AssistantTurn,
+  isTurnStalled,
+  isTurnTimedOut,
+  STALL_MS,
+  TURN_TIMEOUT_MS,
+  turnsFromDm,
+} from '../../../core/assistant-turn';
 import { AlertService } from '../../../core/alert.service';
 import { AssistantTurnComponent } from '../../../components/domain/assistant-turn/assistant-turn';
 
@@ -218,12 +234,13 @@ interface ConversationRow {
                 这里只保留任务行**不表达**的内容:容器不可用 / 通道未上报。
               -->
               <!--
-                不可用徽标(2026-11-08 统一口径):
-                原先只判 status,不看心跳 → 45 天没心跳的僵尸容器在这里不报警,
-                而移动端会话列表已把它判成红色「离线」。现在同走 resolveAgentState。
+                页头状态徽标(2026-11-11 统一):
+                只在**离线 / 未接入**时露出,在线与否交给下方任务行表达。
+                「正在回复 / 思考中」也由任务行说,避免同一屏两次说法。
+                判据:sessionPresence(心跳 80 分钟 + Matrix 通道 + 本会话相位)。
               -->
-              @if (agentUnavailable(a)) {
-                <app-status-badge [agent]="a" />
+              @if (!headerPresence().busy && headerPresence().text !== '在线') {
+                <app-status-badge [view]="headerPresence()" />
               }
             }
               <!--
@@ -299,22 +316,28 @@ interface ConversationRow {
             🔴 区分两种「连不上」:容器离线 vs 自己在连接中 ——
                否则用户分不清是网络问题还是对方容器没跑(实测反馈)。
           -->
-          @if (chat.connection() !== 'connected' || currentContainerState() === 'unreported') {
+          <!--
+            两种「连不上」分开显示(2026-11-11):
+              · 自己的网络(chat.connection())—— 是我这边的问题
+              · 对方的容器(offlineBanner())—— 是容器那边的问题
+            合并成一条会让用户分不清该刷新自己还是找管理员。
+          -->
+          @if (chat.connection() !== 'connected') {
             <div class="banner-hold">
-              @if (currentContainerState() === 'unreported' && chat.connection() === 'connected') {
-                <ui-alert type="warning" title="容器状态未知,可能无法应答">
-                  该容器还没有上报在线状态,发给它的消息可能无人处理。
-                </ui-alert>
-              } @else {
-                <ui-alert
-                  [type]="chat.connection() === 'connecting' ? 'info' : 'warning'"
-                  [title]="chat.connection() === 'connecting' ? '正在连接…' : '连接已断开'"
-                >
-                  @if (chat.connection() === 'disconnected') {
-                    <ui-button variant="outline" size="sm" (click)="reconnect()">重新连接</ui-button>
-                  }
-                </ui-alert>
-              }
+              <ui-alert
+                [type]="chat.connection() === 'connecting' ? 'info' : 'warning'"
+                [title]="chat.connection() === 'connecting' ? '正在连接…' : '连接已断开'"
+              >
+                @if (chat.connection() === 'disconnected') {
+                  <ui-button variant="outline" size="sm" (click)="reconnect()">重新连接</ui-button>
+                }
+              </ui-alert>
+            </div>
+          } @else if (offlineBanner()) {
+            <div class="banner-hold">
+              <ui-alert type="warning" [title]="'数字生命' + currentContainerState().text">
+                {{ offlineBanner() }}
+              </ui-alert>
             </div>
           }
 
@@ -543,10 +566,23 @@ interface ConversationRow {
                   <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>
+                    <!--
+                      文件大小 + 上传状态(2026-11-11):
+                      原来只有一个文件名和 ✕,用户不知道多大、传到哪一步了。
+                    -->
+                    <span class="pending-meta" [class.failed]="pf.progress?.phase === 'failed'">
+                      {{ fileSize(pf.file.size) }}
+                      @if (pf.progress; as p) {
+                        @if (p.phase !== 'uploading') {
+                          · {{ filePhaseText(p) }}
+                        }
+                      }
+                    </span>
                     <button
                       type="button"
                       class="pending-x"
                       [attr.aria-label]="'移除 ' + pf.file.name"
+                      [disabled]="chat.uploading()"
                       (click)="chat.unstageFile(pf.id)"
                     >
                       <i class="fa-solid fa-xmark" aria-hidden="true"></i>
@@ -562,6 +598,7 @@ interface ConversationRow {
             [sending]="chat.sending()"
             [uploading]="chat.uploading()"
             [pct]="chat.uploadPct()"
+            [stage]="chat.uploadPhase()"
             [disabled]="chat.connection() !== 'connected'"
             [hasAttachments]="chat.pendingFiles().length > 0"
             (draftChange)="chat.setDraft($event)"
@@ -606,13 +643,14 @@ interface ConversationRow {
           [tasks]="agentTasks()"
           [deliverables]="agentDeliverables()"
           [runtimes]="railRuntimes()"
+          [highlightId]="railHighlight()"
           [ownerNameOf]="agentName"
           filesTitle="本会话文件"
           filesEmpty="这条对话里还没有文件。"
           resultsTitle="相关成果"
           resultsEmpty="该数字生命暂无交付成果。"
-          tasksTitle="相关任务"
-          tasksEmpty="该数字生命暂无任务。"
+          [tasksTitle]="tasksTitle()"
+          tasksEmpty="该容器暂未上报任务。"
           [preview]="previewFile()"
           (previewRequest)="openFile($event)"
           (previewClosed)="previewFile.set(null)"
@@ -1188,7 +1226,7 @@ interface ConversationRow {
         display: inline-flex;
         align-items: center;
         gap: var(--sp-2);
-        max-width: 220px;
+        max-width: 280px;
         padding: var(--sp-1) var(--sp-2);
         border: 1px solid var(--edge);
         border-radius: var(--r-md);
@@ -1196,6 +1234,16 @@ interface ConversationRow {
         font-size: var(--fs-12);
         color: var(--ink-2);
       }
+      /* 附件大小 + 上传状态(2026-11-11):弱化展示,失败时转危险色 */
+      .pending-meta {
+        flex: none;
+        color: var(--faint);
+        font-size: var(--fs-11);
+        white-space: nowrap;
+      }
+      .pending-meta.failed {
+        color: var(--danger);
+      }
       .pending-thumb {
         width: 32px;
         height: 32px;
@@ -1945,6 +1993,15 @@ export class DkChatPage implements OnDestroy {
    */
   readonly railTab = signal<RailTab>(readRailTabPref());
 
+  /**
+   * 右栏里需要高亮定位的条目 id(2026-11-11)。
+   *
+   * 任务 / 成果独立路由下线后,聊天里的深链改为「打开右栏 + 定位」,
+   * 由 `openRailAt()` 置位、2 秒后自动清空。
+   */
+  readonly railHighlight = signal<string | null>(null);
+  private railHighlightTimer: ReturnType<typeof setTimeout> | null = null;
+
   /** 标签定义(模板遍历用,避免在模板里重复写四份) */
   readonly railTabs: { key: RailTab; label: string; icon: string; hint: string }[] = [
     { key: 'tasks', label: '任务', icon: 'fa-list-check', hint: '该容器名下的任务' },
@@ -2027,7 +2084,23 @@ export class DkChatPage implements OnDestroy {
     return id ? this.tasks.all().filter((t) => t.agentId === id) : [];
   });
 
+  /**
+   * 右栏「任务」的中文标题(2026-11-11)。
+   *
+   * 🔴 任务属于**容器**不属于会话 —— 不能假装按会话过滤(会丢功能),
+   *    但也不能让用户以为「这是本会话的任务」。所以标题里直接写清楚。
+   *    用户反馈「开新会话还能看到别的会话的东西」,根因就在这里。
+   */
+  readonly tasksTitle = computed(() => {
+    const name = this.chat.agentId() ? this.agentName() : '';
+    return name ? `${name} 的全部任务` : '该容器的全部任务';
+  });
+
   readonly pct = (v?: number) => fmtPercent(v);
+  /** 附件大小(胶囊上用,如 `2.4MB`) */
+  readonly fileSize = (n: number) => formatBytes(n);
+  /** 附件上传阶段文案(`上传中 42%` / `登记中…` / `发送中…`) */
+  readonly filePhaseText = (p: FileProgress) => phaseText(p);
   readonly tokens = (a: { usage?: { totalTokens?: number } }) => fmtTokens(usageOf(a as never).totalTokens);
 
   /**
@@ -2224,14 +2297,31 @@ export class DkChatPage implements OnDestroy {
     const hasOutput = last.blocks.some((b) => b.body.length > 0 || b.steps.length > 0);
     const timedOut = userMs > 0 && isTurnTimedOut(Date.now() - userMs, hasOutput, last.live);
 
-    if (timedOut) {
-      // 判一次超时 → 立刻标记为已处理,之后永不再判
+    /*
+     * 🔴 卡住检测(2026-11-11):回了**一部分**就停住的情况。
+     *
+     * `isTurnTimedOut` 要求 `!hasOutput`,所以容器先回一句、跑两步工具、
+     * 然后再也没下文时它**永远不触发** —— 界面停在「已结束」什么也不说
+     * (用户实测 09:51 之后一片空白)。
+     *
+     * 这里用「距最后一次输出的静止时长」判,两个判据互不重叠。
+     */
+    const lastActivity = last.blocks.reduce((max, b) => Math.max(max, b.updatedAt), 0);
+    const idleMs = lastActivity > 0 ? Date.now() - lastActivity : 0;
+    const stalled = isTurnStalled(idleMs, hasOutput, last.live);
+
+    if (timedOut || stalled) {
+      // 判一次 → 标记为已处理,之后不再重复打扰
       this._handledTimeouts.add(last.id);
       this._alert.resetDismiss(DkChatPage.TIMEOUT_TITLE);
       this._alert.syncState(
         DkChatPage.TIMEOUT_TITLE,
         true,
-        `最近一条消息已超过 ${Math.round(TURN_TIMEOUT_MS / 1000)} 秒没有回复。请确认容器是否在线,或重试。`,
+        stalled
+          ? `容器已停止响应超过 ${Math.round(STALL_MS / 1000)} 秒,任务可能已中断。可点「重新发送」把刚才的消息放回输入框。`
+          : `最近一条消息已超过 ${Math.round(TURN_TIMEOUT_MS / 1000)} 秒没有回复。请确认容器是否在线,或点「重新发送」重试。`,
+        /* 「重新发送」= 回填草稿,不是直接发出 —— 避免没看清就重复扣费 */
+        { label: '重新发送' },
       );
       return;
     }
@@ -2239,6 +2329,19 @@ export class DkChatPage implements OnDestroy {
     this._alert.syncState(DkChatPage.TIMEOUT_TITLE, false);
   }
 
+  /**
+   * 把最后一条用户消息放回输入框(2026-11-11)。
+   *
+   * 只回填、**不自动发送**:容器卡住后盲目重发会重复扣费,
+   * 让用户看一眼再决定要不要发。
+   */
+  private resendLast(): void {
+    const mine = [...this.chat.messages()].reverse().find((m) => m.sender === 'me' && m.text?.trim());
+    if (!mine?.text) return;
+    this.chat.setDraft(mine.text);
+    this._alert.show('已放回输入框', '确认内容无误后再点发送即可。', 'warning');
+  }
+
   /**
    * 已处理过(提示过)的超时回合 id。
    *
@@ -2399,17 +2502,36 @@ export class DkChatPage implements OnDestroy {
     return this.unread.countOf(agentId);
   }
 
-  /** 可对话状态(判定唯一实现在 models.agentPresence) */
-  containerState(agentId: string): AgentPresence {
-    return agentPresence(this.live.agentById(agentId));
+  /** 可对话状态(2026-11-11 收敛到 `livenessOf`,与通讯录/工作台同一判据) */
+  containerState(agentId: string): LivenessView {
+    return livenessOf(this.live.agentById(agentId), Date.now(), this.live.fetchedAt());
   }
 
-  /** 当前打开会话的容器状态(用于区分「容器离线」与「自己在连接中」) */
-  readonly currentContainerState = computed<AgentPresence>(() => {
+  /**
+   * 当前打开会话的容器在线状态(2026-11-11 统一)。
+   *
+   * 原先用 `agentPresence`(只看 Matrix 通道、不看心跳),
+   * 与通讯录/工作台的 80 分钟口径不一致 —— 同一个容器两边说法不同。
+   */
+  readonly currentContainerState = computed<LivenessView>(() => {
     const id = this.chat.agentId();
-    return id ? agentPresence(this.live.agentById(id)) : 'offline';
+    return livenessOf(id ? this.live.agentById(id) : undefined, Date.now(), this.live.fetchedAt());
   });
 
+  /**
+   * 会话页头状态(在线 / 正在回复 / 离线)。
+   *
+   * 用户裁决(2026-11-11):「按页面分层」—— 只有会话页额外显示本会话相位,
+   * 因为那是用户正在等的反馈。离线时相位一律不显示(不能说明明断了还在回复)。
+   */
+  readonly headerPresence = computed(() => {
+    const typing = this.chat.peerTyping() ? ('replying' as const) : undefined;
+    return sessionPresence(this.chat.agent(), typing);
+  });
+
+  /** 离线/未接入时的横幅提示(null = 在线,不显示) */
+  readonly offlineBanner = computed(() => blockedHint(this.currentContainerState()));
+
   readonly filteredConversations = computed(() => {
     const q = this.keyword().trim().toLowerCase();
     if (!q) return this.conversations();
@@ -2432,16 +2554,44 @@ export class DkChatPage implements OnDestroy {
   readonly sessionFiles = computed(() => {
     const seen = new Set<string>();
     const out: { name: string; url: string; mimeType: string; size: number }[] = [];
+    /*
+     * ① 消息流里「我发的」附件 —— **先走这条**(2026-11-11 调整顺序)。
+     *
+     * 它带真实的 `size` 和 `cloudUrl`,信息最全。
+     * 之前台账先走、消息流后走,导致显示的是台账那条(size 写死 0,
+     * 右栏不显示大小),看起来就像文件「少了信息」。
+     */
     for (const m of this.chat.messages()) {
       const f = m.file;
       if (!f || !f.url) continue;
       // 只收「我发的」附件
       if (m.sender !== 'me') continue;
-      const key = f.url + f.name;
-      if (seen.has(key)) continue;
-      seen.add(key);
+      const id = fileIdentity(f.name, f.size);
+      if (seen.has(id)) continue;
+      seen.add(id);
       out.push({ name: f.name, url: f.url, mimeType: f.mimeType, size: f.size });
     }
+    /*
+     * ② 台账里 `uploadedBy:'user'` 的记录(2026-11-11)。
+     *
+     * 为什么还要这一路:文件台账按会话直查,**刷新后仍在**;
+     * 而消息流只覆盖「本页加载到的那段历史」,翻页/重开可能取不到。
+     *
+     * 🔴 去重**不能比 URL**(2026-11-11 修复):台账存 S3 地址,
+     *    而消息流的 `f.url` 是 `mxc://` —— 两者永远不相等,
+     *    于是同一个文件在右栏出现两条(用户实测「发一个文件出现两个」)。
+     *    改用 `fileIdentity`(名字+大小)。
+     */
+    for (const d of this._dbUserFiles()) {
+      const a = d.artifacts[0];
+      if (!a?.url) continue;
+      const bytes = d.sizeBytes ?? 0;
+      const id = fileIdentity(a.name, bytes);
+      if (seen.has(id)) continue;
+      seen.add(id);
+      /* 大小未知时传 -1:`fmtSize` 会显示为空,而不是误导性的「0 B」 */
+      out.push({ name: a.name, url: a.url, mimeType: a.type ?? '', size: bytes > 0 ? bytes : -1 });
+    }
     return out;
   });
 
@@ -2488,7 +2638,8 @@ export class DkChatPage implements OnDestroy {
    */
   readonly agentDeliverables = computed(() => {
     const sess = this.chat.currentSession();
-    const fromDb = this._dbFiles();
+    /* 台账这一路已按来源分流:成果只吃「容器产出」那半边(2026-11-11) */
+    const fromDb = this._dbAgentFiles();
     const agentFiles = this.chat
       .messages()
       .filter((m) => m.sender === 'agent' && m.file?.url)
@@ -2498,30 +2649,52 @@ export class DkChatPage implements OnDestroy {
     return this.deliverables.merge(fromDb, fromMessages, fromRecord);
   });
 
-  /** 按会话从数据库加载的文件(①) */
-  private readonly _dbFiles = signal<Deliverable[]>([]);
-  readonly dbFiles = this._dbFiles.asReadonly();
+  /**
+   * 按会话从数据库加载的文件(①),**已按来源分流**(2026-11-11)。
+   *
+   * 用户裁决「文件 / 成果严格互斥」:我上传的文件登记进台账时带
+   * `uploadedBy:'user'`,必须回到「文件」页签,不能顺着台账流进「成果」。
+   */
+  private readonly _dbUserFiles = signal<Deliverable[]>([]);
+  private readonly _dbAgentFiles = signal<Deliverable[]>([]);
+  readonly dbFiles = this._dbAgentFiles.asReadonly();
 
   /** 拉当前会话的文件台账;会话切换时重跑 */
   private loadSessionFiles(): void {
     const sid = this.chat.currentSession()?.id;
     if (!sid) {
-      this._dbFiles.set([]);
+      this._dbUserFiles.set([]);
+      this._dbAgentFiles.set([]);
       return;
     }
     void this.deliverables
-      .loadForSession(sid)
-      .then((files) => {
+      .loadSplitForSession(sid)
+      .then(({ userFiles, agentFiles }) => {
         /* 期间用户可能又切了会话,只认发请求时的那个 */
-        if (this.chat.currentSession()?.id === sid) this._dbFiles.set(files);
+        if (this.chat.currentSession()?.id !== sid) return;
+        this._dbUserFiles.set(userFiles);
+        this._dbAgentFiles.set(agentFiles);
       })
       .catch(() => {
         /* 台账加载失败不该盖掉消息流兜底的结果,静默即可 */
-        if (this.chat.currentSession()?.id === sid) this._dbFiles.set([]);
+        if (this.chat.currentSession()?.id === sid) {
+          this._dbUserFiles.set([]);
+          this._dbAgentFiles.set([]);
+        }
       });
   }
 
   constructor() {
+    /*
+     * 提示条上的「重新发送」(2026-11-11)。
+     *
+     * 动作的实际语义只有页面知道(要取本页最后一条用户消息),
+     * 所以由这里注册给 `AlertService`,提示条组件只管触发。
+     */
+    this._alert.onAction((a) => {
+      if (a.title === DkChatPage.TIMEOUT_TITLE) this.resendLast();
+    });
+
     /*
      * ① 右侧栏:会话切换或消息数变化时重拉文件台账(2026-11-10)。
      *
@@ -3012,11 +3185,6 @@ export class DkChatPage implements OnDestroy {
     this.railOpen.set(true);
   }
 
-  goTasks(): void {
-    const id = this.chat.agentId();
-    if (id) void this.router.navigate(['/', this.device.mode(), 'tasks', id]);
-  }
-
   /**
    * 成果预览(2026-10-31 用户要求):改为**右栏内联**,与消息附件共用同一个预览面板。
    *
@@ -3049,14 +3217,34 @@ export class DkChatPage implements OnDestroy {
       .map((l) => l.split(','));
   }
 
+  /**
+   * 成果深链 → **在右栏内定位**(2026-11-11)。
+   *
+   * 「成果」独立页面的路由已下线(用户裁决:入口和路由一起关),
+   * 原先这里 `navigate` 到 `/deliverables?focus=`,路由一关就会跳空。
+   * 右栏本来就有「成果」页签,直接用它承载:切页签 + 展开 + 高亮该条。
+   */
   goArtifact(id: string): void {
-    void this.router.navigate(['/', this.device.mode(), 'deliverables'], {
-      queryParams: { focus: id },
-    });
+    this.openRailAt('results', id);
   }
 
+  /** 任务深链 → 右栏「任务」页签定位(原因同上) */
   goTask(id: string): void {
-    void this.router.navigate(['/', this.device.mode(), 'tasks', id]);
+    this.openRailAt('tasks', id);
+  }
+
+  /**
+   * 打开右栏并定位到某条,2 秒后清掉高亮。
+   *
+   * 清高亮是必要的:高亮只是一次性的「刚才点的是这条」提示,
+   * 一直挂着会让用户以为那是选中态。
+   */
+  private openRailAt(tab: RailTab, id: string): void {
+    this.railTab.set(tab);
+    this.railOpen.set(true);
+    this.railHighlight.set(id);
+    if (this.railHighlightTimer) clearTimeout(this.railHighlightTimer);
+    this.railHighlightTimer = setTimeout(() => this.railHighlight.set(null), 2000);
   }
 
   /**
@@ -3104,11 +3292,20 @@ export class DkChatPage implements OnDestroy {
    * 每走一格检查一次,回合都结束后自动停表。
    */
   private syncClock(): void {
-    const live = this.aiRows().some((r) => r.kind === 'turn' && r.turn.live);
+    /*
+     * 2026-11-11:静默等待期也要走表。
+     * `live` 在 25 秒静默后就转 false,但用户仍在等(180 秒才超时)——
+     * 只按 `live` 起表的话,等待文案里的秒数会冻住。
+     */
+    const needsTick = (): boolean =>
+      this.aiRows().some(
+        (r) => r.kind === 'turn' && (r.turn.live || r.turn.blocks.some((b) => b.waiting)),
+      );
+    const live = needsTick();
     if (live && this.clockHandle === null) {
       this.clockHandle = setInterval(() => {
         this.clockTick.update((n) => n + 1);
-        if (!this.aiRows().some((r) => r.kind === 'turn' && r.turn.live)) this.stopClock();
+        if (!needsTick()) this.stopClock();
       }, 1000);
     } else if (!live && this.clockHandle !== null) {
       this.stopClock();

+ 11 - 0
src/app/desktop/pages/contacts/contacts.ts

@@ -5,6 +5,8 @@ import { PageComponent } from '../../../components/shell/page/page';
 import { StatusBadgeComponent } from '../../../components/domain/status-badge/status-badge';
 import { PresenceBadgeComponent } from '../../../components/domain/presence-badge/presence-badge';
 import { Agent } from '../../../core/api/models';
+import { AlertService } from '../../../core/alert.service';
+import { blockedHint, livenessOf } from '../../../core/agent-liveness';
 import { ContactsService } from '../../../core/contacts.service';
 import { DeviceService } from '../../../core/device.service';
 import { fmtTokens } from '../../../core/format';
@@ -333,6 +335,7 @@ import { fmtTokens } from '../../../core/format';
 })
 export class DkContactsPage implements OnInit {
   readonly contacts = inject(ContactsService);
+  private readonly alert = inject(AlertService);
   private readonly router = inject(Router);
   private readonly device = inject(DeviceService);
 
@@ -368,8 +371,16 @@ export class DkContactsPage implements OnInit {
    * 且聊天页的齿轮入口已隐藏,容器设置只能从这里进。
    *
    * 现在统一进**设置页**;需要对话时在设置页里点「进入会话」。
+   *
+   * 🔴 2026-11-11 补充:未接入 / 离线的容器**直接拦下**并提示先接入。
+   *    原来点进去之后才在设置页里发现用不了,用户不知道下一步该干什么。
    */
   open(a: Agent): void {
+    const hint = blockedHint(livenessOf(a));
+    if (hint) {
+      this.alert.show('暂时无法查看该数字生命', hint, 'warning');
+      return;
+    }
     void this.router.navigate(['/', this.device.mode(), 'agent', a.id]);
   }
 }

+ 110 - 26
src/app/desktop/pages/group-chat/group-chat.ts

@@ -14,6 +14,7 @@ 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 { AlertService } from '../../../core/alert.service';
 import { ChatService } from '../../../core/chat.service';
 import { ChatSessionService } from '../../../core/chat-session.service';
 import { SessionService } from '../../../core/session.service';
@@ -29,7 +30,8 @@ import { routeOpen, OpenableFile } from '../../../core/file-open';
 import { AssistantTurnComponent } from '../../../components/domain/assistant-turn/assistant-turn';
 import { MentionListComponent, MentionOption, MentionState } from '../../../components/domain/mention-list/mention-list';
 import { GroupQueueService } from '../../../core/group-queue.service';
-import { agentPresence } from '../../../core/api/models';
+import { livenessOf } from '../../../core/agent-liveness';
+import { fileIdentity } from '../../../core/s3-link';
 import { ImageLightboxComponent } from '../../../components/preview/image-lightbox/image-lightbox';
 import {
   applyMention,
@@ -279,6 +281,7 @@ import { ComposerComponent } from '../../../components/domain/composer/composer'
           [tasks]="railTasks()"
           [deliverables]="railDeliverables()"
           [runtimes]="railRuntimes()"
+          [highlightId]="railHighlight()"
           [ownerNameOf]="memberName"
           [tabs]="railTabs"
           tasksTitle="群成员的任务"
@@ -338,6 +341,14 @@ import { ComposerComponent } from '../../../components/domain/composer/composer'
         flex: none;
         width: var(--rail-w, 320px);
         min-height: 0;
+        /*
+         * 与单聊右栏同一底色(2026-11-11 用户反馈「两边配色不一致」)。
+         *
+         * 单聊在 chat.ts 的 .rail-col 上覆盖成 --rail-surface(灰蓝面板),
+         * 群聊之前没有覆盖,用的是组件 :host 默认的 --surface(白色面板)——
+         * 于是标签条 / 选中态的层次对比度两边不同,看起来像两个不同的组件。
+         */
+        background: var(--rail-surface);
       }
       /*
        * 拖宽手柄:与日常页同交互(向左拖变宽)。
@@ -637,6 +648,7 @@ import { ComposerComponent } from '../../../components/domain/composer/composer'
 })
 export class DkGroupChatPage implements OnDestroy {
   private readonly route = inject(ActivatedRoute);
+  private readonly alert = inject(AlertService);
   private readonly sessions = inject(ChatSessionService);
   private readonly sessionSvc = inject(SessionService);
   private readonly groups = inject(GroupChatService);
@@ -742,13 +754,30 @@ export class DkGroupChatPage implements OnDestroy {
   protected readonly railFiles = computed<RailFile[]>(() => {
     const seen = new Set<string>();
     const out: RailFile[] = [];
+    /*
+     * ① 台账里 `uploadedBy:'user'` 的记录 —— **优先**(2026-11-11,与单聊同口径)。
+     *
+     * 消息流只有 Matrix 副本(`mxc://` 转换地址),刷新/换设备后会失效;
+     * 台账存的是云盘直链,刷新不丢。之前群聊只读消息流,所以用户发完
+     * 文件刷新一下就「没了」。
+     */
+    for (const d of this._dbUserFiles()) {
+      const a = d.artifacts[0];
+      if (!a?.url) continue;
+      const bytes = d.sizeBytes ?? 0;
+      const id = fileIdentity(a.name, bytes);
+      if (seen.has(id)) continue;
+      seen.add(id);
+      out.push({ name: a.name, url: a.url, mimeType: a.type ?? '', size: bytes > 0 ? bytes : -1 });
+    }
+    /* ② 消息流里我发的附件(实时兜底,`fileIdentity` 去重避免与台账重复) */
     for (const m of this.messages()) {
       const f = m.file;
       if (!f?.url) continue;
       if (m.sender !== 'me') continue;
-      const key = f.url + f.name;
-      if (seen.has(key)) continue;
-      seen.add(key);
+      const id = fileIdentity(f.name, f.size);
+      if (seen.has(id)) continue;
+      seen.add(id);
       out.push({ name: f.name, url: f.url, mimeType: f.mimeType, size: f.size });
     }
     return out;
@@ -777,7 +806,12 @@ export class DkGroupChatPage implements OnDestroy {
    * 每条成果带上「是哪个成员产出的」,右栏据此显示归属。
    */
   protected readonly railDeliverables = computed<Deliverable[]>(() => {
-    const fromDb = this._dbFiles();
+    /*
+     * 台账这一路**只吃容器产出**(2026-11-11,与单聊 `agentDeliverables` 同口径)。
+     * 之前用未分流的 `_dbFiles`,用户上传的文件也会混进「成果」,
+     * 与「文件」页签重复显示。
+     */
+    const fromDb = this._dbAgentFiles();
     const agentMsgs = this.messages().filter((m) => m.sender === 'agent' && m.file?.url);
     const fromMessages = this.deliverablesSvc
       .fromAgentFiles(agentMsgs.map((m) => m.file!))
@@ -786,27 +820,49 @@ export class DkGroupChatPage implements OnDestroy {
         // 补上产出成员(`fromAgentFiles` 不带归属)
         agentId: agentMsgs[i]?.senderId ?? '',
       }));
+    /*
+     * ③ 会话记录上的 `deliverableUrls` —— **降级为最后兜底**。
+     *
+     * 它是伪群聊架构下的遗留:容器回执按 DM 房间记账,同一条回复
+     * 既写进单聊又写进群,会造成跨会话串味。台账(①②)已经覆盖,
+     * 这里只兜住「容器纯文字派活、台账还没来得及登记」的极窄场景。
+     */
     const fromRecord = this.deliverablesSvc.bySessionUrls(this.session()?.deliverableUrls);
     return this.deliverablesSvc.merge(fromDb, fromMessages, fromRecord);
   });
 
-  /** 按群会话从数据库加载的文件(①) */
-  private readonly _dbFiles = signal<Deliverable[]>([]);
-  readonly dbFiles = this._dbFiles.asReadonly();
+  /** 按群从台账加载的文件(① 用户上传 / ② 容器产出,已分流) */
+  private readonly _dbUserFiles = signal<Deliverable[]>([]);
+  private readonly _dbAgentFiles = signal<Deliverable[]>([]);
+  readonly dbFiles = this._dbAgentFiles.asReadonly();
 
   private loadSessionFiles(): void {
     const sid = this.session()?.id;
     if (!sid) {
-      this._dbFiles.set([]);
+      this._dbUserFiles.set([]);
+      this._dbAgentFiles.set([]);
       return;
     }
+    /*
+     * 按**群**加载并分流(2026-11-11)。
+     *
+     * 原来用 `loadForSession`(不分流),台账里用户上传的记录原样进了
+     * 「成果」页签 —— 同一个文件在「文件」和「成果」各显示一次。
+     * 服务端 `listByGroup` 同时匹配 groupId 与 threadRoot,
+     * 所以没有 groupId 的存量群内上传记录也查得到。
+     */
     void this.deliverablesSvc
-      .loadForSession(sid)
-      .then((files) => {
-        if (this.session()?.id === sid) this._dbFiles.set(files);
+      .loadSplitForGroup(sid)
+      .then(({ userFiles, agentFiles }) => {
+        if (this.session()?.id !== sid) return;
+        this._dbUserFiles.set(userFiles);
+        this._dbAgentFiles.set(agentFiles);
       })
       .catch(() => {
-        if (this.session()?.id === sid) this._dbFiles.set([]);
+        if (this.session()?.id === sid) {
+          this._dbUserFiles.set([]);
+          this._dbAgentFiles.set([]);
+        }
       });
   }
 
@@ -818,19 +874,48 @@ export class DkGroupChatPage implements OnDestroy {
 
   /** 状态页:每个成员一行 */
   protected readonly railRuntimes = computed<RailRuntime[]>(() =>
-    this.members().map((a) => ({
-      agentId: a.id,
-      name: a.name,
-      taskText: this.pulse.describe(a).text,
-      taskTone: this.pulse.describe(a).tone,
-      cpuPercent: a.host?.cpuPercent,
-      memPercent: a.host?.memPercent,
-      totalTokens: usageOf(a as never).totalTokens,
-    })),
+    this.members().map((a) => {
+      const line = this.pulse.describe(a);
+      return {
+        agentId: a.id,
+        name: a.name,
+        taskText: line.text,
+        taskTone: line.tone,
+        cpuPercent: a.host?.cpuPercent,
+        memPercent: a.host?.memPercent,
+        totalTokens: usageOf(a as never).totalTokens,
+        /*
+         * 「最近动态」(2026-11-11 补齐)。
+         *
+         * 单聊右栏的状态页一直有这个区块,群聊漏了 —— 同一个组件、
+         * 同一个页签,群聊却永远空白,用户会以为「群里没动静」。
+         */
+        pulses: this.pulse.pulsesFor(a.id),
+      };
+    }),
   );
 
+  /**
+   * 右栏里需要高亮定位的条目 id(2026-11-11 补齐)。
+   *
+   * 与单聊 `chat.ts` 的 `railHighlight` 同口径:从消息里点任务/成果时,
+   * 右栏打开对应页签并高亮那一条,2 秒后自动清掉。
+   * 群聊之前没这个能力 —— 点了只会切页签,列表几十条时找不到是哪条。
+   */
+  protected readonly railHighlight = signal<string | null>(null);
+  private railHighlightTimer: ReturnType<typeof setTimeout> | null = null;
+  /**
+   * 任务入口(2026-11-11)。
+   *
+   * 任务页路由已下线(入口和路由一起关),所以不再跳转。
+   * 但**不要只弹提示**——右栏本来就有「任务」页签,直接打开并定位到那一条,
+   * 比让用户自己去翻有效得多(与单聊 `goTask` 同口径)。
+   */
   protected openTask(id: string): void {
-    void this.router.navigate(['/desktop/tasks'], { queryParams: { task: id } });
+    this.railHighlight.set(id);
+    this.railOpen.set(true);
+    if (this.railHighlightTimer) clearTimeout(this.railHighlightTimer);
+    this.railHighlightTimer = setTimeout(() => this.railHighlight.set(null), 2000);
   }
 
   /* ------------------------------------------------------------------ */
@@ -1148,9 +1233,8 @@ export class DkGroupChatPage implements OnDestroy {
    */
   private mentionStateOf(agentId: string): MentionState {
     const a = this.live.agentById(agentId);
-    const presence = agentPresence(a);
-    if (presence === 'offline') return 'blocked';
-    if (a?.status === 'error' || a?.status === 'stopped') return 'blocked';
+    /* 判据收敛到 livenessOf(心跳 80 分钟 + Matrix 通道),与通讯录/工作台同一口径 */
+    if (!livenessOf(a).online) return 'blocked';
     if (this.groupLive.isTyping(agentId)) return 'busy';
     return 'ready';
   }

+ 111 - 55
src/app/desktop/pages/workbench/workbench.ts

@@ -10,13 +10,15 @@ import {
   UiProgress,
   UiSkeleton,
   UiStatistic,
+  UiTooltip,
 } from '../../../ui';
 import { PageComponent } from '../../../components/shell/page/page';
 import { InfiniteScrollDirective } from '../../../shared/infinite-scroll.directive';
 import { FilterChipsComponent } from '../../../components/domain/filter-chips/filter-chips';
 import { StatusBadgeComponent } from '../../../components/domain/status-badge/status-badge';
 import { Agent } from '../../../core/api/models';
-import { resolveAgentState } from '../../../core/agent-state';
+import { AlertService } from '../../../core/alert.service';
+import { isReporterSelfTalk, livenessOf } from '../../../core/agent-liveness';
 import { DeviceService } from '../../../core/device.service';
 import { hasMetrics as hasMetricsFn, fmtBalance, fmtPercent, fmtTokens, isMatrixReady, usageOf } from '../../../core/format';
 import { LiveService } from '../../../core/live.service';
@@ -47,6 +49,7 @@ import { fmtDuration } from '../../../core/time';
     UiProgress,
     UiSkeleton,
     UiStatistic,
+    UiTooltip,
     InfiniteScrollDirective,
     FilterChipsComponent,
     PageComponent,
@@ -126,17 +129,21 @@ import { fmtDuration } from '../../../core/time';
           <!-- 总览统计:底座 ui-statistic,不再手写统计块 -->
           <div class="overview">
             <ui-statistic title="数字生命" [value]="live.agents().length" icon="fa-solid fa-cube" />
+            <!--
+              2026-11-11 用户裁决:工作台只表达「在线 / 离线」,
+              不再显示「运行中」「需要关注」这类运行态汇总。
+            -->
             <ui-statistic
-              title="运行中"
-              [value]="runningCount()"
+              title="在线"
+              [value]="onlineCount()"
               color="success"
-              icon="fa-solid fa-play"
+              icon="fa-solid fa-signal"
             />
             <ui-statistic
-              title="需要关注"
-              [value]="blockedCount()"
-              [color]="blockedCount() > 0 ? 'danger' : 'default'"
-              icon="fa-solid fa-circle-exclamation"
+              title="离线"
+              [value]="offlineCount()"
+              [color]="offlineCount() > 0 ? 'danger' : 'default'"
+              icon="fa-solid fa-plug-circle-xmark"
             />
             <ui-statistic
               title="阻塞任务"
@@ -186,7 +193,12 @@ import { fmtDuration } from '../../../core/time';
 
                 <div class="card-task">
                   <span class="eyebrow">当前任务</span>
-                  <p class="ellipsis">{{ a.currentTask || '暂无进行中的任务' }}</p>
+                  <!--
+                    2026-11-11 用户裁决:工作台只表达「是否在线」。
+                    原先是 currentTask 直出,上报器自指时会把
+                    periodic status reporter (xlosi3m46q) 印在卡片上。
+                  -->
+                  <p class="ellipsis">{{ liveTaskOf(a) }}</p>
                 </div>
 
                 @if (hasMetrics(a)) {
@@ -205,9 +217,20 @@ import { fmtDuration } from '../../../core/time';
 
                   <div class="card-foot">
                     <span class="foot-item">Token <strong>{{ tokens(a) }}</strong></span>
-                    <ui-button variant="ghost" size="sm" iconRight="fa-solid fa-arrow-up-right-from-square" (click)="showBalance(a)">
-                      {{ balance(a) }}
-                    </ui-button>
+                    <!--
+                      余额口径(2026-11-11):这是**主人账号**的共用余额,不是本容器独立额度。
+                      容器账单挂在主人账号上,同一主人的所有容器显示同一个数 —— 必须标注,避免误读。
+                    -->
+                    <ui-tooltip [content]="balanceTip(a)" position="top">
+                      <ui-button
+                        variant="ghost"
+                        size="sm"
+                        iconRight="fa-solid fa-arrow-up-right-from-square"
+                        (click)="showBalance(a)"
+                      >
+                        <span class="balance-label">账号余额</span> {{ balance(a) }}
+                      </ui-button>
+                    </ui-tooltip>
                   </div>
                 } @else if (ready(a)) {
                   <!--
@@ -498,34 +521,48 @@ import { fmtDuration } from '../../../core/time';
   ],
 })
 export class DkWorkbenchPage implements OnInit {
+  /**
+   * Fmode Studio 充值页(2026-11-11)。
+   *
+   * 与云函数 `xlosi3m46q` 取余额的 `billing/query` 同源,
+   * 所以这里显示的金额和点进去看到的余额是同一口径。
+   */
+  private static readonly BALANCE_URL = 'https://app.fmode.cn/dev/studio/balance';
+
   readonly live = inject(LiveService);
   readonly tasks = inject(TasksService);
   private readonly router = inject(Router);
   private readonly device = inject(DeviceService);
+  private readonly alert = inject(AlertService);
 
   readonly channelAgent = signal<Agent | null>(null);
 
   readonly blockedTasks = computed(() => this.tasks.blocked());
+
   /**
-   * 「运行中」计数(2026-11-08 统一口径):含回复/思考,并排除心跳过期的僵尸容器。
-   * 原先只数 `status === 'running'`,45 天没心跳的容器也算进去。
+   * 在线容器数(2026-11-11 用户裁决:工作台只显示「在线 / 离线」)。
+   *
+   * 判据统一走 `livenessOf()`(心跳 80 分钟 + Matrix 通道),
+   * 不再用 `resolveAgentState` 的「执行中 / 空闲」这套运行态。
    */
-  readonly runningCount = computed(
-    () =>
-      this.live.agents().filter((a) => {
-        const s = resolveAgentState({ agent: a }).state;
-        return s === 'running' || s === 'replying' || s === 'thinking';
-      }).length,
+  readonly onlineCount = computed(
+    () => this.live.agents().filter((a) => livenessOf(a, Date.now(), this.live.fetchedAt()).online).length,
   );
 
-  /** 「需要关注」:不可用容器(离线 + 异常),与移动端同一张表 */
-  readonly blockedCount = computed(
-    () =>
-      this.live.agents().filter((a) => {
-        const s = resolveAgentState({ agent: a }).state;
-        return s === 'offline' || s === 'error';
-      }).length,
-  );
+  /** 离线容器数(总数 − 在线) */
+  readonly offlineCount = computed(() => this.live.agents().length - this.onlineCount());
+
+  /**
+   * 卡片上「当前任务」的文案(2026-11-11)。
+   *
+   * 容器上报器会把 `status: running` + `currentTask: "periodic status reporter"`
+   * 硬编码上报,直接展示等于告诉用户「它在干活」,其实什么业务都没做。
+   * 这里只认**真实业务任务**。
+   */
+  readonly liveTaskOf = (a: Agent): string => {
+    if (a.status !== 'running' || isReporterSelfTalk(a)) return '暂无进行中的任务';
+    return a.currentTask?.trim() || a.currentAction?.trim() || '暂无进行中的任务';
+  };
 
   async ngOnInit(): Promise<void> {
     await this.live.ensureLoaded();
@@ -536,7 +573,8 @@ export class DkWorkbenchPage implements OnInit {
    * 工作台列表:搜索 + 状态筛选 + 分页(用户要求统一为「搜索+参数筛选+分页」)。
    */
   readonly keyword = signal('');
-  readonly statusFilter = signal<'all' | 'running' | 'idle' | 'problem'>('all');
+  /** 2026-11-11:筛选收成两态(在线 / 离线) */
+  readonly statusFilter = signal<'all' | 'online' | 'offline'>('all');
 
   private readonly PAGE_SIZE = 12;
   private readonly shown = signal(this.PAGE_SIZE);
@@ -546,14 +584,12 @@ export class DkWorkbenchPage implements OnInit {
   readonly filteredAgents = computed(() => {
     const q = this.keyword().trim().toLowerCase();
     const f = this.statusFilter();
+    const fetchedAt = this.live.fetchedAt();
     return this.live.agents().filter((a) => {
-      /* 2026-11-08 统一口径:按合成态(含心跳)筛,与移动端同一张表 */
-      const s = resolveAgentState({ agent: a }).state;
-      const isRunning = s === 'running' || s === 'replying' || s === 'thinking';
-      const isProblem = s === 'offline' || s === 'error';
-      if (f === 'running' && !isRunning) return false;
-      if (f === 'idle' && s !== 'idle') return false;
-      if (f === 'problem' && !isProblem) return false;
+      /* 判据统一走 livenessOf(心跳 80 分钟 + 通道),与徽标同源 */
+      const online = livenessOf(a, Date.now(), fetchedAt).online;
+      if (f === 'online' && !online) return false;
+      if (f === 'offline' && online) return false;
       if (!q) return true;
       return (
         a.name.toLowerCase().includes(q) || (a.specialty ?? '').toLowerCase().includes(q)
@@ -566,18 +602,11 @@ export class DkWorkbenchPage implements OnInit {
 
   readonly hasMore = computed(() => this.shown() < this.filteredAgents().length);
 
-  readonly statusChips = computed(() => {
-    const all = this.live.agents().map((a) => resolveAgentState({ agent: a }).state);
-    const running = all.filter((s) => s === 'running' || s === 'replying' || s === 'thinking').length;
-    const idle = all.filter((s) => s === 'idle').length;
-    const problem = all.filter((s) => s === 'offline' || s === 'error').length;
-    return [
-      { key: 'all', label: '全部', count: all.length },
-      { key: 'running', label: '执行中', count: running },
-      { key: 'idle', label: '空闲', count: idle },
-      { key: 'problem', label: '异常', count: problem },
-    ];
-  });
+  readonly statusChips = computed(() => [
+    { key: 'all', label: '全部', count: this.live.agents().length },
+    { key: 'online', label: '在线', count: this.onlineCount() },
+    { key: 'offline', label: '离线', count: this.offlineCount() },
+  ]);
 
   onSearch(v: string): void {
     this.keyword.set(v);
@@ -585,7 +614,7 @@ export class DkWorkbenchPage implements OnInit {
   }
 
   onStatusSelect(key: string): void {
-    this.statusFilter.set(key as 'all' | 'running' | 'idle' | 'problem');
+    this.statusFilter.set(key as 'all' | 'online' | 'offline');
     this.resetPaging();
   }
 
@@ -629,6 +658,14 @@ export class DkWorkbenchPage implements OnInit {
     return fmtBalance(a.balance);
   }
 
+  /** 余额悬浮说明:讲清「谁的余额 / 已用多少」 */
+  balanceTip(a: Agent): string {
+    const used = a.balance?.usedCny;
+    const usedText = used == null ? '' : `;已用 ${fmtBalance({ remainCny: used })}`;
+    const owner = a.balance?.sharedAccount ? '主人账号' : '账号';
+    return `${owner}共用余额(容器账单挂在主人账号,非本容器独立额度)${usedText}。点击前往充值`;
+  }
+
   dur(h?: number): string {
     return fmtDuration(h);
   }
@@ -637,22 +674,41 @@ export class DkWorkbenchPage implements OnInit {
     return this.live.agentById(id)?.name ?? '数字生命';
   }
 
-  /** AC-07.4:余额优先外链 */
+  /**
+   * 余额 → 充值页(2026-11-11 修复)。
+   *
+   * 🔴 原来只在 `a.balance?.pageUrl` 有值时跳转,而云函数 `xlosi3m46q`
+   *    **根本不返回 `pageUrl`**(只返回 remainCny/usedCny/source)——
+   *    于是点击余额调用走到 else 分支什么都不做,用户看到的就是「点了没反应」。
+   *
+   * 现在:有 `pageUrl` 就用它(后端将来若下发,优先尊重);
+   * 没有就回落到 Fmode Studio 充值页(与云函数取余额的 `billing/query` 同源)。
+   */
   showBalance(a: Agent): void {
-    if (a.balance?.pageUrl) {
-      window.open(a.balance.pageUrl, '_blank', 'noopener');
-    }
+    const url = a.balance?.pageUrl || DkWorkbenchPage.BALANCE_URL;
+    window.open(url, '_blank', 'noopener');
   }
 
   openChannel(a: Agent): void {
     this.channelAgent.set(a);
   }
 
+  /**
+   * 任务入口(2026-11-11 改)。
+   *
+   * 「任务」页路由已随用户裁决下线(入口和路由一起关),原来跳
+   * `/desktop/tasks`、`/desktop/tasks/:id` 会落到会话页,看起来像点坏了。
+   * 工作台上仍保留这两个按钮,但改为**如实告知**:不假装还能跳过去。
+   */
   openTasks(): void {
-    void this.router.navigate(['/', this.device.mode(), 'tasks']);
+    this.alert.show('任务看板暂未开放', '任务入口已下线,可在会话右栏「任务」页签查看该容器的全部任务。', 'warning');
   }
 
   openAgentTasks(a: Agent): void {
-    void this.router.navigate(['/', this.device.mode(), 'tasks', a.id]);
+    this.alert.show(
+      `「${a.name}」的任务`,
+      '任务看板暂未开放,进入该容器的会话后,可在右栏「任务」页签查看。',
+      'warning',
+    );
   }
 }

+ 22 - 4
src/app/desktop/shell/dk-nav/dk-nav.ts

@@ -21,6 +21,18 @@ interface NavGroup {
 /**
  * 桌面主导航 —— 深墨色面板(视觉锚点,颜色随配色整套变化)
  *
+ * ## ⚠️ 本组件已不在任何界面中挂载(2026-11-11 核实)
+ *
+ * 桌面外壳 `desktop-shell.ts` 引用的是 `SessionTreeComponent`(会话树 + 一级菜单),
+ * **不引用本组件**。真正的左栏菜单数据源是 `core/nav-menu.service.ts` 的 `NAV_CATALOG`。
+ *
+ * 🔴 改这里**不会**影响界面 —— 2026-11-11 曾把「工作台」在这里注释掉,
+ *    界面毫无变化,白改一轮。调整菜单请改 `NAV_CATALOG`。
+ *
+ * 现存价值:`navItemActive` 纯函数被 `session-tree.ts` 复用(判断路由是否激活),
+ *         所以文件保留。改菜单时请同步本文件,避免日后有人从这里「恢复」出
+ *         一套与 NAV_CATALOG 不一致的入口。
+ *
  * 结构调整(2026-10-15 用户反馈):
  *   · **二级分组导航**:业务 / 资产 / 运维 / 我的 —— 解决"导航过长 + 找不到任务看板"
  *   · **补入任务看板入口**(用户反馈:不知道从哪进)
@@ -393,19 +405,25 @@ export class DkNavComponent {
          */
         // { 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' },
+        /*
+         * 🔒 2026-11-11 用户裁决:项目 / 任务 / 事件 / 成果 入口关闭。
+         *    与 `nav-menu.service.ts` 的 NAV_CATALOG 保持一致(本文件是死代码,见类注释)。
+         */
+        // { path: '/projects', label: '项目', icon: 'fa-folder' },
+        // { path: '/events', label: '事件', icon: 'fa-bolt' },
       ],
     },
     {
       label: '资产',
-      items: [{ path: '/deliverables', label: '成果', icon: 'fa-box' }],
+      items: [
+        // { path: '/deliverables', label: '成果', icon: 'fa-box' },
+      ],
     },
     {
       label: '运维',
       items: [
         { path: '/workbench', label: '工作台', icon: 'fa-table-cells-large' },
-        { path: '/tasks', label: '任务看板', icon: 'fa-list-check' },
+        // { path: '/tasks', label: '任务看板', icon: 'fa-list-check' },
       ],
     },
     {

+ 9 - 4
src/app/desktop/shell/dk-top-nav/dk-top-nav.ts

@@ -280,14 +280,19 @@ export class DkTopNavComponent {
   protected readonly theme = inject(ThemeService);
 
   /** 一级路由(D10) */
+  /*
+   * ⚠️ 本组件同样不在界面中挂载(2026-11-11 核实),菜单真源是
+   *    `core/nav-menu.service.ts` 的 `NAV_CATALOG`。这里只与它保持内容一致。
+   *    2026-11-11:项目 / 任务 / 事件 / 成果 入口关闭,工作台恢复。
+   */
   protected readonly items: NavItem[] = [
     { path: '/chat', label: '会话', icon: 'fa-comment' },
-    { path: '/projects', label: '项目', icon: 'fa-folder' },
+    // { path: '/projects', label: '项目', icon: 'fa-folder' },
     { path: '/contacts', label: '通讯录', icon: 'fa-address-book' },
-    { path: '/events', label: '事件', icon: 'fa-bolt' },
-    { path: '/deliverables', label: '成果', icon: 'fa-box' },
+    // { path: '/events', label: '事件', icon: 'fa-bolt' },
+    // { path: '/deliverables', label: '成果', icon: 'fa-box' },
     { path: '/workbench', label: '工作台', icon: 'fa-table-cells-large' },
-    { path: '/tasks', label: '任务', icon: 'fa-list-check' },
+    // { path: '/tasks', label: '任务', icon: 'fa-list-check' },
   ];
 
   protected readonly userMenuOpen = signal(false);

+ 28 - 0
src/app/mobile/components/m-dock/m-dock.ts

@@ -7,6 +7,7 @@ import {
   output,
   viewChild,
 } from '@angular/core';
+import { UploadPhase } from '../../../core/upload-state';
 
 /**
  * 底部输入坞(Demo `.dock`)
@@ -60,6 +61,11 @@ import {
         <div class="m-dock-progress" role="progressbar" [attr.aria-valuenow]="pct()" aria-valuemin="0" aria-valuemax="100">
           <i [style.width.%]="pct()"></i>
         </div>
+        <!--
+          阶段说明(2026-11-11):只有云盘直传有真实百分比,
+          登记/发送阶段如实说在干什么,不再让进度条停在某个数字上不动。
+        -->
+        <p class="m-dock-stage">{{ stageLabel() }}</p>
       }
     </div>
   `,
@@ -158,6 +164,11 @@ import {
         background: var(--primary);
         transition: width var(--motion-detail) var(--ease-standard);
       }
+      .m-dock-stage {
+        margin: var(--sp-1) 0 0;
+        color: var(--muted);
+        font-size: var(--fs-11);
+      }
     `,
   ],
 })
@@ -170,6 +181,23 @@ export class MDockComponent {
   readonly sending = input(false);
   readonly uploading = input(false);
   readonly pct = input(0);
+  /** 整批上传阶段(2026-11-11),与桌面 composer 同口径 */
+  readonly stage = input<UploadPhase>('uploading');
+
+  protected readonly stageLabel = computed(() => {
+    switch (this.stage()) {
+      case 'registering':
+        return '上传完成,正在登记…';
+      case 'sending':
+        return '正在发送给容器…';
+      case 'done':
+        return '已发送';
+      case 'failed':
+        return '部分附件上传失败';
+      default:
+        return `正在上传… ${this.pct()}%`;
+    }
+  });
 
   readonly draftChange = output<string>();
   readonly send = output<string>();

+ 11 - 16
src/app/mobile/components/m-drawer/m-drawer.ts

@@ -13,8 +13,6 @@ import { NavMenuService } from '../../../core/nav-menu.service';
 import { MNavCustomComponent } from '../m-nav-custom/m-nav-custom';
 import { UiMDialog } from '../../../ui/mobile/components/dialog/dialog';
 import { PromptModalService } from '../../../components/domain/prompt-modal/prompt-modal.service';
-import { SessionService } from '../../../core/session.service';
-import { canManageCompany } from '../../../core/acl';
 import { LockScrollDirective } from '../../directives/lock-scroll.directive';
 
 /** 抽屉布局常量(与 PC 左栏 `SessionTreeComponent.PRIMARY_COUNT` 对齐) */
@@ -121,14 +119,12 @@ interface NavRow {
                   <i class="fa-solid fa-chevron-right m-chev" aria-hidden="true"></i>
                 </button>
               }
-              <!-- 企业管理(2026-11-04):仅公司管理员 / 系统超管可见 -->
-              @if (canManage()) {
-                <button type="button" class="m-entry m-entry--sub" (click)="go('/admin')">
-                  <span class="m-entry-lead"><i class="fa-solid fa-building-shield" aria-hidden="true"></i></span>
-                  <b>企业管理</b>
-                  <i class="fa-solid fa-chevron-right m-chev" aria-hidden="true"></i>
-                </button>
-              }
+              <!--
+                企业管理入口已移除(2026-11-11 用户裁决)。
+                落地页 /mobile/admin 下线,成员管理改由菜单目录
+                (NAV_CATALOG 的 admin-users)直接指向 /admin/users,
+                不再经过中间页。管理员仍可在「更多」里看到「成员管理」。
+              -->
               <!-- 编辑菜单(2026-11-03 用户要求:收进「更多」,不占一级位置) -->
               <button type="button" class="m-entry m-entry--sub" (click)="customOpen.set(true)">
                 <span class="m-entry-lead"><i class="fa-solid fa-sliders" aria-hidden="true"></i></span>
@@ -201,8 +197,11 @@ interface NavRow {
           <p class="m-tree-empty">还没有工作区,点「新建」按项目分开存放对话</p>
         }
 
-        <!-- ①·群 群聊(2026-11-05):夹在工作区与日常之间 -->
-        @if (groupSessions().length > 0) {
+        <!--
+          🔒 2026-11-11 用户裁决:群聊入口整体隐藏(PC + 移动端一致)。
+          恢复:把下面的 false && 去掉,并同步恢复 session-tree.ts 的对应分组。
+        -->
+        @if (false && groupSessions().length > 0) {
           <section>
             <button
               type="button"
@@ -861,10 +860,6 @@ export class MDrawerComponent {
   private readonly workspacesSvc = inject(WorkspaceService);
   private readonly navMenu = inject(NavMenuService);
   private readonly prompt = inject(PromptModalService);
-  private readonly session = inject(SessionService);
-
-  /** 是否显示「企业管理」入口(仅公司管理员 / 系统超管) */
-  protected readonly canManage = computed(() => canManageCompany(this.session.me()));
 
   /** 编辑菜单弹层 */
   protected readonly customOpen = signal(false);

+ 20 - 10
src/app/mobile/components/m-life-card/m-life-card.ts

@@ -1,6 +1,6 @@
 import { ChangeDetectionStrategy, Component, computed, input, output } from '@angular/core';
 import { Agent } from '../../../core/api/models';
-import { resolveAgentState } from '../../../core/agent-state';
+import { isReporterSelfTalk, livenessOf } from '../../../core/agent-liveness';
 import { MAvatarComponent } from '../m-avatar/m-avatar';
 import { BadgeTone, MBadgeComponent } from '../m-badge/m-badge';
 
@@ -25,7 +25,7 @@ import { BadgeTone, MBadgeComponent } from '../m-badge/m-badge';
         <app-m-badge [tone]="tone()" [dot]="true">{{ statusText() }}</app-m-badge>
       </header>
 
-      <div class="m-cur">{{ agent().currentTask || '暂无进行中的任务' }}</div>
+      <div class="m-cur">{{ taskText() }}</div>
 
       @if (hasMetrics()) {
         <div class="m-metric">
@@ -176,15 +176,25 @@ export class MLifeCardComponent {
   readonly recharge = output<Agent>();
 
   /**
-   * 容器状态(2026-11-08 统一口径)。
+   * 容器状态(2026-11-11 收敛)。
    *
-   * 🔴 原先自建一套文案(running 叫「运行中」)+ `AGENT_TONE`:
-   *    · 与聊天页、会话列表、通讯录的说法都不一致
-   *    · 不看心跳 → 僵尸容器显示绿色「运行中」
-   * 现在全站共用 `resolveAgentState`。
+   * 工作台与其他列表页一样只表达「在线 / 离线」,
+   * 判据统一走 `livenessOf()`(心跳 80 分钟 + Matrix 通道),
+   * 与 PC 工作台卡片完全同源。
    */
-  private readonly state = computed(() => resolveAgentState({ agent: this.agent() }));
-  protected readonly tone = computed<BadgeTone>(() => this.state().tone as BadgeTone);
-  protected readonly statusText = computed(() => this.state().text);
+  private readonly live = computed(() => livenessOf(this.agent()));
+  protected readonly tone = computed<BadgeTone>(() => this.live().tone as BadgeTone);
+  protected readonly statusText = computed(() => this.live().text);
+
+  /**
+   * 当前任务(2026-11-11)。
+   * 上报器自指时不展示(会把 `periodic status reporter` 印在卡片上)。
+   */
+  protected readonly taskText = computed(() => {
+    const a = this.agent();
+    if (a.status !== 'running' || isReporterSelfTalk(a)) return '暂无进行中的任务';
+    return a.currentTask?.trim() || a.currentAction?.trim() || '暂无进行中的任务';
+  });
+
   protected readonly hasMetrics = computed(() => !!this.agent().host && this.agent().matrixReady);
 }

+ 7 - 1
src/app/mobile/components/m-new-chat/m-new-chat.ts

@@ -38,7 +38,12 @@ import { LockScrollDirective } from '../../directives/lock-scroll.directive';
             </button>
           </header>
 
-          <!-- 分组:单聊(默认)/ 群聊(2026-11-05) -->
+          <!--
+            分组:单聊(默认)/ 群聊(2026-11-05)
+            🔒 2026-11-11 用户裁决:群聊下线,只剩单聊 → 整个分段控件隐藏
+            (只留一个选项的分段控件是纯噪音)。恢复时把 @if (false) 改回 @if (true)。
+          -->
+          @if (false) {
           <div class="m-seg-row">
             <app-m-segmented
               [options]="modes"
@@ -46,6 +51,7 @@ import { LockScrollDirective } from '../../directives/lock-scroll.directive';
               (valueChange)="onMode($event)"
             />
           </div>
+          }
 
           <div class="m-sheet-body">
             @if (mode() === 'dm') {

+ 37 - 15
src/app/mobile/mobile.routes.ts

@@ -16,31 +16,46 @@ export const mobileRoutes: Routes = [
   // 多条会话:同一容器下的某条对话(`:sessionId` = 线程根)
   { path: 'chat/:id/:sessionId', loadComponent: () => import('./pages/chat/chat').then((m) => m.MbChatPage) },
   /*
-   * 企业管理(2026-11-04):仅公司管理员 / 系统超管可见。
-   * 守卫挡在路由层,菜单也按同一判据隐藏(双层,避免直达链接先闪页面)。
+   * 企业管理落地页(2026-11-11 用户裁决:**下线**)。
+   *
+   * 这一页只是「成员管理」的一个中转入口(两个按钮),功能已被
+   * `/admin/users` 完全覆盖,抽屉里的「成员管理」也直接指向后者。
+   * 保留它只会让用户从成员管理返回时多跳一层。
+   *
+   * 改重定向而非直接删路由:旧链接/书签不白屏,直接落到成员管理。
    */
-  {
-    path: 'admin',
-    canActivate: [adminGuard],
-    loadComponent: () => import('./pages/admin/admin').then((m) => m.MbAdminPage),
-  },
+  { path: 'admin', redirectTo: 'admin/users', pathMatch: 'full' },
   {
     /* 成员管理(2026-11-10)。鉴权同样在云函数侧,这里只挡入口 */
     path: 'admin/users',
     canActivate: [adminGuard],
     loadComponent: () => import('./pages/admin/users/users').then((m) => m.MbAdminUsersPage),
   },
-  { path: 'projects', loadComponent: () => import('./pages/projects/projects').then((m) => m.MbProjectsPage) },
-  { path: 'projects/:id', loadComponent: () => import('./pages/projects/projects').then((m) => m.MbProjectsPage) },
+  /*
+   * 🔒 2026-11-11 用户裁决:项目 / 任务 / 事件 / 成果 四个入口与路由一起关闭。
+   * 改重定向而非删除路由:旧链接不留白屏,统一回会话列表。
+   * 页面组件源码保留,恢复时换回注释里的 `loadComponent`,
+   * 并同步恢复 `core/nav-menu.service.ts` 的 `NAV_CATALOG` 菜单项。
+   */
+  { path: 'projects', redirectTo: 'messages', pathMatch: 'full' },
+  // { path: 'projects', loadComponent: () => import('./pages/projects/projects').then((m) => m.MbProjectsPage) },
+  { path: 'projects/:id', redirectTo: 'messages', pathMatch: 'full' },
+  // { path: 'projects/:id', loadComponent: () => import('./pages/projects/projects').then((m) => m.MbProjectsPage) },
   /* 容器设置(2026-11-04):从通讯录点容器进入,只提供重命名 */
   { path: 'agent/:id', loadComponent: () => import('./pages/agent/agent').then((m) => m.MbAgentPage) },
   /* 群聊(伪群聊,2026-11-05):独立页面,与单聊状态模型不同 */
-  { path: 'group/:groupId', loadComponent: () => import('./pages/group-chat/group-chat').then((m) => m.MbGroupChatPage) },
+  /* 🔒 群聊(2026-11-11 用户裁决:入口整体隐藏)。恢复见 desktop.routes.ts 同处注释 */
+  { path: 'group/:groupId', redirectTo: 'messages', pathMatch: 'full' },
+  // { path: 'group/:groupId', loadComponent: () => import('./pages/group-chat/group-chat').then((m) => m.MbGroupChatPage) },
   // 任务列表与详情共用同一组件(详情态由路由参数切换,与项目页同法)
-  { path: 'tasks', loadComponent: () => import('./pages/tasks/tasks').then((m) => m.MbTasksPage) },
-  { path: 'tasks/:id', loadComponent: () => import('./pages/tasks/tasks').then((m) => m.MbTasksPage) },
-  { path: 'events', loadComponent: () => import('./pages/events/events').then((m) => m.MbEventsPage) },
-  { path: 'deliverables', loadComponent: () => import('./pages/deliverables/deliverables').then((m) => m.MbDeliverablesPage) },
+  { path: 'tasks', redirectTo: 'messages', pathMatch: 'full' },
+  // { path: 'tasks', loadComponent: () => import('./pages/tasks/tasks').then((m) => m.MbTasksPage) },
+  { path: 'tasks/:id', redirectTo: 'messages', pathMatch: 'full' },
+  // { path: 'tasks/:id', loadComponent: () => import('./pages/tasks/tasks').then((m) => m.MbTasksPage) },
+  { path: 'events', redirectTo: 'messages', pathMatch: 'full' },
+  // { path: 'events', loadComponent: () => import('./pages/events/events').then((m) => m.MbEventsPage) },
+  { path: 'deliverables', redirectTo: 'messages', pathMatch: 'full' },
+  // { path: 'deliverables', loadComponent: () => import('./pages/deliverables/deliverables').then((m) => m.MbDeliverablesPage) },
   // ---- 带 TabBar 的常规页面 ----
   {
     path: '',
@@ -49,7 +64,14 @@ export const mobileRoutes: Routes = [
       { path: '', pathMatch: 'full', redirectTo: 'messages' },
       { path: 'messages', loadComponent: () => import('./pages/messages/messages').then((m) => m.MbMessagesPage) },
       { path: 'contacts', loadComponent: () => import('./pages/contacts/contacts').then((m) => m.MbContactsPage) },
-      { path: 'workbench', loadComponent: () => import('./pages/workbench/workbench').then((m) => m.MbWorkbenchPage) },
+      /*
+       * 工作台(2026-11-11 恢复)。
+       * 云函数 `xlosi3m46q` v0.0.9 修正余额取数口径后重新开放。
+       */
+      {
+        path: 'workbench',
+        loadComponent: () => import('./pages/workbench/workbench').then((m) => m.MbWorkbenchPage),
+      },
       { path: 'mine', loadComponent: () => import('./pages/mine/mine').then((m) => m.MbMinePage) },
       // 🔴 此处**不能**使用 `**` 兜底:会吞掉 shell 外的全屏路由
     ],

+ 0 - 444
src/app/mobile/pages/admin/admin.ts

@@ -1,444 +0,0 @@
-import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core';
-import { Router } from '@angular/router';
-import { canManageCompany, resolveScope } from '../../../core/acl';
-import { SessionService } from '../../../core/session.service';
-import { ContactsService } from '../../../core/contacts.service';
-import { Agent, Person } from '../../../core/api/models';
-import { MAppBarComponent } from '../../components/m-app-bar/m-app-bar';
-import { MAvatarComponent } from '../../components/m-avatar/m-avatar';
-import { MEmptyComponent } from '../../components/m-empty/m-empty';
-import { AgentRenameService } from '../../../core/agent-rename.service';
-import { PromptModalService } from '../../../components/domain/prompt-modal/prompt-modal.service';
-
-/**
- * 企业管理(**预留页**,2026-11-04)
- *
- * ## 现在做什么
- *
- * 只搭结构、不实现功能:
- *   · 入口只对公司管理员 / 系统超管可见(`canManageCompany`)
- *   · 页面按「公司 → 部门」展示组织架构骨架(部门维度暂为「未分组」)
- *   · 各项管理能力以**明确的「尚未开放」**呈现,不做假开关
- *
- * ## 为什么不做成"能点但没用"
- *
- * 容器管理(配额、权限、部门归属、跨用户授权)依赖容器侧的写入能力,
- * 目前只有本地对象层面的改动、不会真正影响容器。
- * 做成可点但无效的开关,会让管理员以为自己改了实际配置 —— 比不提供更糟。
- *
- * ## 后续接入点
- *
- * 1. 部门维度:`Person.department` / `Agent.department` 有值即自动分组(模板已就绪)
- * 2. 容器管理:在「容器清单」行内加操作菜单,调服务端接口
- * 3. 成员管理:在「同事」行内加角色/部门调整
- */
-@Component({
-  selector: 'app-mb-admin-page',
-  changeDetection: ChangeDetectionStrategy.OnPush,
-  imports: [MAppBarComponent, MAvatarComponent, MEmptyComponent],
-  template: `
-    <div class="m-page">
-      <app-m-app-bar title="企业管理" [back]="true" (backTap)="back()" />
-
-      <div class="m-body">
-        @if (!canManage()) {
-          <app-m-empty
-            icon="fa-solid fa-lock"
-            title="无访问权限"
-            description="仅企业管理员可进入此页面"
-          />
-        } @else {
-          <section class="m-section">
-            <div class="m-card m-profile">
-              <span class="m-role">{{ roleLabel() }}</span>
-              <div class="m-profile-body">
-                <div class="m-profile-name">{{ me()?.companyName || '未归属公司' }}</div>
-                <div class="m-profile-sub">{{ me()?.name || '—' }} · {{ me()?.mobile || '—' }}</div>
-              </div>
-            </div>
-          </section>
-
-          <section class="m-section">
-            <h2 class="m-section-title">组织架构</h2>
-            @for (g of groups(); track g.companyId ?? 'none') {
-              <div class="m-card m-org">
-                <div class="m-org-head">
-                  <i class="fa-solid fa-building" aria-hidden="true"></i>
-                  <b>{{ g.companyName }}</b>
-                  <span class="m-org-count">
-                    {{ countPeople(g) }} 位同事 · {{ countAgents(g) }} 个容器
-                  </span>
-                </div>
-                @for (d of g.departments; track d.name) {
-                  <div class="m-dept">
-                    <div class="m-dept-head">
-                      <i class="fa-solid fa-sitemap" aria-hidden="true"></i>
-                      {{ d.name }}
-                      @if (d.name === '未分组') {
-                        <span class="m-dept-hint">部门维度待接入</span>
-                      }
-                    </div>
-                    @for (p of d.people; track p.id) {
-                      <div class="m-row">
-                        <app-m-avatar [name]="p.name" tone="person" size="sm" />
-                        <span class="m-row-body">
-                          <span class="m-row-t1">{{ p.name }}</span>
-                          <span class="m-row-t2">
-                            {{ p.mobile || '—' }} · {{ p.agents?.length || 0 }} 个容器
-                          </span>
-                        </span>
-                      </div>
-                    }
-                    @for (a of d.agents; track a.id) {
-                      <div class="m-row">
-                        <app-m-avatar [name]="a.name" size="sm" />
-                        <span class="m-row-body">
-                          <span class="m-row-t1">{{ a.name }}</span>
-                          <span class="m-row-t2">{{ a.specialty || '数字生命' }}</span>
-                        </span>
-                        <!-- 重命名:仅管理员可见(普通成员看不到这个按钮) -->
-                        <button
-                          type="button"
-                          class="m-rename"
-                          [attr.aria-label]="'重命名 ' + a.name"
-                          [disabled]="renameSvc.busy() === a.id"
-                          (click)="rename(a)"
-                        >
-                          <i class="fa-solid fa-pen" aria-hidden="true"></i>
-                        </button>
-                      </div>
-                    } @empty {
-                      @if (d.people.length === 0) {
-                        <p class="m-dept-empty">该部门暂无成员</p>
-                      }
-                    }
-                  </div>
-                }
-              </div>
-            } @empty {
-              <app-m-empty title="暂无组织数据" icon="fa-solid fa-building" />
-            }
-          </section>
-
-          <!-- 成员管理(2026-11-10 已开放):其余能力仍标「尚未开放」,不做假开关 -->
-          <section class="m-section">
-            <h2 class="m-section-title">成员</h2>
-            <ul class="m-list">
-              <li>
-                <button type="button" class="m-row" (click)="goMembers()">
-                  <span class="m-row-body">
-                    <span class="m-row-t1">成员管理</span>
-                    <span class="m-row-t2">新增、改资料、重置密码、移除组织成员</span>
-                  </span>
-                  <i class="fa-solid fa-chevron-right m-chev" aria-hidden="true"></i>
-                </button>
-              </li>
-            </ul>
-          </section>
-
-          <!-- 管理能力:明确标注未开放,不做假开关 -->
-          <section class="m-section">
-            <h2 class="m-section-title">管理能力</h2>
-            <ul class="m-list">
-              @for (cap of capabilities; track cap.key) {
-                <li class="m-row m-row--cap">
-                  <span class="m-row-body">
-                    <span class="m-row-t1">{{ cap.label }}</span>
-                    <span class="m-row-t2">{{ cap.desc }}</span>
-                  </span>
-                  <span class="m-badge-soon">尚未开放</span>
-                </li>
-              }
-            </ul>
-          </section>
-        }
-      </div>
-    </div>
-  `,
-  styles: [
-    `
-      :host {
-        display: block;
-        background: var(--canvas);
-      }
-      .m-page {
-        display: flex;
-        flex-direction: column;
-        height: 100dvh;
-        box-sizing: border-box;
-      }
-      .m-body {
-        flex: 1;
-        min-height: 0;
-        overflow-y: auto;
-        -webkit-overflow-scrolling: touch;
-        padding-bottom: calc(var(--mob-safe-bottom) + var(--sp-6));
-      }
-      .m-section {
-        padding: 0 var(--mob-gutter);
-      }
-      .m-section + .m-section {
-        margin-top: var(--sp-6);
-      }
-      .m-section:first-of-type {
-        margin-top: var(--sp-3);
-      }
-      .m-section-title {
-        margin: 0 0 var(--sp-3);
-        color: var(--muted);
-        font-size: var(--fs-13);
-        font-weight: 400;
-      }
-      .m-card {
-        padding: var(--sp-4);
-        border: 1px solid var(--edge);
-        border-radius: var(--r-lg);
-        background: var(--surface);
-        box-shadow: var(--shadow-soft);
-      }
-      .m-profile {
-        display: flex;
-        align-items: center;
-        gap: var(--sp-3);
-      }
-      .m-role {
-        flex: none;
-        padding: var(--sp-1) var(--sp-2);
-        border-radius: var(--r-sm);
-        background: var(--primary-subtle);
-        color: var(--primary);
-        font-size: var(--fs-11);
-        font-weight: 600;
-      }
-      .m-profile-body {
-        min-width: 0;
-      }
-      .m-profile-name {
-        color: var(--ink);
-        font-size: var(--fs-17);
-        font-weight: 600;
-      }
-      .m-profile-sub {
-        color: var(--muted);
-        font-size: var(--fs-13);
-      }
-      .m-org + .m-org {
-        margin-top: var(--sp-3);
-      }
-      .m-org-head {
-        display: flex;
-        align-items: center;
-        gap: var(--sp-2);
-        padding-bottom: var(--sp-3);
-        border-bottom: 1px solid var(--edge);
-      }
-      .m-org-head > i {
-        color: var(--muted);
-      }
-      .m-org-head b {
-        flex: 1;
-        min-width: 0;
-        overflow: hidden;
-        text-overflow: ellipsis;
-        white-space: nowrap;
-        font-size: var(--fs-15);
-      }
-      .m-org-count {
-        flex: none;
-        color: var(--muted);
-        font-size: var(--fs-11);
-      }
-      .m-dept {
-        margin-top: var(--sp-3);
-      }
-      .m-dept-head {
-        display: flex;
-        align-items: center;
-        gap: var(--sp-2);
-        margin-bottom: var(--sp-1);
-        color: var(--ink-2);
-        font-size: var(--fs-13);
-        font-weight: 600;
-      }
-      .m-dept-head > i {
-        color: var(--muted);
-        font-size: var(--fs-11);
-      }
-      .m-dept-hint {
-        margin-left: auto;
-        color: var(--muted);
-        font-size: var(--fs-11);
-        font-weight: 400;
-      }
-      .m-dept-empty {
-        margin: 0;
-        padding: var(--sp-2) 0 var(--sp-2) var(--sp-5);
-        color: var(--muted);
-        font-size: var(--fs-12);
-      }
-      .m-row {
-        display: flex;
-        align-items: center;
-        gap: var(--sp-3);
-        min-height: 48px;
-        padding: var(--sp-2) 0;
-      }
-      .m-row--cap {
-        padding: var(--sp-3) var(--sp-4);
-        min-height: 56px;
-      }
-      .m-row-body {
-        flex: 1;
-        min-width: 0;
-      }
-      .m-row-t1 {
-        display: block;
-        color: var(--ink);
-        font-size: var(--fs-15);
-      }
-      .m-row-t2 {
-        display: block;
-        color: var(--muted);
-        font-size: var(--fs-13);
-      }
-      .m-list {
-        margin: 0;
-        padding: 0;
-        list-style: none;
-        border: 1px solid var(--edge);
-        border-radius: var(--r-lg);
-        background: var(--surface);
-        overflow: hidden;
-      }
-      .m-list > li + li {
-        border-top: 1px solid var(--edge);
-      }
-      .m-rename {
-        display: grid;
-        place-items: center;
-        flex: none;
-        width: 32px;
-        height: 32px;
-        border: 0;
-        border-radius: var(--r-sm);
-        background: none;
-        color: var(--muted);
-        font-size: var(--fs-12);
-        cursor: pointer;
-      }
-      .m-rename:active {
-        background: var(--surface-hover);
-      }
-      .m-rename:disabled {
-        opacity: 0.4;
-      }
-      .m-toast {
-        position: fixed;
-        left: 50%;
-        bottom: calc(var(--mob-tabbar-h) + var(--sp-6));
-        transform: translateX(-50%);
-        z-index: var(--z-toast);
-        max-width: 80vw;
-        padding: var(--sp-2) var(--sp-4);
-        border-radius: var(--r-md);
-        background: var(--ink);
-        color: var(--canvas);
-        font-size: var(--fs-13);
-        text-align: center;
-        box-shadow: var(--shadow-pop);
-      }
-      .m-badge-soon {
-        flex: none;
-        padding: var(--sp-1) var(--sp-2);
-        border-radius: var(--r-sm);
-        background: var(--neutral-soft);
-        color: var(--neutral);
-        font-size: var(--fs-11);
-      }
-    `,
-  ],
-})
-export class MbAdminPage {
-  private readonly router = inject(Router);
-  private readonly session = inject(SessionService);
-  private readonly contacts = inject(ContactsService);
-  /** 重命名服务(模板要访问 busy,故为 protected) */
-  protected readonly renameSvc = inject(AgentRenameService);
-  private readonly prompt = inject(PromptModalService);
-
-  protected readonly me = this.session.me;
-  protected readonly canManage = computed(() => canManageCompany(this.session.me()));
-  protected readonly roleLabel = computed(() => resolveScope(this.session.me()).label);
-  protected readonly groups = computed(() => this.contacts.companyGroups());
-
-  /** 管理能力清单(预留,均标注「尚未开放」) */
-  protected readonly capabilities = [
-    { key: 'quota', label: '容器配额与计费', desc: '统一设置部门容器的资源与用量上限' },
-    { key: 'dept', label: '部门归属', desc: '把容器划到具体部门,供部门内共享' },
-    { key: 'perm', label: '容器权限', desc: '配置谁可以与该容器对话、谁可以管理' },
-    {
-      key: 'member',
-      label: '成员细粒度权限',
-      desc: '按成员或部门单独授权:可读 / 可对话 / 可管理,逐容器生效',
-    },
-    { key: 'audit', label: '使用审计', desc: '查看容器使用记录与异常告警' },
-  ];
-
-  /**
-   * 重命名容器(2026-11-04)。
-   *
-   * 结果写回**服务端**:容器名是同公司跨用户可见的,
-   * 存本地会导致「我这里改了、同事看到的还是旧名」。
-   * 服务端接口就绪前调用会失败,界面如实提示 —— 不做本地假象。
-   */
-  /** 进入成员管理页(2026-11-10) */
-  protected goMembers(): void {
-    void this.router.navigateByUrl('/mobile/admin/users');
-  }
-
-  protected rename(agent: Agent): void {
-    this.prompt.open({
-      title: '重命名容器',
-      value: agent.name,
-      placeholder: '新的容器名称',
-      allowEmpty: false,
-      confirmText: '保存',
-      validate: (v) => {
-        const n = v.trim();
-        if (!n) return '名称不能为空';
-        if (n.length > 24) return '名称不能超过 24 个字符';
-        if (n === agent.name) return '名称没有变化';
-        return null;
-      },
-      onConfirm: (v) => {
-        void this.renameSvc
-          .rename(agent.id, v.trim())
-          .catch((e: unknown) => {
-            this.toast(e instanceof Error ? e.message : '重命名失败');
-          });
-      },
-    });
-  }
-
-  /** 轻量错误提示(复用现有 toast,不额外引入依赖) */
-  private toast(msg: string): void {
-    const el = document.createElement('div');
-    el.className = 'm-toast';
-    el.textContent = msg;
-    document.body.appendChild(el);
-    setTimeout(() => el.remove(), 2400);
-  }
-
-  protected countPeople(g: { departments: { people: Person[] }[] }): number {
-    return g.departments.reduce((n, d) => n + d.people.length, 0);
-  }
-
-  protected countAgents(g: { departments: { agents: Agent[] }[] }): number {
-    return g.departments.reduce((n, d) => n + d.agents.length, 0);
-  }
-
-  protected back(): void {
-    if (history.length > 1) history.back();
-    else void this.router.navigateByUrl('/mobile/mine');
-  }
-}

+ 11 - 1
src/app/mobile/pages/admin/users/users.ts

@@ -420,8 +420,18 @@ export class MbAdminUsersPage implements OnInit {
     await this.svc.ensureLoaded();
   }
 
+  /**
+   * 返回(2026-11-11 改)。
+   *
+   * 企业管理落地页 `/mobile/admin` 已下线,原来固定跳回它会让用户落到
+   * 一个已不存在的中间页。改为**回到来处**:
+   *   · 从抽屉「成员管理」直接进来 → 回会话列表
+   *   · 从别处(将来可能有)进来 → 正常后退
+   * 与移动端其他详情页(如 `pages/agent`)的返回写法保持同一口径。
+   */
   protected back(): void {
-    void this.router.navigateByUrl('/mobile/admin');
+    if (history.length > 1) history.back();
+    else void this.router.navigateByUrl('/mobile/messages');
   }
 
   protected async reload(): Promise<void> {

+ 9 - 0
src/app/mobile/pages/agent/agent.ts

@@ -1,6 +1,8 @@
 import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core';
 import { ActivatedRoute, Router } from '@angular/router';
 import { Agent } from '../../../core/api/models';
+import { AlertService } from '../../../core/alert.service';
+import { blockedHint, livenessOf } from '../../../core/agent-liveness';
 import { AgentRenameService } from '../../../core/agent-rename.service';
 import { LiveService } from '../../../core/live.service';
 import { MAppBarComponent } from '../../components/m-app-bar/m-app-bar';
@@ -285,6 +287,7 @@ import { MEmptyComponent } from '../../components/m-empty/m-empty';
 export class MbAgentPage {
   private readonly route = inject(ActivatedRoute);
   private readonly router = inject(Router);
+  private readonly alert = inject(AlertService);
   protected readonly live = inject(LiveService);
   protected readonly renameSvc = inject(AgentRenameService);
 
@@ -351,6 +354,12 @@ export class MbAgentPage {
    * 与「新建对话」弹层选中该容器等价(`/chat/:id/new`,不带 ws 参数)。
    */
   protected enterChat(agent: Agent): void {
+    /* 2026-11-11:未接入 / 离线拦下,避免进到发不出消息的会话 */
+    const hint = blockedHint(livenessOf(agent));
+    if (hint) {
+      this.alert.show('暂时无法进入会话', hint, 'warning');
+      return;
+    }
     void this.router.navigateByUrl(`/mobile/chat/${agent.id}/new`);
   }
 

+ 128 - 23
src/app/mobile/pages/chat/chat.ts

@@ -37,10 +37,18 @@ import {
   toolActivitySummary,
 } from '../../../core/message-segments';
 import { Message } from '../../../core/api/models';
-import { resolveAgentState } from '../../../core/agent-state';
+import { sessionPresence } from '../../../core/agent-liveness';
+import { fileIdentity } from '../../../core/s3-link';
 import { mergeConsecutiveBodies } from '../../../core/message-merge';
 import { ToolActivityComponent } from '../../../components/domain/tool-activity/tool-activity';
-import { AssistantTurn, isTurnTimedOut, TURN_TIMEOUT_MS, turnsFromDm } from '../../../core/assistant-turn';
+import {
+  AssistantTurn,
+  isTurnStalled,
+  isTurnTimedOut,
+  STALL_MS,
+  TURN_TIMEOUT_MS,
+  turnsFromDm,
+} from '../../../core/assistant-turn';
 import { AlertService } from '../../../core/alert.service';
 import { AssistantTurnComponent } from '../../../components/domain/assistant-turn/assistant-turn';
 
@@ -267,6 +275,7 @@ type SheetTab = 'task' | 'file' | 'deliver' | 'state';
           [sending]="chat.sending()"
           [uploading]="chat.uploading()"
           [pct]="chat.uploadPct()"
+          [stage]="chat.uploadPhase()"
           [target]="agentName()"
           [hasAttachments]="chat.pendingFiles().length > 0"
           [placeholder]="'发消息给 ' + agentName()"
@@ -327,7 +336,12 @@ type SheetTab = 'task' | 'file' | 'deliver' | 'state';
                   <ul class="m-list">
                     @for (t of relatedTasks(); track t.id) {
                       <li>
-                        <button type="button" class="m-row" (click)="goTask(t.id)">
+                        <button
+                          type="button"
+                          class="m-row"
+                          [class.hl]="railHighlight() === t.id"
+                          (click)="goTask(t.id)"
+                        >
                           <span class="m-row-body">
                             <span class="m-row-t1">{{ t.title }}</span>
                             <span class="m-row-t2">{{ t.updatedAt || '—' }}</span>
@@ -786,6 +800,11 @@ type SheetTab = 'task' | 'file' | 'deliver' | 'state';
         text-decoration: none;
         cursor: pointer;
       }
+      /* 深链定位高亮(2026-11-11):任务引用点进来时短暂标出是哪条 */
+      .m-row.hl {
+        background: var(--surface-hover);
+        box-shadow: inset 3px 0 0 var(--primary);
+      }
       .m-row-body {
         flex: 1;
         min-width: 0;
@@ -856,6 +875,15 @@ export class MbChatPage implements OnDestroy {
   /** 其余文件走全屏预览 */
   protected readonly preview = signal<{ url: string; name: string; mimeType: string } | null>(null);
   protected readonly sheetTab = signal<SheetTab>('task');
+
+  /**
+   * 抽屉里需要高亮定位的任务 id(2026-11-11)。
+   *
+   * 「任务」页路由下线后,消息里的任务引用改为「打开抽屉 + 定位」,
+   * 与桌面 `openRailAt()` 同口径,2 秒后自动清空。
+   */
+  protected readonly railHighlight = signal<string | null>(null);
+  private railHighlightTimer: ReturnType<typeof setTimeout> | null = null;
   /** 抽屉在本页不使用(返回即可),保留导入以便后续「会话切换」扩展 */
   protected readonly drawerOpen = signal(false);
 
@@ -971,13 +999,21 @@ export class MbChatPage implements OnDestroy {
     const hasOutput = last.blocks.some((b) => b.body.length > 0 || b.steps.length > 0);
     const timedOut = userMs > 0 && isTurnTimedOut(Date.now() - userMs, hasOutput, last.live);
 
-    if (timedOut) {
+    /* 卡住检测(2026-11-11,与桌面同口径):回了一部分就停住的情况 */
+    const lastActivity = last.blocks.reduce((max, b) => Math.max(max, b.updatedAt), 0);
+    const idleMs = lastActivity > 0 ? Date.now() - lastActivity : 0;
+    const stalled = isTurnStalled(idleMs, hasOutput, last.live);
+
+    if (timedOut || stalled) {
       this._handledTimeouts.add(last.id);
       this.alert.resetDismiss(MbChatPage.TIMEOUT_TITLE);
       this.alert.syncState(
         MbChatPage.TIMEOUT_TITLE,
         true,
-        `最近一条消息已超过 ${Math.round(TURN_TIMEOUT_MS / 1000)} 秒没有回复。请确认容器是否在线,或重试。`,
+        stalled
+          ? `容器已停止响应超过 ${Math.round(STALL_MS / 1000)} 秒,任务可能已中断。可点「重新发送」把刚才的消息放回输入框。`
+          : `最近一条消息已超过 ${Math.round(TURN_TIMEOUT_MS / 1000)} 秒没有回复。请确认容器是否在线,或点「重新发送」重试。`,
+        { label: '重新发送' },
       );
       return;
     }
@@ -985,6 +1021,14 @@ export class MbChatPage implements OnDestroy {
     this.alert.syncState(MbChatPage.TIMEOUT_TITLE, false);
   }
 
+  /** 把最后一条用户消息放回输入框(2026-11-11,与桌面同口径,不自动发送) */
+  private resendLast(): void {
+    const mine = [...this.chat.messages()].reverse().find((m) => m.sender === 'me' && m.text?.trim());
+    if (!mine?.text) return;
+    this.chat.setDraft(mine.text);
+    this.alert.show('已放回输入框', '确认内容无误后再点发送即可。', 'warning');
+  }
+
   /**
    * 已处理过(提示过)的超时回合 id。
    *
@@ -1015,17 +1059,27 @@ export class MbChatPage implements OnDestroy {
   protected readonly title = computed(
     () => this.chat.currentSession()?.title ?? this.chat.agent()?.name ?? '会话',
   );
-  /** 容器名 + 状态(2026-11-08 统一口径):全站同一套 resolveAgentState */
+  /**
+   * 容器名 + 状态(2026-11-11 收敛)。
+   *
+   * 与 PC 会话页头同一判据 `sessionPresence`:
+   * 在线/离线走 `livenessOf`,本会话是否在回复单独叠加。
+   * 离线时相位一律不显示(不能说明明断了还在回复)。
+   */
+  protected readonly presence = computed(() =>
+    sessionPresence(this.chat.agent(), this.chat.peerTyping() ? 'replying' : undefined),
+  );
+
   protected readonly subtitle = computed(() => {
     const a = this.chat.agent();
     if (!a) return '';
-    return `${a.name} · ${resolveAgentState({ agent: a }).text}`;
+    return `${a.name} · ${this.presence().text}`;
   });
 
-  /** 状态抽屉用同一个口径(不自拼 statusText,英文 id 不外露) */
+  /** 状态抽屉用同一个口径 */
   protected readonly agentState = computed(() => {
     const a = this.chat.agent();
-    return a ? resolveAgentState({ agent: a }) : null;
+    return a ? { text: this.presence().text, tone: this.presence().tone } : null;
   });
 
   /**
@@ -1059,13 +1113,38 @@ export class MbChatPage implements OnDestroy {
    * 🔴 容器产出的不算「文件」,那是「成果」—— 见 `deliverables`。
    *    原来不看 sender,同一份成果在「文件」和「成果」各出现一次。
    */
-  protected readonly files = computed(() =>
-    this.chat
-      .messages()
-      .filter((m) => m.sender === 'me')
-      .map((m) => m.file)
-      .filter((f): f is NonNullable<typeof f> => !!f),
-  );
+  protected readonly files = computed(() => {
+    const out: NonNullable<Message['file']>[] = [];
+    const seen = new Set<string>();
+    /*
+     * ① 消息流里我发的附件 —— **先走这条**(2026-11-11,与桌面同口径):
+     *    带真实 size 与 cloudUrl,信息最全。
+     */
+    for (const m of this.chat.messages()) {
+      const f = m.file;
+      if (m.sender !== 'me' || !f) continue;
+      const id = fileIdentity(f.name, f.size);
+      if (seen.has(id)) continue;
+      seen.add(id);
+      out.push(f);
+    }
+    /*
+     * ② 台账里 `uploadedBy:'user'` 的记录:刷新后仍在,补上消息流翻页取不到的历史。
+     *
+     * 🔴 去重不能比 URL:台账存 S3 地址、消息流 `f.url` 是 `mxc://`,永远不等
+     *    → 同一个文件会出现两条(用户实测)。改按「名字+大小」。
+     */
+    for (const d of this._dbUserFiles()) {
+      const a = d.artifacts[0];
+      if (!a?.url) continue;
+      const bytes = d.sizeBytes ?? 0;
+      const id = fileIdentity(a.name, bytes);
+      if (seen.has(id)) continue;
+      seen.add(id);
+      out.push({ kind: 'file', name: a.name, url: a.url, size: bytes > 0 ? bytes : -1, mimeType: a.type ?? '' });
+    }
+    return out;
+  });
 
   /**
    * 成果抽屉「成果」页签 = **本会话里容器产出的文件**(2026-11-09 与桌面同口径)。
@@ -1078,6 +1157,7 @@ export class MbChatPage implements OnDestroy {
    */
   protected readonly deliverables = computed(() => {
     const sess = this.chat.currentSession();
+    /* 台账已按来源分流:成果只吃「容器产出」那半边(2026-11-11) */
     const fromDb = this.dbFiles();
     const agentFiles = this.chat
       .messages()
@@ -1088,7 +1168,11 @@ export class MbChatPage implements OnDestroy {
     return this.deliverablesSvc.merge(fromDb, fromMessages, fromRecord);
   });
 
-  /** 按会话从数据库加载的文件(2026-11-10:与桌面端同口径) */
+  /**
+   * 按会话从数据库加载的文件(2026-11-10:与桌面端同口径)。
+   * 2026-11-11 起按来源分流,见 `_dbUserFiles` / `_dbFiles` 注释。
+   */
+  private readonly _dbUserFiles = signal<Deliverable[]>([]);
   private readonly _dbFiles = signal<Deliverable[]>([]);
   readonly dbFiles = this._dbFiles.asReadonly();
 
@@ -1096,16 +1180,22 @@ export class MbChatPage implements OnDestroy {
   private loadSessionFiles(): void {
     const sid = this.chat.currentSession()?.id;
     if (!sid) {
+      this._dbUserFiles.set([]);
       this._dbFiles.set([]);
       return;
     }
     void this.deliverablesSvc
-      .loadForSession(sid)
-      .then((files) => {
-        if (this.chat.currentSession()?.id === sid) this._dbFiles.set(files);
+      .loadSplitForSession(sid)
+      .then(({ userFiles, agentFiles }) => {
+        if (this.chat.currentSession()?.id !== sid) return;
+        this._dbUserFiles.set(userFiles);
+        this._dbFiles.set(agentFiles);
       })
       .catch(() => {
-        if (this.chat.currentSession()?.id === sid) this._dbFiles.set([]);
+        if (this.chat.currentSession()?.id === sid) {
+          this._dbUserFiles.set([]);
+          this._dbFiles.set([]);
+        }
       });
   }
 
@@ -1138,6 +1228,11 @@ export class MbChatPage implements OnDestroy {
   }
 
   constructor() {
+    /* 提示条「重新发送」(2026-11-11,与桌面同口径) */
+    this.alert.onAction((a) => {
+      if (a.title === MbChatPage.TIMEOUT_TITLE) this.resendLast();
+    });
+
     /*
      * 右侧栏:会话切换或消息数变化时重拉文件台账(2026-11-10,与桌面端同口径)。
      * 容器产出的文件要等登记完才进台账,回合结束消息数变化正好触发刷新。
@@ -1406,9 +1501,19 @@ export class MbChatPage implements OnDestroy {
     void this.router.navigateByUrl('/mobile/messages');
   }
 
+  /**
+   * 任务深链(2026-11-11 改)。
+   *
+   * 「任务」页路由已下线(用户裁决:入口和路由一起关),原来跳
+   * `/mobile/tasks/:id` 会落到消息列表,用户以为点坏了。
+   * 改为**留在当前页**:关掉抽屉、打开成果/状态抽屉的任务页,并高亮该条。
+   */
   protected goTask(id: string): void {
-    this.sheetOpen.set(false);
-    void this.router.navigateByUrl(`/mobile/tasks/${id}`);
+    this.sheetTab.set('task');
+    this.railHighlight.set(id);
+    this.sheetOpen.set(true);
+    if (this.railHighlightTimer) clearTimeout(this.railHighlightTimer);
+    this.railHighlightTimer = setTimeout(() => this.railHighlight.set(null), 2000);
   }
 
   /**

+ 15 - 3
src/app/mobile/pages/contacts/contacts.ts

@@ -4,7 +4,8 @@ import { Router } from '@angular/router';
 import { ContactTab, ContactsService } from '../../../core/contacts.service';
 import { LiveService } from '../../../core/live.service';
 import { Agent, Person } from '../../../core/api/models';
-import { resolveAgentState } from '../../../core/agent-state';
+import { AlertService } from '../../../core/alert.service';
+import { blockedHint, livenessOf } from '../../../core/agent-liveness';
 import { MAppBarComponent } from '../../components/m-app-bar/m-app-bar';
 import { MAvatarComponent } from '../../components/m-avatar/m-avatar';
 import { BadgeTone, MBadgeComponent } from '../../components/m-badge/m-badge';
@@ -307,6 +308,7 @@ export class MbContactsPage {
   protected readonly activeSessionId = computed(() => this.chat.sessionId());
 
   private readonly router = inject(Router);
+  private readonly alert = inject(AlertService);
   protected readonly contacts = inject(ContactsService);
   private readonly live = inject(LiveService);
 
@@ -342,11 +344,11 @@ export class MbContactsPage {
    * 现在与 PC 通讯录、会话列表、新建弹窗共用 `resolveAgentState`。
    */
   protected toneOf(a: Agent): BadgeTone {
-    return resolveAgentState({ agent: a }).tone as BadgeTone;
+    return livenessOf(a).tone as BadgeTone;
   }
 
   protected statusText(a: Agent): string {
-    return resolveAgentState({ agent: a }).text;
+    return livenessOf(a).text;
   }
 
   /**
@@ -356,7 +358,17 @@ export class MbContactsPage {
    * 原实现走 `AgentRouteService.routeFor()`(已接入→会话 / 未接入→详情),
    * 与设置意图不符。
    */
+  /**
+   * 打开容器设置(2026-11-11 加拦截)。
+   *
+   * 未接入 / 离线的容器直接拦下并提示先接入 —— 进去了也做不了任何事。
+   */
   protected openAgent(a: Agent): void {
+    const hint = blockedHint(livenessOf(a));
+    if (hint) {
+      this.alert.show('暂时无法查看该数字生命', hint, 'warning');
+      return;
+    }
     void this.router.navigateByUrl(`/mobile/agent/${a.id}`);
   }
 

+ 19 - 9
src/app/mobile/pages/group-chat/group-chat.ts

@@ -728,12 +728,14 @@ export class MbGroupChatPage implements OnDestroy {
    *   ③ 群会话记录上的 `deliverableUrls`(旧数据兜底)
    */
   protected readonly groupDeliverables = computed<Deliverable[]>(() => {
-    const fromDb = this._dbFiles();
+    /* 台账只吃容器产出(2026-11-11,与桌面/单聊同口径),用户上传归「文件」 */
+    const fromDb = this._dbAgentFiles();
     const agentMsgs = this.messages().filter((m) => m.sender === 'agent' && m.file?.url);
     const fromMessages = this.deliverablesSvc.fromAgentFiles(agentMsgs.map((m) => m.file!)).map((d) => ({
       ...d,
       agentId: agentMsgs.find((m) => m.file?.url === d.artifacts[0]?.url)?.senderId ?? '',
     }));
+    /* ③ 降级为最后兜底:伪群聊下它会与单聊重复记账 */
     const fromRecord = this.deliverablesSvc.bySessionUrls(this.session()?.deliverableUrls);
     return this.deliverablesSvc.merge(fromDb, fromMessages, fromRecord);
   });
@@ -783,23 +785,31 @@ export class MbGroupChatPage implements OnDestroy {
     turnsFromGroup(this.turns(), this.groupLive.clock(), GROUP_QUIET_MS, (id) => this.memberName(id)),
   );
 
-  /** 按群会话从数据库加载的文件(①) */
-  private readonly _dbFiles = signal<Deliverable[]>([]);
-  readonly dbFiles = this._dbFiles.asReadonly();
+  /** 按群从台账加载的文件(① 用户上传 / ② 容器产出,已分流) */
+  private readonly _dbUserFiles = signal<Deliverable[]>([]);
+  private readonly _dbAgentFiles = signal<Deliverable[]>([]);
+  readonly dbFiles = this._dbAgentFiles.asReadonly();
 
   private loadSessionFiles(): void {
     const sid = this.session()?.id;
     if (!sid) {
-      this._dbFiles.set([]);
+      this._dbUserFiles.set([]);
+      this._dbAgentFiles.set([]);
       return;
     }
+    /* 按群加载并分流(2026-11-11,与桌面群聊同口径) */
     void this.deliverablesSvc
-      .loadForSession(sid)
-      .then((files) => {
-        if (this.session()?.id === sid) this._dbFiles.set(files);
+      .loadSplitForGroup(sid)
+      .then(({ userFiles, agentFiles }) => {
+        if (this.session()?.id !== sid) return;
+        this._dbUserFiles.set(userFiles);
+        this._dbAgentFiles.set(agentFiles);
       })
       .catch(() => {
-        if (this.session()?.id === sid) this._dbFiles.set([]);
+        if (this.session()?.id === sid) {
+          this._dbUserFiles.set([]);
+          this._dbAgentFiles.set([]);
+        }
       });
   }
 

+ 49 - 65
src/app/mobile/pages/workbench/workbench.ts

@@ -2,7 +2,8 @@ import { ChatService } from '../../../core/chat.service';
 import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core';
 import { Router } from '@angular/router';
 import { Agent } from '../../../core/api/models';
-import { resolveAgentState } from '../../../core/agent-state';
+import { AlertService } from '../../../core/alert.service';
+import { isReporterSelfTalk, livenessOf } from '../../../core/agent-liveness';
 import { AgentRouteService } from '../../../core/agent-route.service';
 import { LiveService } from '../../../core/live.service';
 import { TasksService } from '../../../core/tasks.service';
@@ -64,13 +65,13 @@ import { LockScrollDirective } from '../../directives/lock-scroll.directive';
               <b class="m-stat-value">{{ live.agents().length }}</b>
             </div>
             <div class="m-stat">
-              <span class="m-stat-label">运行中</span>
-              <b class="m-stat-value m-stat-value--running">{{ runningCount() }}</b>
+              <span class="m-stat-label">在线</span>
+              <b class="m-stat-value m-stat-value--running">{{ onlineCount() }}</b>
             </div>
             <div class="m-stat">
-              <span class="m-stat-label">需要关注</span>
-              <b class="m-stat-value" [class.m-stat-value--warn]="blockedCount() > 0">
-                {{ blockedCount() }}
+              <span class="m-stat-label">离线</span>
+              <b class="m-stat-value" [class.m-stat-value--warn]="offlineCount() > 0">
+                {{ offlineCount() }}
               </b>
             </div>
           </div>
@@ -315,12 +316,16 @@ import { LockScrollDirective } from '../../directives/lock-scroll.directive';
   ],
 })
 export class MbWorkbenchPage {
+  /** Fmode Studio 充值页(2026-11-11,与桌面工作台同一地址) */
+  private static readonly BALANCE_URL = 'https://app.fmode.cn/dev/studio/balance';
+
   private readonly chat = inject(ChatService);
 
   /** 当前打开的会话(抽屉高亮用) */
   protected readonly activeSessionId = computed(() => this.chat.sessionId());
 
   private readonly router = inject(Router);
+  private readonly alert = inject(AlertService);
   private readonly tasks = inject(TasksService);
   private readonly agentRoute = inject(AgentRouteService);
   protected readonly live = inject(LiveService);
@@ -332,76 +337,50 @@ export class MbWorkbenchPage {
 
   /** 搜索关键字 */
   protected readonly keyword = signal('');
-  /** 状态筛选(对齐 PC 工作台的分类) */
-  protected readonly statusFilter = signal<'all' | 'running' | 'idle' | 'error'>('all');
+  /** 状态筛选(2026-11-11 用户裁决:工作台只分在线/离线) */
+  protected readonly statusFilter = signal<'all' | 'online' | 'offline'>('all');
 
-  /** 运行中的容器数 */
   /**
-   * 「运行中」计数(2026-11-08 统一口径):
-   * 用合成态统计,包含「正在执行 / 正在回复 / 思考中」,并排除心跳过期的僵尸容器。
-   * 原先只数 `status === 'running'` —— 45 天没心跳的容器照样算进去。
+   * 在线容器数(2026-11-11,与 PC 工作台同口径)。
+   * 判据统一走 `livenessOf()`,不再用「执行中 / 空闲」这套运行态。
    */
-  protected readonly runningCount = computed(
-    () =>
-      this.agents().filter((a) => {
-        const s = resolveAgentState({ agent: a }).state;
-        return s === 'running' || s === 'replying' || s === 'thinking';
-      }).length,
+  protected readonly onlineCount = computed(
+    () => this.agents().filter((a) => livenessOf(a, Date.now(), this.live.fetchedAt()).online).length,
   );
 
-  /** 「需要关注」:不可用的容器(离线 + 异常),与 PC 工作台同口径 */
-  protected readonly blockedCount = computed(
-    () =>
-      this.agents().filter((a) => {
-        const s = resolveAgentState({ agent: a }).state;
-        return s === 'offline' || s === 'error';
-      }).length,
-  );
+  /** 离线容器数 */
+  protected readonly offlineCount = computed(() => this.agents().length - this.onlineCount());
 
   /** 被阻塞的任务(对齐 PC 的「阻塞任务上浮」) */
   protected readonly blockedTasks = computed(() =>
     this.tasks.all().filter((t) => t.status === 'blocked'),
   );
 
+  /** 任务入口(2026-11-11):任务页路由已下线,改为如实告知,不再跳空 */
   protected openTask(id: string): void {
-    void this.router.navigateByUrl(`/mobile/tasks/${id}`);
+    this.alert.show(
+      '任务看板暂未开放',
+      `任务入口已下线(${id}),可在会话页的「任务」抽屉里查看该容器的全部任务。`,
+      'warning',
+    );
   }
 
-  /**
-   * 状态筛选(2026-11-08 统一口径)。
-   *
-   * 🔴 原先直接用 `a.status`:
-   *   · 不看心跳 → 僵尸容器仍算进「执行中」
-   *   · 「异常」漏了 stopped,与 PC 工作台口径不一致
-   * 现在按 `resolveAgentState` 的合成态统计,PC / 移动 / 会话列表结论一致。
-   *   running 组 = 正在执行 + 正在回复 + 思考中(都是「在干活」)
-   *   idle 组    = 空闲
-   *   error 组   = 离线 + 异常(都不可用)
-   */
-  protected readonly statusOptions = computed(() => {
-    const list = this.agents().map((a) => resolveAgentState({ agent: a }).state);
-    const running = list.filter((s) => s === 'running' || s === 'replying' || s === 'thinking').length;
-    const idle = list.filter((s) => s === 'idle').length;
-    const bad = list.filter((s) => s === 'offline' || s === 'error').length;
-    return [
-      { value: 'all' as const, label: '全部', count: list.length },
-      { value: 'running' as const, label: '执行中', count: running },
-      { value: 'idle' as const, label: '空闲', count: idle },
-      { value: 'error' as const, label: '异常', count: bad },
-    ];
-  });
+  /** 状态筛选(2026-11-11 用户裁决:工作台只分在线/离线) */
+  protected readonly statusOptions = computed(() => [
+    { value: 'all' as const, label: '全部', count: this.agents().length },
+    { value: 'online' as const, label: '在线', count: this.onlineCount() },
+    { value: 'offline' as const, label: '离线', count: this.offlineCount() },
+  ]);
 
   /** 关键字 + 状态过滤后的数字生命 */
   protected readonly visibleAgents = computed(() => {
     const q = this.keyword().trim().toLowerCase();
     const f = this.statusFilter();
+    const fetchedAt = this.live.fetchedAt();
     return this.agents().filter((a) => {
-      const st = resolveAgentState({ agent: a }).state;
-      const isRunning = st === 'running' || st === 'replying' || st === 'thinking';
-      const isBad = st === 'offline' || st === 'error';
-      if (f === 'error' && !isBad) return false;
-      if (f === 'running' && !isRunning) return false;
-      if (f === 'idle' && st !== 'idle') return false;
+      const online = livenessOf(a, Date.now(), fetchedAt).online;
+      if (f === 'online' && !online) return false;
+      if (f === 'offline' && online) return false;
       if (!q) return true;
       return (
         a.name.toLowerCase().includes(q) || (a.specialty ?? '').toLowerCase().includes(q)
@@ -435,16 +414,21 @@ export class MbWorkbenchPage {
     });
   }
 
+  /**
+   * 充值(2026-11-11 修复)。
+   *
+   * 🔴 原来只认云端下发的 `balance.pageUrl`,而云函数根本不返回它 ——
+   *    点击永远弹「没有独立充值入口」,等于把用户堵死。
+   *    现在回落到 Fmode Studio 充值页(与云函数取余额同源)。
+   */
   protected recharge(a: Agent): void {
-    const url = a.balance?.pageUrl;
-    if (url && url !== '#') {
-      window.open(url, '_blank', 'noopener,noreferrer');
-      return;
-    }
-    this.dialog.set({
-      title: '充值',
-      text: '该数字生命暂未提供充值入口,请联系管理员在平台后台操作。',
-    });
+    const raw = a.balance?.pageUrl;
+    const url = raw && raw !== '#' ? raw : MbWorkbenchPage.BALANCE_URL;
+    /*
+     * 移动端卡片只展示不分金额,但说明里要讲清余额归属:
+     * 容器账单挂在主人账号,所有容器共用同一份余额(2026-11-11)。
+     */
+    window.open(url, '_blank', 'noopener,noreferrer');
   }
 
   protected newChat(): void {

Unele fișiere nu au fost afișate deoarece prea multe fișiere au fost modificate în acest diff