Selaa lähdekoodia

feat(chat): 会话域修复 + 成果按会话归档 + 文件统一上云

## 会话域
- 移动端首页发不出消息:草稿经 sessionStorage 随行进入会话
- 移动端会话页接入抽屉,可切换会话
- 正文截断:已结束的块直接全文渲染,不再受流式进度截断
- 读秒跳变:echo 认领时复用本地 ts
- 回合永久「输出中」:加每秒时钟,仅在有 live 回合时起表
- 新建会话误显示「已删除」:判据改为「本地无 + 云端墓碑 + 非新建态」
- 首次发消息改为强顺序:落库成功才发送

## 排版
- 新增 normalize-md:覆盖容器 4 种排版形态,段落数 1→6
- 列表圆点/表格/代码块改用 em,随字号整体缩放

## 成果与文件
- ChatThread 增加 deliverableUrls;云函数 v0.1.9 已部署
- 语义分界:文件=用户上传,成果=容器产出
- 成果按会话隔离,不回退到整个容器
- 单聊上传 Matrix + S3 双写
- 正文链接自动识别为文件卡片;只认 agent 消息 + 合法扩展名
- size 统一:-1 表示未知,不显示「0 B」
- CORS 不可用时降级直开原 URL

## 文档
- 新增《当前业务逻辑总览》《需求-实现对照表》
- 重写 docs/README 加入快速入口与过期文档清单
- 9 份修改留痕

验证:tsc 通过;单测 1413 全绿;build 通过

Co-Authored-By: Claude Code <noreply@anthropic.com>
Huccct 8 tuntia sitten
vanhempi
commit
ee56e2eece
47 muutettua tiedostoa jossa 4122 lisäystä ja 95 poistoa
  1. 22 1
      cloud/functions/fmodeagent-chatthreads.js
  2. 126 0
      docs/00-tracking/2026-11-09-今日进度与明日待办.md
  3. 122 0
      docs/00-tracking/2026-11-09-修改留痕-CORS降级与自动上云.md
  4. 132 0
      docs/00-tracking/2026-11-09-修改留痕-lj-test实测与成果归档.md
  5. 139 0
      docs/00-tracking/2026-11-09-修改留痕-云函数部署与端到端实测.md
  6. 180 0
      docs/00-tracking/2026-11-09-修改留痕-会话域实测与同步.md
  7. 120 0
      docs/00-tracking/2026-11-09-修改留痕-容器成果自动归档.md
  8. 148 0
      docs/00-tracking/2026-11-09-修改留痕-成果按会话归档与文件上云.md
  9. 149 0
      docs/00-tracking/2026-11-09-修改留痕-排版与数据清理.md
  10. 101 0
      docs/00-tracking/2026-11-09-修改留痕-项目逻辑文档补齐.md
  11. 139 0
      docs/00-tracking/2026-11-10-进度记录与遗留问题.md
  12. 294 0
      docs/02-design/architecture/当前业务逻辑总览.md
  13. 127 0
      docs/02-design/architecture/需求-实现对照表.md
  14. 157 0
      docs/03-interface/容器侧改造-文件与成果统一上云-20261109.md
  15. 31 0
      docs/README.md
  16. 135 0
      scripts/deploy-cloud-fn.mjs
  17. 195 0
      scripts/purge-chatthreads.mjs
  18. 13 1
      src/app/components/domain/assistant-turn/assistant-turn.ts
  19. 41 3
      src/app/components/domain/message-bubble/message-bubble.ts
  20. 11 8
      src/app/components/domain/new-session-modal/new-session-modal.ts
  21. 12 8
      src/app/components/domain/presence-badge/presence-badge.ts
  22. 5 0
      src/app/components/domain/session-rail/rail-shared.ts
  23. 38 0
      src/app/components/domain/session-rail/session-rail.ts
  24. 33 9
      src/app/components/preview/md-render/md-render.ts
  25. 116 0
      src/app/components/preview/md-render/normalize-md.spec.ts
  26. 259 0
      src/app/components/preview/md-render/normalize-md.ts
  27. 6 0
      src/app/core/agent-state.ts
  28. 23 0
      src/app/core/api/models.ts
  29. 57 6
      src/app/core/attachment-fetch.ts
  30. 125 3
      src/app/core/chat-session.service.ts
  31. 7 0
      src/app/core/chat-session.ts
  32. 78 2
      src/app/core/chat.service.spec.ts
  33. 240 5
      src/app/core/chat.service.ts
  34. 56 0
      src/app/core/deliverables.service.spec.ts
  35. 83 0
      src/app/core/deliverables.service.ts
  36. 6 0
      src/app/core/format.ts
  37. 3 3
      src/app/core/matrix-client.service.ts
  38. 10 0
      src/app/core/mobile-chat-view.service.ts
  39. 92 0
      src/app/core/s3-link.spec.ts
  40. 91 0
      src/app/core/s3-link.ts
  41. 6 0
      src/app/core/task-pulse.service.spec.ts
  42. 42 16
      src/app/core/task-pulse.service.ts
  43. 130 7
      src/app/desktop/pages/chat/chat.ts
  44. 31 3
      src/app/mobile/components/m-app-bar/m-app-bar.ts
  45. 29 10
      src/app/mobile/components/m-msg/m-msg.ts
  46. 141 6
      src/app/mobile/pages/chat/chat.ts
  47. 21 4
      src/app/mobile/pages/home/home.ts

+ 22 - 1
cloud/functions/fmodeagent-chatthreads.js

@@ -32,6 +32,7 @@
 //   workspaceId String(归属工作区;'' = 日常对话)— v0.1.4 新增
 //   kind String('dm' | 'group';缺省 = dm)— v0.1.6 新增
 //   memberIds Array<String>(群成员容器 id;单聊空)— v0.1.6 新增
+//   deliverableUrls Array<String>(本会话的成果链接)— v0.1.9 新增(2026-11-09)
 //   唯一语义:(user, threadRoot) —— upsert 前查重保证
 
 var CLASS_NAME = 'FmodeChatThread';
@@ -63,7 +64,12 @@ function _strArr(v) {
   if (!Array.isArray(v)) return [];
   var out = [];
   for (var i = 0; i < v.length && out.length < 50; i++) {
-    var s = _str(v[i], 120);
+    /*
+     * 🔴 上限 500 而不是 120(2026-11-09):成果链接是完整 URL,
+     *    典型长度 s3.fmode.cn/deliverables/<agentId>/<YYYYMM>/<id>/<hash>-<name>
+     *    常常超过 120 字符,按 120 截断会得到打不开的地址。
+     */
+    var s = _str(v[i], 500);
     if (s && out.indexOf(s) < 0) out.push(s);
   }
   return out;
@@ -173,6 +179,11 @@ async function opList(userPtr, p) {
         // 归到「群聊」分组,会以首成员身份混进单聊列表
         kind: r.get('kind') === 'group' ? 'group' : 'dm',
         memberIds: _strArr(r.get('memberIds')),
+        /*
+         * v0.1.7:本会话的成果链接(2026-11-09)。
+         * 客户端据此只显示「属于这条对话」的成果,不再显示整个容器的。
+         */
+        deliverableUrls: _strArr(r.get('deliverableUrls')),
       };
     }),
   });
@@ -255,6 +266,16 @@ async function opUpsert(userPtr, p) {
     if (Array.isArray(it.memberIds) && it.memberIds.length > 0) {
       rec.set('memberIds', _strArr(it.memberIds));
     }
+    /*
+     * v0.1.7:本会话的成果链接(2026-11-09 用户裁决)。
+     *
+     * 🔴 只在明确传入时写 —— 与 kind/memberIds 同理,
+     *    后续 upsert 不带这个字段时不能把已有链接清空。
+     * 🔴 用 `[]` 显式传空数组才表示「清空」(用户删光了成果)。
+     */
+    if (Array.isArray(it.deliverableUrls)) {
+      rec.set('deliverableUrls', _strArr(it.deliverableUrls));
+    }
     // 🔴 upsert 表示「这条会话现在存在」→ 永远解除墓碑。
     //    上一版只有传 revive:true 才解除,客户端漏传就会出现
     //    「本地有、云端是墓碑」→ 下次拉取被当已删除跳过 → 会话消失(实测)。

+ 126 - 0
docs/00-tracking/2026-11-09-今日进度与明日待办.md

@@ -0,0 +1,126 @@
+# 2026-11-09 进度记录与明日待办
+
+| 字段 | 值 |
+|---|---|
+| 日期 | 2026-11-09 |
+| 范围 | 会话域实测、排版、成果归档、文件上云、容器规范 |
+
+---
+
+## 一、今天完成了什么
+
+### 1. 会话域缺陷修复(8 项)
+
+| # | 问题 | 修复 |
+|---|---|---|
+| 1 | 移动端首页发不出消息 | `target` 从未赋值 → 草稿经 sessionStorage 传给会话页 |
+| 2 | 移动端会话页切不了会话 | 抽屉组件导入了但没用 → 接上 |
+| 3 | 正文被截断(574→565 字) | 块 key 变化导致 `slice(0,0)` → 已结束的块直接全文渲染 |
+| 4 | 读秒跳变 | `mergeTimeline(..., [])` 传空数组使回落失效 → 传入本地消息 |
+| 5 | 状态口径各页不一致 | 全部收敛到 `resolveAgentState` |
+| 6 | 回合永久「输出中」 | 加每秒时钟,只在有 live 回合时起表 |
+| 7 | 新建会话显示「已删除」 | 判据误伤 `messageCount === 0` → 三重条件 |
+| 8 | 首次发消息未等落库 | `commitNow()` 强顺序:落库成功才发送 |
+
+### 2. AI 回复排版(从 ChatGPT 效果出发)
+
+**根因**:容器发的是单换行 `\n`,CommonMark 把整段算成一个 `<p>`。
+
+新增 `normalize-md.ts`,覆盖容器 4 种排版形态:
+
+| # | 形态 | 示例 |
+|---|---|---|
+| 1 | 标题在行首 | `**核心能力**` + 正文 |
+| 2 | 标题粘在行尾 | `…结论先行。**我的能力**` |
+| 3 | 标题在行首但前一行是正文 | 正文 + `**第二段**` |
+| 4 | 空格 + 短标题短语 | `…绝不编造。 我的边界` |
+
+**效果**:`<p>` 段落数 **1 → 6**。
+
+### 3. 成果按会话归档(用户核心需求)
+
+| 层 | 改动 |
+|---|---|
+| 数据 | `ChatThreadRecord.deliverableUrls: string[]` |
+| 云函数 | v0.1.9 已热更(写入 + 返回 + `_strArr` 上限 120→500) |
+| 前端 | `addDeliverableUrl()`、`bySessionUrls()` |
+| 双端 | 桌面 `agentDeliverables` / 移动 `bySessionUrlsOf` 同源 |
+
+### 4. 文件上云
+
+| 项 | 状态 |
+|---|---|
+| 用户上传 | Matrix + S3 双写 |
+| 容器成果 | 正文链接自动识别 → 文件卡片 + 成果列表 |
+| CORS | S3 没配 CORS → 前端降级为直开原 URL |
+
+### 5. 容器侧规范(已固化到技能文件)
+
+容器已把两条规范写进 `/opt/data/skills/fmode-storage/SKILL.md`:
+
+1. **交付文件必须自动上云**,不需要用户说
+2. **链接独占一行、裸 URL**(不用 `MEDIA:` 内联、不用 markdown 链接语法)
+
+### 6. 文档
+
+| 文档 | 内容 |
+|---|---|
+| `当前业务逻辑总览.md` | 五条主线的真实链路 |
+| `需求-实现对照表.md` | 21 条 FR → 代码 → 测试 → 状态 |
+| `docs/README.md` | 快速入口 + 过期文档清单 |
+
+---
+
+## 二、验证状态
+
+| 项 | 结果 |
+|---|---|
+| 单测 | ✅ 1407 → 见最新一轮 |
+| tsc | ✅ |
+| build | ✅ |
+| 文件上云 | ✅ curl 200,字节一致 |
+| 成果隔离 | ✅ A=1 / B=0 |
+| 双端一致 | ✅ |
+| 容器自动上云 | ✅ 普通任务自动完成 |
+
+---
+
+## 三、明天要做
+
+### P0 · 必须做
+
+| # | 项 | 说明 |
+|---|---|---|
+| 1 | **S3 配置 CORS** | 现在预览靠降级方案,无法注入 CSP 沙箱。需要运维配 `Access-Control-Allow-Origin` |
+| 2 | **验证容器输出格式生效** | 规范已写进技能文件,但**还没用真实任务验证**卡片渲染是否稳定 |
+
+### P1 · 应该做
+
+| # | 项 | 说明 |
+|---|---|---|
+| 3 | 缩放效果验证 | 列表圆点/表格/代码块已改 em,需实际调字号看效果 |
+| 4 | `app-lj-test-v1` 容器 | 用户说暂不管,但要记得它的搬运脚本缺失 |
+| 5 | 群聊相关 | C1/C8/C9/C10 仍未实测 |
+
+### P2 · 技术债
+
+| # | 项 |
+|---|---|
+| 6 | `messageCount` 双写虚高 |
+| 7 | 容器主动登记成果表(现在靠前端从正文提取) |
+| 8 | `events` / `palette` / `matrix-client` 无单测 |
+
+---
+
+## 四、今天踩过的坑(避免重复)
+
+| # | 坑 | 教训 |
+|---|---|---|
+| 1 | 清理脚本把群聊一起删了 | 用户说「删会话」要确认是否含群聊 |
+| 2 | 一度判「回合卡样式坏」 | 内容太短本来就没卡片头,是我误判 |
+| 3 | 一度判「顶栏不收敛」 | 容器确实报 running,前端是对的 |
+| 4 | `resume` 测试时建了多条会话 | 每次访问 `/new` 真的建一条 |
+| 5 | 线上云函数 v0.1.8 比本地新 | **部署前必须先对比线上版本** |
+| 6 | 链接提取只做实时消息 | 历史回读路径也要处理 |
+| 7 | Angular `[style.--x]` 不生效 | 自定义属性要用计算好的像素值直接绑 |
+| 8 | 模板/样式里用反引号 | 会破坏 Angular 模板字符串 |

+ 122 - 0
docs/00-tracking/2026-11-09-修改留痕-CORS降级与自动上云.md

@@ -0,0 +1,122 @@
+# 修改留痕:CORS 降级 + 容器自动上云规范(2026-11-09 第八轮)
+
+| 字段 | 值 |
+|---|---|
+| 日期 | 2026-11-09 |
+| 触发 | 用户实测反馈:① 点成果文件报 CORS「加载失败」 ② 不该在提示词里写「上传云盘」 |
+| 结果 | 两处均已修复并验证 |
+
+---
+
+## 一、CORS:文件加载失败
+
+### 实测确认
+
+```
+$ curl -sI -H "Origin: http://localhost:4200" https://s3.fmode.cn/...
+HTTP/1.1 200 OK
+(无任何 Access-Control-* 头)
+```
+
+**`s3.fmode.cn` 完全没配 CORS。** 这是存储/CDN 侧配置,前端无权修改。
+
+### 前端降级方案
+
+| 场景 | 原行为 | 改后 |
+|---|---|---|
+| 预览(`toBlobUrl`) | `fetch` 抛错 → 显示「加载失败」 | **退回原 URL** 作为 iframe src |
+| 下载(`downloadAs`) | `fetch` 抛错 → 报错 | 降级为**新标签打开** |
+
+依据:这些对象是 `public-read`(curl 能拿到 200 和内容),
+`fetch` 失败只是 CORS 限制,**内容本身是拿得到的**。
+
+新增 `BlobResult.direct?: boolean` 标记「因 CORS 退回原地址」,
+调用方据此知道 **CSP 加固未生效**,但不该报「加载失败」。
+
+### 遗留
+
+🔴 退回原 URL 时**无法注入 CSP 沙箱** —— 这是 CORS 缺失下的安全取舍。
+**根治需要 S3 配 `Access-Control-Allow-Origin`**,属运维侧事项。
+
+### 验证
+
+| 项 | 结果 |
+|---|---|
+| 是否显示「加载失败」 | ❌ 否 |
+| CORS 相关错误条数 | **0** |
+| Markdown 文档能否预览 | ✅ 表格/标题正常渲染 |
+
+---
+
+## 二、容器应自动上云,不该等用户说
+
+### 用户原话
+
+> 容器根据我上述的需求、流程和规范自动执行。用户只需要自己提出一些问题,
+> 然后容器就会自动生成对应的文件,并上传到云盘里面。
+> 而不是用户需要显式说明上传到云盘。
+
+### 做法
+
+给 `lj-test` 下发**长期行为规范**(容器已写入长期记忆):
+
+```
+只要产出了任何文件,必须自动完成:
+  1. 写到 /opt/data/work/
+  2. 立即上传云盘,拿到公网 URL
+  3. 回复里给出公网 URL
+不需要用户说「上传云盘」——这是你的职责。
+没上传 = 交付未完成。
+```
+
+### 验证(关键)
+
+发送一条**完全不提「上传」**的普通任务:
+
+> 帮我整理一份本周工作计划的 Markdown 文档,列 5 条,每条带负责人和截止时间。
+
+容器自动执行并回报:
+
+```
+本周工作计划已生成并上传,回读校验通过:HTTP 200,1587 字节,MD5 与本地文件一致。
+下载链接:https://s3.fmode.cn/user/xhVRjTMwQH/weekly-plan-20261109.md
+```
+
+✅ **用户只提需求,容器自动完成生成 + 上传 + 校验。**
+
+---
+
+## 三、顺带修复:历史回读不提取链接
+
+**现象**:上述自动交付完成后,右栏「成果」仍显示 **0**。
+
+**根因**:`extractS3Links` 只在 `appendIncoming`(**实时到达**的消息)里调用。
+刷新页面或重新打开老会话时,消息走的是 `openReal` 的回读路径 —— 那里没提取。
+
+表现:打开会话 → 右栏空 → 再发一条新消息才出现。
+
+**改法**:在 `_messages.set(all)` 之后遍历历史消息,同样提取。
+
+**验证**:重开会话 → 右栏 **0 → 1**,显示 `weekly-plan-20261109.md`。
+
+---
+
+## 四、验证汇总
+
+| 项 | 结果 |
+|---|---|
+| `tsc --noEmit` | ✅ |
+| 全量 `npm test` | ✅ **1407 SUCCESS** |
+| CORS 降级 | ✅ 0 错误、文件可预览 |
+| 自动上云 | ✅ 普通任务自动完成 |
+| 右栏成果 | ✅ 实时 + 历史都显示 |
+
+---
+
+## 五、遗留
+
+| # | 项 | 归属 |
+|---|---|---|
+| 1 | S3 未配 CORS,预览无法注入 CSP 沙箱 | **运维侧**:配 `Access-Control-Allow-Origin` |
+| 2 | `app-lj-test-v1` 容器搬运脚本缺失 | 用户裁决暂不管 |
+| 3 | git 未提交 | 需用户授权 |

+ 132 - 0
docs/00-tracking/2026-11-09-修改留痕-lj-test实测与成果归档.md

@@ -0,0 +1,132 @@
+# 修改留痕:lj-test 实测 + 成果按会话归档打通(2026-11-09 第五轮)
+
+| 字段 | 值 |
+|---|---|
+| 日期 | 2026-11-09 |
+| 触发 | 用户要求:以 **lj-test** 为主测试容器(只有它配了状态上报),把之前的操作在它上面重做 |
+| 用户裁决 | lj-test 作为测试开发主容器 |
+
+---
+
+## 一、实测发现:lj-test 根本不需要改造
+
+我之前对 `app-lj-test-v1` 做的那套改造,**在 `lj-test` 上早就做好了**:
+
+| 项 | lj-test 状态 |
+|---|---|
+| `artifact-drain.py` | ✅ 存在(2026-10-21 建的,2820 字节) |
+| `fmode-artifact-drain` cron | ✅ **enabled**,`*/3`,已跑 **9963 次**,`last_status=ok` |
+| `fmode-status-reporter` | ⏸️ paused(2026-11-07 改为事件驱动,符合预期) |
+| `AGENT_ID` | `agent-node-lj-test` |
+| `pendingArtifacts` 调用 | ✅ 正常 |
+
+**结论**:`lj-test` 的存储链路**一直是通的**。
+之前发现的「文件丢失」问题是 `app-lj-test-v1` 独有的。
+
+### 验证老成果仍可访问
+
+```
+$ curl -sI https://s3.fmode.cn/deliverables/agent-node-lj-test/202610/UlCsQutor3/0-mvhkccjtlg-index.html
+HTTP/1.1 200 OK
+Content-Length: 9844
+```
+
+---
+
+## 二、让 lj-test 做真实交付(实测)
+
+发出的任务:生成一个 md 文件 → 上传云盘 → 报告 URL 和 threadRoot。
+
+### 容器的表现(质量很高)
+
+1. 先做环境事实取证(hostname、内核、Node/Python 版本、磁盘)
+2. 生成文件并做写入校验
+3. 上传后**公网回读验证**(HTTP=200、SIZE=1310、sha256 比对)
+4. 查 `state.db` 取 thread_id,给了两条佐证
+
+### 我的独立验证(不采信容器自述)
+
+```
+$ curl -sI https://s3.fmode.cn/user/xhVRjTMwQH/report/20261109/verify-20261109.md
+HTTP/1.1 200 OK
+Content-Length: 1310
+
+$ curl -s <同 URL> | sha256sum
+39f631d55b5f1329df23ba5721091a40fb8f29976f238b633f605d02b5c97218
+```
+
+**与容器自报的 sha256 完全一致** ✅
+
+### 容器给出的重要发现
+
+> 同一房间 `!miyB1DxRE5E9Aj5d7lE1wcgPWXWx1Mkaw4P-yiQIYdU` 里,库里最新两条会话的
+> thread_id 不同:`$approot_mw9cgh1u_31plvlsv`(本条)与 `$approot_mw9cdq2h_9ilwdmop`(上一条)。
+
+**我的核查**:这**不是容器分叉**,是我测试时连续访问 `/new` 建了多条会话所致。
+`lj-test` 名下 3 条单聊会话,每条对应我的一次测试。
+
+---
+
+## 三、成果按会话归档:前端已完成,云函数待部署
+
+### 前端(已完成)
+
+| 文件 | 改动 |
+|---|---|
+| `models.ts` | `ChatThreadRecord` / `ChatThreadUpsert` 加 `deliverableUrls?: string[]` |
+| `chat-session.ts` | `ChatSession` 加同字段 |
+| `chat-session.service.ts` | `hydrateFromCloud` 读取、`toUpsert` 上云、**新增 `addDeliverableUrl()`** |
+| `chat.service.ts` | 用户上传拿到 cloudUrl → 记入会话;容器附件带 S3 直链 → 记入会话 |
+| `deliverables.service.ts` | 新增 `bySessionUrls()`(无 urls 返回空,不回退容器) |
+| 桌面 / 移动 `chat.ts` | 右栏成果改按会话过滤 |
+
+### 云函数(源码已改,**待部署**)
+
+`cloud/functions/fmodeagent-chatthreads.js` → v0.1.7:
+
+| 改动 | 说明 |
+|---|---|
+| `opList` 返回 `deliverableUrls` | 客户端才能读到 |
+| `opUpsert` 写入 `deliverableUrls` | 只在明确传入时写,不清空已有值 |
+| `_strArr` 上限 120 → **500** | 成果 URL 常常超过 120 字符,按 120 截断会得到打不开的地址 |
+
+**校验通过**:
+```
+线上返回字段(9 条记录并集):agentId, archived, kind, lastMessageAt, lastPreview,
+  memberIds, messageCount, objectId, roomId, threadRoot, title, titleLocked, workspaceId
+✅ 一致:本地声明的 13 个字段线上都有。
+```
+
+### 为什么没部署
+
+`PUT /parse/classes/Function/<id>` 需要 **Master-Key**,我没有。
+已准备好脚本 `scripts/deploy-cloud-fn.mjs`(默认 dry-run、有体积回退保护、403 退避重试):
+
+```bash
+PARSE_MASTER_KEY=xxx node scripts/deploy-cloud-fn.mjs           # 只对比
+PARSE_MASTER_KEY=xxx node scripts/deploy-cloud-fn.mjs --apply   # 真正热更
+```
+
+---
+
+## 四、验证
+
+| 项 | 结果 |
+|---|---|
+| `tsc --noEmit` | ✅ |
+| 全量 `npm test` | ✅ **1398 SUCCESS** |
+| lj-test 存储链路 | ✅ 本来就通(9963 次成功执行) |
+| lj-test 真实交付 | ✅ 文件上云,curl 200,sha256 一致 |
+| 云函数语法 | ✅ `node -c` 通过 |
+| 云函数字段一致性 | ✅ 13 个字段线上都有 |
+
+---
+
+## 五、仍未完成
+
+| # | 项 | 阻塞 |
+|---|---|---|
+| 1 | **云函数部署** | 需要 Master-Key |
+| 2 | **成果归档端到端验证** | 依赖第 1 项 |
+| 3 | App 右栏显示本会话成果 | 依赖第 1 项 |
+| 4 | 容器自动把成果链接写进会话记录 | 需容器侧改造(当前由前端记录) |

+ 139 - 0
docs/00-tracking/2026-11-09-修改留痕-云函数部署与端到端实测.md

@@ -0,0 +1,139 @@
+# 修改留痕:云函数部署 + lj-test 端到端实测通过(2026-11-09 第六轮)
+
+| 字段 | 值 |
+|---|---|
+| 日期 | 2026-11-09 |
+| 触发 | 用户提供 Master-Key,要求部署后做完整实测 |
+| 用户要求 | 文件是否存云盘 / 成果隔离 / **PC 与 Mobile 逻辑一致** |
+
+---
+
+## 一、云函数部署 v0.1.9 ✅
+
+### 部署前做的安全核查(重要)
+
+线上版本是 **v0.1.8(11-05 更新)**,比我本地改的版本**更晚**。
+直接部署有覆盖别人改动的风险,所以先对比:
+
+| 检查 | 结果 |
+|---|---|
+| 线上是否有本地缺失的函数 | **无**(`comm` 对比,线上独有的函数为空) |
+| 线上独有代码 | 只有 `memberIds` 的旧写法(本地是加强版) |
+| 结论 | **本地是严格超集**,部署安全 |
+
+### 部署结果
+
+```
+🔴 APPLY:将热更线上云函数
+  本地源码:15934 字符,v0.1.9
+  线上源码:15319 字符,v0.1.8
+  ✅ 已热更:{"updatedAt":"2026-11-09T14:28:59.930Z"}
+```
+
+### 部署后验证
+
+```
+code: 200 | 会话数: 4
+返回字段: ..., kind, memberIds, deliverableUrls   ← 新字段已上线
+deliverableUrls: []
+```
+
+---
+
+## 二、端到端实测(lj-test)
+
+### 用例 A · 用户上传文件
+
+新建会话 `$approot_mw9d0xkp_2gni45oh:fmode.cn`,上传 `e2e-upload-test.txt`(47 字节)。
+
+**结果**:
+
+```
+会话记录 deliverableUrls:
+["https://s3.fmode.cn/user/xhVRjTMwQH/chat/202611/xhvrjtmwqh-1794234710222-785147-e2e-upload-test.txt"]
+```
+
+### 用例 B · S3 对象可访问
+
+```
+$ curl -sI <url>
+HTTP/1.1 200 OK
+Content-Length: 47
+
+$ curl -s <url>
+E2E 上传验证文件 2026-11-09T14:31:57.539Z
+
+本地对照:47 字节,内容完全相同 ✅
+```
+
+**容器侧也独立确认**:它回复说收到附件、算了 SHA256、内容全文一致。
+
+### 用例 C · 成果隔离
+
+| 会话 | 钉钉成果数 | 说明 |
+|---|---|---|
+| A(上传过文件) | **1** | 显示文件名 |
+| B(新建空会话) | **0** | 「暂无成果」 |
+
+✅ **隔离生效**,B 看不到 A 的成果。
+
+### 用例 D · 双端一致
+
+| 检查点 | PC 端 | 移动端 | 结论 |
+|---|---|---|---|
+| 会话 A 成果数 | 1 | 1 | ✅ 相同 |
+| 会话 B 成果数 | 0「暂无成果」 | 0「暂无成果」 | ✅ 相同 |
+| 成果点击可打开 | 右栏内联 | 预览层 | ✅ 都能打开 |
+| 成果过滤来源 | `bySessionUrls` | `bySessionUrlsOf` | ✅ 同一实现 |
+
+---
+
+## 三、实测中发现并修复的真缺陷
+
+### 缺陷:用户上传的文件在「成果」里永远看不到
+
+**现象**:会话 A 的 `deliverableUrls` 有 1 条,但右栏「成果」显示 **0**。
+
+**根因**:`bySessionUrls` 只去 `deliverables` 表里按 URL 匹配。
+而**用户上传的文件根本不在那张表里** —— 它只是消息附件 + 一个 S3 链接。
+
+**改法**(`deliverables.service.ts`):
+
+```
+以 deliverableUrls 为准生成列表:
+  ① 能在 deliverables 表里匹配到 → 用富信息(标题/版本)
+  ② 匹配不到(用户上传的) → 按 URL 造一条最小记录,文件名从 URL 提取
+```
+
+同时补 `filename` 字段,否则右栏显示「暂无附件」。
+
+**验证**:修复后会话 A 显示 1 条,文件名正确。
+
+### 缺陷:移动端缺「未上云」提示
+
+桌面 `session-rail` 有 `rail-warn`,移动端成果抽屉没有。
+已补 `m-rail-warn` + `notOnCloudCount()`,双端口径一致。
+
+---
+
+## 四、验证汇总
+
+| 项 | 结果 |
+|---|---|
+| 云函数部署 | ✅ v0.1.9 已热更,新字段正常返回 |
+| `tsc --noEmit` | ✅ |
+| 全量 `npm test` | ✅ **1400 SUCCESS**(新增 2 条 bySessionUrls 用例) |
+| `npm run build` | 见下 |
+| 用户文件上云 | ✅ S3 200,内容字节一致 |
+| 成果隔离 | ✅ A=1 / B=0 |
+| 双端一致 | ✅ 数量、开打方式、过滤实现全部相同 |
+
+---
+
+## 五、仍未完成
+
+| # | 项 | 说明 |
+|---|---|---|
+| 1 | 容器产出的成果自动入会话 | 目前靠前端在消息流里识别 S3 链接,容器主动写入更好 |
+| 2 | 其余容器 | `app-lj-test-v1` 有搬运脚本缺失问题(lj-test 正常) |
+| 3 | git 提交 | 未提交,等用户授权 |

+ 180 - 0
docs/00-tracking/2026-11-09-修改留痕-会话域实测与同步.md

@@ -0,0 +1,180 @@
+# 修改留痕:会话域逻辑评审与 PC/Mobile 同步修复(2026-11-09)
+
+| 字段 | 值 |
+|---|---|
+| 日期 | 2026-11-09 |
+| 触发 | 用户要求:以 PC 端为基准评审会话域逻辑;以 lj-test 为样本实测;确认后同步到 Mobile 与其他容器 |
+| 用户裁决 | ① 先修移动端两处硬伤再实测 ② 群聊整段按 PC 基准重构两端 ③ 我自动跑实测 + 关键点请用户确认 ④ 清理测试会话 |
+
+---
+
+## 一、样本容器(实测前只读核对)
+
+| 项 | 值 |
+|---|---|
+| 容器 | `lj-test` / `zGDCmgNQcd` / `agent-node-lj-test` |
+| 心跳 | 有效(阈值 80 分钟内) |
+| status | `idle` |
+| Matrix | `connected`,`listenerOk: true` |
+
+> 实测中发现 App 里实际使用的 agentId 是 **`agent-node-app-lj-test-v1`**(与平台状态表里的 `agent-node-lj-test` 不是同一个串),会话目录按前者查询。
+
+---
+
+## 二、批次 1 · 移动端硬伤(实测已验证)
+
+### C4 · 首页输入坞发不出消息
+
+**根因**:`mobile/pages/home/home.ts` 的 `target` signal 从未被赋值,
+`send()` 无论如何都只是打开选择器,用户打的字被丢弃。
+
+**改法**:
+- 去掉无用的 `target` 分支,`send()` 直接把草稿存起来并打开选择器
+- 选人后经 `sessionStorage`(键 `fmode_home_draft_<agentId>`)把草稿带给会话页
+- 会话页 `openSession()` 建好空会话后取回草稿、清掉键
+
+**为什么不用路由 state**:会话页建好空会话会 `replaceUrl` 换成真实 id,state 会丢。
+
+**实测**:首页输入「你好,这是首页发送测试」→ 选人 → 进入会话,输入坞内容
+`"你好,这是首页发送测试"` ✅(截图 `B1-picker.png` / `B1-inchat.png`)
+
+### C5 · 会话页无法切换会话
+
+**根因**:`mobile/pages/chat/chat.ts` 导入了 `MDrawerComponent`,
+但模板里从未使用,`drawerOpen` 恒为 `false` —— 进了会话就出不去。
+
+**改法**:顶栏加「切换会话」按钮,挂载 `<app-m-drawer>`,
+传入 `activeSessionId` 高亮当前会话。
+
+**实测**:抽屉打开、当前会话「测试会话状态」高亮 ✅(截图 `B2-drawer.png`)
+
+---
+
+## 三、批次 2 · 状态口径统一(C6)
+
+实测确认:同一个容器在不同页面结论相反 —— 新建弹窗按 `agentLayers` 自拼
+(`alive === 'stale'` 判成红色「离线」),左栏按 `resolveAgentState` 显示黄色
+「状态未同步」。
+
+| 文件 | 改动 |
+|---|---|
+| `new-session-modal.ts` | 删除自拼判据,改调 `resolveAgentState`;保持「在线/离线」二值展示 |
+| `presence-badge.ts` | 改调 `resolveAgentState`;`danger` 不再压成 `secondary`(灰) |
+| `agent-state.ts` | `agentLayers` 补 `BROKEN`(`stopped`/`offline`)分支,与 `resolveAgentState` 同结论 |
+
+---
+
+## 四、批次 3 · 回合与计时(C2 / C3 / C7)
+
+### C2 · 回合卡可能永久「输出中」
+
+**根因**:`aiRows` 里的 `now` 是 `Date.now()` 快照,只有 `messages()` 或
+`peerTyping()` 变化时才重算。容器一直报 typing 又不发新消息时,
+25 秒静默永远等不到,卡片停在「正在输出…」。
+
+**改法**:桌面与移动各加一个每秒时钟(`clockTick` / `now`),
+**只在有 `live` 回合时起表**,回合结束自动停表,离页 `ngOnDestroy` 清理。
+
+### C7 · 读秒跳变
+
+**根因**:`chat.service.ts` 的 `appendIncoming` 里写
+`mergeTimeline([...rest, mapped], [])` —— 第二个参数传**空数组**,
+`localTs` 映射表因此为空,echo 认领时「沿用本地 ts」的回落**完全失效**,
+ts 直接换成服务端时钟。
+
+**改法**:把被替换的本地消息一并传入 `mergeTimeline`。
+
+### C3 · 顶栏状态判定顺序(已修,但「顶栏不收敛」经复查**不是前端缺陷**)
+
+**判定顺序问题(真实)**:`task-pulse.service.ts` 里 `status==='running'` 排在
+`isBusy` 之前,导致 `local && isBusy` 分支不可达;心跳过期检查排在
+「最近有消息进来」之前,会让容器正在回话时顶栏显示「状态未同步」。
+
+**改法**:顺序改为「用户动作 / 实时消息流 > 容器自报 > 心跳新鲜度」。
+
+**🔴 「顶栏不收敛」的复查结论(重要,纠正 2026-11-09 过程中一次误判)**:
+
+实测中观察到「回合卡 30 秒已 done,顶栏还挂着『刚刚开始 · 正在处理你的消息』」,
+一度判定为前端缺陷并尝试修改 `isBusy` 语义。复查后发现**判断错误**:
+
+- App 里显示的「app-lj-test-v1」,在平台状态表里的 `name` 是 **「重命名测试1」**
+  (`objectId = axmoJmFPHR`)
+- 该实体实测 `status = running`、心跳 112 秒前 —— **容器确实在上报 running**
+- 而平台里另一个 `name = lj-test`(`objectId = zGDCmgNQcd`)才是 `idle`
+
+**所以顶栏显示「正在处理」是正确的**:容器自己说它还在跑。
+这是操作追踪里记录过的「reporter 把 `status=running` 硬编码」问题,
+前端已用 `isReporterSelfTalk` 兜底,但该容器 `currentTask` 为空、特征命不中,
+属**容器侧待整改**,不应在前端加语义补丁。
+
+**已回滚**:`isBusy` 的语义修改与其 spec 用例已还原,避免为掩盖容器问题而
+引入不必要的前端复杂度。
+
+**保留的改动**:`observe()` 里「容器报非 running 即清本地标记」——
+修复的是另一个真实缺陷(容器从未进过 `running` 时,清理代码走不到,
+本地标记会挂到进程结束)。
+
+---
+
+## 五、正文被截断(实测发现,重要)
+
+**现象**:长回复正文长度从 574 掉到 565,最后一整段消失。
+
+**根因**:`assistant-turn.ts` 的 `segments` 用 `shown[b.key] ?? 0` 取流式进度。
+容器一轮回复分多条事件到达,`mergeConsecutiveBodies` 合并时**重建块 → key 变化**,
+新 key 在新 map 里是 `undefined` → 取 0 → `slice(0, 0)` **整段清空**。
+
+**改法**:已结束的块(`!b.live`)直接全文渲染,不参与流式截断。
+流式的意义只在「还在生成」,结束后再截断只会丢内容。
+
+**实测对比**:
+
+| 项 | 修复前 | 修复后 |
+|---|---|---|
+| 内容缩短次数 | 1 次(574→565) | **0 次**,长度稳定 555 |
+
+---
+
+## 六、确认「不是缺陷」的两项(如实记录)
+
+| 现象 | 结论 |
+|---|---|
+| 短回复(「收到」两字)没有卡片形态 | **正常**。内容太短,卡片头(过程步骤)不出现是设计使然。用户判断正确 |
+| 长回复里「一、产品定位 / 二、核心能力 / 三、适用与价值」挤成一段 | **不是前端问题**。实测 DOM 文本显示换行符只出现在段落之间一次,「一、」「二、」「三、」之间是**空格** —— 容器把三段塞进一条 Matrix 消息,用空格分隔。前端 `white-space: pre-wrap` 已生效,无法通用修复,属**容器输出格式**问题 |
+
+---
+
+## 七、验证
+
+| 项 | 结果 |
+|---|---|
+| `tsc --noEmit`(app + spec) | ✅ |
+| 全量 `npm test` | ✅ **1377 SUCCESS** |
+| 移动端首页发送 | ✅ 草稿随行 |
+| 移动端会话切换 | ✅ 抽屉可用 |
+| 内容截断 | ✅ 0 次缩短 |
+| 回合收尾 | ✅ 33 秒 done 后不再变化 |
+| 顶栏「正在处理」 | ✅ **不是缺陷**:该容器确实上报 `status=running`(详见 §四 C3 复查) |
+
+**未验证项**:
+1. 群聊(C1/C8/C9/C10)**尚未实测**。其中 C1 的 P0 判断已被源码核实推翻
+   (`appendReply` 的 `turnId` 与 `appendMine` 同源,不存在「回复挂不上」)
+2. Mobile 端其余用例(B3/B4/B5)未跑
+3. 第二容器(`lj`)泛化未做
+4. `npm run smoke` / `smoke:groups` 本轮未复跑(上次执行是 2026-11-06)
+
+---
+
+## 八、测试数据清理
+
+本轮实测在真实账号下造出 5 条测试会话,已**软删**(`isDeleted=true`,规则 6):
+
+| objectId | 标题 |
+|---|---|
+| `IHN7iULVxS` | 测试:请只回复「收到」两个字 |
+| `7oOWoLDLwy` | 测试:请只回复「收到」两个字 |
+| `XFX8c1VCEF` | 测试:请只回复「收到」两个字 |
+| `sfmmWW3pyE` | 测试:请只回复「收到」两个字 |
+| `6uIoYkj3Ab` | 用一句话回答:今天几号? |
+
+清理后:26 → 22 条有效会话。

+ 120 - 0
docs/00-tracking/2026-11-09-修改留痕-容器成果自动归档.md

@@ -0,0 +1,120 @@
+# 修改留痕:容器成果自动归档到会话(2026-11-09 第七轮)
+
+| 字段 | 值 |
+|---|---|
+| 日期 | 2026-11-09 |
+| 触发 | 用户追问「没完成的三件」中的第一件:容器新产出成果能否出现在右栏 |
+| 结论 | **发现问题并修复,端到端验证通过** |
+
+---
+
+## 一、实测发现的真问题
+
+### 现象
+
+让 `lj-test` 生成成果文件并上传云盘:
+
+| 位置 | 结果 |
+|---|---|
+| S3 云盘 | ✅ `curl` 200,62 字节,内容正确 |
+| 会话记录 `deliverableUrls` | ❌ **空 `[]`** |
+| 消息附件 | ❌ 没有文件附件 |
+| 右栏「成果」 | ❌ **显示 0** |
+
+**用户会以为文件丢了。**
+
+### 根因
+
+容器产出的成果**不发文件附件**,而是把云盘 URL **写在回复正文里**。
+
+而 `ChatService.appendIncoming` 只认 `mapped.file`(附件字段),
+正文里出现的链接完全没被处理。
+
+---
+
+## 二、修复
+
+### 新增 `core/s3-link.ts`
+
+```ts
+export function extractS3Links(text: string): string[]
+```
+
+从消息正文里提取云盘链接:
+
+| 规则 | 说明 |
+|---|---|
+| 只认 `s3.fmode.cn` / `file-cloud.fmode.cn` | 不把普通链接当成果 |
+| 去掉句末中文标点 | `https://...a.md。` → 去掉 `。` |
+| 去重 | 同一链接出现两次只记一次 |
+| 空输入安全 | 返回 `[]` |
+
+### 接入 `chat.service.ts`
+
+```ts
+const fileUrl = mapped.file?.cloudUrl || ...;
+if (fileUrl) this.sessions.addDeliverableUrl(sid, fileUrl);
+// 新增:正文里的链接也认
+for (const u of extractS3Links(mapped.text)) {
+  this.sessions.addDeliverableUrl(sid, u);
+}
+```
+
+---
+
+## 三、端到端验证
+
+### 会话 C(历史消息,验证"补认")
+
+| 修复前 | 修复后 |
+|---|---|
+| 成果 0 | **成果 1**(`c-result-20261109.md`) |
+
+### 会话 D(全新会话,验证"实时")
+
+| 项 | 结果 |
+|---|---|
+| S3 可访问 | ✅ `d-result-20261109.md` |
+| PC 端右栏 | ✅ **1 条** |
+| 移动端抽屉 | ✅ **1 条**(同名同链接) |
+| 双端一致 | ✅ 完全相同 |
+
+---
+
+## 四、关于「成果写进会话记录」的说明
+
+用户问这个是什么意思。用一句话解释:
+
+> 右侧「成果」页签要显示「这条对话产生的文件」,系统就得知道**哪条对话有哪些成果**。
+> 这个对应关系存在会话记录的 `deliverableUrls` 数组里。
+> 「写进会话记录」= 把成果链接填进这个数组。
+
+**现在有三个入口都会写**:
+
+| 来源 | 触发点 |
+|---|---|
+| 用户上传的文件 | `uploadPending` 拿到 S3 地址后 |
+| 消息附件里的 S3 链接 | `appendIncoming` 的 `mapped.file` |
+| **正文里的 S3 链接**(本轮新增) | `appendIncoming` 的 `extractS3Links` |
+
+---
+
+## 五、验证汇总
+
+| 项 | 结果 |
+|---|---|
+| `tsc --noEmit`(app + spec) | ✅ |
+| 全量 `npm test` | ✅ **1407 SUCCESS**(新增 `s3-link.spec.ts` 7 条) |
+| 会话 C 补认 | ✅ 0 → 1 |
+| 会话 D 实时 | ✅ PC 1 / 移动 1 |
+| S3 可下载 | ✅ 200,内容正确 |
+
+---
+
+## 六、仍未完成
+
+| # | 项 | 说明 |
+|---|---|---|
+| 1 | `app-lj-test-v1` 容器 | 用户说暂不管 |
+| 2 | 容器主动登记成果表 | 现在靠前端从正文提取;容器若能直接调 `enqueueArtifact` 更可靠 |
+| 3 | git 提交 | 未提交 |

+ 148 - 0
docs/00-tracking/2026-11-09-修改留痕-成果按会话归档与文件上云.md

@@ -0,0 +1,148 @@
+# 修改留痕:成果按会话归档 + 文件统一上云(2026-11-09 第三轮)
+
+| 字段 | 值 |
+|---|---|
+| 日期 | 2026-11-09 |
+| 触发 | 用户反馈:新建会话显示「该会话已删除」;成果应按对话绑定;核实 S3 存储 |
+| 用户裁决 | ① 文件流:处理后必须上云,之后查看都走云盘 ② 成果链接放进会话记录 ③ 单聊上传也传 S3 |
+
+---
+
+## 一、重大发现:容器成果文件全部丢失(实测)
+
+**方法**:直接让 `lj-test` 容器在它的环境里**执行命令**(不是让它自述)。
+
+```bash
+$ ls /opt/data/git-repos/agent-status-reporter/scripts/artifact-drain.py
+python3: can't open file ... [Errno 2] No such file or directory
+
+$ grep "artifact" <jobs.json>
+exit=1
+```
+
+| 结论 | 证据 |
+|---|---|
+| 成果搬运脚本**不存在** | `ls` 报 No such file or directory |
+| 定时任务里**没有** artifact 项 | `grep` 退出码 1 |
+| 只有 `fmode-status-report.sh` 在跑 | `last_status: ok`,completed 4642 次,但只上报状态 |
+
+**后果**:容器产出的成果文件全部留在 `/opt/data/`,App 拿不到、浏览器访问不到。
+前端仍在登记 `FmodeArtifactJob` 队列,但**没有消费方** → 附件永远没 url →
+右栏不显示且**没有任何提示**。
+
+**历史对照**:2026-10-21 这条链路跑通过(curl 回读 3689 字节,内容正确)。
+11 月容器重部署时漏掉了 —— 11-03 的接入指令只要求 `fmode-status-report.sh`。
+
+**容器自述(已核实)**:
+- 成果文件**没传 S3**,只留容器本地
+- 发文件走 Matrix `upload_media`,拿 `mxc://`
+- `deliverables` 上报参数只有 6 个键,**无** threadRoot / sessionId / roomId
+- 用户发的文件从 Matrix 媒体库取(只接受 `mxc://`)
+- 容器本地 `state.db` 里**有** `thread_id`,但从未上报
+
+---
+
+## 二、新建会话误判「已删除」(已修)
+
+**根因**:`openReal` 里误判分支的条件是 `!byId(sid) || messageCount === 0` ——
+**新建对话恰恰就是 0 条消息**,再碰巧命中云端墓碑就显示「已删除」。
+
+**改法**:三重条件
+1. 本地目录**完全没有**这条(`byId` 取不到)
+2. 云端墓碑里有它
+3. 不在本机新建态(`_blankSessionId`)
+
+**实测**:新建 → 切走 → 再进,不再显示「该会话已删除」✅
+
+---
+
+## 三、成果按会话归档(前端部分已完成)
+
+### 数据模型
+
+`ChatThreadRecord` / `ChatThreadUpsert` / `ChatSession` 各加:
+
+```ts
+/** 本会话关联的成果链接(云盘可访问地址) */
+deliverableUrls?: string[];
+```
+
+- `hydrateFromCloud` 读取(云端为准、本地兜底)
+- `toUpsert` 上云
+- **不新增表**,复用现有会话记录(符合用户「会话在库里已有一条记录」的说法)
+
+### 查询层
+
+`deliverables.service.ts` 新增 `bySessionUrls(urls)`:
+
+```ts
+bySessionUrls(urls) {
+  if (!urls?.length) return [];        // 🔴 不回退到整个容器
+  return items.filter(d => d.artifacts.some(a => a.url && want.has(a.url)));
+}
+```
+
+### 展示层
+
+| 端 | 改动 |
+|---|---|
+| 桌面 `chat.ts` | `agentDeliverables` 从 `byAgent(agentId)` 改为 `bySessionUrls(session.deliverableUrls)` |
+| 移动 `chat.ts` | `deliverables` 改走 `bySessionUrlsOf` |
+| `mobile-chat-view.service.ts` | 新增 `bySessionUrlsOf` |
+
+**降级**:没有 `deliverableUrls` → 显示空,**不回退到整个容器**(宁缺勿错)。
+
+---
+
+## 四、文件统一上云(前端部分已完成)
+
+### 单聊上传:Matrix + S3 双写
+
+`chat.service.ts` 的 `uploadPending`:
+
+```
+① Matrix 照常发(容器只认 mxc://,供它读取与消息流回溯)
+② 额外调 api.uploadFile 存一份到 S3(供 App 长期查看)
+③ S3 失败不阻断发送 —— 降级为只有 Matrix,并如实提示
+```
+
+🔴 不是像早期那样「只走 OBS」—— 容器收不到,会回「没看到附件」。
+
+### 文件卡加 `cloudUrl`
+
+`FileCard` 加 `cloudUrl?: string`;`openFile` 优先用它(`blob:` 刷新即失效)。
+
+### 未上云提示(当前最紧迫)
+
+`session-rail` 成果页签新增:**有 N 个成果文件未上传云盘,暂时无法查看**。
+原来这种情况是**完全静默**的。
+
+---
+
+## 五、容器侧改造(已出任务书,待推动)
+
+`docs/03-interface/容器侧改造-文件与成果统一上云-20261109.md`
+
+4 项任务:
+1. 部署 `artifact-drain.py` + cron(文档 384 行有现成代码)
+2. 成果产出后**主动入队**(不再等 App 打开)
+3. 用户文件处理完也上云
+4. 上传成功后把链接写进对应会话记录的 `deliverableUrls`
+
+**验收第 5 项是唯一有效的**:`curl -I <url>` 必须 HTTP 200。
+
+---
+
+## 六、验证
+
+| 项 | 结果 |
+|---|---|
+| `tsc --noEmit`(app + spec) | ✅ |
+| 全量 `npm test` | ✅ **1396 SUCCESS**(新增 `bySessionUrls` 用例 2 条) |
+| 新建会话误判 | ✅ 实测不再显示「已删除」 |
+| `npm run build` | 待确认 |
+
+**未验证项**:
+1. 成果按会话隔离的端到端实测 —— **需要容器先能产出带 `deliverableUrls` 的数据**
+2. 单聊 S3 双写的真机实测
+3. 容器侧 4 项改造未执行(需你推动)

+ 149 - 0
docs/00-tracking/2026-11-09-修改留痕-排版与数据清理.md

@@ -0,0 +1,149 @@
+# 修改留痕:AI 回复排版 + 会话数据软删 + 已删数据不加载(2026-11-09 第二轮)
+
+| 字段 | 值 |
+|---|---|
+| 日期 | 2026-11-09 |
+| 触发 | 用户实测截图:AI 回复整段粘连,要求参考 ChatGPT 排版;并要求软删对话记录、已删数据不再加载 |
+| 用户裁决 | ① 全部会话都软删(含真实历史)② 已删会话:列表隐藏 + URL 不可直达 |
+
+---
+
+## 一、排版问题:根因与修复
+
+### 根因(用真实数据确认,非推测)
+
+抓取实际渲染的 HTML:
+
+```html
+<p><strong>我的身份</strong></p>
+<p>我是主人的数字分身…结论先行。
+<strong>我的能力</strong></p>
+<p>我能操作浏览器…</p>
+```
+
+| 事实 | 说明 |
+|---|---|
+| 渲染器 | `ngx-markdown`(`md-render.ts`),标题/列表/引用/代码块样式**早已按 ChatGPT 对齐** |
+| 容器输出 | 小标题与正文之间是**单个 `\n`**,不是空行 |
+| CommonMark 规则 | 单换行 = 空格 → 相邻行被合并成同一个 `<p>` |
+| 另一种形态 | 标题粘在**上一段行尾**(`…结论先行。**我的能力**`),下一行才是正文 |
+
+**结论:不是渲染器不行,是数据里的单换行在 markdown 里不构成分段。前端可以归一化。**
+
+### 修复
+
+新增 `src/app/components/preview/md-render/normalize-md.ts`,导出纯函数
+`normalizeMarkdown(text)`。
+
+**实测中陆续发现容器有 4 种排版形态**,逐一覆盖:
+
+| # | 形态 | 示例 | 处理 |
+|---|---|---|---|
+| 1 | 标题在行首,后接正文 | `**核心能力**\n我是…` | 标题**后**补空行 |
+| 2 | 标题粘在上一段**行尾** | `…结论先行。**我的能力**` | 拆成独立行,前后各补空行 |
+| 3 | 标题在行首,但前一行是正文 | `正文。\n**第二段:…**\n正文` | 标题**前**补空行 |
+| 4 | 空格 + 短标题短语收尾 | `…绝不编造。 我的边界` | 按标题长相识别(不依赖空格数) |
+
+其余规则:
+
+| 规则 | 处理 |
+|---|---|
+| 已是空行分段(正常 markdown) | **原样不动**(保证幂等) |
+| 代码围栏 ``` 内 | **逐字保留** |
+| 列表项之间、缩进续行 | 不断开 |
+
+🔴 **只补不拆**:永远只插入换行,不删除任何字符(有单测锁定)。
+
+接入点:`md-render.ts` 模板改为 `[data]="data()"`,
+`data = computed(() => normalizeMarkdown(this.text()))`。
+双端共用该组件 → 一次改动两端同时生效。
+
+### 实测效果
+
+| 指标 | 修复前 | 修复后 |
+|---|---|---|
+| `<p>` 段落数 | 1 | **6** |
+| 小标题 | 与正文粘连 | 各自独立成段 |
+| 段落间距 | 无 | 空行分隔 |
+
+---
+
+## 二、会话数据软删
+
+新增一次性脚本 `scripts/purge-chatthreads.mjs`:
+
+- 默认 **dry-run**,`--apply` 才执行
+- 走 `op:'remove'`(软删),不物理删除
+- 不碰 Matrix 房间与消息,只删目录索引
+- 清单落盘到 `test-results/purge-chatthreads/`
+
+### 执行结果
+
+| 容器 | 条数 |
+|---|---|
+| `lj` | 5 |
+| 重命名测试1(= `app-lj-test-v1`) | 37 |
+| `lj-test` | 32 |
+| **合计** | **74** |
+
+✅ 成功 74 / 失败 0,复核剩余未删会话 **0 条**。
+
+---
+
+## 三、已删数据不加载
+
+### 现状漏洞(已核实)
+
+`chat.service.ts` 的 `openReal` 在「目录里查不到」时会**无条件 adopt**
+地址里的会话 —— 即使这条已经被删,只要 URL 带着线程根就能打开并继续发消息。
+
+### 修复
+
+1. `chat-session.service.ts` 新增 `isCloudRemoved(id)`:只认云端墓碑
+   (与 `isRemoved` 区分 —— 后者含本机临时黑名单,目录未加载时可能误伤)
+2. `chat.service.ts` 的 `openReal`:命中墓碑 → 不认领,
+   `emptyReason = 'removed'`,显示「该会话已删除」
+3. 桌面 + 移动两端各加一个「已删除」空态
+
+🔴 **刻意不并入 `_removed`**:后者持久化到 localStorage,
+一旦云端某次返回异常,本地会永久拉黑一批正常会话。
+
+### 实测中发现的服务端语义问题(重要)
+
+`fetchChatThreads(agentId, { includeDeleted: true })` 的参数名叫
+`includeDeleted`,但**实测返回的是「只含已删」,不是「活跃 + 已删」**:
+
+| 请求 | 返回条数 |
+|---|---|
+| 默认 `list` | 2(活跃) |
+| `includeDeleted: true` | 41(**全部是已删**) |
+| 两个集合的交集 | **空** |
+
+原实现把它当并集用,导致墓碑表里混入错误数据。已在代码注释中记录实测证据。
+
+---
+
+## 四、验证
+
+| 项 | 结果 |
+|---|---|
+| `tsc --noEmit`(app + spec) | ✅ |
+| 全量 `npm test` | ✅ **1394 SUCCESS**(新增 17 条归一化用例) |
+| 排版实测(移动端) | ✅ `<p>` 从 1 → 6,小标题独立成段 |
+| 软删执行 | ✅ 74 条成功,复核剩余 0 |
+| `npm run build` | ✅ 成功 |
+
+**未验证项**:
+1. 桌面端排版实测(组件共用,理论上一致,未单独截图)
+2. 「已删除」空态的端到端验证(单测已过,实测待做)
+3. 群聊相关缺陷(C1/C8/C9/C10)仍未实测
+
+---
+
+## 五、过程中自查发现并纠正的两次错误
+
+| # | 错误 | 纠正 |
+|---|---|---|
+| 1 | 曾判定「回合卡没有卡片形态是缺陷」 | 用户指出是内容太短所致,**判断错误已撤回** |
+| 2 | 曾判定「顶栏不收敛是前端缺陷」并改 `isBusy` 语义 | 复查发现 App 里的 `app-lj-test-v1` 在平台上叫「重命名测试1」,确实上报 `status=running`。**前端是对的**,改动已回滚 |
+| 3 | 曾把云端墓碑并入 `_removed` | 8 条既有单测失败 —— 会永久拉黑正常会话。**已回退**,改用 `cloudRemoved` 独立判定 |

+ 101 - 0
docs/00-tracking/2026-11-09-修改留痕-项目逻辑文档补齐.md

@@ -0,0 +1,101 @@
+# 修改留痕:项目逻辑文档补齐 + 容器成果上云闭环验证(2026-11-09 第四轮)
+
+| 字段 | 值 |
+|---|---|
+| 日期 | 2026-11-09 |
+| 触发 | 用户要求:① 项目逻辑/需求/操作都要在文档里展示 ② 容器侧的事直接让容器做并实测 |
+| 用户裁决 | ① 容器能自主完成写文件+加定时任务 ② 文档粒度:总览 + 需求对照 |
+
+---
+
+## 一、文档补齐(新增 3 份)
+
+| 文档 | 解决的痛点 |
+|---|---|
+| `docs/02-design/architecture/当前业务逻辑总览.md` | 原先**没有**「现在怎么跑」的文档,只有 7215 行流水账 |
+| `docs/02-design/architecture/需求-实现对照表.md` | 需求 → 代码 → 测试 → 状态,一张表查完 |
+| `docs/README.md`(重写) | 加了「快速入口」和「已知过期文档」清单 |
+
+### 《当前业务逻辑总览》写了什么
+
+五条主线的真实链路:
+
+1. **整体架构** —— 端分流 / 分层 / Matrix+云函数+S3 三方分工
+2. **会话域** —— 新建不落库 → 首次发消息强顺序落库 → 发送 → 回读 → 回合聚合
+3. **群聊(伪群聊)** —— 为什么不是真群、怎么分发、怎么归集
+4. **状态体系** —— 三个入口的区别、心跳阈值(**80/120 分钟**,纠正了网上流传的「30 秒」说法)
+5. **文件与成果** —— 双写策略、搬运链路、**当前断裂点**
+6. **云函数清单** —— 7 个 objectId 各自职责
+
+### 《需求-实现对照表》写了什么
+
+- 21 条 FR + 6 条 NFR + 6 条业务规则 → 实现位置 → 测试 → 状态
+- 本轮新增的 10 项逻辑单独列表
+- **§五 已知问题清单**(改代码前必看)
+
+---
+
+## 二、容器改造实测:全链路打通 ✅
+
+### 过程
+
+| 步骤 | 结果 |
+|---|---|
+| 发改造指令给 `agent-node-app-lj-test-v1` | 容器自主执行 |
+| 写 `artifact-drain.py` | ✅ 容器自己发现「cron 不继承环境变量」,主动加了 wrapper |
+| 加定时任务 | ✅ `*/3 * * * *`、`enabled: true`、`last_status: ok`、`completed: 1` |
+| 造测试成果 | ⚠️ 卡住:容器没有自己的 `deliverableId` |
+| 容器自纠 | ✅ 自己探测出无创建接口,**并主动清理了误入队到别人名下的任务** |
+| 补发指令后 | ✅ 容器成功创建「上云验证」成果并上传 S3 |
+
+### 最终验证(我 curl,不采信容器自述)
+
+```bash
+$ curl -sI "https://s3.fmode.cn/deliverables/agent-node-app-lj-test-v1/202611/wm0ldwYRBq/0-mw9bjh2fhm-test-artifact-20261109.md"
+HTTP/1.1 200 OK
+Content-Type: text/markdown; charset=utf-8
+Content-Length: 31
+
+$ curl -s <同 URL>
+这是容器上云验证文件
+```
+
+| 验收项 | 结果 |
+|---|---|
+| HTTP 状态 | ✅ **200** |
+| 内容 | ✅ 「这是容器上云验证文件」 |
+| 字节数 | ✅ 31 |
+| 对象键规则 | ✅ `deliverables/<agentId>/<YYYYMM>/<deliverableId>/<序号>-<hash>-<文件名>` |
+| 搬运队列 | ✅ 已清空(`jobs: []`) |
+
+### 容器表现(值得记录)
+
+容器在这轮表现得相当专业:
+1. 先验证依赖(agentId、token、云函数)再动手
+2. 发现 cron 不继承环境变量,主动加 wrapper
+3. 用错 id 入队后**主动撤销**,避免污染别人数据
+4. 卡住时明确说「做不到」,没有编造成功
+
+---
+
+## 三、验证
+
+| 项 | 结果 |
+|---|---|
+| `tsc --noEmit` | ✅ |
+| 全量 `npm test` | ✅ **1398 SUCCESS** |
+| `npm run build` | ✅ |
+| 容器脚本 | ✅ 已部署 |
+| 定时任务 | ✅ 已启用 |
+| **成果上云端到端** | ✅ **curl HTTP 200 + 内容正确** |
+
+---
+
+## 四、仍未完成
+
+| 项 | 说明 |
+|---|---|
+| 成果写进会话记录 | 容器目前能上云,但还没把链接写进 `FmodeChatThread.deliverableUrls` |
+| App 右栏显示该成果 | 需要上面那条打通后才有数据 |
+| 容器自动产出成果 | 现在靠手动触发,容器处理完业务后应主动入队 |
+| 其余容器 | 只改了 `app-lj-test-v1`,`lj` / `lj-test` 未动 |

+ 139 - 0
docs/00-tracking/2026-11-10-进度记录与遗留问题.md

@@ -0,0 +1,139 @@
+# 2026-11-09 ~ 11-10 进度记录与遗留问题
+
+| 字段 | 值 |
+|---|---|
+| 周期 | 2026-11-09 ~ 2026-11-10 |
+| 主题 | 会话域修复 / 排版 / 成果归档 / 文件上云 / 容器规范 |
+
+---
+
+## 一、这两天的完整工作
+
+### 1. 会话域缺陷(9 项)
+
+| # | 问题 | 根因 | 状态 |
+|---|---|---|---|
+| 1 | 移动首页发不出消息 | `target` 从未赋值 | ✅ |
+| 2 | 移动会话页切不了会话 | 抽屉导入了没用 | ✅ |
+| 3 | 正文被截断(574→565) | 块 key 变化导致 `slice(0,0)` | ✅ |
+| 4 | 读秒跳变 | `mergeTimeline(..., [])` 传空数组 | ✅ |
+| 5 | 状态口径不一致 | 各页自写一套 | ✅ |
+| 6 | 回合永久「输出中」 | `now` 是快照,不重算 | ✅ |
+| 7 | 新建会话显示「已删除」 | 误伤 `messageCount === 0` | ✅ |
+| 8 | 首次发消息未等落库 | 改为 `commitNow()` 强顺序 | ✅ |
+| 9 | 正文被附件挤掉 | `@if(file){} @else {正文}` 二选一 | ✅ |
+
+### 2. AI 回复排版
+
+新增 `normalize-md.ts`,覆盖容器 4 种排版形态(连标题、行尾标题、行首标题、空格短语)。
+效果:`<p>` 段落数 **1 → 6**。
+
+### 3. 成果归档(核心)
+
+| 层 | 实现 |
+|---|---|
+| 数据 | `ChatThreadRecord.deliverableUrls: string[]` |
+| 云函数 | **v0.1.9 已部署**(写入 + 返回 + `_strArr` 上限 500) |
+| 前端 | `addDeliverableUrl()` / `bySessionUrls()` / `fromAgentFiles()` |
+| 语义 | **文件 = 我上传的**(sender=me);**成果 = 容器产出的**(sender=agent) |
+
+### 4. 文件上云
+
+| 链路 | 状态 |
+|---|---|
+| 用户上传 | Matrix + S3 双写 ✅ |
+| 容器成果 | 自动上云 ✅(容器已固化为长期规范) |
+| 文件链接识别 | 只认 agent 消息 + 合法扩展名 ✅ |
+
+### 5. 容器侧规范(已写入技能文件 v1.3.0)
+
+1. **交付文件自动上云**——用户不需要说「上传云盘」
+2. **URL 独占一行、裸链接**——不用 `MEDIA:` 内联、不用 markdown 链接语法
+3. **交付格式模板**:`文件:<名> — <说明>` + 下一行裸 URL
+4. **语义对齐**:容器产出 = 成果,不描述成「用户文件」
+5. **举例不写带省略号的假 URL**——会被客户端误识别成真实文件
+
+### 6. 文档
+
+- `docs/02-design/architecture/当前业务逻辑总览.md`(新)
+- `docs/02-design/architecture/需求-实现对照表.md`(新)
+- `docs/README.md` 重写(快速入口 + 过期清单)
+- 8 份修改留痕
+
+---
+
+## 二、验证状态
+
+| 项 | 结果 |
+|---|---|
+| 单测 | ✅ **1413 条** |
+| tsc | ✅ |
+| build | ✅ |
+| 文件上云 | ✅ curl 200,内容一致 |
+| 成果隔离 | ✅ A=1 / B=0 |
+| 双端一致 | ✅ |
+| 容器自动上云 | ✅ 普通任务自动完成 |
+
+---
+
+## 三、⚠️ 未完成的问题
+
+### P0 · 阻塞体验
+
+| # | 问题 | 影响 | 建议 |
+|---|---|---|---|
+| 1 | **S3 未配 CORS** | 前端只能用「直开原 URL」绕过,**HTML 预览没有 CSP 沙箱** | 运维配 `Access-Control-Allow-Origin` |
+
+### P1 · 应该做
+
+| # | 问题 | 说明 |
+|---|---|---|
+| 2 | 成果链接靠「正文里找 URL」 | 容器若发 JSON 结构会更可靠(见下) |
+| 3 | 群聊相关缺陷 | C1/C8/C9/C10 仍未实测 |
+| 4 | `app-lj-test-v1` 容器 | 搬运脚本缺失,用户裁决暂不管 |
+| 5 | 缩放效果 | 列表/表格/代码块已改 em,未实际调字号验证 |
+
+### P2 · 技术债
+
+| # | 项 |
+|---|---|
+| 6 | `messageCount` 双写虚高 |
+| 7 | `events` / `palette` / `matrix-client` 无单测 |
+| 8 | `API-契约.md` 部分过期 |
+
+---
+
+## 四、关于「直接输出 JSON 是否更好」
+
+用户提出的方向:**容器直接输出结构化 JSON**(文件链接数组 + 正文),而不是让前端从正文里猜。
+
+### 对比
+
+| 维度 | 现状:从正文提取 URL | 方案:容器输出 JSON |
+|---|---|---|
+| 可靠性 | 低——取决于容器措辞,已踩过 2 次坑(示例 URL 被当真、前缀行残留) | 高——字段明确 |
+| 容错 | 需大量规则(扩展名白名单、排除省略号、排除用户消息) | 只需解析 JSON |
+| 阅读体验 | 好——正文是自然语言 | 差——用户看到 JSON 原文 |
+| 实现成本 | 前端已实现 | 前端要解析 + 渲染,容器要改输出 |
+
+### 推荐:**混合方案**
+
+容器输出「正文 + 文件清单」两段,用**约定标记**分隔:
+
+````
+本周工作计划已生成,含 5 条任务。
+
+<!--fmode:files
+[
+  {"name": "weekly-plan-20261109.md", "url": "https://s3.fmode.cn/...", "size": 1587}
+]
+-->
+````
+
+优点:
+- 正文照常可读(标记段前端剥离后不显示)
+- 文件清单是结构化 JSON,**不需要猜**
+- 兼容现状:没有标记时回落到「从正文提取」
+- 可带 `size`,解决「大小未知」问题
+
+**这个方案需要你拍板后我再实现**,因为它涉及容器侧再改一次输出格式。

+ 294 - 0
docs/02-design/architecture/当前业务逻辑总览.md

@@ -0,0 +1,294 @@
+# 当前业务逻辑总览
+
+| 字段 | 值 |
+|---|---|
+| 版本 | v1.0 |
+| 最后更新日期 | 2026-11-09 |
+| 状态 | **生效** —— 描述「现在实际怎么跑」,不是「计划怎么做」 |
+| 事实来源 | 逐条对照源码核实;凡未验证的标 ⚠️ |
+
+> **本文的定位**:`操作追踪.md` 是流水账(7215 行),`PRD/SPEC` 是需求。
+> 本文回答一个问题:**这个项目现在到底是怎么跑起来的?**
+> 改了代码请同步更新本文(黄金规则 4)。
+
+---
+
+## 一、整体架构
+
+```
+浏览器
+  │
+  ├─ 端分流:入口页一次性判定(UA + pointer:coarse + 宽度)
+  │   → /desktop/** 或 /mobile/**,之后不再变
+  │
+  ├─ 前端(Angular 20 SPA,无自建后端)
+  │    core/api/ 抽象层 → parse-api(真实) / mock-api(演示)
+  │
+  ├─ Matrix(matrix.fmode.cn)
+  │    消息正文 + 附件,收发都走这里
+  │
+  ├─ fmode 云函数(server.fmode.cn/api/functions)
+  │    鉴权、容器状态、会话目录、成果、S3 签名
+  │
+  └─ S3 / OBS(s3.fmode.cn)
+       文件副本 + 云盘长期查看,不参与收发
+```
+
+### 三方分工(不要混)
+
+| 系统 | 管什么 | 不管什么 |
+|---|---|---|
+| **Matrix** | 消息、附件、线程 | 业务元数据 |
+| **云函数** | 容器状态、会话目录、成果记录、签名 | 不存文件内容 |
+| **S3** | 文件副本、公网下载 | 不参与消息投递 |
+
+---
+
+## 二、会话域(核心链路)
+
+### 2.1 新建会话
+
+```
+用户点「新建对话」
+  → ChatService.startBlank()
+  → 本地生成空会话(id = $approot_<时间36>_<随机8>:fmode.cn)
+  → 不落库 ← 🔴 关键:此时云端没有任何记录
+  → 左侧立刻可见并选中
+```
+
+**为什么先不落库**:点了又反悔/切走不会在云端留空壳。
+空壳靠 `pruneEmpty()` 清理(全局最多留一条空会话)。
+
+### 2.2 首次发消息(强顺序)
+
+```
+用户点发送
+  → 插入乐观消息(id = local-<ts>-<rand>,state=sending)
+  → 【必须先成功】commitNow() 落库到 FmodeChatThread
+        ↓ 失败 → 消息标红「发送失败」,不往容器发,可重试
+        ↓ 成功
+  → 附件:先 Matrix 发原生事件,再额外 PUT 一份到 S3(S3 失败不阻断)
+  → tryRelay():是否为「去问另一个容器」的旁路指令
+  → 否则 sendReal() → MatrixClientService.sendText()
+  → 事件写入房间,带 com.fmode.dispatch / localId / threadId
+```
+
+> 🔴 **2026-11-09 用户要求**:落库成功才发送。原实现是「发出去就不管,
+> 落库在后台跑」,失败会出现「消息已进 Matrix 但目录没有这条记录」。
+
+### 2.3 回读与归属
+
+```
+Matrix /sync 事件
+  → appendIncoming()
+  → belongsToSession() 判定归属:
+       ① threadId === 本会话线程根
+       ② 或带 com.fmode.dispatch 标记指向本会话
+  → 自己的 echo 按 localId 认领,替换乐观消息(不重复)
+```
+
+### 2.4 回合聚合
+
+```
+buildTurns()         以「用户消息」为锚点切回合
+  ├─ 静默 25 秒(QUIET_MS)→ 回合关闭
+  ├─ typing 状态参与判定
+  └─ 容器明确报 idle → 立即收尾
+      ↓
+turnsFromDm()        转成视图模型(单聊固定 1 块)
+      ↓
+app-assistant-turn   渲染成一张卡(过程折叠 + 正文合并 + 文件宫格)
+```
+
+### 2.5 关键函数速查
+
+| 函数 | 文件 | 职责 |
+|---|---|---|
+| `startBlank()` | `core/chat.service.ts` | 建空会话,不落库 |
+| `commitNow()` | `core/chat-session.service.ts` | 强顺序落库(await) |
+| `sendText()` | `core/chat.service.ts` | 发送主流程 |
+| `appendIncoming()` | `core/chat.service.ts` | 实时消息入口 |
+| `buildTurns()` | `core/turn-aggregator.ts` | 回合切分 |
+| `pruneEmpty()` | `core/chat-session.service.ts` | 清理空壳 |
+
+---
+
+## 三、群聊(伪群聊)
+
+### 3.1 它不是真的群
+
+> 🔴 **每个成员容器仍只在自己的 DM 房间里工作**,互相看不到对方的对话。
+> App 负责把一条消息分别发到 N 个房间,再把回复按容器归集。
+
+这样做的好处:不会抢答、不会回环、不用改容器。
+
+### 3.2 建群
+
+```
+GroupChatService.createGroup()
+  → 要求 ≥2 个已接入 Matrix 的容器
+  → roomId 借用首成员的房间(仅占位)
+  → kind='group'、memberIds 立即落库
+```
+
+### 3.3 发送
+
+```
+GroupSendService.send()
+  → plan()(group-mentions.ts):校验 @
+       没 @ → 拦下并提示
+       @ 了非成员 → 拦下并标红
+  → 逐成员**串行**发(并发会触发 connectSession 互相断开)
+       每个成员:先附件,后文字
+       记录 DispatchAnchor(发送前该房间最后一条事件 id)
+```
+
+### 3.4 回复归集
+
+```
+GroupLiveService 监听各成员房间
+  → 按 baselineEventId 判断「这是本轮之后的新回复」
+  → appendReply(turnId)
+      ↓
+buildGroupTurns()  按 fromAgentId 分块
+      ↓
+app-assistant-turn  每个容器一块,段首标容器名
+```
+
+### 3.5 已知死路
+
+真群聊(工作组,路由 `/desktop/groups`)**已于 2026-10-29 下线**,
+源码保留未删。原因:多容器协作依赖容器侧配置,当前环境不成立。
+
+---
+
+## 四、状态体系
+
+### 4.1 三个入口,不要混用
+
+| 函数 | 用在哪 | 判据 |
+|---|---|---|
+| `resolveAgentState()` | 左栏、通讯录、移动端、新建弹窗 | 离线/异常 > 回复 > 思考 > 执行 > 空闲 |
+| `agentLayers()` | 工作台 | 三层:alive / link / busy |
+| `TaskPulseService` | 会话顶栏任务行 | 用户动作优先,容器 idle 才解除 |
+
+### 4.2 心跳阈值(实测值)
+
+| 阈值 | 值 | 含义 |
+|---|---|---|
+| `HEARTBEAT_STALE_SEC` | **80 分钟** | 超过 → 状态未同步 |
+| `ALIVE_DEAD_SEC` | **120 分钟** | 超过 → 已离线 |
+
+> 🔴 容器上报周期是 **1 小时 + 0~15 分钟抖动**(用户实测确认)。
+> 网上的「30 秒心跳」说法已过期,不要照抄。
+
+### 4.3 上报器自指过滤
+
+容器 reporter 会把自己「上报状态」当成 running 上报。
+`isReporterSelfTalk()` 负责识别并降级成空闲,否则永远显示「正在执行」。
+
+---
+
+## 五、文件与成果
+
+### 5.1 用户上传的文件
+
+```
+选文件 → stageFiles()(只暂存,不上传)
+  → 点发送 → uploadPending()
+      ① Matrix:sendFile() → mxc:// 原生事件(容器从这里读)
+      ② S3:api.uploadFile() → publicUrl(App 长期查看用)
+         ⚠️ S3 失败不阻断,消息上如实提示
+```
+
+> 🔴 **必须两边都有**:
+> - 只走 Matrix → 换设备/清缓存后可访问性依赖 Matrix
+> - 只走 S3 → 容器收不到(它只认 `mxc://`,实测会回「没看到附件」)
+
+### 5.2 容器产出的成果
+
+设计上的链路:
+
+```
+App 发现附件只有容器路径(source)、没有 url
+  → ArtifactSyncService → enqueueArtifact
+  → 云函数写入 FmodeArtifactJob 队列
+  → 容器轮询 pendingArtifacts → PUT 到 S3 → ackArtifact
+  → 云函数回写 artifacts[].url
+```
+
+**🔴 当前实际状态(2026-11-09 实测):这条链路的消费方不存在**
+
+```
+$ ls /opt/data/.../artifact-drain.py
+python3: can't open file ...: [Errno 2] No such file or directory
+```
+
+| 项 | 状态 |
+|---|---|
+| 前端入队 | ✅ 正常 |
+| 云函数队列 | ✅ 正常(`pendingArtifacts` 返回 200) |
+| **容器消费** | ❌ **脚本不存在** |
+| 结果 | 成果文件留在容器本地,App 拿不到 |
+
+**2026-11-09 已下发改造指令让容器自主部署**,见
+《容器侧改造-文件与成果统一上云-20261109.md》。
+
+### 5.3 成果按会话归档
+
+```
+FmodeChatThread.deliverableUrls: string[]
+  ↓
+右栏「成果」= bySessionUrls(deliverableUrls)
+  ↓
+没有 urls → 显示空,**不回退到整个容器**
+```
+
+> 设计原则:**宁缺勿错**。宁可少显示,也不把别的会话的成果串进来。
+
+---
+
+## 六、云函数清单
+
+| objectId | 职责 | 表 |
+|---|---|---|
+| `xlosi3m46q` | 容器状态、心跳、余额 | `FmodeAgent` |
+| `oR5O67i1Cm` | 会话目录(list/upsert/patch/remove) | `FmodeChatThread`、`FmodeWorkspace` |
+| `lfYlgU7SkK` | 成果列表 + 搬运队列 | `Deliverable`、`FmodeArtifactJob` |
+| `AlP56LCKFm` | S3 预签名 | — |
+| `RP5Ht55NeX` | Matrix 凭据签发 | — |
+| `AqXGNNVRNb` | 容器重命名 | — |
+| `V1EBgm05y2` | 工作组(**已下线**) | `FmodeWorkGroup` |
+
+**调用协议**:
+
+```http
+POST https://server.fmode.cn/api/functions
+Content-Type: application/json
+X-Parse-Application-Id: ncloudmaster
+
+{ "id": "<objectId>", "token": "<sessionToken>", "params": {...} }
+```
+
+---
+
+## 七、已知问题与限制(如实记录)
+
+| # | 问题 | 状态 |
+|---|---|---|
+| 1 | 容器成果搬运脚本缺失 → 文件丢失 | 改造指令已下发,待验证 |
+| 2 | 任务看板云函数接收端未实现 | 页面落空态,用户裁决挂起 |
+| 3 | `includeDeleted: true` 返回的是「只含已删」而非「活跃+已删」 | 已在代码里按实测语义处理 |
+| 4 | 容器把 `status=running` 写死 | 前端有兜底,根治需容器改 |
+| 5 | `messageCount` 双写导致虚高 | 未修,属数据模型变更 |
+| 6 | `API-契约.md` 里 OBS 直传的描述与现有代码相反 | **已过期**,以本文为准 |
+
+---
+
+## 八、关联文档
+
+- 需求与验收:`docs/01-requirements/prd/`
+- 需求-实现对照:`docs/02-design/architecture/需求-实现对照表.md`
+- 接口契约:`docs/03-interface/api/API-契约.md`(⚠️ 部分过期)
+- 容器接入:`docs/03-interface/容器接入上线手册-20261020.md`
+- 变更流水:`docs/00-tracking/操作追踪.md`

+ 127 - 0
docs/02-design/architecture/需求-实现对照表.md

@@ -0,0 +1,127 @@
+# 需求 - 实现对照表
+
+| 字段 | 值 |
+|---|---|
+| 版本 | v1.0 |
+| 最后更新日期 | 2026-11-09 |
+| 状态 | 生效 |
+| 用途 | 一条需求 → 在哪实现的 → 有没有测 → 现在什么状态 |
+
+> **阅读方式**:想知道「某个功能在哪」查这里;想改代码先看「状态」列有没有坑。
+> **维护规则**:新增需求或改动实现,同步更新本表(黄金规则 4)。
+
+**状态图例**:✅ 已完成并验证 · 🟡 已完成未验证 · 🔴 有已知问题 · ⏸️ 挂起
+
+---
+
+## 一、功能需求(FR)
+
+| 编号 | 需求 | 实现位置 | 测试 | 状态 |
+|---|---|---|---|---|
+| FR-01 | 端分流与导航 | `core/device.service.ts` · `app.routes.ts` | `device.service.spec.ts` | ✅ |
+| FR-02 | 登录与鉴权 | `pages/login/` · `mobile/pages/login/` · `core/session.service.ts` | `session.service.spec.ts` | 🟡 AC-02.4 依赖服务端 |
+| FR-03 | 通讯录 | `desktop/pages/contacts/` · `mobile/pages/contacts/` | `contacts.service.spec.ts` | ✅ |
+| FR-04 | 会话(IM 中枢) | `core/chat.service.ts` · `desktop/pages/chat/` · `mobile/pages/chat/` | `chat.service.spec.ts`(60 条) | ✅ |
+| FR-05 | 项目 | `desktop/pages/projects/` · `mobile/pages/projects/` | `projects.service.spec.ts` | ✅ |
+| FR-06 | 事件 | `desktop/pages/events/` · `mobile/pages/events/` | — | 🟡 纯派生,未单测 |
+| FR-07 | 工作台 | `desktop/pages/workbench/` · `mobile/pages/workbench/` | `live.service.spec.ts` | ✅ |
+| FR-07b | 任务看板 | `desktop/pages/tasks/` · `core/tasks.service.ts` | `tasks.service.spec.ts` | ⏸️ **云函数未实现,用户裁决挂起** |
+| FR-08 | 成果 | `desktop/pages/deliverables/` · `core/deliverables.service.ts` | `deliverables.service.spec.ts`(19 条) | 🔴 **文件上云链路中断**(见 §五) |
+| FR-09 | 数字生命详情 | `desktop/pages/agent/` · `mobile/pages/agent/` | — | 🟡 仅支持重命名 |
+| FR-10 | 我的 | `desktop/pages/mine/` · `mobile/pages/mine/` | — | ✅ |
+| FR-11 | 语音通话 | — | — | ⏸️ 增项,暂不实现 |
+| FR-12 | UI 规范符合性 | `ui-kit` + `verify-ui.mjs` | `npm run verify:ui` | ✅ 11/12 |
+| FR-13 | 无障碍基线 | 全局 | `verify:ui` | ✅ |
+| FR-14 | 单一事实源与轮询收敛 | `core/live.service.ts`(唯一轮询器) | `live.service.spec.ts` | ✅ |
+| FR-15 | 代码洁净度 | `verify-ui.mjs` | 同上 | ✅ |
+| FR-16 | 旧版替换迁移 | `app.routes.ts` 的 `normalize()` | `device.service.spec.ts` | ✅ |
+| FR-17 | 三类画像信息架构 | 双端布局 | `smoke` | ✅ |
+| FR-18 | 范围冻结声明 | — | — | ✅ 治理条款 |
+| FR-19 | 移动端聚合首页 | `mobile/pages/home/` | — | ✅ |
+| FR-20 | 超管可见范围 | `core/acl.ts` · `mobile/pages/admin/` | `acl.spec.ts` | 🟡 5 项管理能力未开放 |
+| **FR-19(群聊)** | 群聊·多路派活 | `desktop/pages/group-chat/` · `core/group-*.ts` | `group-*.spec.ts` | 🟡 见 §五 |
+
+---
+
+## 二、本轮(2026-11-09)实现的关键逻辑
+
+| 逻辑 | 需求来源 | 实现 | 测试 | 状态 |
+|---|---|---|---|---|
+| 首次发消息**先落库再发送** | 用户要求 | `chat.service.ts` `sendText` + `chat-session.service.ts` `commitNow` | `chat.service.spec.ts` 新增 2 条 | ✅ |
+| 新建会话不落库 | 2026-11-01 裁决 | `startBlank()` | 同上 | ✅ |
+| 空会话最多一条 | 同上 | `pruneEmpty()` | 同上 | ✅ |
+| 已删会话列表隐藏 + URL 不可直达 | 2026-11-09 裁决 | `openReal()` 三重条件 | 既有用例 | ✅ |
+| 成果按会话归档 | 2026-11-09 裁决 | `ChatThread.deliverableUrls` + `bySessionUrls()` | `deliverables.service.spec.ts` 2 条 | 🟡 等容器产出数据 |
+| 单聊上传 Matrix + S3 双写 | 2026-11-09 裁决 | `uploadPending()` | 未单测 | 🟡 待实测 |
+| AI 回复排版归一化 | 2026-11-09 用户反馈 | `normalize-md.ts` | `normalize-md.spec.ts`(17 条) | ✅ |
+| 未上云成果如实提示 | 2026-11-09 | `session-rail.ts` `notOnCloudCount` | 未单测 | ✅ |
+| 回合每秒时钟 | 2026-11-09 | `desktop/mobile chat.ts` `syncClock` | 未单测 | ✅ |
+| 读秒锚点不跳变 | 2026-11-09 | `appendIncoming` 复用 `localTs` | — | ✅ |
+| 状态口径统一 | 2026-11-09 | 全部走 `resolveAgentState` | `agent-state.spec.ts` | ✅ |
+
+---
+
+## 三、非功能需求(NFR)
+
+| 编号 | 类别 | 要求 | 实现 | 状态 |
+|---|---|---|---|---|
+| NFR-01 | 性能 | 路由懒加载,Matrix SDK 不进主包 | `loadComponent` + 动态 import | ✅ |
+| NFR-02 | 安全 | 前端零服务端密钥 | 签名全走云函数 | ✅ |
+| NFR-03 | 安全 | 租户隔离由服务端权威 | 依赖 `zl4yU8PAEB` | 🟡 服务端未改造 |
+| NFR-04 | 兼容 | 桌面 1440×900 / 移动 390×844 | 双端布局规范 | ✅ |
+| NFR-05 | 可测 | 单测 + 双端冒烟 | Karma + Playwright | ✅ 1398 条 |
+| NFR-06 | 可追踪 | 文档全链 | `docs/` 五层 | ✅ |
+
+---
+
+## 四、业务规则(PRD §六)
+
+| # | 规则 | 实现位置 | 状态 |
+|---|---|---|---|
+| R1 | 端分流一次性判定 | `device.service.ts` | ✅ |
+| R2 | 未接入容器点行进**详情**而非会话 | `core/agent-route.service.ts`(单点实现) | ✅ |
+| R3 | 单一事实源:每个数据域一个 owner | `live` / `contacts` / `deliverables` / `tasks` 各一个 | ✅ |
+| R4 | 租户归属由服务端判定 | 依赖云函数 | 🟡 |
+| R5 | 任务看板一期只读 | `tasks.ts` 有只读锁标 | ✅ |
+| R6 | 业务表带 `company`,删除用软删 | 云函数侧 | ✅ |
+| **R7** | **成果按会话存放** | `ChatThread.deliverableUrls` | 🟡 新增 |
+
+---
+
+## 五、已知问题清单(改代码前必看)
+
+| # | 问题 | 影响 | 位置 | 处置 |
+|---|---|---|---|---|
+| 1 | **容器成果搬运脚本缺失** | 容器产出的文件全部留在本地,App 拿不到 | 容器侧 | 2026-11-09 已下发改造指令 |
+| 2 | 任务看板云函数未实现 | `fetchTasks` 直接抛错,页面落空态 | `parse-api.ts` | 用户裁决挂起 |
+| 3 | 容器把 `status=running` 写死 | 永远显示「正在执行」 | 容器 reporter | 前端 `isReporterSelfTalk` 兜底 |
+| 4 | `messageCount` 双写虚高 | 左栏消息数偏大 | `chat.service.ts` | 未修 |
+| 5 | `includeDeleted` 语义反直觉 | 返回的是「只含已删」 | 云函数 | 已按实测语义处理 |
+| 6 | `API-契约.md` 部分过期 | OBS 直传描述与代码相反 | 文档 | 以《当前业务逻辑总览》为准 |
+| 7 | 真群聊(工作组)已下线 | `/desktop/groups` 不可用 | `desktop.routes.ts` 注释 | 源码保留 |
+
+---
+
+## 六、测试覆盖现状
+
+| 测试入口 | 命令 | 规模 | 覆盖什么 |
+|---|---|---|---|
+| 单元测试 | `npm test` | **1398 条** | 服务层逻辑、纯函数 |
+| 双端冒烟 | `npm run build:mock && npm run smoke` | 28 条 | 路由可达、页面结构 |
+| 群聊冒烟 | `DEV_TOKEN=... npm run smoke:groups` | 17 条(1 SKIP) | 群聊交互 |
+| UI 规范 | `npm run verify:ui` | 12 项 | 令牌、触达区 |
+| 文档完整性 | `npm run verify:docs` | — | 文档结构 |
+
+**未覆盖**:
+- `matrix-client.service.ts`(与 SDK 强耦合)
+- `events.service`、`palette.service`
+- 真实容器交互(只能手动测)
+
+---
+
+## 七、关联文档
+
+- 逻辑总览:`docs/02-design/architecture/当前业务逻辑总览.md`
+- 需求来源:`docs/01-requirements/prd/PRD-20261014-fmode-app-v2.md`
+- 验收标准:`docs/01-requirements/prd/SPEC-20261014-fmode-app-v2-草案.md`
+- 变更流水:`docs/00-tracking/操作追踪.md`

+ 157 - 0
docs/03-interface/容器侧改造-文件与成果统一上云-20261109.md

@@ -0,0 +1,157 @@
+# 容器侧改造:文件与成果统一上云盘(2026-11-09)
+
+> **给容器操作者/维护者的执行清单。**
+> 本文件是「交给容器」的完整任务书,可直接下发。
+
+---
+
+## 一、为什么要改(实测结论)
+
+2026-11-09 在 `agent-node-app-lj-test-v1` 容器上实际执行命令核实:
+
+```bash
+$ ls /opt/data/git-repos/agent-status-reporter/scripts/artifact-drain.py
+python3: can't open file '...': [Errno 2] No such file or directory
+
+$ grep "artifact" <jobs.json>
+exit=1
+```
+
+| 结论 | 影响 |
+|---|---|
+| 成果搬运脚本**不存在** | 容器产出的文件全部留在 `/opt/data/`,App 下载不到 |
+| 定时任务里**没有** artifact 相关项 | 即使脚本存在也不会被执行 |
+| 只有 `fmode-status-report.sh` 在跑 | 它只上报状态,不上传文件 |
+
+**历史**:2026-10-21 这条链路是跑通过的(当时 curl 回读 3689 字节,内容正确)。
+11 月容器重部署时漏掉了这一步 —— 接入文档只提了状态上报。
+
+---
+
+## 二、目标流程(用户明确要求)
+
+```
+用户上传文件
+   ↓
+容器存本地 → 读取 → 处理          ← 第一次读取可以走本地
+   ↓
+【必须】上传云盘,拿到公网链接
+   ↓
+App 之后所有查看 / 下载 / 成果展示 → 一律走云盘
+```
+
+**三条硬要求**:
+
+1. 用户文件:存本地读取没问题,但**处理完必须上云**
+2. 成果文件:**先上云,再把链接返回给 App**
+3. 成果链接要能对应到**具体是哪条对话**产出的
+
+---
+
+## 三、改造任务(4 项)
+
+### 任务 1 · 部署成果搬运脚本
+
+**取代码**:`docs/03-interface/容器接入上线手册-20261020.md:384`(Python 参考实现)
+
+**部署位置**:
+```
+/opt/data/git-repos/agent-status-reporter/scripts/artifact-drain.py
+```
+
+**定时任务**(Hermes cron,每 3 分钟):
+```
+name: fmode-artifact-drain
+script: artifact-drain.py
+schedule: */3 * * * *
+enabled: true
+```
+
+**验证**:
+```bash
+ls -la /opt/data/git-repos/agent-status-reporter/scripts/artifact-drain.py
+python3 /opt/data/git-repos/agent-status-reporter/scripts/artifact-drain.py
+# 期望:[drain] pending 0(或 pending N → OK)
+```
+
+### 任务 2 · 成果产出后主动入队
+
+**现状**:只有 App 打开并加载成果列表时才入队(`ArtifactSyncService.syncPending`)。
+容器处理完不再看 App 的话,文件就一直没人搬运。
+
+**改造**:容器产出一个文件后,**主动调用云函数把该文件入队**:
+```
+云函数: lfYlgU7SkK
+action: enqueueArtifact
+参数:   deliverableId / agentId / source(容器内路径) / filename / index
+```
+
+**效果**:不等 App,容器自己把事情做完。
+
+### 任务 3 · 用户上传的文件处理完也上云
+
+**现状**:用户文件只在 Matrix 媒体库,容器读完后没有上云动作。
+
+**改造**:处理完成后,把该文件(或处理后的产物)上传云盘,并登记到对应会话。
+
+### 任务 4 · 成果链接写进会话记录
+
+**这是「成果按会话隔离」的关键。**
+
+**你的实现方式**(用户已明确):
+> 每个会话在数据库里都有一条记录,把这条会话成果的**云盘可访问链接**放进一个数组里,
+> 查询时读出来即可。
+
+**对应到本项目的字段**:
+
+```json
+// FmodeChatThread 记录(会话记录)
+{
+  "threadRoot": "$approot_xxx:fmode.cn",   // 会话 id
+  "agentId": "agent-node-xxx",
+  "deliverableUrls": [                      // ← 需要容器/云函数写入
+    "https://s3.fmode.cn/deliverables/xxx/a.md",
+    "https://s3.fmode.cn/deliverables/xxx/b.xlsx"
+  ]
+}
+```
+
+**容器侧要做的事**:上传成功后,把公网链接追加到「本次对话」对应的会话记录的
+`deliverableUrls` 数组。
+
+容器本地 `state.db` 里**已经有** `thread_id`(容器自述确认),只需要在上传成功后
+把它和链接一起写回去。
+
+---
+
+## 四、验收标准
+
+| # | 检查 | 命令/方式 | 通过标准 |
+|---|---|---|---|
+| 1 | 脚本存在 | `ls -la .../artifact-drain.py` | 文件存在 |
+| 2 | 定时任务在跑 | `grep artifact <jobs.json>` | 有 `fmode-artifact-drain` |
+| 3 | 脚本不报错 | `python3 artifact-drain.py` | 退出码 0 |
+| 4 | 成果能上云 | 产出一个成果 → 等 3 分钟 → 查成果记录 | `artifacts[].url` 非空 |
+| 5 | **链接可下载** | `curl -I <url>` | **HTTP 200** |
+| 6 | 会话记录有链接 | 查 `FmodeChatThread.deliverableUrls` | 数组非空,且含该成果链接 |
+
+**第 5 项是唯一有效的验收** —— 只看「url 字段有值」不算,必须实际能下载。
+2026-10-21 那次就是靠 curl 回读 3689 字节确认的。
+
+---
+
+## 五、完成后请回报
+
+```
+1. artifact-drain.py 的 ls 输出
+2. jobs.json 里 artifact 相关条目
+3. 任意一个成果文件的公网 URL(我来 curl 验证)
+4. 该成果所属会话的 threadRoot
+```
+
+---
+
+## 六、不在本次范围
+
+- 不改 Matrix 原生附件的发送方式(容器仍从 Matrix 读用户文件)
+- 不需要容器持有 AK/SK(签名由云函数下发)

+ 31 - 0
docs/README.md

@@ -11,6 +11,31 @@
 
 ---
 
+## 🚀 快速入口(新人 / AI 从这里开始)
+
+**想快速知道「这个项目现在怎么跑」,只看这两份:**
+
+| 我想知道… | 看这份 |
+|---|---|
+| 项目现在到底怎么运作的 | **[当前业务逻辑总览](02-design/architecture/当前业务逻辑总览.md)** |
+| 某条需求在哪实现的、有没有坑 | **[需求-实现对照表](02-design/architecture/需求-实现对照表.md)** |
+
+> 🔴 `操作追踪.md` 是 7000+ 行的流水账,**不要从那里开始读** —— 它是「怎么变成现在这样」的历史,不是「现在是什么样」。
+
+**动手改代码前必看**:《需求-实现对照表》§五「已知问题清单」。
+
+---
+
+## ⚠️ 已知过期文档(以《当前业务逻辑总览》为准)
+
+| 文档 | 过期的部分 |
+|---|---|
+| `03-interface/api/API-契约.md` §2.5 | 写的是「OBS 直传」,与现有单聊代码相反(单聊走 Matrix + S3 双写) |
+| 各处提到「容器 reporter 每 30 秒心跳」 | 实际是 **1 小时 + 0~15 分钟抖动** |
+| `04-quality/追溯矩阵` | 状态列停留在「设计就绪」,未随编码更新 |
+
+---
+
 ## 当前阶段
 
 **阶段② 设计建模 + 设计系统**(规范包阶段视图 2/4,加载 7 个模块)
@@ -38,6 +63,10 @@
 | [2026-11-08-踩坑-会话隔离与状态判定.md](00-tracking/2026-11-08-踩坑-会话隔离与状态判定.md) | 生效 | v1.0 · 两次误判的复盘 + 遗留 P0/P1/P2 清单(明日处理) |
 | [2026-11-09-修改留痕-LoopGuard与会话去重.md](00-tracking/2026-11-09-修改留痕-LoopGuard与会话去重.md) | 生效 | v1.0 · LoopGuard 按会话隔离 + 乐观消息回读认领 + 等待文案兜底 + 容器只读核查 |
 | [2026-11-09-踩坑-乐观消息回读认领.md](00-tracking/2026-11-09-踩坑-乐观消息回读认领.md) | 生效 | v1.0 · 「乐观 UI 必须有回读认领」+ 合并策略必须统一 |
+| [2026-11-09-修改留痕-会话域实测与同步.md](00-tracking/2026-11-09-修改留痕-会话域实测与同步.md) | 生效 | v1.0 · 移动端首页/会话切换修复 + 截断修复 + 状态口径统一 |
+| [2026-11-09-修改留痕-排版与数据清理.md](00-tracking/2026-11-09-修改留痕-排版与数据清理.md) | 生效 | v1.0 · AI 回复排版归一化(4 种形态)+ 74 条会话软删 + 墓碑拦截 |
+| [2026-11-09-修改留痕-成果按会话归档与文件上云.md](00-tracking/2026-11-09-修改留痕-成果按会话归档与文件上云.md) | 🔴 生效 | v1.0 · **发现容器成果文件全部丢失** + 成果按会话归档 + 单聊 Matrix/S3 双写 |
+| [2026-11-09-修改留痕-项目逻辑文档补齐.md](00-tracking/2026-11-09-修改留痕-项目逻辑文档补齐.md) | 生效 | v1.0 · 新增逻辑总览 + 需求对照表,索引重排 |
 
 ### 01-requirements/ 需求文档
 | 文档 | 状态 | 说明 |
@@ -55,6 +84,8 @@
 ### 02-design/ 设计文档
 | 文档 | 状态 | 说明 |
 |---|---|---|
+| [architecture/当前业务逻辑总览.md](02-design/architecture/当前业务逻辑总览.md) | 🔴 **v1.0 · 必读** | 五条主线的真实链路(会话/群聊/状态/文件成果/云函数)+ 已知问题 |
+| [architecture/需求-实现对照表.md](02-design/architecture/需求-实现对照表.md) | 🔴 **v1.0 · 必读** | 21 条 FR + 6 条 NFR + 业务规则 → 代码位置 → 测试 → 状态 |
 | [plans/2026-10-14-v2实施计划.md](02-design/plans/2026-10-14-v2实施计划.md) | 🔴 **生效(编码基线)** | 阶段②收尾 → 阶段③ 四层推进 + 10 个滚动里程碑 + 验证体系 |
 | [plans/2026-10-19-账号账套改造计划.md](02-design/plans/2026-10-19-账号账套改造计划.md) | 🔴 **生效(待开工)** | **规范模板六要素版**:16 个原子任务分 4 Wave(云函数 / 前端判级 / 多账套 / 数据核对)+ 5 道门禁 + 三身份验收 + 安全检查清单 |
 | [plans/2026-10-19-数据层治理与新容器接入.md](02-design/plans/2026-10-19-数据层治理与新容器接入.md) | 🔴 **生效(待开工)** | **主计划补充卷**:`FmodeAgent.company` 空洞精确构成(33 个写侧漏写 / 6 个用户无归属)+ 云函数 6 项优化 C1~C6 + 新容器接入优化 + 容器提示词 |

+ 135 - 0
scripts/deploy-cloud-fn.mjs

@@ -0,0 +1,135 @@
+#!/usr/bin/env node
+/**
+ * 云函数热更脚本(本地使用,不部署、不上传)
+ *
+ * ## 用途
+ *
+ * 把 `cloud/functions/*.js` 的最新源码推到线上 Parse 的 `Function` 表。
+ * 热更免重启(`PUT /parse/classes/Function/<objectId>`)。
+ *
+ * ## 为什么需要 Master-Key
+ *
+ * 写 `Function` 表属于平台管理操作,只有 Master-Key 有权限。
+ * 🔴 **Master-Key 绝不能写进代码或提交进 git**,只能通过环境变量传入:
+ *
+ * ```bash
+ * PARSE_MASTER_KEY=xxx node scripts/deploy-cloud-fn.mjs
+ * PARSE_MASTER_KEY=xxx node scripts/deploy-cloud-fn.mjs --dry-run
+ * ```
+ *
+ * ## 安全
+ *
+ * · 默认 **dry-run**:只打印将要更新什么,不发请求
+ * · `--apply` 才真正 PUT
+ * · 不发请求前先 GET 一次,对比线上版本,避免误覆盖别人改过的代码
+ *
+ * ## 频率限制
+ *
+ * 实测:`PUT` 连续请求会返回 403,需退避重试(见操作追踪 :3125)。
+ */
+import { readFileSync } 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 APP_ID = 'ncloudmaster';
+const BASE = 'https://server.fmode.cn/parse';
+const MK = process.env.PARSE_MASTER_KEY;
+const APPLY = process.argv.includes('--apply');
+
+/** 要部署的函数清单 */
+const TARGETS = [
+  {
+    file: 'cloud/functions/fmodeagent-chatthreads.js',
+    id: 'oR5O67i1Cm',
+    name: 'fmodeagent-chatthreads',
+    version: '0.1.9',
+  },
+];
+
+function authHeaders() {
+  return {
+    'X-Parse-Application-Id': APP_ID,
+    'X-Parse-Master-Key': MK,
+    'Content-Type': 'application/json',
+  };
+}
+
+async function getRemote(id) {
+  const res = await fetch(`${BASE}/classes/Function/${id}`, { headers: authHeaders() });
+  if (!res.ok) throw new Error(`读取失败 HTTP ${res.status}`);
+  return res.json();
+}
+
+/** PUT 有频率限制,403 时退避重试 */
+async function putRemote(id, body, attempt = 1) {
+  const res = await fetch(`${BASE}/classes/Function/${id}`, {
+    method: 'PUT',
+    headers: authHeaders(),
+    body: JSON.stringify(body),
+  });
+  if (res.status === 403 && attempt <= 4) {
+    const wait = 1000 * attempt;
+    console.log(`  ⚠️ 403(频率限制),${wait}ms 后重试 ${attempt}/4`);
+    await new Promise((r) => setTimeout(r, wait));
+    return putRemote(id, body, attempt + 1);
+  }
+  const text = await res.text();
+  if (!res.ok) throw new Error(`PUT 失败 HTTP ${res.status}: ${text.slice(0, 200)}`);
+  try {
+    return JSON.parse(text);
+  } catch {
+    return { raw: text.slice(0, 200) };
+  }
+}
+
+async function main() {
+  if (!MK) {
+    console.error('❌ 缺少 PARSE_MASTER_KEY 环境变量');
+    console.error('   用法:PARSE_MASTER_KEY=xxx node scripts/deploy-cloud-fn.mjs [--apply]');
+    process.exit(2);
+  }
+
+  console.log(APPLY ? '🔴 APPLY:将热更线上云函数' : '🟡 DRY-RUN:只对比,不写入');
+
+  for (const t of TARGETS) {
+    const code = readFileSync(resolve(root, t.file), 'utf8');
+    console.log(`\n=== ${t.name} (${t.id}) ===`);
+    console.log(`  本地源码:${t.file},${code.length} 字符,v${t.version}`);
+
+    let remote;
+    try {
+      remote = await getRemote(t.id);
+    } catch (e) {
+      console.log(`  ⚠️ 读取线上失败:${e.message}(跳过对比)`);
+      remote = null;
+    }
+    if (remote) {
+      const rc = remote.code || '';
+      console.log(`  线上源码:${rc.length} 字符,v${remote.version ?? '?'}`);
+      if (rc === code) {
+        console.log('  ✅ 与线上完全一致,无需部署');
+        continue;
+      }
+      if (rc.length && code.length < rc.length * 0.7) {
+        console.log('  🔴 警告:本地源码明显小于线上,可能回退功能,已跳过');
+        continue;
+      }
+    }
+
+    if (!APPLY) {
+      console.log('  → 加 --apply 才会真正热更');
+      continue;
+    }
+
+    const r = await putRemote(t.id, { code, version: t.version, enabled: true });
+    console.log(`  ✅ 已热更:${JSON.stringify(r).slice(0, 120)}(免重启,立即生效)`);
+  }
+}
+
+main().catch((e) => {
+  console.error('❌ 执行失败:', e.message);
+  process.exit(1);
+});

+ 195 - 0
scripts/purge-chatthreads.mjs

@@ -0,0 +1,195 @@
+#!/usr/bin/env node
+/**
+ * 会话记录软删清理(一次性运维脚本)
+ *
+ * ## 用途
+ *
+ * 把当前账号名下的**会话目录记录**(`FmodeChatThread` 系列表)全部软删,
+ * 用于清理测试过程中堆出来的会话。
+ *
+ * ## 安全
+ *
+ * · 默认 **dry-run**:只列出将删除的记录,**不发任何写请求**
+ * · 必须显式传 `--apply` 才真正删除
+ * · 一律走 `op: 'remove'`(**软删**,`isDeleted=true`),不做物理删除
+ * · **不碰 Matrix 房间与消息** —— 只删目录索引(与 App 内「删会话」一致)
+ *
+ * ## 用法
+ *
+ * ```bash
+ * node scripts/purge-chatthreads.mjs            # 只列出
+ * node scripts/purge-chatthreads.mjs --apply    # 真的软删
+ * ```
+ *
+ * 凭据从 `.claude/dev-login.json` 读取,绝不打印。
+ */
+import { readFileSync, mkdirSync, writeFileSync } 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 FUNCTIONS_URL = 'https://server.fmode.cn/api/functions';
+/** 会话目录云函数(与 `parse-api.ts` 的 `FN.chatThreads` 同源) */
+const FN_CHAT_THREADS = 'oR5O67i1Cm';
+const FN_AGENT_STATUS = 'xlosi3m46q';
+
+const APPLY = process.argv.includes('--apply');
+/**
+ * 连群聊一起删(默认**不删**)。
+ *
+ * 🔴 2026-11-09 教训:用户说「删日常对话」,脚本却把群会话也删了。
+ *    现在默认只删 `kind !== 'group'` 的日常对话,群聊需要显式加
+ *    `--include-groups` 才会一起处理。
+ */
+const INCLUDE_GROUPS = process.argv.includes('--include-groups');
+
+function loadToken() {
+  const p = resolve(root, '.claude/dev-login.json');
+  try {
+    const t = JSON.parse(readFileSync(p, 'utf8'))?.token;
+    if (typeof t === 'string' && t.startsWith('r:')) return t;
+  } catch {
+    /* fallthrough */
+  }
+  console.error(`❌ 读不到会话 Token(期望 ${p} 的 token 且以 "r:" 开头)`);
+  process.exit(2);
+}
+
+const TOKEN = loadToken();
+
+async function callFn(id, params) {
+  const res = await fetch(FUNCTIONS_URL, {
+    method: 'POST',
+    headers: { 'Content-Type': 'application/json' },
+    body: JSON.stringify({ id, token: TOKEN, params }),
+  });
+  const text = await res.text();
+  try {
+    return JSON.parse(text);
+  } catch {
+    return { code: res.status, mess: text.slice(0, 200) };
+  }
+}
+
+/** 拉当前账号名下的全部容器 */
+async function listAgents() {
+  const d = await callFn(FN_AGENT_STATUS, { op: 'query', scope: 'mine' });
+  if (d.code !== 200) throw new Error(`容器列表读取失败: ${d.mess ?? d.code}`);
+  return (d.agents ?? []).map((a) => ({ id: a.objectId, name: a.name, agentId: a.agentId }));
+}
+
+/** 拉某容器下未删除的会话记录 */
+async function listThreads(agentId) {
+  const d = await callFn(FN_CHAT_THREADS, { op: 'list', agentId });
+  if (d.code !== 200) throw new Error(`会话目录读取失败(${agentId}): ${d.mess ?? d.code}`);
+  const all = (d.threads ?? []).filter((t) => !t.isDeleted);
+  // 群聊默认保护(用户的「日常对话」不包括群聊)
+  return INCLUDE_GROUPS ? all : all.filter((t) => t.kind !== 'group');
+}
+
+/** 该容器下被脚本跳过的群聊数量(仅用于报告) */
+async function countGroupsSkipped(agentId) {
+  const d = await callFn(FN_CHAT_THREADS, { op: 'list', agentId });
+  if (d.code !== 200) return 0;
+  return (d.threads ?? []).filter((t) => !t.isDeleted && t.kind === 'group').length;
+}
+
+async function main() {
+  console.log(APPLY ? '🔴 APPLY 模式:将真正执行软删' : '🟡 DRY-RUN:只列出,不删除');
+
+  const agents = await listAgents();
+  console.log(`\n当前账号下容器 ${agents.length} 个\n`);
+
+  const plan = [];
+  for (const a of agents) {
+    let threads = [];
+    try {
+      threads = await listThreads(a.agentId);
+    } catch (e) {
+      console.log(`  ⚠️ ${a.name} (${a.agentId}) 跳过:${e.message}`);
+      continue;
+    }
+    if (threads.length === 0) {
+      const g = await countGroupsSkipped(a.agentId).catch(() => 0);
+      if (g > 0) console.log(`  ${a.name.padEnd(28)} 跳过 ${g} 个群聊(未加 --include-groups)`);
+      continue;
+    }
+    console.log(`  ${a.name.padEnd(28)} ${String(threads.length).padStart(3)} 条`);
+    const g = INCLUDE_GROUPS ? 0 : await countGroupsSkipped(a.agentId).catch(() => 0);
+    if (g > 0) console.log(`  ${''.padEnd(28)} (另有 ${g} 个群聊被保护,未加 --include-groups)`);
+    for (const t of threads) {
+      plan.push({
+        agentId: a.agentId,
+        agentName: a.name,
+        threadRoot: t.threadRoot,
+        roomId: t.roomId,
+        objectId: t.objectId,
+        title: t.title ?? '',
+        messageCount: t.messageCount ?? 0,
+      });
+    }
+  }
+
+  console.log(`\n合计待软删:${plan.length} 条`);
+
+  const outDir = resolve(root, 'test-results/purge-chatthreads');
+  mkdirSync(outDir, { recursive: true });
+  const stamp = new Date().toISOString().replace(/[:.]/g, '-');
+  const dumpPath = resolve(outDir, `plan-${stamp}.json`);
+  writeFileSync(dumpPath, JSON.stringify(plan, null, 2), 'utf8');
+  console.log(`清单已落盘:${dumpPath}`);
+
+  if (plan.length === 0) {
+    console.log('没有需要清理的会话。');
+    return;
+  }
+  if (!APPLY) {
+    console.log('\n加 --apply 才会真正删除。');
+    return;
+  }
+
+  let ok = 0;
+  let fail = 0;
+  for (const [i, p] of plan.entries()) {
+    const r = await callFn(FN_CHAT_THREADS, {
+      op: 'remove',
+      threadRoot: p.threadRoot,
+      roomId: p.roomId,
+      agentId: p.agentId,
+    });
+    if (r.code === 200) {
+      ok++;
+      if ((i + 1) % 10 === 0 || i + 1 === plan.length) {
+        console.log(`  进度 ${i + 1}/${plan.length}`);
+      }
+    } else {
+      fail++;
+      console.log(`  ❌ ${p.objectId} ${p.threadRoot}: ${r.mess ?? r.code}`);
+    }
+  }
+
+  console.log(`\n软删完成:成功 ${ok} 条,失败 ${fail} 条`);
+
+  // 复核:重新拉一遍,确认列表已不含这些记录
+  let remain = 0;
+  for (const a of agents) {
+    try {
+      remain += (await listThreads(a.agentId)).length;
+    } catch {
+      /* 忽略 */
+    }
+  }
+  console.log(`复核:剩余未删除会话 ${remain} 条`);
+  writeFileSync(
+    resolve(outDir, `result-${stamp}.json`),
+    JSON.stringify({ total: plan.length, ok, fail, remain }, null, 2),
+    'utf8',
+  );
+}
+
+main().catch((e) => {
+  console.error('❌ 执行失败:', e.message);
+  process.exit(1);
+});

+ 13 - 1
src/app/components/domain/assistant-turn/assistant-turn.ts

@@ -115,6 +115,7 @@ import { MessageBubbleComponent } from '../message-bubble/message-bubble';
               <app-message-bubble
                 [message]="msg"
                 [hideProcess]="true"
+                [showFile]="false"
                 (openTask)="openTask.emit($event)"
                 (preview)="preview.emit($event)"
               />
@@ -683,7 +684,18 @@ export class AssistantTurnComponent implements OnDestroy {
          * 就当成「已经显示完」直接渲染全文 —— 流式因此失效。
          * 取不到进度时按 0 处理,让 rAF 一帧帧补上来。
          */
-        const n = shown[b.key] ?? 0;
+        /*
+         * 🔴 已结束的块**直接全文渲染**,不参与流式截断(2026-11-09,修截断)。
+         *
+         * 根因:`shown` 的键是**块 key**。容器一轮回复分多条事件到达,
+         * `mergeConsecutiveBodies` 在合并时可能重建块 → key 变了 →
+         * 新 key 在新 map 里是 `undefined` → 取 0 → `slice(0, 0)` **整段清空**。
+         * 实测正文长度从 574 掉到 565,用户看到「后面的内容被吃掉了」。
+         *
+         * 流式的意义只在于「还在生成」;一旦 `!b.live`(本块已结束),
+         * 内容已是最终态,再做截断只会丢内容,没有任何收益。
+         */
+        const n = b.live ? (shown[b.key] ?? 0) : src.text.length;
         /*
          * 🔴 差距过大时直接追赶,不做长距离「逐字补」(2026-11-09)。
          *

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

@@ -70,8 +70,12 @@ import { ToolActivityComponent } from '../tool-activity/tool-activity';
               图片**直接显示缩略图**(用户要求):原来只有一个文件图标,
               看不到内容。点击仍然打开全图预览。
               加载失败时退回图标卡片,不出现裂图。
+
+              🔴 showFile=false 时不渲染 —— 由回合卡的 files 宫格负责,
+                 否则同一条消息会出现两个卡片(2026-11-09 用户实测)。
             -->
-            @if (isImage(file.mimeType, file.name) && !thumbBroken()) {
+            @if (showFile()) {
+              @if (isImage(file.mimeType, file.name) && !thumbBroken()) {
               <button
                 type="button"
                 class="thumb-card"
@@ -130,7 +134,21 @@ import { ToolActivityComponent } from '../tool-activity/tool-activity';
                 </span>
               </div>
             }
-          } @else {
+            }
+          }
+          <!--
+            🔴 正文与附件并列渲染,不是二选一(2026-11-09 重构)。
+
+            原结构是「有文件就渲染卡片,否则渲染正文」—— 一条消息同时有
+            「说明文字 + 附件」时,正文被 else 分支挡掉,用户只看到卡片、
+            看不到文字(实测:正文 524 字整个消失)。
+
+            现在的规则:
+              · 有附件 → 渲染卡片(showFile=false 时不渲染,交给回合卡宫格)
+              · 有正文 → **永远渲染正文**
+              · 两者都有 → 都渲染
+          -->
+          @if (bodyText()) {
             <div class="bubble" [class.failed]="message().state === 'failed'">
               <!--
                 agent 回复里混着「工具调用 / 过程提示」行(形如 圆点 + 工具名)。
@@ -149,7 +167,7 @@ import { ToolActivityComponent } from '../tool-activity/tool-activity';
                 }
               } @else {
                 <!-- markdown 渲染(agent 回复常含 md:加粗/列表/代码块/表格) -->
-                <app-md-render [text]="message().text ?? ''" [compact]="true" />
+                <app-md-render [text]="bodyText()" [compact]="true" />
               }
             </div>
           }
@@ -639,6 +657,26 @@ import { ToolActivityComponent } from '../tool-activity/tool-activity';
 export class MessageBubbleComponent {
   readonly message = input.required<Message>();
   readonly own = input(false);
+  /**
+   * 是否渲染消息自带的附件卡片(2026-11-09)。
+   *
+   * 🔴 默认渲染;但当这条消息**已经进了回合卡**时,回合卡的 `files` 宫格
+   *    也会渲染同一个附件 —— 同一条消息出现**两个卡片**(用户实测截图)。
+   *    回合卡里的那张是对的(带类型/更多信息),所以让气泡关掉。
+   */
+  readonly showFile = input(true);
+
+  /**
+   * 要渲染的正文(2026-11-09)。
+   *
+   * 🔴 与附件**无关**:有附件时正文照样渲染。
+   *    原结构是 `@if (file) {...} @else {正文}`,一条消息同时有
+   *    「说明 + 附件」时正文整个消失(实测 524 字没了)。
+   *
+   * 返回空串时模板不渲染正文块 —— 只在真的没有文字时发生。
+   */
+  readonly bodyText = computed(() => (this.message().text ?? '').trim());
+
   /** 是否显示发送者名(群聊为 true,DM 为 false) */
   readonly showSender = input(false);
   /** 群成员 Matrix ID → 展示名(用于把 mentions 显示成人能读的名字) */

+ 11 - 8
src/app/components/domain/new-session-modal/new-session-modal.ts

@@ -7,7 +7,7 @@ import { NewSessionModalService } from './new-session-modal.service';
 import { WorkspaceService } from '../../../core/workspace.service';
 import { GroupChatService } from '../../../core/group-chat.service';
 import { Agent, SemanticTone } from '../../../core/api/models';
-import { agentLayers } from '../../../core/agent-state';
+import { resolveAgentState } from '../../../core/agent-state';
 
 /**
  * 「新建对话」模态框(全局单例,挂在外壳上)
@@ -321,15 +321,18 @@ export class NewSessionModalComponent {
    *    容器的状态是在会话页面显示的」。
    *
    * 所以这里只回答一个问题:**能不能跟它说话**。
-   * 判据用三层模型:心跳活着(`alive`)+ 通道通(`link === 'ready'`)。
-   * 不再输出「正在执行 / 空闲」——那是会话页的职责。
+   *
+   * 🔴 2026-11-09 修正(C6):原先自行拼 `alive !== 'dead' && alive !== 'stale'`,
+   *    与左栏 `resolveAgentState` 不同源 —— 心跳 80~120 分钟时左栏显示黄色
+   *    「状态未同步」,这里却显示红色「离线」,同屏自相矛盾。
+   *    现在统一按 `resolveAgentState` 判定:只有 truly 不可用才显示离线。
    */
   protected stateOf(a: Agent): { text: string; tone: SemanticTone } {
-    const l = agentLayers(a);
-    const online = l.alive !== 'dead' && l.alive !== 'stale' && l.link === 'ready';
-    return online
-      ? { text: '在线', tone: 'success' }
-      : { text: '离线', tone: 'danger' };
+    const s = resolveAgentState({ agent: a }).state;
+    const offline = s === 'offline' || s === 'error';
+    return offline
+      ? { text: '离线', tone: 'danger' }
+      : { text: '在线', tone: 'success' };
   }
 
   protected createIn(agentId: string): void {

+ 12 - 8
src/app/components/domain/presence-badge/presence-badge.ts

@@ -6,7 +6,7 @@ import {
   AgentPresence,
   agentPresenceTone,
 } from '../../../core/api/models';
-import { agentLayers } from '../../../core/agent-state';
+import { resolveAgentState } from '../../../core/agent-state';
 
 /**
  * 数字生命「可对话状态」徽标 —— **配色 + 文字**(用户明确要求)
@@ -47,24 +47,28 @@ export class PresenceBadgeComponent {
   readonly agent = input<Agent | undefined>(undefined);
   readonly presence = input<AgentPresence | undefined>(undefined);
 
-  /** 显式传入的状态优先;否则按三层模型判定 */
-  private readonly layers = computed(() => agentLayers(this.agent()));
+  /** 显式传入的状态优先;否则走全站唯一口径 `resolveAgentState` */
+  private readonly resolved = computed(() => resolveAgentState({ agent: this.agent() }));
 
   /**
    * 语义色 → 底座徽标 variant。
    *
    * ⚠️ 底座 `BadgeVariant` **不含 `neutral`**(语义色单轨里 neutral 是兜底值),
    *    故此处映射到 `'secondary'`(同样用于「弱化/中性」表达)。
+   *
+   * 🔴 2026-11-09 修正(C6):原先用 `agentLayers().tone`,而 danger 被一并压成
+   *    `secondary`(灰),导致同一容器在桌面通讯录是灰色、移动端是红色。
+   *    现在走 `resolveAgentState`,并把 danger 如实映射为 `danger`。
    */
-  readonly tone = computed<'success' | 'warning' | 'secondary'>(() => {
+  readonly tone = computed<'success' | 'warning' | 'danger' | 'secondary'>(() => {
     // 显式传入旧口径时沿用旧映射,避免调用方大面积改签名
     if (this.presence()) {
       const t = agentPresenceTone(this.presence()!);
-      return t === 'success' ? 'success' : t === 'warning' ? 'warning' : 'secondary';
+      return t === 'success' ? 'success' : t === 'warning' ? 'warning' : t === 'danger' ? 'danger' : 'secondary';
     }
-    const t = this.layers().tone;
-    return t === 'success' ? 'success' : t === 'warning' ? 'warning' : 'secondary';
+    const t = this.resolved().tone;
+    return t === 'success' ? 'success' : t === 'warning' ? 'warning' : t === 'danger' ? 'danger' : 'secondary';
   });
 
-  readonly text = computed(() => this.presence() ? AGENT_PRESENCE_TEXT[this.presence()!] : this.layers().text);
+  readonly text = computed(() => this.presence() ? AGENT_PRESENCE_TEXT[this.presence()!] : this.resolved().text);
 }

+ 5 - 0
src/app/components/domain/session-rail/rail-shared.ts

@@ -116,6 +116,11 @@ export function fileIconByType(type?: string): string {
 /** 字节数 → 人类可读 */
 export function fmtSize(bytes: number): string {
   if (!bytes) return '';
+  /*
+   * 负数 = 「大小未知」(2026-11-09)。云盘链接不带大小,
+   * 显示「-1 B」会让用户以为文件坏了,这里当作「不显示」。
+   */
+  if (bytes < 0) return '';
   if (bytes < 1024) return `${bytes} B`;
   if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
   return `${(bytes / 1024 / 1024).toFixed(1)} MB`;

+ 38 - 0
src/app/components/domain/session-rail/session-rail.ts

@@ -224,6 +224,17 @@ export interface RailRuntime {
             @case ('results') {
               <section class="rail-block">
                 <h3>{{ resultsTitle() }} <span class="count">{{ deliverables().length }}</span></h3>
+                <!--
+                  🔴 未上云提示(2026-11-09):附件没有 url 时,原来**静默不显示**,
+                  用户以为文件没了却看不到任何说明。实测确认容器侧的搬运脚本
+                  已不存在,成果文件留在容器本地,App 取不到。
+                -->
+                @if (notOnCloudCount() > 0) {
+                  <p class="rail-warn" role="status">
+                    <i class="fa-solid fa-triangle-exclamation" aria-hidden="true"></i>
+                    有 {{ notOnCloudCount() }} 个成果文件未上传云盘,暂时无法查看
+                  </p>
+                }
                 @if (deliverables().length === 0) {
                   <p class="rail-empty">{{ resultsEmpty() }}</p>
                 } @else {
@@ -491,6 +502,19 @@ export interface RailRuntime {
         color: var(--faint);
         font-size: var(--fs-12);
       }
+      /* 未上云提示:用 warning 语义色,但只做提示不做阻断 */
+      .rail-warn {
+        display: flex;
+        align-items: flex-start;
+        gap: var(--sp-1);
+        margin: 0 0 var(--sp-2);
+        padding: var(--sp-2);
+        border-radius: var(--r-tag);
+        background: var(--warning-subtle);
+        color: var(--warning);
+        font-size: var(--fs-12);
+        line-height: 1.5;
+      }
       .rail-item {
         display: flex;
         align-items: center;
@@ -569,6 +593,20 @@ export class SessionRailComponent {
   readonly resultsTitle = input('相关成果');
   readonly resultsEmpty = input('暂无交付成果。');
 
+  /**
+   * 有多少成果附件**还没上云**(没有可访问 url)。
+   *
+   * 用户实测:右栏什么都不显示,也没提示,以为文件丢了。
+   * 实测根因是容器侧搬运脚本缺失,成果留在容器本地。
+   * 这里如实统计并提示,不再静默。
+   */
+  readonly notOnCloudCount = computed(() =>
+    this.deliverables().reduce(
+      (n, d) => n + d.artifacts.filter((a) => !a.url).length,
+      0,
+    ),
+  );
+
   // ---------- 事件 ----------
   readonly close = output<void>();
   readonly openTask = output<string>();

+ 33 - 9
src/app/components/preview/md-render/md-render.ts

@@ -1,5 +1,6 @@
-import { ChangeDetectionStrategy, Component, ElementRef, input, signal, viewChild } from '@angular/core';
+import { ChangeDetectionStrategy, Component, computed, ElementRef, input, signal, viewChild } from '@angular/core';
 import { MarkdownComponent } from 'ngx-markdown';
+import { normalizeMarkdown } from './normalize-md';
 
 /**
  * Markdown 渲染 —— 消息正文与附件共用
@@ -28,7 +29,7 @@ import { MarkdownComponent } from 'ngx-markdown';
   imports: [MarkdownComponent],
   template: `
     <div class="md-host" #host [class.md-host--compact]="compact()">
-      <markdown [data]="text()" (ready)="onReady()" />
+      <markdown [data]="data()" (ready)="onReady()" />
     </div>
     <span class="md-copy-live" role="status" aria-live="polite">{{ copyTip() }}</span>
   `,
@@ -111,14 +112,21 @@ import { MarkdownComponent } from 'ngx-markdown';
       :host ::ng-deep .md-host ul > li {
         position: relative;
       }
-      /* 小圆点:跟随正文色、固定在首行中线 */
+      /*
+       * 小圆点:跟随正文色、固定在首行中线。
+       *
+       * 🔴 2026-11-09 用户反馈「缩放时文字变了,列表状态没变」:
+       *    原来尺寸写死 5px、定位写死 0.72em —— 字号调大时圆点不变,
+       *    视觉上列表还是"小号"的。现在全部用 em(相对字号),
+       *    整个文档随 --chat-read-size 等比缩放。
+       */
       :host ::ng-deep .md-host ul > li::before {
         content: '';
         position: absolute;
         left: -0.85em;
-        top: 0.72em;
-        width: 5px;
-        height: 5px;
+        top: calc(0.9em - 0.18em);
+        width: 0.36em;
+        height: 0.36em;
         border-radius: 50%;
         background: currentColor;
         opacity: 0.45;
@@ -126,6 +134,10 @@ import { MarkdownComponent } from 'ngx-markdown';
       :host ::ng-deep .md-host li {
         margin: 0.25em 0;
       }
+      /* 有序列表的序号也跟随字号,避免大小不一致 */
+      :host ::ng-deep .md-host ol > li::marker {
+        font-size: 1em;
+      }
       :host ::ng-deep .md-host blockquote {
         margin: var(--sp-3) 0;
         padding: var(--sp-1) 0 var(--sp-1) var(--sp-3);
@@ -168,7 +180,8 @@ import { MarkdownComponent } from 'ngx-markdown';
         padding: 0;
         background: none;
         color: var(--nav-ink);
-        font-size: var(--fs-13);
+        /* 随正文等比缩放(2026-11-09),不再固定 --fs-13 */
+        font-size: 0.88em;
         line-height: 1.6;
       }
       /* 复制按钮:钉在**外层容器**右上角,代码横滚时保持不动 */
@@ -201,10 +214,12 @@ import { MarkdownComponent } from 'ngx-markdown';
         max-width: 100%;
         margin: var(--sp-2) 0;
         border-collapse: collapse;
-        font-size: var(--fs-13);
+        /* 随正文等比缩放(2026-11-09),不再固定 --fs-13 */
+        font-size: 0.88em;
       }
       :host ::ng-deep .md-host :is(th, td) {
-        padding: 6px 10px;
+        /* 内边距也用 em,缩放时表格同步变大 */
+        padding: 0.42em 0.7em;
         border: 1px solid var(--edge);
         text-align: left;
         word-break: break-word;
@@ -251,6 +266,15 @@ import { MarkdownComponent } from 'ngx-markdown';
 export class MdRenderComponent {
   /** markdown 原文(**纯文本**,不是 HTML) */
   readonly text = input<string>('');
+  /**
+   * 实际送进 markdown 渲染器的文本 —— 先做一次归一化(2026-11-09)。
+   *
+   * 🔴 容器回复里小标题与正文之间常常只有**单个换行**,CommonMark 把
+   *    单换行当空格 → 整段变成同一个 `<p>`,小标题和正文全粘在一起。
+   *    `normalizeMarkdown` 只在标题行后补空行,正常 markdown 原样保留。
+   *    双端共用本组件,改一处两端同时生效。
+   */
+  protected readonly data = computed(() => normalizeMarkdown(this.text()));
   /** 紧凑模式(用于气泡内) */
   readonly compact = input<boolean>(false);
 

+ 116 - 0
src/app/components/preview/md-render/normalize-md.spec.ts

@@ -0,0 +1,116 @@
+import { normalizeMarkdown } from './normalize-md';
+
+/**
+ * 单测:Markdown 归一化(2026-11-09)
+ *
+ * 每条用例对应 `normalizeMarkdown` 的一条设计约束。
+ * 🔴 核心断言是「只补不拆」:绝不删除原文内容。
+ */
+describe('normalizeMarkdown', () => {
+  it('**标题** 与正文之间补空行', () => {
+    const src = '**核心能力**\n我是你的数字分身。\n**对话与协作**\n我在 Matrix 里回你。';
+    const out = normalizeMarkdown(src);
+    expect(out).toContain('**核心能力**\n\n我是你的数字分身。');
+    expect(out).toContain('**对话与协作**\n\n我在 Matrix 里回你。');
+  });
+
+  it('中文序号标题(一、二、三)后补空行', () => {
+    const src = '一、产品定位\n面向小型团队。\n二、核心能力\n三个方向。';
+    const out = normalizeMarkdown(src);
+    expect(out).toContain('一、产品定位\n\n面向小型团队。');
+    expect(out).toContain('二、核心能力\n\n三个方向。');
+  });
+
+  it('数字序号标题(1. / 1、)后补空行', () => {
+    expect(normalizeMarkdown('1. 产品定位\n面向小型团队。')).toContain('1. 产品定位\n\n');
+    expect(normalizeMarkdown('1、产品定位\n面向小型团队。')).toContain('1、产品定位\n\n');
+  });
+
+  it('纯短句标题(≤12 字、无句末标点)后补空行', () => {
+    expect(normalizeMarkdown('核心能力\n我是你的数字分身。')).toContain('核心能力\n\n');
+  });
+
+  it('短句 + 冒号后补空行', () => {
+    expect(normalizeMarkdown('核心能力:\n我是你的数字分身。')).toContain('核心能力:\n\n');
+  });
+
+  it('🔴 已经是空行分段的正常 markdown → 原样不动', () => {
+    const src = '**核心能力**\n\n我是你的数字分身。\n\n**对话**\n\n我在回你。';
+    expect(normalizeMarkdown(src)).toBe(src);
+  });
+
+  it('🔴 幂等:执行两次与执行一次结果相同', () => {
+    const src = '**核心能力**\n我是你的数字分身。\n**对话**\n我在回你。';
+    const once = normalizeMarkdown(src);
+    expect(normalizeMarkdown(once)).toBe(once);
+  });
+
+  it('🔴 代码围栏内的内容逐字保留', () => {
+    const src = '看这段代码:\n```\n核心能力\nlet a = 1;\n```\n结束。';
+    const out = normalizeMarkdown(src);
+    expect(out).toContain('```\n核心能力\nlet a = 1;\n```');
+  });
+
+  it('🔴 列表项之间不断开', () => {
+    const src = '- 第一项\n- 第二项\n- 第三项';
+    expect(normalizeMarkdown(src)).toBe(src);
+  });
+
+  it('🔴 不删除任何字符(只插入换行)', () => {
+    const src =
+      '**核心能力**\n我是你的数字分身。\n一、产品定位\n面向团队。\n```\ncode\n```\n结尾。';
+    const strip = (s: string): string => s.replace(/\n/g, '');
+    expect(strip(normalizeMarkdown(src))).toBe(strip(src));
+  });
+
+  it('空串与单行文本原样返回', () => {
+    expect(normalizeMarkdown('')).toBe('');
+    expect(normalizeMarkdown('只有一行。')).toBe('只有一行。');
+  });
+
+  it('句末标点的长句不加分段(避免把正文拆碎)', () => {
+    const src = '这是一句很长的正文,末尾有句号。\n后面还有一句同样长的话,也有句号。';
+    expect(normalizeMarkdown(src)).toBe(src);
+  });
+
+  it('🔴 标题粘在正文行尾(**xx** 收尾)→ 拆成独立段落', () => {
+    /*
+     * 容器实测排版:正文写完,行尾跟一个 `**我的能力**`,正文在下一行。
+     * 期望变成「正文 \n\n 标题 \n\n 正文」,三个独立段落。
+     */
+    const src =
+      '我是主人的数字分身,结论先行。**我的能力**\n我能操作浏览器访问网页、执行命令。';
+    const out = normalizeMarkdown(src);
+    expect(out).toContain('结论先行。\n\n**我的能力**');
+    expect(out).toContain('**我的能力**\n\n我能操作浏览器');
+  });
+
+  it('🔴 行尾标题拆分也保持幂等', () => {
+    const src = '正文。**我的能力**\n下一段正文。';
+    const once = normalizeMarkdown(src);
+    expect(normalizeMarkdown(once)).toBe(once);
+  });
+
+  it('🔴 行尾标题拆分不丢内容', () => {
+    const src = '正文一句话。**我的边界**\n我只授权范围内行动。';
+    const strip = (s: string): string => s.replace(/\n/g, '');
+    expect(strip(normalizeMarkdown(src))).toBe(strip(src));
+  });
+
+  it('🔴 空格 + 序号短语收尾(职责二:xxx)→ 拆成独立段落', () => {
+    /*
+     * 容器另一种实测形态:不用 markdown 加粗,直接在正文末尾空一格接小标题。
+     *   「我在代表你处理消息。  职责二:在授权范围内执行任务」
+     */
+    const src =
+      '我在这个容器里代表你处理消息,不冒充他人。  职责二:在授权范围内执行任务\n我调用工具实际完成任务。';
+    const out = normalizeMarkdown(src);
+    expect(out).toContain('不冒充他人。\n\n职责二:在授权范围内执行任务');
+    expect(out).toContain('职责二:在授权范围内执行任务\n\n我调用工具');
+  });
+
+  it('🔴 普通句子里的单空格不触发拆分(避免误伤)', () => {
+    const src = '这句话里有空格 但不是在说标题。\n下一句继续。';
+    expect(normalizeMarkdown(src)).toBe(src);
+  });
+});

+ 259 - 0
src/app/components/preview/md-render/normalize-md.ts

@@ -0,0 +1,259 @@
+/**
+ * AI 回复正文的 Markdown 归一化
+ *
+ * ## 解决什么
+ *
+ * 容器(agent)发回的回复里,小标题和正文之间只有**单个换行**:
+ *
+ * ```
+ * **核心能力**
+ * 我是你的数字分身…
+ * **对话与协作**
+ * 我在 Matrix 私聊…
+ * ```
+ *
+ * CommonMark 规则里,单换行等于**空格**,于是整段被渲染成同一个 `<p>`,
+ * 三个小标题和正文全部粘在一起(用户实测截图)。
+ *
+ * 本函数只做一件事:**在「标题行」和「正文」之间补一个空行**,
+ * 让它们各自成段。真正的 markdown(已有空行、代码块、列表)一律原样保留。
+ *
+ * ## 设计约束
+ *
+ * 🔴 **只补不拆**:永远不删除用户/AI 写的内容,只插入换行。
+ * 🔴 **幂等**:对同一段文本执行两次,结果与执行一次相同。
+ * 🔴 **不碰代码块**:``` 围栏内的内容逐字保留(里面的换行有语义)。
+ */
+
+/** 代码围栏标记(``` 或 ~~~,允许缩进) */
+const FENCE = /^\s*(```|~~~)/;
+
+/**
+ * 形如「小标题」的短行特征。
+ *
+ * 命中任一即视为标题行:
+ *   · `**核心能力**`           —— 已是 markdown 加粗
+ *   · `一、产品定位` / `(一)…` —— 中文序号
+ *   · `1. 产品定位` / `1、…`    —— 数字序号
+ *   · `核心能力:`              —— 短句 + 冒号
+ *   · `核心能力`                —— 纯短句(≤ 12 字,无句末标点)
+ */
+const HEADING_PATTERNS: readonly RegExp[] = [
+  /^\*\*[^*]+\*\*[::]?$/, // **标题**
+  /^#{1,6}\s+\S/, // 已是 markdown 标题
+  /^[一二三四五六七八九十]+[、..]\s*\S/, // 一、标题
+  /^[((][一二三四五六七八九十\d]+[))]\s*\S/, // (一)标题
+  /^\d+[、..]\s*\S/, // 1、标题
+];
+
+/** 句末标点 —— 有这些说明是正文句子,不是标题 */
+const SENTENCE_END = /[。!?;.!?;]$/;
+
+/** 纯短句标题的最大长度(字) */
+const BARE_HEADING_MAX = 12;
+
+/**
+ * 这一行**整行**是不是一个小标题。
+ *
+ * 用于「标题在行首」的场景(在它**之前**补空行)。
+ * 比 `HEADING_PATTERNS` 更保守 —— 只认明确的标题形态,不做「短句猜测」,
+ * 否则「好的」这种正常回复也会被当成标题切出来。
+ */
+function isHeadingLine(line: string): boolean {
+  if (!line) return false;
+  // **xxx** 独占一行
+  if (/^\*\*[^*]+\*\*[::]?$/.test(line)) return true;
+  // markdown 标题
+  if (/^#{1,6}\s+\S/.test(line)) return true;
+  // 中文/数字序号 + 短标题
+  if (
+    /^([一二三四五六七八九十]+[、..]|第[一二三四五六七八九十\d]+[段步条点]|\d+[、..])\s*\S/.test(
+      line,
+    ) &&
+    line.length <= 24 &&
+    !SENTENCE_END.test(line)
+  ) {
+    return true;
+  }
+  return false;
+}
+
+/**
+ * 判断某一行的「下一行」是否应该与它隔开。
+ *
+ * @param line    当前行(已 trim)
+ * @param next    下一行(已 trim)
+ * @returns true 表示两者之间要插一个空行
+ */
+function needsBreakAfter(line: string, next: string): boolean {
+  if (!line || !next) return false;
+
+  /*
+   * ⓪ 行**尾部**是标题 —— 真正要断的是「标题之后」而不是「这一行之后」。
+   *
+   * 实测容器的另一种排版:正文写完,紧接着一行 `**我的能力**` 收尾,
+   * 标题本身在行尾,正文在**下一行**:
+   *
+   * ```
+   * 我是主人的数字分身…结论先行。
+   * **我的能力**
+   * 我能操作浏览器访问网页…
+   * ```
+   *
+   * 这时要在「标题行」和它下一行之间断开,而不是在当前行之后断开。
+   * 由调用方 `splitTrailingHeading` 先把这一行拆开再处理。
+   */
+
+  // ① 已命中的标题形态 → 与后文隔开
+  for (const re of HEADING_PATTERNS) {
+    if (re.test(line)) return true;
+  }
+
+  // ② 纯短句标题:够短、无句末标点、且下一行明显更长(是正文而非同类短行)
+  const isBareCandidate =
+    line.length <= BARE_HEADING_MAX && !SENTENCE_END.test(line) && !/^[-*+]\s/.test(line);
+  if (isBareCandidate && next.length > line.length) return true;
+
+  // ③ 短句 + 冒号结尾(如「核心能力:」)→ 与后文隔开
+  if (/[::]$/.test(line) && line.length <= BARE_HEADING_MAX + 4) return true;
+
+  return false;
+}
+
+/**
+ * 判断整行文本的**末尾**是否是一个独立小标题。
+ *
+ * 两种实测形态都认:
+ *   · `…结论先行。**我的能力**`       —— markdown 加粗收尾
+ *   · `…对外发声。 职责二:在授权范围内执行任务` —— 空格分隔 + 序号短语
+ *
+ * @returns 命中时返回标题位置之前的正文部分;否则返回 null
+ */
+function splitTrailingHeading(line: string): { head: string; heading: string } | null {
+  /*
+   * ① `**xxx**` 收尾 —— 中间可能**紧贴**(`结论先行。**我的能力**`)
+   *    或**隔空格**(`…结论先行。 **我的能力**`),两种都认。
+   */
+  const bold = /^(.*\S)\s*(\*\*[^*\n]+\*\*)\s*[::]?\s*$/.exec(line);
+  if (bold) return { head: bold[1].trim(), heading: bold[2].trim() };
+
+  /*
+   * ② 「空白 + 短标题短语」收尾:「…绝不编造。 我的边界」「…发了。 职责二:执行」
+   *
+   * 🔴 实测容器的空格数量不固定(1 个、2 个都有),句末标点也可能没有,
+   *    所以判据换成**标题本身的长相**,不依赖空格数与前置标点:
+   *    · 尾部短语够短(≤ 24 字)
+   *    · 本身不含句末标点(是短语,不是句子)
+   *    · 以「我的/你的/职责/步骤/阶段/目标/结论/要点/第X/一二三、/1.」等开头
+   */
+  const tail = /^(.*\S)\s+(\S[^。!?.!?;]*)$/.exec(line);
+  if (tail) {
+    const cand = tail[2].trim();
+    const looksHeading =
+      cand.length > 0 &&
+      cand.length <= 24 &&
+      !SENTENCE_END.test(cand) &&
+      /^(我的|你的|它的|我们的|职责|步骤|阶段|目标|结论|要点|特点|优势|背景|现状|问题|建议|方案|注意|小结|总结|第[一二三四五六七八九十\d]+|[一二三四五六七八九十]+[、..]|\d+[、..])/.test(
+        cand,
+      );
+    // 正文部分必须够长,否则「第 1 步」这种正常用法会被误切
+    if (looksHeading && tail[1].trim().length >= 8) {
+      return { head: tail[1].trim(), heading: cand };
+    }
+  }
+
+  return null;
+}
+
+/**
+ * 把「单换行分隔的标题 + 正文」补成空行分段。
+ *
+ * @param text 原始正文
+ * @returns 归一化后的正文(只插入换行,不删除任何字符)
+ */
+export function normalizeMarkdown(text: string): string {
+  if (!text) return text;
+
+  const lines = text.split('\n');
+  const out: string[] = [];
+  /** 是否在代码围栏内(围栏内逐字保留) */
+  let inFence = false;
+
+  for (let i = 0; i < lines.length; i++) {
+    const raw = lines[i];
+    const line = raw.trim();
+
+    if (FENCE.test(raw)) {
+      inFence = !inFence;
+      out.push(raw);
+      continue;
+    }
+
+    if (inFence) {
+      out.push(raw);
+      continue;
+    }
+
+    /*
+     * ⓪ 行尾是标题 → 先把它拆成两行(2026-11-09)。
+     *
+     * 容器的常见排版是「正文 + 换行 + 标题 + 换行 + 正文」,
+     * 但标题也可能粘在上一段末尾:
+     *   我是主人的数字分身…结论先行。**我的能力**
+     *   我能操作浏览器…
+     * CommonMark 会把它们算成同一段。这里先把行尾标题切到独立一行,
+     * 再走下面的常规判定,最后自然得到「正文 \n\n 标题 \n\n 正文」。
+     */
+    const headBefore = raw.trim() ? splitTrailingHeading(raw.trim()) : null;
+    if (headBefore) {
+      out.push(headBefore.head);
+      out.push('');
+      out.push(headBefore.heading);
+      // 标题行后面跟的是正文(下一行)→ 再补一个空行
+      const nextRaw0 = lines[i + 1];
+      if (nextRaw0 !== undefined && nextRaw0.trim()) out.push('');
+      continue;
+    }
+
+    /*
+     * 🔴 标题在**行首**时,要在它**之前**断开(2026-11-09)。
+     *
+     * 实测容器的排版:
+     * ```
+     * 我在主人的私聊房间里直接对话。不会编造结论。
+     * **第二段:工具与执行**
+     * 我可以调用终端…
+     * ```
+     * CommonMark 把「正文行」和「标题行」算成同一段(单换行 = 空格),
+     * 标题被吃进上一段。必须在标题行**之前**补空行。
+     *
+     * 与「标题之后补空行」方向相反,两者都要做。
+     */
+    if (i > 0 && isHeadingLine(line) && out.length >= 1 && out[out.length - 1].trim()) {
+      out.push('');
+    }
+
+    out.push(raw);
+
+    /*
+     * 只在「当前行有内容、下一行也有内容」时才考虑补空行。
+     * 已经是空行的位置(正常 markdown 分段)不动 —— 幂等的关键。
+     */
+    if (!line) continue;
+    const nextRaw = lines[i + 1];
+    if (nextRaw === undefined) continue;
+    const next = nextRaw.trim();
+    if (!next) continue;
+
+    /*
+     * 列表项内部不断开:`- xxx` 后面跟 `- yyy` 是同一个列表。
+     * 缩进的续行(以空白开头)也不断开。
+     */
+    if (/^\s*[-*+]\s|^\s*\d+[、..)]\s/.test(nextRaw)) continue;
+    if (/^\s{2,}/.test(nextRaw)) continue;
+
+    if (needsBreakAfter(line, next)) out.push('');
+  }
+
+  return out.join('\n');
+}

+ 6 - 0
src/app/core/agent-state.ts

@@ -280,6 +280,12 @@ export function agentLayers(agent: Agent | null | undefined, now = Date.now()):
   if (alive === 'stale') return { alive, link, busy, text: '状态未同步', tone: 'warning' };
   if (link === 'unready') return { alive, link, busy, text: '未接通', tone: 'warning' };
   if (agent.status === 'error') return { alive, link, busy, text: '异常', tone: 'danger' };
+  /*
+   * 🔴 2026-11-09 补(C6):`stopped` / `offline` 在 `resolveAgentState` 里属
+   *    `BROKEN`,判「离线」;这里原先没有对应分支,落到「空闲」——
+   *    同一容器在新建弹窗显示红色「离线」、在工作台显示「空闲」,自相矛盾。
+   */
+  if (BROKEN.has(agent.status)) return { alive, link, busy: false, text: '离线', tone: 'danger' };
   return { alive, link, busy, text: busy ? '正在执行' : '空闲', tone: busy ? 'success' : 'neutral' };
 }
 

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

@@ -241,6 +241,13 @@ export interface FileCard {
   mimeType: string;
   url: string;
   thumb?: string;
+  /**
+   * 云盘(OBS/S3)公网地址(2026-11-09)。
+   *
+   * 用户要求「之后查看都走云盘」:有这个地址就优先用它,
+   * 没有则回落到 Matrix 的 mxc 转换地址。缺失说明当时 S3 上传失败。
+   */
+  cloudUrl?: string;
 }
 
 export interface Message {
@@ -696,6 +703,14 @@ export interface ChatThreadRecord {
   kind?: 'dm' | 'group';
   /** 群会话成员容器 id 列表 */
   memberIds?: string[];
+  /**
+   * 本会话关联的成果链接(云盘可访问地址)。
+   *
+   * 🔴 2026-11-09 用户裁决:成果按会话存放 —— 每条会话在库里有一条记录,
+   *    记录里放该会话成果的在线链接数组,查询时读出来即可。
+   *    这样不依赖容器上报 threadRoot,「这条对话的成果」由数据本身决定。
+   */
+  deliverableUrls?: string[];
 }
 
 /** upsert 提交项(不含服务端生成字段) */
@@ -720,6 +735,14 @@ export interface ChatThreadUpsert {
   kind?: 'dm' | 'group';
   /** 群会话成员容器 id 列表;单聊为空 */
   memberIds?: string[];
+  /**
+   * 本会话关联的成果链接(云盘可访问地址)。
+   *
+   * 🔴 2026-11-09 用户裁决:成果按会话存放 —— 每条会话在库里有一条记录,
+   *    记录里放该会话成果的在线链接数组,查询时读出来即可。
+   *    这样不依赖容器上报 threadRoot,「这条对话的成果」由数据本身决定。
+   */
+  deliverableUrls?: string[];
   /** 重新出现时撤销软删 */
   revive?: boolean;
 }

+ 57 - 6
src/app/core/attachment-fetch.ts

@@ -43,17 +43,46 @@ export interface BlobResult {
   url: string;
   /** 需要调用方在关闭时释放 */
   revoke: () => void;
+  /**
+   * true = 因 CORS 不可用而**直接用的原地址**(不是 blob)。
+   *
+   * 调用方据此调整提示:这种情况安全加固(CSP 注入)**没有生效**,
+   * 但不该显示「加载失败」—— 文件其实是能看的。
+   */
+  direct?: boolean;
 }
 
 /**
  * 把远程附件抓成 blob: 地址。
  *
- * @param url 原始地址(通常是 Matrix 媒体地址)
+ * ## 为什么要能退回原地址
+ *
+ * 带 CSP 加固的预览必须读文本 → 必须 `fetch` → **跨域需要对方配 CORS**。
+ * 实测 `s3.fmode.cn` **没有配 CORS 头**,所以云盘上的成果一律 fetch 失败
+ * (浏览器报 `blocked by CORS policy`,用户看到「文件加载失败」)。
+ *
+ * 但这些对象是 `public-read`,**直接作为 iframe src 能正常显示** ——
+ * iframe 不受 CORS 限制(只是偶尔无法读取内部内容)。
+ *
+ * 所以:CORS 可用 → 走 blob(可注入 CSP,更安全);
+ *       CORS 不可用 → **退回原 URL**(能看,但这一个 iframe 无法注入 CSP)。
+ *
+ * @param url 原始地址(Matrix 媒体地址 / 云盘地址)
  * @param mime 内容类型(用于构造 Blob,缺失时按 text/html 处理)
- * @throws 网络失败或非 2xx 时抛出,调用方需展示错误而不是空白
+ * @throws 仅在**非 CORS 类**的失败(如 404)时抛出,调用方展示错误而不是空白
  */
 export async function toBlobUrl(url: string, mime = 'text/html'): Promise<BlobResult> {
-  const res = await fetch(url);
+  let res: Response;
+  try {
+    res = await fetch(url);
+  } catch {
+    /*
+     * fetch 抛错基本只有两种:网络断了 / CORS 被拦。
+     * 后者对 public-read 的对象是**假失败** —— 内容其实拿得到,
+     * 所以退回原地址,让用户至少能看到文件。
+     */
+    return { url, revoke: () => undefined, direct: true };
+  }
   if (!res.ok) {
     throw new Error(`加载失败(HTTP ${res.status})`);
   }
@@ -146,9 +175,20 @@ export function injectHtmlSandboxGuard(html: string): string {
  * 点击只会在新标签打开。要可靠下载必须用 blob。
  */
 export async function downloadAs(url: string, filename: string): Promise<void> {
-  const res = await fetch(url);
-  if (!res.ok) throw new Error(`下载失败(HTTP ${res.status})`);
-  const blob = await res.blob();
+  let blob: Blob;
+  try {
+    const res = await fetch(url);
+    if (!res.ok) throw new Error(`下载失败(HTTP ${res.status})`);
+    blob = await res.blob();
+  } catch {
+    /*
+     * 🔴 跨域被 CORS 拦时,fetch 必失败(实测 s3.fmode.cn 未配 CORS 头)。
+     * 但文件本身是 public-read,**直接打开链接能下载**。
+     * 所以降级为「新标签打开」,而不是让用户看到 ERR_FAILED。
+     */
+    openInNewTab(url);
+    return;
+  }
   const objectUrl = URL.createObjectURL(blob);
   try {
     const a = document.createElement('a');
@@ -162,3 +202,14 @@ export async function downloadAs(url: string, filename: string): Promise<void> {
     setTimeout(() => URL.revokeObjectURL(objectUrl), 1000);
   }
 }
+
+/** 新标签打开(CORS 不可用时的降级路径,也可直接预览) */
+export function openInNewTab(url: string): void {
+  const a = document.createElement('a');
+  a.href = url;
+  a.target = '_blank';
+  a.rel = 'noopener noreferrer';
+  document.body.appendChild(a);
+  a.click();
+  a.remove();
+}

+ 125 - 3
src/app/core/chat-session.service.ts

@@ -152,6 +152,8 @@ export class ChatSessionService {
    * 推送成功后立即移除,避免长期存在。
    */
   private readonly _pendingPush = new Set<string>();
+  /** 首次落库的进行中 Promise(供 `commitNow` 等待,避免重复请求) */
+  private readonly _commitWait = new Map<string, Promise<void>>();
 
   /**
    * 从 localStorage 载入(Shell 启动时调用一次;重复调用幂等)。
@@ -264,8 +266,14 @@ export class ChatSessionService {
     let deleted: ChatThreadRecord[];
     try {
       /*
-       * 同时拉两份:活跃会话 + 已软删会话。
-       * 软删那份只用于挡住「同一条线程被重新写回来」(墓碑跨设备有效)。
+       * 两份列表分开拉(2026-11-09 实测确认语义):
+       *   · `fetchChatThreads(agentId)`                  → **仅活跃**会话
+       *   · `fetchChatThreads(agentId, {includeDeleted})` → **仅已软删**会话
+       *
+       * 🔴 参数名叫 includeDeleted,但实测返回的是**只含已删**的记录,
+       *    而不是「活跃 + 已删」。实测证据(2026-11-09):
+       *    默认 list = 2 条,includeDeleted = 41 条,两个集合**完全不相交**。
+       *    原实现把它当「并集」用,导致墓碑里混入了错误数据。
        */
       [live, deleted] = await Promise.all([
         api.fetchChatThreads(agentId),
@@ -281,6 +289,16 @@ export class ChatSessionService {
       return 0;
     }
 
+    /*
+     * 🔴 云端墓碑(2026-11-09):只用于「URL 直达已删会话时拒绝打开」。
+     *
+     * 原先 `cloudRemoved` 存了但**从没被任何地方读取**。现在它是
+     * `openReal` 判定「这条会话已被删除」的权威依据。
+     *
+     * 🔴 刻意**不**并入 `_removed`:后者会持久化到 localStorage,
+     *    并入后一旦云端某次返回异常,本地就会永久拉黑一批正常会话。
+     *    墓碑是「本次会话周期内」的判定依据,重新拉取时自然刷新。
+     */
     this.cloudRemoved = new Set(deleted.map((d) => d.threadRoot));
     const prev = new Map(
       this._all()
@@ -324,6 +342,8 @@ export class ChatSessionService {
          */
         kind: r.kind || local?.kind || undefined,
         memberIds: r.memberIds?.length ? r.memberIds : local?.memberIds,
+        /* 成果链接数组(2026-11-09):云端为准,本地兜底 */
+        deliverableUrls: r.deliverableUrls?.length ? r.deliverableUrls : local?.deliverableUrls,
       });
     }
 
@@ -572,6 +592,16 @@ export class ChatSessionService {
     this.pushOne(s);
   }
 
+  /**
+   * 这条会话是否**还没落过库**(需要走强顺序的 `commitNow`)。
+   *
+   * 判据:仍在待推送集合里 = 本地新建、云端尚无记录。
+   * 已落库的会话后续更新走 `commit()` 的节流推送即可。
+   */
+  needsCommit(id: string): boolean {
+    return this._pendingPush.has(id);
+  }
+
   /**
    * 让「历史遗留会话」存在于列表中(幂等)。
    *
@@ -755,6 +785,19 @@ export class ChatSessionService {
     return this._removed().has(id);
   }
 
+  /**
+   * 该线程是否**已被云端标记为删除**(墓碑)。
+   *
+   * 与 `isRemoved` 的区别:`isRemoved` 还包含「本机删过但云端还没同步」,
+   * 而这里只认云端墓碑 —— 用于「URL 直达已删会话」这种需要权威判断的场景。
+   *
+   * 注意:`cloudRemoved` 在会话目录**还没加载**时是空的。此时返回 false,
+   * 让调用方按原有路径走(不能因为「还没拉到」就拒绝打开任何会话)。
+   */
+  isCloudRemoved(id: string): boolean {
+    return this.cloudRemoved.has(id);
+  }
+
   /**
    * 收敛「新建对话」空壳:**整个列表最多只留一条**。
    *
@@ -804,6 +847,31 @@ export class ChatSessionService {
     this.update(id, patch);
   }
 
+  /**
+   * 把一条云盘链接记到会话的成果列表里(2026-11-09 用户裁决)。
+   *
+   * 用户要求:成果按会话存放 —— 会话记录里存该会话成果的在线链接数组。
+   *
+   * 调用时机:会话里出现带 `cloudUrl` 的附件 / 有新成果上云时。
+   * 幂等:同一链接不重复加;已有链接不会被覆盖掉。
+   */
+  addDeliverableUrl(id: string, url: string | null | undefined): void {
+    const u = (url ?? '').trim();
+    if (!u) return;
+    const cur = this.byId(id);
+    if (!cur) return;
+    const list = cur.deliverableUrls ?? [];
+    if (list.includes(u)) return;
+    // 上限 50,与服务端 `_strArr` 的截断一致
+    const next = [...list, u].slice(0, 50);
+    this.update(id, { deliverableUrls: next });
+    /*
+     * 🔴 立刻落库:成果归属是跨设备可见的数据,只留本地没有意义。
+     *    `pushOne` 是 fire-and-forget,失败会进 `_pushFailed` 并等下次补推。
+     */
+    this.pushOne(this.byId(id));
+  }
+
   /**
    * 写入一条消息(更新预览/时间,必要时推导标题)。
    *
@@ -835,7 +903,59 @@ export class ChatSessionService {
     const due = Date.now() - (this.lastPushAt.get(id) ?? 0) > 10_000;
     if (!this._ready() || (!pending && !titleChanged && !due)) return;
     this.lastPushAt.set(id, Date.now());
-    this.pushOne(this.byId(id) ?? next);
+    /*
+     * 🔴 首次落库(pending)也在这里推,但要能被 `sendText` **等待**(2026-11-09)。
+     *
+     * 用户要求:第一次发消息时先持久化,**存储成功后才发送**。
+     * 做法是把这次推送的 Promise 挂到 `_commitWait`,`commitNow()` 取出来 await;
+     * 调用方(群聊等)没有 await 也不会丢 —— 推送照常在后台跑完。
+     */
+    const session = this.byId(id) ?? next;
+    const p = this.pushOneAsync(session);
+    if (pending) this._commitWait.set(id, p);
+    else void p;
+  }
+
+  /**
+   * 等待该会话的**首次落库**完成(2026-11-09 用户要求)。
+   *
+   * - 若 `noteMessage` 已经发起首次推送 → 直接等那个 Promise,不重复请求
+   * - 若还没发起(例如调用方直接 sendText 没走 noteMessage)→ 主动推一次
+   *
+   * @throws 云端写入失败时抛出,调用方据此把消息标记为失败、不发送
+   */
+  async commitNow(id: string): Promise<void> {
+    const s = this.byId(id);
+    if (!s || s.legacy) return;
+    if (!this.api) return; // 演示态:无云端
+
+    const inflight = this._commitWait.get(id);
+    if (inflight) {
+      this._commitWait.delete(id);
+      await inflight;
+      return;
+    }
+    await this.pushOneAsync(s);
+  }
+
+  /**
+   * 推送一条会话并按结果 resolve/reject(`pushOne` 的同步版)。
+   *
+   * 🔴 `pushOne` 是 fire-and-forget,失败只设提示不抛;首次落库需要抛给调用方,
+   *    所以单开一个可 await 的实现,两者共用同一套成功/失败处理。
+   */
+  private async pushOneAsync(s: ChatSession): Promise<void> {
+    if (s.legacy || !this.api) return;
+    try {
+      await this.api.upsertChatThreads([toUpsert(s)]);
+      this._pendingPush.delete(s.id);
+      if (this._pushFailed()) this._pushFailed.set(null);
+    } catch (e) {
+      const msg = e instanceof Error && e.message ? e.message : '网络异常';
+      // 保留待同步标记,下次 hydrate / pushAll 自动补推
+      this._pushFailed.set(`会话「${s.title || '新对话'}」尚未同步到云端:${msg}`);
+      throw new Error(msg);
+    }
   }
 
   /** 取某房间下所有已知会话的线程根(消息过滤用) */
@@ -915,6 +1035,8 @@ function toUpsert(s: ChatSession): ChatThreadUpsert {
     // 群会话形态与成员(2026-11-05):不同步会导致换设备后群聊退化成单聊
     kind: s.kind,
     memberIds: s.memberIds,
+    // 本会话的成果链接(2026-11-09):成果按会话存放
+    deliverableUrls: s.deliverableUrls,
   };
 }
 

+ 7 - 0
src/app/core/chat-session.ts

@@ -44,6 +44,13 @@ export interface ChatSession {
    *    容器之间互相看不到对方的消息。
    */
   memberIds?: string[];
+  /**
+   * 本会话关联的成果链接(云盘可访问地址,2026-11-09 用户裁决)。
+   *
+   * 成果按会话存放:每条会话记录里存该会话成果的在线链接,
+   * 右栏「成果」只显示这里的,不再显示整个容器的成果。
+   */
+  deliverableUrls?: string[];
   /** 展示标题(用户可改;未改时取首条用户消息前 24 字) */
   title: string;
   /** 是否用户手动改过标题(改过就不再自动覆盖) */

+ 78 - 2
src/app/core/chat.service.spec.ts

@@ -268,6 +268,68 @@ describe('ChatService', () => {
       expect(svc.state()).toBe('ready');
       expect(svc.currentSession()).toBeDefined();
     });
+
+    /*
+     * 🔴 2026-11-09 用户要求:「第一次发送消息的时候,才会开始持久化存储
+     *    这个对话;存储成功后,才会开始发送消息」。
+     *
+     * 原实现是 `commit()`(发出去就不管):消息先发进 Matrix,落库在后台跑。
+     * 现在首次落库必须 `await`,成功才继续发送。
+     */
+    it('🔴 首次发消息:先落库成功,才真正发送到 Matrix', async () => {
+      realMode();
+      mxStub.timelineToMessages.and.returnValue([]);
+      apiStub.fetchChatThreads.and.returnValue(Promise.resolve([]));
+      svc.sessions.reset();
+
+      const order: string[] = [];
+      apiStub.upsertChatThreads.and.callFake(() => {
+        order.push('upsert');
+        return Promise.resolve();
+      });
+      mxStub.sendText.and.callFake(() => {
+        order.push('sendText');
+        return Promise.resolve({ event_id: '$evt1' });
+      });
+
+      await svc.startBlank('agent-node-lj-test');
+      order.length = 0; // 只关心 sendText 期间的顺序
+      await svc.sendText('第一条消息');
+
+      /*
+       * 期望:落库(upsert) → 发送(sendText)。
+       * 允许 upsert 出现多次(标题推送等),但**首次 upsert 必须在 sendText 之前**,
+       * 且 sendText 只能出现一次。
+       */
+      const firstUpsert = order.indexOf('upsert');
+      const firstSend = order.indexOf('sendText');
+      expect(firstUpsert).withContext('应当发生过落库').toBeGreaterThanOrEqual(0);
+      expect(firstSend).withContext('应当发生过发送').toBeGreaterThanOrEqual(0);
+      expect(firstUpsert)
+        .withContext(`落库必须排在发送之前,实际顺序:${order.join(' → ')}`)
+        .toBeLessThan(firstSend);
+      expect(order.filter((x) => x === 'sendText').length).toBe(1);
+    });
+
+    it('🔴 首次落库失败 → 消息标记失败,且不发送到 Matrix', async () => {
+      realMode();
+      mxStub.timelineToMessages.and.returnValue([]);
+      apiStub.fetchChatThreads.and.returnValue(Promise.resolve([]));
+      svc.sessions.reset();
+
+      apiStub.upsertChatThreads.and.returnValue(Promise.reject(new Error('网络异常')));
+      mxStub.sendText.calls.reset();
+
+      await svc.startBlank('agent-node-lj-test');
+      await svc.sendText('这条不该被发出去');
+
+      expect(mxStub.sendText).withContext('落库失败就不能发送').not.toHaveBeenCalled();
+      const failed = svc.messages().find((m) => m.state === 'failed');
+      expect(failed).withContext('消息要标记为失败,用户可重试').toBeDefined();
+
+      // 复原桩,避免影响后续用例
+      apiStub.upsertChatThreads.and.returnValue(Promise.resolve());
+    });
   });
 
   describe('open() 真实态编排', () => {
@@ -1021,7 +1083,14 @@ describe('ChatService · 新建会话期间不得回填历史(2026-11-08)',
 
     // 开始发送,但请求挂起(网络还没返回)
     const pending = svc.sendText('你好');
-    await Promise.resolve();
+    /*
+     * 🔴 2026-11-09:首次发消息会先 `await commitNow()`(落库),
+     *    多了一轮微任务。只 `await Promise.resolve()` 一次,此刻还停在
+     *    落库阶段,`sendText` 根本没走到发送。
+     *    这里连推几轮,确保已经进入「发送请求挂起」的状态。
+     */
+    for (let k = 0; k < 5; k++) await Promise.resolve();
+    await new Promise((r) => setTimeout(r, 0));
 
     // 此刻新会话里应该只有用户自己那条乐观消息
     const during = svc.messages();
@@ -1056,7 +1125,14 @@ describe('ChatService · 新建会话期间不得回填历史(2026-11-08)',
     it('echo 带回同一个 localId → 只留服务端那条,不重复', async () => {
       await svc.open('agent-node-lj-test');
       const pending = svc.sendText('你好');
-      await Promise.resolve();
+      /*
+       * 🔴 2026-11-09:首次发消息会先 `await commitNow()`(落库),
+       *    比原来多了一轮 await。必须连推几轮微任务,确保乐观消息已插入、
+       *    且发送已进入「等待 sendText 返回」的状态,否则下面 `releaseSend()`
+       *    时 `sendText` 还没被调用,Promise 永远挂着(超时)。
+       */
+      for (let k = 0; k < 5; k++) await Promise.resolve();
+      await new Promise((r) => setTimeout(r, 0));
       const localId = svc.messages()[0].localId!;
       expect(localId).toBeTruthy();
 

+ 240 - 5
src/app/core/chat.service.ts

@@ -12,6 +12,7 @@ import {
   MatrixProvision,
 } from './api/models';
 import { DEMO_MESSAGES } from './api/demo-data';
+import { extractS3Links, stripLinks } from './s3-link';
 import { environment } from '../../environments/environment';
 import { LiveService } from './live.service';
 import { SessionService } from './session.service';
@@ -145,7 +146,7 @@ export class ChatService {
    * 🔴 不区分的话,一个历史取不到的旧会话会显示成「下午好,我是 xxx」,
    *    用户以为会话还在、内容丢了,实际是被归属规则挡住了(实测)。
    */
-  private readonly _emptyReason = signal<'fresh' | 'unloaded' | null>(null);
+  private readonly _emptyReason = signal<'fresh' | 'unloaded' | 'removed' | null>(null);
   readonly emptyReason = this._emptyReason.asReadonly();
 
   readonly agentId = this._agentId.asReadonly();
@@ -825,6 +826,29 @@ export class ChatService {
               lastMessageAt: lastMessageAtOf(latest.id),
             }).id;
     } else if (!this.sessions.byId(sid) || this.sessions.byId(sid)!.messageCount === 0) {
+      /*
+       * 🔴 已软删的会话**不允许经由 URL 重新打开**(2026-11-09 用户裁决:
+       *    「列表项里面隐藏,并且不能通过 URL 直达」)。
+       *
+       * ⚠️ 判据必须是「**本地目录里根本没有这条**」,不能把「有这条但 0 条消息」
+       *    也算进来 —— 新建对话恰恰就是 0 条消息的会话,原来那样写会让
+       *    用户点「新建对话」后看到「该会话已删除」(实测反馈 2026-11-09)。
+       *
+       * 三重条件缺一不可:
+       *   ① 本地目录里没有这条(byId 取不到)
+       *   ② 云端墓碑里有它
+       *   ③ 不在本机「新建中」状态里(`_blankSessionId`)
+       */
+      const known = this.sessions.byId(sid);
+      if (!known && this._blankSessionId !== sid && this.sessions.isCloudRemoved(sid)) {
+        if (my !== this.seq) return;
+        this._sessionId.set(null);
+        this._agentId.set(agentId);
+        this._state.set('empty');
+        this._emptyReason.set('removed');
+        this._messages.set([]);
+        return;
+      }
       /*
        * 🔴 云端目录里暂时没有这条会话 —— 仍按地址打开它(2026-11-06)。
        *
@@ -904,6 +928,20 @@ export class ChatService {
      * 更早的历史仍由 `paginate()` 按需向上翻。
      */
     this._messages.set(all);
+    /*
+     * 🔴 历史消息里的云盘链接也要认(2026-11-09)。
+     *
+     * `appendIncoming` 只处理**实时到达**的消息。刷新页面或重新打开一条
+     * 老会话时,消息走的是这条回读路径 —— 如果不在这里提取,
+     * 「打开会话 → 右栏成果空 → 再发一条新消息才出现」,用户会以为成果丢了。
+     */
+    for (const m of all) {
+      if (m.sender !== 'agent') continue;
+      /* 与实时路径同口径:必须通过文件判定才收(排除 `https://s3.fmode.cn/...` 这类占位) */
+      for (const u of extractS3Links(m.text)) {
+        if (this.cardOfCloudLink(u)) this.sessions.addDeliverableUrl(sid, u);
+      }
+    }
 
     // ⑥ 连接状态由 SDK 同步事件驱动
     this._connection.set(this.mx.syncState() === 'connected' ? 'connected' : 'connecting');
@@ -1270,7 +1308,6 @@ export class ChatService {
         ts: Date.parse(msg.ts) || Date.now(),
         fromMe: true,
       });
-      this.sessions.commit(curSid);
       /*
        * 🔴 演示态:消息数必须落进本地目录(2026-11-06 实测)。
        *    宿主页面刷新前只写内存,目录里仍是「0 条消息」;`open()` 开头的
@@ -1298,6 +1335,22 @@ export class ChatService {
     this.pulse.noteUserAction(conversationId);
 
     try {
+      /*
+       * 🔴 首次发消息 = **先落库,落库成功才发送**(2026-11-09 用户要求)。
+       *
+       * 用户原话:「第一次发送消息的时候,才会开始持久化存储这个对话;
+       *           存储成功后,才会开始发送消息」。
+       *
+       * 原实现调 `commit()`(发出去就不管):消息照发、落库在后台跑。
+       * 一旦落库失败,消息已经进 Matrix,而目录里没有这条记录 ——
+       * 换设备就找不到这段对话,用户只在事后看到一条提示。
+       *
+       * 现在首次落库 `await`,失败直接落到下面的 catch:
+       * 消息标记为「发送失败」,**不往容器发**,用户可重试。
+       */
+      if (curSid && this.sessions.needsCommit(curSid)) {
+        await this.sessions.commitNow(curSid);
+      }
       if (environment.useMockData) {
         await this.simulateSend();
       } else {
@@ -1478,12 +1531,39 @@ export class ChatService {
           const mimeType = file.type || 'application/octet-stream';
           if (!this.roomId) throw new Error('会话未就绪');
           const sid = this._sessionId();
+          /*
+           * 🔴 「两条都存」(2026-11-09 用户裁决)。
+           *
+           * 用户要求:文件处理完必须上传云盘,之后 App 里查看**都走云盘**。
+           *
+           * ⚠️ 但**不能**像早期那样「只走 OBS」—— 容器只认 Matrix 的
+           *    `m.file` / `m.image` 原生事件,换成 S3 直链它会回「没收到附件」。
+           *
+           * 所以:Matrix 照常发(供容器读取与消息流回溯),
+           *       S3 **额外**存一份(供 App 长期查看与成果归档)。
+           * S3 失败**不阻断发送** —— 降级为只有 Matrix,并记一条告警。
+           */
           await this.mx.sendFile(
             this.roomId,
             file,
             { name: file.name, size: file.size, mimeType },
             sid ?? undefined,
           );
+          let cloudUrl: string | null = null;
+          try {
+            const up = await this.api.uploadFile(file);
+            cloudUrl = up.publicUrl;
+            /*
+             * 记进会话的成果列表(2026-11-09):用户要求的「成果按对话归档」。
+             * 上传成功立刻写,失败则只有 Matrix 那份,不记链接。
+             */
+            if (sid) this.sessions.addDeliverableUrl(sid, cloudUrl);
+          } catch (e) {
+            /* 如实记录:这条文件只在 Matrix,没进云盘 */
+            this._error.set(
+              `「${file.name}」已发送,但未存入云盘:${e instanceof Error ? e.message : '上传失败'}`,
+            );
+          }
           this._uploadPct.set(Math.round(((i + 1) / pending.length) * 100));
           // 本地预览用对象地址;Matrix 的 mxc 要等回读后才能转 http
           const displayUrl = URL.createObjectURL(file);
@@ -1493,6 +1573,8 @@ export class ChatService {
             size: file.size,
             mimeType,
             url: displayUrl,
+            /* 云盘地址(2026-11-09):有这个就说明两边都有,可在成果里长期访问 */
+            cloudUrl: cloudUrl ?? undefined,
           };
           this._messages.update((m) => [
             ...m,
@@ -1968,12 +2050,46 @@ export class ChatService {
        */
       const card = MatrixClientService.parseFileCard(m.body);
       const file = card ?? (m.media ? m.media : undefined);
+      /*
+       * 🔴 正文里的云盘链接 → 生成文件卡片(2026-11-09)。
+       *
+       * 容器交付成果时不发附件,而是把 `https://s3.fmode.cn/...` 写在回复正文里。
+       * 用户要求:「应该是显示卡片,而不是链接」。
+       *
+       * ⚠️ 2026-11-09 第二次修正 —— 两次都出过问题,记下来避免再犯:
+       *      ① 初版「剥出链接 + 从正文删掉」→ 正文只剩链接时变成**空消息**,
+       *         用户什么都看不到;容器一行一个链接时还会生成**重复卡片**。
+       *      ② 现在:**正文完整保留**(一个字不删),只为第一个链接生成卡片。
+       *         链接在正文里继续显示为可点击文字 —— 这是**有意为之**:
+       *         卡片给「文件」区块用,正文里的链接给用户直接点。
+       *
+       * 重复的两个卡片来自**容器一条回复被拆成多条事件**,
+       * 属于容器输出格式问题,已通过「链接独占一行、一次只给一个主文件」规范约束。
+       */
+      /*
+       * 正文里的云盘链接 → 文件卡片,并把链接从正文里**摘掉**(用户裁决:
+       * 「卡片统一在底部,正文里的 URL 隐去」)。
+       *
+       * 🔴 顺序不能错:**先摘链接 → 再判断剩余内容是否为空**。
+       *    上一版直接把 `text` 置成剥离后的结果,没判断空,
+       *    正文只剩链接时整条消息变成空白(用户实测「回复的文字没有了」)。
+       */
+      const cloudLinks = file ? [] : extractS3Links(m.body);
+      const linkCard = cloudLinks.length > 0 ? this.cardOfCloudLink(cloudLinks[0]) : undefined;
+      const bodyText = linkCard ? stripLinks(m.body, cloudLinks) : m.body;
       return {
         id: m.eventId || `${m.ts}-${m.sender}`,
         conversationId: cid,
         sender: m.sender === myUserId ? ('me' as const) : ('agent' as const),
         kind: m.kind as MessageKind,
-        text: file ? undefined : m.body,
+        /*
+         * 🔴 有附件时**也要保留正文**(2026-11-09 用户反馈「正文没文字了」)。
+         *
+         * 原实现 `text: file ? undefined : m.body` —— 只要消息带附件就把正文丢掉。
+         * 但容器常常是「正文说明 + 文件链接」写在同一条消息里,
+         * 丢掉正文后用户只看到一张卡片,不知道这个文件是干什么的。
+         */
+        text: bodyText || undefined,
         file: file
           ? {
               kind: 'file' as const,
@@ -1982,7 +2098,7 @@ export class ChatService {
               mimeType: file.mimeType,
               url: file.url,
             }
-          : undefined,
+          : linkCard,
         ts: new Date(m.ts).toISOString(),
         state: 'sent' as const,
         // 透传本地 id:合并时用它认领乐观消息(见 mergeTimeline)
@@ -1991,6 +2107,92 @@ export class ChatService {
     });
   }
 
+  /**
+   * 把一条云盘链接包成附件卡片。
+   *
+   * 云盘对象是 `public-read`,链接本身就能下载/预览,
+   * 所以 `url` 直接用它,`cloudUrl` 也指向它(后续「优先走云盘」逻辑由此生效)。
+   * 文件名与类型从 URL 推断。
+   */
+  private cardOfCloudLink(url: string): FileCard | undefined {
+    /*
+     * 🔴 先判断「这是不是文件」(2026-11-09)。
+     *
+     * 原实现只要正文里有云盘域名的链接就造卡 —— 但不全是文件:
+     *   · 目录:`https://s3.fmode.cn/bucket/`
+     *   · 无扩展名的路径:`https://s3.fmode.cn/user/xxx/report`
+     *   · 文档里引用的示例链接
+     * 这些被当成文件卡片显示,用户看到的是一堆莫名其妙的「文件」。
+     */
+    const path = url.split('?')[0].split('#')[0];
+    // 以 / 结尾 = 目录
+    if (path.endsWith('/')) return undefined;
+    const seg = decodeURIComponent(path.split('/').filter(Boolean).pop() ?? '');
+    /*
+     * 🔴 排除「省略号占位」(2026-11-09)。
+     *    容器/用户在举例时常写 `https://s3.fmode.cn/...`,
+     *    末段是 `...`,`lastIndexOf('.')` 会误判成「有扩展名」。
+     */
+    if (/^\.+$/.test(seg) || seg.includes('...') || seg.includes('…')) return undefined;
+    const dot = seg.lastIndexOf('.');
+    // 没有扩展名 = 不是文件
+    if (dot <= 0 || dot === seg.length - 1) return undefined;
+    const ext = seg.slice(dot + 1).toLowerCase();
+    /* 扩展名必须是纯字母且 ≥2 位(`a.1`、`x.` 这类不是文件) */
+    if (!/^[a-z]{2,5}$/.test(ext)) return undefined;
+
+    /*
+     * 扩展名白名单:只认「确实是文件」的类型。
+     * 不在名单里的一律不造卡(链接留在正文里当文字)。
+     */
+    const mimeByExt: Record<string, string> = {
+      md: 'text/markdown',
+      markdown: 'text/markdown',
+      html: 'text/html',
+      htm: 'text/html',
+      pdf: 'application/pdf',
+      txt: 'text/plain',
+      log: 'text/plain',
+      csv: 'text/csv',
+      tsv: 'text/tab-separated-values',
+      json: 'application/json',
+      xml: 'application/xml',
+      yml: 'text/yaml',
+      yaml: 'text/yaml',
+      png: 'image/png',
+      jpg: 'image/jpeg',
+      jpeg: 'image/jpeg',
+      gif: 'image/gif',
+      webp: 'image/webp',
+      svg: 'image/svg+xml',
+      xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
+      xls: 'application/vnd.ms-excel',
+      docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
+      doc: 'application/msword',
+      pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
+      zip: 'application/zip',
+      tar: 'application/x-tar',
+      gz: 'application/gzip',
+      mp4: 'video/mp4',
+      mp3: 'audio/mpeg',
+    };
+    const mime = mimeByExt[ext];
+    if (!mime) return undefined;
+
+    return {
+      kind: 'file',
+      name: seg,
+      /*
+       * 云盘链接不带文件大小。用 -1 表示「未知」,界面据此不显示「0 B」
+       * (0 B 会让人以为文件是空的,实测用户困惑)。
+       */
+      size: -1,
+      mimeType: mime,
+      url,
+      cloudUrl: url,
+    };
+  }
+
   /** 追加一条新到消息(按 eventId 去重;不属于当前会话的忽略) */
   private appendIncoming(m: MxMessage, myUserId: string): void {
     if (!this._agentId()) return;
@@ -2020,8 +2222,17 @@ export class ChatService {
     const localId = mapped?.localId;
     this._messages.update((list) => {
       if (!localId) return [...list, mapped];
+      /*
+       * 🔴 把被替换掉的本地消息一并交给 `mergeTimeline`(2026-11-09 修 C7)。
+       *
+       * 原写法是 `mergeTimeline([...rest, mapped], [])` —— 第二个参数传空数组,
+       * `localTs` 映射表因此为空,echo 认领会**静默失效**,ts 直接换成服务端时钟,
+       * 等待读秒从浏览器时钟跳到服务端时钟(用户实测 95s → 100s)。
+       * 这里把原本地消息传进去,让 localTs 回落真正生效。
+       */
+      const mine = list.filter((x) => x.id === localId);
       const rest = list.filter((x) => x.id !== localId);
-      return this.mergeTimeline([...rest, mapped], []);
+      return this.mergeTimeline([...rest, mapped], mine);
     });
     this.rememberDemoHistory();
     if (this._state() === 'empty') this._state.set('ready');
@@ -2031,6 +2242,30 @@ export class ChatService {
       ts: Date.parse(mapped.ts) || Date.now(),
       fromMe: mapped.sender === 'me',
     });
+    /*
+     * 容器发来的附件如果是云盘直链,记进本会话的成果列表(2026-11-09)。
+     * 用户要求「成果按对话归档」:只有出现在这条对话里的文件才算它的成果。
+     */
+    const fileUrl = mapped.file?.cloudUrl || (mapped.file?.url?.startsWith('https://s3.') ? mapped.file.url : undefined);
+    if (fileUrl) this.sessions.addDeliverableUrl(sid, fileUrl);
+    /*
+     * 🔴 正文里出现的 S3 链接也要认(2026-11-09 实测补充)。
+     *
+     * 容器产出的成果**不会发文件附件**,而是把云盘 URL 直接写在回复正文里。
+     * 只认 `mapped.file` 的话,用户右栏「成果」永远是空的(实测确认)。
+     *
+     * ⚠️ 2026-11-09 第二次修正:**必须同时满足三个条件**,否则会误收:
+     *   ① sender === 'agent'  —— 用户在提问里写的示例链接不算成果
+     *                            (实测:用户发「不要写 https://s3.fmode.cn/...」,
+     *                              结果那条示例被收成了「成果」)
+     *   ② 不是省略号占位     —— `https://s3.fmode.cn/...` 这种是举例,不是文件
+     *   ③ 通过文件判定       —— 有合法扩展名(复用 cardOfCloudLink 的规则)
+     */
+    if (mapped.sender === 'agent') {
+      for (const u of extractS3Links(mapped.text)) {
+        if (this.cardOfCloudLink(u)) this.sessions.addDeliverableUrl(sid, u);
+      }
+    }
     /*
      * 实时到达 + 对方发的 = 「正在回复」。
      * 「用户发的」不算 —— 我方刚发出消息不能说明对方在回。

+ 56 - 0
src/app/core/deliverables.service.spec.ts

@@ -192,5 +192,61 @@ describe('DeliverablesService', () => {
       expect(svc.byId('2')?.title).toBe('b');
       expect(svc.byId('nope')).toBeUndefined();
     });
+
+    /*
+     * 🔴 2026-11-09 用户裁决:成果按会话存放。
+     *    会话记录里有 `deliverableUrls`,只显示这些链接对应的成果。
+     */
+    it('🔴 bySessionUrls 只返回链接匹配的成果', async () => {
+      // 重建带 url 的样本(默认工厂 artifacts 为空)
+      apiStub.fetchDeliverables.and.returnValue(
+        Promise.resolve([
+          d('s1', '会话一成果', '2026-10-01', {
+            artifacts: [{ name: 'a.md', url: 'https://s3.fmode.cn/x/a.md', type: 'md' }],
+          }),
+          d('s2', '会话二成果', '2026-10-02', {
+            artifacts: [{ name: 'b.md', url: 'https://s3.fmode.cn/x/b.md', type: 'md' }],
+          }),
+        ]),
+      );
+      await svc.load();
+
+      const hit = svc.bySessionUrls(['https://s3.fmode.cn/x/b.md']);
+      expect(hit.map((x) => x.id)).withContext('只应命中会话二').toEqual(['s2']);
+    });
+
+    it('🔴 bySessionUrls 无 urls → 返回空,不回退到整个容器', () => {
+      expect(svc.bySessionUrls(undefined)).toEqual([]);
+      expect(svc.bySessionUrls([])).toEqual([]);
+    });
+
+    /*
+     * 🔴 2026-11-09 实测发现:用户上传的文件**不在 deliverables 表里**,
+     *    只存在于会话记录的 deliverableUrls 中。原实现只查表,
+     *    导致用户上传的文件在「成果」里永远看不到。
+     */
+    it('🔴 用户上传的文件(不在成果表里)也要按 URL 显示出来', () => {
+      const userFile = 'https://s3.fmode.cn/user/u/chat/202611/u-1794-e2e.txt';
+      const out = svc.bySessionUrls([userFile]);
+      expect(out.length).withContext('不在表里也要显示').toBe(1);
+      expect(out[0].artifacts[0].url).toBe(userFile);
+      expect(out[0].filename).withContext('要有文件名,否则右栏显示「暂无附件」').toBe('u-1794-e2e.txt');
+    });
+
+    it('🔴 成果表里的链接 + 用户上传的文件 可以混排', async () => {
+      apiStub.fetchDeliverables.and.returnValue(
+        Promise.resolve([
+          d('s1', '容器成果', '2026-10-01', {
+            artifacts: [{ name: 'a.md', url: 'https://s3.fmode.cn/d/a.md', type: 'md' }],
+          }),
+        ]),
+      );
+      await svc.load();
+      const userFile = 'https://s3.fmode.cn/user/u/chat/x.txt';
+      const out = svc.bySessionUrls(['https://s3.fmode.cn/d/a.md', userFile]);
+      expect(out.length).toBe(2);
+      expect(out[0].title).withContext('表里的用富信息').toBe('容器成果');
+      expect(out[1].artifacts[0].url).toBe(userFile);
+    });
   });
 });

+ 83 - 0
src/app/core/deliverables.service.ts

@@ -146,6 +146,73 @@ export class DeliverablesService {
     return this._items().filter((d) => d.agentId === agentId);
   }
 
+  /**
+   * 按**会话**取成果(2026-11-09 用户裁决)。
+   *
+   * 用户原话:「所有成果都是和它们的 Session 绑定的,不是这个容器里所有成果」。
+   *
+   * ## 数据来源
+   *
+   * `deliverableUrls` 里有两个来源,**都要显示**:
+   *   ① 容器产出的成果 → 能在 `deliverables` 表里匹配到,有标题/版本等富信息
+   *   ② **用户上传的文件** → 压根不在 `deliverables` 表里,只有 URL
+   *
+   * 🔴 **宁缺勿错**:没有 `urls` 就返回空,绝不回退到「整个容器的成果」。
+   *
+   * @param urls 会话记录上的 `deliverableUrls`
+   */
+  bySessionUrls(urls: readonly string[] | undefined): Deliverable[] {
+    if (!urls || urls.length === 0) return [];
+    const out: Deliverable[] = [];
+    for (const url of urls) {
+      // ① 先在成果表里找完整记录
+      const hit = this._items().find((d) => d.artifacts.some((a) => a.url === url));
+      if (hit) {
+        // 只保留本会话引用的那个附件,避免把同一成果的其他附件也带出来
+        const only = hit.artifacts.filter((a) => a.url === url);
+        out.push({ ...hit, artifacts: only.length ? only : hit.artifacts });
+        continue;
+      }
+      // ② 成果表里没有(用户上传的文件)→ 按 URL 造一条最小记录
+      const name = fileNameOfUrl(url);
+      out.push({
+        id: `url:${url}`,
+        agentId: '',
+        title: name,
+        /* filename/size 也让右栏能显示「有附件」而不是「暂无附件」 */
+        filename: name,
+        artifacts: [{ name, url, type: '' }],
+        tags: [],
+        deliveredAt: '',
+        state: 'delivered',
+      });
+    }
+    return out;
+  }
+
+  /**
+   * 从**消息流**里取容器产出的成果(2026-11-09)。
+   *
+   * 为什么除了 `bySessionUrls` 还要这个:
+   * 会话记录的 `deliverableUrls` 要等落库+回读才生效,中间有个时间窗;
+   * 而且历史上部分链接没被写进去(实测:最新会话 `urls=[]`)。
+   * 消息流是**实时的、一定有的**,用它兜底能让成果立刻可见。
+   *
+   * @param files 本会话里 `sender === 'agent'` 的附件
+   */
+  fromAgentFiles(files: readonly { name: string; url: string }[]): Deliverable[] {
+    return files.map((f) => ({
+      id: `msg:${f.url}`,
+      agentId: '',
+      title: f.name,
+      filename: f.name,
+      artifacts: [{ name: f.name, url: f.url, type: '' }],
+      tags: [],
+      deliveredAt: '',
+      state: 'delivered' as const,
+    }));
+  }
+
   byId(id: string): Deliverable | undefined {
     return this._items().find((d) => d.id === id);
   }
@@ -160,3 +227,19 @@ export class DeliverablesService {
       .some((value) => value.toLowerCase().includes(q));
   }
 }
+
+/**
+ * 从云盘 URL 里取文件名(用于「只有 URL、成果表里没有」的条目)。
+ *
+ * 例:`https://s3.fmode.cn/user/x/chat/202611/x-1793-e2e.txt` → `x-1793-e2e.txt`
+ * 取不到时回落到「未命名文件」,不抛错。
+ */
+function fileNameOfUrl(url: string): string {
+  try {
+    const path = url.split('?')[0];
+    const seg = path.split('/').filter(Boolean).pop() ?? '';
+    return decodeURIComponent(seg) || '未命名文件';
+  } catch {
+    return '未命名文件';
+  }
+}

+ 6 - 0
src/app/core/format.ts

@@ -17,6 +17,12 @@ import {
 /** 字节数 → `1.1 KB` */
 export function fmtSize(bytes?: number): string {
   if (bytes == null || Number.isNaN(bytes)) return '—';
+  /*
+   * 负数 = 「大小未知」(2026-11-09)。
+   * 云盘链接不带文件大小,若显示成「0 B」用户会以为文件是空的,
+   * 所以用 -1 表示未知,这里不显示。
+   */
+  if (bytes < 0) return '';
   if (bytes < 1024) return `${bytes} B`;
   if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
   return `${(bytes / 1024 / 1024).toFixed(1)} MB`;

+ 3 - 3
src/app/core/matrix-client.service.ts

@@ -769,7 +769,7 @@ export class MatrixClientService implements OnDestroy {
           if (!m) return undefined;
           const http = this.mxcUrlToHttp(m.mxcUrl);
           return http
-            ? { name: m.name, url: http, mimeType: m.mimeType, size: m.size ?? 0 }
+            ? { name: m.name, url: http, mimeType: m.mimeType, size: m.size ?? -1 }
             : undefined;
         })(),
         ts: e.getTs(),
@@ -1102,7 +1102,7 @@ export class MatrixClientService implements OnDestroy {
         const m = parseNativeMedia(ev.getContent());
         if (!m) return undefined;
         const http = this.mxcUrlToHttp(m.mxcUrl);
-        return http ? { name: m.name, url: http, mimeType: m.mimeType, size: m.size ?? 0 } : undefined;
+        return http ? { name: m.name, url: http, mimeType: m.mimeType, size: m.size ?? -1 } : undefined;
       })();
 
       const msg: MxMessage = {
@@ -1148,7 +1148,7 @@ export class MatrixClientService implements OnDestroy {
       if ((j.kind === 'file' || j.type === 'fcard') && j.url && j.name) {
         return {
           name: String(j.name),
-          size: Number(j.size) || 0,
+          size: Number(j.size) || -1,
           mimeType: String(j.mimeType ?? j.mime ?? ''),
           url: String(j.url),
         };

+ 10 - 0
src/app/core/mobile-chat-view.service.ts

@@ -117,6 +117,16 @@ export class MobileChatViewService {
     return this.deliverablesSvc.all().filter((d) => d.agentId === agentId);
   }
 
+  /**
+   * 按**会话**取成果(2026-11-09 用户裁决):只认会话记录上的 `deliverableUrls`。
+   *
+   * 🔴 与 `deliverablesOf`(按容器)的区别:这是「本会话的成果」,
+   *    没有 urls 就返回空,不回退到容器全量。
+   */
+  bySessionUrlsOf(urls: readonly string[] | undefined): Deliverable[] {
+    return this.deliverablesSvc.bySessionUrls(urls);
+  }
+
   /** 按 id 取一行(会话详情页标题/副标题用) */
   rowOf(sessionId: string): SessionRow | undefined {
     const s = this.sessionsSvc.byId(sessionId);

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

@@ -0,0 +1,92 @@
+import { extractS3Links, stripLinks } from './s3-link';
+
+/**
+ * 单测:从消息正文提取云盘链接(2026-11-09)
+ *
+ * 背景:容器产出的成果不发附件,只把 URL 写在正文里。
+ */
+describe('extractS3Links', () => {
+  it('提取正文里的 s3 链接', () => {
+    const text = '公网 URL:\n\nhttps://s3.fmode.cn/user/x/c-result.md\n\n以上。';
+    expect(extractS3Links(text)).toEqual(['https://s3.fmode.cn/user/x/c-result.md']);
+  });
+
+  it('一行里多个链接都能提取', () => {
+    const text = 'A https://s3.fmode.cn/a.md B https://s3.fmode.cn/b.xlsx';
+    expect(extractS3Links(text)).toEqual([
+      'https://s3.fmode.cn/a.md',
+      'https://s3.fmode.cn/b.xlsx',
+    ]);
+  });
+
+  it('🔴 去掉句子末尾的中文标点', () => {
+    expect(extractS3Links('文件在 https://s3.fmode.cn/x/a.md。')).toEqual([
+      'https://s3.fmode.cn/x/a.md',
+    ]);
+    expect(extractS3Links('(见 https://s3.fmode.cn/x/a.md)')).toEqual([
+      'https://s3.fmode.cn/x/a.md',
+    ]);
+  });
+
+  it('🔴 非云盘域名不提取(避免把普通链接当成果)', () => {
+    expect(extractS3Links('官网 https://example.com/a.md')).toEqual([]);
+    expect(extractS3Links('https://matrix.fmode.cn/foo')).toEqual([]);
+  });
+
+  it('去重:同一链接出现两次只返回一次', () => {
+    const u = 'https://s3.fmode.cn/x/a.md';
+    expect(extractS3Links(`${u} 和 ${u}`)).toEqual([u]);
+  });
+
+  it('空输入安全', () => {
+    expect(extractS3Links('')).toEqual([]);
+    expect(extractS3Links(undefined)).toEqual([]);
+    expect(extractS3Links(null)).toEqual([]);
+  });
+
+  it('没有链接的普通文本返回空', () => {
+    expect(extractS3Links('收到,已经处理完毕。')).toEqual([]);
+  });
+});
+
+/**
+ * stripLinks:正文隐去链接(2026-11-09 用户裁决)
+ *
+ * 🔴 上一版的 bug 就是这里:剥掉链接后没判断剩余内容,
+ *    正文只剩链接时整条消息变成空白,用户「回复的文字没有了」。
+ */
+describe('stripLinks', () => {
+  const U = 'https://s3.fmode.cn/user/x/a.md';
+
+  it('🔴 正文有内容 → 保留正文 + 摘掉链接', () => {
+    const out = stripLinks(`本周工作计划已生成。\n\n${U}`, [U]);
+    expect(out).toBe('本周工作计划已生成。');
+  });
+
+  it('🔴 正文只有链接 → 返回空串(调用方据此只显示卡片)', () => {
+    expect(stripLinks(U, [U])).toBe('');
+  });
+
+  it('🔴 摘掉链接后,只剩前缀的行一并删除', () => {
+    const out = stripLinks(`说明文字。\n\n**下载链接:** ${U}`, [U]);
+    expect(out).toBe('说明文字。');
+    expect(out).not.toContain('下载链接');
+  });
+
+  it('链接在中间时,前后正文都保留', () => {
+    const out = stripLinks(`开头。\n\n${U}\n\n结尾。`, [U]);
+    expect(out).toContain('开头。');
+    expect(out).toContain('结尾。');
+    expect(out).not.toContain('s3.fmode.cn');
+  });
+
+  it('没有链接时不改动原文', () => {
+    const src = '收到,已经处理完毕。';
+    expect(stripLinks(src, [])).toBe(src);
+  });
+
+  it('空输入安全', () => {
+    expect(stripLinks('', [U])).toBe('');
+    expect(stripLinks(undefined, [U])).toBe('');
+  });
+});

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

@@ -0,0 +1,91 @@
+/**
+ * 从消息正文里提取云盘链接
+ *
+ * ## 为什么需要
+ *
+ * 两类文件来源不同:
+ * - **用户上传**:发的是 Matrix 文件附件(`m.file`),有 `file` 字段
+ * - **容器成果**:容器把文件传到云盘后,**把 URL 写在回复正文里**,不发附件
+ *
+ * 实测(2026-11-09):只认附件字段的话,容器产出的成果在右栏「成果」里
+ * 完全看不到 —— 用户会以为文件丢了。
+ *
+ * ## 安全
+ *
+ * 只提取**本平台云盘域名**下的链接,不把任意 URL 都当成成果。
+ */
+
+/** 云盘域名白名单(与 API 契约里的 OBS 公网前缀一致) */
+const CLOUD_HOSTS = ['s3.fmode.cn', 'file-cloud.fmode.cn'];
+
+/** URL 里常见的中英文边界符,遇到即截断 */
+const TRAILING_JUNK = /[)\]>,,。;;、"'<>)】」》]+$/;
+
+/**
+ * 从一段文本里提取云盘链接。
+ *
+ * @param text 消息正文
+ * @returns 去重后的链接数组(保持出现顺序);无匹配返回空数组
+ */
+export function extractS3Links(text: string | undefined | null): string[] {
+  if (!text) return [];
+
+  const out: string[] = [];
+  /* 只匹配 https,且必须落在白名单域名下 */
+  const re = /https:\/\/([a-z0-9.-]+)\/[^\s一-龥]*/gi;
+  let m: RegExpExecArray | null;
+  while ((m = re.exec(text)) !== null) {
+    const host = m[1].toLowerCase();
+    if (!CLOUD_HOSTS.includes(host)) continue;
+    // 去掉句末标点、右括号等尾巴
+    const url = m[0].replace(TRAILING_JUNK, '');
+    if (url && !out.includes(url)) out.push(url);
+  }
+  return out;
+}
+
+/**
+ * 把正文里的指定链接**摘掉**,返回清理后的正文。
+ *
+ * ## 用途
+ *
+ * 用户裁决:文件卡片统一放在底部,正文里的 URL 隐去。
+ *
+ * ## 🔴 必须处理的坑
+ *
+ * 只把链接删掉是不够的,会留下空行、孤立的「下载链接:」前缀:
+ *
+ * ```
+ * 本周工作计划已生成。          ← 保留
+ *
+ * 下载链接:                     ← 删掉(只剩前缀,没有链接)
+ * https://s3.fmode.cn/xxx.md    ← 删掉
+ * ```
+ *
+ * @param text  原文
+ * @param links 要摘掉的链接(通常来自 `extractS3Links`)
+ * @returns 清理后的正文;**可能为空字符串** —— 调用方必须判断空再决定是否渲染
+ */
+export function stripLinks(text: string | undefined | null, links: readonly string[]): string {
+  if (!text) return '';
+  if (links.length === 0) return text.trim();
+
+  let out = text;
+  for (const l of links) out = out.split(l).join('');
+
+  const lines = out.split('\n');
+  const kept: string[] = [];
+  for (const raw of lines) {
+    const line = raw.trim();
+    if (!line) continue;
+    /*
+     * 整行只剩「短标签 + 冒号 + 修饰符」的,一并删掉 ——
+     * 那是给链接配的前缀,链接没了它就是一句废话。
+     * 覆盖:`下载链接:`、`**下载链接:**`、`文件:`、`## 下载地址:` 等。
+     */
+    if (/^[*_#\s>·-]*[^。!?]{1,14}[::][*_\s]*$/.test(line)) continue;
+    kept.push(line);
+  }
+  // 每行之间用单空行分隔:保留段落结构,不留下大片空白
+  return kept.join('\n\n').trim();
+}

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

@@ -67,6 +67,12 @@ describe('TaskPulseService', () => {
     expect(svc.describe(agent({ status: 'idle' })).active).toBeFalsy();
   });
 
+  it('容器报 running → 显示忙并给出任务文案', () => {
+    svc.noteUserAction('a1');
+    const line = svc.describe(agent({ status: 'running' }));
+    expect(line.active).toBeTrue();
+  });
+
   it('快照超过 90 秒没更新 → 报「状态未同步」而不是假装空闲', () => {
     const old = new Date(Date.now() - 10 * 60_000).toISOString();
     const line = svc.describe(agent({ status: 'idle', heartbeatAt: old }));

+ 42 - 16
src/app/core/task-pulse.service.ts

@@ -165,13 +165,14 @@ export class TaskPulseService {
    * `localStart` 只在下一次上报到来之前有效:容器已在处理时它自然会变成 running,
    * 容器报 idle 时**以容器为准**,本地推测立刻作废。
    */
-  isBusy(agent: Agent | undefined): boolean {
+  isBusy(agent: Agent | undefined, now = Date.now()): 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);
+    const local = this._localStart().get(agent.id);
+    return local !== undefined && now - local < REPLY_QUIET_MS;
   }
 
   /** 右侧「最近动态」:只列本会话的翻转(用户真正关心的是它在干什么) */
@@ -206,25 +207,27 @@ export class TaskPulseService {
     if (resolved.state === 'offline') {
       return { text: '已离线', tone: 'danger' };
     }
-    if (agent.status === 'running' && !isReporterSelfTalk(agent)) {
-      const task = agent.currentTask?.trim() || agent.currentAction?.trim();
-      return { text: task || '正在处理', tone: 'success', active: true };
-    }
-    if (local && this.isBusy(agent)) {
+    /*
+     * 🔴 2026-11-09 调整顺序(C3):
+     *
+     * 原顺序是「status=running → 心跳未同步 → 正在回复」,有两个问题:
+     *   ① `local && isBusy`(用户刚发消息)排在 `status=running` 之后,
+     *      容器报 running 时永远走不到 → 用户发完消息看不到「刚刚开始」;
+     *   ② 心跳过期(>90s)检查排在「最近有消息进来」之前 → 容器正在回话时,
+     *      顶栏却显示「状态未同步」,与正文的「正在输出…」直接打架。
+     *
+     * 正确优先级:**用户动作 / 实时消息流 > 容器自报 > 心跳新鲜度**。
+     * 理由:消息流是实时的,容器上报是分钟级的;越实时的信号越应该优先。
+     */
+    if (local && this.isBusy(agent, now)) {
       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,但可能**还在往外发消息**(用户实测:还在回复就显示「已完成」)。
+    }    /*
+     * 容器已经回 idle,但可能**还在往外发消息**(用户实测:还在回复就显示「已完成」)。
      *
      * 容器的运行态是分钟级上报的,turn 结束与最后一条消息之间可能差几十秒;
      * 而消息流是实时的。所以「最近有消息进来」比「上报说 idle」更新鲜,
@@ -234,6 +237,15 @@ export class TaskPulseService {
     if (quietMs < REPLY_QUIET_MS) {
       return { text: '正在回复…', tone: 'info', active: true };
     }
+    if (agent.status === 'running' && !isReporterSelfTalk(agent)) {
+      const task = agent.currentTask?.trim() || agent.currentAction?.trim();
+      return { text: task || '正在处理', tone: 'success', active: true };
+    }
+    // 容器说 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' };
+    }
 
     const last = this.lastFinish(agent.id);
     if (last?.durationMs != null && now - last.at < 10 * 60_000) {
@@ -263,7 +275,21 @@ export class TaskPulseService {
             task: prev.task,
             durationMs: Math.max(0, now - prev.at),
           });
-          const next = new Map(this._localStart());
+        }
+      }
+      /*
+       * 本地「我刚发出消息」标记的清理:不能只挂在「running → 非 running」
+       * 这条翻转上(2026-11-09)。
+       *
+       * 原因:若容器**从头到尾没进过 running**(比如它自报一直是 idle,
+       * 或者上报间隔很长错过了整个翻转),`prev.running` 一直是 false,
+       * 那段清理代码永远走不到,`localStart` 就会一直挂到进程结束。
+       * 容器明确报非 running 时直接清掉即可,不需要等翻转。
+       */
+      if (!running) {
+        const cur = this._localStart();
+        if (cur.has(a.id)) {
+          const next = new Map(cur);
           next.delete(a.id);
           this._localStart.set(next);
         }

+ 130 - 7
src/app/desktop/pages/chat/chat.ts

@@ -5,7 +5,9 @@ import {
   effect,
   ElementRef,
   inject,
+  OnDestroy,
   signal,
+  untracked,
   viewChild,
 } from '@angular/core';
 import { ActivatedRoute, Router } from '@angular/router';
@@ -369,6 +371,20 @@ interface ConversationRow {
                       新建对话
                     </ui-button>
                   </div>
+                } @else if (chat.emptyReason() === 'removed') {
+                <!--
+                  已软删的会话(2026-11-09 用户裁决):列表里隐藏,且 URL 不可直达。
+                  🔴 不提供「继续发消息」入口 —— 会话已经删了,再发只会让用户困惑
+                     「为什么删掉的还会出现」。
+                -->
+                <div class="no-session">
+                  <i class="fa-solid fa-trash-can" aria-hidden="true"></i>
+                  <h2 class="no-session-title">该会话已删除</h2>
+                  <p class="no-session-sub">这条对话已经被删除,无法再查看或继续发送消息。</p>
+                  <ui-button variant="primary" size="sm" (click)="requestNewSession()">
+                    新建对话
+                  </ui-button>
+                </div>
                 } @else if (chat.emptyReason() === 'unloaded') {
                 <!--
                   有会话、但一条消息也归属不到它(2026-11-09)。
@@ -1861,10 +1877,15 @@ interface ConversationRow {
     `,
   ],
 })
-export class DkChatPage {
+export class DkChatPage implements OnDestroy {
   readonly chat = inject(ChatService);
   readonly live = inject(LiveService);
 
+  ngOnDestroy(): void {
+    // 离开会话停掉每秒时钟(连接本身保持,与既有行为一致)
+    this.stopClock();
+  }
+
   /**
    * 容器是否不可用(2026-11-08):全站统一口径,判心跳 + 防上报器自指。
    * 用于决定是否显示顶部告警徽标。
@@ -2085,6 +2106,11 @@ export class DkChatPage {
    */
   readonly aiRows = computed<AiRow[]>(() => {
     const out: AiRow[] = [];
+    /*
+     * 🔴 依赖 `clockTick`(2026-11-09,C2):`now` 本身是快照,不进依赖图。
+     *    没有这一行,typing 恒真时 `aiRows` 永不重算,回合停不下、秒数也不走。
+     */
+    this.clockTick();
     const now = Date.now();
 
     for (const t of turnsFromDm(
@@ -2183,7 +2209,10 @@ export class DkChatPage {
 
   /** 上一次看到的「回合内容长度」(正文 + 步骤),用于内容续写时贴底 */
   private lastContentLen = -1;
-
+  /** 每秒时钟(C2):有活着的回合时驱动 `aiRows` 重算,空闲即停 */
+  private clockHandle: ReturnType<typeof setInterval> | null = null;
+  /** 时钟计数值 —— 唯一作用是让 `aiRows` 在依赖图里跟着它重算 */
+  private readonly clockTick = signal(0);
   /** 贴底节流时间戳 */
   private lastStickAt = 0;
   /** 用户是否主动上翻离开了底部(true 时新消息不强行拉回) */
@@ -2197,7 +2226,14 @@ export class DkChatPage {
    * 🔴 必须同时**打开右栏**:预览已经改成右栏内联,
    *    若用户之前收起了右栏,只设 previewFile 是看不到任何反应的(实测 bug)。
    */
-  openFile(f: { url: string; name: string; mimeType: string; size?: number }): void {
+  openFile(f: {
+    url: string;
+    name: string;
+    mimeType: string;
+    size?: number;
+    /** 云盘地址(2026-11-09):有则优先用它,长期可访问 */
+    cloudUrl?: string;
+  }): void {
     const kind = previewKindOf(f.name || f.url, f.mimeType);
     /*
      * 图片 / 音视频不进右栏(用户裁决 2026-10-30):
@@ -2220,7 +2256,13 @@ export class DkChatPage {
       this.mediaFile.set({ url: f.url, name: f.name, kind });
       return;
     }
-    this.previewFile.set({ url: f.url, name: f.name, mimeType: f.mimeType, size: f.size });
+    /*
+     * 🔴 优先用云盘地址(2026-11-09 用户要求「之后查看都走云盘」)。
+     *    `url` 可能是 `blob:`(本地预览,刷新即失效);`cloudUrl` 才是长期可访问的。
+     *    没有 cloudUrl 时保持原行为,不假装有。
+     */
+    const src = f.cloudUrl || f.url;
+    this.previewFile.set({ url: src, name: f.name, mimeType: f.mimeType, size: f.size });
     this.railOpen.set(true);
   }
 
@@ -2275,13 +2317,25 @@ export class DkChatPage {
     );
   });
 
-  /** 本会话里的全部文件(按出现顺序,同 url+name 去重) */
+  /**
+   * 「文件」页签 = **本会话里用户自己上传的附件**(2026-11-09 用户指正)。
+   *
+   * 🔴 语义要分清,否则同一个文件在两个页签各显示一次:
+   *   · 文件 = 我发给容器的(sender === 'me')
+   *   · 成果 = 容器产出的(sender === 'agent')→ 见 `agentDeliverables`
+   *
+   * 原来这里不看 sender,容器的成果也进了「文件」,
+   * 于是同一份 weekly-plan.md 在「文件」和「成果」各出现一次,
+   * 用户质疑「AI 输出的应该是成果,不应该是文件」。
+   */
   readonly sessionFiles = computed(() => {
     const seen = new Set<string>();
     const out: { name: string; url: string; mimeType: string; size: number }[] = [];
     for (const m of this.chat.messages()) {
       const f = m.file;
       if (!f || !f.url) continue;
+      // 只收「我发的」附件
+      if (m.sender !== 'me') continue;
       const key = f.url + f.name;
       if (seen.has(key)) continue;
       seen.add(key);
@@ -2309,9 +2363,36 @@ export class DkChatPage {
     return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
   }
 
+  /**
+   * 右栏「成果」= **本会话里容器产出的文件**(2026-11-09 用户指正)。
+   *
+   * 语义分界:
+   *   · 文件(`sessionFiles`)= 我上传的 → sender === 'me'
+   *   · 成果(这里)         = 容器产出的 → sender === 'agent'
+   *
+   * 两个来源合并去重:
+   *   ① 会话记录 `deliverableUrls`(落库后的权威来源,可能带成果表富信息)
+   *   ② 消息流里 agent 发的文件(实时兜底,不依赖落库时间窗)
+   */
   readonly agentDeliverables = computed(() => {
-    const id = this.chat.agentId();
-    return id ? this.deliverables.byAgent(id) : [];
+    const fromRecord = this.deliverables.bySessionUrls(
+      this.chat.currentSession()?.deliverableUrls,
+    );
+    const agentFiles = this.chat
+      .messages()
+      .filter((m) => m.sender === 'agent' && m.file?.url)
+      .map((m) => m.file!);
+    const fromMessages = this.deliverables.fromAgentFiles(agentFiles);
+
+    const seen = new Set<string>();
+    const out = [];
+    for (const d of [...fromRecord, ...fromMessages]) {
+      const url = d.artifacts[0]?.url ?? d.id;
+      if (seen.has(url)) continue;
+      seen.add(url);
+      out.push(d);
+    }
+    return out;
   });
 
   constructor() {
@@ -2354,6 +2435,22 @@ export class DkChatPage {
       this.stickToBottom();
     });
 
+    /*
+     * 🔴 每秒时钟(2026-11-09,C2)。
+     *
+     * `aiRows` 里的 `now` 是 `Date.now()` 快照 —— 只有 `messages()` 或
+     * `peerTyping()` 变化时才重算。容器若一直报 typing、又不再发新消息,
+     * 回合的「25 秒静默」永远等不到,卡片会一直挂在「正在输出…」
+     * (用户实测 163 秒)。
+     *
+     * 这里每秒推进一次 `now`,让静默判定真正随时间走。
+     * 没有活着的回合时不起表,避免空闲页面白跑定时器。
+     */
+    effect(() => {
+      this.aiRows();
+      untracked(() => this.syncClock());
+    });
+
     /*
      * 正在输入指示器出现时贴底(2026-11-01:已移入消息流末尾)。
      *
@@ -2828,6 +2925,32 @@ export class DkChatPage {
     if (el) el.scrollTop = el.scrollHeight;
   }
 
+  /**
+   * 每秒时钟开关(2026-11-09,C2)。
+   *
+   * 只有存在 `live` 的回合时才起表:静默判定与读秒都需要时间推进,
+   * 但没有活着的回合时起表就是白烧 CPU。
+   * 每走一格检查一次,回合都结束后自动停表。
+   */
+  private syncClock(): void {
+    const live = this.aiRows().some((r) => r.kind === 'turn' && r.turn.live);
+    if (live && this.clockHandle === null) {
+      this.clockHandle = setInterval(() => {
+        this.clockTick.update((n) => n + 1);
+        if (!this.aiRows().some((r) => r.kind === 'turn' && r.turn.live)) this.stopClock();
+      }, 1000);
+    } else if (!live && this.clockHandle !== null) {
+      this.stopClock();
+    }
+  }
+
+  private stopClock(): void {
+    if (this.clockHandle !== null) {
+      clearInterval(this.clockHandle);
+      this.clockHandle = null;
+    }
+  }
+
   /**
    * AC-04.4:滚动到顶部附近(<80px)自动加载更早历史。
    *

+ 31 - 3
src/app/mobile/components/m-app-bar/m-app-bar.ts

@@ -1,4 +1,4 @@
-import { ChangeDetectionStrategy, Component, input, output } from '@angular/core';
+import { ChangeDetectionStrategy, Component, computed, input, output } from '@angular/core';
 
 /**
  * 页面顶栏 —— Demo 的「左右圆形浮动按钮 + 居中标题」(`.navbar` / `.fab`)
@@ -28,7 +28,7 @@ import { ChangeDetectionStrategy, Component, input, output } from '@angular/core
         <span class="m-fab-spacer" aria-hidden="true"></span>
       }
 
-      <div class="m-title">
+      <div class="m-title" [style.right]="titleRight()">
         <span class="m-title-main">{{ title() }}</span>
         @if (subtitle()) {
           <small>{{ subtitle() }}</small>
@@ -103,6 +103,11 @@ import { ChangeDetectionStrategy, Component, input, output } from '@angular/core
       .m-title {
         position: absolute;
         left: var(--touch-min);
+        /*
+         * right 由模板绑定 titleRight() 计算(按钮数 × 44px + 余量)。
+         * 🔴 2026-11-09:原来写死一行 44px,会话页有 2 个按钮时标题会盖住第二个。
+         * 这里保留默认值,实际值以 [style.right] 为准。
+         */
         right: var(--touch-min);
         top: var(--mob-safe-top);
         height: calc(var(--mob-navbar-h) - var(--sp-2));
@@ -122,7 +127,14 @@ import { ChangeDetectionStrategy, Component, input, output } from '@angular/core
       }
       .m-title-main {
         display: block;
-        max-width: 100%;
+        /*
+         * 70%:标题最多占顶栏宽度的七成,超出直接省略号。
+         *
+         * 🔴 2026-11-09 用户实测指正:原先写 100% 会顶满整个标题层,
+         *    配合右侧 2 个按钮时视觉上更挤。70% 留出呼吸空间,
+         *    长标题也不会贴到按钮边上。
+         */
+        max-width: 70%;
         overflow: hidden;
         text-overflow: ellipsis;
         white-space: nowrap;
@@ -154,6 +166,22 @@ export class MAppBarComponent {
   readonly menu = input(false);
   /** 右侧是否已有内容(父组件通过 `[barRight]` 投影);false 时渲染占位保持标题居中 */
   readonly hasRightContent = input(true);
+  /**
+   * 右侧按钮数量(2026-11-09)。
+   *
+   * 标题层是绝对定位并铺满的,必须按实际按钮数预留宽度,
+   * 否则长标题会盖住按钮(用户实测:会话页 2 个按钮被盖住第二个)。
+   */
+  readonly rightCount = input(1);
+
+  /**
+   * 标题层的 `right` 值 = 按钮数 × 44px + 16px 余量。
+   *
+   * 🔴 不用 CSS 变量传递:实测 Angular 的 `[style.--x]` 对自定义属性不生效
+   *    (computed 里读到空字符串),所以直接算出像素值绑到 `right`。
+   * 余量取 16:按钮之间还有 gap,实测 8px 会压到第一个按钮左边缘。
+   */
+  readonly titleRight = computed(() => `${this.rightCount() * 44 + 28}px`);
 
   readonly backTap = output<void>();
   readonly menuTap = output<void>();

+ 29 - 10
src/app/mobile/components/m-msg/m-msg.ts

@@ -51,16 +51,20 @@ import { previewKindOf } from '../../../components/preview/file-kind';
             [ngTemplateOutlet]="fileBlock"
             [ngTemplateOutletContext]="{ $implicit: f, own: true }"
           />
-        } @else if (processy()) {
-          @for (b of blocks(); track $index) {
-            @if (b.kind === 'process') {
-              <app-tool-activity class="m-own-activity" [block]="b" />
-            } @else if (b.kind === 'text') {
-              <div class="m-bubble-me"><app-md-render [text]="b.text" [compact]="true" /></div>
+        }
+        <!-- 正文与附件并列(2026-11-09),有文字就渲染 -->
+        @if (bodyText()) {
+          @if (processy()) {
+            @for (b of blocks(); track $index) {
+              @if (b.kind === 'process') {
+                <app-tool-activity class="m-own-activity" [block]="b" />
+              } @else if (b.kind === 'text') {
+                <div class="m-bubble-me"><app-md-render [text]="b.text" [compact]="true" /></div>
+              }
             }
+          } @else {
+            <div class="m-bubble-me">{{ bodyText() }}</div>
           }
-        } @else {
-          <div class="m-bubble-me">{{ message().text }}</div>
         }
         <div class="m-msg-state">
           @if (message().state === 'sending') {
@@ -78,12 +82,18 @@ import { previewKindOf } from '../../../components/preview/file-kind';
     } @else {
       <!-- ===== 对方:无头像,名称 + 时间单行 ===== -->
       <article class="m-msg-agent">
+        <!--
+          🔴 文件与正文并列渲染(2026-11-09 与桌面端同构)。
+          原结构是「有文件就不渲染正文」,一条消息同时有说明文字和附件时
+          正文会整个消失。
+        -->
         @if (message().file; as f) {
           <ng-container
             [ngTemplateOutlet]="fileBlock"
             [ngTemplateOutletContext]="{ $implicit: f, own: false }"
           />
-        } @else {
+        }
+        @if (bodyText()) {
           @if (processy()) {
             @for (b of blocks(); track $index) {
               @if (b.kind === 'process') {
@@ -93,7 +103,7 @@ import { previewKindOf } from '../../../components/preview/file-kind';
               }
             }
           } @else {
-            <div class="m-md"><app-md-render [text]="message().text ?? ''" [compact]="true" /></div>
+            <div class="m-md"><app-md-render [text]="bodyText()" [compact]="true" /></div>
           }
         }
 
@@ -401,6 +411,15 @@ export class MMsgComponent {
   protected readonly fmtSize = fmtSize;
   protected readonly rel = computed(() => fmtRelTime(this.message().ts));
 
+  /**
+   * 要渲染的正文(2026-11-09)。
+   *
+   * 🔴 与附件无关:有附件时正文照样渲染。
+   *    原模板是「有文件就不渲染正文」,一条消息同时有说明与附件时
+   *    正文整个消失(与桌面端同一个 bug)。
+   */
+  protected readonly bodyText = computed(() => (this.message().text ?? '').trim());
+
   private readonly blockList = computed(() => splitMessage(this.message().text ?? ''));
   protected readonly processy = computed(() =>
     this.blockList().some((b) => b.kind === 'process'),

+ 141 - 6
src/app/mobile/pages/chat/chat.ts

@@ -7,6 +7,7 @@ import {
   effect,
   inject,
   signal,
+  untracked,
   viewChild,
 } from '@angular/core';
 import { ActivatedRoute, Router } from '@angular/router';
@@ -21,12 +22,14 @@ import { previewKindOf } from '../../../components/preview/file-kind';
 import { LockScrollDirective } from '../../directives/lock-scroll.directive';
 import { BadgeTone, MBadgeComponent, TASK_TONE, TASK_STATUS_TEXT } from '../../components/m-badge/m-badge';
 import { ChatService } from '../../../core/chat.service';
+import { PENDING_DRAFT_KEY } from '../home/home';
 import { LoopGuardService } from '../../../core/loop-guard.service';
 import { Task } from '../../../core/api/models';
 import { ChatSessionService } from '../../../core/chat-session.service';
 import { TasksService } from '../../../core/tasks.service';
 import { fmtSize } from '../../../core/format';
 import { MobileChatViewService } from '../../../core/mobile-chat-view.service';
+import { DeliverablesService } from '../../../core/deliverables.service';
 import { buildTurns, QUIET_MS } from '../../../core/turn-aggregator';
 import {
   Block,
@@ -102,13 +105,29 @@ type SheetTab = 'task' | 'file' | 'deliver' | 'state';
         [title]="title()"
         [subtitle]="subtitle()"
         [back]="true"
+        [rightCount]="2"
         (backTap)="back()"
       >
+        <!--
+          会话切换(2026-11-09):抽屉组件早就引入了却没用,导致移动端进了
+          一条会话就出不去 —— 只能退回消息列表再进,等于不能切会话。
+        -->
+        <button barRight type="button" class="m-fab" aria-label="切换会话" (click)="drawerOpen.set(true)">
+          <i class="fa-solid fa-bars-staggered" aria-hidden="true"></i>
+        </button>
         <button barRight type="button" class="m-fab" aria-label="查看成果与状态" (click)="sheetOpen.set(true)">
           <i class="fa-solid fa-folder-open" aria-hidden="true"></i>
         </button>
       </app-m-app-bar>
 
+      <app-m-drawer
+        [open]="drawerOpen()"
+        [activeSessionId]="chat.sessionId()"
+        (close)="drawerOpen.set(false)"
+        (newChat)="drawerOpen.set(false); back()"
+        (newChatWithWs)="drawerOpen.set(false); back()"
+      />
+
       @if (chat.connection() === 'disconnected' && chat.state() !== 'loading') {
         <button type="button" class="m-conn-bar" (click)="reconnect()">
           <i class="fa-solid fa-plug-circle-exclamation" aria-hidden="true"></i>
@@ -147,7 +166,14 @@ type SheetTab = 'task' | 'file' | 'deliver' | 'state';
             />
           }
           @case ('empty') {
-            @if (chat.emptyReason() === 'unloaded') {
+            @if (chat.emptyReason() === 'removed') {
+              <!-- 已软删会话(2026-11-09 用户裁决):URL 直达也不认领 -->
+              <app-m-empty
+                icon="fa-solid fa-trash-can"
+                title="该会话已删除"
+                description="这条对话已经被删除,无法再查看或继续发送消息。"
+              />
+            } @else if (chat.emptyReason() === 'unloaded') {
               <!-- 有会话但历史取不到(2026-11-09):不能伪装成「开始新的对话」 -->
               <app-m-empty
                 icon="fa-solid fa-inbox"
@@ -336,6 +362,16 @@ type SheetTab = 'task' | 'file' | 'deliver' | 'state';
                 }
               }
               @case ('deliver') {
+                <!--
+                  未上云提示(2026-11-09),与桌面 session-rail 同口径。
+                  附件没有 url 时原来静默不显示,用户以为文件丢了。
+                -->
+                @if (notOnCloudCount() > 0) {
+                  <p class="m-rail-warn" role="status">
+                    <i class="fa-solid fa-triangle-exclamation" aria-hidden="true"></i>
+                    有 {{ notOnCloudCount() }} 个成果文件未上传云盘,暂时无法查看
+                  </p>
+                }
                 @if (deliverables().length > 0) {
                   @for (d of deliverables(); track d.id) {
                     <div class="m-deliver">
@@ -440,8 +476,7 @@ type SheetTab = 'task' | 'file' | 'deliver' | 'state';
         border: 1px solid var(--warning);
         border-radius: var(--r-md);
         background: var(--warning-subtle);
-        color: var(--warning);
-        font-size: var(--fs-12);
+        color: var(--warning);        font-size: var(--fs-12);
       }
       .m-loop-text {
         display: flex;
@@ -449,11 +484,23 @@ type SheetTab = 'task' | 'file' | 'deliver' | 'state';
         gap: var(--sp-2);
         flex: 1;
         min-width: 0;
-      }
-      .m-loop-text span {
+      }      .m-loop-text span {
         /* 提示较长,允许换行但不挤掉按钮 */
         overflow-wrap: anywhere;
       }
+      /* 未上云的成果提示(2026-11-09),与桌面 rail-warn 同口径 */
+      .m-rail-warn {
+        display: flex;
+        align-items: flex-start;
+        gap: var(--sp-1);
+        margin: var(--sp-2) var(--mob-gutter) 0;
+        padding: var(--sp-2) var(--sp-3);
+        border-radius: var(--r-tag);
+        background: var(--warning-subtle);
+        color: var(--warning);
+        font-size: var(--fs-13);
+        line-height: 1.5;
+      }
       .m-loop-act {
         flex: none;
         /* 触达区 ≥44px(WCAG 2.2 AA) */
@@ -797,6 +844,8 @@ export class MbChatPage implements OnDestroy {
   private readonly tasksSvc = inject(TasksService);
   private readonly sessionsSvc = inject(ChatSessionService);
   private readonly view = inject(MobileChatViewService);
+  /** 成果列表构造(从 agent 消息附件生成) */
+  private readonly deliverablesSvc = inject(DeliverablesService);
   protected readonly chat = inject(ChatService);
   protected readonly loop = inject(LoopGuardService);
 
@@ -888,6 +937,7 @@ export class MbChatPage implements OnDestroy {
 
   /** 时钟:驱动回合的「静默超时」判定,每秒走一格 */
   private readonly now = signal(Date.now());
+  private nowTimer: ReturnType<typeof setInterval> | null = null;
 
   /** 回合折叠状态(用户手动切换覆盖默认) */
 
@@ -933,14 +983,52 @@ export class MbChatPage implements OnDestroy {
   });
 
   /** 会话内出现过的全部附件(Demo 抽屉「文件」页签) */
+  /**
+   * 「文件」页签 = 本会话里**用户自己上传的**附件(2026-11-09 与桌面同口径)。
+   *
+   * 🔴 容器产出的不算「文件」,那是「成果」—— 见 `deliverables`。
+   *    原来不看 sender,同一份成果在「文件」和「成果」各出现一次。
+   */
   protected readonly files = computed(() =>
     this.chat
       .messages()
+      .filter((m) => m.sender === 'me')
       .map((m) => m.file)
       .filter((f): f is NonNullable<typeof f> => !!f),
   );
 
-  protected readonly deliverables = computed(() => this.view.deliverablesOf(this.chat.agentId()));
+  /**
+   * 成果抽屉「成果」页签 = **本会话里容器产出的文件**(2026-11-09 与桌面同口径)。
+   *
+   * 语义分界:
+   *   · 文件(`files`)= 我上传的
+   *   · 成果(这里)   = 容器产出的
+   *
+   * 两个来源合并去重:会话记录 `deliverableUrls` + 消息流里 agent 发的文件。
+   */
+  protected readonly deliverables = computed(() => {
+    const fromRecord = this.view.bySessionUrlsOf(this.chat.currentSession()?.deliverableUrls);
+    const agentFiles = this.chat
+      .messages()
+      .filter((m) => m.sender === 'agent' && m.file?.url)
+      .map((m) => m.file!);
+    const fromMessages = this.deliverablesSvc.fromAgentFiles(agentFiles);
+
+    const seen = new Set<string>();
+    const out = [];
+    for (const d of [...fromRecord, ...fromMessages]) {
+      const url = d.artifacts[0]?.url ?? d.id;
+      if (seen.has(url)) continue;
+      seen.add(url);
+      out.push(d);
+    }
+    return out;
+  });
+
+  /** 有多少成果附件还没上云(与桌面 `session-rail` 同口径) */
+  protected readonly notOnCloudCount = computed(() =>
+    this.deliverables().reduce((n, d) => n + d.artifacts.filter((a) => !a.url).length, 0),
+  );
 
   private readonly scroller = viewChild<ElementRef<HTMLDivElement>>('scroller');
   /** 用户是否停在底部附近(不在底部时不自动滚动,避免打断向上翻历史) */
@@ -968,10 +1056,47 @@ export class MbChatPage implements OnDestroy {
       if (this.loadingEarlier) return;
       if (this.stickBottom) queueMicrotask(() => this.scrollToBottom());
     });
+
+    // 有活着的回合时才跑每秒时钟(见 `syncClock` 注释)
+    effect(() => {
+      this.aiRows();
+      untracked(() => this.syncClock());
+    });
   }
 
   ngOnDestroy(): void {
     // 离开会话不 close:Matrix 连接与未读计数需要保持(与桌面一致)
+    if (this.nowTimer !== null) {
+      clearInterval(this.nowTimer);
+      this.nowTimer = null;
+    }
+  }
+
+  /*
+   * 🔴 时钟驱动(2026-11-09,C2)。
+   *
+   * `now` signal 原先只有声明、没有任何地方 set —— `aiRows` 只在
+   * `messages()` / `peerTyping()` 变化时重算。容器若一直报 typing、
+   * 又不再发新消息,回合的「25 秒静默」永远等不到,卡片会一直挂在
+   * 「正在输出…」(用户实测 163 秒)。
+   *
+   * 每秒推进一次 `now`,让静默判定真正随时间走。只在有活着的回合时起表,
+   * 空闲页面不白跑定时器;离页即清。
+   */
+  private syncClock(): void {
+    const needsTick = this.aiRows().some((r) => r.kind === 'turn' && r.turn.live);
+    if (needsTick && this.nowTimer === null) {
+      this.nowTimer = setInterval(() => {
+        this.now.set(Date.now());
+        if (!this.aiRows().some((r) => r.kind === 'turn' && r.turn.live)) {
+          if (this.nowTimer !== null) clearInterval(this.nowTimer);
+          this.nowTimer = null;
+        }
+      }, 1000);
+    } else if (!needsTick && this.nowTimer !== null) {
+      clearInterval(this.nowTimer);
+      this.nowTimer = null;
+    }
   }
 
   /**
@@ -985,6 +1110,16 @@ export class MbChatPage implements OnDestroy {
       const ws = this.route.snapshot.queryParamMap.get('ws');
       const created = await this.chat.startBlank(agentId, ws || null);
       if (created) {
+        /*
+         * 首页输入坞带过来的草稿(2026-11-09):用户先在首页打字、再选人,
+         * 字不能丢。取回后立刻清掉,避免下次新建时又冒出来。
+         */
+        const carryKey = PENDING_DRAFT_KEY(agentId);
+        const carried = sessionStorage.getItem(carryKey);
+        if (carried) {
+          sessionStorage.removeItem(carryKey);
+          this.chat.setDraft(carried);
+        }
         void this.router.navigateByUrl(
           `/mobile/chat/${agentId}/${encodeURIComponent(created.id)}`,
           { replaceUrl: true },

+ 21 - 4
src/app/mobile/pages/home/home.ts

@@ -11,6 +11,15 @@ import { MDockComponent } from '../../components/m-dock/m-dock';
 import { MEmptyComponent } from '../../components/m-empty/m-empty';
 import { MNewChatSheetComponent } from '../../components/m-new-chat/m-new-chat';
 
+/**
+ * 首页输入坞草稿的过路键。
+ *
+ * 首页不直接持有 ChatService(它只是个入口页),用户在这里打的字要
+ * 带到刚选好的会话里发送。用 sessionStorage 而不是路由 state,是因为
+ * 会话页建好空会话后会 `replaceUrl` 换成真实 id,路由 state 会丢。
+ */
+export const PENDING_DRAFT_KEY = (agentId: string): string => `fmode_home_draft_${agentId}`;
+
 /**
  * 新建对话引导页(Demo 03)
  *
@@ -263,16 +272,24 @@ export class MbHomePage {
 
   protected onCreated(agentId: string): void {
     this.pickerOpen.set(false);
+    /*
+     * 首页输入坞有草稿时,选完数字生命要**带着草稿进会话**,不能丢。
+     * 草稿经 sessionStorage 传给会话页(路由 state 在 replaceUrl 后会失效,
+     * 而首页与 `chat/:id/new` 是两次独立导航)。
+     */
+    const text = this.draft();
+    if (text.trim()) {
+      sessionStorage.setItem(PENDING_DRAFT_KEY(agentId), text);
+      this.draft.set('');
+    }
     void this.router.navigateByUrl(`/mobile/chat/${agentId}/new`);
   }
 
   /** 未选数字生命时点发送 → 先选人,不丢草稿 */
   protected send(text: string): void {
-    if (!this.target()) {
-      this.pickerOpen.set(true);
-      return;
-    }
+    if (!text.trim()) return;
     this.draft.set(text);
+    // 首页固定先选人:目标一旦选定就进会话,草稿随行
     this.pickerOpen.set(true);
   }