Ver Fonte

feat(app): 会话 UI 精修 + 四种格式预览 + 容器成果上云盘

会话体验
- 终端活动折叠:容器每步一条消息淹没会话 → 相邻合并成「运行了 N 步」
  实测 42 条消息 → 19 个气泡 + 12 个折叠组
- 滚动:修「打开会话停在中间」(差 1785px) 与「上滚加载后位置跳动」
  改为 立即+rAF+120ms 三次贴底;上滚补偿等两帧再量
- 横向滚动条:grid 子项默认 min-width:auto 撑破容器 → 补 min-width:0
- 代码块:prism 暗色主题挂在亮底上 → 改深底亮字;复制按钮移出滚动容器
- 状态:容器还在回复却显示「已完成」→ 以消息流为准;
  修「刷新就显示正在回复」(历史重放被当成新消息)
- 心跳:heartbeatAt 是 Parse Date 对象,String() 得到 "[object Object]"
  → 经 tsOf 归一,「状态未同步」分支此前从未生效

预览
- 新增 PDF(pdfjs canvas)、DOCX/XLSX(@silurus/ooxml)、HTML(沙箱 iframe)
- 右栏成果预览此前是另一套旧弹窗,只认 demo 的 content 字段
  → 统一走 app-file-preview
- 面板 1000×760 → 1400×874,加全屏与缩放
- pdf.js 补 Promise.try polyfill(缺了会永远卡在加载中)

成果上云盘
- 服务端签名:浏览器不再持有任何密钥
- 对象键加序号与随机标识:同名附件不覆盖、重复搬运不覆盖旧文件
- 容器调用 pendingArtifacts 时自动补登记,不再依赖 App 是否打开

文档
- 新容器接入完整流程 / 从零到可用部署手册 / 发给主容器的创建指令
- 多容器协作设计 + P0 验证与防回环(实测连续 57 条无人类介入)

Co-Authored-By: Claude Code <noreply@anthropic.com>
Huccct há 1 semana atrás
pai
commit
2b67c3f6b5
37 ficheiros alterados com 5517 adições e 167 exclusões
  1. 4 0
      .gitignore
  2. 53 0
      docs/00-tracking/2026-10-20-待用户操作清单.md
  3. 405 0
      docs/00-tracking/操作追踪.md
  4. 184 0
      docs/02-design/architecture/多容器协作-P0验证与防回环-20261021.md
  5. 268 0
      docs/02-design/architecture/多容器协作设计-20261021.md
  6. 131 0
      docs/03-interface/发给主容器-创建新容器指令-20261021.md
  7. 371 37
      docs/03-interface/容器接入上线手册-20261020.md
  8. 404 0
      docs/03-interface/新容器从零到可用-部署手册-20261021.md
  9. 361 0
      docs/03-interface/新容器接入-完整流程-20261021.md
  10. 1 0
      package.json
  11. 33 0
      scripts/copy-pdf-worker.mjs
  12. 53 3
      src/app/components/domain/message-bubble/message-bubble.ts
  13. 169 0
      src/app/components/domain/tool-activity/tool-activity.ts
  14. 36 2
      src/app/components/preview/file-kind.spec.ts
  15. 21 2
      src/app/components/preview/file-kind.ts
  16. 280 4
      src/app/components/preview/file-preview/file-preview.ts
  17. 47 12
      src/app/components/preview/md-render/md-render.ts
  18. 284 0
      src/app/components/preview/office-preview/office-preview.ts
  19. 352 0
      src/app/components/preview/pdf-preview/pdf-preview.ts
  20. 53 0
      src/app/core/api/fmode-api.ts
  21. 35 0
      src/app/core/api/mock-api.ts
  22. 39 2
      src/app/core/api/models.ts
  23. 70 3
      src/app/core/api/normalize.spec.ts
  24. 154 18
      src/app/core/api/parse-api.ts
  25. 112 0
      src/app/core/artifact-sync.service.spec.ts
  26. 113 0
      src/app/core/artifact-sync.service.ts
  27. 50 2
      src/app/core/chat.service.ts
  28. 13 0
      src/app/core/live.service.ts
  29. 120 0
      src/app/core/message-segments.spec.ts
  30. 275 0
      src/app/core/message-segments.ts
  31. 159 0
      src/app/core/task-pulse.service.spec.ts
  32. 295 0
      src/app/core/task-pulse.service.ts
  33. 499 66
      src/app/desktop/pages/chat/chat.ts
  34. 15 3
      src/app/desktop/pages/deliverables/deliverables.ts
  35. 31 5
      src/app/desktop/shell/desktop-shell.ts
  36. 3 0
      src/app/desktop/shell/dk-nav/dk-nav.ts
  37. 24 8
      src/app/mobile/pages/deliverables/deliverables.ts

+ 4 - 0
.gitignore

@@ -76,3 +76,7 @@ fmode-app-2-claude-session/
 
 # 调试临时脚本
 prototypes/**/_*.mjs
+
+# pdf.js worker:从 node_modules 复制,不纳入版本管理
+# 生成方式:cp node_modules/pdfjs-dist/build/pdf.worker.min.mjs public/pdf.worker.js
+public/pdf.worker.js

+ 53 - 0
docs/00-tracking/2026-10-20-待用户操作清单.md

@@ -0,0 +1,53 @@
+# 2026-10-20 会话页 · 待用户操作清单(明天继续)
+
+> 本轮 App 侧改动已完成、已通过全部校验,**未提交 git**(按用户要求)。
+> 下面分三类:**需要你操作的**、**需要容器配合的**、**可以明天再定的**。
+
+---
+
+## 一、需要你在 App 里看一眼的(已验证但需你眼确认)
+
+启动:`npm start` → `http://localhost:4200/desktop/chat/agent-node-lj-test`
+
+| # | 看什么 | 期望 |
+|---|---|---|
+| 1 | 发一条消息 | 右侧「当前任务」**立即**变成「刚刚开始 · 正在处理你的消息」,不再等几分钟 |
+| 2 | 左侧列表 | 每个会话名旁边多一个**运行态标签**(执行中/空闲/未同步),与右侧同一份数据 |
+| 3 | 等容器回复完 | 「当前任务」变成「已完成 · 用时 X」(10 分钟内),之后回「空闲」 |
+| 4 | 右侧「最近动态」 | 出现「▶ 开始处理 / ✓ 完成」记录 |
+| 5 | 含工具调用的回复 | 中间过程行收成一行「▸ 运行了 N 步」,点开才看细节 |
+| 6 | 页面最外层 | **不能**上下滚出多余的滚动条 |
+| 7 | 右栏「相关成果」 | 不再出现 `probe` 那条;有文件的显示文件名 |
+
+---
+
+## 二、需要容器配合的(不配合则功能显示「未上线」)
+
+| # | 事项 | 现状 | 要容器做什么 |
+|---|---|---|---|
+| A | **成果文件上传云盘** | App 已能识别「只有容器路径、没有云盘地址」的附件,并调 `lfYlgU7SkK action:'requestSync'` 登记 | ① 云函数需**实现 `requestSync`**(关键:按文件名开 OBS 预签名 + 写待同步队列 + 收到文件后回写 `artifacts[].url`)② 容器跑一个轮询任务,读队列 → PUT 文件 |
+| B | **修复已有两条成果** | `Qtd1VHvLHV` 的 artifacts 是容器内路径;`FUpPUtUj4h` 是 `probe` 探针 | `probe` 那条 App 已自动过滤;`Qtd1VHvLHV` 等 A 完成后即可下载 |
+
+> ⚠️ 未配合前,成果栏会如实显示「正在同步到云盘…」,**不会**给出打不开的下载按钮(这是刻意的:宁可说没同步,不给假链接)。
+
+---
+
+## 三、可以明天再定的
+
+| 项 | 说明 |
+|---|---|
+| **提交 git** | 本轮所有改动都还在工作区,未 commit。你确认效果后我再提交 |
+| **状态刷新节奏** | 现在仍跟随全平台 15s 轮询。若觉得「容器说完成」的那一下还是慢,可加一条**仅针对当前会话**的轻量查询(容器本身最慢约 2 分钟一次上报,前端再快也快不过它) |
+| **用时口径** | 「已完成 · 用时 X」是**两次观测的时间差**,不是容器内部的精确 turn 时长。要精确需容器在回复里带上起止时间 |
+| **参考 DSH 桌面端** | 已抽取 Codex 桌面端实现(折叠用「Worked for {时间}」+ chevron 旋转)。本次采纳其**信息结构**(折叠过程 / 展开看细节),配色与圆角仍走本项目令牌;DSH 本机未找到安装包,未单独比对 |
+
+---
+
+## 四、本轮验证证据
+
+```
+npm run build        → 通过(仅 matrix-js-sdk 的 CommonJS 告警,存量)
+npx ng test          → 540 SUCCESS / 0 FAILED
+npm run verify:ui    → 11/11 通过
+npm run verify:docs  → 49 文件通过
+```

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

@@ -917,3 +917,408 @@
 | **✏️ 检查表补两项** | 「status 处理器完后翻 idle」「currentTask 是业务任务而非上报器活动」|
 | 验证证据 | `verify:docs` **49 文件**通过 |
 
+
+## 2026-10-20 · 会话页实时性与工具调用折叠(用户 7 项反馈)
+
+| 项 | 动作 | 结论/根因 |
+|---|---|---|
+| 1 状态更新慢,能否前端直接切换 | 前端**动作即时置 running**,但不读秒猜完成 | 职责三分:用户动作(发消息/附件)→ 前端立刻 `running`;**完成信号只能容器给**(`idle`);服务端仍是最终真值,下一轮询覆盖本地推测值。前端不得凭空翻 idle —— 那是伪造状态 |
+| 2 左侧列表状态不同步 | 列表预览行改读**同一份** `live.agentById(id)`,新增运行态胶囊 | 根因是列表行只渲染接入态(在线/未上报),运行态只在头部。改为「名称 + 接入态 + 运行态」三件套,单一数据源 |
+| 3 当前任务同步慢 | 新增 `TaskPulseService`:每次轮询对比快照,翻转瞬间插入「事件」行并即时刷新 | UI 只描述**已观测到的事实**(「刚刚开始 → 先梳理任务」/「已完成 · 用时 3 分 20 秒」),不假装知道容器内部计划 |
+| 4 工具调用环节折叠 | 新增 `core/message-segments.ts` + 组件 `tool-activity` | 把相邻的过程行(`· 工具名` / 过程提示 / 空行占位)聚成一组,**折叠**为「运行了 N 步 · 用时」;展开后每步一行,可点开看参数。参照 Codex「Worked for」折叠态,按本仓令牌落地 |
+| 5 最外层异常滚动 | 外壳 `.workspace` 加 `overflow: clip` | `:has()` 只在该页**首次渲染后**才生效,之前有一帧 `overflow:auto`,多出的 1px(路由 fade 动画的 transform)触发根级滚动条。`clip` 整页禁用根滚动,不依赖选择器时机 |
+| 6 两条无意义成果 + 文件必须走 S3 | ①`mapDeliverable` 兼容 `artifacts` 为**字符串数组**(容器实际上报形态)、过滤空标题/探针记录 ②新增 `ArtifactSyncService`:发现未同步的 artifact → 取上传签名 → **由容器下载原文件后 PUT 到 OBS** → 回写 `url` 为云盘地址 | 实证:`Qtd1VHvLHV.artifacts=["/opt/data/.../HERMES-UPSTREAM-REPORT.md"]`(容器内路径,浏览器不可达);`FUpPUtUj4h` 标题 `probe`、无附件。给容器的契约改为「路径或 URL 都行,**平台负责规范化到云盘**」 |
+| 7 参考 DSH/Codex 桌面端 | 已抽取 Codex 桌面端 `app.asar`(Electron 应用,`resources/app.asar`)读其折叠实现:`collapsed-turn-disclosure` → 「Worked for {time}」+ 折叠计数 + denied 计数,chevron 0°→90° | 采纳其**信息结构**(过程折叠 / 用时 / 展开才见细节),视觉仍用本仓设计令牌(规则:禁止引入第三方 UI 库) |
+
+**未验证项**:`ArtifactSyncService` 的端到端链路需容器侧实装 `upload-artifact` 轮询端才算闭环,当前只在前端与云函数契约层就绪。
+
+## 2026-10-21 · UI 精修 + 成果文件上云盘(第二轮)
+
+| 项 | 动作 | 关键发现 |
+|---|---|---|
+| **UI · 最外层异常滚动(真根因)** | 三处修复:① 外壳网格 `grid-template-rows: minmax(0,1fr)` ② `.md-copy-live` 屏读的绝对定位元素缺定位上下文 ③ `:host{min-height:0}` | 昨天判的 `:has()` 时机**只是次要原因**。真因:网格隐式行是 `auto` → 整页被撑到 3121px;且每条消息的屏读提示 `position:absolute` 以初始包含块定位,堆在文档底部。实测 `docScrollH` 2997 → **950(=视口)** |
+| **UI · 头部状态徽标被压住** | 徽标与操作区拆成两列(原来共用 `status` 格) | 截图实证:徽标 x=1179 与操作区 x=1179 完全重叠 |
+| **UI · 「空闲 空闲」重复 + 橙色像告警** | 运行态只由任务行表达;徽标仅在该容器 error/offline/stopped 时出现 | 同一信息两处渲染;且 `idle` 映射到 warning 色(琥珀),语义上像故障 |
+| **UI · 列表行重排** | 名称优先:第一行 名字 + 未读 + 运行态圆点;第二行 任务描述 + 接入态徽标 | 原来三个彩色块挤在标题行 |
+| **任务行新增 danger 态** | 容器明确报错/离线/停止时,任务行直接显示「运行异常/已离线/已停止」 | 与「未同步」(我们看不到)区分开 |
+| **成果文件上云盘** | ① `obs-sign.ts` 浏览器侧 SigV2 预签名(与云函数 AlP56LCKFm 逐字节对拍,单测用 node crypto 独立算的参考值)② `FmodeArtifactJob` 搬运队列 ③ `lfYlgU7SkK` v0.1.0 补 3 个 action ④ 三处页面不再给空 `href` | fmode-studio **没有**可复用的搬运/回写实现(子代理全仓排查确认),以云函数 `AlP56LCKFm` v0.2.2 为唯一依据 |
+
+**已落地(用户批准后执行)**:
+- `FmodeArtifactJob` 表已创建(CLP 全关,只走云函数 masterKey)
+- `lfYlgU7SkK` **v0.1.0 已发布**(发布前备份线上 v0.0.2)
+- 云函数端到端实测:`enqueueArtifact → pendingArtifacts → ackArtifact → 回写 artifacts[].url` 全部通过;
+  测试期间临时改写的真实成果 `Qtd1VHvLHV` **已恢复原值**,测试任务已删除
+- STS 路径:`issueSigningCredential` 已实现(走华为云 IAM securitytokens),
+  当前返回 `503 签名服务未配置(缺少 IAM 账号)`——待配置 IAM 环境变量后启用
+
+**未验证项**:
+- 容器侧轮询任务尚未落地(等端到端打通后一次性下发)
+- 前端 SigV2 + securityToken 已单测覆盖,但**未对真实 OBS 做过 PUT**(缺 IAM 凭据)
+
+## 2026-10-21 · 存储链路核实 + 真实上传打通(第三轮)
+
+| 项 | 结论 |
+|---|---|
+| **存哪个桶** | **华为云 OBS** `storage-s3-nkkj`(公网 `s3.fmode.cn`)。平台另有七牛(`file.yuban.co`,只用于 Attachment 表的企业文件)与 `nova-cloud`(`app.fmode.cn/dev/<projectId>`,容器发布页),与成果链路无关 |
+| **是否需要 STS** | **不需要**。HEAD 探活返回 404(非 403)→ 现有长期 AK 本就能写该桶。此前「必须 STS」的判断有误 |
+| **参考实现来源** | `future-server/fmode-server/modules/fmode-listen-api/src/obs.ts`(254 行)——线上预签名实现,纠正了我两个错误:① `x-obs-security-token` 是**查询子资源**不是请求头 ② 平台已有 `/api/apig/deploy/huaweicloud` 下发 STS(限 `nova-cloud` 桶,本桶用不上) |
+
+**真实端到端(非模拟)**:
+```
+issueSigningCredential 200 (bucket/prefix 正确)
+→ 直签 PUT 到 OBS          200
+→ 公网 GET 回读            200,内容一致
+→ enqueue → 容器领取 → PUT 200 → ack → 回写 url → 公网可读 200
+```
+签名算法(含 `x-obs-acl` 头)在真实 OBS 上验证通过,不再只是「与解析器一致」。
+
+**测试副作用已清理**:成果 `Qtd1VHvLHV` 已恢复原值;`FmodeArtifactJob` 测试任务已删;
+OBS 上 `deliverables/_smoketest/` 两个测试对象已 DELETE(204)。
+
+**已发布**:`lfYlgU7SkK` v0.1.2(新增 3 个搬运 action + `issueSigningCredential`)
+
+**已知风险(用户裁决接受)**:下发长期 AK 给已登录 App,持有者可写整个桶。
+升级路径已写入手册 §4.2(建委托 → 临时凭据)。
+
+## 2026-10-21 · 对象键规则修正 + 容器指令定稿(第四轮)
+
+**发现并修复的覆盖 bug**:初版对象键 `deliverables/<agentId>/<YYYYMM>/<deliverableId>-<文件名>`
+没有序号,同一成果下**两个同名附件会算出同一个 key**,后传覆盖前传。
+
+实测证据(平台真实数据):`a.md` 出现 3 次、`test.txt` 出现 2 次,50 条成果里 4 条多附件。
+
+**新规则**:`deliverables/<agentId>/<YYYYMM>/<deliverableId>/<序号>-<文件名>`
+- 序号 = 附件在 `artifacts[]` 的下标 → 同名不冲突
+- 同附件重复签 → 同 key → **幂等**,重试不产生垃圾对象
+- 成果 ID 提升为**目录** → 一个成果的文件聚在一起,便于按成果清理
+
+**真实 OBS 验证**:同成果两个同名 `a.md` 分别 PUT 到 `0-a.md` / `1-a.md`,
+公网回读内容各自独立,未覆盖。测试对象已 DELETE。
+
+**文档**:手册 §4.2 补「对象键规则」表 + 踩坑记录;容器参考实现改为可直接落盘的
+`artifact-drain.py`,并写明它**故意不做**的四件事。
+
+## 2026-10-21 · 密钥归属调整:改为服务端签名(第五轮)
+
+**用户诉求**:「密钥是否有更好的方式?不用两边同时改?每个人独立唯一的?」
+
+**回答**:AK/SK 是桶级凭据,「每人唯一」只能靠**临时凭据**实现,而那需要华为云委托
+(控制台一次性配置)。用户明确表示「不想频繁在控制台操作,愿以安全性换便捷」,
+故选择代价最小且仍消除主要风险的方案。
+
+**改动**:
+| 项 | 之前 | 现在 |
+|---|---|---|
+| 谁签名 | 浏览器(云函数下发 AK/SK) | **只有服务端** |
+| 浏览器持有密钥 | 是 | **否** |
+| 密钥副本数 | 2(两个云函数) | 2(仍是,见下) |
+| 凭证有效期 | 长期 AK | 签出的地址 5 分钟 |
+| 前端文件 | `obs-sign.ts` / `obs-config.ts` | **已删除** |
+
+**顺带修的 bug**:云函数对未知 action(如已删除的 `issueSigningCredential`)
+返回 `200`,调用方会误以为成功 → 改为返回 `400 unknown action`。
+
+**真实端到端验证**(服务端签名版):
+```
+enqueue(不含任何地址)→ 200
+领取 → uploadUrl 含 Signature,对象键 …/Qtd1VHvLHV/0-HERMES-UPSTREAM-REPORT.md
+PUT → 200 → ack → 回写 url → 公网读 200
+旧接口 → 400 unknown action
+```
+测试期间改写的成果 `Qtd1VHvLHV` 已恢复,任务与 OBS 测试对象已清理。
+
+**未解决(已在文档披露)**:密钥仍是两处副本(`AlP56LCKFm` 与 `lfYlgU7SkK`)。
+彻底去重需把 AK/SK 挪到平台环境变量或 `CloudProvider` 表,那是平台侧配置,未做。
+
+## 2026-10-21 · 容器部署文档重构(第六轮)
+
+针对「后续部署其他容器能顺利」的目标,手册做了四处结构调整:
+
+| 改动 | 原因 |
+|---|---|
+| 新增 **「新容器上线 · 只看这一页」** 快捷入口 | 手册已 878 行,新人不可能通读。入口只留三步 + 分流表 + 三个高频坑 |
+| §八 提示词**按 A/B 两组重写** | 原来把「状态上报」和「成果上云盘」混在一起,容器容易漏掉 B 组 |
+| §九 检查表**拆成四组**(A 存活 / B 上报 / C 成果 / D 端到端) | 原来一长串平铺,C 组(成果)最常被跳过 |
+| 修正第七章子编号(原 `4.1~4.4` 与第四章冲突) | 交叉引用会指错 |
+
+**§八 提示词的关键修正**:上一版说「App 签好地址」,现在是服务端签。
+容器侧用法不变,但描述必须准确 —— 否则容器会以为要自己处理签名。
+
+**实测支撑**(本轮验证,非推断):
+- 每容器独立目录:`deliverables/agent-node-lj-test/202610/...` vs `deliverables/agent-node-liujun/202610/...` ✅
+- 上传后可读回:PUT 200 → 公网 GET 200,内容一致 ✅
+- 归属校验生效:非 owner 领取队列被拒 403 ✅
+- 测试数据(3 个任务、1 个 OBS 对象、成果记录)已全部清理恢复
+
+## 2026-10-21 · 容器实机联调:成果上传全链路打通(第七轮)
+
+**方式**:我直接通过 Matrix 给容器发指令 → 读它回复 → 判断 → 继续下发,反复几轮直到通过。
+
+### 过程中发现并修复的两个问题
+
+**① 我自己的测试污染了真实交付物(我的失误)**
+
+我为验证上传打通,用了真实成果 `Qtd1VHvLHV` 做载体,并写入了 24 字节测试内容
+(`服务端签名版测试`)。恢复时成果记录复原了,但**对象键是确定性的**
+(`Qtd1VHvLHV/0-HERMES-UPSTREAM-REPORT.md`),容器随后按队列搬运时落到同一个 key。
+
+结果:容器报「搬运成功」,而我 curl 到的是测试内容。**两边都没说谎,是数据被覆盖了。**
+
+修复:对象键加入**本次搬运的短随机标识**
+`deliverables/<agentId>/<YYYYMM>/<deliverableId>/<序号>-<stamp>-<文件名>`
+→ 同一成果重复搬运生成新对象,绝不覆盖。
+同时 `enqueueArtifact` 增加「同附件已有 pending 任务则复用」的幂等。
+
+**② 容器误以为「队列空」是 bug**
+
+实际是设计如此:**入队由 App 侧触发**(App 发现附件没 url 才入队)。
+容器没打开 App,队列自然是空的。已在下发指令中说明。
+
+### 最终真实验证(我自己回读,不采信容器自述)
+
+```
+容器执行  python3 artifact-drain.py
+  → [drain] pending 1
+  → [drain] OK HERMES-UPSTREAM-REPORT.md → …/dYJq7cVvxA/0-mvhiuz9y0k-HERMES-UPSTREAM-REPORT.md
+我 curl    HTTP 200, 3689 bytes, 首行 "# Hermes 上游缺陷报告"
+App 截图   右栏两条成果均显示下载按钮,无「未上云」提示
+```
+
+**cron 已由容器接管**:`last_run_at=10:54:43, last_status=ok, state=scheduled`
+
+### 状态
+
+| 项 | 结果 |
+|---|---|
+| 容器侧脚本部署 | ✅ artifact-drain.py |
+| cron 常驻 | ✅ fmode-artifact-drain, */3 |
+| 每容器独立目录 | ✅ 路径含 agentId |
+| 上传后可读回 | ✅ 3689 字节,内容一致 |
+| App 可下载 | ✅ 截图确认 |
+
+## 2026-10-21 · 会话 UI 三个问题(第八轮)
+
+### ① 横向滚动条(用户截图反馈)
+
+**根因**:`.bubble-col` 是 grid 容器,grid 子项默认 `min-width: auto`。
+`max-width: min(680px,78%)` 只约束了**轨道**,约束不住子项 —— 长中文段落
+(实测 `<p>` 宽 1082px)直接把 `.messages` 撑出横向滚动条。
+
+**修复**:`bubble-col` / `bubble` / `md-render` 各加 `min-width: 0`。
+实测 `.messages` 的 `scrollWidth === clientWidth`,横向滚动消失。
+(`<pre>` 代码块仍单独横滚,这是对的 —— 代码不该强制折行。)
+
+### ② 打开会话不滚动到底
+
+**根因**:`queueMicrotask` 执行时 markdown **还没渲染完**,量到的 `scrollHeight` 偏小。
+实测:加载后距底 **1785px**,且此后不再触发(尾部 id 没变)。
+
+**修复**:改为「立即 + rAF + 120ms」三次贴底;并记住 `userScrolledAway`
+(距底 >160px 时新消息不强行拉回,避免用户翻历史被弹走)。
+实测 `gapToBottom: 0`。
+
+### ③ 容器终端输出淹没会话
+
+**根因两层**:
+- **7/12 是空消息**:`redacts`(撤回)、`m.annotation`(表情回应)、`{}` 被渲染成空白气泡
+- 终端活动是**独立消息**,每条一个气泡
+
+**修复**:
+- 跳过空 body 消息
+- 新增 `isToolActivityMessage()`:识别 `💻 terminal` / `✍️ Writing …` 等格式
+  (要求「含工具 emoji + 短行」,长段落不折,防误判)
+- 聊天页 `messageRows` 把**相邻的**终端活动消息合并成一个折叠组
+
+**实测效果**:42 条消息 → **19 个气泡 + 12 个折叠组**,
+折叠头显示「运行了 3 步 · 💻 terminal」,点开看命令细节。
+
+### 顺带清理
+
+用户确认 lj-test 是测试容器:已删除 3 条成果、1 个队列任务、1 个 OBS 对象。
+
+## 2026-10-21 · 会话 UI 修复 + 四种格式预览(第九轮)
+
+### UI 修复
+
+| 问题 | 根因 | 修复 |
+|---|---|---|
+| 左侧列表「空闲」显示两次 | 标题行 run-chip 与第二行任务描述共用 `taskLine()` | 第二行改为:有任务时显示任务,否则显示专长 |
+| 还在回复却显示「已完成」 | 容器运行态是分钟级上报,落后于实时的消息流 | 新增 `noteMessage()`:最近 20s 内有消息 → 「正在回复…」,优先级高于「已完成」 |
+| 代码块亮底亮字看不清 | prism-okaidia 是暗色主题(亮字),却挂在亮底的 `--surface-2` 上 | 代码块改深底:`--nav-surface` 底 + `--nav-ink` 字,对比度约 15:1 |
+| 复制按钮跟着横向滚动 | 按钮挂在会滚动的 `<pre>` 里 | 外层包 `.md-code`,按钮挂外层,只让 `pre` 滚动 |
+| 上滚加载后位置跳动 | `queueMicrotask` 时新内容还没渲染完,量到旧 `scrollHeight` | 等两帧再补偿 `beforeTop + added`;加载期间加锁禁止贴底 |
+
+### 预览(新增两个组件)
+
+| 格式 | 方案 | 实测 |
+|---|---|---|
+| PDF | `pdfjs-dist` 逐页 canvas | ✅ 3 页渲染,中文正常 |
+| DOCX | `@silurus/ooxml` DocxViewer | ✅ 标题/段落/列表/表格完整 |
+| XLSX | `@silurus/ooxml` XlsxViewer | ✅ 双 sheet、表头样式、缩放 |
+| HTML | 沙箱 iframe | ✅ 页面完整渲染 |
+
+**pdf.js 两个坑(参考项目也没覆盖的)**:
+1. `Promise.try`(ES2025)缺失 → 永远卡在「正在加载」,补 polyfill
+2. `.mjs` worker 开发服务器不提供 → 改 `.js`
+3. `state` 切到 ready 同 tick 内 DOM 未就绪 → 等两帧再渲染
+
+**HTML iframe 坑**:`[src]` 绑定在分支创建时不生效 → 改模板引用 + effect 显式 setAttribute。
+
+**验证**:真实生成 PDF/DOCX/XLSX 三份文件 + HTML,上传 OBS 后逐一打开截图确认。
+
+```
+build ✓  test 559 SUCCESS  verify:ui 11/11  verify:docs 51 文件
+```
+
+## 2026-10-21 · 状态误报 + 心跳归一 + HTML 预览降级(第十轮)
+
+### ① 刷新就显示「正在回复」(用户实测)
+
+**是我上一轮修复引入的 bug。** 上一版只看「消息尾部 id 变了」就判定新消息到达,
+但刷新页面时 sync 会**重新灌入整条历史**,尾部 id 必然变 → 一打开就「正在回复」。
+
+修了两层:
+- 历史重读路径(`effect`)**不再**调用 `noteMessage`
+- 实时路径(`appendIncoming`)**还要看消息自身时间戳**:matrix-js-sdk 在初始同步时
+  也会把历史事件经 `onMessage` 回放一遍,走的是"实时"回调但消息很旧
+  → 超过 60 秒的视为历史重放,不据此判断
+
+实测:刷新后顶部/左侧/右侧全部显示「空闲」。
+
+### ② heartbeatAt 变成 "[object Object]"
+
+云函数返回 Parse Date `{__type:'Date',iso:'…'}`,`String()` 得到 `"[object Object]"`,
+下游 `Date.parse()` 得 `NaN` → **「状态未同步」分支永远触发不到**。
+改为经 `tsOf` 归一。实测修复后右侧能正常显示「状态未同步 · 上次上报 2 分钟前」。
+
+### ③ HTML 预览白屏(CDN 缺 CORS 头)
+
+早期用错误 Content-Type 上传的文件被 CDN 缓存 30 天,**CORS 头被剥掉** →
+sandbox iframe 白屏且**不报错**(浏览器不把 CORS 失败暴露给父页面)。
+
+代码侧能做的是**不让用户盯着空白**:加载超时 8 秒显示降级提示 + 「重新加载」
+(带 `_r=` 参数绕缓存)+「在新标签打开」。实测坏缓存文件能正常提示。
+
+> 根治需在华为云 CDN 控制台刷新该文件缓存 / 配置 CORS 透传;新上传的文件都正常。
+
+```
+build ✓  test 564 SUCCESS  verify:ui 11/11  verify:docs 51 文件
+```
+
+## 2026-10-21 · 预览误判修正 + 预览器放大/缩放(第十一轮)
+
+### ① 「30 秒后显示加载超时」的真相
+
+**页面其实一直是好的**,是判定逻辑错了。
+
+- 我用「能否读到 `contentDocument`」判断加载成功 → sandbox iframe 跨源读不到 → **误判失败**
+- 误判后又每 8 秒重新计时,累计约 24~30 秒才稳定弹出「超时」提示
+- 实测截图证明:不带 sandbox 的 iframe 里,登录页**完整渲染**
+
+修正:`load` 事件即视为成功,只对「始终没有 load」的情况保留 15 秒兜底。
+实测 20 秒内无提示,页面正常显示。
+
+### ② 预览区太小 → 放大 + 缩放 + 全屏
+
+| 项 | 之前 | 现在 |
+|---|---|---|
+| 面板尺寸 | 1000 × 760 | **1400 × 874**(`min(1400px, 96vw)` × `min(92dvh, 1100px)`) |
+| 全屏 | 无 | ✅ 头部按钮,铺满视口 |
+| HTML 缩放 | 无 | ✅ 50%~300%,`transform: scale` |
+
+### ③ 右栏预览走的是另一套旧弹窗(用户点「预览」只看到下载按钮)
+
+`app-dk-chat-page` 里右栏成果用了**独立的 ui-modal**,只认 demo 数据里的 `content` 字段,
+真实成果没有 → 弹窗里只剩一个下载按钮。已改为统一复用 `app-file-preview`。
+
+```
+build ✓  test 564 SUCCESS  verify:ui 11/11  verify:docs 51 文件
+```
+
+## 2026-10-21 · 多容器协作:设计 + 新容器部署手册(第十二轮)
+
+### 用户裁决(4 条)
+
+| # | 裁决 | 落地 |
+|---|---|---|
+| 1 | 先不做公共群,做面向任务的工作组 | 设计文档 §二 |
+| 2 | 权限控制做成设置项,用户自选「全部放开 / 二次确认」 | 设计文档 §五 |
+| 3 | 工作组**仅公司管理员可创建**,普通员工暂不开通 | 设计文档 §三(云函数鉴权) |
+| 4 | 先做 P0 验证 | 见下 |
+
+### 新增需求(用户补充)
+
+**只有同公司的容器能通信** → 建组时筛选即可,不需额外机制。
+已写入设计文档 §2.4:前端筛选 + **服务端二次校验**(`agent.company === group.company`)。
+
+### 调研结论(子代理 + 实测)
+
+- Matrix 传输层**完全够用**(conduwuit v1.18,支持群房间/invite/多成员)
+- 但房间模型、绑定表、云函数、容器准入**全部按 1:1 设计**
+- PRD 明确写「多人群聊排除」,需裁决修改
+- `specialty` 岗位字段在真实链路里**根本不存在**
+
+### P0 受阻
+
+需要「两个同公司且在线」的容器,实测:`lj-test`(在线、同公司)+ `linzong`(同公司但**28 天未上报**),
+`agent-node-liujun` 在线但**不同公司**。凑不出一对。
+
+用户决定:**重新创建一个新容器**用于绑定与验证。
+
+### 产出
+
+`docs/03-interface/新容器从零到可用-部署手册-20261021.md`
+- 5 项能力:Matrix 通信 / 状态上报 / 成果上报 / 文件上云盘 / 工作组(待验证)
+- 每步带「怎么验证」,未验证不往下走
+- 吸收了 workshop 文档(fmode.cn/aipm-lesson)的初始化思路
+
+## 2026-10-21 · 多容器协作 P0 验证完成(第十三轮)
+
+### 新容器 agent-lj 已建好并核实
+
+主容器刘雨飏创建后,我**独立回读平台数据**核对(不采信它自述):
+
+| 项 | 值 | 结果 |
+|---|---|---|
+| objectId | K0nReVCfIk | ✅ |
+| owner | xhVRjTMwQH | ✅ 在你账号下 |
+| company | E4KpGvTEto | ✅ 飞码AI |
+| Matrix 身份 | @k0nrevcfik:fmode.cn | ✅ 服务器上真实存在 |
+| 绑定 | active | ✅ |
+| 上报 | 97 秒前 | ✅ |
+
+### P0 验证结论:多容器通信**可行**
+
+实测证据(群里真实事件):
+- 容器被邀请后**自动 join 群**
+- 容器 A 能 @ 容器 B,B 能收到并回应
+- 双向通信确认
+
+### 🔴 死循环已实证发生
+
+测试群运行 28.6 分钟产生 96 条消息:
+
+| 发送者 | 条数 |
+|---|---|
+| agent-lj | 48 |
+| lj-test | 44 |
+| **人类** | **4** |
+
+**最长连续 agent 消息 57 条,无任何人类介入。** 容器消息与人类消息比 23:1。
+
+### 三个已确认事实
+
+1. `MATRIX_REQUIRE_MENTION=true` → 群内必须 @ 才回
+2. **群房间里 `MATRIX_ALLOWED_USERS` 不生效** → 白名单保护失效,需靠房间成员管理控权
+3. 容器会持续观察对方并反复回应 → 必须防回环
+
+### 产出
+
+`docs/02-design/architecture/多容器协作-P0验证与防回环-20261021.md`
+- 检测规则基于实测数据(连续 5 条 / 10 条每分钟)
+- 暂停后**一次人类介入即解除**,不需后台操作
+- 用户可选 4 档模式(严格/标准/宽松/关闭)
+
+### 已执行清理
+
+测试群两个容器已移出,群内仅剩 owner。

+ 184 - 0
docs/02-design/architecture/多容器协作-P0验证与防回环-20261021.md

@@ -0,0 +1,184 @@
+# 多容器协作 · P0 验证报告与防回环设计
+
+| 字段 | 值 |
+|---|---|
+| 日期 | 2026-10-21 |
+| 验证环境 | `agent-node-lj-test`(存量)+ `agent-lj`(新建) |
+| 测试群 | `!V2DEaZBWsXvSdNhFlWMRDBTBaQNtbMo9HkZX7oEcQMc`(已清场) |
+| 结论 | **多容器通信可行;必须先做防回环** |
+
+---
+
+## 一、P0 验证结果
+
+### 1.1 已验证可行 ✅
+
+| 能力 | 证据 |
+|---|---|
+| 容器被邀请后**自动 join 群** | 日志:`invited to !V2DE... — joining (is_direct=False)` → `joined` |
+| 容器能在群里发言 | `agent-lj` 正常回复群消息 |
+| **容器 A 能 @ 容器 B** | `lj-test` @ `@k0nrevcfik` 成功发送 |
+| **容器 B 能收到并回应** | `lj-test` 记录:「对方(k0nrevcfik)回了」 |
+| **双向通信** | 两容器各自确认「双向都通了」 |
+
+**结论:技术路径完全可行,不需要新增任何通信基建。**
+
+### 1.2 三个必须知道的事实
+
+| # | 事实 | 影响 | 应对 |
+|---|---|---|---|
+| 1 | `MATRIX_REQUIRE_MENTION=true` 时群内必须 @ | 不 @ 就不回 | 按场景配置 |
+| 2 | **群房间内 `MATRIX_ALLOWED_USERS` 不生效** | 白名单保护在群里失效 | 靠**房间成员管理**控权,不靠白名单 |
+| 3 | 容器会**持续观察对方并反复回应** | 死循环 | **必须防回环** |
+
+---
+
+## 二、死循环实证(关键)
+
+测试群运行 **28.6 分钟**产生 **96 条**消息:
+
+| 发送者 | 消息数 |
+|---|---|
+| `agent-lj`(@k0nrevcfik) | 48 |
+| `lj-test`(@zgdcmgnqcd) | 44 |
+| **人类(你)** | **4** |
+
+| 指标 | 实测值 |
+|---|---|
+| 容器消息 : 人类消息 | **23 : 1** |
+| **最长连续 agent 消息(无人类介入)** | **57 条** |
+| 平均速率 | 3.4 条/分钟 |
+
+**57 条连续消息没有任何人类参与** —— 两个容器在互相确认、互相等待、互相汇报"对方还在处理"。
+
+这不是理论风险,是**已经发生的事实**。
+
+---
+
+## 三、防回环设计
+
+### 3.1 核心原则
+
+> **限制的是「无人类介入的自动循环」,不是「容器之间的正常沟通」。**
+
+用户要的是容器像同事一样协作,不是被锁死。所以设计必须做到:
+
+- 正常协作:A 问 B 一个具体问题,B 回答 → **不受影响**
+- 异常循环:A 和 B 互相"收到""还在处理""等你回复" → **自动刹车**
+
+### 3.2 检测规则(基于实测数据)
+
+| 规则 | 阈值 | 依据 |
+|---|---|---|
+| **连续无人类消息** | 连续 ≥ **5** 条 agent 消息且无人类介入 | 实测循环达到 57 条;5 条足够区分「正常几轮对话」与「失控」 |
+| **消息速率** | > **10 条/分钟** | 实测循环平均 3.4 条/分钟,但瞬时可达 20+ |
+| **重复内容** | 相同发送者连续 3 条内容相似度 > 80% | 循环里大量"还在处理""继续等待" |
+
+**三个规则任一命中 → 暂停**。
+
+### 3.3 暂停后做什么(不是永久封锁)
+
+```
+触发暂停
+  → 容器在群里发一条:「⚠️ 检测到自动对话循环,已暂停。
+                        回复「继续」可恢复,或由人类发新消息自动解除。」
+  → 停止自动回复,等待:
+      · 人类发任何消息 → 自动解除,恢复正常
+      · 人类回复「继续」 → 解除
+```
+
+**关键:一次人类介入就解除。** 不需要去后台点按钮。
+
+### 3.4 用户可配置(你要的第 3 点)
+
+在「设置 → 多容器协作」里:
+
+| 选项 | 取值 | 默认 |
+|---|---|---|
+| 协作模式 | `严格` / `标准` / `宽松` / `关闭限制` | **标准** |
+| 连续消息阈值 | 3 / 5 / 10 / 不限 | 5 |
+| 速率阈值(条/分钟) | 5 / 10 / 20 / 不限 | 10 |
+| 暂停后自动恢复 | 开 / 关(需人工确认) | 开 |
+
+| 模式 | 行为 |
+|---|---|
+| **严格** | 连续 3 条 / 5 条每分钟 → 暂停 |
+| **标准** | 连续 5 条 / 10 条每分钟 → 暂停(默认) |
+| **宽松** | 连续 10 条 / 20 条每分钟 → 暂停 |
+| **关闭限制** | 不检测(**会烧 token,仅高阶用户**) |
+
+### 3.5 检测点放在哪
+
+**必须放在服务端,不能只在容器侧。**
+
+原因:
+- 容器侧各自实现,标准不一致,且容器被提示注入可能绕过
+- 服务端能看到全房间的消息流,判断最准
+- 容器重启后状态不丢
+
+```
+Matrix 房间消息 → 云函数(订阅/定时扫描)
+                    ↓ 计算连续数与速率
+                超阈值 → 写入 FmodeWorkGroup.paused
+                    ↓
+              容器按 groupId 拉取「是否暂停」
+                    ↓ 暂停中 → 不自动回复
+```
+
+**容器侧的最小职责**:每次要回复前,先问一次「当前组是否被暂停」。
+
+---
+
+## 四、数据模型(新增)
+
+### `FmodeWorkGroup` 增补字段
+
+| 字段 | 类型 | 说明 |
+|---|---|---|
+| `loopGuardMode` | String | `strict` / `standard` / `loose` / `off` |
+| `paused` | Boolean | 当前是否暂停 |
+| `pausedReason` | String | 暂停原因(循环 / 手动) |
+| `lastHumanAt` | Date | 最近一次人类发言时间 |
+
+### 新增表 `FmodeLoopEvent`(审计用)
+
+| 字段 | 类型 | 说明 |
+|---|---|---|
+| `groupId` | String | 哪个组 |
+| `kind` | String | `loop-detected` / `resumed` / `manual-pause` |
+| `detail` | Object | 当时的连续数、速率、样本消息 ID |
+| `createdAt` | Date | |
+
+**作用**:出问题能回溯「什么时候触发的、触发了多少次、是谁恢复的」。
+
+---
+
+## 五、实现顺序(建议)
+
+| 阶段 | 内容 | 能否独立验证 |
+|---|---|---|
+| **P1** | 服务端回环检测 + `paused` 标记 | 造一个循环,看是否被暂停 |
+| **P2** | 容器侧「回复前查暂停」 | 暂停后容器确实不回 |
+| **P3** | 设置页(4 档模式) | 切换模式,阈值生效 |
+| **P4** | 暂停提示与恢复流程 | 人类发一句能解除 |
+
+---
+
+## 六、还没解决的
+
+| 问题 | 说明 |
+|---|---|
+| 群房间里白名单失效 | 目前靠「谁能进群」控权;需要 CloudFunction 在邀请时严格校验 |
+| 容器「自观察」行为 | `agent-lj` 会持续 `sleep 50; 查群消息` 观察对方,这是它自己的行为模式,不在我们控制内 |
+| Token 成本 | 57 条连续消息的 token 消耗需要量化,作为「关闭限制」时的风险提示 |
+
+---
+
+## 七、用户裁决(2026-10-21)
+
+| # | 裁决 |
+|---|---|
+| 1 | 立刻停止测试群 ✅ 已执行(容器已移出) |
+| 2 | 优化测试,确保能实现且能检测死循环 |
+| 3 | **可以限制,但要保证容器间能正常沟通、减少人为调控;限制强度由用户在设置里控制** |
+| — | 目的始终是:**让用户能实现多容器通信** |

+ 268 - 0
docs/02-design/architecture/多容器协作设计-20261021.md

@@ -0,0 +1,268 @@
+# 多容器协作(数字生命工作组)设计方案
+
+| 字段 | 值 |
+|---|---|
+| 文档编号 | 设计-20261021-多容器协作 |
+| 版本 | v1.0(待评审) |
+| 日期 | 2026-10-21 |
+| 状态 | **待你确认后实施** |
+| 前置裁决 | ① 组织方式=临时工作组 ② 容器间权限=同等权限,可互相派活 ③ 先出设计文档 |
+
+---
+
+## 零、一句话概括
+
+**把「员工协作」建模成「工作组」:用户拉几个容器进一个 Matrix 房间,它们像同事一样在里面说话、派活、交付。**
+
+不新建消息系统 —— Matrix 房间本身就是多人实时通信,平台已有的能力够用。
+真正的改动在三处:**数据模型、云函数、容器侧准入**。
+
+---
+
+## 一、为什么现在做不到(实测结论)
+
+| 层面 | 现状 | 阻塞 |
+|---|---|---|
+| Matrix 传输 | conduwuit,支持群房间 / invite / 多成员 | ✅ **够用** |
+| 容器身份 | 每容器独立 `@xxx:fmode.cn` | ✅ **已是独立身份** |
+| 房间模型 | 只有 1:1 DM(`is_direct: true`,`matrix-client.service.ts:269-274`) | ❌ 无群房间 |
+| 绑定表 | `FmodeMatrixBinding` 主键 `{userPointer, agentId}` + 单 `roomId` | ❌ 结构上 1:1 |
+| 云函数 | `RP5Ht55NeX` 只收 `{agentId}`,无建房/拉人 | ❌ 缺接口 |
+| 容器准入 | `MATRIX_HOME_ROOM` 单房间 + `MATRIX_ALLOWED_USERS=主人` | ❌ 只认主人 |
+| 岗位字段 | `specialty` 真实链路**根本不存在**(`parse-api.ts:627` 起不映射) | ❌ 互相不认识 |
+| PRD | 明确写「多人群聊**排除**」 | ❌ 需产品裁决 |
+
+---
+
+## 二、数据模型
+
+### 2.1 新增表:`FmodeWorkGroup`
+
+> 一个工作组 = 一次任务的临时组合。任务完成即归档,不长期存在。
+
+| 字段 | 类型 | 说明 |
+|---|---|---|
+| `name` | String | 组名,如「登录页重构」 |
+| `goal` | String | **目标描述**(成员据此判断该不该接活) |
+| `company` | Pointer\<Company\> | 多租户隔离(黄金规则 6) |
+| `owner` | Pointer\<_User\> | 创建者(人类) |
+| `roomId` | String | Matrix 房间 ID |
+| `members` | Array\<String\> | 成员 agentId 列表 |
+| `status` | String | `active` / `archived` |
+| `createdAtClient` | Date | 创建时间 |
+
+**CLP**:全部关闭,只走云函数(masterKey)。与 `FmodeArtifactJob` 一致。
+
+### 2.2 `FmodeAgent` 新增字段
+
+| 字段 | 类型 | 来源 | 用途 |
+|---|---|---|---|
+| `role` | String | 容器上报 | 岗位,如 `前端` / `后端` / `设计` |
+| `specialty` | String | 容器上报 | 专长描述,如「Vue / React,擅长表单」 |
+
+**上报方式**:在现有 `xlosi3m46q` 上报里加两个字段,或首次由用户在工作组里手动填写。
+🔴 **不要让容器自己编造岗位** —— 岗位是给人看的,应由用户设定。
+
+### 2.4 同公司隔离(用户裁决 2026-10-21)
+
+**只有同一 Company 下的容器才能进同一个工作组。**
+
+实现方式:**建组时筛选成员**,不做额外机制。
+
+```
+用户打开「新建工作组」
+  → 成员候选 = 当前用户可见 且 company = 当前公司 的容器
+  → 服务端 create 时**再次校验**:所有 members 的 company 必须等于 group.company
+```
+
+| 层面 | 做法 |
+|---|---|
+| 前端 | 选择器只列出本公司的容器 |
+| 云函数 | `create` / `invite` 时校验 `agent.company === group.company`,不符返回 403 |
+| 数据 | `FmodeWorkGroup.company` 必填 |
+
+> 🔴 **不能只靠前端筛选** —— 前端筛选只是体验,服务端校验才是隔离。
+> 一个容器若无 company(实测 54 个里 39 个无归属),**不允许加入任何工作组**。
+
+### 2.3 不新增的表
+
+| 不需要 | 原因 |
+|---|---|
+| `Message` 表 | 消息就在 Matrix,不需要落库(App 本来也不读消息表) |
+| `WorkGroupMember` 表 | 成员就用 `members` 数组,量小(一个组几个到十几个) |
+| `Department` | 之前已裁决不做;工作组是临时的,不承担组织架构 |
+
+---
+
+## 三、云函数契约
+
+新增一个云函数 `fmode-workgroup`(或并入现有函数,二选一,建议**新建**,职责单一)。
+
+| action | 入参 | 行为 | 谁能调 |
+|---|---|---|---|
+| `create` | `name, goal, members[]` | 建 Matrix 房间 → 邀请所有成员 → 写 `FmodeWorkGroup` | **仅人类用户** |
+| `invite` | `groupId, agentId` | 邀请新成员进房间 + 更新 members | 仅人类用户 |
+| `archive` | `groupId` | 房间保留(历史可查),状态置 archived | 仅人类用户 |
+| `list` | — | 当前用户可见的工作组 | 人类 |
+| `members` | `groupId` | 组内成员 + 各自岗位/在线状态 | 人类 / 容器 |
+
+### 3.1 建房流程(关键)
+
+```
+人类创建工作组
+  → 服务端用**管理员身份** createRoom({ preset: TrustedPrivate, name })
+  → 逐个邀请成员(人类 owner + 各 agent 的 mxId)
+  → 各容器收到 invite,自动 join(已有逻辑:matrix-client.service.ts:186-192)
+  → 写入 FmodeWorkGroup
+```
+
+🔴 **为什么必须服务端建房**:容器账号密码是 `sha256("fmode-agent-mx:"+objectId)[:24]`,
+**任何拿到 objectId 的人都能算出来**(`开通手册:173-177`)。如果让容器自己拉人,
+等于任何知道 objectId 的人都能冒充任意容器进任意房间。必须在 provision 层收口。
+
+---
+
+## 四、容器侧要改什么
+
+### 4.1 现在的限制
+
+```bash
+MATRIX_HOME_ROOM=!xxx          # 单房间
+MATRIX_ALLOWED_USERS=@主人:fmode.cn   # 只认主人
+```
+
+### 4.2 需要改成
+
+```bash
+# 多房间:除了 DM 还监听工作组房间
+MATRIX_HOME_ROOM=!dm房间
+MATRIX_WORKGROUP_ROOMS=!组1,!组2     # 新增
+
+# 白名单要放行:主人 + 组内其他成员的 mxId
+MATRIX_ALLOWED_USERS=@主人:fmode.cn,@同事A:fmode.cn,@同事B:fmode.cn
+```
+
+### 4.3 容器必须实现的三件事
+
+| # | 要做 | 为什么 |
+|---|---|---|
+| 1 | 监听多个房间 | 单 `HOME_ROOM` 只能看到一组消息 |
+| 2 | **消息来源标记** | 区分「人说的」和「同事说的」,见 §五 |
+| 3 | **防回环** | A 回 B、B 回 A 会无限循环,必须检测 |
+
+### 4.4 防回环(必须做,否则会死循环)
+
+```
+规则:同一房间里,连续 N 条(建议 3)消息都来自 agent 且无人类介入
+      → 停止自动回复,改为「等待人类确认」
+```
+
+这条不做,两个容器能在几分钟内互发几千条消息。
+
+---
+
+## 五、权限模型(你选了「同等权限,可互相派活」)
+
+这是**你明确选择的方向**,我按它设计,但必须把风险摆明。
+
+### 5.1 风险
+
+一个容器**可以指挥另一个容器做事**,这意味着:
+
+- 容器 A 被提示注入(比如读了一个恶意文件)→ A 指挥 B 删数据库
+- B 会**照做**,因为它把 A 当成合法同事
+- 人类在群里可能来不及发现
+
+### 5.2 我建议的护栏(不削弱你选的方向)
+
+在你的选择之上,加三条**最低成本**的保护:
+
+| 护栏 | 做法 | 对正常使用的影响 |
+|---|---|---|
+| **来源标记** | 消息带 `fmode.source: human \| agent:<agentId>` | 无 |
+| **危险动作二次确认** | 容器执行写操作前,若消息来源是 agent,先在工作组里要一次人类确认 | 只有在容器指挥容器时才需要人点一下 |
+| **完整审计** | 工作组的每条消息都留痕(Matrix 本来就有),可回溯「谁让谁做了什么」 | 无 |
+
+> 如果这三条你觉得也多余,可以去掉第 2 条 —— 但**第 1、3 条建议保留**,
+> 它们不增加操作负担,只是让出问题时能查清楚。
+
+### 5.3 什么算「危险动作」
+
+由容器自己声明,建议最小集合:
+
+```
+写文件(工作目录外)/ 删文件 / 执行 shell / 调用外部 API / 发消息给工作组外的人
+```
+
+---
+
+## 六、App 端改动
+
+### 6.1 新页面:工作组列表 / 详情
+
+```
+/desktop/groups              工作组列表
+/desktop/groups/:id          组内会话(复用现有聊天页)
+```
+
+### 6.2 会话页复用
+
+现有 `chat.ts` 已经支持:消息流、附件、成果、折叠、虚拟滚动。
+工作组会话**本质就是多人房间**,主要改动:
+
+| 改动 | 说明 |
+|---|---|
+| 消息发送者识别 | 现在只分 `me` / `agent`,要扩成 `me` / `agent:<id>` / 多人 |
+| 头像与名字 | 每条消息显示「谁说的」(现有气泡没有 sender 名) |
+| 成员栏 | 右栏增加「组内成员」列表(含岗位、在线状态) |
+
+### 6.3 左侧导航
+
+「会话」旁边新增「工作组」,或合并进会话列表(用不同图标区分)。
+
+---
+
+## 七、分阶段落地
+
+| 阶段 | 内容 | 可独立验证 |
+|---|---|---|
+| **P0** | 容器侧:多房间监听 + 白名单放行 + 防回环 | 手工建组,两个容器能互相回话 |
+| **P1** | 云函数 `fmode-workgroup`:建房/邀请/归档 | 用脚本建组,容器自动进组 |
+| **P2** | App 端:工作组列表 + 组内会话 + 成员栏 | 用户能在 App 里建组、看对话 |
+| **P3** | 岗位字段 + 成员专长展示 | 组内能看出谁是前端谁是后端 |
+| **P4** | 来源标记 + 危险动作确认 + 审计 | 容器指挥容器时有人工确认 |
+
+**建议先做 P0**:不碰数据模型、不改前端,先用两个容器验证「能不能真的互相对话」。
+如果 P0 走不通(比如 conduwuit 对 agent-to-agent 有隐藏限制),后面全白做。
+
+---
+
+## 八、需要你决定的事
+
+| # | 问题 | 我的建议 |
+|---|---|---|
+| 1 | PRD 里「群聊排除」这条要不要改 | 改。改成「不做公共群聊,做面向任务的工作组」 |
+| 2 | 危险动作二次确认要不要保留 | 保留,成本很低 |
+| 3 | 工作组是否需要归属到 Company | 需要(多租户隔离是平台铁律) |
+| 4 | 先做 P0 验证还是直接全量 | **先 P0** |
+
+---
+
+## 九、风险清单
+
+| 风险 | 影响 | 缓解 |
+|---|---|---|
+| 容器账号密码可推导 | 任何人可冒充任意容器 | 建房/拉人**只在服务端**做;后续可改为派生密码加盐 |
+| agent-to-agent 死循环 | 两个容器互发几千条 | 防回环规则(§4.4) |
+| 提示注入跨容器传播 | A 被污染后污染 B | 来源标记 + 危险动作确认 |
+| 群房间被 `MATRIX_ALLOWED_USERS` 拦截 | 消息进不来 | 白名单逐一放行;**P0 必须先验证这条** |
+| 54 个容器中 39 个无 company | 工作组归属混乱 | 建组时要求明确 company |
+
+---
+
+## 十、关联文档
+
+- `docs/03-interface/容器接入上线手册-20261020.md` §7.3(Matrix 三道门)
+- `docs/03-interface/新容器接入-完整流程-20261021.md`(容器标准包)
+- `docs/02-design/architecture/账号与账套体系设计.md`(Company / Profile)
+- `docs/01-requirements/prd/PRD-20261014-fmode-app-v2.md:92`(需修改的范围裁决)

+ 131 - 0
docs/03-interface/发给主容器-创建新容器指令-20261021.md

@@ -0,0 +1,131 @@
+【创建新容器 · 必须绑定到我的账号下】
+
+请为我创建一个新的数字生命容器,并完成绑定。
+所有操作完成后向我汇报,**不要凭印象写"应该成功了"**,每个结论都要有回读证据。
+
+============================================================
+一、必须满足的硬性条件(做不到就停下告诉我)
+============================================================
+
+1. 新容器的 owner 必须是【我的账号】,不是别人:
+     _User.objectId : xhVRjTMwQH
+     username       : fmode19170119836
+     mobile         : 19170119836
+
+2. 新容器必须归属公司:
+     Company.objectId : E4KpGvTEto(飞码AI)
+
+   🔴 这一条特别重要:我名下有多个容器分属不同公司,
+   如果建到别的公司下,后续「多容器协作」功能就用不了
+   (规则:只有同公司的容器才能进同一个工作组)。
+
+3. 新容器必须开通 Matrix 通道(不是企微)。
+   后续人机对话、容器间通信都走 Matrix。
+
+============================================================
+二、执行步骤
+============================================================
+
+第 1 步 · 创建容器
+
+  用你现有的能力创建,容器名建议带业务含义,例如:
+     agent-node-boss1     (或者你建议的命名,告诉我理由)
+
+  创建完成后立刻确认它已经:
+    · 能正常运行(gateway 在跑)
+    · 有独立的 hostname
+
+第 2 步 · 绑定到我的账号
+
+  调用平台接口完成绑定(用我的 sessionToken):
+    POST https://server.fmode.cn/api/functions
+    {
+      "id": "RP5Ht55NeX",
+      "token": "<我的 sessionToken>",
+      "params": { "agentId": "<新容器名>" }
+    }
+
+  返回里应该看到:
+    · userMxId  = @xhvrjtmwqh:fmode.cn   ← 这是我的 Matrix 身份,必须一致
+    · agentMxId = @xxxxxxxxxx:fmode.cn   ← 新容器的身份
+    · roomId    = !xxxxxxxxxx:fmode.cn   ← 人机对话房间
+    · accessToken / deviceId             ← 凭据
+
+  🔴 如果 userMxId 不是 @xhvrjtmwqh:fmode.cn,说明绑到别人名下了,立即停止并告诉我。
+
+第 3 步 · 配置新容器的 Matrix
+
+  在【新容器】的 /opt/data/.env 里配置(不是在你自己的容器里改):
+
+    MATRIX_HOMESERVER=https://matrix.fmode.cn
+    MATRIX_USER_ID=<第2步返回的 agentMxId>
+    MATRIX_PASSWORD=<新容器的密码>
+    MATRIX_HOME_ROOM=<第2步返回的 roomId>
+    MATRIX_ALLOWED_USERS=@xhvrjtmwqh:fmode.cn
+
+  说明:
+    · MATRIX_ALLOWED_USERS 是白名单,只放我一个人的 Matrix ID。
+      不放的话,我发的消息会被容器静默丢弃(这是最常见的问题)。
+    · 不要设 MATRIX_IGNORE_USER_PATTERNS,或确保它是空的。
+
+第 4 步 · 配置状态上报
+
+  新容器的 /opt/data/.env 还要有:
+
+    AGENT_ID=<新容器名>
+    FMODE_SESSION_TOKEN=<我的 sessionToken>
+    AGENT_FUNCTION_ID=xlosi3m46q
+
+  然后部署上报脚本(从现有正常容器拷贝,不要自己重写):
+    /opt/data/git-repos/agent-status-reporter/scripts/status-report.py
+
+  常驻方式用 Hermes 原生 cron(jobs.json),**不要用 while true 循环**:
+    {"job_id":"<生成>","name":"fmode-status-reporter",
+     "schedule":"*/2 * * * *","no_agent":true,"script":"fmode-status-report.sh"}
+
+第 5 步 · 验证(这一节最重要,逐项贴原始输出)
+
+  5.1 容器活着
+      ps aux | grep -i hermes | grep -v grep
+      hostname
+
+  5.2 Matrix 配对了(让新容器自己打印它读到的值)
+      echo "MATRIX_USER_ID=$MATRIX_USER_ID"
+      echo "MATRIX_HOME_ROOM=$MATRIX_HOME_ROOM"
+      echo "ALLOWED=${MATRIX_ALLOWED_USERS:0:30}..."
+
+  5.3 平台侧能查到它
+      curl -s 'https://server.fmode.cn/parse/classes/FmodeAgent?where={"agentId":"<新容器名>"}' \
+        -H 'X-Parse-Application-Id: ncloudmaster'
+      → 看 owner 是不是 xhVRjTMwQH,company 是不是 E4KpGvTEto
+
+  5.4 上报通了
+      等 3 分钟后再查一次上面那条命令,updatedAt 应该在 5 分钟内
+
+============================================================
+三、汇报格式(照这个填,不要自由发挥)
+============================================================
+
+CREATE-DONE
+容器名        = <新容器的 hostname>
+容器ID        = <FmodeAgent.objectId>
+owner         = <回读的 user.objectId>   (必须是 xhVRjTMwQH)
+company       = <回读的 company.objectId>(必须是 E4KpGvTEto)
+agentMxId     = <回读>
+userMxId      = <回读>                   (必须是 @xhvrjtmwqh:fmode.cn)
+roomId        = <回读>
+Matrix配置    = <打印出来的三个值>
+上报状态      = <updatedAt 回读值>
+cron          = <job 名 + 是否已接管>
+遇到的问题     = <有就写,没有写"无">
+
+============================================================
+四、纪律
+============================================================
+
+· 每一步要有**回读证据**,不要写「应该成功了」「按理说没问题」
+· 任一步失败就停下告诉我卡在哪、原始报错是什么,不要自己猜着往下做
+· 不要在新容器里安装或修改与本任务无关的东西
+· 不要打印任何密码、token 的完整值(打前 8 位即可)
+
+确认全部完成后再回复我 CREATE-DONE 报告。

+ 371 - 37
docs/03-interface/容器接入上线手册-20261020.md

@@ -14,6 +14,48 @@
 
 ---
 
+## 新容器上线 · 只看这一页
+
+> **新容器首次接入,直接看《新容器接入-完整流程-20261021.md》** —— 那份是一站式的,
+> 把状态上报、成果上报、云盘搬运、验收、故障速查集中在一处。
+> 本文保留全部**踩坑记录与深度细节**,排查怪问题时用。
+
+### 三步走
+
+| 步 | 做什么 | 怎么做 | 花多久 |
+|---|---|---|---|
+| **1** | 跑自检,判断它是死是活 | 复制 §一 的脚本,**只读**,不改任何东西 | 1 分钟 |
+| **2** | 按自检结果选路径 | 看 §一 末尾的分流表 | — |
+| **3** | 按 §八 的提示词交给容器 | 整段复制给容器,让它自己执行 | 10~20 分钟 |
+
+三步之间的对应关系:
+
+```
+§一 自检  →  有进程有数据 → 只查格式(§三、§四)
+          →  有进程没数据 → 修 token / agentId(§7.1)
+          →  没进程有数据 → ⚠️ 数据不是这台写的,先查来源(§7.2)
+          →  没进程没数据 → 从零部署(§二)
+```
+
+### 交付前必须过的四组检查(§九)
+
+```
+A 存活与配置   B 上报数据   C 成果文件   D 端到端
+```
+
+**C 和 D 最常被跳过** —— 状态上报通了只说明「它活着」,
+成果那组才决定「用户能不能拿到它的产出」。
+
+### 三个最常踩的坑(都在别的文档里写满了,这里只放结论)
+
+| 坑 | 结论 |
+|---|---|
+| 用 `find` 找不到脚本就断定「没部署」 | 查 `ps` / `/proc/<pid>/cmdline`,**查运行态不查路径** |
+| 调用返回 401 就断定「token 过期」 | 先换调用姿势(body 传 token),**失败≠失效** |
+| 报告里的数字是「自己算的」 | 必须**回读平台原文**,贴字面值 |
+
+---
+
 ## 零、先看这个:当前全平台真实状态
 
 2026-10-20 全量盘点 54 个容器:
@@ -87,7 +129,7 @@ echo "=== 自检结束 ==="
 
 | 自检结果 | 说明 | 走 |
 |---|---|---|
-| 1 有进程 + 4 有记录且在 5 分钟内 | ✅ 已在正常上报 | 跳到 §三 查格式 |
+| 1 有进程 + 4 有记录且在 5 分钟内 | ✅ 已在正常上报 | 跳到 §三 查格式、§四 查成果 |
 | 1 有进程 + 4 无记录/停更 | 在跑但上报失败 | §5.1 |
 | 1 无进程 + 4 有记录且在更新 | ⚠️ **数据不是这台写的** | §5.2 |
 | 1 无进程 + 4 停更 | ❌ 死了 | **§二 从零部署** |
@@ -296,7 +338,190 @@ currentAction = sessions.last_activity_description   # 本身就是中文
 
 ---
 
-## 四、验收清单(9 条,缺一不可)
+## 四、成果文件怎么报(附件必须能在 App 里打开)
+
+用户要求:**成果文件统一走 S3/云盘,App 里打开和 AI 后续读取都用云盘那份。**
+
+### 4.1 两个字段,各管一件事
+
+```json
+{
+  "title": "Hermes 上游缺陷报告:Matrix 通道静默丢弃消息",
+  "summary": "一句话说清做了什么",
+  "artifacts": ["/opt/data/git-repos/agent-status-reporter/HERMES-UPSTREAM-REPORT.md"]
+}
+```
+
+| 字段 | 填什么 | 说明 |
+|---|---|---|
+| `title` | **必须有实际含义的中文** | 空标题 / `probe` 这类记录会被 App **过滤掉**(用户实测:右栏出现两条无意义文档,一条是 `probe` 探针、一条只有路径没有名字)|
+| `artifacts` | **容器内的绝对路径** 或 **已是云盘 URL** | 两种都能收,平台负责规范化 |
+
+### 4.2 路径怎么变成云盘地址(App 发起 · 容器执行)
+
+```
+① App 看到成果附件只有 source(容器路径)、没有 url
+② App 用 OBS SigV2 签一个「只允许 PUT 这一个 key、5 分钟有效」的地址
+   (签名算法与云函数 AlP56LCKFm v0.2.2 完全一致,已有单测对拍)
+③ App 调云函数 lfYlgU7SkK action:'enqueueArtifact'
+   → 写表 FmodeArtifactJob(pending,含 uploadUrl / headers / expiresAt)
+④ 容器轮询 action:'pendingArtifacts'(用**自己的** sessionToken,带 agentId)
+⑤ 容器读 source 文件 → 按 job.headers 原样 PUT 到 job.uploadUrl
+⑥ 容器调 action:'ackArtifact' 回执 ok / error
+⑦ 云函数回写 artifacts[i].url = 云盘地址,任务置 done
+⑧ App 下一轮刷新即可预览/下载;AI 后续读取也用这个地址
+```
+
+**容器侧只需要做 ④⑤⑥ 三步**,不需要 AK/SK,也不需要自己算签名 ——
+地址与请求头都由云函数签好写进队列。
+
+#### 容器参考实现(Python,Hermes 容器可直接用)
+
+```python
+#!/usr/bin/env python3
+"""
+成果文件搬运:读队列 → PUT 到云盘 → 回执。
+放到 /opt/data/git-repos/agent-status-reporter/scripts/artifact-drain.py
+"""
+import json, os, urllib.request
+
+BASE = "https://server.fmode.cn/api/functions"
+FN   = "lfYlgU7SkK"
+TOK  = os.environ["FMODE_SESSION_TOKEN"]   # 容器自己的 token
+AGENT_ID = os.environ["AGENT_ID"]
+
+def _call(params):
+    body = json.dumps({"id": FN, "token": TOK, "params": params}).encode()
+    req = urllib.request.Request(BASE, data=body, headers={
+        "Content-Type": "application/json",
+        "X-Parse-Application-Id": "ncloudmaster"})
+    with urllib.request.urlopen(req, timeout=30) as r:
+        return json.loads(r.read())
+
+def drain_once():
+    res = _call({"action": "pendingArtifacts", "agentId": AGENT_ID})
+    if res.get("code") != 200:
+        print("[drain] 取队列失败:", res); return
+    jobs = res.get("jobs") or []
+    if not jobs:
+        return
+    print(f"[drain] 待搬运 {len(jobs)} 个")
+    for j in jobs:
+        try:
+            with open(j["source"], "rb") as f:
+                data = f.read()
+            req = urllib.request.Request(j["uploadUrl"], data=data, method="PUT")
+            for k, v in (j.get("headers") or {}).items():
+                req.add_header(k, v)      # 🔴 原样带上,少一个就 403
+            with urllib.request.urlopen(req, timeout=120) as r:
+                if r.status // 100 != 2:
+                    raise RuntimeError("PUT 返回 %s" % r.status)
+            _call({"action": "ackArtifact", "objectId": j["id"],
+                   "ok": True, "publicUrl": j["publicUrl"]})
+            print(f"[drain] OK {j['filename']} → {j['publicUrl']}")
+        except Exception as e:
+            msg = str(e)[:200]
+            _call({"action": "ackArtifact", "objectId": j["id"],
+                   "ok": False, "error": msg})
+            print(f"[drain] FAIL {j['filename']}: {msg}")
+
+if __name__ == "__main__":
+    drain_once()
+```
+
+**常驻**:与上报器一致,用 Hermes 原生 cron,**不要用 `while true`**(重启会丢)。
+在同一个 `jobs.json` 里再加一条:
+
+```json
+{
+  "job_id": "<生成>",
+  "name": "fmode-artifact-drain",
+  "schedule": "*/3 * * * *",
+  "no_agent": true,
+  "script": "fmode-artifact-drain.sh"
+}
+```
+
+**这个脚本不做的事**(故意的):
+
+| 不做 | 原因 |
+|---|---|
+| 不自己算签名 | 地址由 App 签好放进队列,容器照用即可 |
+| 不持有 AK/SK | 凭据出不了 App 的内存 |
+| 不判断该不该传 | 队列里有的就是该传的;已传过的不再出现 |
+| 重试失败的 | 失败即回执,由 App 侧重新排队(避免容器陷入死循环) |
+
+#### 签名由谁做(v0.2.1 定稿:只有服务端)
+
+**浏览器全程不接触任何密钥。** 云端用服务端已有的 AK/SK 签好,App 只报「传哪个」。
+
+```
+App  → enqueueArtifact { deliverableId, agentId, source, filename, index }
+        ↓  云函数用服务端密钥签名(5 分钟有效)
+     FmodeArtifactJob { uploadUrl, publicUrl, headers, expiresAt }
+        ↓
+容器 → pendingArtifacts → 照单 PUT → ackArtifact
+```
+
+**为什么改掉了「App 自签」**:
+
+|  | App 自签(已废弃) | 服务端签(现在) |
+|---|---|---|
+| 浏览器里有密钥吗 | 有,DevTools 可见 | **没有** |
+| 密钥存几份 | **2 份**(两个云函数各一份) | **1 份** |
+| 轮换密钥 | 改两处 | **改一处** |
+| 凭证有效期 | 长期 | 签出的地址 **5 分钟** |
+
+**密钥位置**:云函数 `lfYlgU7SkK` 内的 `OBS_AK` / `OBS_SK` 常量
+(与 `AlP56LCKFm` 同值;两个函数都仅 masterKey 可读)。
+
+> ⚠️ 仍存在两处副本。**彻底去重的办法**是把它们挪到平台的环境变量 /
+> `CloudProvider` 表,两个函数读同一处 —— 需要平台侧配置,未做。
+> 在没做之前:**轮换密钥时两个函数都要改**,这是当前唯一需要"两边同时改"的地方。
+
+#### 签名的两个坑(都已实测)
+
+| 坑 | 正确做法 |
+|---|---|
+| `x-obs-acl` 必须出现在**待签字符串**且 PUT 时带头 | 少了就 403;已实测 PUT 200 |
+| 临时凭据的 `x-obs-security-token` 是**查询子资源**,不是请求头 | 写成头会 `SignatureDoesNotMatch`。依据 `future-server/modules/fmode-listen-api/src/obs.ts:173-177` |
+
+#### 对象键规则(谁放哪,必须唯一)
+
+```
+deliverables/<agentId>/<YYYYMM>/<deliverableId>/<序号>-<文件名>
+```
+
+| 段 | 来源 | 作用 |
+|---|---|---|
+| `deliverables/` | 固定前缀 | STS/签名授权边界;运维按前缀清理 |
+| `<agentId>` | 成果所属容器 | **不同容器**的同名文件天然隔离 |
+| `<YYYYMM>` | 上传时间(UTC) | 按月归档,便于生命周期策略 |
+| `<deliverableId>` | 成果 objectId | **一个成果一个目录**,多附件聚在一起 |
+| `<序号>` | 附件在 `artifacts[]` 的下标 | **同名附件不互相覆盖** |
+| `<文件名>` | 原文件名(已清洗) | 下载时仍可读 |
+
+> 🔴 **曾出过的问题**:初版是 `<deliverableId>-<文件名>`,没有序号。
+> 一个成果里有**两个同名附件**(如 `a.md`)时会算出同一个 key,后传的覆盖前传的
+> —— 用户看到两个文件,打开是同一份。
+>
+> 实测真实数据:`a.md` 出现 3 次、`test.txt` 出现 2 次,50 条成果里 4 条是多附件。
+> **这不是理论风险。** 已在真实 OBS 上验证:换成带序号的规则后两份同名文件可共存。
+
+**幂等**:同一附件(同成果、同序号)重复签,得到**同一个 key**,
+不会因为重试而产生垃圾对象。
+
+### 4.3 禁止做法
+
+| 做法 | 为什么不行 |
+|---|---|
+| 标题为空的成果 | App 会过滤,用户看到的是「少了一条」而不是「一条空的」 |
+| 用 `probe` / `test` 当标题做连通性测试 | 会混进用户视野;测试请用 `extra` 字段标记,别用 title |
+| 把 `artifacts` 写成逗号拼接的大字符串 | 必须是数组,一项一个文件 |
+
+---
+
+## 五、验收清单(9 条,缺一不可)
 ```bash
 curl -s 'https://server.fmode.cn/parse/classes/FmodeAgent?where={"agentId":"<容器名>"}' \
   -H 'X-Parse-Application-Id: ncloudmaster'
@@ -316,7 +541,7 @@ curl -s 'https://server.fmode.cn/parse/classes/FmodeAgent?where={"agentId":"<容
 
 ---
 
-## 五、🔴 四条铁律(本轮全部踩过)
+## 六、🔴 四条铁律(本轮全部踩过)
 
 ### 铁律 1 · 查运行态,不查路径
 
@@ -383,9 +608,9 @@ lastInboundAgeSecs  = 业务活跃度(只展示,不参与健康判断)
 
 ---
 
-## 六、故障速查表
+## 七、故障速查表
 
-### 4.1 上报返回 `agent not found` / 数据不落库
+### 7.1 上报返回 `agent not found` / 数据不落库
 
 | 可能 | 检查 |
 |---|---|
@@ -393,7 +618,7 @@ lastInboundAgeSecs  = 业务活跃度(只展示,不参与健康判断)
 | `AGENT_FUNCTION_ID` 填错 | 返回值带 `"key":"user/.../log/...json"` = 走了 OBS 上传(`AlP56LCKFm`),应为 `xlosi3m46q` |
 | token 失效 | 换姿势重试(见铁律 2);仍 401 才考虑重登 |
 
-### 4.2 数据在更新,但容器里找不到写入方
+### 7.2 数据在更新,但容器里找不到写入方
 
 **症状**:平台 `updatedAt` 在跳,但 `ps` 里没有 reporter 进程。
 
@@ -416,7 +641,7 @@ ps -eo pid,ppid,user,cmd --forest | head -60
 
 **判定技巧**:如果某个字段格式**全平台独一份**,那写入方一定在它自己相关的某处,不是平台统一逻辑。
 
-### 4.3 容器"在线"但不回消息(Matrix)
+### 7.3 容器"在线"但不回消息(Matrix)
 
 这是本轮最复杂的故障,**三道门依次拦截**:
 
@@ -452,7 +677,7 @@ grep -iE "inbound message" /opt/data/logs/gateway.log | tail -10
 [env] ALLOWED = [...]
 ```
 
-### 4.4 上报进程随重启消失
+### 7.4 上报进程随重启消失
 
 **症状**:gateway 重启后,平台 `updatedAt` 冻住不动。
 
@@ -462,31 +687,53 @@ grep -iE "inbound message" /opt/data/logs/gateway.log | tail -10
 
 ---
 
-## 七、给容器的标准操作提示词
+## 八、给容器的标准操作提示词
+
+> 整段复制给容器即可。**两件事**:状态上报 + 成果文件上云盘。
+> 前者是「让平台知道它活着」,后者是「让 App 能打开它的产出」。
 
 ```markdown
-【容器状态上报 · 标准接入】
+【Fmode 容器接入 · 标准操作】
+
+你现在要做两件事:
+  A. 状态上报 —— 让平台知道你在运行、在做什么
+  B. 成果搬运 —— 把你产出的文件送到云盘,App 才能打开
+
+请严格按顺序执行,每一步都要有实际回读的证据,不要凭印象报告。
 
-一、先查存活(不要跳过)
+============================================================
+A. 状态上报
+============================================================
+
+A1. 先查存活(不要跳过)
   ps aux | grep -iE "status|report" | grep -v grep
   ls -l /proc/<pid>/cmdline
   → 有进程:先确认它在报什么、报给谁,别急着重装
-  → 无进程:按下面从零部署
+  → 无进程:按 A2 从零部署
 
-二、部署(不要自己写脚本)
+A2. 部署(不要自己写脚本)
   mkdir -p /opt/data/git-repos/agent-status-reporter/{scripts,state}
   从同宿主正常容器 docker cp 原版 status-report.py
 
-三、配置 /opt/data/.env(先备份)
-  AGENT_ID=<容器名>
+A3. 配置 /opt/data/.env(先备份)
+  cp /opt/data/.env /opt/data/.env.bak-$(date +%Y%m%d%H%M%S)
+  AGENT_ID=<你的容器名>
   FMODE_SESSION_TOKEN=<主人 token>
-  AGENT_FUNCTION_ID=xlosi3m46q
+  AGENT_FUNCTION_ID=xlosi3m46q        ← 填错会走到 OBS 上传,数据不进库
   WECOM_BOT_ID=<企微 BotID>
 
-四、常驻(不要用 while true 循环 —— 重启会丢)
-  用 Hermes 原生 cron 或 s6
+A4. 常驻(不要用 while true 循环 —— gateway 重启会把它带走)
+  用 Hermes 原生 cron(jobs.json),schedule "*/2 * * * *"
+
+A5. 状态字段怎么填(详见手册 §三)
+  status       从 Hermes state.db 现场判定,**不要硬编码 running**
+               sessions.last_activity_description 非空且新鲜 → running
+               已被上游清空 → idle
+  currentTask  「处理<渠道>用户对话」,不要写上报器自己的活动
+  currentAction 直接取 last_activity_description
+  progress    没有真实数据就不填
 
-五、验收(9 条,回读平台值,不许自算)
+A6. 验收(9 条,每条都要**回读平台值**,不许自算)
   □ updatedAt 5 分钟内
   □ host.cpuPercent 0~100
   □ host.memPercent / diskPercent
@@ -497,44 +744,131 @@ grep -iE "inbound message" /opt/data/logs/gateway.log | tail -10
   □ usage.totalTokens > 0
   □ balance.remainCny 有值
 
-六、纪律
-  · 查运行态(ps/proc),不查路径(find)
-  · 调用失败先换姿势,再怀疑 token
-  · 报告里每个数字必须是回读原文的字面值
-  · 配置要用「打印自己读到的值」验证,不要从 /proc 或文件推测
+============================================================
+B. 成果文件上云盘
+============================================================
+
+B1. 先理解分工
+  你的文件在容器里(如 /opt/data/...),App 打不开。
+  平台会自动把文件搬到云盘,你只需要:
+
+    ① 轮询队列拿「待搬运任务」
+    ② 读文件 → PUT 到任务里给的地址
+    ③ 回执成功/失败
+
+  🔴 你不需要,也不应该:
+     ✗ 持有任何 AK/SK
+     ✗ 自己拼 S3 地址
+     ✗ 自己算签名
+     ✗ 判断哪个文件该传
+     地址和请求头都由平台签好放在任务里,照用即可。
+
+B2. 上报成果时怎么写
+  调用云函数 lfYlgU7SkK,action='report':
+    title     必须有含义的中文(空标题 / "probe" 会被 App 过滤掉)
+    artifacts **容器内绝对路径**,例如:
+              ["/opt/data/work/report.md"]
+              (已是 https:// 开头的地址也接受,会跳过搬运)
+    summary   一句话说清做了什么
+
+  一个成果可以有多个附件,全部列进 artifacts 数组即可。
+  同名文件不会互相覆盖(平台按数组下标区分)。
+
+B3. 部署搬运脚本
+  mkdir -p /opt/data/git-repos/agent-status-reporter/scripts
+  写入 artifact-drain.py(完整代码见手册 §4.2)
+  验证:python3 /opt/data/git-repos/agent-status-reporter/scripts/artifact-drain.py
+        → 没有任务时应静默退出(无输出是正常的)
+
+B4. 常驻(与 A4 同样的位置,再加一条)
+  jobs.json 里新增:
+    {
+      "job_id": "<生成一个>",
+      "name": "fmode-artifact-drain",
+      "schedule": "*/3 * * * *",
+      "no_agent": true,
+      "script": "fmode-artifact-drain.sh"
+    }
+
+  注意:脚本里 token 要从环境变量读,不要写死:
+    TOK = os.environ["FMODE_SESSION_TOKEN"]
+    AGENT_ID = os.environ["AGENT_ID"]
+
+B5. 验收
+  □ 手动跑脚本,输出里能看到 "待搬运 N 个"(有任务时)
+  □ 平台回读该成果的 artifacts[].url,应是 https://s3.fmode.cn/deliverables/<你的容器ID>/...
+  □ 该 URL 用 curl 能 200 读到内容
+  □ 文件路径里含你的容器 ID(说明存在你自己的目录下,不会和别人混)
+
+============================================================
+纪律(两条都踩过坑)
+============================================================
+
+  · 查运行态(ps / /proc/<pid>/cmdline),不要用 find 找文件就断定"没部署"
+  · 调用云函数失败先换姿势(body 里传 token),再怀疑 token 失效
+  · 报告里每个数字必须是**回读原文的字面值**,不要写"应该没问题"
+  · 配置是否生效要看「程序自己打印出来的值」,不要从 /proc/<pid>/environ 推测
+    (.env 是 Python 层加载的,不进 environ)
 
 报告格式:
+
 REPORTER-OK agentId=<...>
 updatedAt=<平台回读>
 cpuPercent=<回读> memPercent=<回读> diskPercent=<回读>
 channels=<对象/数组> matrix=<true/false>
 heartbeatAt=<回读> usage.totalTokens=<回读> balance.remainCny=<回读>
 常驻方式=<cron/s6/其他>
+
+ARTIFACT-OK
+queue=<能取到任务吗>
+sampleUrl=<回读到的 artifacts[].url 字面值>
+httpStatus=<该 URL 的 curl 状态码>
+objectKey=<完整对象键,应含你的 agentId>
 ```
 
 ---
 
-## 八、上线检查表(复制给执行人)
+## 九、上线检查表(复制给执行人)
+
+按 **四组** 逐项勾选。任一组未过,都算没上线。
 
 ```markdown
-□ 存活三查(ps / proc / 平台回读)已做
-□ 脚本是原版(不是自写复刻)
-□ .env 已备份
+A. 存活与配置
+□ 存活三查做过(ps / /proc/<pid>/cmdline / 平台回读)
+□ 用的是原版 status-report.py,不是自写复刻
+□ .env 改前已备份
 □ AGENT_ID / FMODE_SESSION_TOKEN / AGENT_FUNCTION_ID / WECOM_BOT_ID 四项已配
-□ 上报走 xlosi3m46q(返回不是 "key":"user/.../log/...json")
-□ 常驻用 cron 或 s6(不是 while true)
-□ 平台回读 updatedAt 在 5 分钟内
-□ 9 条验收断言全过(§四)
-□ 任务状态语义正确(§三)
-□ 机器人在 App 会话里能实际回复消息
-□ status 在消息处理完后从 running 翻成 idle(不是恒 running)
-□ currentTask 是业务任务而非上报器自身活动
+□ AGENT_FUNCTION_ID=xlosi3m46q(返回值里没有 "key":"user/.../log/...json")
+□ 常驻方式不是 while true(cron 或 s6)
+
+B. 上报数据(每项都要回读平台值)
+□ updatedAt 在 5 分钟内
+□ 9 条验收断言全过(§五)
+□ 任务状态语义正确:status 从 running 能翻成 idle(§三)
+□ currentTask 是业务任务,不是上报器自己的活动
 □ 重启 gateway 后上报能自动恢复
+
+C. 成果文件(App 里要能打开)
+□ 成果上报时 title 有实际含义(不是空、不是 probe)
+□ artifacts 填的是容器内绝对路径
+□ artifact-drain.py 已部署且手动能跑通
+□ jobs.json 里加了 fmode-artifact-drain(*/3 * * * *)
+□ 回读 artifacts[].url 是 https://s3.fmode.cn/deliverables/<本容器ID>/...
+□ 该 URL 用 curl 能 200 读到内容
+□ 对象键里含本容器 ID(证明存在自己目录下)
+
+D. 端到端
+□ 在 App 会话里发消息,机器人能实际回复
+□ App 刷新后,该成果出现可点的下载按钮
+□ 下载下来的内容与容器内原文件一致
 ```
 
+**最容易被跳过、也最容易出问题的是 C 组**:
+状态上报通了只说明"它活着",C 组才决定"它的产出用户能不能拿到"。
+
 ---
 
-## 九、关联文档
+## 十、关联文档
 
 - `docs/03-interface/reporter字段规范-20261020.md` —— 逐字段契约(改脚本时查)
 - `docs/03-interface/容器初始化流程优化建议-20261020.md` —— workshop 流程优化 + 方法论

+ 404 - 0
docs/03-interface/新容器从零到可用-部署手册-20261021.md

@@ -0,0 +1,404 @@
+# 新容器 · 从零到可用 · 完整部署手册
+
+| 字段 | 值 |
+|---|---|
+| 版本 | v1.0 |
+| 日期 | 2026-10-21 |
+| 适用 | **全新创建的容器**,从零配到能在 App 里对话、上报状态、上传成果 |
+| 前置 | 容器已创建并启动(有 Hermes gateway 在跑) |
+| 参照 | 已跑通的 `agent-node-lj-test`(唯一完整样本) |
+
+> 这份手册是**照着做就能通**的。每一步都标了「怎么验证」——
+> 没验证过就不要往下走,否则问题会累积到后面很难定位。
+
+---
+
+## 零、总览:要让容器具备的 5 项能力
+
+| # | 能力 | 依赖 | 做完的判据 |
+|---|---|---|---|
+| 1 | **Matrix 通信** | 平台开好的 Matrix 身份 | App 里发消息能收到回复 |
+| 2 | **状态上报** | 云函数 `xlosi3m46q` | 平台 `updatedAt` 5 分钟内 |
+| 3 | **成果上报** | 云函数 `lfYlgU7SkK` | App 成果栏能看到 |
+| 4 | **文件上云盘** | 搬运队列 + cron | 下载按钮可点、内容正确 |
+| 5 | **(后续)工作组** | 多房间监听 | 待 P0 验证 |
+
+前 4 项**现在就能做完**。第 5 项是待验证的新能力,见 §六。
+
+---
+
+## 一、先搞清楚:新容器的身份从哪来
+
+新容器创建后,你手上有:
+
+```
+容器名(hostname)   例:agent-node-xxxxx
+Matrix 身份          例:@abcdefg123:fmode.cn(由平台分配)
+房间 ID              例:!xxxxx:fmode.cn
+```
+
+**这些不是你自己造的**,而是平台通过 `RP5Ht55NeX`(provision)分配的。
+
+### 怎么拿到
+
+在 App 里打开该容器的会话,或直接调:
+
+```
+POST https://server.fmode.cn/api/functions
+{ "id": "RP5Ht55NeX", "token": "<你的 sessionToken>", "params": { "agentId": "<容器名>" } }
+→ { roomId, userMxId, agentMxId, accessToken, deviceId, homeserver }
+```
+
+🔴 **注意**:返回的 `accessToken` 是**你(主人)的** token,不是容器的。
+容器有自己独立的身份 `agentMxId`,凭据是容器自己持有的。
+
+---
+
+## 二、给容器的第一段话(复制粘贴)
+
+> 新建好容器后,先发这段话让它做自检,**不要一上来就配置**。
+
+```markdown
+【环境自检 · 只读,不要改任何东西】
+
+请依次执行并贴出**原始输出**:
+
+1. 身份与环境
+   hostname
+   echo "AGENT_ID=$AGENT_ID"
+   env | grep -iE "FMODE|MATRIX|WECOM" | sed 's/=.*/=<已设置>/'
+
+2. Hermes 是否在跑
+   ps aux | grep -i hermes | grep -v grep | head -3
+
+3. Matrix 通道状态
+   ls /opt/data/logs/gateway.log 2>/dev/null && grep -iE "initial sync complete|matrix.*connect" /opt/data/logs/gateway.log | tail -3
+
+4. 网络连通性
+   curl -s -o /dev/null -w "parse:%{http_code}\n" https://server.fmode.cn/parse/health
+   curl -s -o /dev/null -w "matrix:%{http_code}\n" https://matrix.fmode.cn/_matrix/client/versions
+
+5. 现有配置文件
+   ls -la /opt/data/.env 2>/dev/null || echo "无 .env"
+
+报告格式:
+SELFCHECK
+hostname=
+agent_id=
+hermes=<运行中/未运行>
+matrix=<已连接/未连接/未配置>
+network=<parse状态码>/<matrix状态码>
+结论=<还缺什么>
+```
+
+**看它的报告,对照下表决定下一步:**
+
+| 自检结果 | 走哪条 |
+|---|---|
+| Matrix 已连接 + 能收发消息 | 跳过 §三,直接 §四 |
+| Matrix 未配置 | §三 从零配 Matrix |
+| Hermes 未运行 | 容器创建有问题,找平台方 |
+
+---
+
+## 三、Matrix 接入(核心)
+
+### 3.1 容器需要什么
+
+| 项 | 值 | 从哪来 |
+|---|---|---|
+| homeserver | `https://matrix.fmode.cn` | 固定 |
+| agent 的 Matrix ID | `@xxxxx:fmode.cn` | provision 返回的 `agentMxId` |
+| agent 的密码 | 容器自己持有 | 平台下发 |
+| 主人在 Matrix 的 ID | `@xxxxx:fmode.cn` | provision 返回的 `userMxId` |
+| DM 房间 | `!xxxxx:fmode.cn` | provision 返回的 `roomId` |
+
+### 3.2 `.env` 配置(改前先备份)
+
+```bash
+cp /opt/data/.env /opt/data/.env.bak-$(date +%Y%m%d%H%M%S)
+```
+
+```bash
+# ---- Matrix ----
+MATRIX_HOMESERVER=https://matrix.fmode.cn
+MATRIX_USER_ID=@<agent的id>:fmode.cn
+MATRIX_PASSWORD=<平台下发的密码>
+MATRIX_HOME_ROOM=!<DM房间id>:fmode.cn
+
+# 只接受主人发来的消息(第一道门)
+MATRIX_ALLOWED_USERS=@<主人的id>:fmode.cn
+
+# 如果不配这个,所有消息都会被丢弃
+# MATRIX_IGNORE_USER_PATTERNS=   ← 留空或不要设
+
+# ---- 平台 ----
+AGENT_ID=<容器名>
+FMODE_SESSION_TOKEN=<主人 token>
+AGENT_FUNCTION_ID=xlosi3m46q
+```
+
+### 3.3 Matrix 的三道门(**最容易卡住的地方**)
+
+消息从主人发到容器,要过三道关,任何一道没过都会「静默丢弃」:
+
+| 门 | 现象 | 怎么查 |
+|---|---|---|
+| ① `MATRIX_IGNORE_USER_PATTERNS` | 消息进了 sync 但没反应 | `grep -iE "ignoring sender\|ignore pattern" gateway.log` |
+| ② `MATRIX_ALLOWED_USERS` | 日志显示 `Unauthorized user (ignored)` | `grep -i "Unauthorized user" gateway.log` |
+| ③ 房间白名单 | DM 一般豁免,群房间可能被拦 | 见 §六 |
+
+**排查命令**:
+
+```bash
+# ① 消息有没有进 sync
+grep -n "<event_id>" /opt/data/logs/gateway.log*
+
+# ② 有没有被忽略
+grep -iE "ignoring sender|ignore pattern" /opt/data/logs/gateway.log*
+
+# ③ 有没有被授权门挡
+grep -iE "Unauthorized user|unrecognized" /opt/data/logs/gateway.log*
+
+# ④ 有没有 inbound 记录
+grep -iE "inbound message" /opt/data/logs/gateway.log | tail -5
+```
+
+> ⚠️ **配置读取的假阴性**:`.env` 是 Python 层 `load_hermes_dotenv()` 加载的,
+> **不会出现在 `/proc/<pid>/environ`**。用它查「有没有配」永远是空。
+> 正确做法:**让容器打印它自己读到的生效值**。
+
+### 3.4 验证 Matrix 通了
+
+```markdown
+【验证 Matrix 通道】
+
+请做两件事并贴原始输出:
+
+1. 打印你实际读到的 Matrix 配置(不要打印密码):
+   echo "MATRIX_USER_ID=$MATRIX_USER_ID"
+   echo "MATRIX_HOME_ROOM=$MATRIX_HOME_ROOM"
+   echo "ALLOWED=${MATRIX_ALLOWED_USERS:0:20}..."
+
+2. 确认 gateway 在正常 sync:
+   grep -iE "initial sync complete" /opt/data/logs/gateway.log | tail -1
+   tail -20 /opt/data/logs/gateway.log | grep -iE "sync|error"
+
+我会在 App 里给你发一条测试消息,你收到后回复「收到,时间 <当前时间>」。
+```
+
+---
+
+## 四、状态上报
+
+### 4.1 部署脚本(不要自己重写)
+
+```bash
+mkdir -p /opt/data/git-repos/agent-status-reporter/{scripts,state}
+# 从正常容器拷,或从仓库取
+docker cp <正常容器>:/opt/data/git-repos/agent-status-reporter/scripts/status-report.py \
+          /opt/data/git-repos/agent-status-reporter/scripts/
+```
+
+> 原版 1100 行覆盖了变化检测、失败队列、token 自愈、任务残留清理、
+> channel 探测、usage 聚合。**自己复刻必然漏掉一部分。**
+
+### 4.2 状态字段怎么填(Hermes 容器必读)
+
+**Hermes 没有独立"任务"对象,任务 = 一个 turn。**
+
+| 字段 | 从哪来 | ❌ 禁止 |
+|---|---|---|
+| `status` | `state.db` 的 `sessions.last_activity_description` | 硬编码 `running` |
+| `currentTask` | `处理<渠道>用户对话` | 写上报器自己的活动 |
+| `currentAction` | 直接取 `last_activity_description` | 写 `reporter heartbeat @ …` |
+| `progress` | 没有可靠数据 → **不填** | 编一个数 |
+
+```python
+desc = sessions.last_activity_description
+age  = now - sessions.last_activity_at
+if desc and age < 180:   status = "running"
+elif desc == "":         status = "idle"      # 上游 turn 结束会清空
+else:                    status = "error"
+```
+
+源码依据:`/opt/hermes/agent/activity_tracking.py:120-138`
+
+### 4.3 常驻(**不要用 while true**)
+
+| 方式 | 可靠性 |
+|---|---|
+| **Hermes 原生 cron** | ✅ 推荐 |
+| s6 服务 | ✅ 最稳,需 root |
+| `while true` 循环 | ❌ 重启就丢 |
+
+```json
+{ "job_id": "<生成>", "name": "fmode-status-reporter",
+  "schedule": "*/2 * * * *", "no_agent": true, "script": "fmode-status-report.sh" }
+```
+
+---
+
+## 五、成果上报 + 文件上云盘
+
+### 5.1 上报成果
+
+调用 `lfYlgU7SkK`,`action='report'`:
+
+```json
+{
+  "agentId": "<容器名>",
+  "title": "有实际含义的中文标题",
+  "summary": "一句话说清做了什么",
+  "artifacts": ["/opt/data/work/report.md"]
+}
+```
+
+| 字段 | 规则 |
+|---|---|
+| `title` | 🔴 必须有含义。空标题 / `probe` 会被 App 过滤 |
+| `artifacts` | **容器内绝对路径** |
+
+### 5.2 文件上云盘
+
+**好消息:容器不需要做任何额外配置。** 平台会自动搬运。
+
+```
+App 打开 → 发现附件没 url → 自动入队
+容器 cron 调 pendingArtifacts → 自动补登记本容器的成果
+容器读文件 → PUT → 回执
+平台回写 url
+```
+
+容器只需部署一个搬运脚本(见下)。
+
+### 5.3 搬运脚本
+
+```python
+#!/usr/bin/env python3
+# /opt/data/git-repos/agent-status-reporter/scripts/artifact-drain.py
+import json, os, urllib.request
+
+BASE = "https://server.fmode.cn/api/functions"
+FN   = "lfYlgU7SkK"
+TOK  = os.environ["FMODE_SESSION_TOKEN"]
+AGENT_ID = os.environ["AGENT_ID"]
+
+def _call(params):
+    body = json.dumps({"id": FN, "token": TOK, "params": params}).encode()
+    req = urllib.request.Request(BASE, data=body, headers={
+        "Content-Type": "application/json",
+        "X-Parse-Application-Id": "ncloudmaster"})
+    with urllib.request.urlopen(req, timeout=30) as r:
+        return json.loads(r.read())
+
+def drain_once():
+    res = _call({"action": "pendingArtifacts", "agentId": AGENT_ID})
+    jobs = res.get("jobs") or []
+    if not jobs:
+        return
+    print("[drain] pending %d" % len(jobs))
+    for j in jobs:
+        try:
+            with open(j["source"], "rb") as f:
+                data = f.read()
+            req = urllib.request.Request(j["uploadUrl"], data=data, method="PUT")
+            for k, v in (j.get("headers") or {}).items():
+                req.add_header(k, v)      # 🔴 原样带上,少一个就 403
+            with urllib.request.urlopen(req, timeout=120) as r:
+                if r.status // 100 != 2:
+                    raise RuntimeError("PUT %s" % r.status)
+            _call({"action": "ackArtifact", "objectId": j["id"],
+                   "ok": True, "publicUrl": j["publicUrl"]})
+            print("[drain] OK %s -> %s" % (j["filename"], j["publicUrl"]))
+        except Exception as e:
+            msg = str(e)[:200]
+            _call({"action": "ackArtifact", "objectId": j["id"], "ok": False, "error": msg})
+            print("[drain] FAIL %s: %s" % (j["filename"], msg))
+
+if __name__ == "__main__":
+    drain_once()
+```
+
+### 5.4 常驻
+
+```json
+{ "job_id": "<生成>", "name": "fmode-artifact-drain",
+  "schedule": "*/3 * * * *", "no_agent": true, "script": "fmode-artifact-drain.sh" }
+```
+
+---
+
+## 六、多容器协作(待 P0 验证 · 暂不部署)
+
+> 🔴 **这一节现在不要执行。** 等 P0 验证通过后我再给具体配置。
+
+需要验证的关键问题:
+
+1. 群房间里,容器能不能收到**其他容器**的消息?
+2. `MATRIX_ALLOWED_USERS` 对群房间是否会被拦截?
+3. 房间白名单(第三道门)对非 DM 房间是什么行为?
+
+如果 1 能通、2/3 有明确解法,多容器协作就能落地。
+
+---
+
+## 七、验收清单
+
+```markdown
+A. Matrix 通信
+□ 容器打印的 MATRIX_USER_ID / HOME_ROOM 与 provision 返回一致
+□ App 发消息 → 容器回复
+□ gateway.log 有 initial sync complete
+
+B. 状态上报
+□ 平台 updatedAt 5 分钟内
+□ cpuPercent / memPercent / diskPercent 有值
+□ channels 是对象不是数组
+□ heartbeatAt 60 秒内
+□ status 能 running → idle
+
+C. 成果与文件
+□ 上报成果成功(返回 id)
+□ artifacts[].url = https://s3.fmode.cn/deliverables/<容器ID>/...
+□ 该 URL curl 能 200
+□ App 里点下载内容正确
+
+D. 端到端
+□ App 里发消息、机器人回复
+□ App 里能看到成果并下载
+```
+
+---
+
+## 八、常见故障
+
+| 现象 | 原因 | 解法 |
+|---|---|---|
+| App 发消息没回复 | Matrix 三道门 | §3.3 |
+| 平台看不到容器 | 上报没配 | §四 |
+| 成果有但下载不了 | 搬运没跑 | §5.3 |
+| 状态永远 running | 硬编码了 | §4.2 |
+| 重启后上报停止 | while true | 改 cron |
+
+---
+
+## 九、和《新容器接入-完整流程-20261021》的关系
+
+| 文档 | 用途 |
+|---|---|
+| **本文** | 全新容器从零到可用,**按顺序执行** |
+| 《新容器接入-完整流程》 | 协议细节、字段规范、故障速查 |
+| 《容器接入上线手册-20261020》 | 历史踩坑记录,排查怪问题时翻 |
+
+---
+
+## 十、需要你提供的
+
+新容器创建好后,请告诉我:
+
+1. **容器名**(hostname)
+2. **它是否已经在 App 里绑定**(能收到 provision 返回就说明绑定了)
+3. **你希望它扮演什么角色**(前端?后端?设计?——后续工作组要用)
+
+我会用它做 P0 多容器验证。

+ 361 - 0
docs/03-interface/新容器接入-完整流程-20261021.md

@@ -0,0 +1,361 @@
+# 新容器接入 · 完整手册(一站式)
+
+| 字段 | 值 |
+|---|---|
+| 版本 | v1.0 |
+| 日期 | 2026-10-21 |
+| 适用 | **任何新容器**上线,或存量容器排查 |
+| 取代 | 本文把所有相关协议集中到一处;细节与踩坑记录仍见 `容器接入上线手册-20261020.md` |
+
+> 本文是**照着做就能通**的那一份。每一步都来自真实跑通的流程,
+> 不是设计稿。遇到问题先看第七节,那里是最常见的六类故障。
+
+---
+
+## 零、一页总览
+
+```
+容器要做两件事
+  A. 状态上报   → 平台知道它活着、在做什么
+  B. 成果搬运   → 它的产出 App 能打开
+
+平台侧已有的东西
+  云函数 xlosi3m46q   状态上报
+  云函数 lfYlgU7SkK   成果上报 + 搬运队列(v0.2.2)
+  云函数 RP5Ht55NeX   Matrix 开通
+  表 AgentDeliverable 成果
+  表 FmodeArtifactJob 搬运队列
+  OBS storage-s3-nkkj 云盘(公网 s3.fmode.cn)
+```
+
+**三条铁律**(都踩过):
+
+1. 查**运行态**(`ps` / `/proc/<pid>/cmdline`),不要用 `find` 找不到就断定没部署
+2. 调用失败先**换姿势**(body 里传 token),再怀疑 token 失效
+3. 报告里每个数字必须是**回读原文的字面值**,不要写"应该没问题"
+
+---
+
+## 一、上线前自检(1 分钟,只读)
+
+```bash
+AGENT_ID="${AGENT_ID:-$(hostname)}"
+echo "hostname : $(hostname)"
+echo "AGENT_ID : $AGENT_ID"
+
+# 有没有上报进程、它实际跑的是哪个脚本
+PROC=$(ps aux | grep -iE "status-report|status_report" | grep -v grep)
+[ -n "$PROC" ] && echo "✅ 进程: $(echo "$PROC" | head -1)" || echo "❌ 无上报进程"
+
+# 关键变量(读实际生效值)
+for k in AGENT_ID FMODE_SESSION_TOKEN AGENT_FUNCTION_ID WECOM_BOT_ID; do
+  v=$(printenv "$k" 2>/dev/null || grep -E "^${k}=" /opt/data/.env 2>/dev/null | cut -d= -f2-)
+  [ -n "$v" ] && echo "  ✅ $k = ${v:0:12}..." || echo "  ❌ $k 未配置"
+done
+
+# 平台侧有没有记录
+curl -s "https://server.fmode.cn/parse/classes/FmodeAgent?where={\"agentId\":\"$AGENT_ID\"}" \
+  -H 'X-Parse-Application-Id: ncloudmaster' -o /tmp/fa.json
+grep -o '"updatedAt":"[^"]*"' /tmp/fa.json | head -1 || echo "  ❌ 无记录"
+```
+
+### 自检结果 → 走哪条路
+
+| 结果 | 说明 | 去哪 |
+|---|---|---|
+| 有进程 + 有记录且 5 分钟内 | ✅ 活着 | 只查格式:§三、§四 |
+| 有进程 + 无记录/停更 | 在跑但上报失败 | §7.1 |
+| 无进程 + 有记录且在更新 | ⚠️ **数据不是这台写的** | §7.2 |
+| 无进程 + 停更 | ❌ 死了 | 从 §二 部署 |
+
+---
+
+## 二、状态上报部署
+
+### 2.1 脚本(不要自己重写)
+
+```bash
+mkdir -p /opt/data/git-repos/agent-status-reporter/{scripts,state}
+# 从同宿主正常容器拷,或从仓库取
+docker cp <正常容器>:/opt/data/git-repos/agent-status-reporter/scripts/status-report.py \
+          /opt/data/git-repos/agent-status-reporter/scripts/
+```
+
+> 原版 1100 行覆盖了变化检测、失败队列、token 自愈、任务残留清理、官方 channel 探测、
+> usage 聚合。自己复刻必然漏掉一部分 —— 全平台唯一格式偏离(`balance` 用了 `cny` 键)
+> 就来自一个自写脚本。
+
+### 2.2 配置 `/opt/data/.env`(改前备份)
+
+```bash
+cp /opt/data/.env /opt/data/.env.bak-$(date +%Y%m%d%H%M%S)
+```
+
+| 变量 | 值 | 填错会怎样 |
+|---|---|---|
+| `AGENT_ID` | 容器名 | 缺省会退化成 hostname → agent not found |
+| `FMODE_SESSION_TOKEN` | 主人 sessionToken | 决定归属 |
+| `AGENT_FUNCTION_ID` | `xlosi3m46q` | 🔴 填错会走 OBS 上传,数据进桶不进库 |
+| `WECOM_BOT_ID` | 企微 BotID | 有企微通道时必填 |
+
+### 2.3 常驻
+
+| 方式 | 可靠性 |
+|---|---|
+| **Hermes 原生 cron**(jobs.json) | ✅ 推荐,gateway 重启后自动继续 |
+| s6 服务 | ✅ 最稳,但普通用户建不了 |
+| `while true` 循环 | ❌ **随会话死,重启就丢** |
+
+```json
+{ "job_id": "<生成>", "name": "fmode-status-reporter",
+  "schedule": "*/2 * * * *", "no_agent": true, "script": "fmode-status-report.sh" }
+```
+
+### 2.4 状态字段怎么填(Hermes 容器必读)
+
+**Hermes 没有独立"任务"对象,任务 = 一个 turn。**
+
+| 字段 | 从哪来 | 禁止 |
+|---|---|---|
+| `status` | `state.db` 的 `sessions.last_activity_description` | 硬编码 `running` |
+| `currentTask` | `处理<渠道>用户对话` | 写上报器自己的活动 |
+| `currentAction` | 直接取 `last_activity_description` | 写 `reporter heartbeat @ …` |
+| `progress` | 没有可靠数据 → **不填** | 编一个数 |
+
+```python
+desc = sessions.last_activity_description
+age  = now - sessions.last_activity_at
+if desc and age < 180:   status = "running"
+elif desc == "":         status = "idle"      # 上游 turn 结束会清空
+else:                    status = "error"     # DB 读不到,如实报错
+```
+
+源码依据:`/opt/hermes/agent/activity_tracking.py:120-138` 的
+`reset_activity_labels_after_turn()`。
+
+---
+
+## 三、成果上报
+
+调用云函数 `lfYlgU7SkK`,`action='report'`:
+
+```json
+{
+  "agentId": "<容器名>",
+  "title": "有实际含义的中文标题",
+  "summary": "一句话说清做了什么",
+  "artifacts": ["/opt/data/work/report.md"]
+}
+```
+
+| 字段 | 规则 |
+|---|---|
+| `title` | 🔴 必须有实际含义。空标题 / 纯符号 / `probe`(且无附件)会被 App **过滤掉** |
+| `artifacts` | **容器内绝对路径**;已是 `https://` 的也接受(跳过搬运) |
+| 多附件 | 全部列进数组,同名文件不会互相覆盖(按数组下标区分) |
+
+---
+
+## 四、成果文件上云盘
+
+### 4.1 分工
+
+```
+App   → 发现成果附件只有 source 没 url → 调 enqueueArtifact
+云端  → 用服务端密钥签好上传地址,写进 FmodeArtifactJob
+容器  → 轮询 pendingArtifacts → 读文件 → PUT → ackArtifact
+云端  → 回写 artifacts[].url
+App   → 下次刷新即可下载
+```
+
+**容器不需要**:AK/SK、自己拼地址、自己算签名、判断哪个文件该传。
+
+### 4.2 部署搬运脚本
+
+```python
+#!/usr/bin/env python3
+# /opt/data/git-repos/agent-status-reporter/scripts/artifact-drain.py
+import json, os, urllib.request
+
+BASE = "https://server.fmode.cn/api/functions"
+FN   = "lfYlgU7SkK"
+TOK  = os.environ["FMODE_SESSION_TOKEN"]
+AGENT_ID = os.environ["AGENT_ID"]
+
+def _call(params):
+    body = json.dumps({"id": FN, "token": TOK, "params": params}).encode()
+    req = urllib.request.Request(BASE, data=body, headers={
+        "Content-Type": "application/json",
+        "X-Parse-Application-Id": "ncloudmaster"})
+    with urllib.request.urlopen(req, timeout=30) as r:
+        return json.loads(r.read())
+
+def drain_once():
+    res = _call({"action": "pendingArtifacts", "agentId": AGENT_ID})
+    if res.get("code") != 200:
+        print("[drain] fetch failed:", res); return
+    jobs = res.get("jobs") or []
+    if not jobs:
+        return
+    print("[drain] pending %d" % len(jobs))
+    for j in jobs:
+        try:
+            with open(j["source"], "rb") as f:
+                data = f.read()
+            req = urllib.request.Request(j["uploadUrl"], data=data, method="PUT")
+            for k, v in (j.get("headers") or {}).items():
+                req.add_header(k, v)          # 🔴 原样带上,少一个就 403
+            with urllib.request.urlopen(req, timeout=120) as r:
+                if r.status // 100 != 2:
+                    raise RuntimeError("PUT %s" % r.status)
+            _call({"action": "ackArtifact", "objectId": j["id"],
+                   "ok": True, "publicUrl": j["publicUrl"]})
+            print("[drain] OK %s -> %s" % (j["filename"], j["publicUrl"]))
+        except Exception as e:
+            msg = str(e)[:200]
+            _call({"action": "ackArtifact", "objectId": j["id"], "ok": False, "error": msg})
+            print("[drain] FAIL %s: %s" % (j["filename"], msg))
+
+if __name__ == "__main__":
+    drain_once()
+```
+
+### 4.3 常驻(jobs.json 再加一条)
+
+```json
+{ "job_id": "<生成>", "name": "fmode-artifact-drain",
+  "schedule": "*/3 * * * *", "no_agent": true, "script": "fmode-artifact-drain.sh" }
+```
+
+### 4.4 对象键规则(谁放哪)
+
+```
+deliverables/<agentId>/<YYYYMM>/<deliverableId>/<序号>-<随机短标识>-<文件名>
+```
+
+| 段 | 作用 |
+|---|---|
+| `<agentId>` | **每个容器独立目录**,同名文件天然隔离 |
+| `<YYYYMM>` | 按月归档 |
+| `<deliverableId>` | 一个成果一个目录 |
+| `<序号>` | 同名附件不互相覆盖 |
+| `<随机短标识>` | 重复搬运生成新对象,**绝不覆盖旧文件** |
+
+**实测隔离效果**:
+```
+容器 A: deliverables/agent-node-lj-test/202610/.../0-a.md
+容器 B: deliverables/agent-node-liujun/202610/.../0-a.md
+```
+
+**回读方式**:`artifacts[].url` 就是公网地址,`curl` 直接能读。
+AI 后续读取也用这个地址。⚠️ 当前是公开可读(知道 URL 即可访问)。
+
+---
+
+## 五、验收:四组,缺一不可
+
+```markdown
+A. 存活与配置
+□ 存活三查做过(ps / /proc/<pid>/cmdline / 平台回读)
+□ 用的是原版 status-report.py
+□ .env 改前已备份
+□ AGENT_ID / FMODE_SESSION_TOKEN / AGENT_FUNCTION_ID / WECOM_BOT_ID 四项已配
+□ AGENT_FUNCTION_ID=xlosi3m46q
+□ 常驻方式不是 while true
+
+B. 上报数据(每项回读平台值)
+□ updatedAt 5 分钟内
+□ cpuPercent 0~100 / memPercent / diskPercent
+□ channels 是对象不是数组
+□ heartbeatAt 60 秒内
+□ usage.totalTokens > 0
+□ balance.remainCny 有值
+□ status 能 running → idle(不是恒 running)
+□ currentTask 是业务任务
+
+C. 成果文件
+□ title 有实际含义
+□ artifacts 填的是容器内绝对路径
+□ artifact-drain.py 已部署且手动跑得通
+□ jobs.json 加了 fmode-artifact-drain
+□ 回读 artifacts[].url 是 https://s3.fmode.cn/deliverables/<本容器ID>/...
+□ 该 URL curl 能 200
+□ 对象键含本容器 ID
+
+D. 端到端
+□ App 里发消息,机器人能回复
+□ App 刷新后成果出现下载按钮
+□ 下载内容与容器原文件一致
+```
+
+---
+
+## 六、给容器的标准提示词
+
+见《容器接入上线手册-20261020.md》§八(A 状态上报 / B 成果搬运 两组),
+直接整段复制给容器。
+
+---
+
+## 七、故障速查
+
+### 7.1 上报返回 `agent not found` / 数据不落库
+
+| 可能 | 检查 |
+|---|---|
+| `AGENT_ID` 没生效 | `ps auxww \| grep status-report` 看完整命令行 |
+| `AGENT_FUNCTION_ID` 填错 | 返回值带 `"key":"user/.../log/...json"` = 走了 OBS |
+| token 失效 | 换姿势重试(body 传 token),仍 401 才考虑重登 |
+
+### 7.2 数据在更新,但容器里找不到写入方
+
+```bash
+hostname                              # 和平台 host.hostname 对比
+grep -rl "<agentId>" /opt /etc /root /home 2>/dev/null
+ps -eo pid,ppid,user,cmd --forest | head -60
+```
+
+**判定技巧**:某字段格式全平台独一份 → 写入方一定在它自己相关的某处。
+
+### 7.3 容器"在线"但不回消息(Matrix 三道门)
+
+| 门 | 症状 | 修复 |
+|---|---|---|
+| `MATRIX_IGNORE_USER_PATTERNS` | 消息进 sync 但被静默丢弃 | 从忽略名单移除 |
+| `MATRIX_ALLOWED_USERS` | 抛 `Unauthorized user (ignored)` | 加进允许名单 |
+| 房间白名单 | DM 被当非白名单 | 一般 DM 豁免 |
+
+> ⚠️ **配置假阴性**:`.env` 是 Python 层 `load_hermes_dotenv()` 加载的,
+> **不进 `/proc/<pid>/environ`**。用它查"有没有配"永远是空。
+> 正确做法:让容器打印它自己读到的生效值。
+
+### 7.4 上报进程随重启消失
+
+原因是 `while true` 起的循环随 gateway 一起死。改用 Hermes 原生 cron 或 s6。
+
+### 7.5 成果显示「未上云」
+
+| 检查 | 说明 |
+|---|---|
+| `pendingArtifacts` 返回 403 | 容器不是该 agent 的 owner |
+| 队列为空 | **正常** —— 入队由 App 触发,App 没打开就不会入队 |
+| cron 没跑 | `last_status` 看是不是 ok |
+| `artifacts[].url` 为空 | 搬运没成功,看任务 `error` 字段 |
+
+### 7.6 文件上传成功但内容不对
+
+| 可能 | 排查 |
+|---|---|
+| 源路径写错 | 容器里 `ls -l <source>` 看大小,和云端对比 |
+| 对象被覆盖 | 新版本对象键含随机标识,不会覆盖;若怀疑缓存,看 `Last-Modified` |
+| 上传的是测试文件 | 检查成果的 `artifacts[].source` 指向哪 |
+
+---
+
+## 八、关联文档
+
+- 《容器接入上线手册-20261020.md》—— 逐节详述 + 全部踩坑记录
+- 《reporter字段规范-20261020.md》—— 字段契约
+- 云函数 `lfYlgU7SkK` v0.2.2(成果 + 搬运队列)
+- 云函数 `xlosi3m46q` v0.1.3(状态上报)
+- 云函数 `AlP56LCKFm` v0.2.2(聊天附件预签名)

+ 1 - 0
package.json

@@ -3,6 +3,7 @@
   "version": "0.0.0",
   "scripts": {
     "ng": "ng",
+    "postinstall": "node scripts/copy-pdf-worker.mjs",
     "start": "ng serve",
     "build": "ng build",
     "watch": "ng build --watch --configuration development",

+ 33 - 0
scripts/copy-pdf-worker.mjs

@@ -0,0 +1,33 @@
+#!/usr/bin/env node
+/**
+ * 把 pdf.js 的 worker 复制到 public/。
+ *
+ * ## 为什么要这一步
+ *
+ * pdf.js 需要 worker 文件从**同源**加载(`/pdf.worker.js`),
+ * 但它只有新版 `.min.mjs`,而开发服务器对 `public/` 下的 `.mjs` 不解析,
+ * 因此复制成 `.js`。
+ *
+ * ## 为什么不进 git
+ *
+ * 1.3 MB 的构建产物,随 pdfjs 版本变化,没理由占仓库空间。
+ * 由 `postinstall` 自动生成,克隆后 `npm install` 即得。
+ */
+import { copyFileSync, existsSync, mkdirSync } from 'node:fs';
+import { dirname, resolve } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const here = dirname(fileURLToPath(import.meta.url));
+const root = resolve(here, '..');
+const src = resolve(root, 'node_modules/pdfjs-dist/build/pdf.worker.min.mjs');
+const dest = resolve(root, 'public/pdf.worker.js');
+
+if (!existsSync(src)) {
+  // 未安装 pdfjs-dist(如只装了部分依赖)时静默跳过,不阻塞 npm install
+  console.log('[copy-pdf-worker] 跳过:未找到 pdfjs-dist');
+  process.exit(0);
+}
+
+mkdirSync(dirname(dest), { recursive: true });
+copyFileSync(src, dest);
+console.log('[copy-pdf-worker] 已生成 public/pdf.worker.js');

+ 53 - 3
src/app/components/domain/message-bubble/message-bubble.ts

@@ -2,7 +2,9 @@ import { ChangeDetectionStrategy, Component, computed, input, output } from '@an
 import { Message } from '../../../core/api/models';
 import { fmtRelTime } from '../../../core/time';
 import { fmtSize } from '../../../core/format';
+import { Block, splitMessage, hasProcess } from '../../../core/message-segments';
 import { MdRenderComponent } from '../../preview/md-render/md-render';
+import { ToolActivityComponent } from '../tool-activity/tool-activity';
 
 /**
  * 消息气泡 —— 底座零聊天语义组件,必须自建(《组件规范》§四-1)
@@ -17,7 +19,7 @@ import { MdRenderComponent } from '../../preview/md-render/md-render';
 @Component({
   selector: 'app-message-bubble',
   changeDetection: ChangeDetectionStrategy.OnPush,
-  imports: [MdRenderComponent],
+  imports: [MdRenderComponent, ToolActivityComponent],
   template: `
     @if (message().kind === 'notice') {
       <p class="notice">{{ message().text }}</p>
@@ -39,8 +41,25 @@ import { MdRenderComponent } from '../../preview/md-render/md-render';
             </button>
           } @else {
             <div class="bubble" [class.failed]="message().state === 'failed'">
-              <!-- markdown 渲染(agent 回复常含 md:加粗/列表/代码块/表格) -->
-              <app-md-render [text]="message().text ?? ''" [compact]="true" />
+              <!--
+                agent 回复里混着「工具调用 / 过程提示」行(形如 圆点 + 工具名)。
+                这里拆成块:正文照常渲染,**连续的过程行折叠成一行**,
+                用户展开才看细节(对齐 Codex 桌面端的「Worked for」)。
+              -->
+              @if (processy()) {
+                @for (b of blocks(); track $index) {
+                  @if (b.kind === 'process') {
+                    <app-tool-activity [block]="b" />
+                  } @else if (b.kind === 'blank') {
+                    <span class="seg-gap" aria-hidden="true"></span>
+                  } @else {
+                    <app-md-render [text]="b.text" [compact]="true" />
+                  }
+                }
+              } @else {
+                <!-- markdown 渲染(agent 回复常含 md:加粗/列表/代码块/表格) -->
+                <app-md-render [text]="message().text ?? ''" [compact]="true" />
+              }
             </div>
           }
 
@@ -82,9 +101,20 @@ import { MdRenderComponent } from '../../preview/md-render/md-render';
         display: grid;
         gap: var(--sp-1);
         max-width: min(680px, 78%);
+        /*
+         * 🔴 必须显式允许收缩。
+         *
+         * grid 子项的默认 min-width 是 auto —— 内容(尤其是不含空格的
+         * 长中文串、URL、代码)撑多宽就多宽:max-width 只约束了轨道,
+         * 约束不住子项。于是气泡把 .messages 撑出横向滚动条(用户实测)。
+         *
+         * min-width: 0 让轨道允许收窄,配合下方的 overflow-wrap 才真正断行。
+         */
         min-width: 0;
       }
       .bubble {
+        min-width: 0;
+        overflow-wrap: anywhere;
         padding: var(--sp-3) var(--sp-4);
         border-radius: var(--r-lg);
         background: var(--surface);
@@ -95,6 +125,16 @@ import { MdRenderComponent } from '../../preview/md-render/md-render';
         background: var(--primary-subtle);
         border-color: transparent;
       }
+      /* 分段渲染后各块之间的间距(markdown 块自带 margin,这里只兜底) */
+      .bubble app-md-render:not(:first-child) {
+        display: block;
+        min-width: 0;
+        margin-top: var(--sp-2);
+      }
+      .seg-gap {
+        display: block;
+        height: var(--sp-2);
+      }
       .bubble.failed {
         border-color: var(--danger);
       }
@@ -220,6 +260,16 @@ export class MessageBubbleComponent {
 
   readonly fmtSize = fmtSize;
 
+  /**
+   * 拆分结果(仅当**确实有**可折叠过程时才分段渲染)。
+   * 没有过程行的普通回复仍走整段 markdown,避免把 md 语法拆坏。
+   */
+  readonly processy = computed(() => hasProcess(this.message()));
+
+  readonly blocks = computed<Block[]>(() =>
+    this.processy() ? splitMessage(this.message().text ?? '') : [],
+  );
+
   rel(ts: string): string {
     return fmtRelTime(ts);
   }

+ 169 - 0
src/app/components/domain/tool-activity/tool-activity.ts

@@ -0,0 +1,169 @@
+import { ChangeDetectionStrategy, Component, computed, input, signal } from '@angular/core';
+import { Block, stepCountLabel } from '../../../core/message-segments';
+
+/**
+ * 过程折叠区 —— 把 agent 回复里的「工具调用 / 过程提示」收成一行。
+ *
+ * ## 参照(学习对象,不是复制代码)
+ *
+ * Codex 桌面端在「agent 活动」与「最终回复」之间放一条折叠行:
+ *
+ * ```
+ * › Worked for 2m 13s            ← 折叠态:chevron + 用时
+ * ```
+ *
+ * 展开后每步一行,可继续点开参数。本项目采纳**信息结构**:
+ *
+ * ```
+ * › 运行了 3 步 · 已完成         ← 折叠态
+ *   · bash   git status --short   ← 展开态
+ *   · read_file  /opt/data/…
+ * ```
+ *
+ * ## 纪律
+ *
+ * · **不编用时**:消息只有一个时间戳,没有逐步时间 → 不显示「用时 X 秒」
+ * · **颜色全走令牌**:过程区用 `--muted` / `--edge`,不抢正文注意力
+ * · **键盘可达**:折叠头是原生 button,带 aria-expanded
+ * · **尊重 prefers-reduced-motion**:只动画 chevron 的 transform
+ */
+@Component({
+  selector: 'app-tool-activity',
+  changeDetection: ChangeDetectionStrategy.OnPush,
+  template: `
+    <div class="activity">
+      <button
+        type="button"
+        class="head"
+        [attr.aria-expanded]="open()"
+        (click)="open.set(!open())"
+      >
+        <i class="fa-solid fa-chevron-right chev" [class.open]="open()" aria-hidden="true"></i>
+        <span class="label">{{ summary() }}</span>
+      </button>
+      @if (open()) {
+        <ul class="steps">
+          @for (s of steps(); track $index) {
+            <li class="step">
+              <span class="dot" aria-hidden="true"></span>
+              <span class="step-label">{{ s.label }}</span>
+              @if (s.detail) {
+                <code class="step-detail">{{ s.detail }}</code>
+              }
+            </li>
+          }
+        </ul>
+      }
+    </div>
+  `,
+  styles: [
+    `
+      :host {
+        display: block;
+      }
+      .activity {
+        /* 不使用左边竖线:与气泡左边界对齐时看起来像多出一条分隔线 */
+        padding-left: 2px;
+      }
+      .head {
+        display: inline-flex;
+        align-items: center;
+        gap: var(--sp-2);
+        max-width: min(680px, 78%);
+        min-height: 28px;
+        padding: 0 var(--sp-3);
+        border: 1px solid var(--edge);
+        border-radius: var(--r-tag);
+        background: var(--surface-2);
+        color: var(--ink-2);
+        font-size: var(--fs-12);
+        cursor: pointer;
+      }
+      .head:hover {
+        color: var(--ink);
+        background: var(--surface-hover);
+      }
+      .label {
+        min-width: 0;
+        overflow: hidden;
+        text-overflow: ellipsis;
+        white-space: nowrap;
+      }
+      .chev {
+        font-size: var(--fs-11);
+        transition: transform var(--motion-fast) var(--ease-standard);
+      }
+      .chev.open {
+        transform: rotate(90deg);
+      }
+      .steps {
+        margin: var(--sp-1) 0 0;
+        padding: 0;
+        list-style: none;
+        display: grid;
+        gap: 2px;
+      }
+      .step {
+        display: flex;
+        align-items: baseline;
+        gap: var(--sp-2);
+        min-width: 0;
+        font-size: var(--fs-12);
+        color: var(--ink-2);
+      }
+      .dot {
+        width: 4px;
+        height: 4px;
+        flex-shrink: 0;
+        border-radius: 50%;
+        background: var(--edge-strong);
+        transform: translateY(-2px);
+      }
+      .step-label {
+        flex-shrink: 0;
+        font-weight: 600;
+      }
+      .step-detail {
+        min-width: 0;
+        overflow: hidden;
+        text-overflow: ellipsis;
+        white-space: nowrap;
+        color: var(--muted);
+        font-family: var(--font-mono, ui-monospace, monospace);
+        font-size: var(--fs-11);
+      }
+      @media (prefers-reduced-motion: reduce) {
+        .chev {
+          transition: none;
+        }
+      }
+    `,
+  ],
+})
+export class ToolActivityComponent {
+  /** 这一段里的过程块(由 `message-bubble` 拆分后传入) */
+  readonly block = input.required<Extract<Block, { kind: 'process' }>>();
+
+  readonly open = signal(false);
+
+  readonly steps = computed(() => this.block().steps);
+
+  /**
+   * 折叠头文案。
+   * 末步是「已完成」类状态时带完成标记,让用户不展开也知道这段结束了。
+   */
+  readonly summary = computed(() => {
+    const steps = this.steps();
+    const last = steps[steps.length - 1];
+    const done = last?.kind === 'status' && /✅|✔|完成/.test(last.label);
+    const n = stepCountLabel(steps.length);
+    /*
+     * 折叠态要能让用户一眼看出「它动了什么」,而不只是「运行了 N 步」。
+     * 取第一步的标签作为代表(那通常是动作名,如 `💻 terminal`、`✍️ Writing …`)。
+     */
+    const first = steps[0]?.label ?? '';
+    const head = first ? `${n} · ${first}` : n;
+    return done ? `${head} · 已完成` : head;
+  });
+}
+

+ 36 - 2
src/app/components/preview/file-kind.spec.ts

@@ -47,8 +47,13 @@ describe('file-kind 判定', () => {
       expect(previewKindOf('a.markdown')).toBe('markdown');
     });
 
-    it('Office 类(含 csv)', () => {
-      for (const e of ['doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'csv']) {
+    it('Office 类:docx/xlsx/csv 有专门预览,其余归 office(提示下载)', () => {
+      // 2026-10-21:docx / xlsx / csv 细分出专门预览组件
+      expect(previewKindOf('a.docx')).toBe('docx');
+      expect(previewKindOf('a.xlsx')).toBe('xlsx');
+      expect(previewKindOf('a.csv')).toBe('xlsx');
+      // 老格式与 PPT 仍不给在线预览
+      for (const e of ['doc', 'xls', 'ppt', 'pptx']) {
         expect(previewKindOf(`a.${e}`)).toBe('office');
       }
     });
@@ -97,3 +102,32 @@ describe('file-kind 判定', () => {
     });
   });
 });
+
+/**
+ * 新增预览类型(2026-10-21):
+ * html / docx / xlsx 从原 text / office 里细分出来,各自有专门预览组件。
+ */
+describe('previewKindOf 新增类型', () => {
+  it('html / htm → html(用沙箱 iframe 预览)', () => {
+    expect(previewKindOf('index.html')).toBe('html');
+    expect(previewKindOf('https://s3.fmode.cn/a/b.htm')).toBe('html');
+  });
+
+  it('docx → docx(本地渲染)', () => {
+    expect(previewKindOf('报告.docx')).toBe('docx');
+  });
+
+  it('xlsx / csv → xlsx(表格预览)', () => {
+    expect(previewKindOf('数据.xlsx')).toBe('xlsx');
+    expect(previewKindOf('导出.csv')).toBe('xlsx');
+  });
+
+  it('老格式 doc / xls 仍归 office(提示下载,不假装可预览)', () => {
+    expect(previewKindOf('旧文档.doc')).toBe('office');
+    expect(previewKindOf('旧表格.xls')).toBe('office');
+  });
+
+  it('🔴 html 不再被当纯文本渲染(曾落到 text 分支显示源码)', () => {
+    expect(previewKindOf('page.html')).not.toBe('text');
+  });
+});

+ 21 - 2
src/app/components/preview/file-kind.ts

@@ -8,7 +8,18 @@
  */
 
 /** 预览器种类 */
-export type PreviewKind = 'image' | 'video' | 'audio' | 'pdf' | 'markdown' | 'office' | 'text' | 'unknown';
+export type PreviewKind =
+  | 'image'
+  | 'video'
+  | 'audio'
+  | 'pdf'
+  | 'markdown'
+  | 'html'
+  | 'docx'
+  | 'xlsx'
+  | 'office'
+  | 'text'
+  | 'unknown';
 
 const IMAGE_EXT = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'bmp', 'avif'];
 const VIDEO_EXT = ['mp4', 'webm', 'mov', 'm4v', 'ogv'];
@@ -16,7 +27,12 @@ const AUDIO_EXT = ['mp3', 'wav', 'ogg', 'm4a', 'aac', 'flac'];
 const PDF_EXT = ['pdf'];
 const MD_EXT = ['md', 'markdown'];
 const OFFICE_EXT = ['doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'csv'];
-const TEXT_EXT = ['txt', 'json', 'log', 'xml', 'yml', 'yaml', 'ini', 'conf', 'ts', 'js', 'tsx', 'jsx', 'py', 'sh', 'sql', 'html', 'css'];
+/** 可本地渲染的 office 子集(有专门组件) */
+const DOCX_EXT = ['docx'];
+const XLSX_EXT = ['xlsx', 'csv'];
+/** HTML:用 iframe 沙箱预览(零依赖) */
+const HTML_EXT = ['html', 'htm'];
+const TEXT_EXT = ['txt', 'json', 'log', 'xml', 'yml', 'yaml', 'ini', 'conf', 'ts', 'js', 'tsx', 'jsx', 'py', 'sh', 'sql', 'css'];
 
 /** 从 url 或文件名取扩展名(小写,去查询串/哈希) */
 export function extOf(nameOrUrl: string): string {
@@ -42,6 +58,9 @@ export function previewKindOf(nameOrUrl: string, mimeType?: string): PreviewKind
   if (AUDIO_EXT.includes(ext)) return 'audio';
   if (PDF_EXT.includes(ext)) return 'pdf';
   if (MD_EXT.includes(ext)) return 'markdown';
+  if (HTML_EXT.includes(ext)) return 'html';
+  if (DOCX_EXT.includes(ext)) return 'docx';
+  if (XLSX_EXT.includes(ext)) return 'xlsx';
   if (OFFICE_EXT.includes(ext)) return 'office';
   if (TEXT_EXT.includes(ext)) return 'text';
 

+ 280 - 4
src/app/components/preview/file-preview/file-preview.ts

@@ -6,8 +6,12 @@ import {
   input,
   output,
   signal,
+  viewChild,
+  ElementRef,
 } from '@angular/core';
 import { MdRenderComponent } from '../md-render/md-render';
+import { PdfPreviewComponent } from '../pdf-preview/pdf-preview';
+import { OfficePreviewComponent } from '../office-preview/office-preview';
 import { PreviewKind, previewKindOf, typeLabelOf } from '../file-kind';
 
 /**
@@ -37,12 +41,13 @@ import { PreviewKind, previewKindOf, typeLabelOf } from '../file-kind';
 @Component({
   selector: 'app-file-preview',
   changeDetection: ChangeDetectionStrategy.OnPush,
-  imports: [MdRenderComponent],
+  imports: [MdRenderComponent, PdfPreviewComponent, OfficePreviewComponent],
   template: `
     @if (open()) {
       <div class="fp-backdrop" role="presentation" (click)="close()">
         <div
           class="fp-panel"
+          [class.fp-panel--full]="fullscreen()"
           role="dialog"
           aria-modal="true"
           [attr.aria-label]="'预览 ' + (name() || '文件')"
@@ -52,6 +57,23 @@ import { PreviewKind, previewKindOf, typeLabelOf } from '../file-kind';
           <header class="fp-head">
             <span class="fp-title truncate">{{ name() || '文件' }}</span>
             <span class="fp-kind">{{ kindLabel() }}</span>
+            <!-- HTML 预览支持整体缩放(内容不可控,只能缩容器) -->
+            @if (kind() === 'html') {
+              <div class="fp-zoom">
+                <button type="button" (click)="zoomBy(-0.1)" aria-label="缩小">−</button>
+                <span>{{ (htmlZoom() * 100).toFixed(0) }}%</span>
+                <button type="button" (click)="zoomBy(0.1)" aria-label="放大">+</button>
+              </div>
+            }
+            <button
+              type="button"
+              class="fp-close"
+              [attr.aria-label]="fullscreen() ? '退出全屏' : '全屏'"
+              [attr.aria-pressed]="fullscreen()"
+              (click)="fullscreen.set(!fullscreen())"
+            >
+              <i class="fa-solid" [class]="fullscreen() ? 'fa-compress' : 'fa-expand'" aria-hidden="true"></i>
+            </button>
             <button
               #closeBtn
               type="button"
@@ -80,6 +102,55 @@ import { PreviewKind, previewKindOf, typeLabelOf } from '../file-kind';
               @case ('audio') {
                 <audio class="fp-audio" [src]="url()" controls></audio>
               }
+              @case ('pdf') {
+                <app-pdf-preview [url]="url()" />
+              }
+              @case ('docx') {
+                <app-office-preview [url]="url()" [name]="name()" kind="docx" />
+              }
+              @case ('xlsx') {
+                <app-office-preview [url]="url()" [name]="name()" kind="xlsx" />
+              }
+              @case ('html') {
+                <!--
+                  HTML 预览:用一个**沙箱 iframe** 直接打开。
+                  sandbox 只给 allow-scripts(不给 allow-same-origin),
+                  与主站 DOM 完全隔离 —— 容器产出的页面是不可信内容。
+                -->
+                <!--
+                  🔴 用模板引用 + effect 显式 setAttribute,不用 [src] 绑定。
+                  实测:@switch 分支刚创建时 Angular 的 [src] 尚未落到 iframe 上,
+                  结果 src="" —— iframe 空白,而组件里 safeUrl() 明明是对的。
+                -->
+                <div class="fp-html-wrap">
+                  <iframe
+                    #htmlFrame
+                    class="fp-html"
+                    [style.width.%]="100 / htmlZoom()"
+                    [style.height.%]="100 / htmlZoom()"
+                    [style.transform]="'scale(' + htmlZoom() + ')'"
+                    sandbox="allow-scripts allow-popups allow-forms"
+                    referrerpolicy="no-referrer"
+                    [attr.title]="name() || '页面预览'"
+                    (load)="onFrameLoad()"
+                  ></iframe>
+                  <!--
+                    加载超时提示:CDN 缓存缺 CORS 头时,iframe 会一直白屏且不报错
+                    (浏览器不把 CORS 失败暴露给父页面)。此时给用户明确出口,
+                    而不是让他盯着一个永远转不出来的空白页。
+                  -->
+                  @if (frameStalled()) {
+                    <div class="fp-html-stall">
+                      <i class="fa-solid fa-circle-exclamation" aria-hidden="true"></i>
+                      <p>页面加载超时,可能是 CDN 缓存了旧版本或缺少跨域头。</p>
+                      <div class="fp-html-actions">
+                        <button type="button" (click)="retryFrame()">重新加载</button>
+                        <a [href]="safeUrl()" target="_blank" rel="noopener noreferrer">在新标签打开</a>
+                      </div>
+                    </div>
+                  }
+                </div>
+              }
               @case ('markdown') {
                 @if (textState() === 'loading') {
                   <p class="fp-hint">正在加载…</p>
@@ -106,7 +177,7 @@ import { PreviewKind, previewKindOf, typeLabelOf } from '../file-kind';
                   <i class="fa-solid fa-file-arrow-down" aria-hidden="true"></i>
                   <p class="fp-fallback-title">该格式暂不支持在线预览</p>
                   <p class="fp-fallback-desc">
-                    {{ kind() === 'office' ? 'Office 文档' : '此类型文件' }}需要下载后用本地应用打开。
+                    {{ kind() === 'office' ? '.doc/.xls/.ppt 老格式' : '此类型文件' }}需要下载后用本地应用打开。
                   </p>
                   <a class="fp-download" [href]="url()" [attr.download]="name()" rel="noopener">
                     下载文件
@@ -138,8 +209,12 @@ import { PreviewKind, previewKindOf, typeLabelOf } from '../file-kind';
       .fp-panel {
         display: flex;
         flex-direction: column;
-        width: min(1000px, 96vw);
-        height: min(760px, 90dvh);
+        /*
+         * 用户反馈「预览区域太小」→ 默认就接近满屏(原来 1000×760)。
+         * 留一点边距让遮罩可见,暗示可以点外面关闭。
+         */
+        width: min(1400px, 96vw);
+        height: min(92dvh, 1100px);
         border-radius: var(--r-lg);
         background: var(--surface);
         box-shadow: var(--shadow-pop);
@@ -167,6 +242,35 @@ import { PreviewKind, previewKindOf, typeLabelOf } from '../file-kind';
         color: var(--muted);
         font-size: var(--fs-11);
       }
+      /* 全屏:真正铺满视口 */
+      .fp-panel--full {
+        width: 100vw;
+        height: 100dvh;
+        max-width: none;
+        max-height: none;
+        border-radius: 0;
+      }
+      .fp-zoom {
+        display: inline-flex;
+        align-items: center;
+        gap: var(--sp-1);
+        margin-left: var(--sp-2);
+      }
+      .fp-zoom button {
+        width: 28px;
+        height: 28px;
+        border: 1px solid var(--edge);
+        border-radius: var(--r-sm);
+        background: var(--surface);
+        color: var(--ink);
+        cursor: pointer;
+      }
+      .fp-zoom span {
+        min-width: 40px;
+        text-align: center;
+        font-size: var(--fs-12);
+        color: var(--muted);
+      }
       .fp-close {
         display: grid;
         place-items: center;
@@ -184,6 +288,68 @@ import { PreviewKind, previewKindOf, typeLabelOf } from '../file-kind';
         background: var(--surface-hover);
         color: var(--ink);
       }
+      /*
+        HTML 预览 iframe:必须显式宽高。
+        没有样式时 iframe 默认 300x150 且 fp-body 是 flex 容器 → 撑不满,看起来是空白。
+      */
+      .fp-html-wrap {
+        position: relative;
+        width: 100%;
+        height: 100%;
+        min-height: 60vh;
+        overflow: auto;
+      }
+      .fp-html-stall {
+        position: absolute;
+        inset: 0;
+        display: grid;
+        place-content: center;
+        justify-items: center;
+        gap: var(--sp-3);
+        padding: var(--sp-5);
+        background: var(--surface);
+        color: var(--muted);
+        text-align: center;
+      }
+      .fp-html-actions {
+        display: flex;
+        gap: var(--sp-3);
+        align-items: center;
+      }
+      .fp-html-actions button,
+      .fp-html-actions a {
+        min-height: var(--touch-min);
+        display: inline-flex;
+        align-items: center;
+        padding: 0 var(--sp-4);
+        border-radius: var(--r-sm);
+        font-size: var(--fs-13);
+        cursor: pointer;
+        text-decoration: none;
+      }
+      .fp-html-actions button {
+        border: 0;
+        background: var(--primary);
+        color: var(--primary-content);
+      }
+      .fp-html-actions a {
+        border: 1px solid var(--edge);
+        background: var(--surface);
+        color: var(--primary);
+      }
+      .fp-html {
+        display: block;
+        /* 缩放时以左上角为原点,避免内容跑到视口外 */
+        transform-origin: 0 0;
+        /* grid 的 place-items:center 会让 iframe 收缩到内容尺寸 → 必须显式拉伸 */
+        place-self: stretch;
+        width: 100%;
+        height: 100%;
+        min-height: 60vh;
+        border: 0;
+        border-radius: var(--r-md);
+        background: var(--surface);
+      }
       .fp-body {
         flex: 1;
         min-height: 0;
@@ -323,11 +489,119 @@ export class FilePreviewComponent {
 
   readonly kindLabel = computed(() => typeLabelOf(this.name() || this.url()));
 
+  /**
+   * HTML 预览地址:强制 https(混合内容会被浏览器拦),并拒绝非 http(s) 协议
+   * (防止 javascript: / data: 之类的伪地址进入 iframe)。
+   */
+  private readonly htmlFrame = viewChild<ElementRef<HTMLIFrameElement>>('htmlFrame');
+
+  /** 预览面板是否全屏 */
+  readonly fullscreen = signal(false);
+
+  /** HTML 预览缩放(内容不可控,缩容器 + transform) */
+  readonly htmlZoom = signal(1);
+
+  zoomBy(delta: number): void {
+    this.htmlZoom.update((z) => Math.min(3, Math.max(0.5, Math.round((z + delta) * 10) / 10)));
+  }
+
+  /** iframe 是否卡住(一直没有 load 事件) */
+  readonly frameStalled = signal(false);
+  private frameTimer: ReturnType<typeof setTimeout> | null = null;
+  /** 重试计数:追加参数强制绕过 CDN 缓存 */
+  private readonly frameRetry = signal(0);
+
+  /** iframe 挂载后显式设置 src(绕开 [src] 绑定时机问题) */
+  private readonly frameSync = effect(() => {
+    const el = this.htmlFrame()?.nativeElement;
+    const src = this.buildFrameSrc();
+    if (!el || !src) return;
+    if (el.getAttribute('src') !== src) el.setAttribute('src', src);
+  });
+
+  /** 实际加载地址:正常情况就是原文地址;重试时带 `_r=` 绕开缓存 */
+  private buildFrameSrc(): string {
+    const base = this.safeUrl();
+    if (!base) return '';
+    const n = this.frameRetry();
+    if (n === 0) return base;
+    try {
+      const u = new URL(base);
+      u.searchParams.set('_r', String(Date.now()));
+      return u.toString();
+    } catch {
+      return base;
+    }
+  }
+
+  /**
+   * iframe load 回调。
+   *
+   * 🔴 不能用「能否读到 contentDocument」判断成功与否。
+   *
+   * 踩坑记录:页面其实**渲染成功了**,但因为带着 sandbox 属性(且是跨源),
+   * 父页面读不到 `contentDocument` → 被误判为「加载失败」→ 每 8 秒循环计时,
+   * 约 30 秒后弹出「加载超时」提示(用户实测)。实际截图证明页面好好的。
+   *
+   * 现在:`load` 事件 = 加载完成,直接视为成功。
+   * 真正失败的情况(DNS/404/连接被拒)浏览器会触发 error 或根本不出 load,
+   * 由计时器兜底。
+   */
+  onFrameLoad(): void {
+    this.clearFrameTimer();
+    this.frameStalled.set(false);
+  }
+
+  retryFrame(): void {
+    this.frameStalled.set(false);
+    this.frameRetry.update((n) => n + 1);
+    const el = this.htmlFrame()?.nativeElement;
+    if (el) el.setAttribute('src', '');
+    this.startFrameTimer();
+  }
+
+  private startFrameTimer(): void {
+    this.clearFrameTimer();
+    this.frameStalled.set(false);
+    /*
+     * 15 秒兜底:足够慢网加载。只用于「一直没有 load 事件」的情况,
+     * 加载成功后的判定完全依赖 onFrameLoad。
+     */
+    this.frameTimer = setTimeout(() => this.frameStalled.set(true), 15_000);
+  }
+
+  private clearFrameTimer(): void {
+    if (this.frameTimer) {
+      clearTimeout(this.frameTimer);
+      this.frameTimer = null;
+    }
+  }
+
+  readonly safeUrl = computed(() => {
+    const raw = this.url();
+    try {
+      const u = new URL(raw, location.origin);
+      if (u.protocol !== 'http:' && u.protocol !== 'https:') return '';
+      return u.toString();
+    } catch {
+      return '';
+    }
+  });
+
   constructor() {
     // 打开或换了文件时,按需拉取文本内容(markdown / text)
     effect(() => {
       const isOpen = this.open();
       const target = this.url();
+      // 每次打开 HTML 预览都重新计时(CDN 缺 CORS 时 iframe 会永久白屏)
+      if (isOpen && this.kind() === 'html') {
+        this.frameRetry.set(0);
+        this.startFrameTimer();
+      } else {
+        this.clearFrameTimer();
+        this.frameStalled.set(false);
+        this.htmlZoom.set(1);
+      }
       if (!isOpen || !target) return;
       const k = this.kind();
       if (k !== 'markdown' && k !== 'text') return;
@@ -361,6 +635,8 @@ export class FilePreviewComponent {
   }
 
   close(): void {
+    this.fullscreen.set(false);
+    this.htmlZoom.set(1);
     this.closed.emit();
   }
 }

+ 47 - 12
src/app/components/preview/md-render/md-render.ts

@@ -36,6 +36,15 @@ import { MarkdownComponent } from 'ngx-markdown';
     `
       :host {
         display: block;
+        /*
+         * 🔴 必须建立定位上下文。
+         *
+         * 屏读提示 .md-copy-live 是 position:absolute;宿主若为 static,
+         * 它会以**初始包含块**为基准 → 每条消息一个绝对定位元素堆在文档底部,
+         * 把 document.scrollHeight 从 950 撑到 2997px,
+         * 浏览器里表现为「最外层能滚出一大段空白」(用户实测的异常滚动)。
+         */
+        position: relative;
       }
       .md-host {
         font-size: var(--fs-15);
@@ -95,36 +104,52 @@ import { MarkdownComponent } from 'ngx-markdown';
         font-size: 0.92em;
         font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
       }
-      :host ::ng-deep .md-host pre {
+      /*
+        代码块:**深底 + 亮字**。
+        语法高亮用的是 prism-okaidia(暗色主题,亮色文字),
+        此前挂在亮底 --surface-2 上 → 亮底亮字几乎看不清(用户实测)。
+        两种正确组合:深底亮字 / 亮底深字。这里固定用深底,暗色模式下也一致。
+      */
+      :host ::ng-deep .md-host .md-code {
         position: relative;
         margin: var(--sp-2) 0;
+        border-radius: var(--r-md);
+        background: var(--nav-surface);
+      }
+      :host ::ng-deep .md-host pre {
+        margin: 0;
         padding: var(--sp-3);
         border-radius: var(--r-md);
-        background: var(--surface-2);
+        background: transparent;
+        /* 只有代码块本体横滚,复制按钮在外层、不跟着滚 */
         overflow-x: auto;
       }
       :host ::ng-deep .md-host pre code {
         padding: 0;
         background: none;
+        color: var(--nav-ink);
         font-size: var(--fs-13);
         line-height: 1.6;
       }
+      /* 复制按钮:钉在**外层容器**右上角,代码横滚时保持不动 */
       :host ::ng-deep .md-host .md-copy-btn {
         position: absolute;
-        top: var(--sp-2);
-        right: var(--sp-2);
-        min-height: 28px;
+        top: 6px;
+        right: 6px;
+        z-index: 1;
+        min-height: 26px;
         padding: 0 8px;
-        border: 1px solid var(--edge);
+        border: 1px solid var(--nav-line);
         border-radius: var(--r-tag);
-        background: var(--surface);
-        color: var(--muted);
+        background: var(--nav-hover);
+        color: var(--nav-text);
         font-size: var(--fs-12);
         cursor: pointer;
       }
       :host ::ng-deep .md-host .md-copy-btn:hover {
-        color: var(--ink);
-        border-color: var(--edge-strong);
+        color: var(--nav-ink);
+        border-color: var(--nav-muted);
+        background: var(--nav-active);
       }
       :host ::ng-deep .md-host table {
         width: 100%;
@@ -200,7 +225,17 @@ export class MdRenderComponent {
     });
 
     root.querySelectorAll('pre').forEach((pre) => {
-      if (pre.querySelector('.md-copy-btn')) return; // 幂等
+      if (pre.parentElement?.classList.contains('md-code')) return; // 幂等
+      /*
+       * 🔴 按钮不能挂在 pre 里:pre 是横向滚动容器,
+       * absolute 定位会**跟着内容一起滚走**(用户实测)。
+       * 外层包一个不滚动的 .md-code,按钮挂在它上面,代码在 pre 里滚。
+       */
+      const wrap = document.createElement('div');
+      wrap.className = 'md-code';
+      pre.parentNode?.insertBefore(wrap, pre);
+      wrap.appendChild(pre);
+
       const btn = document.createElement('button');
       btn.type = 'button';
       btn.className = 'md-copy-btn';
@@ -210,7 +245,7 @@ export class MdRenderComponent {
         const code = pre.querySelector('code')?.textContent ?? '';
         void this.copy(code);
       });
-      pre.appendChild(btn);
+      wrap.appendChild(btn);
     });
   }
 

+ 284 - 0
src/app/components/preview/office-preview/office-preview.ts

@@ -0,0 +1,284 @@
+import {
+  ChangeDetectionStrategy,
+  Component,
+  ElementRef,
+  effect,
+  input,
+  signal,
+  viewChild,
+} from '@angular/core';
+
+/**
+ * Office 预览(docx / xlsx)—— 本地解析,分页 / 表格渲染。
+ *
+ * ## 来源
+ *
+ * 照搬 `E:\work\lami\borin-academy-beta-version` 的方案:`@silurus/ooxml`。
+ * 选它而不是 mammoth/SheetJS,是因为**保真度**(版式、图表、合并单元格都能还原);
+ * 代价是包体大 —— 因此**全部懒加载**,不打开预览就不下载。
+ *
+ * ## 两者的 API 不同(实测)
+ *
+ * ```
+ * DocxViewer(canvas, { container })   // 按页渲染到 canvas,翻页用 goToPage()
+ * XlsxViewer(container)               // 自己建画布与滚动区,内部管 sheet 切换
+ * ```
+ *
+ * ## 借鉴的两条关键做法
+ *
+ * 1. **`loadToken` 防串扰**:用户快速切换文件时,旧文件的解析结果必须丢弃,
+ *    否则会出现「点 A 显示 B」。
+ * 2. **加载超时**:大文件或网络差时给明确提示,而不是无限转圈。
+ *
+ * ## 不支持的格式
+ *
+ * `.doc` / `.xls` / `.ppt`(97-2003 老格式)解析成本极高,明确提示需下载,
+ * **不假装能预览**。
+ */
+@Component({
+  selector: 'app-office-preview',
+  changeDetection: ChangeDetectionStrategy.OnPush,
+  template: `
+    <div class="of-wrap">
+      @if (state() === 'loading') {
+        <div class="of-state">
+          <i class="fa-solid fa-circle-notch fa-spin" aria-hidden="true"></i>
+          <p>正在解析 {{ kind() }} 文档…(大文件可能需要几秒)</p>
+        </div>
+      } @else if (state() === 'error') {
+        <div class="of-state">
+          <i class="fa-solid fa-triangle-exclamation" aria-hidden="true"></i>
+          <p class="err">{{ error() }}</p>
+          <a class="dl" [href]="url()" [attr.download]="name()">下载后本地查看</a>
+        </div>
+      }
+
+      @if (kind() === 'docx') {
+        <!-- Docx:canvas + 翻页 -->
+        <div class="of-docx" [class.hidden]="state() !== 'ready'">
+          <div class="of-toolbar">
+            <button type="button" (click)="go(-1)" aria-label="上一页">‹</button>
+            <span class="pg">{{ page() }} / {{ pageCount() }}</span>
+            <button type="button" (click)="go(1)" aria-label="下一页">›</button>
+          </div>
+          <div class="of-canvas-scroll" #scroll>
+            <canvas #canvas></canvas>
+          </div>
+        </div>
+      } @else {
+        <!-- Xlsx:viewer 自建 DOM,这里只提供挂载点 -->
+        <div class="of-xlsx" [class.hidden]="state() !== 'ready'" #stage></div>
+      }
+    </div>
+  `,
+  styles: [
+    `
+      :host {
+        display: block;
+        height: 100%;
+      }
+      .of-wrap {
+        display: flex;
+        flex-direction: column;
+        height: 100%;
+        min-height: 0;
+      }
+      .hidden {
+        display: none;
+      }
+      .of-state {
+        flex: 1;
+        display: grid;
+        place-content: center;
+        justify-items: center;
+        gap: var(--sp-3);
+        padding: var(--sp-5);
+        color: var(--muted);
+        text-align: center;
+      }
+      .err {
+        max-width: 420px;
+        color: var(--danger);
+      }
+      .dl {
+        min-height: var(--touch-min);
+        display: inline-flex;
+        align-items: center;
+        padding: 0 var(--sp-4);
+        border-radius: var(--r-sm);
+        background: var(--primary);
+        color: var(--primary-content);
+        text-decoration: none;
+        font-size: var(--fs-13);
+      }
+      .of-docx {
+        flex: 1;
+        display: flex;
+        flex-direction: column;
+        min-height: 0;
+      }
+      .of-toolbar {
+        display: flex;
+        align-items: center;
+        gap: var(--sp-2);
+        padding: var(--sp-2) var(--sp-3);
+        border-bottom: 1px solid var(--edge);
+        font-size: var(--fs-12);
+        color: var(--ink-2);
+      }
+      .of-toolbar button {
+        width: 32px;
+        height: 32px;
+        border: 1px solid var(--edge);
+        border-radius: var(--r-sm);
+        background: var(--surface);
+        color: var(--ink);
+        cursor: pointer;
+      }
+      .pg {
+        min-width: 56px;
+        text-align: center;
+        color: var(--muted);
+      }
+      .of-canvas-scroll {
+        flex: 1;
+        min-height: 0;
+        overflow: auto;
+        background: var(--surface-2);
+        padding: var(--sp-3);
+        display: grid;
+        justify-items: center;
+      }
+      .of-canvas-scroll canvas {
+        max-width: 100%;
+        background: var(--surface);
+        box-shadow: var(--shadow-card);
+      }
+      .of-xlsx {
+        flex: 1;
+        min-height: 0;
+        overflow: hidden;
+        background: var(--surface-2);
+      }
+    `,
+  ],
+})
+export class OfficePreviewComponent {
+  readonly url = input.required<string>();
+  readonly name = input('');
+  /** 'docx' | 'xlsx' */
+  readonly kind = input('docx');
+
+  readonly state = signal<'loading' | 'ready' | 'error'>('loading');
+  readonly error = signal('');
+  readonly page = signal(1);
+  readonly pageCount = signal(0);
+
+  private readonly canvasEl = viewChild<ElementRef<HTMLCanvasElement>>('canvas');
+  private readonly stageEl = viewChild<ElementRef<HTMLElement>>('stage');
+
+  /** 防串扰:每次加载自增,回包时不是当前值就丢弃 */
+  private loadToken = 0;
+  private viewer: {
+    destroy?(): void | Promise<void>;
+    goToPage?(n: number): Promise<void>;
+    pageCount?: number;
+  } | null = null;
+
+  constructor() {
+    effect(() => {
+      const u = this.url();
+      const k = this.kind().toLowerCase();
+      if (!u) return;
+      void this.load(u, k);
+    });
+  }
+
+  go(delta: number): void {
+    const total = this.pageCount();
+    const next = Math.min(total, Math.max(1, this.page() + delta));
+    if (next === this.page()) return;
+    this.page.set(next);
+    // ooxml 的 goToPage 是 0 基
+    void this.viewer?.goToPage?.(next - 1);
+  }
+
+  private async load(url: string, kind: string): Promise<void> {
+    const token = ++this.loadToken;
+    this.state.set('loading');
+    this.error.set('');
+    this.page.set(1);
+    this.pageCount.set(0);
+
+    // 老格式明确不支持(解析成本极高),直接给出口
+    if (kind === 'doc' || kind === 'xls' || kind === 'ppt' || kind === 'pptx') {
+      this.error.set(
+        `.${kind} 是旧版 Office 格式,暂不支持在线预览。请下载后用本地应用打开。`,
+      );
+      this.state.set('error');
+      return;
+    }
+
+    // 等模板把挂载点渲染出来(首次进入时可能还没)
+    await new Promise<void>((r) => requestAnimationFrame(() => r()));
+
+    await this.teardown();
+
+    try {
+      if (kind === 'xlsx') {
+        const host = this.stageEl()?.nativeElement;
+        if (!host) throw new Error('预览容器未就绪');
+        host.innerHTML = '';
+        const { XlsxViewer } = await import('@silurus/ooxml/xlsx');
+        if (token !== this.loadToken) return;
+        const viewer = new XlsxViewer(host, { cellScale: 1 });
+        this.viewer = viewer as unknown as typeof this.viewer;
+        await withTimeout(viewer.load(url), LOAD_TIMEOUT_MS, '表格解析超时,可下载后查看。');
+      } else {
+        const canvas = this.canvasEl()?.nativeElement;
+        if (!canvas) throw new Error('预览容器未就绪');
+        const { DocxViewer } = await import('@silurus/ooxml/docx');
+        if (token !== this.loadToken) return;
+        const viewer = new DocxViewer(canvas, { container: canvas.parentElement ?? undefined });
+        this.viewer = viewer as unknown as typeof this.viewer;
+        await withTimeout(viewer.load(url), LOAD_TIMEOUT_MS, '文档解析超时,可下载后查看。');
+        if (token !== this.loadToken) return;
+        this.pageCount.set(viewer.pageCount);
+      }
+      if (token !== this.loadToken) return;
+      this.state.set('ready');
+    } catch (e) {
+      if (token !== this.loadToken) return;
+      this.error.set(friendly(e));
+      this.state.set('error');
+      await this.teardown();
+    }
+  }
+
+  private async teardown(): Promise<void> {
+    try {
+      await this.viewer?.destroy?.();
+    } catch {
+      /* 清理失败不影响后续 */
+    }
+    this.viewer = null;
+  }
+}
+
+const LOAD_TIMEOUT_MS = 30_000;
+
+function withTimeout<T>(p: Promise<T>, ms: number, msg: string): Promise<T> {
+  return Promise.race([
+    p,
+    new Promise<never>((_, reject) => setTimeout(() => reject(new Error(msg)), ms)),
+  ]);
+}
+
+/** 错误文案具体化,不要只给一个 "解析失败" */
+function friendly(e: unknown): string {
+  const m = e instanceof Error ? e.message : String(e);
+  if (/CORS|Failed to fetch|NetworkError/i.test(m)) return '无法下载文件(可能是跨域限制)。';
+  if (/timeout|超时/i.test(m)) return m;
+  if (/password/i.test(m)) return '文档有密码保护,无法预览。';
+  return `解析失败:${m}`;
+}

+ 352 - 0
src/app/components/preview/pdf-preview/pdf-preview.ts

@@ -0,0 +1,352 @@
+import {
+  ChangeDetectionStrategy,
+  Component,
+  ElementRef,
+  effect,
+  input,
+  output,
+  signal,
+  viewChild,
+} from '@angular/core';
+
+/**
+ * PDF 预览 —— 逐页 canvas 渲染。
+ *
+ * ## 借鉴(E:\work\lami\borin-academy-beta-version 的 pdf-preview 组件)
+ *
+ * 那套实现踩过坑,以下几条直接照搬,不做"优化":
+ *
+ * 1. **polyfill 必须 `Object.defineProperty(enumerable: false)` 注入**
+ *    —— 直接赋值会生成可枚举属性,触发 pdf.js 的
+ *    「unexpected enumerable properties」污染检查,加载直接失败。
+ * 2. **worker 地址带缓存版本号** —— 企微等内置浏览器会长期复用旧 worker。
+ * 3. **加载失败要能重试**,且错误文案要具体(不要只写"加载失败")。
+ *
+ * 未照搬的部分:双版本 legacy 降级(我们主用 pdf.js 6.x,现代浏览器足够;
+ * 真遇到老内核再加,避免一开始就把复杂度堆上来)。
+ */
+@Component({
+  selector: 'app-pdf-preview',
+  changeDetection: ChangeDetectionStrategy.OnPush,
+  template: `
+    <div class="pdf-wrap">
+      @if (state() === 'loading') {
+        <div class="pdf-state">
+          <i class="fa-solid fa-circle-notch fa-spin" aria-hidden="true"></i>
+          <p>正在加载 PDF…</p>
+        </div>
+      } @else if (state() === 'error') {
+        <div class="pdf-state">
+          <i class="fa-solid fa-triangle-exclamation" aria-hidden="true"></i>
+          <p class="err">{{ error() }}</p>
+          <button type="button" class="retry" (click)="reload()">重试</button>
+        </div>
+      } @else {
+        <div class="pdf-toolbar">
+          <button type="button" (click)="zoomBy(-0.2)" aria-label="缩小">−</button>
+          <span class="zoom">{{ (scale() * 100).toFixed(0) }}%</span>
+          <button type="button" (click)="zoomBy(0.2)" aria-label="放大">+</button>
+          <span class="pages">{{ pageCount() }} 页</span>
+          <a class="dl" [href]="url()" download>下载</a>
+        </div>
+        <div class="pdf-scroll" #scroll>
+          <div class="pdf-pages" #pages></div>
+        </div>
+      }
+    </div>
+  `,
+  styles: [
+    `
+      :host {
+        display: block;
+        height: 100%;
+      }
+      .pdf-wrap {
+        display: flex;
+        flex-direction: column;
+        height: 100%;
+        min-height: 0;
+      }
+      .pdf-state {
+        flex: 1;
+        display: grid;
+        place-content: center;
+        justify-items: center;
+        gap: var(--sp-3);
+        color: var(--muted);
+      }
+      .pdf-state .err {
+        max-width: 420px;
+        text-align: center;
+        color: var(--danger);
+      }
+      .retry {
+        min-height: var(--touch-min);
+        padding: 0 var(--sp-4);
+        border: 1px solid var(--edge);
+        border-radius: var(--r-sm);
+        background: var(--surface);
+        color: var(--primary);
+        cursor: pointer;
+      }
+      .pdf-toolbar {
+        display: flex;
+        align-items: center;
+        gap: var(--sp-2);
+        padding: var(--sp-2) var(--sp-3);
+        border-bottom: 1px solid var(--edge);
+        font-size: var(--fs-12);
+        color: var(--ink-2);
+      }
+      .pdf-toolbar button {
+        width: 32px;
+        height: 32px;
+        border: 1px solid var(--edge);
+        border-radius: var(--r-sm);
+        background: var(--surface);
+        color: var(--ink);
+        cursor: pointer;
+      }
+      .zoom,
+      .pages {
+        min-width: 48px;
+        text-align: center;
+        color: var(--muted);
+      }
+      .dl {
+        margin-left: auto;
+        color: var(--primary);
+        text-decoration: none;
+      }
+      .pdf-scroll {
+        flex: 1;
+        min-height: 0;
+        overflow: auto;
+        background: var(--surface-2);
+        padding: var(--sp-3);
+      }
+      /* 页面居中,canvas 宽度随缩放变化 */
+      .pdf-pages {
+        display: grid;
+        justify-items: center;
+        gap: var(--sp-3);
+      }
+      .pdf-pages canvas {
+        max-width: 100%;
+        height: auto;
+        box-shadow: var(--shadow-card);
+        background: var(--surface);
+      }
+    `,
+  ],
+})
+export class PdfPreviewComponent {
+  readonly url = input.required<string>();
+  readonly close = output<void>();
+
+  readonly state = signal<'loading' | 'ready' | 'error'>('loading');
+  readonly error = signal('');
+  readonly scale = signal(1);
+  readonly pageCount = signal(0);
+
+  private readonly pagesEl = viewChild<ElementRef<HTMLElement>>('pages');
+
+  /** 已渲染的页面(重渲染时清理,避免叠加) */
+  private rendered: HTMLCanvasElement[] = [];
+  private doc: { destroy(): void } | null = null;
+
+  constructor() {
+    effect(() => {
+      const u = this.url();
+      if (u) void this.load(u);
+    });
+  }
+
+  reload(): void {
+    void this.load(this.url());
+  }
+
+  zoomBy(delta: number): void {
+    const next = Math.min(3, Math.max(0.4, this.scale() + delta));
+    if (next === this.scale()) return;
+    this.scale.set(next);
+    void this.renderAll();
+  }
+
+  private async load(url: string): Promise<void> {
+    this.state.set('loading');
+    this.error.set('');
+    this.rendered = [];
+    try {
+      installPdfjsCompatPolyfills();
+      // 懒加载:pdf.js 体积大,不进首屏
+      const pdfjs = await import('pdfjs-dist');
+      pdfjs.GlobalWorkerOptions.workerSrc = workerSrc();
+      this.doc?.destroy();
+      const task = pdfjs.getDocument({
+        url,
+        verbosity: pdfjs.VerbosityLevel?.ERRORS ?? 0,
+      } as never);
+      const doc = (await task.promise) as unknown as {
+        numPages: number;
+        getPage(n: number): Promise<unknown>;
+        destroy(): void;
+      };
+      this.doc = doc;
+      this.pageCount.set(doc.numPages);
+      this.state.set('ready');
+      /*
+       * 等一帧:`@else` 分支(含 .pdf-pages 容器)在本 tick 之后才插入 DOM,
+       * 立刻查会是 null。两帧更稳(首帧布局、次帧含样式)。
+       */
+      await nextFrames(2);
+      if (this.state() !== 'ready') return;
+      // 先渲染第一页,再后台渲染其余(大文件不至于白屏太久)
+      await this.renderPage(doc, 1);
+      void this.renderRest(doc);
+    } catch (e) {
+      this.error.set(pdfErrorText(e));
+      this.state.set('error');
+    }
+  }
+
+  private async renderAll(): Promise<void> {
+    if (!this.doc) return;
+    const host = this.pagesHost();
+    if (!host) return;
+    host.innerHTML = '';
+    this.rendered = [];
+    const doc = this.doc as unknown as { numPages: number; getPage(n: number): Promise<unknown> };
+    for (let i = 1; i <= doc.numPages; i++) {
+      // eslint-disable-next-line no-await-in-loop
+      await this.renderPage(doc, i);
+    }
+  }
+
+  private async renderRest(doc: { numPages: number; getPage(n: number): Promise<unknown> }): Promise<void> {
+    for (let i = 2; i <= doc.numPages; i++) {
+      // eslint-disable-next-line no-await-in-loop
+      await this.renderPage(doc, i);
+    }
+  }
+
+  /**
+   * 取渲染容器。
+   *
+   * 🔴 不用 viewChild 直接取:`state` 从 loading 切到 ready 的**同一个 tick** 里,
+   *    `@else` 分支刚插入 DOM,viewChild 还没更新 → 拿到 undefined,
+   *    renderPage 静默返回,结果就是「工具栏有、页面空白」。
+   *    这里直接查 DOM,拿到的必然是已挂载的元素。
+   */
+  private pagesHost(): HTMLElement | null {
+    const fromRef = this.pagesEl()?.nativeElement;
+    if (fromRef) return fromRef;
+    return document.querySelector('app-pdf-preview .pdf-pages');
+  }
+
+  private async renderPage(
+    doc: { getPage(n: number): Promise<unknown> },
+    n: number,
+  ): Promise<void> {
+    const host = this.pagesHost();
+    if (!host) return;
+    const page = (await doc.getPage(n)) as {
+      getViewport(o: { scale: number }): { width: number; height: number };
+      render(o: { canvasContext: CanvasRenderingContext2D; viewport: unknown }): { promise: Promise<void> };
+    };
+    /*
+     * 按容器宽度自适应,再叠加用户缩放。
+     * 🔴 容器刚插入 DOM 时 clientWidth 可能是 0 或极小(192px 那种),
+     *    直接用它会把页面渲染得极小。这里给一个合理下限,并优先用弹层宽度。
+     */
+    const panel = host.closest('.fp-panel') as HTMLElement | null;
+    const avail = Math.max(host.clientWidth, panel?.clientWidth ?? 0, 360) - 32;
+    const base = Math.min(avail, 900);
+    const vp1 = page.getViewport({ scale: 1 });
+    const fit = base / vp1.width;
+    // 设备像素比:高分屏不糊
+    const dpr = Math.min(window.devicePixelRatio || 1, 2);
+    const viewport = page.getViewport({ scale: fit * this.scale() * dpr });
+
+    const canvas = document.createElement('canvas');
+    canvas.width = Math.floor(viewport.width);
+    canvas.height = Math.floor(viewport.height);
+    canvas.style.width = `${Math.floor(viewport.width / dpr)}px`;
+    const ctx = canvas.getContext('2d');
+    if (!ctx) return;
+    await page.render({ canvasContext: ctx, viewport }).promise;
+    host.appendChild(canvas);
+    this.rendered.push(canvas);
+  }
+}
+
+/** 等 n 帧(让 Angular 把新分支渲染进 DOM) */
+function nextFrames(n: number): Promise<void> {
+  return new Promise((resolve) => {
+    const step = (left: number): void => {
+      if (left <= 0) return resolve();
+      requestAnimationFrame(() => step(left - 1));
+    };
+    step(n);
+  });
+}
+
+/** worker 地址带缓存版本(企微等内置浏览器会长期复用旧 worker) */
+const WORKER_VERSION = '20261021-1';
+function workerSrc(): string {
+  return `${new URL('/pdf.worker.js', location.origin).toString()}?v=${WORKER_VERSION}`;
+}
+
+/** 错误文案要具体,不要只写"加载失败" */
+function pdfErrorText(e: unknown): string {
+  const msg = e instanceof Error ? e.message : String(e);
+  if (/Password/i.test(msg)) return '这个 PDF 有密码保护,无法预览。可下载后用本地应用打开。';
+  if (/Invalid|corrupt/i.test(msg)) return 'PDF 文件可能已损坏,无法解析。';
+  if (/CORS|Failed to fetch|NetworkError/i.test(msg)) return '无法下载 PDF 文件(可能是跨域限制)。';
+  return `PDF 加载失败:${msg}`;
+}
+
+/**
+ * pdf.js 在渲染路径直接调用 ES2022 `Array/String.prototype.at` 与
+ * ES2021 `String.prototype.replaceAll`,企微等老内核缺失 → 会抛
+ * "pattern.at is not a function"。
+ *
+ * 🔴 必须 `defineProperty(enumerable: false)`:直接赋值生成可枚举属性,
+ *    会触发 pdf.js 的 Array.prototype 污染检查,加载直接失败。
+ */
+function installPdfjsCompatPolyfills(): void {
+  const defineHidden = (obj: object, name: string, value: unknown): void => {
+    Object.defineProperty(obj, name, { value, writable: true, configurable: true, enumerable: false });
+  };
+  const ap = Array.prototype as unknown as { at?: unknown };
+  if (typeof ap.at !== 'function') {
+    defineHidden(Array.prototype, 'at', function (this: unknown[], i: number) {
+      const k = i < 0 ? this.length + i : i;
+      return k < 0 || k >= this.length ? undefined : this[k];
+    });
+  }
+  const sp = String.prototype as unknown as { at?: unknown; replaceAll?: unknown };
+  if (typeof sp.at !== 'function') {
+    defineHidden(String.prototype, 'at', function (this: string, i: number) {
+      const k = i < 0 ? this.length + i : i;
+      return k < 0 || k >= this.length ? undefined : this.charAt(k);
+    });
+  }
+  if (typeof sp.replaceAll !== 'function') {
+    defineHidden(String.prototype, 'replaceAll', function (this: string, s: string, r: string) {
+      return this.split(s).join(r);
+    });
+  }
+  /*
+   * 🔴 `Promise.try`(ES2025)—— pdf.js 6.x 在消息通道里直接调用它。
+   *    实测报错:`TypeError: Promise.try is not a function`,表现为**永远卡在「正在加载」**。
+   *    参考项目没遇到这条,是因为它对老内核走了 3.11 legacy 兜底;
+   *    我们只用 6.x,所以必须自己补。
+   */
+  const PP = Promise as unknown as { try?: unknown };
+  if (typeof PP.try !== 'function') {
+    defineHidden(Promise, 'try', function (fn: (...a: unknown[]) => unknown, ...args: unknown[]) {
+      return new Promise((resolve) => resolve(fn(...args)));
+    });
+  }
+}

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

@@ -1,5 +1,6 @@
 import {
   Agent,
+  ArtifactJob,
   AuthSession,
   Deliverable,
   DeliverableGroup,
@@ -84,4 +85,56 @@ export abstract class FmodeApi {
     file: File,
     onProgress?: (pct: number) => void,
   ): Promise<{ key: string; publicUrl: string }>;
+
+  // ---------- 成果文件入库(云盘同步) ----------
+
+  /**
+   * 告诉平台「这个成果的附件在哪、该传到哪」。
+   *
+   * 平台侧(云函数 lfYlgU7SkK `action:'requestSync'`):
+   *   ① 按文件名取 OBS 预签名(与前端上传**同一条**上传通道)
+   *   ② 把预签名地址写进该成果的待同步队列,**由容器自己下载原文件后 PUT**
+   *   ③ 收到文件后回写 `artifacts[i].url` 为云盘公网地址
+   *
+   * 为什么不在这里直接传:文件在**容器里**(如 /opt/data/...),
+   * 浏览器拿不到,只能由容器完成搬运。前端负责的是「登记 + 触发」。
+   */
+  abstract requestArtifactSync(input: {
+    deliverableId: string;
+    /** 容器内的原始路径 */
+    source: string;
+    filename: string;
+  }): Promise<{ queued: boolean; message?: string }>;
+
+  // ---------- 成果文件搬运队列(容器侧轮询) ----------
+
+  /**
+   * 登记一次搬运:把容器里的 `source` 文件搬到云盘。
+   *
+   * 🔴 **只报「传哪个」,不报「传到哪」** —— 上传地址与必需的头由云函数
+   *    用服务端密钥签好后写进队列,客户端不接触任何密钥
+   *    (2026-10-21 定稿;此前的「App 自签」方案已废弃)。
+   *
+   * @param index 附件在成果 `artifacts[]` 中的下标 —— 保证同名附件不互相覆盖
+   */
+  abstract enqueueArtifactJob(job: {
+    deliverableId: string;
+    agentId: string;
+    /** 容器内的原始路径 */
+    source: string;
+    filename: string;
+    index: number;
+  }): Promise<{ id: string }>;
+
+  /** 容器领取待办(按 agentId,只取未完成且未过期的) */
+  abstract fetchArtifactJobs(agentId: string): Promise<ArtifactJob[]>;
+
+  /** 容器回执:上传成功(带公网地址)/ 失败(带原因) */
+  abstract ackArtifactJob(input: {
+    id: string;
+    ok: boolean;
+    publicUrl?: string;
+    error?: string;
+  }): Promise<void>;
+
 }

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

@@ -9,6 +9,7 @@ import {
 } from './demo-data';
 import {
   Agent,
+  ArtifactJob,
   AuthSession,
   Deliverable,
   DeliverableGroup,
@@ -171,6 +172,40 @@ export class MockApi extends FmodeApi {
     return { key: ticket.key, publicUrl: ticket.publicUrl };
   }
 
+  async requestArtifactSync(_input: {
+    deliverableId: string;
+    source: string;
+    filename: string;
+  }): Promise<{ queued: boolean; message?: string }> {
+    // 演示态:没有真实容器,直接视为已排队(不产生副作用)
+    return { queued: true, message: '演示模式:未实际同步' };
+  }
+
+  async enqueueArtifactJob(_job: {
+    deliverableId: string;
+    agentId: string;
+    source: string;
+    filename: string;
+    index: number;
+  }): Promise<{ id: string }> {
+    return { id: `mock-job-${Date.now()}` };
+  }
+
+  async fetchArtifactJobs(_agentId: string): Promise<ArtifactJob[]> {
+    return [];
+  }
+
+  async ackArtifactJob(_input: {
+    id: string;
+    ok: boolean;
+    publicUrl?: string;
+    error?: string;
+  }): Promise<void> {
+    // 演示态无副作用
+  }
+
+
+
   // ---------- 内部 ----------
 
   private delay(): Promise<void> {

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

@@ -184,6 +184,12 @@ export interface Artifact {
   name: string;
   url: string;
   type: string;
+  /**
+   * 容器内的原始路径(非 URL 时才有)。
+   * 该 artifact **尚未上传到云盘**,由 `ArtifactSyncService` 触发同步;
+   * `url` 为空时 UI 不得提供下载(避免给一个打不开的链接)。
+   */
+  source?: string;
 }
 
 export type DeliverableState = 'delivered' | 'failed';
@@ -350,9 +356,40 @@ export interface MatrixProvision {
   userCreated?: boolean;
 }
 
-/** 对应云函数 AlP56LCKFm 的返回(取签名阶段) */
-export interface UploadTicket {
+/**
+ * 成果文件搬运任务(表 `FmodeArtifactJob`)。
+ *
+ * 由 **App(前端)** 创建、由 **容器** 领取执行:
+ *
+ * ```
+ * App: 发现成果附件只有容器路径 → 签好 PUT 地址 → 建任务(pending)
+ * 容器: 轮询 pending → 读 source → 按 headers 原样 PUT 到 uploadUrl → 回执 done
+ * App: 下一次刷新时,成果的 artifacts[i].url 已变成云盘地址
+ * ```
+ *
+ * 🔴 `uploadUrl` 是**限时(5 分钟)单对象写入授权**,不是长期凭据;
+ *    过期后由 App 重新签发,容器不需要也不应该保存它。
+ */
+export interface ArtifactJob {
+  id: string;
+  deliverableId: string;
+  agentId: string;
+  /** 容器内文件路径 */
+  source: string;
+  filename: string;
+  mimeType: string;
+  /** 🔴 以下三项由**云函数**签名后写入;客户端只读,不得自行构造 */
   uploadUrl: string;
+  publicUrl: string;
+  headers: Record<string, string>;
+  /** epoch 秒(预签名有效期,默认 5 分钟) */
+  expiresAt: number;
+  status?: 'pending' | 'done' | 'failed';
+  error?: string;
+}
+
+/** 对应云函数 AlP56LCKFm 的返回(取签名阶段) */
+export interface UploadTicket {  uploadUrl: string;
   key: string;
   publicUrl: string;
   headers?: Record<string, string>;

+ 70 - 3
src/app/core/api/normalize.spec.ts

@@ -136,7 +136,8 @@ describe('parse-api 字段归一(兼容非标准容器)', () => {
         agents: [{ agentId: 'a', heartbeatAt: '2026-10-20T10:00:00Z', host: '{"probedAt":"2026-01-01T00:00:00Z"}' }],
       });
       const [a] = await api.fetchAgents();
-      expect(a.heartbeatAt).toBe('2026-10-20T10:00:00Z');
+      // 统一归一为 ISO 字符串(与时区写法无关,时刻一致)
+      expect(a.heartbeatAt).toBe('2026-10-20T10:00:00.000Z');
     });
 
     it('缺失时回落 host.probedAt(容器实际用的键)', async () => {
@@ -145,7 +146,7 @@ describe('parse-api 字段归一(兼容非标准容器)', () => {
         agents: [{ agentId: 'a', host: '{"probedAt":"2026-10-20T10:34:07+08:00"}' }],
       });
       const [a] = await api.fetchAgents();
-      expect(a.heartbeatAt).toBe('2026-10-20T10:34:07+08:00');
+      expect(a.heartbeatAt).toBe('2026-10-20T02:34:07.000Z'); // = 10:34:07+08:00
     });
 
     it('再缺失时回落 updatedAtClient.iso', async () => {
@@ -184,6 +185,18 @@ describe('parse-api 字段归一(兼容非标准容器)', () => {
     });
   });
 
+  describe('🔴 heartbeatAt 是 Parse Date 对象时(实测 bug)', () => {
+    it('{__type:Date,iso} → ISO 字符串,不是 "[object Object]"', async () => {
+      stubFetch({
+        code: 200,
+        agents: [{ agentId: 'a', heartbeatAt: { __type: 'Date', iso: '2026-10-21T03:24:27.888Z' } }],
+      });
+      const [a] = await api.fetchAgents();
+      expect(a.heartbeatAt).toBe('2026-10-21T03:24:27.888Z');
+      expect(a.heartbeatAt).not.toContain('object');
+    });
+  });
+
   describe('usage 映射(此前完全缺失 → Token 用量永远空白)', () => {
     it('🔴 容器上报 inputTokens/outputTokens → 映射为 prompt/completion', async () => {
       stubFetch({
@@ -241,6 +254,60 @@ describe('parse-api 字段归一(兼容非标准容器)', () => {
     });
   });
 
+  describe('deliverable artifacts 形态归一(实测两种形态混用)', () => {
+    it('🔴 字符串数组(容器内路径)→ name 取自路径末段,url 留空并保留 source', async () => {
+      stubFetch({
+        code: 200,
+        deliverables: [
+          {
+            objectId: 'd1',
+            title: 'Hermes 上游缺陷报告',
+            artifacts: ['/opt/data/git-repos/agent-status-reporter/HERMES-UPSTREAM-REPORT.md'],
+          },
+        ],
+      });
+      const [d] = await api.fetchDeliverables();
+      expect(d.filename).toBe('HERMES-UPSTREAM-REPORT.md');
+      expect(d.fileType).toBe('MD');
+      expect(d.artifacts[0].url).toBe(''); // 未上云盘 → UI 不给假下载
+      expect(d.artifacts[0].source).toBe(
+        '/opt/data/git-repos/agent-status-reporter/HERMES-UPSTREAM-REPORT.md',
+      );
+    });
+
+    it('字符串数组里的 http 地址 → 视作已可访问', async () => {
+      stubFetch({
+        code: 200,
+        deliverables: [
+          { objectId: 'd1', title: '报告', artifacts: ['https://s3.fmode.cn/a/report.pdf'] },
+        ],
+      });
+      const [d] = await api.fetchDeliverables();
+      expect(d.artifacts[0].url).toBe('https://s3.fmode.cn/a/report.pdf');
+      expect(d.artifacts[0].source).toBeUndefined();
+    });
+
+    it('🔴 过滤无信息标题(空 / 纯符号)与 probe 探针记录', async () => {
+      stubFetch({
+        code: 200,
+        deliverables: [
+          { objectId: 'ok', title: '真实成果' },
+          { objectId: 'blank', title: '' },
+          { objectId: 'dots', title: '· ·' },
+          { objectId: 'probe', title: 'probe' },
+          { objectId: 'probe-with-file', title: 'probe', artifacts: ['/x/a.pdf'] },
+        ],
+      });
+      const list = await api.fetchDeliverables();
+      const ids = list.map((d) => d.id);
+      expect(ids).toContain('ok');
+      expect(ids).not.toContain('blank');
+      expect(ids).not.toContain('dots');
+      expect(ids).not.toContain('probe');
+      expect(ids).toContain('probe-with-file'); // 有附件的 probe 保留(可能是真成果)
+    });
+  });
+
   describe('综合:非标准容器的完整映射', () => {
     it('数组 channels + cpuLoad1 + probedAt → 全部归一正确', async () => {
       stubFetch({
@@ -262,7 +329,7 @@ describe('parse-api 字段归一(兼容非标准容器)', () => {
       expect(a.matrixReady).toBe(true); // ← 修复前是 false
       expect(a.host?.cpuPercent).toBeCloseTo(16.4, 1); // ← 修复前是 undefined
       expect(a.host?.memPercent).toBe(55.6);
-      expect(a.heartbeatAt).toBe('2026-10-20T10:34:07+08:00'); // ← 修复前是 undefined
+      expect(a.heartbeatAt).toBe('2026-10-20T02:34:07.000Z'); // = 10:34:07+08:00 // ← 修复前是 undefined
     });
   });
 });

+ 154 - 18
src/app/core/api/parse-api.ts

@@ -1,8 +1,11 @@
+import { tsOf } from '../time';
 import { Scope } from '../acl';
 import { FmodeApi } from './fmode-api';
 import {
   Agent,
   AgentStatus,
+  Artifact,
+  ArtifactJob,
   AuthSession,
   ChannelConfig,
   Deliverable,
@@ -246,7 +249,22 @@ export class ParseApi extends FmodeApi {
       params,
     );
     if (d.code !== 200) throw new Error(pickMessage(d) ?? '成果查询失败');
-    return (d.deliverables ?? []).map((x) => mapDeliverable(x as Record<string, unknown>));
+    return (d.deliverables ?? [])
+      .map((x) => mapDeliverable(x as Record<string, unknown>))
+      /*
+       * 🔴 过滤「非成果」记录(用户实测:右栏出现两条没有意义的文档):
+       *
+       *   · `title` 为空/纯符号 → 没有任何信息,点了也看不懂是什么
+       *   · `title` 恰为 `probe` 且无附件 → 上报器的**探针测试**,不是交付物
+       *
+       * 过滤放在映射层(全项目唯一入口),页面不必各自判断。
+       */
+      .filter((x) => {
+        const t = x.title.trim();
+        if (!t || !/[\p{L}\p{N}]/u.test(t)) return false;
+        if (/^probe$/i.test(t) && x.artifacts.length === 0) return false;
+        return true;
+      });
   }
 
   async fetchDeliverablesByProject(limit?: number): Promise<DeliverableGroup[]> {
@@ -258,7 +276,15 @@ export class ParseApi extends FmodeApi {
     return (d.groups ?? []).map((g) => {
       const raw = g as Record<string, unknown>;
       const items = Array.isArray(raw['items'])
-        ? raw['items'].map((x) => mapDeliverable(x as Record<string, unknown>))
+        ? raw['items']
+            .map((x) => mapDeliverable(x as Record<string, unknown>))
+            // 与 fetchDeliverables 同一过滤口径(见上)
+            .filter((x) => {
+              const t = x.title.trim();
+              if (!t || !/[\p{L}\p{N}]/u.test(t)) return false;
+              if (/^probe$/i.test(t) && x.artifacts.length === 0) return false;
+              return true;
+            })
         : [];
       return {
         project: raw['project'] ? String(raw['project']) : undefined,
@@ -309,8 +335,7 @@ export class ParseApi extends FmodeApi {
   async uploadFile(
     file: File,
     onProgress?: (pct: number) => void,
-  ): Promise<{ key: string; publicUrl: string }> {
-    const ticket = await this.requestUploadUrl(
+  ): Promise<{ key: string; publicUrl: string }> {    const ticket = await this.requestUploadUrl(
       file.name,
       file.type || 'application/octet-stream',
       file.size,
@@ -325,6 +350,74 @@ export class ParseApi extends FmodeApi {
     return { key: ticket.key, publicUrl: ticket.publicUrl };
   }
 
+  async requestArtifactSync(input: {
+    deliverableId: string;
+    source: string;
+    filename: string;
+  }): Promise<{ queued: boolean; message?: string }> {
+    const d = await this.callFunction<{ code: number; queued?: boolean; message?: string; msg?: string }>(
+      FN.deliverables,
+      {
+        action: 'requestSync',
+        objectId: input.deliverableId,
+        source: input.source,
+        filename: input.filename,
+      },
+    );
+    if (d.code !== 200) {
+      // 契约未实现时清晰报错,不假装成功(前端不得伪造"已上传云盘")
+      throw new Error(pickMessage(d) ?? '云盘同步登记失败(云函数未实现 requestSync)');
+    }
+    return { queued: d.queued === true, message: d.message };
+  }
+
+  // ---------- 成果文件搬运队列 ----------
+
+  async enqueueArtifactJob(job: {
+    deliverableId: string;
+    agentId: string;
+    source: string;
+    filename: string;
+    index: number;
+  }): Promise<{ id: string }> {
+    // 只报「传哪个」;地址与头由云函数签好写进队列(客户端不接触密钥)
+    const d = await this.callFunction<{ code: number; id?: string }>(FN.deliverables, {
+      action: 'enqueueArtifact',
+      ...job,
+    });
+    if (d.code !== 200 || !d.id) {
+      throw new Error(pickMessage(d) ?? '搬运任务创建失败');
+    }
+    return { id: d.id };
+  }
+
+  async fetchArtifactJobs(agentId: string): Promise<ArtifactJob[]> {
+    const d = await this.callFunction<{ code: number; jobs?: unknown[] }>(FN.deliverables, {
+      action: 'pendingArtifacts',
+      agentId,
+    });
+    if (d.code !== 200) throw new Error(pickMessage(d) ?? '搬运任务查询失败');
+    return (d.jobs ?? []).map((x) => x as ArtifactJob);
+  }
+
+  async ackArtifactJob(input: {
+    id: string;
+    ok: boolean;
+    publicUrl?: string;
+    error?: string;
+  }): Promise<void> {
+    const d = await this.callFunction<{ code: number }>(FN.deliverables, {
+      action: 'ackArtifact',
+      objectId: input.id,
+      ok: input.ok,
+      publicUrl: input.publicUrl,
+      error: input.error,
+    });
+    if (d.code !== 200) throw new Error(pickMessage(d) ?? '搬运任务回执失败');
+  }
+
+
+
   /** PUT 直传;浏览器 XMLHttpRequest 才能拿到上传进度 */
   private putWithProgress(
     url: string,
@@ -511,11 +604,22 @@ function normalizeHost(v: unknown): Agent['host'] {
  *    不回落的后果:UI 判「心跳超时」→ 在线容器被显示成离线。
  */
 function normalizeHeartbeat(raw: Record<string, unknown>, host: unknown): string | undefined {
-  if (raw['heartbeatAt']) return String(raw['heartbeatAt']);
+  /*
+   * 🔴 必须经 `tsOf` 归一,不能 `String()`。
+   *    云函数里 heartbeatAt 是 Parse Date → `{__type:'Date', iso:'…'}`,
+   *    `String()` 得到 `"[object Object]"` → 下游 `Date.parse()` 得 NaN →
+   *    「心跳超时」判定永远为假(实测:状态永远显示「空闲」,从不报「状态未同步」)。
+   */
+  const isoOf = (v: unknown): string | undefined => {
+    const t = tsOf(v);
+    return Number.isFinite(t) ? new Date(t).toISOString() : undefined;
+  };
+  const direct = isoOf(raw['heartbeatAt']);
+  if (direct) return direct;
   const h = parseJsonLoose<{ probedAt?: string }>(host);
-  if (h?.probedAt) return String(h.probedAt);
+  if (h?.probedAt) return isoOf(h.probedAt);
   const uac = raw['updatedAtClient'] as { iso?: string } | undefined;
-  if (uac?.iso) return String(uac.iso);
+  if (uac?.iso) return isoOf(uac.iso);
   return undefined;
 }
 
@@ -615,20 +719,52 @@ function mapAgent(raw: Record<string, unknown>): Agent {
 /** 成果原始记录 → `Deliverable` 领域模型 */
 function mapDeliverable(raw: Record<string, unknown>): Deliverable {
   const status = String(raw['status'] ?? '');
-  const artifacts = Array.isArray(raw['artifacts'])
-    ? (raw['artifacts'] as Record<string, unknown>[]).map((a) => ({
-        name: String(a['name'] ?? ''),
-        url: String(a['url'] ?? ''),
-        type: String(a['type'] ?? ''),
-      }))
-    : [];
   /*
-   * 🔴 派生展示字段:`filename` / `fileType` / `size` 此前**从未赋值**
-   * (只在 demo-data 里有),真实数据下右栏渲染成「· 」,形如「仅有 · 的文档」。
+   * 🔴 `artifacts` 是**自由数组**,实测两种形态混用(同一张表里都有):
    *
-   * 契约未提供独立字段 → 从首个 artifact 推导;无 artifact 时给明确占位,
-   * 让 UI 能显示「暂无附件」而不是一串空分隔符。
+   * ```
+   * ["/opt/data/git-repos/agent-status-reporter/REPORT.md"]   ← 字符串数组(容器最常这样写)
+   * [{ name: "report.pdf", url: "https://…", type: "pdf" }]    ← 对象数组(契约标准)
+   * ```
+   *
+   * 此前只处理对象数组,字符串形态进来后 `name` 变成空串 →
+   * 右栏渲染成「仅有 · 的文档」(用户实测反馈)。
+   *
+   * 字符串形态的分辨口径:
+   *   · 以 `http` 开头        → 视作已是可访问地址
+   *   · 其它(容器内路径)    → **未同步**,由 `ArtifactSyncService` 交给容器上传到云盘
    */
+  const rawArts = Array.isArray(raw['artifacts']) ? (raw['artifacts'] as unknown[]) : [];
+  const artifacts: Artifact[] = rawArts
+    .map((a): Artifact | null => {
+      if (typeof a === 'string') {
+        const s = a.trim();
+        if (!s) return null;
+        const name = s.split(/[/\\]/).pop() || s;
+        const isUrl = /^https?:\/\//i.test(s);
+        return {
+          name,
+          url: isUrl ? s : '',
+          type: name.split('.').pop() || '',
+          // 非 URL 形态的原始值(容器内路径),保留给同步服务
+          source: isUrl ? undefined : s,
+        } as Artifact;
+      }
+      if (a && typeof a === 'object') {
+        const o = a as Record<string, unknown>;
+        const name = String(o['name'] ?? o['filename'] ?? '');
+        const url = String(o['url'] ?? '');
+        if (!name && !url) return null;
+        return {
+          name: name || url.split('/').pop() || '',
+          url,
+          type: String(o['type'] ?? ''),
+        };
+      }
+      return null;
+    })
+    .filter((x): x is Artifact => x != null);
+
   const first = artifacts[0];
   const filename = first?.name || undefined;
   const rawType = first?.type || filename?.split('.').pop() || '';

+ 112 - 0
src/app/core/artifact-sync.service.spec.ts

@@ -0,0 +1,112 @@
+import { TestBed } from '@angular/core/testing';
+import { ArtifactSyncService } from './artifact-sync.service';
+import { FmodeApi } from './api/fmode-api';
+import { Deliverable } from './api/models';
+
+/**
+ * 成果文件上云盘(App 只报「传哪个」,地址由服务端签)。
+ *
+ * 回归重点:
+ *   ① 只处理「有 source、无 url」的附件
+ *   ② **幂等**:同一附件不会每轮轮询都打一次云函数
+ *   ③ **不把上传地址/请求头传给云函数** —— 那意味着客户端能伪造目标地址
+ *   ④ 走的是**下标**而非文件名,保证同名附件不互相覆盖
+ *   ⑤ 失败如实记录,不假装成功
+ */
+describe('ArtifactSyncService', () => {
+  const apiStub = {
+    enqueueArtifactJob: jasmine.createSpy('enqueueArtifactJob'),
+  };
+  let svc: ArtifactSyncService;
+
+  function d(id: string, artifacts: { name: string; url?: string; source?: string }[]): Deliverable {
+    return {
+      id,
+      agentId: 'agent-node-lj-test',
+      title: 't',
+      artifacts: artifacts.map((a) => ({
+        name: a.name,
+        url: a.url ?? '',
+        type: a.name.split('.').pop() ?? '',
+        source: a.source,
+      })),
+      tags: [],
+      deliveredAt: '',
+      state: 'delivered',
+    };
+  }
+
+  beforeEach(() => {
+    apiStub.enqueueArtifactJob.calls.reset();
+    apiStub.enqueueArtifactJob.and.returnValue(Promise.resolve({ id: 'job1' }));
+    TestBed.configureTestingModule({
+      providers: [ArtifactSyncService, { provide: FmodeApi, useValue: apiStub }],
+    });
+    svc = TestBed.inject(ArtifactSyncService);
+  });
+
+  it('有 source 无 url → 入队', async () => {
+    const n = await svc.syncPending([d('x', [{ name: 'r.md', source: '/opt/data/r.md' }])]);
+    expect(n).toBe(1);
+    expect(apiStub.enqueueArtifactJob).toHaveBeenCalledWith({
+      deliverableId: 'x',
+      agentId: 'agent-node-lj-test',
+      source: '/opt/data/r.md',
+      filename: 'r.md',
+      index: 0,
+    });
+  });
+
+  it('🔴 不向云函数传上传地址或请求头(客户端不得指定目标)', async () => {
+    await svc.syncPending([d('x', [{ name: 'r.md', source: '/opt/data/r.md' }])]);
+    const arg = apiStub.enqueueArtifactJob.calls.mostRecent().args[0] as Record<string, unknown>;
+    expect(arg['uploadUrl']).toBeUndefined();
+    expect(arg['headers']).toBeUndefined();
+    expect(arg['publicUrl']).toBeUndefined();
+  });
+
+  it('🔴 多附件按**下标**入队(同名文件不会算成同一个)', async () => {
+    await svc.syncPending([
+      d('D1', [
+        { name: 'a.md', source: '/x/a.md' },
+        { name: 'a.md', source: '/y/a.md' },
+      ]),
+    ]);
+    const calls = apiStub.enqueueArtifactJob.calls.allArgs().map((a) => a[0]);
+    expect(calls.length).toBe(2);
+    expect(calls.map((c) => c.index)).toEqual([0, 1]);
+    expect(calls.map((c) => c.source)).toEqual(['/x/a.md', '/y/a.md']);
+  });
+
+  it('已有 url → 不入队', async () => {
+    const n = await svc.syncPending([
+      d('x', [{ name: 'r.md', url: 'https://s3.fmode.cn/a', source: '/opt/data/r.md' }]),
+    ]);
+    expect(n).toBe(0);
+    expect(apiStub.enqueueArtifactJob).not.toHaveBeenCalled();
+  });
+
+  it('没有 source(对象数组形态且无 url)→ 不处理', async () => {
+    expect(await svc.syncPending([d('x', [{ name: 'r.md' }])])).toBe(0);
+  });
+
+  it('🔴 幂等:连续两次只入队一次', async () => {
+    await svc.syncPending([d('x', [{ name: 'r.md', source: '/opt/data/r.md' }])]);
+    await svc.syncPending([d('x', [{ name: 'r.md', source: '/opt/data/r.md' }])]);
+    expect(apiStub.enqueueArtifactJob.calls.count()).toBe(1);
+  });
+
+  it('云函数拒绝 → 记录原因,且不重试', async () => {
+    apiStub.enqueueArtifactJob.and.returnValue(Promise.reject(new Error('云盘签名服务未配置')));
+    await svc.syncPending([d('x', [{ name: 'r.md', source: '/opt/data/r.md' }])]);
+    expect(svc.errorOf('x', '/opt/data/r.md')).toContain('未配置');
+
+    await svc.syncPending([d('x', [{ name: 'r.md', source: '/opt/data/r.md' }])]);
+    expect(apiStub.enqueueArtifactJob.calls.count()).toBe(1);
+  });
+
+  it('入队计数用于 UI 提示', async () => {
+    await svc.syncPending([d('x', [{ name: 'r.md', source: '/opt/data/r.md' }])]);
+    expect(svc.queued()).toBe(1);
+  });
+});

+ 113 - 0
src/app/core/artifact-sync.service.ts

@@ -0,0 +1,113 @@
+import { inject, Injectable, signal } from '@angular/core';
+import { FmodeApi } from './api/fmode-api';
+import { Deliverable } from './api/models';
+
+/**
+ * 成果文件上云盘 —— App 只负责「报告 + 触发」,签名与搬运都不在浏览器。
+ *
+ * ## 链路
+ *
+ * ```
+ * ① App 发现成果附件只有 source(容器路径)、没有 url
+ * ② App 调云函数 lfYlgU7SkK action:'enqueueArtifact'
+ *    (只报:哪个成果、哪个附件、第几个、叫什么)
+ * ③ 云函数用**服务端密钥**签好上传地址,写进 FmodeArtifactJob
+ * ④ 容器轮询 action:'pendingArtifacts' → 读文件 → PUT → action:'ackArtifact'
+ * ⑤ 云函数回写 artifacts[i].url,App 下次刷新即可下载
+ * ```
+ *
+ * ## 为什么前端不持有任何密钥(2026-10-21 定稿)
+ *
+ * 曾经的方案是「App 自己签」:云函数把 AK/SK 下发给浏览器。
+ * 问题是 ① 密钥会出现在 DevTools 里 ② 两个云函数各存一份,轮换要改两处。
+ *
+ * 现在是:**密钥只存在服务端一处**,浏览器全程不接触。
+ * 代价只是每个附件多一次云函数调用。
+ */
+@Injectable({ providedIn: 'root' })
+export class ArtifactSyncService {
+  private readonly api = inject(FmodeApi);
+
+  /** 正在处理的键(防并发重复提交) */
+  private readonly inFlight = new Set<string>();
+  /** 已处理过的键(成功或失败都算,避免每轮轮询都打云函数) */
+  private readonly done = new Set<string>();
+
+  /** 失败原因(`deliverableId/source` → 文案),供 UI 如实展示 */
+  private readonly _errors = signal<ReadonlyMap<string, string>>(new Map());
+  readonly errors = this._errors.asReadonly();
+
+  /** 本次会话已入队待搬运的数量 */
+  private readonly _queued = signal(0);
+  readonly queued = this._queued.asReadonly();
+
+  /**
+   * 扫描一批成果,把「有 source、没 url」的附件排进搬运队列。
+   * 幂等:同一成果同一附件只处理一次。返回本次**新入队**数量。
+   */
+  async syncPending(list: Deliverable[]): Promise<number> {
+    const jobs: { d: Deliverable; source: string; name: string; index: number }[] = [];
+    for (const d of list) {
+      d.artifacts.forEach((a, index) => {
+        if (a.url || !a.source) return;
+        const key = this.keyOf(d.id, a.source);
+        if (this.done.has(key) || this.inFlight.has(key)) return;
+        // 下标进队列:同名附件必须落到不同的 key
+        jobs.push({ d, source: a.source, name: a.name, index });
+      });
+    }
+    if (jobs.length === 0) return 0;
+
+    await Promise.all(jobs.map((j) => this.enqueue(j.d, j.source, j.name, j.index)));
+    return jobs.length;
+  }
+
+  private async enqueue(
+    d: Deliverable,
+    source: string,
+    name: string,
+    index: number,
+  ): Promise<void> {
+    const key = this.keyOf(d.id, source);
+    this.inFlight.add(key);
+    try {
+      await this.api.enqueueArtifactJob({
+        deliverableId: d.id,
+        agentId: d.agentId,
+        source,
+        filename: name,
+        index,
+      });
+      this.done.add(key);
+      this._queued.update((n) => n + 1);
+    } catch (e) {
+      this.fail(d.id, source, e instanceof Error ? e.message : '上云登记失败');
+    } finally {
+      this.inFlight.delete(key);
+    }
+  }
+
+  private fail(deliverableId: string, source: string, msg: string): void {
+    const key = this.keyOf(deliverableId, source);
+    this.done.add(key);
+    this._errors.update((m) => {
+      const next = new Map(m);
+      next.set(key, msg);
+      return next;
+    });
+  }
+
+  /** 某附件同步失败原因(没有则 undefined) */
+  errorOf(deliverableId: string, source: string): string | undefined {
+    return this._errors().get(this.keyOf(deliverableId, source));
+  }
+
+  /** 是否已入队(UI 可显示「正在搬运到云盘」) */
+  isQueued(deliverableId: string, source: string): boolean {
+    return this.done.has(this.keyOf(deliverableId, source));
+  }
+
+  private keyOf(deliverableId: string, source: string): string {
+    return `${deliverableId}|${source}`;
+  }
+}

+ 50 - 2
src/app/core/chat.service.ts

@@ -1,3 +1,6 @@
+/** 首屏加载条数:只渲染最近一屏,其余上滚再取(用户要求,避免一次性加载全部) */
+const INITIAL_PAGE = 30;
+
 import { computed, effect, inject, Injectable, signal } from '@angular/core';
 import { FmodeApi } from './api/fmode-api';
 import {
@@ -14,6 +17,7 @@ import { LiveService } from './live.service';
 import { SessionService } from './session.service';
 import { UnreadService } from './unread.service';
 import { MxMessage, MatrixClientService } from './matrix-client.service';
+import { TaskPulseService } from './task-pulse.service';
 
 export type ConnectionState = 'connecting' | 'connected' | 'disconnected';
 
@@ -43,6 +47,7 @@ export class ChatService {
   private readonly session = inject(SessionService);
   private readonly mx = inject(MatrixClientService);
   private readonly unread = inject(UnreadService);
+  private readonly pulse = inject(TaskPulseService);
 
   private readonly _agentId = signal<string | null>(null);
   private readonly _messages = signal<Message[]>([]);
@@ -143,6 +148,11 @@ export class ChatService {
       const next = this.mapTimeline(this.mx.timelineToMessages(rid), myUserId);
       if (next.length === 0) return;
       this._messages.set(next);
+      /*
+       * 🔴 这里**不能**标记「正在回复」:这是历史重读(每次 sync 都会重新灌一遍),
+       *    刷新页面时尾部 id 必然变 → 一打开就显示「正在回复」(用户实测)。
+       *    真正的实时信号在 appendIncoming()。
+       */
       if (this._state() === 'empty') this._state.set('ready');
       this._hasMoreHistory.set(this.mx.paginationToken(rid) != null);
     });
@@ -273,8 +283,17 @@ export class ChatService {
       this._peerTyping.set(typing);
     });
 
-    // ⑤ 读当前 timeline
-    this._messages.set(this.mapTimeline(this.mx.timelineToMessages(rid), myUserId));
+    /*
+     * ⑤ 读当前 timeline —— **只取尾部一屏**(v2 起)。
+     *
+     * 🔴 以前一次性把整条 timeline 灌进来:一个长会话 40+ 条、每条含
+     *    大段终端输出,打开就要渲染全部,卡顿且耗内存(用户要求虚拟滚动)。
+     *
+     * 现在:首屏只显示最近 INITIAL_PAGE 条;用户上滚时再通过 paginate() 往前取。
+     * `_hasMoreHistory` 由 timeline 里是否还有更早事件决定。
+     */
+    const all = this.mapTimeline(this.mx.timelineToMessages(rid), myUserId);
+    this._messages.set(all.slice(-INITIAL_PAGE));
 
     // ⑥ 连接状态由 SDK 同步事件驱动
     this._connection.set(this.mx.syncState() === 'connected' ? 'connected' : 'connecting');
@@ -404,6 +423,16 @@ export class ChatService {
     this._messages.update((m) => [...m, msg]);
     this._draft.set('');
     this._sending.set(true);
+    /*
+     * 🔴 用户一发出消息就进入「忙」——**这是事实,不是推测**(容器必定会收到)。
+     *
+     * 容器的运行态上报是分钟级(Hermes 按 turn 写库,再等下一轮上报),
+     * 不这么做的话「发了消息 → 右侧还是空闲」会持续很久(用户实测反馈)。
+     *
+     * 记在 `TaskPulseService` 里而非改 Agent 数据:服务端仍是最终真值,
+     * 只有容器报 `idle` 才能把它翻回去(前端绝不自己说「完成」)。
+     */
+    this.pulse.noteUserAction(conversationId);
 
     try {
       if (environment.useMockData) {
@@ -428,6 +457,8 @@ export class ChatService {
 
     this._uploading.set(true);
     this._uploadPct.set(0);
+    // 传文件同样是「用户已交办任务」→ 立刻进入忙(同 sendText)
+    this.pulse.noteUserAction(conversationId);
     try {
       for (const file of list) {
         const { publicUrl } = await this.api.uploadFile(file, (pct) => this._uploadPct.set(pct));
@@ -472,6 +503,11 @@ export class ChatService {
       if (my !== this.seq) return false;
       const myUserId = this.provision?.userMxId ?? '';
       const next = this.mapTimeline(this.mx.timelineToMessages(this.roomId), myUserId);
+      /*
+       * 分页后**保留完整已加载区间**(不做上限截断):
+       * 用户是在往上翻,超出窗口的内容用户正要看,截断会让人往前翻却看不到。
+       * 真正省开销的是首屏只取一屏 + 后续按需加载,而不是丢弃已加载内容。
+       */
       this._messages.set(next);
       // 条数未增长 = 已到最早,停止继续请求
       this._hasMoreHistory.set(ok && next.length > before);
@@ -546,6 +582,18 @@ export class ChatService {
     const [mapped] = this.mapTimeline([m], myUserId);
     this._messages.update((list) => [...list, mapped]);
     if (this._state() === 'empty') this._state.set('ready');
+    /*
+     * 实时到达 + 对方发的 = 「正在回复」。
+     * 「用户发的」不算 —— 我方刚发出消息不能说明对方在回。
+     */
+    const agentId = this._agentId();
+    if (agentId) {
+      this.pulse.noteMessage(agentId, mapped.id, {
+        live: true,
+        fromAgent: mapped.sender === 'agent',
+        ts: Date.parse(mapped.ts) || Date.now(),
+      });
+    }
   }
 
   private teardownSubscriptions(): void {

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

@@ -26,6 +26,10 @@ export class LiveService {
   private readonly _state = signal<LoadState>('idle');
   private readonly _error = signal<string | null>(null);
   private readonly _lastUpdated = signal<string | null>(null);
+  /** 轮询轮次(数据未变也自增,供下游做快照对比) */
+  private readonly _tick = signal(0);
+
+  readonly tick = this._tick.asReadonly();
 
   readonly agents = this._agents.asReadonly();
   readonly state = this._state.asReadonly();
@@ -87,6 +91,15 @@ export class LiveService {
       // 数据未变 → 不换引用(避免整列重渲染导致闪烁/滚动跳动)
       if (!sameAgents(this._agents(), agents)) {
         this._agents.set(agents);
+      } else {
+        /*
+         * 🔴 数据没变也必须让下游「知道又到一轮了」。
+         *
+         * `TaskPulseService` 靠每次轮询建立快照对比来发现状态翻转;
+         * 只在引用变化时才通知的话,「running → idle 但其它字段都没动」
+         * 这种最常见的翻转会被吞掉。故用独立的轻量信号承载轮次。
+         */
+        this._tick.update((n) => n + 1);
       }
       // 区分「空」与「就绪」:空列表是合法但需显示空态的状态(AC-03.4 禁止用空态掩盖失败)
       this._state.set(agents.length === 0 ? 'empty' : 'ready');

+ 120 - 0
src/app/core/message-segments.spec.ts

@@ -0,0 +1,120 @@
+import { hasProcess, isToolActivityMessage, parseSegments, splitMessage, stepCountLabel, toolActivitySummary } from './message-segments';
+
+/**
+ * 过程行拆分(用于「工具调用折叠」)。
+ *
+ * 设计原则:**宁可漏判,不可误判** —— 把用户要看的结果折进去,
+ * 比多留一行过程文本糟糕得多。
+ */
+describe('message-segments', () => {
+  describe('parseSegments 判定', () => {
+    it('圆点 + 工具名 → tool 步', () => {
+      const [s] = parseSegments('· bash');
+      expect(s).toEqual({ kind: 'tool', label: 'bash' } as never);
+    });
+
+    it('圆点 + 工具名 + 参数 → detail 分离', () => {
+      const [s] = parseSegments('· bash: git status --short');
+      expect(s).toEqual({ kind: 'tool', label: 'bash', detail: 'git status --short' });
+    });
+
+    it('圆点后接中文短语 → 不算工具(交回正文)', () => {
+      const [s] = parseSegments('· 已完成');
+      expect(s.kind).toBe('text');
+    });
+
+    it('「正在…」短句 → status 步', () => {
+      const [s] = parseSegments('正在读取配置文件…');
+      expect(s.kind).toBe('status');
+    });
+
+    it('✅ 完成行 → status 步', () => {
+      const [s] = parseSegments('✅ 3 个文件已处理');
+      expect(s.kind).toBe('status');
+    });
+
+    it('🔴 长句即使以「正在」开头也不折叠(避免吃掉正文)', () => {
+      const long = '正在说明一下:这次的问题是上报器把自己的心跳写进了 currentTask 字段,' +
+        '导致用户看到永远正在执行,需要改成从 state.db 判断。';
+      const [s] = parseSegments(long);
+      expect(s.kind).toBe('text');
+    });
+
+    it('普通正文 → text', () => {
+      const [s] = parseSegments('我看了下环境,没问题。');
+      expect(s.kind).toBe('text');
+    });
+
+    it('空行 → blank', () => {
+      const [s] = parseSegments('   ');
+      expect(s.kind).toBe('blank');
+    });
+  });
+
+  describe('toBlocks / splitMessage 分组', () => {
+    it('连续过程行合成一个 block', () => {
+      const blocks = splitMessage('先看环境。\n· bash\n· read_file\n· edit_file\n改好了。');
+      const kinds = blocks.map((b) => b.kind);
+      expect(kinds).toEqual(['text', 'process', 'text']);
+      const proc = blocks[1] as { steps: unknown[] };
+      expect(proc.steps.length).toBe(3);
+    });
+
+    it('过程行之间的空行不打断分组', () => {
+      const blocks = splitMessage('· bash\n\n· read_file\n\n结论如下。');
+      expect(blocks[0].kind).toBe('process');
+      expect((blocks[0] as { steps: unknown[] }).steps.length).toBe(2);
+    });
+
+    it('孤立单步不折叠(内联成一行,少点一次)', () => {
+      const blocks = splitMessage('正文一\n· bash\n正文二');
+      expect(blocks.every((b) => b.kind !== 'process')).toBe(true);
+      expect(blocks.some((b) => b.kind === 'text' && b.text.includes('bash'))).toBe(true);
+    });
+
+    it('没有过程行的消息 → hasProcess 为 false(走整段 markdown)', () => {
+      expect(hasProcess({ text: '这是一段普通回复,含 **加粗**。\n\n第二段。' })).toBe(false);
+    });
+
+    it('含过程行 → hasProcess 为 true', () => {
+      expect(hasProcess({ text: '· bash\n· read_file\n好的。' })).toBe(true);
+    });
+
+    it('步骤数文案', () => {
+      expect(stepCountLabel(3)).toBe('运行了 3 步');
+    });
+  });
+
+
+  describe("容器终端活动消息(实测格式)", () => {
+    it("💻 terminal + 代码块 → 是终端活动", () => {
+      expect(isToolActivityMessage("💻 terminal\\n```\\ncd /opt/data && chmod +x x.sh\\n```")).toBeTrue();
+    });
+
+    it("✍️ Writing 路径 + 命令 → 是终端活动", () => {
+      expect(isToolActivityMessage("✍️ Writing /opt/data/work/i.html 💻 terminal\\n```\\nnode -e x\\n```")).toBeTrue();
+    });
+
+    it("带 emoji 的单行状态(攒经验)→ 是终端活动", () => {
+      expect(isToolActivityMessage("💾 攒经验中,已入账:🔧 「ops」强化成功")).toBeTrue();
+    });
+
+    it("🔴 正常回复带一个 emoji → 不算终端活动(不能误折)", () => {
+      expect(isToolActivityMessage("修复验证通过 ✅ 字节数一致,本地与云端 MD5 相同。")).toBeFalse();
+    });
+
+    it("🔴 长段落即使含命令片段 → 不算终端活动", () => {
+      const long = "我执行了 curl 命令做验证,返回的是 200,说明整条链路是通的,这一步之后就可以把结果整理成报告了,目前没有未完成的事项需要处理。";
+      expect(isToolActivityMessage(long)).toBeFalse();
+    });
+
+    it("空文本 → false", () => {
+      expect(isToolActivityMessage("")).toBeFalse();
+      expect(isToolActivityMessage(undefined)).toBeFalse();
+    });
+
+    it("摘要取第一行动作名", () => {
+      expect(toolActivitySummary("💻 terminal\\n```\\nls\\n```")).toContain("terminal");
+    });
+  });
+});

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

@@ -0,0 +1,275 @@
+import { Message } from './api/models';
+
+/**
+ * 把一条 agent 消息拆成「可见正文」与「过程行」两类片段。
+ *
+ * ## 为什么需要这一层(实测反馈)
+ *
+ * 容器的回复长这样(一条 Matrix 事件里全是换行):
+ *
+ * ```
+ * 我先看一下环境。
+ * · bash
+ * · read_file
+ * 找到了,正在改。
+ * · edit_file
+ * 改完了。
+ * ```
+ *
+ * 现在这些「过程行」和正文平铺,用户要一路滚。
+ * Codex / 桌面端的做法是:**过程折叠成一行「运行了 3 步」,展开才见细节**。
+ *
+ * ## 判定口径(只认**明确**的过程行,不瞎猜)
+ *
+ * ```
+ * 以「·」或「•」开头,且后接工具名  →  tool
+ * 形如 ⌛/⏳/…/正在 + 短句(≤40 字)  →  status
+ * 空行                             →  blank(折叠组内不计)
+ * 其余                             →  text(正文,永不折叠)
+ * ```
+ *
+ * 🔴 宁可漏判(正文里多留一行),也不误判(把用户要看的结果折进去)。
+ */
+
+export interface TextSegment {
+  kind: 'text';
+  text: string;
+}
+
+export interface ProcessStep {
+  kind: 'tool' | 'status';
+  /** 工具名或状态原话 */
+  label: string;
+  /** 工具参数(如 `git status`),无则空 */
+  detail?: string;
+}
+
+export interface BlankSegment {
+  kind: 'blank';
+}
+
+export type Segment = TextSegment | ProcessStep | BlankSegment;
+
+/** 一段可折叠的过程区 */
+export interface ProcessGroup {
+  kind: 'process';
+  steps: ProcessStep[];
+}
+
+export type Block = TextSegment | ProcessGroup | BlankSegment;
+
+/** 短行上限:超过这个长度的「正在…」更可能是正文,不折叠 */
+const STATUS_MAX = 40;
+
+/** 工具行的常见前缀符号 */
+const BULLET = /^[·•\-*]\s*/;
+
+/**
+ * 单个步骤 `/ 工具名:参数` 或 `/ 工具名 参数` 都能拆。
+ * 工具名限定为「字母数字下划线点横线」,避免把「· 已完成」误判成工具。
+ */
+const TOOL_LINE = /^([a-zA-Z][\w.\-]{0,40})(?:\s*[::]\s*|\s+)(.*)$/;
+
+/**
+ * 过程提示行:以省略号/沙漏/「正在」开头,且是短句。
+ * 例:`⌛ 正在读取…`、`…思考中`、`正在整理结果。`
+ */
+const STATUS_LINE = /^(?:[⌛⏳…\.]{1,3}\s*)?(?:正在|排队|稍候|处理中|搜索中|读取中|思考中)/;
+
+const DONE_LINE = /^(?:✅|✔|✔️)\s*.{0,60}$/;
+
+/**
+ * 容器「终端活动」行的判定(2026-10-21 实测格式)。
+ *
+ * 容器把 Hermes 的每一步都作为**独立 m.text 消息**发出来,形如:
+ *
+ * ```
+ * 💻 terminal
+ * ```
+ * cd /opt/data && chmod +x ...
+ * ```
+ *
+ * ```
+ * ✍️ Writing /opt/data/work/login-page/index.html 💻 terminal
+ * ```
+ * cd /opt/data && node -e "..."
+ * ```
+ *
+ * 也有单行的:`* 💻 terminal ...`、`🐍 Running code ...`、`💾 攒经验中…`。
+ *
+ * 判定口径:**同时满足**以下才算终端活动,避免把正文里的代码块折掉:
+ *   ① 出现工具/动作 emoji(💻 🐍 ✍️ 🔧 📄 💾 🔍 📖 🧪 …)
+ *   ② 该行很短(< 120 字符)
+ */
+const TOOL_EMOJI =
+  /[💻🐍✍️🔧📄💾🔍📖🧪🛠️⚙️📝🗂️📦🚀✅❌⚠️🔁⏳⌛▶️⏸️🎯📊🔗💡🧠📌🗑️📋🎨🔨🪄]/u;
+
+/** 形如 `💻 terminal`、`✍️ Writing /path`、`* 💻 terminal ...` 的短行 */
+function isToolActivityLine(line: string): boolean {
+  const t = line.trim();
+  if (!t || t.length > 120) return false;
+  if (!TOOL_EMOJI.test(t)) return false;
+  /*
+   * 排除「正文里偶尔带个 emoji 的句子」:要求去掉 emoji 与符号后仍是
+   * 短片段(工具名 / 命令),而不是完整叙述句。
+   */
+  return !/[。!?;]$/.test(t);
+}
+
+/**
+ * 把「一条纯终端活动消息」整体识别出来(用于**整条折叠**,不做正文拆分)。
+ *
+ * 返回 `true` 表示这条消息全是终端活动,UI 可整条收进折叠组。
+ */
+export function isToolActivityMessage(text: string | undefined): boolean {
+  if (!text) return false;
+  const lines = text.split(/\r?\n/).filter((l) => l.trim() !== '');
+  if (lines.length === 0) return false;
+  // 去掉 ``` 围栏后,剩下的每一行都得是终端活动或纯命令
+  const body = lines.filter((l) => !/^```/.test(l.trim()));
+  if (body.length === 0) return true;
+  // 只要有一行是「像人话的长段落」,就不是纯终端消息
+  return body.every((l) => {
+    const t = l.trim();
+    if (isToolActivityLine(t)) return true;
+    // 命令本身:短、无中文句号、常见命令/路径特征
+    return (
+      t.length <= 120 &&
+      !/。$/.test(t) &&
+      (/^[\w./~$*-]/.test(t) || /^[a-zA-Z_]+=/.test(t))
+    );
+  });
+}
+
+/** 从终端活动消息里提取一句话摘要(折叠头显示什么) */
+export function toolActivitySummary(text: string): string {
+  const first = text
+    .split(/\r?\n/)
+    .map((l) => l.trim())
+    .find((l) => l && !/^```/.test(l) && isToolActivityLine(l));
+  if (!first) return '终端操作';
+  return first.replace(/^[*·•-]\s*/, '').slice(0, 60);
+}
+
+/** 把一条消息正文拆成段 */
+export function parseSegments(text: string): Segment[] {
+  const out: Segment[] = [];
+  for (const raw of text.split(/\r?\n/)) {
+    const line = raw.trimEnd();
+    if (line.trim() === '') {
+      out.push({ kind: 'blank' });
+      continue;
+    }
+    const step = asProcessStep(line);
+    out.push(step ?? { kind: 'text', text: line });
+  }
+  return out;
+}
+
+function asProcessStep(line: string): ProcessStep | null {
+  if (line.startsWith('·') || line.startsWith('•')) {
+    const inner = line.replace(BULLET, '').trim();
+    if (!inner) return null;
+    // 带分隔符(空格/冒号)的工具形态:`bash: git status`、`read_file /opt/x`
+    const m = TOOL_LINE.exec(inner);
+    if (m) {
+      return { kind: 'tool', label: m[1], detail: m[2].trim() || undefined };
+    }
+    /*
+     * 单个英文单词且不含空格(如 `· bash`、`· edit_file`)→ 也是工具行。
+     * 实测容器的写法两种都有:「· bash」与「· bash: git status --short」。
+     */
+    if (/^[a-zA-Z][\w.\-]{0,40}$/.test(inner)) {
+      return { kind: 'tool', label: inner };
+    }
+    // 中文短语(如「· 已完成」)不算工具,交回正文
+    return null;
+  }
+  const t = line.trim();
+  if (t.length <= STATUS_MAX && STATUS_LINE.test(t)) {
+    return { kind: 'status', label: t };
+  }
+  if (t.length <= STATUS_MAX && DONE_LINE.test(t)) {
+    return { kind: 'status', label: t };
+  }
+  return null;
+}
+
+/**
+ * 段 → 块:连续的过程行(中间允许空行)合成一个 `ProcessGroup`。
+ *
+ * 分组规则:
+ *   · 遇到正文 → 结束当前组
+ *   · 组内只剩 1 步且前后都是正文时**不折叠**(单个步骤没信息量,折叠反而多点一次)
+ */
+export function toBlocks(segments: Segment[]): Block[] {
+  const blocks: Block[] = [];
+  let pending: ProcessStep[] = [];
+  // 组前是否隔了空行(影响「单步是否值得折叠」的判断)
+  let gapBefore = 0;
+  // 组内是否真的出现过空行间隔
+
+  const flush = (): void => {
+    if (pending.length === 0) return;
+    /*
+     * 孤立单步(前后都没隔空行)→ 内联成一行,不做折叠容器。
+     * 单个步骤折叠起来反而要多点一次,没有信息量上的收益。
+     */
+    if (pending.length === 1 && gapBefore === 0) {
+      const s = pending[0];
+      blocks.push({ kind: 'text', text: s.detail ? `· ${s.label} ${s.detail}` : `· ${s.label}` });
+      pending = [];
+      return;
+    }
+    blocks.push({ kind: 'process', steps: pending });
+    pending = [];
+    gapBefore = 0;
+  };
+
+  for (const seg of segments) {
+    if (seg.kind === 'blank') {
+      if (pending.length === 0) {
+        blocks.push({ kind: 'blank' });
+        gapBefore += 1;
+      }
+      continue;
+    }
+    if (seg.kind === 'text') {
+      flush();
+      blocks.push(seg);
+      gapBefore = 0;
+      continue;
+    }
+    pending.push(seg);
+  }
+  flush();
+  return blocks;
+}
+
+/** 便捷入口:正文 → 块 */
+export function splitMessage(text: string): Block[] {
+  return toBlocks(parseSegments(text));
+}
+
+/**
+ * 一组过程的总时长(毫秒)。
+ *
+ * 消息本身只带一个时间戳,没有逐步时间 → **不编时长**,返回 `undefined`。
+ * 有起止时间时才计算(将来容器若上报 `steps[].at` 即可直接启用)。
+ */
+export function groupDurationMs(steps: { at?: number }[]): number | undefined {
+  const times = steps.map((s) => s.at).filter((t): t is number => typeof t === 'number');
+  if (times.length < 2) return undefined;
+  return Math.max(...times) - Math.min(...times);
+}
+
+/** 步骤数文案:「运行了 3 步」 */
+export function stepCountLabel(n: number): string {
+  return `运行了 ${n} 步`;
+}
+
+/** 判断消息是否含有可折叠的过程(供测试与列表预览复用) */
+export function hasProcess(m: Pick<Message, 'text'>): boolean {
+  if (!m.text) return false;
+  return splitMessage(m.text).some((b) => b.kind === 'process');
+}

+ 159 - 0
src/app/core/task-pulse.service.spec.ts

@@ -0,0 +1,159 @@
+import { TestBed } from '@angular/core/testing';
+import { Agent } from './api/models';
+import { FmodeApi } from './api/fmode-api';
+import { LiveService } from './live.service';
+import { TaskPulseService } from './task-pulse.service';
+
+/**
+ * 运行态脉冲 —— 「当前任务」不要等容器几分钟才更新。
+ *
+ * 核心纪律(本文件就是它的回归防线):
+ *   🔴 **前端只能把状态往前推(用户动作 → 忙),不能自己说「完成」。**
+ */
+describe('TaskPulseService', () => {
+  let svc: TaskPulseService;
+  let live: LiveService;
+
+  function agent(over: Partial<Agent> = {}): Agent {
+    return {
+      id: 'a1',
+      name: 'lj-test',
+      status: 'idle',
+      matrixReady: true,
+      unread: 0,
+      heartbeatAt: new Date().toISOString(),
+      ...over,
+    };
+  }
+
+  /** 模拟一轮轮询落地 */
+  function tick(agents: Agent[]): void {
+    (live as unknown as { _agents: { set(v: Agent[]): void }; _tick: { update(f: (n: number) => number): void } })._agents.set(agents);
+    (live as unknown as { _tick: { update(f: (n: number) => number): void } })._tick.update((n) => n + 1);
+    TestBed.tick();
+  }
+
+  beforeEach(() => {
+    TestBed.configureTestingModule({
+      providers: [
+        LiveService,
+        TaskPulseService,
+        // LiveService 只注入 FmodeApi 用于取数;本测试直接操作其信号,不需要真实实现
+        { provide: FmodeApi, useValue: { fetchAgents: () => Promise.resolve([]) } },
+      ],
+    });
+    live = TestBed.inject(LiveService);
+    svc = TestBed.inject(TaskPulseService);
+  });
+
+  it('容器报 running → 显示任务描述', () => {
+    const line = svc.describe(agent({ status: 'running', currentTask: '处理企业微信用户对话' }));
+    expect(line.text).toBe('处理企业微信用户对话');
+    expect(line.active).toBeTrue();
+  });
+
+  it('🔴 用户发消息 → 立刻显示「刚刚开始」(不等容器上报)', () => {
+    svc.noteUserAction('a1');
+    const line = svc.describe(agent({ status: 'unknown' as Agent['status'] }));
+    expect(line.active).toBeTrue();
+    expect(line.text).toContain('刚刚开始');
+  });
+
+  it('🔴 前端不会自己翻回「空闲」:只有容器报 idle 才解除', () => {
+    svc.noteUserAction('a1');
+    // 容器还没说话(status 未知)→ 仍是忙
+    expect(svc.describe(agent({ status: 'unknown' as Agent['status'] })).active).toBeTrue();
+    // 容器明确说 idle → 解除
+    expect(svc.describe(agent({ status: 'idle' })).active).toBeFalsy();
+  });
+
+  it('快照超过 90 秒没更新 → 报「状态未同步」而不是假装空闲', () => {
+    const old = new Date(Date.now() - 10 * 60_000).toISOString();
+    const line = svc.describe(agent({ status: 'idle', heartbeatAt: old }));
+    expect(line.text).toContain('状态未同步');
+    expect(line.tone).toBe('warning');
+  });
+
+  it('心跳新鲜且 idle → 显示空闲', () => {
+    expect(svc.describe(agent({ status: 'idle' })).text).toBe('空闲');
+  });
+
+  it('🔴 容器明确报异常/离线 → 覆盖其它判断,标 danger', () => {
+    // 即使心跳新鲜、本地也没在推测忙碌,也要如实显示它坏了
+    expect(svc.describe(agent({ status: 'error' }))).toEqual({ text: '运行异常', tone: 'danger' });
+    expect(svc.describe(agent({ status: 'offline' }))).toEqual({ text: '已离线', tone: 'danger' });
+    expect(svc.describe(agent({ status: 'stopped' }))).toEqual({ text: '已停止', tone: 'danger' });
+  });
+
+  it('心跳新鲜但容器说 error → 不显示「空闲」(先报坏)', () => {
+    const line = svc.describe(agent({ status: 'error' }));
+    expect(line.text).not.toBe('空闲');
+  });
+
+  it('观测到 running → idle 时产生「完成」动态,并带用时', () => {
+    tick([agent({ status: 'running', currentTask: '写报告' })]); // 建基线
+    tick([agent({ status: 'running', currentTask: '写报告' })]);
+    tick([agent({ status: 'idle' })]);
+
+    const finished = svc.pulses().find((p) => p.kind === 'finished');
+    expect(finished).toBeDefined();
+    expect(finished!.task).toBe('写报告');
+  });
+
+  it('首次观测只建基线,不把「第一次看到 idle」当成刚完成', () => {
+    tick([agent({ status: 'idle' })]);
+    expect(svc.pulses().length).toBe(0);
+  });
+
+  it('数据没变的轮次也要观测(running→idle 其它字段都没动)', () => {
+    const running = agent({ status: 'running' });
+    tick([running]);
+    (live as unknown as { _tick: { update(f: (n: number) => number): void } })._tick.update((n) => n + 1);
+    TestBed.tick();
+    // 引用不变,但换了状态
+    (live as unknown as { _agents: { set(v: Agent[]): void } })._agents.set([agent({ status: 'idle' })]);
+    (live as unknown as { _tick: { update(f: (n: number) => number): void } })._tick.update((n) => n + 1);
+    TestBed.tick();
+    expect(svc.pulses().some((p) => p.kind === 'finished')).toBeTrue();
+  });
+
+  it('🔴 容器已报 idle 但仍在输出 → 显示「正在回复」,不显示「已完成」', () => {
+    const a = agent({ status: 'idle' });
+    // 模拟刚收到一条消息
+    svc.noteMessage('a1', 'm-1', { live: true, fromAgent: true, ts: Date.now() });
+    const line = svc.describe(a);
+    expect(line.text).toContain('正在回复');
+    expect(line.text).not.toContain('已完成');
+  });
+
+  it('消息静默超过窗口 → 回到空闲/已完成判断', () => {
+    svc.noteMessage('a1', 'm-1', { live: true, fromAgent: true, ts: Date.now() });
+    // 模拟 30 秒后(超过 20s 窗口)
+    const line = svc.describe(agent({ status: 'idle' }), Date.now() + 30_000);
+    expect(line.text).not.toContain('正在回复');
+  });
+
+  describe('🔴 刷新页面不能误报「正在回复」(用户实测)', () => {
+    it('历史重放(live=false)→ 不算正在回复', () => {
+      svc.noteMessage('a1', 'm-old', { live: false, fromAgent: true });
+      expect(svc.describe(agent({ status: 'idle' })).text).toBe('空闲');
+    });
+
+    it('实时但消息很旧(初始同步回放)→ 不算正在回复', () => {
+      const old = Date.now() - 5 * 60_000;
+      svc.noteMessage('a1', 'm-old', { live: true, fromAgent: true, ts: old });
+      expect(svc.describe(agent({ status: 'idle' })).text).toBe('空闲');
+    });
+
+    it('实时且刚发生、且是对方发的 → 正在回复', () => {
+      svc.noteMessage('a1', 'm-new', { live: true, fromAgent: true, ts: Date.now() });
+      const line = svc.describe(agent({ status: 'idle' }), Date.now());
+      expect(line.text).toContain('正在回复');
+    });
+
+    it('自己发的消息 → 不算对方在回复', () => {
+      svc.noteMessage('a1', 'm-mine', { live: true, fromAgent: false, ts: Date.now() });
+      expect(svc.describe(agent({ status: 'idle' })).text).toBe('空闲');
+    });
+  });
+});

+ 295 - 0
src/app/core/task-pulse.service.ts

@@ -0,0 +1,295 @@
+import { computed, effect, inject, Injectable, signal } from '@angular/core';
+import { Agent } from './api/models';
+import { LiveService } from './live.service';
+
+/** 服务端数据「新鲜」窗口:超过这个时长的 idle 不再当作「刚刚完成」展示 */
+const FRESH_MS = 90_000;
+
+/**
+ * 「还在回复」的静默窗口:最近一次消息进来后这么久内,都算正在输出。
+ * 容器回复是分段发的,段与段之间有几秒间隔,取 20 秒足够覆盖又不至于一直挂着。
+ */
+const REPLY_QUIET_MS = 20_000;
+
+/** 超过这么久的事件视为「历史重放」,不据此判断"正在回复" */
+const STALE_MSG_MS = 60_000;
+
+export type PulseKind = 'started' | 'finished';
+
+export interface TaskPulse {
+  agentId: string;
+  kind: PulseKind;
+  /** 观测到翻转的时刻(本地,毫秒) */
+  at: number;
+  /** 开始时的任务描述(来自上一轮 currentTask) */
+  task?: string;
+  /** 完成时的用时(由两次观测的时刻差估算) */
+  durationMs?: number;
+}
+
+interface Mark {
+  running: boolean;
+  at: number;
+  task?: string;
+}
+
+/**
+ * 运行态脉冲 —— 「当前任务」不要等很久才出现。
+ *
+ * ## 解决什么(用户实测反馈)
+ *
+ * 容器的上报间隔是分钟级(Hermes turn 开始/结束才写库,再等下一轮上报),
+ * 所以「发了消息 → 右侧还是空闲」会持续很久,用户以为没发出去。
+ *
+ * ## 做法:**三分职责,谁都不越界**
+ *
+ * | 信号来源 | 能确定的事 | 谁负责 |
+ * |---|---|---|
+ * | 用户在 App 里发消息 / 传附件 | 任务**已经开始** | 前端**立即**置 running |
+ * | 容器上报 `status=idle` | 任务**已经结束** | 只有容器能说 |
+ * | 服务端快照 | 上一轮观测到的任务描述 | 轮询对比得出「变化」 |
+ *
+ * 🔴 **前端绝不自己翻回 idle** —— 那是编造「完成」。
+ *    下一条用户消息出现前的一切「忙」,都以容器的 `idle` 为准。
+ *
+ * ## 界面该怎么描述
+ *
+ * 只描述**已观测到的事实**:
+ *
+ * ```
+ * 刚刚开始 · 正在处理你的消息      ← 用户刚发消息
+ * 正在执行 · 处理企业微信用户对话  ← 容器上报的任务描述
+ * 已完成 · 用时 3 分 20 秒        ← 观测到 running → idle
+ * 空闲                            ← 容器说闲 AND 快照新鲜
+ * 状态未同步 · 等待容器上报        ← 快照超过 90s 没变(不假装在线空闲)
+ * ```
+ *
+ * 不写「先梳理需求 → 再执行」这类**计划**:容器没报计划,写了就是编。
+ */
+@Injectable({ providedIn: 'root' })
+export class TaskPulseService {
+  private readonly live = inject(LiveService);
+
+  /** 本地认为「刚开始」的会话(agentId → 发出消息的时刻) */
+  private readonly _localStart = signal<ReadonlyMap<string, number>>(new Map());
+  /** 每次轮询观测到的翻转 */
+  private readonly _pulses = signal<readonly TaskPulse[]>([]);
+
+  readonly pulses = this._pulses.asReadonly();
+
+  /** 上一轮观测(agentId → 是否在忙 / 时刻 / 任务描述) */
+  private marks = new Map<string, Mark>();
+
+  /** 最近一条消息到达时刻(agentId → 毫秒),用于「正在回复」判定 */
+  private readonly lastMsgAt = new Map<string, number>();
+  /** 会话里最后一条消息(含内容),用于识别新消息到达 */
+  private lastSeenMsgId = '';
+  /** 首次观测需要先建立基线,不能把「第一次看到 idle」当成「刚完成」 */
+  private primed = false;
+
+  constructor() {
+    effect(() => {
+      // 每轮轮询都要观测一次(数据未变时 agents 引用不变,靠 tick 推进)
+      this.live.tick();
+      const agents = this.live.agents();
+      if (agents.length === 0) return;
+      this.observe(agents);
+    });
+  }
+
+  /** 用户刚发出消息:本地立刻进入 running(**仅前端展示,不改服务端**) */
+  noteUserAction(agentId: string): void {
+    const next = new Map(this._localStart());
+    next.set(agentId, Date.now());
+    this._localStart.set(next);
+    // 本地推测也记进 marks 的「开始时刻」,便于算用时
+    const prev = this.marks.get(agentId);
+    if (prev && !prev.running) {
+      this.push({ agentId, kind: 'started', at: Date.now(), task: prev.task });
+    }
+    this.marks.set(agentId, { running: true, at: Date.now(), task: prev?.task });
+  }
+
+  /**
+   * 记录「有新消息到达」(由 ChatService / 聊天页调用)。
+   *
+   * 这是比容器上报**更新鲜**的信号:消息是实时的,运行态是分钟级的。
+   */
+  noteMessage(
+    agentId: string,
+    msgId: string,
+    opts?: { live?: boolean; fromAgent?: boolean; ts?: number },
+  ): void {
+    if (!agentId || msgId === this.lastSeenMsgId) return;
+    this.lastSeenMsgId = msgId;
+    /*
+     * 🔴 只有「**实时到达的、对方发的**」消息才算「正在回复」。
+     *
+     * 上一版只看「尾部 id 变了」,导致**刷新页面时重新灌入历史**也被当成新消息
+     * → 一打开就显示「正在回复」,20 秒后才好(用户实测)。
+     *
+     * 历史重读与实时事件的区分由调用方给(`live`);默认按历史处理,
+     * 宁可少报「正在回复」,也不能在没有任何回复时假装它在回。
+     */
+    if (opts?.live !== true || opts?.fromAgent !== true) return;
+    /*
+     * 🔴 还要看**消息自身的时间戳**:matrix-js-sdk 在初始同步时会把已同步的历史
+     *    事件经由 onMessage 回放一遍 —— 它走的是"实时"回调,但消息其实很旧。
+     *    只看「回调是不是实时的」会把刷新时的历史重放当成新消息(用户实测:
+     *    一打开就显示「正在回复」)。
+     */
+    const ts = opts.ts ?? Date.now();
+    if (Date.now() - ts > STALE_MSG_MS) return;
+    this.lastMsgAt.set(agentId, Date.now());
+  }
+
+  /** 最近一次消息活动的时刻(没有则返回 0,视作很久以前) */
+  private lastActivity(agentId: string): number {
+    return this.lastMsgAt.get(agentId) ?? 0;
+  }
+
+  /** 当前会话是否在「本地推测的忙碌」中(容器明确说 idle 时自动解除) */
+  localStartAt = (agentId: string): number | undefined => this._localStart().get(agentId);
+
+  /**
+   * 是否在忙(供 UI 判定)。
+   *
+   * `localStart` 只在下一次上报到来之前有效:容器已在处理时它自然会变成 running,
+   * 容器报 idle 时**以容器为准**,本地推测立刻作废。
+   */
+  isBusy(agent: Agent | undefined): boolean {
+    if (!agent) return false;
+    if (agent.status === 'running') return true;
+    if (agent.status === 'idle' || agent.status === 'offline' || agent.status === 'stopped') {
+      return false;
+    }
+    return this._localStart().has(agent.id);
+  }
+
+  /** 右侧「最近动态」:只列本会话的翻转(用户真正关心的是它在干什么) */
+  pulsesFor(agentId: string | null): readonly TaskPulse[] {
+    const all = this._pulses();
+    return (agentId ? all.filter((p) => p.agentId === agentId) : all).slice(0, 6);
+  }
+
+  /** 「当前任务」的一句话文案(唯一实现,列表/头部/右栏都用它) */
+  describe(agent: Agent | undefined, now = Date.now()): TaskLine {
+    // 依赖这些信号,让调用方在数据更新后重算
+    this._localStart();
+    this.live.tick();
+    return this.describeOf(agent, now);
+  }
+
+  private describeOf(agent: Agent | undefined, now: number): TaskLine {
+    if (!agent) return { text: '', tone: 'neutral' };
+    const local = this._localStart().get(agent.id);
+    if (agent.status === 'running') {
+      const task = agent.currentTask?.trim() || agent.currentAction?.trim();
+      return { text: task || '正在处理', tone: 'success', active: true };
+    }
+    /*
+     * 容器明确报的「坏了」要压过其它判断(用户最需要先知道的是它能不能干活)。
+     * 注意跟「未同步」区分:这是容器**主动说**自己坏了,不是我们看不到。
+     */
+    if (agent.status === 'error' || agent.status === 'offline' || agent.status === 'stopped') {
+      return {
+        text: { error: '运行异常', offline: '已离线', stopped: '已停止' }[agent.status],
+        tone: 'danger',
+      };
+    }
+    if (local && this.isBusy(agent)) {
+      return {
+        text: '刚刚开始 · 正在处理你的消息',
+        tone: 'info',
+        active: true,
+        since: local,
+      };
+    }
+    // 容器说 idle,但快照可能早就停了 → 分清「真空闲」和「状态没同步」
+    const beat = agent.heartbeatAt ? Date.parse(agent.heartbeatAt) : NaN;
+    if (Number.isFinite(beat) && now - beat > FRESH_MS) {
+      return { text: `状态未同步 · 上次上报 ${agoText(now - beat)}`, tone: 'warning' };
+    }
+    /*
+     * 🔴 容器已经回 idle,但可能**还在往外发消息**(用户实测:还在回复就显示「已完成」)。
+     *
+     * 容器的运行态是分钟级上报的,turn 结束与最后一条消息之间可能差几十秒;
+     * 而消息流是实时的。所以「最近有消息进来」比「上报说 idle」更新鲜,
+     * 必须以它为准 —— 否则用户看着还在输出,状态却说完成了。
+     */
+    const quietMs = now - this.lastActivity(agent.id);
+    if (quietMs < REPLY_QUIET_MS) {
+      return { text: '正在回复…', tone: 'info', active: true };
+    }
+
+    const last = this.lastFinish(agent.id);
+    if (last?.durationMs != null && now - last.at < 10 * 60_000) {
+      return { text: `已完成 · 用时 ${fmtDur(last.durationMs)}`, tone: 'success' };
+    }
+    return { text: '空闲', tone: 'neutral' };
+  }
+
+  private lastFinish(agentId: string): TaskPulse | undefined {
+    return this._pulses().find((p) => p.agentId === agentId && p.kind === 'finished');
+  }
+
+  private observe(agents: Agent[]): void {
+    const now = Date.now();
+    const pulses: TaskPulse[] = [];
+    for (const a of agents) {
+      const running = a.status === 'running';
+      const prev = this.marks.get(a.id);
+      if (prev) {
+        if (!prev.running && running) {
+          pulses.push({ agentId: a.id, kind: 'started', at: now, task: a.currentTask });
+        } else if (prev.running && !running) {
+          pulses.push({
+            agentId: a.id,
+            kind: 'finished',
+            at: now,
+            task: prev.task,
+            durationMs: Math.max(0, now - prev.at),
+          });
+          const next = new Map(this._localStart());
+          next.delete(a.id);
+          this._localStart.set(next);
+        }
+      }
+      this.marks.set(a.id, { running, at: running ? prev?.at ?? now : now, task: a.currentTask });
+    }
+    if (!this.primed) {
+      this.primed = true;
+      return; // 首次只建基线,不报翻转
+    }
+    if (pulses.length) this.push(...pulses);
+  }
+
+  private push(...items: TaskPulse[]): void {
+    this._pulses.update((old) => [...items.slice().reverse(), ...old].slice(0, 40));
+  }
+}
+
+export interface TaskLine {
+  text: string;
+  tone: 'success' | 'warning' | 'info' | 'danger' | 'neutral';
+  /** 是否处于「正在做」状态(UI 加动效) */
+  active?: boolean;
+  /** 开始时刻(用来在前端递增显示已用时) */
+  since?: number;
+}
+
+function agoText(ms: number): string {
+  const sec = Math.max(0, Math.round(ms / 1000));
+  if (sec < 60) return `${sec} 秒前`;
+  const min = Math.round(sec / 60);
+  if (min < 60) return `${min} 分钟前`;
+  return `${Math.round(min / 60)} 小时前`;
+}
+
+function fmtDur(ms: number): string {
+  const sec = Math.round(ms / 1000);
+  if (sec < 60) return `${sec} 秒`;
+  const m = Math.floor(sec / 60);
+  const s = sec % 60;
+  return s ? `${m} 分 ${s} 秒` : `${m} 分钟`;
+}

+ 499 - 66
src/app/desktop/pages/chat/chat.ts

@@ -22,6 +22,7 @@ import {
 import { ComposerComponent } from '../../../components/domain/composer/composer';
 import { MessageBubbleComponent } from '../../../components/domain/message-bubble/message-bubble';
 import { TypingIndicatorComponent } from '../../../components/domain/typing-indicator/typing-indicator';
+import { ToolActivityComponent } from '../../../components/domain/tool-activity/tool-activity';
 import { FilePreviewComponent } from '../../../components/preview/file-preview/file-preview';
 import { PresenceBadgeComponent } from '../../../components/domain/presence-badge/presence-badge';
 import { AgentPresence, agentPresence } from '../../../core/api/models';
@@ -31,6 +32,12 @@ import { Deliverable, TASK_STATUS_TEXT } from '../../../core/api/models';
 import { DeliverablesService } from '../../../core/deliverables.service';
 import { DeviceService } from '../../../core/device.service';
 import { fmtPercent, fmtTokens, usageOf } from '../../../core/format';
+import { TaskLine, TaskPulseService } from '../../../core/task-pulse.service';
+import { ArtifactSyncService } from '../../../core/artifact-sync.service';
+import { isToolActivityMessage, toolActivitySummary } from '../../../core/message-segments';
+import { Block } from '../../../core/message-segments';
+import { Message } from '../../../core/api/models';
+import { fmtTime } from '../../../core/time';
 import { LiveService } from '../../../core/live.service';
 import { UnreadService } from '../../../core/unread.service';
 import { TasksService } from '../../../core/tasks.service';
@@ -47,6 +54,11 @@ import { TasksService } from '../../../core/tasks.service';
  * AC:04.1 四栏 / 04.2 发送 / 04.3 上传进度 / 04.5 自增高 / 04.6 状态不重复
  *     / 04.8 失败可重试 / 04.9 连接状态 / 04.10 超长截断 / 04.11 三态
  */
+/** 一条消息的渲染形态:普通气泡 or 终端活动折叠组 */
+type MessageRow =
+  | { kind: 'msg'; key: string; message: Message }
+  | { kind: 'tool'; key: string; block: Extract<Block, { kind: 'process' }> };
+
 @Component({
   selector: 'app-dk-chat-page',
   changeDetection: ChangeDetectionStrategy.OnPush,
@@ -62,6 +74,7 @@ import { TasksService } from '../../../core/tasks.service';
     ComposerComponent,
     MessageBubbleComponent,
     TypingIndicatorComponent,
+    ToolActivityComponent,
     FilePreviewComponent,
     PresenceBadgeComponent,
     StatusBadgeComponent,
@@ -119,16 +132,29 @@ import { TasksService } from '../../../core/tasks.service';
                     <span class="conv-info">
                       <span class="conv-line">
                         <strong class="ellipsis">{{ c.name }}</strong>
-                        <!--
-                          可对话状态:**配色 + 文字**胶囊(用户要求,比圆点清晰)。
-                          判定唯一实现在 models.agentPresence()(以接入态为准)。
-                        -->
-                        <app-presence-badge [agent]="live.agentById(c.id)" />
                         @if (unreadOf(c.id) > 0) {
                           <span class="unread">{{ unreadOf(c.id) }}</span>
                         }
+                        <!--
+                          运行态是**最该被一眼看到**的信息(用户要求左右同步):
+                          有任务时显示实心小圆点 + 文字,空闲时只留灰点,不抢注意力。
+                          接入态/未同步等次要信息下沉到第二行常驻,不在标题行堆芯片。
+                        -->
+                        <span class="run-chip" [class]="'tone-' + taskLine(c.id).tone">
+                          <i class="dot" aria-hidden="true"></i>{{ runLabel(c.id) }}
+                        </span>
+                      </span>
+                      <span class="conv-subline">
+                        <!--
+                          第二行只放「和运行态**不同**的信息」。
+                          🔴 曾出现「空闲」显示两次(用户实测):标题行的 run-chip
+                          和第二行的任务描述都写「空闲」—— 同一份数据渲染两遍。
+                          现在:标题行答「在不在干活」,第二行只在有具体任务时显示
+                          任务内容,否则退回展示专长,不再重复状态词。
+                        -->
+                        <span class="ellipsis conv-task">{{ convSubline(c.id, c.specialty) }}</span>
+                        <app-presence-badge [agent]="live.agentById(c.id)" />
                       </span>
-                      <span class="conv-preview ellipsis">{{ c.currentTask || c.specialty }}</span>
                     </span>
                   </button>
                 }
@@ -152,8 +178,31 @@ import { TasksService } from '../../../core/tasks.service';
                 <strong>{{ a.name }}</strong>
                 <small class="ellipsis">{{ a.specialty || '数字生命' }}</small>
               </div>
-              <!-- AC-04.6:状态**只在此处出现一次**,不在右栏重复 -->
-              <app-status-badge [agent]="a" />
+              <!--
+                AC-04.6 的修正(2026-10-21):原先在此放运行态徽标,但下方任务行
+                已经表达了「在不在干活」→ 同一信息出现两次,且 idle 用 warning 色
+                看起来像故障(用户实测:「空闲 空闲」两行、橙色像告警)。
+                这里只保留任务行**不表达**的内容:容器不可用 / 通道未上报。
+              -->
+              @if (a.status === 'error' || a.status === 'offline' || a.status === 'stopped') {
+                <app-status-badge [agent]="a" />
+              }
+            }
+              <!--
+              当前任务条(用户要求:先看到「它在做什么」,再往下看消息)。
+              文案唯一实现是 TaskPulseService.describe(),列表/头部/右栏同一份。
+              独立一行 → 任务名再长也不再挤压头像与标题。
+            -->
+            @if (chat.agent(); as a) {
+              <div
+                class="task-line"
+                [class]="'tone-' + taskLine(a.id).tone"
+                [class.busy]="taskLine(a.id).active"
+                [title]="taskLine(a.id).text"
+              >
+                <span class="spin" aria-hidden="true"></span>
+                <span class="task-text ellipsis">{{ taskLine(a.id).text }}</span>
+              </div>
             }
             <div class="chat-actions">
               <!-- 任务:点开右栏并切到任务面板;再点隐藏 -->
@@ -240,14 +289,23 @@ import { TasksService } from '../../../core/tasks.service';
                     <span class="history-end">已到最早</span>
                   }
                 </div>
-                @for (m of chat.messages(); track m.id) {
-                  <app-message-bubble
-                    [message]="m"
-                    [own]="m.sender === 'me'"
-                    (retry)="chat.retry($event)"
-                    (openTask)="openTaskRail()"
-                    (preview)="openFile($event)"
-                  />
+                @for (row of messageRows(); track row.key) {
+                  @if (row.kind === 'tool') {
+                    <!--
+                      连续的终端活动消息(容器把 Hermes 每一步都单独发一条)
+                      合并成一个折叠组:默认收起,点开才看命令细节。
+                      用户实测反馈:这些内容把会话完全淹没了。
+                    -->
+                    <app-tool-activity [block]="row.block" />
+                  } @else {
+                    <app-message-bubble
+                      [message]="row.message!"
+                      [own]="row.message!.sender === 'me'"
+                      (retry)="chat.retry($event)"
+                      (openTask)="openTaskRail()"
+                      (preview)="openFile($event)"
+                    />
+                  }
                 }
                 <!-- 对方正在输入(FR-04;容器侧已实测在发 m.typing) -->
                 @if (chat.peerTyping()) {
@@ -288,13 +346,41 @@ import { TasksService } from '../../../core/tasks.service';
               <section class="rail-block">
                 <h3>运行状态</h3>
                 <dl class="kv">
-                  <div><dt>当前任务</dt><dd>{{ a.currentTask || '—' }}</dd></div>
+                  <div>
+                    <dt>当前任务</dt>
+                    <dd [class]="'tone-ink-' + taskLine(a.id).tone">{{ taskLine(a.id).text }}</dd>
+                  </div>
                   <div><dt>CPU</dt><dd>{{ pct(a.host?.cpuPercent) }}</dd></div>
                   <div><dt>内存</dt><dd>{{ pct(a.host?.memPercent) }}</dd></div>
                   <div><dt>Token 用量</dt><dd>{{ tokens(a) }}</dd></div>
                 </dl>
               </section>
 
+              <!--
+                最近动态:只记录**观测到的**开始/结束(每次轮询对比得出)。
+                只列本会话 —— 用户在这里关心的是「它刚才干了什么」。
+              -->
+              @if (pulses().length > 0) {
+                <section class="rail-block">
+                  <h3>最近动态</h3>
+                  <ol class="pulse-list">
+                    @for (p of pulses(); track p.at + p.kind) {
+                      <li class="pulse-item">
+                        <i
+                          class="fa-solid"
+                          [class.fa-play]="p.kind === 'started'"
+                          [class.fa-check]="p.kind === 'finished'"
+                          aria-hidden="true"
+                        ></i>
+                        <span class="pulse-text ellipsis">
+                          {{ p.kind === 'started' ? (p.task || '开始处理') : '完成' }}
+                        </span>
+                        <small>{{ clock(p.at) }}</small>                      </li>
+                    }
+                  </ol>
+                </section>
+              }
+
               @if (railTab() === 'tasks') {
                 <section class="rail-block">
                   <h3>相关任务 <span class="count">{{ agentTasks().length }}</span></h3>
@@ -339,6 +425,21 @@ import { TasksService } from '../../../core/tasks.service';
                             · {{ d.size }}
                           }
                         </small>
+                        <!--
+                          附件还在容器里(未上云盘):如实说明,不给假下载按钮。
+                          「正在同步到云盘」= 已登记;有同步失败原因时直接展示原因。
+                        -->
+                        @if (pendingOf(d); as a) {
+                          <small class="ellipsis sync-hint" [title]="syncError(d.id, a) || ''">
+                            @if (syncError(d.id, a); as err) {
+                              <i class="fa-solid fa-circle-exclamation" aria-hidden="true"></i>
+                              未上云:{{ err }}
+                            } @else {
+                              <i class="fa-solid fa-cloud-arrow-up" aria-hidden="true"></i>
+                              排队搬往云盘…
+                            }
+                          </small>
+                        }
                       </span>
                       <span class="rail-actions">
                         <ui-button
@@ -349,11 +450,11 @@ import { TasksService } from '../../../core/tasks.service';
                           ariaLabel="预览"
                           (click)="preview(d)"
                         />
-                        @if (d.artifacts[0]; as art) {
+                        @if (d.artifacts[0]?.url; as url) {
                           <a
                             class="dl-btn"
-                            [href]="art.url"
-                            [attr.download]="art.name"
+                            [href]="url"
+                            [attr.download]="d.artifacts[0].name"
                             aria-label="下载"
                           >
                             <i class="fa-solid fa-download" aria-hidden="true"></i>
@@ -370,44 +471,20 @@ import { TasksService } from '../../../core/tasks.service';
         </aside>
       }
     </div>
-    <!-- 右栏成果预览:底座 ui-modal -->
-    <ui-modal
+    <!--
+      右栏成果预览。
+
+      🔴 以前这里**另写了一套 ui-modal**,只认 demo 数据里的 content 字段;
+      真实成果没有 content → 弹窗里只剩一个下载按钮(用户实测:「点击预览无法预览」)。
+      现在统一走 app-file-preview(图片/视频/PDF/DOCX/XLSX/HTML/MD/文本 都支持)。
+    -->
+    <app-file-preview
       [open]="!!previewing()"
-      [title]="previewing()?.title ?? ''"
-      size="lg"
-      (openChange)="previewing.set(null)"
-    >
-      @if (previewing(); as d) {
-        <p class="preview-meta">{{ d.filename }} · {{ d.size }} · {{ d.version }}</p>
-        <div class="preview-body">
-          @if (d.fileType === 'CSV') {
-            <div class="csv-wrap">
-              <table class="csv">
-                <tbody>
-                  @for (row of csvRows(d); track $index) {
-                    <tr>@for (cell of row; track $index) { <td>{{ cell }}</td> }</tr>
-                  }
-                </tbody>
-              </table>
-            </div>
-          } @else {
-            <pre class="doc">{{ d.content || d.summary }}</pre>
-          }
-        </div>
-        <div class="preview-actions">
-          <!--
-            一个成果可能有多个附件(如 EN 版 / ZH 版),
-            按钮**必须带文件名**,否则用户不知道下的是哪一个(实测反馈:两个一样的「下载」)。
-          -->
-          @for (art of d.artifacts; track art.name) {
-            <a class="download" [href]="art.url" [attr.download]="art.name" [title]="art.name">
-              <i class="fa-solid fa-download" aria-hidden="true"></i>
-              <span class="dl-name">{{ art.name }}</span>
-            </a>
-          }
-        </div>
-      }
-    </ui-modal>
+      [url]="previewing()?.artifacts?.[0]?.url ?? ''"
+      [name]="previewing()?.filename ?? previewing()?.title ?? ''"
+      [mimeType]="''"
+      (closed)="previewing.set(null)"
+    />
 
     <!-- 消息附件预览(按类型分发:图片/视频/pdf/md/文本/office) -->
     <app-file-preview
@@ -425,6 +502,9 @@ import { TasksService } from '../../../core/tasks.service';
       :host {
         display: block;
         height: 100%;
+        /* 参与父级网格时,默认的 min-height:auto 会被内容撑高 → 整页滚动 */
+        min-height: 0;
+        overflow: hidden;
       }
 
       /*
@@ -589,6 +669,19 @@ import { TasksService } from '../../../core/tasks.service';
         font-size: var(--fs-12);
         color: var(--muted);
       }
+      /* 第二行:任务描述占满剩余宽度,接入态徽标靠右不换行 */
+      .conv-subline {
+        display: flex;
+        align-items: center;
+        gap: var(--sp-2);
+        min-width: 0;
+      }
+      .conv-task {
+        flex: 1;
+        min-width: 0;
+        font-size: var(--fs-12);
+        color: var(--muted);
+      }
       .unread {
         min-width: 18px;
         padding: 0 5px;
@@ -601,13 +694,40 @@ import { TasksService } from '../../../core/tasks.service';
         flex-shrink: 0;
       }
 
+      /*
+        头部两行:① 头像 + 名称 + 状态徽标 + 操作  ② 当前任务整行。
+        用 grid 显式分行,任务名再长也不会挤压头像/标题(此前挤成一排)。
+      */
       .chat-head {
-        display: flex;
+        display: grid;
+        grid-template-columns: auto minmax(0, 1fr) auto auto;
+        grid-template-areas:
+          'avatar title status actions'
+          'task   task  task   task';
         align-items: center;
-        gap: var(--sp-3);
+        column-gap: var(--sp-3);
+        row-gap: var(--sp-2);
         padding: var(--sp-3) var(--sp-4);
         border-bottom: 1px solid var(--edge);
       }
+      .chat-head > ui-avatar {
+        grid-area: avatar;
+      }
+      .chat-head > .chat-title {
+        grid-area: title;
+      }
+      /* 🔴 徽标与操作区各占一格 —— 曾经共用 status 格,操作按钮把徽标压住了 */
+      .chat-head > app-status-badge {
+        grid-area: status;
+        justify-self: end;
+      }
+      .chat-head > .chat-actions {
+        grid-area: actions;
+        justify-self: end;
+      }
+      .chat-head > .task-line {
+        grid-area: task;
+      }
       .chat-title {
         display: grid;
         gap: 2px;
@@ -626,6 +746,159 @@ import { TasksService } from '../../../core/tasks.service';
         display: flex;
         gap: var(--sp-1);
       }
+      /*
+        当前任务整行:一个会话一屏只出现一次「它在做什么」。
+        左侧细色条 + 会转的小点表达「正在做」,完成态不转。
+        用整行而非胶囊:任务描述通常较长,胶囊装不下只能省略。
+      */
+      .task-line {
+        display: flex;
+        align-items: center;
+        gap: var(--sp-2);
+        min-width: 0;
+        padding: var(--sp-1) var(--sp-3);
+        border-left: 3px solid var(--edge-strong);
+        border-radius: var(--r-sm);
+        background: var(--surface-2);
+        font-size: var(--fs-12);
+        color: var(--ink-2);
+      }
+      .task-line .spin {
+        width: 8px;
+        height: 8px;
+        flex-shrink: 0;
+        border-radius: 50%;
+        background: var(--muted);
+      }
+      .task-line.busy .spin {
+        background: var(--success);
+        animation: pulse-dot 1.4s var(--ease-standard) infinite;
+      }
+      .task-line.tone-warning {
+        border-left-color: var(--warning);
+        color: var(--warning);
+      }
+      .task-line.tone-warning .spin {
+        background: var(--warning);
+      }
+      .task-line.tone-danger {
+        border-left-color: var(--danger);
+        color: var(--danger);
+      }
+      .task-line.tone-danger .spin {
+        background: var(--danger);
+      }
+      .task-line.tone-success,
+      .task-line.tone-info {
+        border-left-color: var(--success);
+        color: var(--success);
+      }
+      .task-line.tone-success .spin,
+      .task-line.tone-info .spin {
+        background: var(--success);
+      }
+      @keyframes pulse-dot {
+        0%,
+        100% {
+          opacity: 1;
+          transform: scale(1);
+        }
+        50% {
+          opacity: 0.35;
+          transform: scale(0.8);
+        }
+      }
+      @media (prefers-reduced-motion: reduce) {
+        .task-line.busy .spin {
+          animation: none;
+        }
+      }
+
+      /*
+        运行态小标签:圆点 + 文字(不用底色块,避免标题行三个彩色块打架)。
+        只有「执行中」的圆点会呼吸,其余是静态灰/黄点。
+      */
+      .run-chip {
+        display: inline-flex;
+        align-items: center;
+        gap: 4px;
+        flex-shrink: 0;
+        font-size: var(--fs-11);
+        color: var(--muted);
+        white-space: nowrap;
+      }
+      .run-chip .dot {
+        width: 6px;
+        height: 6px;
+        border-radius: 50%;
+        background: var(--edge-strong);
+      }
+      .run-chip.tone-success .dot,
+      .run-chip.tone-info .dot {
+        background: var(--success);
+      }
+      .run-chip.tone-success,
+      .run-chip.tone-info {
+        color: var(--success);
+      }
+      .run-chip.tone-success .dot,
+      .run-chip.tone-info .dot {
+        animation: pulse-dot 1.4s var(--ease-standard) infinite;
+      }      .run-chip.tone-warning {
+        color: var(--warning);
+      }
+      .run-chip.tone-danger {
+        color: var(--danger);
+      }
+      .run-chip.tone-danger .dot {
+        background: var(--danger);
+      }
+      .run-chip.tone-warning .dot {
+        background: var(--warning);
+      }
+      @media (prefers-reduced-motion: reduce) {
+        .run-chip .dot {
+          animation: none !important;
+        }
+      }
+
+      /* 最近动态 */
+      .pulse-list {
+        margin: 0;
+        padding: 0;
+        list-style: none;
+        display: grid;
+        gap: var(--sp-2);
+      }
+      .pulse-item {
+        display: flex;
+        align-items: center;
+        gap: var(--sp-2);
+        min-width: 0;
+        font-size: var(--fs-12);
+        color: var(--ink-2);
+      }
+      .pulse-item i {
+        width: 12px;
+        flex-shrink: 0;
+        color: var(--muted);
+        font-size: var(--fs-11);
+      }
+      .pulse-text {
+        flex: 1;
+        min-width: 0;
+      }
+      .pulse-item small {
+        flex-shrink: 0;
+        color: var(--muted);
+        font-size: var(--fs-11);
+      }
+      .tone-ink-success {
+        color: var(--success);
+      }
+      .tone-ink-warning {
+        color: var(--warning);
+      }
       /* 右栏切换按钮的激活态 */
       .chat-actions ui-button.on button {
         background: var(--primary-subtle);
@@ -761,6 +1034,12 @@ import { TasksService } from '../../../core/tasks.service';
         min-width: 0;
         flex: 1;
       }
+      .sync-hint {
+        display: inline-flex;
+        align-items: center;
+        gap: var(--sp-1);
+        color: var(--muted);
+      }
       .rail-meta strong {
         font-size: var(--fs-13);
         color: var(--ink);
@@ -784,6 +1063,8 @@ export class DkChatPage {
   private readonly unread = inject(UnreadService);
   private readonly deliverables = inject(DeliverablesService);
   private readonly tasks = inject(TasksService);
+  private readonly pulse = inject(TaskPulseService);
+  private readonly artifactSync = inject(ArtifactSyncService);
   private readonly route = inject(ActivatedRoute);
   private readonly router = inject(Router);
   private readonly device = inject(DeviceService);
@@ -803,6 +1084,94 @@ export class DkChatPage {
   readonly pct = (v?: number) => fmtPercent(v);
   readonly tokens = (a: { usage?: { totalTokens?: number } }) => fmtTokens(usageOf(a as never).totalTokens);
 
+  /**
+   * 「当前任务」文案 —— **唯一实现**在 `TaskPulseService.describe()`。
+   * 列表行、头部任务条、右栏运行状态**三处共用同一份**,保证左右同步。
+   */
+  taskLine(agentId: string): TaskLine {
+    return this.pulse.describe(this.live.agentById(agentId));
+  }
+
+  /** 列表行紧凑标签:一次只答一个问题 —— 在不在干活 */
+  runLabel(agentId: string): string {
+    const line = this.taskLine(agentId);
+    if (line.tone === 'danger') return '异常';
+    if (line.active) return '执行中';
+    if (line.tone === 'warning') return '未同步';
+    return '空闲';
+  }
+
+  /**
+   * 列表第二行文案 —— **不与标题行的运行态重复**。
+   *
+   * 有具体任务时显示任务;否则显示专长(更适合"没在忙"的时候告知用户它是谁)。
+   */
+  convSubline(agentId: string, specialty?: string): string {
+    const line = this.taskLine(agentId);
+    // 状态词(空闲/执行中/未同步/异常)已经在标题行,这里不再重复
+    if (!line.active && (line.tone === 'neutral' || line.tone === 'danger')) {
+      return specialty || '数字生命';
+    }
+    return line.text || specialty || '数字生命';
+  }
+
+  /** 本会话最近动态 */
+  readonly pulses = computed(() => this.pulse.pulsesFor(this.chat.agentId()));
+
+  /**
+   * 消息 → 渲染行:**连续的终端活动消息合并成一个折叠组**。
+   *
+   * 容器把 Hermes 的每一步(`💻 terminal` + 命令、`✍️ Writing ...`)
+   * 都作为独立消息发出来,一条回复能产生十几条 —— 会话被淹没。
+   *
+   * 规则:相邻且都属于终端活动的消息合成一组;遇到正常对话立即断开。
+   */
+  readonly messageRows = computed<MessageRow[]>(() => {
+    const rows: MessageRow[] = [];
+    let group: { text: string; summary: string }[] = [];
+    const flush = (): void => {
+      if (group.length === 0) return;
+      const first = group[0];
+      rows.push({
+        kind: 'tool',
+        key: `tool-${rows.length}-${first.summary}`,
+        block: {
+          kind: 'process',
+          steps: group.map((g) => ({ kind: 'tool' as const, label: g.summary, detail: g.text })),
+        },
+      });
+      group = [];
+    };
+    for (const m of this.chat.messages()) {
+      // 空消息(撤回 / 表情回应 / 空 body)不渲染 —— 实测占 7/12
+      if (!m.text?.trim() && !m.file) continue;
+      if (m.sender === 'agent' && isToolActivityMessage(m.text)) {
+        group.push({ text: m.text!.slice(0, 400), summary: toolActivitySummary(m.text!) });
+        continue;
+      }
+      flush();
+      rows.push({ kind: 'msg', key: m.id, message: m });
+    }
+    flush();
+    return rows;
+  });
+
+  /** 某个成果里「还在容器里、未上云盘」的首个附件(没有则 undefined) */
+  pendingOf(d: Deliverable): { name: string; source: string } | undefined {
+    const a = d.artifacts.find((x) => !x.url && x.source);
+    return a ? { name: a.name, source: a.source! } : undefined;
+  }
+
+  /** 附件同步失败原因(UI 如实展示,不假装正在同步) */
+  syncError(deliverableId: string, a: { source: string }): string | undefined {
+    return this.artifactSync.errorOf(deliverableId, a.source);
+  }
+
+  /** 动态里的时刻(本地毫秒 → HH:MM) */
+  clock(ms: number): string {
+    return fmtTime(ms);
+  }
+
   private readonly messagesEl = viewChild<ElementRef<HTMLElement>>('messages');
 
   /** 会话列表:仅已接入的数字生命 */
@@ -834,6 +1203,10 @@ export class DkChatPage {
    * 用于区分「尾部新增」(要滚底)与「头部插入」(分页,保持锚点)。
    */
   private lastTailId = '';
+  /** 用户是否主动上翻离开了底部(true 时新消息不强行拉回) */
+  private userScrolledAway = false;
+  /** 正在加载更早历史:期间禁止任何「贴底」动作,否则锚点会被破坏 */
+  private loadingEarlier = false;
 
   /** 打开消息附件预览(按类型分发由 app-file-preview 负责) */
   openFile(f: { url: string; name: string; mimeType: string }): void {
@@ -884,7 +1257,7 @@ export class DkChatPage {
       this.lastTailId = tailId;
       // 首次(prev 为空)或尾部确实变了 → 滚底
       if (prev === '' || tailId !== prev) {
-        queueMicrotask(() => this.scrollToBottom());
+        this.stickToBottom();
       }
     });
 
@@ -898,6 +1271,16 @@ export class DkChatPage {
     this.route.paramMap.subscribe((params) => {
       void this.onRouteChange(params.get('id'));
     });
+
+    /*
+     * 成果附件同步:读到成果列表后,把「只有容器路径、没有云盘地址」的附件登记去同步。
+     * 幂等(服务内部去重),每次列表刷新都可以安全调用。
+     */
+    effect(() => {
+      const list = this.deliverables.all();
+      if (list.length === 0) return;
+      void this.artifactSync.syncPending(list);
+    });
   }
 
   private async onRouteChange(id: string | null): Promise<void> {
@@ -987,6 +1370,29 @@ export class DkChatPage {
     void this.router.navigate(['/', this.device.mode(), 'tasks', id]);
   }
 
+  /**
+   * 贴底:**滚动两次**。
+   *
+   * 🔴 踩坑(2026-10-21 实测):只滚一次时,消息已进 DOM 但 **markdown 还没渲染完**,
+   *    那一刻 `scrollHeight` 偏小,滚完仍差 1700+ px —— 用户打开会话看到的是中间某处,
+   *    而且之后**再也不动**(因为尾部 id 没再变,effect 不再触发)。
+   *
+   * 做法:立即滚一次(同步可见),下一个宏任务再滚一次(吃掉 md 渲染后的增量)。
+   * 用 `requestAnimationFrame` 而非 `queueMicrotask`:后者在渲染前执行,量不到最终高度。
+   */
+  private stickToBottom(): void {
+    // 用户主动上翻看历史时不要把他拽回来(但首次进入要贴底)
+    if (this.userScrolledAway && this.lastTailId !== '') return;
+    // 正在往前翻页:此时 layout 变化属于「向上插入」,绝不能贴底
+    if (this.loadingEarlier) return;
+    this.scrollToBottom();
+    requestAnimationFrame(() => {
+      this.scrollToBottom();
+      // 图片/mermaid 等异步内容会继续长高,再补一次
+      setTimeout(() => this.scrollToBottom(), 120);
+    });
+  }
+
   private scrollToBottom(): void {
     const el = this.messagesEl()?.nativeElement;
     if (el) el.scrollTop = el.scrollHeight;
@@ -1000,20 +1406,47 @@ export class DkChatPage {
    */
   onMessagesScroll(_e: Event): void {
     const el = this.messagesEl()?.nativeElement;
-    if (!el || el.scrollTop > 80) return;
+    if (!el) return;
+    /*
+     * 记住「用户是否主动离开底部」。
+     * 距底 > 160px 视为用户在往上翻 —— 此时新消息到达**不要**把他拽回底部
+     * (否则翻历史翻到一半就被弹走);滚回接近底部时自动恢复贴底。
+     */
+    const gap = el.scrollHeight - el.scrollTop - el.clientHeight;
+    this.userScrolledAway = gap > 160;
+    if (el.scrollTop > 80) return;
     if (this.chat.paginating() || !this.chat.hasMoreHistory()) return;
     void this.loadEarlier();
   }
 
   /** 加载更早历史,并保持滚动锚点 */
+  /**
+   * 加载更早历史,并**保持滚动锚点**(用户要求:位置不能动)。
+   *
+   * 🔴 踩坑:原实现用 `queueMicrotask` 补偿 —— 那一刻新消息还没渲染完,
+   *    量到的 `scrollHeight` 仍是旧值,补偿量算成 0 → 视口被顶到上面去了。
+   *
+   * 正确做法:等**两帧**再量(首帧是布局,次帧含 markdown 渲染后的增量),
+   * 用 `新高度 - 旧高度` 作为补偿量。两次测量都拿实时的 `scrollHeight`。
+   */
   async loadEarlier(): Promise<void> {
     const el = this.messagesEl()?.nativeElement;
-    const beforeHeight = el?.scrollHeight ?? 0;
+    if (!el) return;
+    const beforeHeight = el.scrollHeight;
+    const beforeTop = el.scrollTop;
+    this.loadingEarlier = true;
     const grew = await this.chat.paginate();
-    if (!grew || !el) return;
-    // 内容变高后,把视口下移同样的增量 → 视觉上停在原位置
-    queueMicrotask(() => {
-      el.scrollTop = el.scrollHeight - beforeHeight;
+    if (!grew) {
+      this.loadingEarlier = false;
+      return;
+    }
+    // 两帧之后再量:一帧可能还没完成 markdown 渲染
+    requestAnimationFrame(() => {
+      requestAnimationFrame(() => {
+        const added = el.scrollHeight - beforeHeight;
+        if (added > 0) el.scrollTop = beforeTop + added;
+        this.loadingEarlier = false;
+      });
     });
   }
 }

+ 15 - 3
src/app/desktop/pages/deliverables/deliverables.ts

@@ -192,9 +192,16 @@ import { fmtDate } from '../../../core/time';
         </div>
         <div class="preview-actions">
           @for (art of d.artifacts; track art.name) {
-            <a class="download" [href]="art.url" [attr.download]="art.name">
-              <i class="fa-solid fa-download" aria-hidden="true"></i>下载
-            </a>
+            <!-- 未上云盘的附件不给 href(空链接点了没反应,比不给更糟) -->
+            @if (art.url) {
+              <a class="download" [href]="art.url" [attr.download]="art.name">
+                <i class="fa-solid fa-download" aria-hidden="true"></i>下载
+              </a>
+            } @else {
+              <span class="download pending" title="文件仍在容器内,尚未上云盘">
+                <i class="fa-solid fa-cloud-arrow-up" aria-hidden="true"></i>同步中
+              </span>
+            }
           }
         </div>
       }
@@ -357,6 +364,11 @@ import { fmtDate } from '../../../core/time';
         font-size: var(--fs-13);
         text-decoration: none;
       }
+      /* 同步中:不可点,弱化色说明「文件还没上云盘」 */
+      .download.pending {
+        background: var(--surface-hover);
+        color: var(--muted);
+      }
     `,
   ],
 })

+ 31 - 5
src/app/desktop/shell/desktop-shell.ts

@@ -51,23 +51,49 @@ import { DkNavComponent } from './dk-nav/dk-nav';
     `
       :host {
         display: block;
+        /* 🔴 根链必须逐层给高度,否则内容会把整页撑高(见 .app-shell 注释) */
         height: 100%;
+        min-height: 0;
+        overflow: hidden;
       }
       .app-shell {
         height: 100dvh;
         display: grid;
         grid-template-columns: auto minmax(0, 1fr);
+        /*
+         * 🔴 行高必须显式 minmax(0, 1fr)。
+         *
+         * 只写 height:100dvh + 两列时,**隐式行是 auto** —— 行会被内容撑高,
+         * 网格自己跑到 3000px 高,overflow:hidden 就形同虚设:
+         * 浏览器里表现为「最外层滚出 3000px 空白」(用户实测的异常滚动)。
+         *
+         * 列的 minmax(0,1fr) 只解决横向;纵向必须靠这一行。
+         */
+        grid-template-rows: minmax(0, 1fr);
         overflow: hidden;
       }
       .workspace {
+        min-height: 0;
         min-width: 0;
-        overflow: auto;
-        scrollbar-gutter: stable;
+        /*
+         * 🔴 overflow: auto → **clip**(用户实测:最外层会异常滚动)。
+         *
+         * 原因:底下的 :has() 规则要等页面渲染出 .conversation-layout 才生效,
+         *       之前有一帧是 auto;而路由进场动画给页面加了 transform,
+         *       高度多出 1px → 根级滚出一条 1px 的滚动条,整页能被推动。
+         * clip = 溢出直接裁掉且**不可编程滚动**,页面各自管各自的内部滚动,
+         * 也不再依赖 :has() 的生效时机。
+         */
+        overflow: clip;
         background: var(--canvas);
       }
-      /* 会话类页面由页面自身接管内部滚动;其余页面滚整个工作区 */
-      .workspace:has(> * .conversation-layout) {
-        overflow: hidden;
+      /*
+       * 普通页面(工作台/成果/任务…)返回 auto:这些页面的 min-height 是内容自然高度,
+       * 由工作区滚动(页面内没有别的滚动容器)。会话类页面已用 overflow: clip 锁死。
+       */
+      .workspace:not(:has(> * .conversation-layout)) {
+        overflow: auto;
+        scrollbar-gutter: stable;
       }
       .narrow-desktop {
         position: fixed;

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

@@ -116,6 +116,9 @@ interface NavGroup {
       :host {
         display: block;
         height: 100%;
+        /* 网格项默认 min-height:auto —— 导航项多时会把侧栏撑高、带出整页滚动 */
+        min-height: 0;
+        overflow: hidden;
       }
       .sidebar {
         display: flex;

+ 24 - 8
src/app/mobile/pages/deliverables/deliverables.ts

@@ -72,15 +72,20 @@ import { fmtDate } from '../../../core/time';
                         <ui-m-tag [plain]="true">{{ d.fileType || 'FILE' }}</ui-m-tag>
                       </button>
                       <!-- 直接下载(用户要求:外层卡片要有下载按钮,不必先进预览) -->
-                      @if (d.artifacts[0]; as art) {
+                      @if (d.artifacts[0]?.url; as url) {
                         <a
                           class="row-dl"
-                          [href]="art.url"
-                          [attr.download]="art.name"
-                          [attr.aria-label]="'下载 ' + art.name"
+                          [href]="url"
+                          [attr.download]="d.artifacts[0].name"
+                          [attr.aria-label]="'下载 ' + d.artifacts[0].name"
                         >
                           <i class="fa-solid fa-download" aria-hidden="true"></i>
                         </a>
+                      } @else if (d.artifacts[0]) {
+                        <!-- 未上云盘:不给假下载按钮(空 href 点了没反应) -->
+                        <span class="row-dl pending" [attr.aria-label]="'未上云:' + d.artifacts[0].name">
+                          <i class="fa-solid fa-cloud-arrow-up" aria-hidden="true"></i>
+                        </span>
                       }
                     </div>
                   }
@@ -102,15 +107,20 @@ import { fmtDate } from '../../../core/time';
                         </span>
                         <time>{{ date(d.deliveredAt) }}</time>
                       </button>
-                      @if (d.artifacts[0]; as art) {
+                      @if (d.artifacts[0]?.url; as url) {
                         <a
                           class="row-dl"
-                          [href]="art.url"
-                          [attr.download]="art.name"
-                          [attr.aria-label]="'下载 ' + art.name"
+                          [href]="url"
+                          [attr.download]="d.artifacts[0].name"
+                          [attr.aria-label]="'下载 ' + d.artifacts[0].name"
                         >
                           <i class="fa-solid fa-download" aria-hidden="true"></i>
                         </a>
+                      } @else if (d.artifacts[0]) {
+                        <!-- 未上云盘:不给假下载按钮(空 href 点了没反应) -->
+                        <span class="row-dl pending" [attr.aria-label]="'未上云:' + d.artifacts[0].name">
+                          <i class="fa-solid fa-cloud-arrow-up" aria-hidden="true"></i>
+                        </span>
                       }
                     </div>
                   }
@@ -291,6 +301,12 @@ import { fmtDate } from '../../../core/time';
       .row-dl:active {
         background: var(--primary-ring);
       }
+      /* 同步中:不可点,用弱化色说明「文件还没上云盘」 */
+      .row-dl.pending {
+        background: var(--surface-hover);
+        color: var(--muted);
+        cursor: default;
+      }
       .row-icon {
         display: grid;
         place-items: center;