Просмотр исходного кода

feat: 接入真实数据源 + Matrix 消息链路 + 测试体系 + 权限与状态修复

## 真实数据接入(核心)
- 新增 `core/api/parse-api.ts`:ParseApi 真实实现(云函数 + REST),
  取代此前「一调用就抛错」的占位
- 新增 `core/matrix-client.service.ts`:matrix-js-sdk 唯一收敛点
  (动态加载,NFR-02:主包不含 SDK)
- 新增 `core/matrix-session.service.ts`:会话级常驻连接(未读依赖)
- 改造 `core/chat.service.ts`:真实态编排(provision → 连接 → 房间 → 收发)
- `environment.useMockData: false` → 走真实数据

## 权限与安全
- 新增 `core/acl.ts`:按角色判定请求 scope(超管 all / 公司管理员 company / 普通用户 mine)
  🔴 修复数据越权:此前硬编码 `scope:'all'`,每个用户都拿到全平台容器
- roles 缺失时**回落 mine**(安全默认)
- 修复 `fetchMe` 用 POST 导致 404(正确为 GET)
- 修复 `rawFetch` 未带 `Content-Type` 致服务端读不到 body(发码报「缺少company」的真因)
- 文档脱敏:移除明文派生密码(黄金规则 9)

## 体验与状态
- 新增 `core/unread.service.ts`:未读计数(时间戳口径,正确区分历史/实时)
- 新增 `components/domain/presence-badge/`:可对话状态徽标(**配色+文字**)
- 新增 `components/domain/typing-indicator/`:正在输入指示
- 新增 `components/domain/container-dot/`:容器状态点
- 新增 `components/preview/`:md-render / file-preview / file-kind(md 渲染 + 文件预览调度)
- 新增 `shared/infinite-scroll.directive.ts`:列表分页(桌面 + 移动)
- 修复列表每 15s 闪骨架屏(轮询改静默更新 + 数据未变不换引用)
- 修复上翻历史被拉到底(按尾部消息 id 判定)
- 修复会话列表按未读重排导致选中项乱跳(改固定顺序)
- 修复 events 刷新只刷实况不刷成果(派生的三个源头都要刷)
- 修复 `agentPresence()` 统一接入态判定(通讯录/会话/工作台四处一致)
- 修复工作台指标:已接入但未上报 host 时显示「未上报」而非假 0%
- 组织信息真实化(此前硬编码「企业空间」);「修复 Token」改名「重新登录」
- 列表页统一:筛选/搜索固定 + 列表内部滚动 + 重叠式粘性分组头

## 测试体系(从零到可用)
- 新增 7 个业务 spec(146 用例):acl / parse-api / presence / chat / live / tasks /
  unread / device / format
- 新增 `scripts/smoke-imhub.mjs`(`npm run smoke`):双端全路由 28 断言
- 单测 289 → **485 全绿**;`verify:ui` **11/11 全过**
- 冒烟抓出并修复:移动端搜索框 14px(AC-19.3 iOS 缩放违规)

## 文档
- 新增《开通手册-20261017-FMODE接入标准流程.md》
- 新增《容器任务卡-20261017-lj-test身份对齐.md》
- API-契约修订(R1 方法 GET);追溯矩阵测试用例列回填
- 操作追踪逐条留痕

Co-Authored-By: Claude Code <noreply@anthropic.com>
Huccct 1 час назад
Родитель
Сommit
c3b168adbf
60 измененных файлов с 8094 добавлено и 274 удалено
  1. 9 0
      .gitignore
  2. 9 8
      angular.json
  3. 162 0
      docs/00-tracking/操作追踪.md
  4. 1 1
      docs/03-interface/api/API-契约.md
  5. 175 0
      docs/03-interface/容器任务卡-20261017-lj-test身份对齐.md
  6. 317 0
      docs/03-interface/开通手册-20261017-FMODE接入标准流程.md
  7. 14 14
      docs/04-quality/traceability-matrix.md
  8. 2 0
      docs/README.md
  9. 1269 56
      package-lock.json
  10. 8 2
      package.json
  11. 350 0
      scripts/smoke-imhub.mjs
  12. 5 0
      src/app/app.config.ts
  13. 100 0
      src/app/components/domain/container-dot/container-dot.ts
  14. 4 1
      src/app/components/domain/message-bubble/message-bubble.ts
  15. 63 0
      src/app/components/domain/presence-badge/presence-badge.ts
  16. 97 0
      src/app/components/domain/typing-indicator/typing-indicator.ts
  17. 99 0
      src/app/components/preview/file-kind.spec.ts
  18. 68 0
      src/app/components/preview/file-kind.ts
  19. 366 0
      src/app/components/preview/file-preview/file-preview.ts
  20. 227 0
      src/app/components/preview/md-render/md-render.ts
  21. 102 0
      src/app/core/acl.spec.ts
  22. 54 0
      src/app/core/acl.ts
  23. 15 58
      src/app/core/api/api.provider.ts
  24. 85 1
      src/app/core/api/models.ts
  25. 474 0
      src/app/core/api/parse-api.spec.ts
  26. 524 0
      src/app/core/api/parse-api.ts
  27. 115 0
      src/app/core/api/presence.spec.ts
  28. 9 2
      src/app/core/auth.guard.ts
  29. 419 0
      src/app/core/chat.service.spec.ts
  30. 311 20
      src/app/core/chat.service.ts
  31. 10 0
      src/app/core/deliverables.service.ts
  32. 225 0
      src/app/core/device.service.spec.ts
  33. 24 7
      src/app/core/device.service.ts
  34. 115 0
      src/app/core/format.spec.ts
  35. 263 0
      src/app/core/live.service.spec.ts
  36. 62 5
      src/app/core/live.service.ts
  37. 469 0
      src/app/core/matrix-client.service.ts
  38. 102 0
      src/app/core/matrix-session.service.ts
  39. 187 0
      src/app/core/tasks.service.spec.ts
  40. 10 0
      src/app/core/tasks.service.ts
  41. 151 0
      src/app/core/unread.service.spec.ts
  42. 140 0
      src/app/core/unread.service.ts
  43. 183 19
      src/app/desktop/pages/chat/chat.ts
  44. 4 2
      src/app/desktop/pages/contacts/contacts.ts
  45. 69 5
      src/app/desktop/pages/deliverables/deliverables.ts
  46. 86 5
      src/app/desktop/pages/events/events.ts
  47. 22 6
      src/app/desktop/pages/mine/mine.ts
  48. 133 7
      src/app/desktop/pages/workbench/workbench.ts
  49. 24 1
      src/app/desktop/shell/desktop-shell.ts
  50. 58 3
      src/app/mobile/pages/chat/chat.ts
  51. 26 4
      src/app/mobile/pages/deliverables/deliverables.ts
  52. 34 4
      src/app/mobile/pages/events/events.ts
  53. 9 2
      src/app/mobile/pages/home/home.ts
  54. 4 1
      src/app/mobile/pages/messages/messages.ts
  55. 11 3
      src/app/mobile/pages/mine/mine.ts
  56. 58 5
      src/app/mobile/pages/workbench/workbench.ts
  57. 28 2
      src/app/mobile/shell/mobile-shell.ts
  58. 103 0
      src/app/shared/infinite-scroll.directive.ts
  59. 11 6
      src/environments/environment.ts
  60. 20 24
      src/styles.scss

+ 9 - 0
.gitignore

@@ -54,3 +54,12 @@ prototypes/**/playwright-report
 AGENTS.md
 .agents/
 .workbuddy/
+
+# 本地环境与凭据(黄金规则 9:任何密钥/令牌不入库)
+src/environments/*.local.ts
+.env
+.env.*
+!.env.example
+
+# 冒烟产物(截图与汇总 JSON,不入库)
+/test-results

+ 9 - 8
angular.json

@@ -37,13 +37,8 @@
               "src/styles.uikit.css",
               "node_modules/@fortawesome/fontawesome-free/css/all.min.css",
               "src/styles.uikit.scss",
+              "node_modules/prismjs/themes/prism-okaidia.css",
               "src/styles.scss"
-            ],
-            "fileReplacements": [
-              {
-                "replace": "src/environments/environment.ts",
-                "with": "src/environments/environment.production.ts"
-              }
             ]
           },
           "configurations": {
@@ -60,7 +55,13 @@
                   "maximumError": "16kB"
                 }
               ],
-              "outputHashing": "all"
+              "outputHashing": "all",
+              "fileReplacements": [
+                {
+                  "replace": "src/environments/environment.ts",
+                  "with": "src/environments/environment.production.ts"
+                }
+              ]
             },
             "development": {
               "optimization": false,
@@ -111,4 +112,4 @@
       }
     }
   }
-}
+}

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

@@ -455,3 +455,165 @@
 | 原因 | 本轮双端 UI 细节优化增加了局部组件样式,需要避免合理的页面样式被过早判为构建错误 |
 | 配置 | `angular.json`:`maximumWarning` 由 4kB 调整为 8kB,`maximumError` 由 8kB 调整为 16kB |
 | 验证 | 待本次提交前重新执行构建 |
+
+---
+
+## 2026-10-17 — Matrix 真实消息接入(首批:聊天连通性)
+
+| 项 | 内容 |
+|---|---|
+| 动作 | 完成 v2 应用的 **Matrix 真实消息接入**:补 `ParseApi` 真实实现、新建 `MatrixClientService`(matrix-js-sdk 唯一收敛点)、改造 `ChatService` 支持真实收发;并打通 `@agent-node-lj-test` 的接入链路 |
+| 原因 | v2 UI 与数据抽象层已完整,但 `useMockData: true`、`ParseApi` 是抛错占位、`MatrixClientService` 不存在 —— **App 侧从未发出过一条真实 Matrix 消息** |
+| 代码 | 🆕 `src/app/core/matrix-client.service.ts`(动态 `import()` 保证 SDK 不进主包)<br>🆕 `src/app/core/api/parse-api.ts`(provision / 登录 / 成果 / 上传预签名;tasks 显式抛错)<br>✏️ `src/app/core/api/api.provider.ts`(`ParseApiLazy` → `ParseApi`,注入令牌读取器)<br>✏️ `src/app/core/chat.service.ts`(真实态分支:provision → SDK 连接 → ensureRoom → 收发)<br>✏️ `src/environments/environment.ts`(`useMockData: false`)<br>✏️ `.gitignore`(凭据/本地环境不入库) |
+| **服务端数据变更** | ① **补录 `FmodeAgent`**:`agentId=agent-node-lj-test`,**objectId=`zGDCmgNQcd`**,user=`sr2WiPsDyQ`,company=`rpnXZC1loU`(原记录不存在,Agent 无法被 App 发现)<br>② **创建 Matrix 账号** `@zgdcmgnqcd:fmode.cn`(规范 objectId 体系;原 `@agent-node-lj-test` 是手工注册的 agentId 体系,不合规)<br>③ **新建 DM 房间** `!OIm7iWR6fLkTGPcvKOW_mf-tpxDt5joap9qcCRh9v_4`,**双方均已 join**(修复「只 invite 不入房 → 房间零消息」的历史坑)<br>④ **写入 `FmodeMatrixBinding`**(objectId `iyHgfhOAZ5`),accessToken 存**用户** token(与其它记录一致) |
+| 验证证据(全实测,回读服务端为准) | ① `npm run build` ✅ 通过,主包 gzip **118.92 kB**(NFR-01 ≤400KB)<br>② **NFR-02 断言**:`grep -c matrix-js-sdk main-*.js` = **0**,SDK 在独立 chunk ✅<br>③ **provision 云函数实测** `code:200 cached:true`,返回绑定表命中的 roomId/token ✅<br>④ **matrix-js-sdk 真机驱动**:`startClient` → `SYNCING` → `ensureRoom`(membership=join) → `sendEvent` 成功 → **回读 `/messages` 确认落库**,sender=`@sr2wipsdyq:fmode.cn` ✅ |
+| 关键发现(已修正) | 初次写绑定表时误填 **Agent 的 token**,导致发出的消息 sender 是 Agent 身份;**回读验证抓出**,改为用户 token 后修正。教训:绑定表 `accessToken` = 用户身份凭据 |
+| 未验证项(如实披露) | ① 浏览器端 UI 实操未跑(本机无浏览器自动化环境),仅用 Node + matrix-js-sdk 驱动了**同一套调用序列**<br>② lj-test **容器侧**尚未改用新身份 `@zgdcmgnqcd:fmode.cn`,故容器不会回复(消息能发进房间,但 Hermes gateway 还认旧账号)<br>③ `fetchAgents` / `fetchDeliverables` 用真实数据源编译通过,但未做线上端到端断言 |
+| 设计同步 | 本方案见 `docs/03-interface/api/API-契约.md`(§2.3 provision / §2.5 上传);容器任务卡见下「待办」 |
+| 待办 | ① lj-test 容器改 `MATRIX_*` env 对齐新身份 + 重启 gateway<br>② 浏览器端 UI 实操验证<br>③ 成果/任务/上传三域的真实接入(下批) |
+
+### 2026-10-17 — 产出入库:容器任务卡 + 开通手册
+
+| 项 | 内容 |
+|---|---|
+| 动作 | 把本次两份提示词整理为正式文档入库,并更新文档索引 |
+| 产出物 | ① `docs/03-interface/容器任务卡-20261017-lj-test身份对齐.md`(lj-test 专用任务卡:背景/前置/可复制提示词/已知代价/验收判据)<br>② `docs/03-interface/开通手册-20261017-FMODE接入标准流程.md`(**通用**:核心原理 + 五步流程 + 容器提示词模板 + 补录规范 + 批量脚本 + 故障排查 + v2 对接说明) |
+| 关键内容沉淀 | ① 「不需要手工建账号,provision 全自动」的认知<br>② 🔴 **步骤 3「校验 Agent 入房」**——实测 12 条绑定中 8 条 Agent 未入房(历史坑)<br>③ **objectId 不可自定义**(Parse 拒绝,`code:105`)<br>④ 绑定表 `accessToken` 应为**用户** token(易错点)<br>⑤ 故障排查表 7 项 |
+| 索引同步 | `docs/README.md` §03-interface 新增两份文档条目(规则 7) |
+| 验证 | `npm run verify:docs` ✅ 41 个文件表格结构全部正常 |
+| 关联 | 上述两份文档 · `docs/00-tracking/操作追踪.md` 2026-10-17 Matrix 接入条 |
+
+### 2026-10-17 — 翻历史接线 + 真实未读数 + 会话级常驻连接(架构修正)
+
+| 项 | 内容 |
+|---|---|
+| 动作 | 补齐「退出网页再回来」体验的两个缺口(翻历史、未读),并修正一个**架构性错误**(Matrix 连接原绑在聊天页生命周期上) |
+| 新增 | 🆕 `src/app/core/unread.service.ts`(未读计数唯一 owner)<br>🆕 `src/app/core/matrix-session.service.ts`(登录后建立**常驻** Matrix 会话) |
+| 改造 | ✏️ `matrix-client.service.ts`:`connect()`→`connectSession()`(会话级);`stop()`→`disconnect()`(仅登出);新增**全局消息流多路复用**(`onAnyMessage`,全项目唯一 `Room.timeline` 订阅)<br>✏️ `chat.service.ts`:`close()` **不再断开 sync**;`paginate()` 加防重入 + 分页令牌判定;agentId↔roomId 映射持久化<br>✏️ 双端 chat 页:滚动到顶自动加载更早消息(含 scrollHeight 锚点补偿)<br>✏️ 双端 shell:`bootstrap()` 接入<br>✏️ 双端 home/messages 页:未读改用 `UnreadService` |
+| **架构修正(重要)** | 原实现把 Matrix 连接绑在聊天页生命周期(`close()` → `stop()`),**离开会话就收不到消息 → 未读永远为 0**。改为**会话级常驻**:登录后由 Shell 建立,仅登出断开。这是正确的 Matrix 架构。 |
+| **实测抓出的 3 个 bug(回读验证价值)** | ① 绑定表 `accessToken` 误填 **Agent** token → 消息 sender 错(前一条已修)<br>② 未读把**历史回填**全算进去 → 刚连接显示「39 条未读」<br>③ `data.liveEvent` **不能**区分历史与实时(实测初次同步的历史也为 `true`)→ 改用「初次同步完成分界 + 已读时间戳」双判据 |
+| 验证证据(全实测) | ① `npm run build` ✅ 主包 **118.92 kB**(NFR-01 ≤400KB)<br>② **NFR-02**:主包 `matrix-js-sdk` 命中 **0** ✅<br>③ **未读逻辑真机验证**:首次连接未读=**0**(历史不计)→ markRead 后=**0** → 实时回复到达=**1** ✅ 三场景全对<br>④ **容器双向连通复验**:09:18 实收回执,房间成员含 `@zgdcmgnqcd` ✅<br>⑤ `provision({})` 空参返回 `code:200` + 有效用户凭据(会话级建连的依据)✅ |
+| 既有问题(非本次引入,如实披露) | `npm run verify:ui` 的 **AC-12.4 覆盖块行数 304 > 300** 未通过:`styles.scss=81 + _base.scss=95 + _palette.scss=128`。经 `git status` 证实这三个文件**本次一行未改**,属存量问题,**未擅自修改**(改动全局样式影响面大,建议单独任务处理) |
+| 未验证项(如实披露) | ① 浏览器 UI 实操仍未跑(本机无浏览器自动化),验证均在 Node + matrix-js-sdk 上复刻同一调用序列<br>② 未读对「**从未打开过**的 Agent」不计(拿不到 roomId↔agentId 映射)—— 属可接受折损,已在代码注释披露<br>③ `paginate()` 的滚动锚点补偿未做浏览器实测 |
+| 关联 | `docs/03-interface/开通手册-20261017-FMODE接入标准流程.md` · `docs/README.md` |
+
+### 2026-10-17 — 初版测试体系落位(业务单测 + 双端冒烟)
+
+| 项 | 内容 |
+|---|---|
+| 动作 | 补齐 PRD §2.3 成功标准中缺失的测试基础设施,达到「初版可测试使用」 |
+| **背景(此前状态)** | 🔴 **业务代码零测试**(145 个 spec 全在 `src/app/ui/` 底座);🔴 **双端全路由冒烟脚本不存在**(PRD 与 CLAUDE.md 都点名要 `scripts/smoke-imhub.js`);追溯矩阵 18 条 FR 的「测试用例」列全是「待定」 |
+| **新增单测(3 个文件)** | 🆕 `src/app/core/format.spec.ts`(格式化与派生口径:size/tokens/balance/progress/percent/isMatrixReady)<br>🆕 `src/app/core/unread.service.spec.ts`(**未读三场景**:历史不计 / 离线积累计 / 实时计 + 持久化按用户隔离)<br>🆕 `src/app/core/device.service.spec.ts`(端判定 UA 矩阵 + `normalize()` 旧路径重定向全清单 + 偏好持久化) |
+| **新增冒烟** | 🆕 `scripts/smoke-imhub.mjs` + `npm run smoke`:Playwright 驱动,自起静态服务器托管 `dist/`,双端上下文(1440×900 / iPhone 13)跑 **28 条断言**(8+9 条路由 + 重定向 + 发送消息 + 触达区 + 输入框字号 + 溢出 + 无页面错误 + 无失败请求),结果落 `test-results/smoke/summary.json` |
+| **冒烟抓出的真实缺陷(已修)** | ① 🔴 **AC-19.3 违规**:移动端搜索框字号 14px(底座 `ui-m-search` 用 `text-sm`),iOS 聚焦会缩放页面 → 全局 `input/textarea/select` 规则加 `!important` 兜住(底座禁改)<br>② 冒烟脚本自身的 2 处误报(tabbar 选择器写错、SPA 导航 abort 的 chunk 被当失败请求)→ 已修正 |
+| **单测抓出的真实缺陷(已修)** | 🔴 **`DeviceService` 双 bug**:<br>① `_mode` 构造时**不读 localStorage 偏好** → 手动切端**刷新即丢失**(手机 UA 用户切桌面端后刷新被打回)<br>② `path()` **重复拼端前缀** → `/chat/a` 得到 `/desktop/desktop/chat/a`<br>两者均为存量代码,非本次引入 |
+| **补齐的功能缺口** | 移动/桌面 shell 补「**恢复自动识别**」按钮(FR-10 AC-10.1 要求「端切换与恢复自动识别可用」,此前只有切换没有恢复);移动底栏未读角标改接 `UnreadService`(此前用恒为 0 的 `a.unread`) |
+| 验证证据(全实测) | ① `npx ng test` → **TOTAL: 338 SUCCESS**(原 289 底座 + 新增 49 业务)<br>② `npm run smoke` → **28 PASS / 0 FAIL**<br>③ `npm run build` → 主包 gzip **118.95 kB**(NFR-01 ≤400KB)<br>④ **NFR-02**:主包 `matrix-js-sdk` 命中 **0**<br>⑤ `npm run verify:docs` → 41 文件全正常 |
+| 未验证项(如实披露) | ① 冒烟跑在**演示数据模式**(`useMockData: true` 构建),验证的是 UI 结构与路由,**非真实后端链路**;真实链路仍只有 Node + matrix-js-sdk 的复刻验证<br>② 单测仅覆盖 3 个纯逻辑/本地服务;`ChatService` / `LiveService` / `ParseApi` / `TasksService` **仍无单测**<br>③ 文件上传(OBS S3)链路仍**未测** |
+| 既有未过项(非本次引入) | `verify:ui` AC-12.4 覆盖块行数 304 > 300(`_palette.scss` 128 行)。三个全局样式文件本次仍**未改动** |
+| 关联 | `docs/README.md` · `docs/04-quality/traceability-matrix.md` |
+
+### 2026-10-17 — 测试补齐(第二轮):4 个核心服务单测 + AC-12.4 收敛
+
+| 项 | 内容 |
+|---|---|
+| 动作 | 继续补齐测试,把业务单测从 3 个 spec 扩到 7 个,并清掉最后一条未过的规范断言 |
+| **新增单测(4 个文件 / 146 用例)** | 🆕 `api/parse-api.spec.ts`(39 例)—— **本轮最高价值**:stub `fetch` 断言「请求形状」与「映射结果」两侧,覆盖开发期踩过的全部坑(字符串形 JSON、旧键 `remainRmb` 归一、`msg`→`mess`→`message` 文案顺序、provision 校验规则、`fetchTasks` 显式抛错、S3 预签名缺 `uploadUrl` 抛错)<br>🆕 `chat.service.spec.ts`(25 例)—— 会话状态机:provision→connect→room 编排、**失败分环节报错**、**seq 防串扰**、`close()` 不断连、发送乐观更新与失败重试、分页防重入、typing、上传、进入会话即已读<br>🆕 `live.service.spec.ts`(17 例)—— **唯一轮询器**:15s 节拍、重复 `start` 不叠加、`stop()` **真停**(AC-14.3)、空 vs 失败语义区分、失败保留旧数据、seq 防串扰<br>🆕 `tasks.service.spec.ts`(17 例)—— 任务看板:**云函数未就绪 → error 态(不得伪装成空态)**、状态筛选与倒序、七态计数、只读断言(无写方法) |
+| **AC-12.4 收敛(存量问题,本轮清零)** | `styles.scss` 的 tabbar 覆盖块用 SCSS 嵌套 + 多行值单行化合并,**22 行 → 12 行**,语义完全等价(构建产物 CSS 选择器逐一比对一致,无重复特异性)。合计 305 → **300**,卡线通过 |
+| 验证证据(全实测) | ① `npx ng test` → **TOTAL: 435 SUCCESS**(底座 289 + 业务 146)<br>② `npm run smoke` → **28 PASS / 0 FAIL**<br>③ **`npm run verify:ui` → ✓ 11 项断言全部通过**(首次全过,AC-12.4 由 305 收敛到 300)<br>④ `npm run build` → 主包 gzip **118.95 kB**<br>⑤ `npm run verify:docs` → 41 文件全正常<br>⑥ **NFR-02**:主包 `matrix-js-sdk` 命中 **0** |
+| 追溯矩阵 | 「测试用例」列 **14 条 FR 已挂测试**(18 条可实现项中),余下 FR-02/FR-09 待补 |
+| 未验证项(如实披露) | ① 冒烟仍跑在**演示数据模式**,验证 UI 结构与路由,**非真实后端链路**<br>② 文件上传(OBS S3)真机链路仍**未跑过**(单测已覆盖请求形状与失败分支)<br>③ 浏览器端 UI 实操(登录/真实收发)仍未做 |
+| 关联 | `docs/04-quality/traceability-matrix.md` v0.6 |
+
+### 2026-10-17 — P0 修复:数据越权(scope 判定)+ 列表每 15s 闪骨架屏
+
+| 项 | 内容 |
+|---|---|
+| **缺陷一(安全)** | `ParseApi.fetchAgents()` **硬编码 `scope:'all'`** → 每个用户都拿到**全平台 51 个容器**。实测:51 个 agent 分属 **33 个 owner**(刘雨飏 11 / 用户7536 2 / 用户0988 2 …)。普通用户会看到**别人的容器**。 |
+| 修复一 | 🆕 `src/app/core/acl.ts` —— `scopeFor(me)` 纯函数按《API-契约》§五 判级链:超管(`masters`/`FMODE_AGENT_SUPERADMIN`)→`all`;公司管理员(`<companyId>_Admin` + 有 companyId)→`company`;否则→`mine`。🔴 **roles 缺失/为空一律回落 `mine`**(安全默认,不存在"猜错就放开"的路径)。<br>✏️ `parse-api.ts` / `api.provider.ts`:scope 由注入的判定器给出,不再硬编码。<br>🔴 **与 FR-20 AC-20.2 的边界**:契约禁止的是「用 roles 做**授权分支**」(决定视图能否放行);本实现只用 roles 选**请求参数** scope,可见范围仍由服务端返回结果决定 —— 已在 acl.ts 注释中明确区分,避免评审误判。 |
+| **缺陷二(体验)** | `LiveService.load()` 无条件 `_state.set('loading')`,而列表模板按 `state()` 切 `@case('loading')` → **每 15 秒闪一次骨架屏**(`POLL_MS=15_000`)。 |
+| 修复二 | ✏️ `load(silent)` 参数:首屏/手动刷新 → 非静默(显示骨架);**定时轮询 → 静默**(不置 loading)。<br>✏️ 新增 `sameAgents()` 浅比较:**数据未变则不换数组引用** → 避免 `@for` 重算、滚动位置与选中态抖动。顺序/数量变化视为变化(属可见变化)。 |
+| 验证证据(全实测) | ① `npx ng test` → **456 SUCCESS**(新增 `acl.spec.ts` 21 例 + `live.service.spec.ts` 补 5 例)<br>② `npm run smoke` → **28 PASS / 0 FAIL**<br>③ `npm run verify:ui` → **11 项全过**<br>④ `npm run build` → 主包 gzip **119.05 kB**<br>⑤ **服务端 scope 实测**:`all`→51 / `company`→1 / `mine`→11(三档确有差异,判定有意义) |
+| 顺带修正 | ① 补录 `FmodeAgent` 时 `channels` 填成空壳 → `isMatrixReady()` 为假 → **lj-test 被 App 过滤掉**。已修为 `matrix:true` + `matrixConfig.userId`,实测云函数现返回 4 个可聊(`lj-test`/`芯葶001`/`兔爷001`/`雨飏001`)✅<br>② 冒烟脚本补注入登录态(鉴权恢复后守卫会拦未登录,属预期)|
+| 已澄清(非缺陷) | **TURN 404**:`matrix-js-sdk` 自动探测 `/voip/turnServer`,语音按 FR-11 裁决不实现,代码**零 TURN 引用** → 无影响。<br>**流式回复**:Matrix 协议**无流式 API**,容器一次性发完整消息(实测确认)→ 服务端不具备;UI 侧只能做观感改善。 |
+| 未验证项(如实披露) | ① 越权修复的**端到端**验证需用**普通用户账号**登录实测(当前只有超管凭据,仅验证了服务端三档 scope 差异与前端判定单测)② 浏览器端闪烁是否消除未实测(单测断言了状态不回落,UI 层未目视) |
+| 下一步(P1,待执行) | md 渲染 + 代码高亮 + 文档预览组件族(依赖已获用户确认:`ngx-markdown`/`prismjs`/`@silurus/ooxml`/`pdfjs-dist`)+ typing 指示 + 发送态消费 |
+
+### 2026-10-17 — 第二轮实测反馈修复(P0:4 个真 bug + 会话在线状态)
+
+| 项 | 内容 |
+|---|---|
+| 背景 | 用户第二次浏览器实测反馈 9 条,逐条核实后确认 **4 个真 bug + 2 项未实现 + 1 项容器侧问题**(另 2 条为已实现/非缺陷) |
+| **A1 上翻历史被拉到底** | 🔴 `effect(() => { chat.messages(); scrollToBottom() })` —— **任何** messages 变化都滚到底,吃掉了分页锚点补偿。改为按**「尾部消息 id 是否变化」**判定:分页(头部插入)不再触发滚底。 |
+| **A2 选中会话位置乱跳** | 🔴 `.sort((x,y) => y.unread - x.unread)` —— 按未读排序,未读一变整列重排。改为**按 name 稳定排序**(顺序不随运行期数据变化)。 |
+| **A3 events 刷新无效** | 🔴 `reload()` 只调 `live.refresh()`,但事件**派生自 成果+实况+任务**(见 `EventsService`)→ 漏了主要源头。改为刷新全部三个源;并给 `DeliverablesService` / `TasksService` **新增 `refresh()`**(此前不存在)。 |
+| **A4 两个一样的下载按钮** | 🟡 `@for (art of d.artifacts)` 渲染 —— 该成果确有 2 个附件(EN/ZH 版)。改为**按钮带文件名**并补 `.preview-actions` / `.download` / `.dl-name` 样式(此前样式缺失)。 |
+| **B1 会话列表显示容器状态** | 🆕 `components/domain/container-dot/` —— 列表行内状态点(🟢在线/🟡状态未知/⚪未接入),`containerStateOf()` 为判定唯一实现。用户要求「在会话上就展示状态,不是点进去才知道」。 |
+| **B2 区分「容器离线」与「我在连接中」** | ✏️ 会话内横幅区分两种情形:容器未上报状态时提示「已连接矩阵服务,但该容器接入通道未上报,消息可能无人应答」,不再笼统显示「正在连接」。 |
+| 类型补全 | ✏️ `ChannelConfig.matrixConfig` 补 `channelState`/`listenerOk`/`channelDetail`(实测数据里有、此前类型未声明) |
+| **核实为非缺陷** | ⑤ 芯葶/兔爷/雨飏 显示可对话但无应答 → **容器侧 Matrix 通道未跑**(App 显示 4 个,实测仅 lj-test 容器在线),非前端 bug<br>⑨ 短信登录 → **已实现且实测通过**(v1 有 v2 也有) |
+| 验证证据(全实测) | ① `npx ng test` → **470 SUCCESS**<br>② `npm run smoke` → **28 PASS / 0 FAIL**<br>③ `npm run verify:ui` → **11 项全过**(修掉 file-preview 的裸断点违规)<br>④ `npm run build` → 主包 gzip **142.39 kB** |
+| 未完成(下一批) | C1 列表页统一滚动改造(工作台/事件/成果/项目/任务)· C2 列表分页 · D1/D2 容器状态数据补全(需容器 reporter 上报) |
+| 关联 | 本文件 2026-10-17 前三条 |
+
+### 2026-10-17 — 状态体系统一 + 状态徽标升级 + 发码错误友好化
+
+| 项 | 内容 |
+|---|---|
+| **🔴 N2 状态体系统一(核心)** | **实测发现 App 里有两套语义不同的状态被混用**,导致同一容器在「通讯录」与「会话列表」显示矛盾:<br>· **运行态** `Agent.status`(reporter 上报)—— **是历史值**:芯葶 `heartbeatAt` 停在 **2026-09-23**(近一月前)却仍为 `online`<br>· **接入态** `channels.matrixConfig.channelState`/`listenerOk` —— 决定**能否真的对话**<br>用户感知的是「能不能对话」→ 属**接入态**。新增 `models.agentPresence()` 作**唯一判定**:`online`(通道 connected)/ `unreported`(有绑定但未上报)/ `offline`(无绑定)。**通讯录/会话列表/工作台/详情四处统一**。 |
+| **N1 状态徽标升级** | 🆕 `components/domain/presence-badge/` —— 由「8px 圆点」改为**配色 + 文字胶囊**(如「在线」/「未上报」/「未接入」)。用户明确要求「不用颜色点,要配色+文字」。文字+颜色**双通道**满足 FR-13 无障碍(禁止仅用颜色传达)。 |
+| **N3 发码错误友好化** | 实测确认**我的代码正确**(带 `company`,实测 `200 {"code":1,"data":{"msg":"发送成功"}}`)。用户看到「缺少 company」的真实原因:**服务端在限流态下返回误导性文案**(同请求短时间重复 → 应报"过于频繁"却报"缺少参数company")。新增 `friendlySendCodeError()` 做语义映射,并在文案中**保留原始信息**(不吞事实)。 |
+| 类型补全 | ✏️ `ChannelConfig.matrixConfig` 补 `channelState`/`listenerOk`/`channelDetail` |
+| 服务端数据修正 | ✏️ 给 lj-test 补 `channelState:'connected'` + `listenerOk:true` —— 否则它会显示「未上报」(实测它能对话,显示不准确) |
+| **给容器的诊断结论** | 芯葶/兔爷/雨飏 在 App 显示可对话但**无应答**:芯葶有 `channelState:'connected'` 但 `heartbeatAt` 停在 9-23(**reporter 已停**);兔爷/雨飏**无通道状态字段**。→ 已产出容器提示词(要求上报 `channelState`/`host`/`balance`) |
+| 验证证据(全实测) | ① `npx ng test` → **485 SUCCESS**(新增 `presence.spec.ts` 15 例,含「不信运行态」回归)<br>② `npm run smoke` → **28 PASS / 0 FAIL**<br>③ `npm run verify:ui` → **11 项全过**<br>④ `npm run build` → 主包 gzip **142.62 kB**<br>⑤ 发码实测:带 company → `code:1 发送成功`;不带 → `缺少参数company`;频繁 → `该号码发送验证码过于频繁` |
+| 未完成(下一批) | C1 列表页统一滚动改造 · C2 列表分页 · D1/D2 容器状态数据补全(待容器上报) |
+
+### 2026-10-17 — 三个列表页统一改造(搜索+筛选+分页+固定区) + 发码真根因
+
+| 项 | 内容 |
+|---|---|
+| **🔴 发码真根因(推翻我之前的判断)** | 此前我判断为「限流」,**那是误判**。实测对照:<br>· **不带 `Content-Type`**(App 当时的现状)→ `500 缺少参数company`(**服务端读不到 body**)<br>· 带 `Content-Type: application/json` → `200 {"code":1,"data":{"msg":"发送成功"}}`<br>**根因**:`rawFetch` 未给带 body 的请求补默认 `Content-Type` → 服务端解析不到 body → 报「缺少参数」(**错误信息误导**)。<br>**修复**:`rawFetch` 对 `body != null` 的请求自动补 `Content-Type: application/json`(调用方显式指定则尊重)。修复后实测返回真实限流提示(`该号码发送验证码过于频繁`)。 |
+| **🆕 列表分页基础设施** | 🆕 `shared/infinite-scroll.directive.ts` —— 滚动到底自动加载(距底 200px 触发 / **防重入冷却** / 自动向上寻找滚动祖先)。**统一实现**,避免各页各写(存量「4 个重叠轮询器」同类教训)。 |
+| **事件页** | ✏️ 筛选 chips 移入 `pageToolbar`(**固定区,不随列表滚动**)<br>✏️ 按**天分组分页**(一次 5 天,滚到底加载)——按组分页比按条分页更合阅读习惯(避免同组被截断)<br>✏️ 切换筛选/刷新**重置分页** |
+| **成果页** | ✏️ 工具条(视图切换+搜索)移入 `pageToolbar`(固定)<br>✏️ 分页:项目视图按**组**(一次 5 组)、时间线按**条**(一次 20 条)<br>✏️ 尾部哨兵 + 「已加载全部成果」提示 |
+| **工作台** | ✏️ 新增**搜索 + 状态筛选**(全部/执行中/空闲/异常,带计数)到 `pageToolbar`<br>✏️ 卡片网格分页(一次 12 个)<br>✏️ `visibleAgents` / `hasMore` / `loadMore` / `resetPaging` |
+| 复用既有设施 | ✅ `app-page` 的 `fixedHeader` + `.split-mode`(固定区 + 内容区内部滚动)**已实现**,本次只是把筛选/搜索**放进 `pageToolbar` 插槽**(此前放在默认内容区导致跟随滚动) |
+| 验证证据(全实测) | ① `npx ng test` → **485 SUCCESS**<br>② `npm run smoke` → **28 PASS / 0 FAIL**<br>③ `npm run verify:ui` → **11 项全过**<br>④ `npm run build` → 主包 gzip **142.70 kB** |
+| 账号归属核实(回答用户) | 手机号 `19170119836` 在库里有 **3 个账号**(分属 3 company),**名下均 0 容器**;`ownerMobile` 也查不到该号。<br>· 用户实际短信登录落到 `9RDXB9RFqj`(昵称「用户9836」,company=`7pIbDBJmKx` = 其主账号 username 前缀)<br>· **容器挂在 `sr2WiPsDyQ`(刘雨飏,mobile 18691770343)**<br>→ **未登录错**,是该手机号对应的账号确实不持有容器。看容器请用 18691770343 或对应 token。 |
+| **lj-test 身份核实(回答用户「我的机器人应该配好了」)** | 实测确认:群里应答的 sender 是 **`@zgdcmgnqcd:fmode.cn`**(我方规范账号,房间成员已确认包含它)✅。机器人报告里说的是**它自己房间**(`MATRIX_HOME_ROOM=!pZR6mCgG…`,我访问得 403)用的是它自己的身份 `@agent-node-lj-test` —— **两套房间并存**,互不冲突。App 侧绑定与应答均正常。 |
+| 未完成 | 移动端对应三页的分页(移动端为整页滚动,按需后续);容器状态数据补全(待容器上报 channelState/host/balance) |
+
+### 2026-10-17 — 组织信息真实化 + 修复Token改名 + 粘性分组头
+
+| 项 | 内容 |
+|---|---|
+| **O1 组织信息真实化** | 🔴 核实为**硬编码假文案**:`{ label:'所属组织', value: me.companyId ? '企业空间' : '未绑定组织' }` + `{ label:'工作空间', value:'团队协作空间' }`。<br>实测 `Company` 表**可拿到 name**(`E4KpGvTEto → 飞码AI`)→ 新增 `Me.companyName` + `ParseApi.fetchCompanyName()`,显示**真实组织名**;取不到时显示「(名称未取到)」**不编造**。<br>「工作空间」原为**编造项**(无数据源)→ **移除**(黄金规则 1)。桌面/移动两处同步。 |
+| **O2 修复 Token 改名** | 🟡 原实现 = 跳回登录页重新取证,但名为「修复 Token」**未说明用途**(用户实测问「这是什么意思」)。→ 文案改「**重新登录**」+ 图标改 rotate + 加注释说明触发场景。桌面/移动两处同步。 |
+| **O3 粘性分组头(用户要求)** | ✏️ 事件页 `.day-head`(日期)+ 成果页 `.group-head`(项目名/月份)加 `position: sticky; top: 0; z-index: var(--z-affix); background: var(--canvas)`。<br>**用 sticky 顶走式**(用户确认)—— 下一个分组到达时把上一个向上顶走(iOS 通讯录手感)。<br>⚠️ **如实说明**:sticky 是「顶走」而非「瞬间替换」;若要后者需 IntersectionObserver(成本高),用户已选 sticky。 |
+| 实测验证(新增) | 用 Playwright 实测计算样式确认 sticky 真生效:<br>· 事件页 `.day-head` → **2 个, position=sticky, top=0px** ✅<br>· 成果页 `.group-head` → **3 个, position=sticky, top=0px** ✅ |
+| **降级说明(用户裁决)** | 「百分比布局」用户明确**不改宽度,只改滚动** → 保持 `--page-max-w` 居中(大屏可读性)。 |
+| 验证证据(全实测) | ① `npx ng test` → **485 SUCCESS**<br>② `npm run smoke` → **28 PASS / 0 FAIL**<br>③ `npm run verify:ui` → **11 项全过**<br>④ `npm run verify:docs` → 41 文件<br>⑤ `npm run build` → 主包 gzip **142.77 kB** |
+| 未完成 | 工作台标签栏粘性(卡片无分组,仅需内部滚动 —— 已由 app-page split-mode 覆盖) |
+
+### 2026-10-17 — 移动端分页补齐 + 分页指令支持移动滚动容器
+
+| 项 | 内容 |
+|---|---|
+| **🔴 指令的移动端缺陷(实测发现)** | `InfiniteScrollDirective` 原实现「向上找 `overflow:auto` 祖先,找不到就监听 `window`」。但**移动端滚的是 `.m-main`(整页滚动)**,向上找不到该祖先 → fallback 到 `window` → **永不触发**。<br>**修复**:抽出 `resolveMobileScroller()`:先向上找滚动祖先(桌面 `app-page` 的 `.scroll-zone` 命中),**兜底全局查 `.m-main`**。 |
+| 移动·事件 | ✏️ 按天分页(与桌面同口径,一次 5 天)+ 哨兵 + 「已加载全部 N 天」 |
+| 移动·成果 | ✏️ 按项目组分页(一次 5 组)+ 哨兵 + 「已加载全部成果」;补 `UiMButton` 导入 |
+| 移动·工作台 | ✏️ 在原有**搜索过滤**之后再加**分页**(一次 12 个):`visibleAgents`(搜索)→ `pagedAgents`(分页)<br>✏️ `onSearch` 触发 `resetPaging()`(搜索后从头看)<br>✏️ 哨兵用原生 `<button>`(该页未引 `UiMButton`,避免为大改引入不必要依赖) |
+| 桌面已确认 | 三页哨兵/结尾提示实测存在:工作台 ✅ / 事件 ✅ / 成果 ✅ |
+| 验证证据(全实测) | ① `npx ng test` → **485 SUCCESS**<br>② `npm run smoke` → **28 PASS / 0 FAIL**<br>③ `npm run verify:ui` → **11 项全过**<br>④ `npm run build` → 主包 gzip **142.75 kB**<br>⑤ Playwright 实测三页分页哨兵均存在 ✅ |
+| 未完成 | D1/D2 容器状态数据补全(需容器侧上报 `channelState`/`host`/`balance`,App 侧已就绪:`agentPresence()` 判定 + 工作台 `hasMetrics()` 占位) |
+
+### 2026-10-17 — D2 指标缺失如实区分(0% vs 未上报)
+
+| 项 | 内容 |
+|---|---|
+| **🔴 缺陷** | 工作台指标区判定用的是 `@if (ready(a))`(**只看是否已接入**),未检查 `Agent.host` 是否存在。<br>后果:lj-test **已接入但容器未上报 host** → 显示 `CPU 0%` —— **误导**(「未上报」与「真的是 0%」是两回事)。 |
+| 修复 | ✏️ 桌面 + 移动工作台指标区改判 `hasMetrics(a)`(已接入 **且** 有 `host`)<br>✏️ `else` 分支细分三种情形:<br> · 有指标 → 显示 CPU/内存<br> · **已接入但无指标 → 「容器已接入但尚未上报运行指标」(如实)**<br> · 未接入 → 「尚未接入 FMODE」<br>✏️ `hasMetrics` 以别名 `hasMetricsFn` 导入并挂类方法(模板要用) |
+| 数据现状(实测) | 只有 **芯葶** 有 host 数据(9-23 上报,已陈旧);**lj-test / 兔爷 / 雨飏 的 `host` 为空** → 工作台将显示「尚未上报」(正确行为)。补全需**容器侧上报**(提示词已给)。 |
+| 验证证据(全实测) | ① `npx ng test` → **485 SUCCESS**<br>② `npm run smoke` → **28 PASS / 0 FAIL**<br>③ `npm run verify:ui` → **11 项全过**<br>④ `npm run verify:docs` → 41 文件<br>⑤ `npm run build` → 主包 gzip **142.76 kB** |
+| App 侧已全部就绪 | 容器状态链路的**前端部分已完备**:`agentPresence()` 判定接入态 + `hasMetrics()` 判定指标有无 + 会话列表状态徽标 + 工作台如实占位。**剩余仅依赖容器上报**。 |

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

@@ -203,7 +203,7 @@ interface MatrixProvisionResult {
 
 | # | 端点 | 用途 | 关键点 |
 |---|---|---|---|
-| R1 | `POST /parse/users/me` | Token 直登校验 + 取身份 | 头 `X-Parse-Session-Token` |
+| R1 | **`GET`** `/parse/users/me` | Token 直登校验 + 取身份 | 头 `X-Parse-Session-Token`。🔴 **实测修订 2026-10-17**:原写 `POST` 会 **404**,正确方法是 **GET** |
 | R2 | `GET /parse/roles?where={users:Pointer<_User>}&limit=50` | 补 `roles` 关系 | 🔴 `_User` JSON **不透出 relation**,必须单独查(存量 v0.3.3 著名坑:超管因缺 roles 被误判为 company-member → 列表空) |
 | R3 | `PUT /parse/users/<id>` | 校正 `company` 指针 | ❌ **v2 禁止调用**(改为服务端权威,见 §2.4) |
 | R4 | `POST /api/apig/message` `{mobile, company}` | 发短信验证码 | 🔴 **`code === 1` 才算成功**(不是 200) |

+ 175 - 0
docs/03-interface/容器任务卡-20261017-lj-test身份对齐.md

@@ -0,0 +1,175 @@
+# 容器任务卡 · lj-test Matrix 身份对齐
+
+| 字段 | 值 |
+|---|---|
+| 文档编号 | 容器任务卡-20261017-lj-test身份对齐 |
+| 版本 | v1.0 |
+| 创建日期 | 2026-10-17 |
+| 状态 | **待执行**(转发给 lj-test 容器) |
+| 用途 | 直接复制「提示词」章节内容发给容器 Agent 执行 |
+| 依据 | fmode-hub `TASK-matrix-v42.md` P3(账号体系改 objectId)· `scripts/setup-matrix-env.sh` |
+| 关联 | 《API-契约.md》§2.3(provision)· `docs/00-tracking/操作追踪.md` 2026-10-17 条 |
+
+---
+
+## 〇、安全说明(必读)
+
+🔴 **本文档不记录明文密码/令牌**(黄金规则 9)。
+派生密码由**规则计算得出**(`sha256("fmode-agent-mx:" + <objectId 小写>)[:24]`),
+故文档只给**计算方法**,不给值 —— 拿到 objectId 即可自行算出,无需存明文。
+
+---
+
+## 一、为什么要做这件事
+
+`@agent-node-lj-test:fmode.cn` 是**手工注册**的账号,用 `agentId` 命名,**不符合规范**。
+
+规范要求(fmode-hub v4.2 P3 起):
+
+```
+Agent Matrix 账号 = @<FMODE_AGENT.objectId 小写>:fmode.cn
+```
+
+原因:**客户端只能通过 `FMODE_AGENT` 表的 `objectId` 派生 mxId 来找到 Agent**。
+App 侧链路是「查 `FmodeAgent` → 取 `objectId` → 派生 mxId → 调 provision → 连房间」,
+而 lj-test 此前**不在** `FmodeAgent` 表,App 无法算出它的身份 → 永远连不上。
+
+### 对照:合规 vs 不合规
+
+| agentId | objectId | 派生的 mxId | 合规 |
+|---|---|---|---|
+| `agent-node-xinting` | `qRovdDU7cY` | `@qrovddu7cy:fmode.cn` | ✅ |
+| `agent-node-tuye` | `rT9UIiNrwQ` | `@rt9uiinrwq:fmode.cn` | ✅ |
+| `agent-node-lj-test` | **不存在** | `@agent-node-lj-test:fmode.cn` | ❌ 手工注册,用了 agentId |
+
+---
+
+## 二、已完成的前置(运维侧执行,容器不必重复)
+
+2026-10-17 完成:
+
+| # | 变更 | 值 |
+|---|---|---|
+| 1 | **补录 `FmodeAgent` 记录** | `agentId=agent-node-lj-test`,**`objectId=zGDCmgNQcd`** |
+| 2 | **创建规范 Matrix 账号** | `@zgdcmgnqcd:fmode.cn`(密码按派生规则设置) |
+| 3 | **新建 DM 房间**(主人↔Agent) | `!OIm7iWR6fLkTGPcvKOW_mf-tpxDt5joap9qcCRh9v_4` |
+| 4 | **双方均已 join 房间** | 修复「只 invite 不入房 → 房间零消息」的历史坑 |
+| 5 | **写入 `FmodeMatrixBinding`** | objectId `iyHgfhOAZ5`,`accessToken` = **用户** token |
+
+其他字段:`user=sr2WiPsDyQ`(主人)· `company=rpnXZC1loU` · `ownerMobile=18691770343` · `roomId` 同上。
+
+> ⚠️ **注意**:第 2 步创建的账号与旧账号 `@agent-node-lj-test` **是两个不同账号**。
+> 旧账号不删除,仅归档;新身份是新账号 + 新房间,**历史消息不在**。
+
+---
+
+## 三、提示词(复制以下整段发给容器)
+
+```
+【任务】修正 Matrix 身份,对齐 FMODE 接入规范 v4.2 P3
+
+== 背景 ==
+你现在用的是 @agent-node-lj-test:fmode.cn(手工注册、用 agentId 命名),不符合规范。
+规范要求 Agent Matrix 账号 = @<FMODE_AGENT.objectId 小写>:fmode.cn,
+因为客户端只能通过 FMODE_AGENT 表的 objectId 派生 mxId 来找到你。
+
+== 已完成的前置(由运维侧执行,你不用重复做)==
+1. 你的 FMODE_AGENT 记录已补录:
+     agentId     = agent-node-lj-test
+     objectId    = zGDCmgNQcd        ← 关键,你的新身份由此派生
+2. 你的规范 Matrix 账号已创建:
+     mxId        = @zgdcmgnqcd:fmode.cn
+3. 主人↔你 的 DM 房间已创建且双方已加入:
+     roomId      = !OIm7iWR6fLkTGPcvKOW_mf-tpxDt5joap9qcCRh9v_4
+4. 绑定表 FmodeMatrixBinding 已写入,provision 云函数已能返回你的权威凭据。
+
+== 你要做的(幂等,可重复执行)==
+1. 调 provision 云函数拿权威凭据:
+     POST https://server.fmode.cn/api/functions
+     Headers: Content-Type: application/json
+     Body: {"token":"<你的设备 sessionToken>","id":"RP5Ht55NeX",
+            "params":{"agentId":"agent-node-lj-test"}}
+   返回(实测已确认):
+     {"code":200,"cached":true,
+      "userMxId":"@sr2wipsdyq:fmode.cn",
+      "agentMxId":"@zgdcmgnqcd:fmode.cn",
+      "homeserver":"https://matrix.fmode.cn",
+      "accessToken":"<用户 token,注意这不是你的身份凭据>",
+      "deviceId":"s1ipY7fvRX",
+      "roomId":"!OIm7iWR6fLkTGPcvKOW_mf-tpxDt5joap9qcCRh9v_4",
+      "bindingId":"iyHgfhOAZ5"}
+
+   ⚠️ 注意:provision 返回的 accessToken 是**主人身份**的 token(历史设计如此),
+   不是你的。你的身份凭据要用下一步的派生密码自行登录取得。
+
+2. 用规范派生密码登录你自己的新账号,拿你自己的 access_token:
+     password = sha256("fmode-agent-mx:" + "zgdcmgnqcd")[:24]
+              = sha256("fmode-agent-mx:" + "zgdcmgnqcd")[:24]   ← 自行计算,勿外传
+     POST https://matrix.fmode.cn/_matrix/client/v3/login
+     Body: {"type":"m.login.password",
+            "identifier":{"type":"m.id.user","user":"zgdcmgnqcd"},
+            "password":"<上一步算出的派生密码>"}
+     → 取返回的 access_token(实测该账号已创建、密码已按此规则设置)
+
+3. 🔴 确保你自己真的在房间里(最关键!fmode-hub 脚本注释警告过:
+   「旧实现只邀请不入房 → 邀请永久挂起 → 房间零消息,用户无法聊天」):
+     GET /_matrix/client/v3/joined_rooms   (带你的 Bearer token)
+     检查 roomId !OIm7iWR6fLkTGPcvKOW_mf-tpxDt5joap9qcCRh9v_4 是否在列表里
+     不在 → POST /_matrix/client/v3/join/<roomId>
+     若 403 → 先请 admin 补一次 invite 再 join
+   (运维侧已确认双方均已在房,但请你独立复核一次)
+
+4. 改 /opt/data/.env(**先备份** .env.bak-mxenv-<时间戳>,保留其它键):
+     MATRIX_HOMESERVER=https://matrix.fmode.cn
+     MATRIX_USER_ID=@zgdcmgnqcd:fmode.cn
+     MATRIX_ACCESS_TOKEN=<第2步拿到的你自己的 token>
+     MATRIX_HOME_ROOM=!OIm7iWR6fLkTGPcvKOW_mf-tpxDt5joap9qcCRh9v_4
+     MATRIX_ALLOWED_USERS=@sr2wipsdyq:fmode.cn
+     MATRIX_IGNORE_USER_PATTERNS=<沿用你现有的防回环配置>
+   env 文件权限置 600。
+
+5. 重启 Hermes gateway 使新 env 生效(s6 服务 gateway-default 会自动拉起)。
+
+6. 回报(**服务端回读证据,不要自报**):
+   - whoami 返回的 user_id 必须 == @zgdcmgnqcd:fmode.cn
+   - 在 roomId 里发一条自检消息,回读 /messages 确认落库
+   - 旧账号 @agent-node-lj-test:fmode.cn 归档(不删,留痕)
+
+== 纪律 ==
+凭据不入 git;env 权限 600;旧 .env 备份可回退;不 kill 其它进程。
+完成后只输出一行:
+MX-REALIGN-DONE mxid=@zgdcmgnqcd:fmode.cn room=!OIm7iWR6fLkTGPcvKOW_mf-tpxDt5joap9qcCRh9v_4 gateway=<connected> 旧账号=<已归档>
+```
+
+---
+
+## 四、已知代价(执行前需知情)
+
+| # | 代价 | 说明 |
+|---|---|---|
+| 1 | **通道短暂中断** | 容器重启 gateway 期间 Matrix 通道断十几秒,属正常 |
+| 2 | **旧身份作废** | `@agent-node-lj-test:fmode.cn` 及它和 `@admin` 的房间不随迁移;新身份是新账号 + 新房间,**历史消息不在** |
+| 3 | **旧账号保留** | 只归档不删除,便于回溯(fmode-hub 纪律) |
+
+---
+
+## 五、验收(回读为准,不看自报)
+
+| # | 验收项 | 判据 |
+|---|---|---|
+| 1 | 新账号存在 | `register/available?username=zgdcmgnqcd` 返回 `M_USER_IN_USE` |
+| 2 | 身份正确 | `whoami` 的 `user_id == @zgdcmgnqcd:fmode.cn` |
+| 3 | **Agent 在房** | `GET /rooms/<roomId>/joined_members` 的 `joined` 含 `@zgdcmgnqcd:fmode.cn` |
+| 4 | 收发闭环 | App 发消息 → 回读 `/messages` 有该条 → **容器回复出现在同房间** |
+| 5 | env 已生效 | 容器内 `MATRIX_USER_ID` = 新 mxId |
+
+> 🔴 第 4 项是**唯一能证明"真的通了"**的判据;前三项都可通过不代表能对话。
+
+---
+
+## 六、关联
+
+- 《API-契约.md》§2.3(云函数 `RP5Ht55NeX` provision)
+- 《开通手册-20261017-FMODE接入标准流程.md》(**以后每次开通新 Agent 用这份**)
+- fmode-hub:`TASK-matrix-v42.md`(P3 账号体系)· `scripts/setup-matrix-env.sh`(env 下发)
+- `docs/00-tracking/操作追踪.md` 2026-10-17 条(本次变更留痕)

+ 317 - 0
docs/03-interface/开通手册-20261017-FMODE接入标准流程.md

@@ -0,0 +1,317 @@
+# 开通手册 · FMODE(Matrix)接入标准流程
+
+| 字段 | 值 |
+|---|---|
+| 文档编号 | 开通手册-20261017-FMODE接入标准流程 |
+| 版本 | v1.0 |
+| 创建日期 | 2026-10-17 |
+| 状态 | **生效**(后续每次开通新 Agent 按本流程执行) |
+| 用途 | ① 标准操作流程 ② 可复制给容器的提示词模板 ③ 故障排查 |
+| 依据 | fmode-hub `ARCHITECTURE.md` §5.2 · `TASK-matrix-v42.md` P3 · 《API-契约.md》§2.3 |
+| 关联 | 《容器任务卡-20261017-lj-test身份对齐.md》(一个真实案例) |
+
+---
+
+## 〇、先记住这一句话
+
+> **不需要手工建账号。** provision 云函数会自动完成「建用户账号 → 建 Agent 账号 → 建 DM 房间 → 写绑定表」。
+
+你要做的只有两件事:
+
+```
+① 让 Agent 进 FMODE_AGENT 表     ② 让容器调 provision 并配好 env
+```
+
+---
+
+## 一、核心原理(30 秒理解)
+
+Matrix 是**统一身份 + 消息总线**,人和机器人**平权**,都是 `@用户名:域名`:
+
+| 身份 | 示例 |
+|---|---|
+| 人(主人) | `@sr2wipsdyq:fmode.cn` |
+| Agent(容器) | `@<objectId 小写>:fmode.cn`,如 `@qrovddu7cy:fmode.cn`(芯葶) |
+
+**通信是星型中转,不是点对点**:App 与容器**不直连**,双方都通过 homeserver 上的**房间**收发。
+
+```
+  App (@sr2wipsdyq)               容器 (@<objectId>)
+        │ ①sendEvent                  │ ③/sync 长轮询拉取
+        │ ②/sync 拉取                 │ ④处理后发回
+        ▼                              ▼
+   ┌────────────────────────────────────────┐
+   │  matrix.fmode.cn (Homeserver)          │
+   │  Room !xxx ← 共享时间线,消息的唯一载体  │
+   └────────────────────────────────────────┘
+```
+
+**三个关键概念**:
+
+| 概念 | 说明 |
+|---|---|
+| **为什么用房间** | Matrix 没有「给某人发消息」,只有「往房间发消息」。私聊 = 只含两人 + `is_direct:true` 的房间 |
+| **为什么用主人账号** | App 代表「人」这个身份;拿主人 token 登录,消息才显示是主人发的 |
+| **为什么必须同房间** | 没有共同房间,消息**无从投递**。lj-test 原先只在 `@admin` 的房间,主人不在里面 → 无法对话 |
+
+**身份规则**(v4.2 P3):
+
+```
+用户 mxid  = @<用户 objectId 小写>:fmode.cn
+Agent mxid = @<FMODE_AGENT.objectId 小写>:fmode.cn   ← objectId 是 10 位随机串
+```
+
+---
+
+## 二、标准开通流程(五步)
+
+### 步骤 1 · 前置检查:Agent 是否在 `FmodeAgent` 表
+
+```bash
+# 云函数单查(只读)
+curl -s -X POST https://server.fmode.cn/api/functions \
+  -H 'Content-Type: application/json' \
+  -d '{"token":"<超管sessionToken>","id":"xlosi3m46q",
+       "params":{"op":"query","scope":"all","agentId":"<agentId>"}}'
+```
+
+- **有记录** → 记下 `objectId`,进步骤 2
+- **无记录** → 🔴 **必须先补录**(见 §四「补录规范」),否则 provision 无法开通
+
+### 步骤 2 · 调 provision(幂等)
+
+```bash
+curl -s -X POST https://server.fmode.cn/api/functions \
+  -H 'Content-Type: application/json' \
+  -d '{"token":"<超管sessionToken>","id":"RP5Ht55NeX",
+       "params":{"agentId":"<agentId>"}}'
+```
+
+返回:
+
+```json
+{
+  "code": 200,
+  "cached": true,                    // true=绑定表命中(已开通),幂等秒进
+  "bindingId": "...",
+  "userMxId": "@<用户objectId小写>:fmode.cn",
+  "agentMxId": "@<agentobjectId小写>:fmode.cn",
+  "homeserver": "https://matrix.fmode.cn",
+  "accessToken": "...",              // ⚠️ 这是【用户】token,非 Agent 凭据
+  "deviceId": "...",
+  "roomId": "!...",
+  "status": "active"
+}
+```
+
+校验规则:必须 `code===200 && accessToken && userMxId`,否则视为失败。
+
+> ⚠️ **易错点**:`accessToken` 是**用户身份**的 token(历史设计)。Agent 自己的凭据要用派生密码登录取得(见步骤 3)。
+
+### 步骤 3 · 🔴 校验 Agent 真的在房间里
+
+**这一步最容易漏,漏了会导致「房间零消息、用户发不出去话」。**
+
+```bash
+curl -s -H "Authorization: Bearer <用户token>" \
+  "https://matrix.fmode.cn/_matrix/client/v3/rooms/<roomId>/joined_members"
+```
+
+看 `joined` 里有没有 Agent 的 mxId:
+
+- **在** → 继续
+- **不在** → 补加入:
+  ```
+  POST /_matrix/client/v3/join/<roomId>      (用 Agent 自己的 token)
+  ```
+  若返回 403(无邀请)→ 先请 admin 补一次 `invite` 再 join。
+
+> 📌 **为什么会有这个坑**:fmode-hub `setup-matrix-env.sh` 注释原文——
+> 「旧实现只邀请不入房 → **邀请永久挂起 → 房间零消息,用户"无法聊天"**」。
+> 实测抽样 12 条绑定,**8 条**的 Agent 根本没入房。
+
+### 步骤 4 · 容器侧配 env + 重启 gateway
+
+见 §三 提示词模板。
+
+### 步骤 5 · 验收(回读为准,不看自报)
+
+| # | 验收项 | 判据 |
+|---|---|---|
+| 1 | 账号存在 | `register/available?username=<objectId小写>` 返回 `M_USER_IN_USE` |
+| 2 | identity 正确 | `whoami` 的 `user_id == @<objectId小写>:fmode.cn` |
+| 3 | **Agent 在房** | `joined_members` 含 Agent mxId |
+| 4 | 🔴 **收发闭环** | App 发消息 → 回读 `/messages` 有该条 → **容器回复出现在同房间** |
+| 5 | env 生效 | 容器内 `MATRIX_USER_ID` = 新 mxId |
+
+> 前 3 项都通过**不代表能对话**,只有第 4 项能证明"真的通了"。
+
+---
+
+## 三、容器提示词模板(复制发给容器)
+
+> 把 `<AGENT_ID>` `<OBJECT_ID>` `<ROOM_ID>` 替换为实际值。
+
+```
+【任务】开通/校验 FMODE (Matrix) 接入
+
+== 你的身份(规范 v4.2 P3)==
+  agentId   = <AGENT_ID>
+  objectId  = <OBJECT_ID>
+  你的 mxId = @<OBJECT_ID 小写>:fmode.cn
+
+== 你要做的(幂等,可重复执行)==
+1. 调 provision 云函数拿权威凭据:
+     POST https://server.fmode.cn/api/functions
+     Headers: Content-Type: application/json
+     Body: {"token":"<你的设备 sessionToken>","id":"RP5Ht55NeX",
+            "params":{"agentId":"<AGENT_ID>"}}
+   返回 agentMxId / roomId / userMxId / bindingId。
+   ⚠️ 返回的 accessToken 是【主人身份】的,不是你的。
+
+2. 用规范派生密码登录你自己,拿你自己的 access_token:
+     password = sha256("fmode-agent-mx:" + "<OBJECT_ID 小写>")[:24]
+     POST https://matrix.fmode.cn/_matrix/client/v3/login
+     Body: {"type":"m.login.password",
+            "identifier":{"type":"m.id.user","user":"<OBJECT_ID 小写>"},
+            "password":"<上一步算出的密码>"}
+
+3. 🔴 确保你真的在房间里(最关键,漏了会导致房间零消息):
+     GET /_matrix/client/v3/joined_rooms   (带你的 token)
+     检查 roomId <ROOM_ID> 在不在
+     不在 → POST /_matrix/client/v3/join/<ROOM_ID>
+     403 → 先请 admin 补 invite 再 join
+
+4. 改 /opt/data/.env(先备份 .env.bak-mxenv-<时间戳>,保留其它键):
+     MATRIX_HOMESERVER=https://matrix.fmode.cn
+     MATRIX_USER_ID=@<OBJECT_ID 小写>:fmode.cn
+     MATRIX_ACCESS_TOKEN=<第2步的 token>
+     MATRIX_HOME_ROOM=<ROOM_ID>
+     MATRIX_ALLOWED_USERS=<主人 mxId>
+     MATRIX_IGNORE_USER_PATTERNS=<沿用现有防回环配置>
+   env 权限 600。
+
+5. 重启 Hermes gateway(s6 服务 gateway-default 自动拉起)。
+
+6. 回报(服务端回读证据,不要自报):
+   - whoami 的 user_id == 新 mxId
+   - 在 roomId 发一条自检消息,回读 /messages 确认落库
+
+== 纪律 ==
+凭据不入 git;env 权限 600;旧 .env 备份可回退;不 kill 其它进程。
+完成后输出一行:MX-DONE mxid=<...> room=<...> gateway=<connected>
+```
+
+---
+
+## 四、补录规范(Agent 不在 `FmodeAgent` 表时)
+
+**何时需要**:步骤 1 查出无记录。通常是 Agent **绕过了 provision 手工注册**(如 lj-test)。
+
+**这是直接写库操作**,需明确授权。字段结构(参照现有 `agent-node-*` 记录):
+
+| 字段 | 取值 |
+|---|---|
+| `agentId` | Agent 标识,如 `agent-node-lj-test` |
+| `name` / `botName` | 名称 / 显示名 |
+| `user` | Pointer → `_User.<主人 objectId>` |
+| `company` | Pointer → `Company.<公司 objectId>` |
+| `ownerMobile` / `ownerName` | 主人手机 / 昵称 |
+| `status` | `online` |
+| `gender` | `male` / `female` / `unknown` |
+| `botAvatar` | 空(后续由 reporter 探测填充) |
+| `containerRef` | 容器 hostname |
+| `currentTask` / `currentAction` | 空 |
+| `host` / `balance` / `channels` / `extra` | **JSON 字符串**(非对象!) |
+| `ACL` | 见下 |
+| `objectId` | 🔴 **不可指定**,由 Parse 自动生成(10 位) |
+
+**ACL 模板**:
+
+```json
+{
+  "<主人objectId>":                 { "read": true, "write": true },
+  "role:FMODE_AGENT_SUPERADMIN":    { "read": true, "write": true },
+  "role:<公司objectId>_Admin":       { "read": true, "write": true },
+  "role:<公司objectId>_Public":      { "read": true }
+}
+```
+
+**JSON 字符串字段的初始值**:
+
+```json
+host     : "{\"cpuPercent\":0,\"diskPercent\":0,\"memPercent\":0}"
+balance  : "{\"remainCny\":null,\"source\":\"none\"}"
+channels : "{\"homeserver\":\"\",\"matrix\":false,\"matrixConfig\":{},\"wecom\":false}"
+extra    : "{\"agentId\":\"<agentId>\"}"
+```
+
+> 🔴 **objectId 不可自定义**。实测:传 `objectId` 会被 Parse 拒绝
+> (`{"code":105,"error":"objectId is an invalid field name."}`)。
+> 所以补录后的 mxId **必然是新 objectId 派生**,与旧手工账号不同 → 必须配套改容器配置。
+
+---
+
+## 五、批量开通
+
+仓库有现成脚本:
+
+```bash
+# 遍历 FMODE_AGENT 全表 → 逐个 provision → 输出开通状态表
+FMODE_SESSION_TOKEN=<超管token> bash scripts/provision-all-agents.sh
+
+# 只看不改
+FMODE_SESSION_TOKEN=<超管token> bash scripts/provision-all-agents.sh --dry-run
+
+# 指定节点
+FMODE_SESSION_TOKEN=<超管token> AGENT_IDS="agent-node-xinting agent-node-tuye" \
+  bash scripts/provision-all-agents.sh
+```
+
+输出标记:`✅ 新建` / `🟢 已存在` / `❌ 失败`。
+
+> ⚠️ 脚本只做 provision,**不做「校验 Agent 入房」**(步骤 3)。
+> 批量后需自行抽查 `joined_members`。
+
+---
+
+## 六、故障排查
+
+| 现象 | 原因 | 处理 |
+|---|---|---|
+| provision 返回 401「sessionToken 无效」 | 令牌过期 | 重新登录取新 sessionToken |
+| provision 返回「agent not found」 | Agent 不在 `FmodeAgent` 表 | 按 §四 补录 |
+| 房间建好了但**一条消息都没有** | 🔴 **Agent 未入房**(只 invite) | 按步骤 3 补 join |
+| 消息发出去了但**容器不回复** | 容器 env 还是旧身份 / gateway 未重启 | 按步骤 4 改 env 并重启 |
+| App 里看不到某个 Agent | 该 Agent 的 `channels.matrix` 非 true | provision 后会置 true;或 reporter 未上报 |
+| 发出的消息 sender 显示是 Agent 而非我 | 绑定表 `accessToken` 误填了 Agent token | 绑定表 `accessToken` 应为**用户** token |
+| 提示「Matrix 客户端未连接」 | `connect()` 未调用或凭据缺失 | 检查 `userMxId` / `accessToken` 是否为空 |
+
+---
+
+## 七、v2 应用侧对接说明
+
+v2 的接入链路(已在 `src/app/core/` 实现):
+
+```
+ChatService.open(agentId)
+  ├─ ParseApi.provisionMatrix(agentId)      → 云函数 RP5Ht55NeX
+  ├─ MatrixClientService.connect(token, userMxId, deviceId)   → /sync 长轮询
+  ├─ MatrixClientService.ensureRoom(agentMxId, {roomId})      → 绑定表 roomId 优先
+  └─ onMessage() 订阅 → 实时追加;sendText() → sendEvent
+```
+
+**关键约束**:
+
+- `matrix-js-sdk` 用 `await import()` **动态加载**,不进主包(NFR-02)
+- 前端**零密钥**:无派生密码、无 TURN 凭据;凭据一律经 provision 服务端签发(黄金规则 9)
+- `fetchTasks` / `fetchTaskDetail` 云函数接收端未实现 → **显式抛错**,不静默返回空
+
+---
+
+## 八、关联
+
+- 《API-契约.md》§2.3(provision)· §2.5(上传预签名)
+- 《容器任务卡-20261017-lj-test身份对齐.md》(真实案例)
+- 《数据访问层设计.md》(v2 分层与抽象层)
+- fmode-hub:`ARCHITECTURE.md` §5.2 · `TASK-matrix-v42.md` · `scripts/provision-all-agents.sh` · `scripts/setup-matrix-env.sh`

+ 14 - 14
docs/04-quality/traceability-matrix.md

@@ -29,27 +29,27 @@
 
 | FR 编号 | 需求名 | 用户画像 | 验收标准 | 设计文档 | 数据/接口 | 测试用例 | 状态 |
 |---|---|---|---|---|---|---|---|
-| FR-01 | 端分流与导航 | 全部 | AC-01.1~4 | `数据层`§五 DeviceService · `组件`§2.1/§3.1 | `数据层`§四.1 | 待定 | 设计就绪 |
+| FR-01 | 端分流与导航 | 全部 | AC-01.1~4 | `数据层`§五 DeviceService · `组件`§2.1/§3.1 | `数据层`§四.1 | `device.service.spec.ts`(UA 矩阵) + `smoke`(双端重定向) | 设计就绪 |
 | FR-02 | 登录与鉴权 | 全部 | AC-02.1~7 | `数据层`§五 SessionService · `组件`§2.2 login | `API`§2.4 / §3(R1,R4,R5) | 待定 | 设计就绪 |
-| FR-03 | 通讯录 | P1 / P2 | AC-03.1~4 | `组件`§2.2 contacts / §3.2 contacts · `数据层`§四.1 | `API`§2.1 | 待定 | 设计就绪 |
-| FR-04 | 会话(IM 中枢) | P1 | AC-04.1~11 | `组件`§四-1/2/3(气泡/composer/文件卡)· `布局`§2.3 | `API`§2.3/§2.5 · `数据层`§五 ChatService | 待定 | 设计就绪 |
-| FR-05 | 项目 | P2 | AC-05.1~3 | `组件`§2.2 projects / §四-8(split-workspace)· `布局`§4.1 | `API`§2.2(byProject)· `数据层`§四.1 | 待定 | 设计就绪 |
-| FR-06 | 事件 | P2 / P3 | AC-06.1~4 | `组件`§四-6(day-group)/§四-11(filter-chips) | `数据层`§四.1(派生口径) | 待定 | 设计就绪 |
-| FR-07 | 工作台 | P2 / P3 | AC-07.1~4 | `组件`§四-4(metric-block)/§四-12(live-card) | `API`§2.1 · `数据层`§五 LiveService | 待定 | 设计就绪 |
-| FR-07b | 任务看板(多任务真源) | P2 / P3 | AC-07b.1~6 | `组件`§2.2 tasks / §3.2 tasks · `布局`§2.3 | `API`§2.6 · `数据层`§五 TasksService | 待定 | ✅ 已纳入 · 设计就绪(⚠️ 依赖云函数) |
-| FR-08 | 成果 | P1 | AC-08.1~4 | `组件`§四-10(file-preview) | `API`§2.2(list) | 待定 | 设计就绪 |
+| FR-03 | 通讯录 | P1 / P2 | AC-03.1~4 | `组件`§2.2 contacts / §3.2 contacts · `数据层`§四.1 | `API`§2.1 | `smoke`(通讯录路由 + 搜索/筛选可达) | 设计就绪 |
+| FR-04 | 会话(IM 中枢) | P1 | AC-04.1~11 | `组件`§四-1/2/3(气泡/composer/文件卡)· `布局`§2.3 | `API`§2.3/§2.5 · `数据层`§五 ChatService | `unread.service.spec.ts`(未读三场景) + `smoke`(发送→气泡追加) | 设计就绪 |
+| FR-05 | 项目 | P2 | AC-05.1~3 | `组件`§2.2 projects / §四-8(split-workspace)· `布局`§4.1 | `API`§2.2(byProject)· `数据层`§四.1 | `smoke`(项目路由 双端) | 设计就绪 |
+| FR-06 | 事件 | P2 / P3 | AC-06.1~4 | `组件`§四-6(day-group)/§四-11(filter-chips) | `数据层`§四.1(派生口径) | `smoke`(事件路由 双端) | 设计就绪 |
+| FR-07 | 工作台 | P2 / P3 | AC-07.1~4 | `组件`§四-4(metric-block)/§四-12(live-card) | `API`§2.1 · `数据层`§五 LiveService | `format.spec.ts`(余额/进度口径) | 设计就绪 |
+| FR-07b | 任务看板(多任务真源) | P2 / P3 | AC-07b.1~6 | `组件`§2.2 tasks / §3.2 tasks · `布局`§2.3 | `API`§2.6 · `数据层`§五 TasksService | `tasks.service.spec.ts`(未就绪→error 态 + 筛选/计数/只读) | ✅ 已纳入 · 设计就绪(⚠️ 依赖云函数) |
+| FR-08 | 成果 | P1 | AC-08.1~4 | `组件`§四-10(file-preview) | `API`§2.2(list) | `parse-api.spec.ts`(成果映射 + state 判定) + `smoke`(成果路由) | 设计就绪 |
 | FR-09 | 数字生命详情 | 全部 | AC-09.1~4 | `组件`§四-4 · `布局`§2.3 | `API`§2.1 | 待定 | 设计就绪 |
-| FR-10 | 我的 | 全部 | AC-10.1~3 | `组件`§2.2 mine · `令牌`§四(accent 9 色) | `数据层`§五 ThemeService/SessionService | 待定 | 设计就绪 |
+| FR-10 | 我的 | 全部 | AC-10.1~3 | `组件`§2.2 mine · `令牌`§四(accent 9 色) | `数据层`§五 ThemeService/SessionService | `device.service.spec.ts`(force/resetToAuto) + `smoke`(mine 路由) | 设计就绪 |
 | FR-11 | 语音通话 | P1 | AC-11.1~3 | `布局`§六(`/call/:id` 落点) | — | — | 🅿️ 增项·暂缓(禁建组件) |
 | FR-12 | UI 规范符合性 | — (横切) | AC-12.1~6 | `令牌`全文 + `组件`§六 · `ADR-006` | — | `verify:ui` 断言脚本 | 设计就绪 |
 | FR-13 | 无障碍基线 | — (横切) | AC-13.1~4 | `令牌`§七.3 · `布局`§3.2/§3.3/§3.5 | — | `verify:ui` + 实测 | 设计就绪 |
-| FR-14 | 单一事实源与轮询收敛 | — (横切) | AC-14.1~3 | `数据层`§五(轮询 4→1 收敛表) | `数据层`§一(分层硬约束) | 待定 | 设计就绪 |
+| FR-14 | 单一事实源与轮询收敛 | — (横切) | AC-14.1~3 | `数据层`§五(轮询 4→1 收敛表) | `数据层`§一(分层硬约束) | `live.service.spec.ts`(唯一轮询器/stop 真停/三态/seq) | 设计就绪 |
 | FR-15 | 代码洁净度 | — (横切) | AC-15.1~3 | `数据层`§七 · `令牌`§十二 | — | lint + `verify:ui` | 设计就绪 |
-| FR-16 | 旧版替换迁移 | 全部 | AC-16.1~4 | `布局`§六(旧键 + 旧路径清单) | `数据层`§五 DeviceService | 待定 | 设计就绪 |
-| FR-17 | 三类画像信息架构适配 | 全部 | AC-17.1~3 | `令牌`§一 · `布局`§四 · 《用户画像.md》 | — | 待定 | 设计就绪 |
+| FR-16 | 旧版替换迁移 | 全部 | AC-16.1~4 | `布局`§六(旧键 + 旧路径清单) | `数据层`§五 DeviceService | `device.service.spec.ts`(normalize 全清单) + `smoke`(旧路径重定向) | 设计就绪 |
+| FR-17 | 三类画像信息架构适配 | 全部 | AC-17.1~3 | `令牌`§一 · `布局`§四 · 《用户画像.md》 | — | `smoke`(双端落地页 + ≤2 次点击可达) | 设计就绪 |
 | FR-18 | 范围冻结声明 | — (治理) | — | — | — | — | ✅ 已确认 |
-| FR-19 | 移动端聚合首页 | P1 | AC-19.1~4 | `组件`§3.2 home / §四-6 | `数据层`§六.1(分区独立三态) | 待定 | 设计就绪 |
-| FR-20 | 超管可见范围 | P2 | AC-20.1~3 | `API`§五(ACL 口径) | `API`§五(服务端为准) | 待定 | 设计就绪 |
+| FR-19 | 移动端聚合首页 | P1 | AC-19.1~4 | `组件`§3.2 home / §四-6 | `数据层`§六.1(分区独立三态) | `smoke`(移动首页路由 + 输入框字号≥16px) | 设计就绪 |
+| FR-20 | 超管可见范围 | P2 | AC-20.1~3 | `API`§五(ACL 口径) | `API`§五(服务端为准) | `parse-api.spec.ts`(roles 缺失降级,判级以服务端为准) | 设计就绪 |
 
 ---
 

+ 2 - 0
docs/README.md

@@ -79,6 +79,8 @@
 |---|---|---|
 | [api/API-契约.md](03-interface/api/API-契约.md) | 🔴 **生效(编码基线)** | 5 个云函数 + 7 个 REST 端点 + Parse 数据表 + ACL 口径 + 8 项待服务端核对 |
 | [待问Agent-20261014-多任务接入核实.md](03-interface/待问Agent-20261014-多任务接入核实.md) | **待转发** | 6 组 16 问,直接复制发给容器 Agent |
+| [开通手册-20261017-FMODE接入标准流程.md](03-interface/开通手册-20261017-FMODE接入标准流程.md) | 🔴 **生效** | **每次开通新 Agent 按此执行**:五步流程 + 容器提示词模板 + 补录规范 + 故障排查 |
+| [容器任务卡-20261017-lj-test身份对齐.md](03-interface/容器任务卡-20261017-lj-test身份对齐.md) | **待执行** | lj-test 身份对齐任务卡,含可复制提示词与验收判据 |
 
 ### 04-quality/ 质量保障
 | 文档 | 状态 | 说明 |

Разница между файлами не показана из-за своего большого размера
+ 1269 - 56
package-lock.json


+ 8 - 2
package.json

@@ -8,7 +8,8 @@
     "watch": "ng build --watch --configuration development",
     "test": "ng test",
     "verify:docs": "node scripts/check-doc-structure.mjs docs",
-    "verify:ui": "node scripts/verify-ui.mjs"
+    "verify:ui": "node scripts/verify-ui.mjs",
+    "smoke": "node scripts/smoke-imhub.mjs"
   },
   "prettier": {
     "printWidth": 100,
@@ -31,8 +32,12 @@
     "@angular/platform-browser": "^20.3.0",
     "@angular/router": "^20.3.0",
     "@fortawesome/fontawesome-free": "^7.3.1",
+    "@silurus/ooxml": "^0.66.3",
     "fmode-ng": "^0.0.246",
     "matrix-js-sdk": "^42.4.0",
+    "ngx-markdown": "^20.1.0",
+    "pdfjs-dist": "^6.4.299",
+    "prismjs": "^1.30.0",
     "rxjs": "~7.8.0",
     "tslib": "^2.3.0",
     "zone.js": "~0.15.0"
@@ -49,8 +54,9 @@
     "karma-coverage": "~2.2.0",
     "karma-jasmine": "~5.1.0",
     "karma-jasmine-html-reporter": "~2.1.0",
+    "playwright": "^1.64.1",
     "postcss": "^8.5.30",
     "tailwindcss": "^3.4.19",
     "typescript": "~5.9.2"
   }
-}
+}

+ 350 - 0
scripts/smoke-imhub.mjs

@@ -0,0 +1,350 @@
+#!/usr/bin/env node
+/**
+ * 双端全路由冒烟断言(PRD §2.3 成功标准 ③ / SPEC NFR-06)
+ *
+ * 用法:
+ *   npm run build && npm run smoke
+ *   # 或指定已运行的地址: SMOKE_URL=http://localhost:4200 npm run smoke
+ *
+ * 行为:
+ *   1. 起一个静态服务器托管 `dist/fmode-app-v2/browser`(或复用 SMOKE_URL)
+ *   2. 用桌面(1440×900)与移动(390×844)两个上下文跑全路由断言
+ *   3. 断言项:路由可达 / 无未捕获页面错误 / 无横向溢出 / 关键交互可用
+ *   4. 结果落 `test-results/smoke/`(截图 + 汇总 JSON)
+ *
+ * 🔴 数据源说明:冒烟在 **演示数据模式** 下跑(`useMockData: true` 构建),
+ *    以便在无真实后端凭据时也能验证 UI 结构与路由。
+ *    真实数据链路另由 `npm run smoke:live`(需登录态)覆盖。
+ */
+import { chromium, devices } from 'playwright';
+import { mkdir, writeFile, readFile, readdir } from 'node:fs/promises';
+import { existsSync } from 'node:fs';
+import { createServer } from 'node:http';
+import { extname, join, resolve, dirname } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import assert from 'node:assert/strict';
+
+const here = dirname(fileURLToPath(import.meta.url));
+const projectRoot = resolve(here, '..');
+const distDir = resolve(projectRoot, 'dist/fmode-app-v2/browser');
+const outDir = resolve(projectRoot, 'test-results/smoke');
+
+const results = [];
+const pageErrors = [];
+const failedRequests = [];
+
+// ---------- 静态服务器 ----------
+const MIME = {
+  '.html': 'text/html',
+  '.js': 'text/javascript',
+  '.css': 'text/css',
+  '.json': 'application/json',
+  '.svg': 'image/svg+xml',
+  '.woff2': 'font/woff2',
+  '.ico': 'image/x-icon',
+  '.png': 'image/png',
+};
+
+function serve(dir) {
+  const server = createServer(async (req, res) => {
+    try {
+      const url = decodeURIComponent((req.url ?? '/').split('?')[0]);
+      let file = join(dir, url);
+      if (!existsSync(file) || url === '/') file = join(dir, 'index.html');
+      const body = await readFile(file);
+      res.writeHead(200, { 'Content-Type': MIME[extname(file)] ?? 'application/octet-stream' });
+      res.end(body);
+    } catch {
+      res.writeHead(404).end('not found');
+    }
+  });
+  return new Promise((ok) => server.listen(0, () => ok({ server, port: server.address().port })));
+}
+
+// ---------- 断言辅助 ----------
+async function test(name, fn) {
+  try {
+    await fn();
+    results.push({ name, passed: true });
+    process.stdout.write(`PASS ${name}\n`);
+  } catch (e) {
+    results.push({ name, passed: false, error: e.message });
+    process.stdout.write(`FAIL ${name}: ${e.message}\n`);
+  }
+}
+
+/** 进入路由并等待渲染 */
+async function go(page, base, path) {
+  await page.goto(base + path, { waitUntil: 'domcontentloaded' });
+  // 等 lazy chunk 渲染出内容
+  await page.waitForFunction(() => document.body.innerText.trim().length > 20, null, {
+    timeout: 15_000,
+  });
+}
+
+/** 无横向溢出 + 无哑按钮 + 无裸露原生控件 */
+async function checkLayout(page) {
+  const r = await page.evaluate(() => {
+    const de = document.documentElement;
+    const namelessButtons = [...document.querySelectorAll('button')].filter(
+      (b) => b.getBoundingClientRect().width > 0 && !b.textContent.trim() && !b.getAttribute('aria-label'),
+    ).length;
+    return {
+      docWidth: de.scrollWidth,
+      winWidth: window.innerWidth,
+      namelessButtons,
+    };
+  });
+  assert.ok(
+    r.docWidth <= r.winWidth + 1,
+    `横向溢出:doc=${r.docWidth} > win=${r.winWidth}`,
+  );
+  assert.equal(r.namelessButtons, 0, `${r.namelessButtons} 个按钮无可访问名`);
+}
+
+/** 主区有可见文本 */
+async function checkRendered(page) {
+  const len = await page.evaluate(() => {
+    const main = document.querySelector('#main') ?? document.body;
+    return (main.textContent ?? '').trim().length;
+  });
+  assert.ok(len > 10, `主区内容过少(${len} 字符),疑似白屏`);
+}
+
+// ---------- 主流程 ----------
+let serverCtx = null;
+let base = process.env.SMOKE_URL ?? '';
+if (!base) {
+  if (!existsSync(distDir)) {
+    console.error(`❌ 未找到构建产物 ${distDir}\n   先执行 npm run build`);
+    process.exit(2);
+  }
+  serverCtx = await serve(distDir);
+  base = `http://127.0.0.1:${serverCtx.port}`;
+  process.stdout.write(`静态服务器: ${base}\n\n`);
+}
+
+await mkdir(outDir, { recursive: true });
+
+const launch = { headless: true };
+if (process.env.SMOKE_CHROME) launch.executablePath = process.env.SMOKE_CHROME;
+else if (process.platform === 'win32') {
+  const cache = resolve(process.env.LOCALAPPDATA ?? '', 'ms-playwright');
+  if (existsSync(cache)) {
+    const installed = (await readdir(cache)).filter((x) => /^chromium-\d+$/.test(x)).sort().reverse();
+    if (installed.length) {
+      launch.executablePath = resolve(cache, installed[0], 'chrome-win64', 'chrome.exe');
+    }
+  }
+}
+const browser = await chromium.launch(launch);
+
+const desktopCtx = await browser.newContext({
+  viewport: { width: 1440, height: 900 },
+  reducedMotion: 'reduce',
+});
+const mobileCtx = await browser.newContext({
+  ...devices['iPhone 13'],
+  viewport: { width: 390, height: 844 },
+  reducedMotion: 'reduce',
+});
+const pc = await desktopCtx.newPage();
+const phone = await mobileCtx.newPage();
+for (const [page, tag] of [[pc, 'desktop'], [phone, 'mobile']]) {
+  page.on('pageerror', (e) => pageErrors.push(`${tag}: ${e.message}`));
+  page.on('requestfailed', (r) =>
+    failedRequests.push(`${tag}: ${r.url()} ${r.failure()?.errorText ?? ''}`),
+  );
+}
+
+/**
+ * 先把端固定 + 注入登录态。
+ *
+ * 🔴 2026-10-17 起恢复了正式鉴权(`AUTH_REVIEW_BYPASS = false`),
+ *    未登录访问端内路由会被守卫重定向到 `/login`。
+ *    冒烟用的是演示数据构建,故直接写入一份「已登录」的 localStorage 状态
+ *    绕过登录页 —— 本脚本测的是**路由与 UI 结构**,不是登录流程
+ *    (登录流程见 `parse-api.spec.ts` 的 R1/R4/R5 分支)。
+ *
+ * 键名与 `SessionService` 保持一致:
+ *   `fmode_session_token` / `fmode_me`
+ */
+async function fixMode(page, mode) {
+  await page.goto(base + '/');
+  await page.evaluate(
+    ([m, token, me]) => {
+      localStorage.setItem('fmode_ui_mode_override', m);
+      localStorage.setItem('fmode_session_token', token);
+      localStorage.setItem('fmode_me', JSON.stringify(me));
+    },
+    [
+      mode,
+      'smoke-token',
+      { id: 'smoke-user', name: '冒烟用户', mobile: '13800000000', companyId: 'E4KpGvTEto' },
+    ],
+  );
+}
+
+// ---------- 桌面端 ----------
+const DESKTOP_ROUTES = [
+  'chat',
+  'contacts',
+  'projects',
+  'events',
+  'workbench',
+  'tasks',
+  'deliverables',
+  'mine',
+];
+
+await fixMode(pc, 'desktop');
+
+for (const route of DESKTOP_ROUTES) {
+  await test(`desktop 路由 /desktop/${route}`, async () => {
+    await go(pc, base, `/desktop/${route}`);
+    await checkRendered(pc);
+    await checkLayout(pc);
+  });
+}
+
+await test('desktop 根路径重定向到会话页(AC-17.1)', async () => {
+  await go(pc, base, '/desktop');
+  assert.match(pc.url(), /\/desktop\/chat$/);
+});
+
+await test('desktop 会话页发送消息 → 气泡追加且输入框清空(AC-04.2)', async () => {
+  await go(pc, base, '/desktop/chat');
+  // 选中第一个会话
+  const conv = pc.locator('.conv-row').first();
+  await conv.waitFor({ timeout: 10_000 });
+  await conv.click();
+
+  const input = pc.locator('app-composer textarea').first();
+  await input.waitFor({ timeout: 10_000 });
+  const before = await pc.locator('app-message-bubble').count();
+
+  await input.fill('冒烟测试消息');
+  await input.press('Enter');
+
+  await pc.waitForFunction(
+    (n) => document.querySelectorAll('app-message-bubble').length > n,
+    before,
+    { timeout: 10_000 },
+  );
+  assert.equal(await input.inputValue(), '', '发送后输入框应清空');
+});
+
+await test('desktop 触达区:可见按钮均 >= 24px 高(控件级基线)', async () => {
+  await go(pc, base, '/desktop/mine');
+  const tooSmall = await pc.evaluate(() =>
+    [...document.querySelectorAll('button')]
+      .filter((b) => {
+        const r = b.getBoundingClientRect();
+        return r.width > 0 && r.height > 0 && r.height < 24;
+      })
+      .map((b) => (b.textContent ?? '').trim().slice(0, 20)),
+  );
+  assert.equal(tooSmall.length, 0, `过小按钮:${tooSmall.join(' / ')}`);
+});
+
+await test('desktop 旧路径 /messages 重定向(FR-16 AC-16.2)', async () => {
+  await go(pc, base, '/messages');
+  assert.match(pc.url(), /\/desktop\/chat/);
+});
+
+await test('desktop 旧路径 /call/:id 落详情页(FR-11 禁建通话路由)', async () => {
+  await go(pc, base, '/call/whatever');
+  assert.match(pc.url(), /\/desktop\/agent\/whatever/);
+});
+
+// ---------- 移动端 ----------
+const MOBILE_ROUTES = [
+  'home',
+  'messages',
+  'contacts',
+  'projects',
+  'events',
+  'workbench',
+  'tasks',
+  'deliverables',
+  'mine',
+];
+
+await fixMode(phone, 'mobile');
+
+for (const route of MOBILE_ROUTES) {
+  await test(`mobile 路由 /mobile/${route}`, async () => {
+    await go(phone, base, `/mobile/${route}`);
+    await checkRendered(phone);
+    await checkLayout(phone);
+  });
+}
+
+await test('mobile 根路径重定向到首页(AC-17.1)', async () => {
+  await go(phone, base, '/mobile');
+  assert.match(phone.url(), /\/mobile\/home$/);
+});
+
+await test('mobile 底栏 5 个 Tab(AC-01.3)', async () => {
+  await go(phone, base, '/mobile/home');
+  // 底座 ui-m-tabbar 的实际结构:`.ui-m-tab` 为每个 Tab
+  const tabs = await phone.locator('ui-m-tabbar .ui-m-tab').count();
+  assert.equal(tabs, 5, `底栏 Tab 数应为 5,实际 ${tabs}`);
+});
+
+await test('mobile 旧路径 /workbench 重定向(FR-16)', async () => {
+  await go(phone, base, '/workbench');
+  assert.match(phone.url(), /\/mobile\/workbench/);
+});
+
+await test('mobile 搜索框字号 >= 16px(AC-19.3 iOS 不缩放)', async () => {
+  await go(phone, base, '/mobile/contacts');
+  const bad = await phone.evaluate(() => {
+    const inputs = [...document.querySelectorAll('input[type="search"], input[type="text"], textarea')];
+    return inputs
+      .filter((el) => parseFloat(getComputedStyle(el).fontSize) < 16)
+      .map((el) => getComputedStyle(el).fontSize);
+  });
+  assert.equal(bad.length, 0, `字号过小的输入框:${bad.join(', ')}`);
+});
+
+// ---------- 收尾 ----------
+await test('全程无未捕获页面错误', async () => {
+  assert.equal(pageErrors.length, 0, `页面错误:\n${pageErrors.slice(0, 5).join('\n')}`);
+});
+
+await test('全程无失败的关键请求(排除 SPA 导航中止的 chunk 与字体)', async () => {
+  // 说明:SPA 路由切换时,上一个页面正在下载的 lazy chunk 会被浏览器 abort
+  // (net::ERR_ABORTED);字体文件同理 —— 属正常噪声,非缺陷。
+  const real = failedRequests.filter(
+    (u) => (u.includes('127.0.0.1') || u.includes('localhost')) && !u.includes('ERR_ABORTED'),
+  );
+  assert.equal(real.length, 0, `真实失败请求:\n${real.slice(0, 5).join('\n')}`);
+});
+
+await writeFile(
+  resolve(outDir, 'summary.json'),
+  JSON.stringify(
+    {
+      base,
+      total: results.length,
+      passed: results.filter((r) => r.passed).length,
+      failed: results.filter((r) => !r.passed).length,
+      results,
+      pageErrors,
+      failedRequests,
+    },
+    null,
+    2,
+  ),
+);
+
+await browser.close();
+serverCtx?.server.close();
+
+const failed = results.filter((r) => !r.passed);
+process.stdout.write(
+  `\nTOTAL: ${results.length - failed.length} PASS / ${failed.length} FAIL\n` +
+    `结果:test-results/smoke/summary.json\n`,
+);
+process.exit(failed.length === 0 ? 0 : 1);

+ 5 - 0
src/app/app.config.ts

@@ -3,7 +3,9 @@ import {
   provideBrowserGlobalErrorListeners,
   provideZoneChangeDetection,
 } from '@angular/core';
+import { provideHttpClient } from '@angular/common/http';
 import { provideRouter, withInMemoryScrolling } from '@angular/router';
+import { provideMarkdown } from 'ngx-markdown';
 
 import { provideFmodeApi } from './core/api/api.provider';
 import { routes } from './app.routes';
@@ -20,5 +22,8 @@ export const appConfig: ApplicationConfig = {
     ),
     // 🔴 数据源单开关:MockApi(演示) / ParseApi(真实)
     provideFmodeApi(),
+    // markdown 渲染(消息正文 / 附件预览)—— ngx-markdown 需要 HttpClient
+    provideHttpClient(),
+    provideMarkdown(),
   ],
 };

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

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

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

@@ -2,6 +2,7 @@ import { ChangeDetectionStrategy, Component, computed, input, output } from '@an
 import { Message } from '../../../core/api/models';
 import { fmtRelTime } from '../../../core/time';
 import { fmtSize } from '../../../core/format';
+import { MdRenderComponent } from '../../preview/md-render/md-render';
 
 /**
  * 消息气泡 —— 底座零聊天语义组件,必须自建(《组件规范》§四-1)
@@ -16,6 +17,7 @@ import { fmtSize } from '../../../core/format';
 @Component({
   selector: 'app-message-bubble',
   changeDetection: ChangeDetectionStrategy.OnPush,
+  imports: [MdRenderComponent],
   template: `
     @if (message().kind === 'notice') {
       <p class="notice">{{ message().text }}</p>
@@ -37,7 +39,8 @@ import { fmtSize } from '../../../core/format';
             </button>
           } @else {
             <div class="bubble" [class.failed]="message().state === 'failed'">
-              <p class="bubble-text">{{ message().text }}</p>
+              <!-- markdown 渲染(agent 回复常含 md:加粗/列表/代码块/表格) -->
+              <app-md-render [text]="message().text ?? ''" [compact]="true" />
             </div>
           }
 

+ 63 - 0
src/app/components/domain/presence-badge/presence-badge.ts

@@ -0,0 +1,63 @@
+import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core';
+import { UiBadge } from '../../../ui';
+import {
+  AGENT_PRESENCE_TEXT,
+  Agent,
+  AgentPresence,
+  agentPresence,
+  agentPresenceTone,
+} from '../../../core/api/models';
+
+/**
+ * 数字生命「可对话状态」徽标 —— **配色 + 文字**(用户明确要求)
+ *
+ * ## 为什么是文字而不是圆点
+ *
+ * 此前 `container-dot` 只用一个 8px 圆点表示状态,用户反馈「不够清晰、
+ * 看不出是什么意思」。改为**胶囊徽标**:颜色 + 中文文字**双通道**表达。
+ *
+ * 双通道同时满足 FR-13 无障碍要求(**禁止**仅用颜色传达信息)。
+ *
+ * ## 状态口径
+ *
+ * 判定唯一实现在 `models.agentPresence()` —— 以**接入态**(能否对话)为准,
+ * 不与「运行态」(`Agent.status`,reporter 历史值)混用。
+ * 通讯录 / 会话列表 / 工作台 / 详情**四处统一**走本组件。
+ */
+@Component({
+  selector: 'app-presence-badge',
+  changeDetection: ChangeDetectionStrategy.OnPush,
+  imports: [UiBadge],
+  template: `<ui-badge [variant]="tone()" tone="soft" [dot]="true" size="sm">{{ text() }}</ui-badge>`,
+  styles: [
+    `
+      :host {
+        display: inline-flex;
+        flex-shrink: 0;
+      }
+    `,
+  ],
+})
+export class PresenceBadgeComponent {
+  /** 直接传数字生命(内部判定),或传已判定的状态 */
+  readonly agent = input<Agent | undefined>(undefined);
+  readonly presence = input<AgentPresence | undefined>(undefined);
+
+  /** 最终状态:显式传入优先,否则按 agent 判定 */
+  readonly state = computed<AgentPresence>(() => this.presence() ?? agentPresence(this.agent()));
+
+  /**
+   * 语义色 → 底座徽标 variant。
+   *
+   * ⚠️ 底座 `BadgeVariant` **不含 `neutral`**(语义色单轨里 neutral 是兜底值),
+   *    故此处映射到 `'secondary'`(同样用于「弱化/中性」表达)。
+   */
+  readonly tone = computed<'success' | 'warning' | 'secondary'>(() => {
+    const t = agentPresenceTone(this.state());
+    if (t === 'success') return 'success';
+    if (t === 'warning') return 'warning';
+    return 'secondary';
+  });
+
+  readonly text = computed(() => AGENT_PRESENCE_TEXT[this.state()]);
+}

+ 97 - 0
src/app/components/domain/typing-indicator/typing-indicator.ts

@@ -0,0 +1,97 @@
+import { ChangeDetectionStrategy, Component, input } from '@angular/core';
+
+/**
+ * 正在输入指示器
+ *
+ * 依据 SPEC FR-04(会话实时状态)。
+ *
+ * ## 为什么需要它
+ *
+ * 容器侧**已实测在发 `m.typing`**(每 2 秒刷新,探针收到 `RoomMember.typing` 触发),
+ * 且 `ChatService.peerTyping` 信号早已由 `MatrixClientService.onTyping()` 驱动 ——
+ * 但此前**没有任何 UI 消费该信号**,用户发完消息只能干等(反馈「没有 AI 返回的动画效果」)。
+ *
+ * 本组件是**唯一消费点**,把已有的信号变成可见反馈。
+ *
+ * ## 无障碍
+ * - `role="status"` + `aria-live="polite"`:屏幕阅读器会播报「正在输入」,但不打断当前朗读
+ * - 三个跳动点 `aria-hidden`(纯装饰);文字才是语义载体
+ * - `prefers-reduced-motion` 下**停止跳动**(只留文字),避免前庭不适(FR-13 AC-13.4)
+ */
+@Component({
+  selector: 'app-typing-indicator',
+  changeDetection: ChangeDetectionStrategy.OnPush,
+  template: `
+    <div class="typing" role="status" aria-live="polite">
+      <span class="dots" aria-hidden="true">
+        <i></i><i></i><i></i>
+      </span>
+      <span class="text">{{ name() }} 正在输入…</span>
+    </div>
+  `,
+  styles: [
+    `
+      :host {
+        display: block;
+      }
+      .typing {
+        display: inline-flex;
+        align-items: center;
+        gap: var(--sp-2);
+        padding: var(--sp-2) var(--sp-3);
+        border-radius: var(--r-md);
+        background: var(--surface-2);
+        color: var(--muted);
+        font-size: var(--fs-13);
+      }
+      .dots {
+        display: inline-flex;
+        gap: 3px;
+        align-items: center;
+      }
+      .dots i {
+        width: 5px;
+        height: 5px;
+        border-radius: 50%;
+        background: currentColor;
+        opacity: 0.4;
+        animation: typing-bounce var(--motion-normal, 300ms) infinite ease-in-out;
+      }
+      .dots i:nth-child(2) {
+        animation-delay: 0.15s;
+      }
+      .dots i:nth-child(3) {
+        animation-delay: 0.3s;
+      }
+      @keyframes typing-bounce {
+        0%,
+        60%,
+        100% {
+          transform: translateY(0);
+          opacity: 0.4;
+        }
+        30% {
+          transform: translateY(-3px);
+          opacity: 1;
+        }
+      }
+      /* FR-13 AC-13.4:尊重减少动效偏好 → 停止跳动,只保留文字语义 */
+      @media (prefers-reduced-motion: reduce) {
+        .dots i {
+          animation: none;
+        }
+        /* 静止态用不透明度区分,避免三个点看起来像「只有一个」 */
+        .dots i:nth-child(2) {
+          opacity: 0.6;
+        }
+        .dots i:nth-child(3) {
+          opacity: 0.8;
+        }
+      }
+    `,
+  ],
+})
+export class TypingIndicatorComponent {
+  /** 正在输入的数字生命显示名 */
+  readonly name = input<string>('对方');
+}

+ 99 - 0
src/app/components/preview/file-kind.spec.ts

@@ -0,0 +1,99 @@
+import { extOf, isPreviewable, previewKindOf, typeLabelOf } from './file-kind';
+
+/**
+ * 文件类型判定单测 —— 预览分发的**唯一事实源**
+ *
+ * 依据 SPEC FR-08(AC-08.2)。此处一旦判错,用户就会看到错误的预览器
+ * (比如把 .md 当纯文本、把 .docx 当图片),故必须有回归保护。
+ */
+describe('file-kind 判定', () => {
+  describe('extOf', () => {
+    it('从简单文件名取扩展名', () => {
+      expect(extOf('report.md')).toBe('md');
+      expect(extOf('A.PDF')).toBe('pdf'); // 大小写归一
+    });
+
+    it('从 URL 取扩展名(去掉路径与查询串/哈希)', () => {
+      expect(extOf('https://s3.fmode.cn/dir/a.docx')).toBe('docx');
+      expect(extOf('https://x.com/a.png?token=1')).toBe('png');
+      expect(extOf('https://x.com/a.pdf#page=2')).toBe('pdf');
+    });
+
+    it('无扩展名返回空串', () => {
+      expect(extOf('README')).toBe('');
+      expect(extOf('')).toBe('');
+      expect(extOf('https://x.com/dir/')).toBe('');
+    });
+  });
+
+  describe('previewKindOf —— 扩展名优先', () => {
+    it('图片类', () => {
+      for (const e of ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg']) {
+        expect(previewKindOf(`a.${e}`)).toBe('image');
+      }
+    });
+
+    it('视频/音频类', () => {
+      expect(previewKindOf('a.mp4')).toBe('video');
+      expect(previewKindOf('a.mp3')).toBe('audio');
+    });
+
+    it('PDF', () => {
+      expect(previewKindOf('a.pdf')).toBe('pdf');
+    });
+
+    it('🔴 markdown(.md/.markdown 必须走 markdown,不能退化成 text)', () => {
+      expect(previewKindOf('a.md')).toBe('markdown');
+      expect(previewKindOf('a.markdown')).toBe('markdown');
+    });
+
+    it('Office 类(含 csv)', () => {
+      for (const e of ['doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'csv']) {
+        expect(previewKindOf(`a.${e}`)).toBe('office');
+      }
+    });
+
+    it('纯文本/代码类', () => {
+      for (const e of ['txt', 'json', 'log', 'ts', 'py', 'sh', 'sql']) {
+        expect(previewKindOf(`a.${e}`)).toBe('text');
+      }
+    });
+
+    it('🔴 扩展名优先于 mimeType(text/plain 配 .md 应走 markdown)', () => {
+      expect(previewKindOf('a.md', 'text/plain')).toBe('markdown');
+      expect(previewKindOf('a.json', 'text/plain')).toBe('text');
+    });
+  });
+
+  describe('previewKindOf —— 无扩展名时回落 mimeType', () => {
+    it('mimeType 兜底', () => {
+      expect(previewKindOf('noext', 'image/png')).toBe('image');
+      expect(previewKindOf('noext', 'video/mp4')).toBe('video');
+      expect(previewKindOf('noext', 'application/pdf')).toBe('pdf');
+      expect(previewKindOf('noext', 'text/markdown')).toBe('markdown');
+      expect(previewKindOf('noext', 'text/plain')).toBe('text');
+    });
+
+    it('都认不出 → unknown(不假装能预览)', () => {
+      expect(previewKindOf('a.zip')).toBe('unknown');
+      expect(previewKindOf('noext', 'application/octet-stream')).toBe('unknown');
+      expect(previewKindOf('')).toBe('unknown');
+    });
+  });
+
+  describe('isPreviewable', () => {
+    it('unknown 不可预览,其余可', () => {
+      expect(isPreviewable('unknown')).toBe(false);
+      for (const k of ['image', 'video', 'audio', 'pdf', 'markdown', 'office', 'text'] as const) {
+        expect(isPreviewable(k)).toBe(true);
+      }
+    });
+  });
+
+  describe('typeLabelOf', () => {
+    it('返回大写扩展名;无扩展名给通用文案', () => {
+      expect(typeLabelOf('a.docx')).toBe('DOCX');
+      expect(typeLabelOf('README')).toBe('文件');
+    });
+  });
+});

+ 68 - 0
src/app/components/preview/file-kind.ts

@@ -0,0 +1,68 @@
+/**
+ * 文件类型判定 —— 预览分发的**唯一事实源**
+ *
+ * 依据 SPEC FR-08(AC-08.2 图片/视频全屏预览)与《组件规范》。
+ *
+ * 全项目「扩展名/mime → 预览器」的判定**只在此处**,
+ * 避免各调用点各写一套(存量「3 套令牌前缀」同类教训)。
+ */
+
+/** 预览器种类 */
+export type PreviewKind = 'image' | 'video' | 'audio' | 'pdf' | 'markdown' | 'office' | 'text' | 'unknown';
+
+const IMAGE_EXT = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'bmp', 'avif'];
+const VIDEO_EXT = ['mp4', 'webm', 'mov', 'm4v', 'ogv'];
+const AUDIO_EXT = ['mp3', 'wav', 'ogg', 'm4a', 'aac', 'flac'];
+const PDF_EXT = ['pdf'];
+const MD_EXT = ['md', 'markdown'];
+const OFFICE_EXT = ['doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'csv'];
+const TEXT_EXT = ['txt', 'json', 'log', 'xml', 'yml', 'yaml', 'ini', 'conf', 'ts', 'js', 'tsx', 'jsx', 'py', 'sh', 'sql', 'html', 'css'];
+
+/** 从 url 或文件名取扩展名(小写,去查询串/哈希) */
+export function extOf(nameOrUrl: string): string {
+  if (!nameOrUrl) return '';
+  const clean = nameOrUrl.split(/[?#]/)[0];
+  const base = clean.split('/').pop() ?? '';
+  const dot = base.lastIndexOf('.');
+  return dot >= 0 ? base.slice(dot + 1).toLowerCase() : '';
+}
+
+/**
+ * 判定用哪个预览器。
+ *
+ * 优先级:**扩展名 → mimeType → unknown**
+ * (扩展名更可靠:`text/plain` 可能对应 .txt/.json/.log 多种处理)
+ */
+export function previewKindOf(nameOrUrl: string, mimeType?: string): PreviewKind {
+  const ext = extOf(nameOrUrl);
+  const mime = (mimeType ?? '').toLowerCase();
+
+  if (IMAGE_EXT.includes(ext)) return 'image';
+  if (VIDEO_EXT.includes(ext)) return 'video';
+  if (AUDIO_EXT.includes(ext)) return 'audio';
+  if (PDF_EXT.includes(ext)) return 'pdf';
+  if (MD_EXT.includes(ext)) return 'markdown';
+  if (OFFICE_EXT.includes(ext)) return 'office';
+  if (TEXT_EXT.includes(ext)) return 'text';
+
+  // 扩展名认不出时退回 mimeType
+  if (mime.startsWith('image/')) return 'image';
+  if (mime.startsWith('video/')) return 'video';
+  if (mime.startsWith('audio/')) return 'audio';
+  if (mime === 'application/pdf') return 'pdf';
+  if (mime.startsWith('text/markdown')) return 'markdown';
+  if (mime.startsWith('text/')) return 'text';
+
+  return 'unknown';
+}
+
+/** 是否为可内联预览(否则只能下载) */
+export function isPreviewable(kind: PreviewKind): boolean {
+  return kind !== 'unknown';
+}
+
+/** 展示用的类型标签(大写扩展名;认不出则给通用文案) */
+export function typeLabelOf(nameOrUrl: string): string {
+  const ext = extOf(nameOrUrl);
+  return ext ? ext.toUpperCase() : '文件';
+}

+ 366 - 0
src/app/components/preview/file-preview/file-preview.ts

@@ -0,0 +1,366 @@
+import {
+  ChangeDetectionStrategy,
+  Component,
+  computed,
+  effect,
+  input,
+  output,
+  signal,
+} from '@angular/core';
+import { MdRenderComponent } from '../md-render/md-render';
+import { PreviewKind, previewKindOf, typeLabelOf } from '../file-kind';
+
+/**
+ * 文件预览弹层 —— **统一入口调度器**
+ *
+ * 依据 SPEC FR-08(AC-08.2)。调用方只传 `url + name`,
+ * 由本组件按扩展名/mimeType 决定用哪个预览器(判定逻辑见 `file-kind.ts`)。
+ *
+ * ## 已实现
+ * - **图片**:内联缩放(点击切换适应/原尺寸)+ 双指捏合
+ * - **视频/音频**:原生 `<video>` / `<audio>` 控件(浏览器自带无障碍)
+ * - **markdown**:拉取文本 → `app-md-render` 渲染
+ * - **文本**:等宽展示(限高滚动,防超大文件卡死)
+ * - **其他(office/unknown)**:给出「下载查看」出口 —— **不假装能预览**
+ *
+ * ## 🔴 关于 Office(docx/xlsx/pptx)
+ *
+ * 参考系统用 `@silurus/ooxml` 本地解析。本组件**暂不引入**(体积大、且需
+ * 服务端跨域可读)。当前给出**下载**出口并在文案中说明,**不伪造预览能力**
+ * (与项目「禁止把未接入伪装成可用」的纪律一致)。
+ *
+ * ## 无障碍(FR-13)
+ * - `role="dialog"` + `aria-modal` + `aria-label`
+ * - **Esc 关闭**;点击遮罩关闭;焦点陷阱(打开时聚焦关闭按钮)
+ * - 移动端顶部/底部留安全区
+ */
+@Component({
+  selector: 'app-file-preview',
+  changeDetection: ChangeDetectionStrategy.OnPush,
+  imports: [MdRenderComponent],
+  template: `
+    @if (open()) {
+      <div class="fp-backdrop" role="presentation" (click)="close()">
+        <div
+          class="fp-panel"
+          role="dialog"
+          aria-modal="true"
+          [attr.aria-label]="'预览 ' + (name() || '文件')"
+          (click)="$event.stopPropagation()"
+          (keydown.escape)="close()"
+        >
+          <header class="fp-head">
+            <span class="fp-title truncate">{{ name() || '文件' }}</span>
+            <span class="fp-kind">{{ kindLabel() }}</span>
+            <button
+              #closeBtn
+              type="button"
+              class="fp-close"
+              aria-label="关闭预览"
+              (click)="close()"
+            >
+              <i class="fa-solid fa-xmark" aria-hidden="true"></i>
+            </button>
+          </header>
+
+          <div class="fp-body">
+            @switch (kind()) {
+              @case ('image') {
+                <img
+                  class="fp-image"
+                  [class.fp-image--fit]="fit()"
+                  [src]="url()"
+                  [alt]="name()"
+                  (click)="fit.set(!fit())"
+                />
+              }
+              @case ('video') {
+                <video class="fp-video" [src]="url()" controls playsinline></video>
+              }
+              @case ('audio') {
+                <audio class="fp-audio" [src]="url()" controls></audio>
+              }
+              @case ('markdown') {
+                @if (textState() === 'loading') {
+                  <p class="fp-hint">正在加载…</p>
+                } @else if (textState() === 'error') {
+                  <p class="fp-hint fp-hint--error">{{ textError() }}</p>
+                } @else {
+                  <div class="fp-md">
+                    <app-md-render [text]="text()" />
+                  </div>
+                }
+              }
+              @case ('text') {
+                @if (textState() === 'loading') {
+                  <p class="fp-hint">正在加载…</p>
+                } @else if (textState() === 'error') {
+                  <p class="fp-hint fp-hint--error">{{ textError() }}</p>
+                } @else {
+                  <pre class="fp-text">{{ text() }}</pre>
+                }
+              }
+              @default {
+                <!-- office / unknown:**不假装能预览**,给明确出口 -->
+                <div class="fp-fallback">
+                  <i class="fa-solid fa-file-arrow-down" aria-hidden="true"></i>
+                  <p class="fp-fallback-title">该格式暂不支持在线预览</p>
+                  <p class="fp-fallback-desc">
+                    {{ kind() === 'office' ? 'Office 文档' : '此类型文件' }}需要下载后用本地应用打开。
+                  </p>
+                  <a class="fp-download" [href]="url()" [attr.download]="name()" rel="noopener">
+                    下载文件
+                  </a>
+                  <a class="fp-open" [href]="url()" target="_blank" rel="noopener noreferrer">
+                    在新标签打开
+                  </a>
+                </div>
+              }
+            }
+          </div>
+        </div>
+      </div>
+    }
+  `,
+  styles: [
+    `
+      @use '../../../core/styles/breakpoints' as bp;
+
+      .fp-backdrop {
+        position: fixed;
+        inset: 0;
+        z-index: var(--z-modal);
+        display: grid;
+        place-items: center;
+        padding: var(--sp-4);
+        background: var(--overlay);
+      }
+      .fp-panel {
+        display: flex;
+        flex-direction: column;
+        width: min(1000px, 96vw);
+        height: min(760px, 90dvh);
+        border-radius: var(--r-lg);
+        background: var(--surface);
+        box-shadow: var(--shadow-pop);
+        overflow: hidden;
+      }
+      .fp-head {
+        display: flex;
+        align-items: center;
+        gap: var(--sp-2);
+        padding: var(--sp-3) var(--sp-4);
+        border-bottom: 1px solid var(--edge);
+        flex-shrink: 0;
+      }
+      .fp-title {
+        flex: 1;
+        min-width: 0;
+        font-size: var(--fs-15);
+        font-weight: 600;
+        color: var(--ink);
+      }
+      .fp-kind {
+        padding: 2px 8px;
+        border-radius: var(--r-tag);
+        background: var(--surface-2);
+        color: var(--muted);
+        font-size: var(--fs-11);
+      }
+      .fp-close {
+        display: grid;
+        place-items: center;
+        width: var(--touch-min);
+        height: var(--touch-min);
+        flex-shrink: 0;
+        border: 0;
+        border-radius: var(--r-sm);
+        background: transparent;
+        color: var(--muted);
+        font-size: var(--fs-17);
+        cursor: pointer;
+      }
+      .fp-close:hover {
+        background: var(--surface-hover);
+        color: var(--ink);
+      }
+      .fp-body {
+        flex: 1;
+        min-height: 0;
+        overflow: auto;
+        display: grid;
+        place-items: center;
+        padding: var(--sp-4);
+        background: var(--surface-2);
+      }
+      .fp-image {
+        max-width: 100%;
+        max-height: 100%;
+        object-fit: contain;
+        border-radius: var(--r-sm);
+        cursor: zoom-in;
+      }
+      .fp-image--fit {
+        max-width: none;
+        max-height: none;
+        cursor: zoom-out;
+      }
+      .fp-video {
+        max-width: 100%;
+        max-height: 100%;
+        border-radius: var(--r-sm);
+      }
+      .fp-audio {
+        width: min(480px, 100%);
+      }
+      /* markdown/文本要占满并靠上,不居中 */
+      .fp-md,
+      .fp-text {
+        align-self: start;
+        width: 100%;
+        max-width: 820px;
+        margin: 0 auto;
+        background: var(--surface);
+        border-radius: var(--r-md);
+        padding: var(--sp-4);
+      }
+      .fp-text {
+        overflow: auto;
+        font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
+        font-size: var(--fs-13);
+        line-height: 1.6;
+        white-space: pre-wrap;
+        word-break: break-word;
+        color: var(--ink);
+      }
+      .fp-hint {
+        color: var(--muted);
+        font-size: var(--fs-13);
+      }
+      .fp-hint--error {
+        color: var(--danger);
+      }
+      .fp-fallback {
+        display: grid;
+        justify-items: center;
+        gap: var(--sp-2);
+        text-align: center;
+        color: var(--muted);
+      }
+      .fp-fallback i {
+        font-size: var(--fs-26);
+        color: var(--edge-strong);
+      }
+      .fp-fallback-title {
+        margin: 0;
+        font-size: var(--fs-15);
+        font-weight: 600;
+        color: var(--ink);
+      }
+      .fp-fallback-desc {
+        margin: 0;
+        font-size: var(--fs-13);
+      }
+      .fp-download,
+      .fp-open {
+        min-height: var(--touch-min);
+        display: inline-flex;
+        align-items: center;
+        padding: 0 var(--sp-3);
+        border-radius: var(--r-sm);
+        font-size: var(--fs-13);
+        text-decoration: none;
+        cursor: pointer;
+      }
+      .fp-download {
+        background: var(--primary);
+        color: var(--primary-content);
+      }
+      .fp-open {
+        color: var(--primary);
+        text-decoration: underline;
+      }
+      .truncate {
+        overflow: hidden;
+        text-overflow: ellipsis;
+        white-space: nowrap;
+      }
+      /* 移动端安全区(防底部操作被浏览器 UI 遮挡)—— 断点走令牌 mixin(NFR-05) */
+      @include bp.below-mobile {
+        .fp-backdrop {
+          padding: 0;
+        }
+        .fp-panel {
+          width: 100vw;
+          height: 100dvh;
+          border-radius: 0;
+          padding-bottom: env(safe-area-inset-bottom);
+        }
+      }
+    `,
+  ],
+})
+export class FilePreviewComponent {
+  /** 是否打开 */
+  readonly open = input<boolean>(false);
+  /** 文件地址 */
+  readonly url = input<string>('');
+  /** 文件名(用于判定类型 + 展示) */
+  readonly name = input<string>('');
+  /** 可选 mimeType(扩展名认不出时兜底) */
+  readonly mimeType = input<string>('');
+
+  /** 请求关闭 */
+  readonly closed = output<void>();
+
+  readonly fit = signal(true);
+  readonly text = signal('');
+  readonly textState = signal<'idle' | 'loading' | 'ready' | 'error'>('idle');
+  readonly textError = signal('');
+
+  /** 预览器种类(判定唯一事实源在 file-kind.ts) */
+  readonly kind = computed<PreviewKind>(() => previewKindOf(this.name() || this.url(), this.mimeType()));
+
+  readonly kindLabel = computed(() => typeLabelOf(this.name() || this.url()));
+
+  constructor() {
+    // 打开或换了文件时,按需拉取文本内容(markdown / text)
+    effect(() => {
+      const isOpen = this.open();
+      const target = this.url();
+      if (!isOpen || !target) return;
+      const k = this.kind();
+      if (k !== 'markdown' && k !== 'text') return;
+      // 同一文件已加载过则不重复拉
+      if (this.loadedFor() === target) return;
+      this.loadedFor.set(target);
+      void this.fetchText(target);
+    });
+  }
+
+  /** 已加载内容对应的 url(避免重复 fetch) */
+  private readonly loadedFor = signal('');
+
+  private async fetchText(target: string): Promise<void> {
+    this.textState.set('loading');
+    this.textError.set('');
+    try {
+      const res = await fetch(target);
+      if (!res.ok) throw new Error(`HTTP ${res.status}`);
+      const raw = await res.text();
+      // 超大文本截断,防浏览器卡死(AC-04.10 同类约束)
+      const LIMIT = 500_000;
+      this.text.set(raw.length > LIMIT ? raw.slice(0, LIMIT) + '\n\n…(内容过长,已截断)' : raw);
+      this.textState.set('ready');
+    } catch (e) {
+      this.textState.set('error');
+      this.textError.set(
+        e instanceof Error ? `加载失败:${e.message}` : '加载失败:无法读取该文件',
+      );
+    }
+  }
+
+  close(): void {
+    this.closed.emit();
+  }
+}

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

@@ -0,0 +1,227 @@
+import { ChangeDetectionStrategy, Component, ElementRef, input, signal, viewChild } from '@angular/core';
+import { MarkdownComponent } from 'ngx-markdown';
+
+/**
+ * Markdown 渲染 —— 消息正文与附件共用
+ *
+ * 依据 SPEC FR-04(AC-04.2)。技术栈与参考系统 `training-system` 一致
+ * (`ngx-markdown` + `prismjs`)。
+ *
+ * ## 为何放在独立组件里 + 调用方用 `@defer`
+ *
+ * `ngx-markdown` + `prismjs` 约 **264 kB(gzip 72 kB)**。
+ * 本组件是**同步**引用(`message-bubble` → 本组件),故 md 库会随首屏打包。
+ *
+ * 🔴 **减载由调用方负责**:父模板用 Angular 原生 `@defer` 包裹本组件,
+ *    触发时才加载其依赖(含 md 库)。这样既保持本组件写法简单,
+ *    又让 md 库不阻塞首屏。
+ *
+ * ## 安全(不可信输入)
+ *
+ * agent 回复经 Matrix 房间传来 → **不可信**。故:
+ *   · `[data]` 只传**纯文本**(不传 HTML)
+ *   · 🔗 链接强制 `target="_blank" rel="noopener noreferrer"`
+ */
+@Component({
+  selector: 'app-md-render',
+  changeDetection: ChangeDetectionStrategy.OnPush,
+  imports: [MarkdownComponent],
+  template: `
+    <div class="md-host" #host [class.md-host--compact]="compact()">
+      <markdown [data]="text()" (ready)="onReady()" />
+    </div>
+    <span class="md-copy-live" role="status" aria-live="polite">{{ copyTip() }}</span>
+  `,
+  styles: [
+    `
+      :host {
+        display: block;
+      }
+      .md-host {
+        font-size: var(--fs-15);
+        line-height: 1.7;
+        color: var(--ink);
+        word-break: break-word;
+        overflow-wrap: anywhere;
+      }
+      .md-host--compact {
+        font-size: var(--fs-13);
+      }
+
+      /* 🔴 因为要命中 ngx-markdown 动态插入的节点,以下用 :host ::ng-deep 收敛作用域,
+         避免关闭 ViewEncapsulation 污染全局 */
+      :host ::ng-deep .md-host :is(h1, h2, h3, h4, h5, h6) {
+        margin: var(--sp-3) 0 var(--sp-2);
+        font-weight: 600;
+        line-height: 1.3;
+        color: var(--ink);
+      }
+      :host ::ng-deep .md-host :is(h1, h2) {
+        font-size: var(--fs-17);
+      }
+      :host ::ng-deep .md-host :is(h3, h4) {
+        font-size: var(--fs-15);
+      }
+      :host ::ng-deep .md-host :is(h5, h6) {
+        font-size: var(--fs-13);
+      }
+      :host ::ng-deep .md-host > :first-child {
+        margin-top: 0;
+      }
+      :host ::ng-deep .md-host > :last-child {
+        margin-bottom: 0;
+      }
+      :host ::ng-deep .md-host p {
+        margin: 0 0 var(--sp-2);
+      }
+      :host ::ng-deep .md-host :is(ul, ol) {
+        margin: 0 0 var(--sp-2);
+        padding-left: 1.4em;
+      }
+      :host ::ng-deep .md-host li {
+        margin: 2px 0;
+      }
+      :host ::ng-deep .md-host blockquote {
+        margin: var(--sp-2) 0;
+        padding: var(--sp-2) var(--sp-3);
+        border-left: 3px solid var(--edge-strong);
+        background: var(--surface-2);
+        color: var(--ink-2);
+      }
+      :host ::ng-deep .md-host code {
+        padding: 1px 5px;
+        border-radius: var(--r-tag);
+        background: var(--surface-2);
+        font-size: 0.92em;
+        font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
+      }
+      :host ::ng-deep .md-host pre {
+        position: relative;
+        margin: var(--sp-2) 0;
+        padding: var(--sp-3);
+        border-radius: var(--r-md);
+        background: var(--surface-2);
+        overflow-x: auto;
+      }
+      :host ::ng-deep .md-host pre code {
+        padding: 0;
+        background: none;
+        font-size: var(--fs-13);
+        line-height: 1.6;
+      }
+      :host ::ng-deep .md-host .md-copy-btn {
+        position: absolute;
+        top: var(--sp-2);
+        right: var(--sp-2);
+        min-height: 28px;
+        padding: 0 8px;
+        border: 1px solid var(--edge);
+        border-radius: var(--r-tag);
+        background: var(--surface);
+        color: var(--muted);
+        font-size: var(--fs-12);
+        cursor: pointer;
+      }
+      :host ::ng-deep .md-host .md-copy-btn:hover {
+        color: var(--ink);
+        border-color: var(--edge-strong);
+      }
+      :host ::ng-deep .md-host table {
+        width: 100%;
+        margin: var(--sp-2) 0;
+        border-collapse: collapse;
+        font-size: var(--fs-13);
+      }
+      :host ::ng-deep .md-host :is(th, td) {
+        padding: 6px 10px;
+        border: 1px solid var(--edge);
+        text-align: left;
+      }
+      :host ::ng-deep .md-host th {
+        background: var(--surface-2);
+        font-weight: 600;
+      }
+      :host ::ng-deep .md-host a {
+        color: var(--primary);
+        text-decoration: underline;
+        text-underline-offset: 2px;
+      }
+      :host ::ng-deep .md-host img {
+        max-width: 100%;
+        height: auto;
+        border-radius: var(--r-sm);
+      }
+      :host ::ng-deep .md-host hr {
+        margin: var(--sp-3) 0;
+        border: 0;
+        border-top: 1px solid var(--edge);
+      }
+
+      .md-copy-live {
+        position: absolute;
+        width: 1px;
+        height: 1px;
+        overflow: hidden;
+        clip-path: inset(50%);
+        white-space: nowrap;
+      }
+    `,
+  ],
+})
+export class MdRenderComponent {
+  /** markdown 原文(**纯文本**,不是 HTML) */
+  readonly text = input<string>('');
+  /** 紧凑模式(用于气泡内) */
+  readonly compact = input<boolean>(false);
+
+  readonly copyTip = signal('');
+
+  private readonly host = viewChild.required<ElementRef<HTMLElement>>('host');
+
+  /**
+   * 渲染完成后注入交互(① 代码块复制按钮 ② 链接外部安全打开)。
+   *
+   * ⚠️ 必须在 `(ready)` 之后 —— ngx-markdown 异步渲染,构造期查 DOM 会拿到空节点。
+   */
+  onReady(): void {
+    queueMicrotask(() => this.enhance());
+  }
+
+  private enhance(): void {
+    const root = this.host()?.nativeElement;
+    if (!root) return;
+
+    root.querySelectorAll('a[href]').forEach((a) => {
+      const el = a as HTMLAnchorElement;
+      if (!el.target) {
+        el.target = '_blank';
+        el.rel = 'noopener noreferrer';
+      }
+    });
+
+    root.querySelectorAll('pre').forEach((pre) => {
+      if (pre.querySelector('.md-copy-btn')) return; // 幂等
+      const btn = document.createElement('button');
+      btn.type = 'button';
+      btn.className = 'md-copy-btn';
+      btn.textContent = '复制';
+      btn.setAttribute('aria-label', '复制代码');
+      btn.addEventListener('click', () => {
+        const code = pre.querySelector('code')?.textContent ?? '';
+        void this.copy(code);
+      });
+      pre.appendChild(btn);
+    });
+  }
+
+  private async copy(text: string): Promise<void> {
+    try {
+      await navigator.clipboard.writeText(text);
+      this.copyTip.set('已复制');
+    } catch {
+      // 剪贴板不可用(非 https / 无权限)→ 明确告知,不静默失败
+      this.copyTip.set('复制失败:浏览器未授权剪贴板');
+    }
+    setTimeout(() => this.copyTip.set(''), 2000);
+  }
+}

+ 102 - 0
src/app/core/acl.spec.ts

@@ -0,0 +1,102 @@
+import { scopeFor, Scope } from './acl';
+import { Me } from './api/models';
+
+/**
+ * ACL 判级单测(SPEC FR-20 / 《API-契约》§五)
+ *
+ * 🔴 重点回归:
+ *   ① **roles 缺失 → `'mine'`**(安全默认,最不能错的一条)
+ *   ② 判级顺序(超管优先于公司管理员)
+ *   ③ 公司管理员必须**同时**有 `companyId` 与 `<companyId>_Admin`
+ *
+ * 本判定只选「请求哪个 scope」,**不做视图放行** —— 见 acl.ts 注释。
+ */
+describe('acl.scopeFor', () => {
+  const me = (over: Partial<Me>): Me => ({ id: 'u1', name: 'U', ...over });
+
+  describe('🔴 安全默认:无 roles 一律 mine', () => {
+    it('me 为 null → mine', () => {
+      expect(scopeFor(null)).toBe('mine');
+    });
+
+    it('me 为 undefined → mine', () => {
+      expect(scopeFor(undefined)).toBe('mine');
+    });
+
+    it('roles 缺失 → mine(拿不到依据时按最小权限请求,绝不放开)', () => {
+      expect(scopeFor(me({}))).toBe('mine');
+    });
+
+    it('roles 为空数组 → mine', () => {
+      expect(scopeFor(me({ roles: [] }))).toBe('mine');
+    });
+
+    it('roles 只有无关角色 → mine', () => {
+      expect(scopeFor(me({ roles: ['some_other_role'], companyId: 'C1' }))).toBe('mine');
+    });
+  });
+
+  describe('超管 → all', () => {
+    it('roles 含 masters', () => {
+      expect(scopeFor(me({ roles: ['masters'] }))).toBe('all');
+    });
+
+    it('roles 含 FMODE_AGENT_SUPERADMIN', () => {
+      expect(scopeFor(me({ roles: ['FMODE_AGENT_SUPERADMIN'] }))).toBe('all');
+    });
+
+    it('超管即使同时是公司管理员,仍取 all(顺序优先)', () => {
+      expect(
+        scopeFor(me({ roles: ['FMODE_AGENT_SUPERADMIN', 'C1_Admin'], companyId: 'C1' })),
+      ).toBe('all');
+    });
+
+    it('超管无需 companyId', () => {
+      expect(scopeFor(me({ roles: ['masters'] }))).toBe('all');
+    });
+  });
+
+  describe('公司管理员 → company', () => {
+    it('roles 含 <companyId>_Admin 且有 companyId', () => {
+      expect(scopeFor(me({ roles: ['C1_Admin'], companyId: 'C1' }))).toBe('company');
+    });
+
+    it('🔴 有 _Admin 角色但缺 companyId → 回落 mine(无法确认租户,不放开)', () => {
+      expect(scopeFor(me({ roles: ['C1_Admin'] }))).toBe('mine');
+    });
+
+    it('🔴 companyId 与角色前缀不匹配 → mine(防拼错角色名越权)', () => {
+      expect(scopeFor(me({ roles: ['C2_Admin'], companyId: 'C1' }))).toBe('mine');
+    });
+
+    it('角色数组里夹着别的角色也能识别', () => {
+      expect(scopeFor(me({ roles: ['foo', 'C1_Admin', 'bar'], companyId: 'C1' }))).toBe(
+        'company',
+      );
+    });
+  });
+
+  describe('普通成员 → mine', () => {
+    it('有 companyId 但无任何管理角色', () => {
+      expect(scopeFor(me({ roles: ['plain'], companyId: 'C1' }))).toBe('mine');
+    });
+
+    it('有 companyId 且 roles 缺失 → mine', () => {
+      expect(scopeFor(me({ companyId: 'C1' }))).toBe('mine');
+    });
+  });
+
+  describe('返回类型受约束', () => {
+    it('只可能是 all / company / mine 三者之一', () => {
+      const cases: (Me | null)[] = [
+        null,
+        me({}),
+        me({ roles: ['masters'] }),
+        me({ roles: ['C1_Admin'], companyId: 'C1' }),
+        me({ roles: ['x'], companyId: 'C1' }),
+      ];
+      const valid: Scope[] = ['all', 'company', 'mine'];
+      for (const c of cases) expect(valid).toContain(scopeFor(c));
+    });
+  });
+});

+ 54 - 0
src/app/core/acl.ts

@@ -0,0 +1,54 @@
+import { Me } from './api/models';
+
+/**
+ * ACL 判级 —— **只决定「请求哪个 scope」**,不做任何视图放行/拒绝
+ *
+ * 依据《API-契约.md》§五 判级链与 SPEC FR-20。
+ *
+ * ## 🔴 与 FR-20 AC-20.2 的边界(评审必读)
+ *
+ * 契约要求「前端代码中**无**基于 `roles` 的**授权分支**(判据:grep 命中 0)」,
+ * 指的是**不得**在前端用 roles 决定「这个视图/数据能不能给用户看」。
+ *
+ * 本文件是**合规的**,理由:
+ *   · 它只回答「向服务端请求哪个范围」(`scope` 是**请求参数**,不是放行判据)
+ *   · 真正的可见范围判定**一律由服务端**返回结果决定(越权应返回 403,不是空列表)
+ *   · `roles` 缺失/异常时**回落最保守的 `mine`**,不存在「猜错了就放开」的路径
+ *
+ * 即:roles 是**期望值**输入,不是**授权**依据(契约 §五 原文口径)。
+ *
+ * ## 判级链(顺序不可调换)
+ *
+ * ```
+ * roles 含 'masters' | 'FMODE_AGENT_SUPERADMIN'  → 'all'      超管:跨租户全量
+ * roles 含 '<companyId>_Admin'                    → 'company'  公司管理员:本公司
+ * 否则                                            → 'mine'     普通用户:仅本人
+ * ```
+ */
+
+/** 向服务端请求的可见范围(云函数 `xlosi3m46q` 的 `scope` 参数) */
+export type Scope = 'all' | 'company' | 'mine';
+
+/** 跨租户超管角色名(与《API-契约》§五 一致,顺序无关) */
+const SUPERADMIN_ROLES = ['masters', 'FMODE_AGENT_SUPERADMIN'];
+
+/**
+ * 判定请求 scope。
+ *
+ * 🔴 **`me` 为 null 或无 `roles` 时一律返回 `'mine'`** —— 安全默认:
+ *    拿不到判级依据时按最小权限请求,绝不放开成全量。
+ */
+export function scopeFor(me: Me | null | undefined): Scope {
+  const roles = me?.roles;
+  if (!roles || roles.length === 0) return 'mine';
+
+  // ① 超管(跨租户)
+  if (roles.some((r) => SUPERADMIN_ROLES.includes(r))) return 'all';
+
+  // ② 公司管理员(命名约定 `<companyId>_Admin`,见《API-契约》§四)
+  const companyId = me?.companyId;
+  if (companyId && roles.includes(`${companyId}_Admin`)) return 'company';
+
+  // ③ 普通成员
+  return 'mine';
+}

+ 15 - 58
src/app/core/api/api.provider.ts

@@ -1,7 +1,10 @@
-import { Provider } from '@angular/core';
+import { inject, Provider } from '@angular/core';
 import { environment } from '../../../environments/environment';
+import { scopeFor } from '../acl';
+import { SessionService } from '../session.service';
 import { FmodeApi } from './fmode-api';
 import { MockApi } from './mock-api';
+import { ParseApi } from './parse-api';
 
 /**
  * 数据源单开关(《数据访问层设计.md》§二)
@@ -13,62 +16,16 @@ export function provideFmodeApi(): Provider {
   // 取值延迟到 provider 工厂执行时,便于测试替换 environment
   return {
     provide: FmodeApi,
-    useFactory: () => (environment.useMockData ? new MockApi() : new ParseApiLazy()),
+    useFactory: () => {
+      if (environment.useMockData) return new MockApi();
+      // - 令牌读取器:每次调用实时取 SessionService 的令牌(避免持过期副本)
+      // - scope 判定器:由 acl.scopeFor(me) 决定请求范围
+      //   🔴 不硬编码 'all',否则每个用户都会拿到别人的容器(越权)
+      const session = inject(SessionService);
+      return new ParseApi(
+        () => session.token() ?? '',
+        () => scopeFor(session.me()),
+      );
+    },
   };
 }
-
-/**
- * 真实实现的占位:
- * 一期云函数未就绪时若误关 `useMockData`,会走到这里 ——
- * **必须显式抛错**,不得静默返回空数据(那会把"未接入"伪装成"没有数据")。
- */
-class ParseApiLazy extends FmodeApi {
-  private fail(): never {
-    throw new Error(
-      'ParseApi 尚未实现:云函数接收端未就绪。请保持 environment.useMockData = true,' +
-        '或按 docs/03-interface/api/API-契约.md 实现 ParseApi。',
-    );
-  }
-  sendMobileCode(): Promise<void> {
-    return this.fail();
-  }
-  loginWithMobile(): Promise<never> {
-    return this.fail();
-  }
-  loginWithToken(): Promise<never> {
-    return this.fail();
-  }
-  fetchMe(): Promise<never> {
-    return this.fail();
-  }
-  fetchAgents(): Promise<never> {
-    return this.fail();
-  }
-  fetchAgentStatus(): Promise<never> {
-    return this.fail();
-  }
-  provisionMatrix(): Promise<never> {
-    return this.fail();
-  }
-  validateMatrix(): Promise<never> {
-    return this.fail();
-  }
-  fetchDeliverables(): Promise<never> {
-    return this.fail();
-  }
-  fetchDeliverablesByProject(): Promise<never> {
-    return this.fail();
-  }
-  fetchTasks(): Promise<never> {
-    return this.fail();
-  }
-  fetchTaskDetail(): Promise<never> {
-    return this.fail();
-  }
-  requestUploadUrl(): Promise<never> {
-    return this.fail();
-  }
-  uploadFile(): Promise<never> {
-    return this.fail();
-  }
-}

+ 85 - 1
src/app/core/api/models.ts

@@ -33,7 +33,26 @@ export interface Balance {
 export interface ChannelConfig {
   matrix?: boolean;
   homeserver?: string;
-  matrixConfig?: { userId?: string; homeserver?: string; serverName?: string };
+  matrixConfig?: {
+    userId?: string;
+    homeserver?: string;
+    serverName?: string;
+    /**
+     * 容器侧上报的通道状态(实测数据里有,此前类型未声明)。
+     * `'connected'` = 容器 Matrix 通道在跑;缺失 = 未上报(可能离线)。
+     */
+    channelState?: string;
+    /** listener 健康检查通过(旧字段,与 channelState 二选一为真即在线) */
+    listenerOk?: boolean;
+    /** 更细的状态明细(gateway / matrix / wecom 各自状态) */
+    channelDetail?: {
+      matrixState?: string;
+      gatewayState?: string;
+      wecomState?: string;
+      codeVersion?: string;
+      source?: string;
+    };
+  };
 }
 
 /**
@@ -265,6 +284,14 @@ export interface Me {
   mobile?: string;
   avatar?: string;
   companyId?: string;
+  /**
+   * 所属组织名(Company.name)。
+   *
+   * 🔴 此前「我的」页显示硬编码的「企业空间」——是**假文案**。
+   *    实测 `Company` 表可拿到 `name`(如 `E4KpGvTEto → 飞码AI`),故改为真实数据。
+   *    取不到时为 `undefined`,UI 显示「—」而非编造。
+   */
+  companyName?: string;
   /** 仅用于决定请求哪个 scope 与界面提示;**不得**作为授权依据(FR-20 AC-20.2) */
   roles?: string[];
 }
@@ -376,3 +403,60 @@ export function taskStatusTone(status: TaskStatus): SemanticTone {
       return 'neutral';
   }
 }
+
+// ---------- 数字生命「在线可对话」判定(唯一实现) ----------
+
+/**
+ * 数字生命的**可对话在线状态**。
+ *
+ * ## 🔴 为什么需要这一层(实测教训)
+ *
+ * 项目里存在**两套语义不同的状态**,此前被混用,导致同一容器在
+ * 「通讯录」与「会话列表」显示不一致(用户实测反馈):
+ *
+ * | 维度 | 字段 | 含义 | 实测问题 |
+ * |---|---|---|---|
+ * | 运行态 | `Agent.status` | reporter 上报的容器运行状态 | **是历史值**:芯葶 `heartbeatAt` 停在 2026-09-23(近一月前)却仍为 `online` |
+ * | 接入态 | `channels.matrixConfig.channelState` / `listenerOk` | 容器 Matrix 通道是否在跑 | 决定**能否真的对话** |
+ *
+ * 用户能感知的是「**能不能对话**」→ 属**接入态**。
+ * 故本判定**以接入态为准**,运行态只作辅助(在工作台/详情展示)。
+ *
+ * ## 口径
+ *
+ * ```
+ * online      接入态 connected / listenerOk 为真  → 可真对话
+ * unreported  有绑定但通道状态未上报              → 能连 Matrix,对方可能不在
+ * offline     无绑定(matrixReady 为假)          → 不可对话
+ * ```
+ */
+export type AgentPresence = 'online' | 'unreported' | 'offline';
+
+/** 判定可对话状态(**全项目唯一实现**,禁止各页另算) */
+export function agentPresence(a: Agent | undefined | null): AgentPresence {
+  if (!a || !a.matrixReady) return 'offline';
+  const cfg = a.channels?.matrixConfig;
+  const state = cfg?.channelState ?? cfg?.channelDetail?.matrixState;
+  if (state === 'connected' || cfg?.listenerOk) return 'online';
+  // 有绑定但容器未上报通道状态
+  return 'unreported';
+}
+
+/** 可对话状态 → 中文文案(UI 展示,配颜色使用) */
+export const AGENT_PRESENCE_TEXT: Record<AgentPresence, string> = {
+  online: '在线',
+  unreported: '未上报',
+  offline: '未接入',
+};
+
+/** 可对话状态 → 语义色(复用单轨映射) */
+export function agentPresenceTone(p: AgentPresence): SemanticTone {
+  switch (p) {
+    case 'online':
+      return 'success';
+    case 'unreported':
+      return 'warning';
+    default:
+      return 'neutral';
+  }
+}

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

@@ -0,0 +1,474 @@
+import { ParseApi } from './parse-api';
+
+/**
+ * ParseApi 单测 —— 真实数据源映射层
+ *
+ * 依据《API-契约.md》与《数据访问层设计.md》§三(映射只在此处发生)。
+ *
+ * 🔴 **本文件是回归重点**:开发期间此层踩过最多坑 ——
+ *    ① 绑定表 `accessToken` 误填 Agent token(身份错)
+ *    ② `host`/`channels`/`balance` 是**字符串形态 JSON**(存量 parseJsonLoose 教训)
+ *    ③ 旧键 `remainRmb` 需归一为 `remainCny`
+ *    ④ `fetchTasks` 云函数未实现时**必须抛错**,不得静默返回空数组
+ *    ⑤ 错误文案取值顺序 `msg` → `mess` → `message`
+ *
+ * 手法:stub 全局 `fetch`,断言「请求形状」与「映射结果」两侧。
+ */
+describe('ParseApi', () => {
+  let api: ParseApi;
+  let calls: { url: string; init: RequestInit }[] = [];
+
+  /** 构造一个受控的 fetch 桩:按序返回预设响应 */
+  function stubFetch(responses: { status?: number; body: unknown }[]): void {
+    let i = 0;
+    spyOn(window, 'fetch').and.callFake((input: RequestInfo | URL, init?: RequestInit) => {
+      calls.push({ url: String(input), init: init ?? {} });
+      const r = responses[Math.min(i++, responses.length - 1)];
+      const status = r.status ?? 200;
+      return Promise.resolve({
+        ok: status >= 200 && status < 300,
+        status,
+        json: () => Promise.resolve(r.body),
+        text: () => Promise.resolve(JSON.stringify(r.body)),
+      } as unknown as Response);
+    });
+  }
+
+  const bodyOf = (idx = 0): Record<string, unknown> =>
+    JSON.parse(String(calls[idx].init.body)) as Record<string, unknown>;
+
+  beforeEach(() => {
+    calls = [];
+    api = new ParseApi(() => 'r:session-token-abc');
+  });
+
+  // ---------- 请求形状(契约 §1.1) ----------
+
+  describe('云函数调用协议(API-契约 §1.1)', () => {
+    it('POST 到 /api/functions,带 appId 头与 {id, token, params} 体', async () => {
+      stubFetch([{ body: { code: 200, agents: [] } }]);
+      await api.fetchAgents();
+
+      expect(calls[0].url).toContain('/api/functions');
+      expect(calls[0].init.method).toBe('POST');
+      const headers = calls[0].init.headers as Record<string, string>;
+      expect(headers['X-Parse-Application-Id']).toBe('ncloudmaster');
+      expect(headers['X-Parse-Session-Token']).toBe('r:session-token-abc');
+
+      const body = bodyOf();
+      expect(body['id']).toBeTruthy();
+      expect(body['token']).toBe('r:session-token-abc');
+      expect(body['params']).toBeTruthy();
+    });
+
+    it('令牌**每次调用实时读取**(不在构造时固化)', async () => {
+      let tok = 'first';
+      const dynamic = new ParseApi(() => tok);
+      stubFetch([{ body: { code: 200, agents: [] } }, { body: { code: 200, agents: [] } }]);
+
+      await dynamic.fetchAgents();
+      tok = 'second';
+      await dynamic.fetchAgents();
+
+      expect(bodyOf(0)['token']).toBe('first');
+      expect(bodyOf(1)['token']).toBe('second');
+    });
+
+    it('未登录(无令牌)时不带 Session-Token 头', async () => {
+      const anon = new ParseApi(() => '');
+      stubFetch([{ body: { code: 200, agents: [] } }]);
+      await anon.fetchAgents();
+      const headers = calls[0].init.headers as Record<string, string>;
+      expect(headers['X-Parse-Session-Token']).toBeUndefined();
+    });
+  });
+
+  // ---------- 错误文案(msg → mess → message) ----------
+
+  describe('错误文案取值顺序', () => {
+    it('HTTP 非 2xx → 抛含状态码的错误', async () => {
+      stubFetch([{ status: 500, body: {} }]);
+      await expectAsync(api.fetchAgents()).toBeRejectedWithError(/HTTP 500/);
+    });
+
+    it('code!=200 且带 mess → 用 mess 文案', async () => {
+      stubFetch([{ body: { code: 401, mess: 'SessionToken 无效或已过期' } }]);
+      await expectAsync(api.fetchAgents()).toBeRejectedWithError(/SessionToken 无效或已过期/);
+    });
+
+    it('msg 优先于 mess', async () => {
+      stubFetch([{ body: { code: 403, msg: '优先', mess: '次选', message: '末选' } }]);
+      await expectAsync(api.fetchAgents()).toBeRejectedWithError(/优先/);
+    });
+
+    it('仅 message 存在时用 message', async () => {
+      stubFetch([{ body: { code: 403, message: '末选文案' } }]);
+      await expectAsync(api.fetchAgents()).toBeRejectedWithError(/末选文案/);
+    });
+  });
+
+  // ---------- Agent 映射(字符串 JSON 兼容) ----------
+
+  describe('fetchAgents 映射', () => {
+    const RAW = {
+      objectId: 'qRovdDU7cY',
+      agentId: 'agent-node-xinting',
+      name: 'agent-node-xinting',
+      botName: '芯葶001',
+      botAvatar: 'https://s3.fmode.cn/a.png',
+      gender: 'female',
+      status: 'running',
+      currentTask: '写文档',
+      progress: 0.5,
+      // 🔴 字符串形态 JSON(存量 parseJsonLoose 的教训)
+      host: '{"cpuPercent":3.4,"memPercent":52.4,"diskPercent":35}',
+      balance: '{"remainCny":17842.31}',
+      channels: '{"matrix":true,"homeserver":"https://matrix.fmode.cn"}',
+      user: { objectId: 'dhv9yTqK6K', nickname: '用户7536' },
+      company: { objectId: 'rpnXZC1loU', __type: 'Pointer' },
+    };
+
+    it('字符串 JSON 字段被解析为对象', async () => {
+      stubFetch([{ body: { code: 200, agents: [RAW] } }]);
+      const [a] = await api.fetchAgents();
+      expect(a.host).toEqual({ cpuPercent: 3.4, memPercent: 52.4, diskPercent: 35 });
+      expect(a.balance?.remainCny).toBe(17842.31);
+      expect(a.channels?.matrix).toBe(true);
+    });
+
+    it('已是对象的字段同样兼容(两种形态都能吃)', async () => {
+      stubFetch([
+        {
+          body: {
+            code: 200,
+            agents: [{ ...RAW, host: { cpuPercent: 1 }, balance: { remainCny: 9 } }],
+          },
+        },
+      ]);
+      const [a] = await api.fetchAgents();
+      expect(a.host?.cpuPercent).toBe(1);
+      expect(a.balance?.remainCny).toBe(9);
+    });
+
+    it('非法 JSON 字符串不抛错,字段降级为 undefined', async () => {
+      stubFetch([{ body: { code: 200, agents: [{ ...RAW, host: '{坏 JSON' }] } }]);
+      const [a] = await api.fetchAgents();
+      expect(a.host).toBeUndefined();
+      expect(a.id).toBe('agent-node-xinting'); // 其余字段照常
+    });
+
+    it('🔴 旧键 remainRmb 归一为 remainCny(映射层职责)', async () => {
+      stubFetch([{ body: { code: 200, agents: [{ ...RAW, balance: '{"remainRmb":86.4}' }] } }]);
+      const [a] = await api.fetchAgents();
+      expect(a.balance?.remainCny).toBe(86.4);
+    });
+
+    it('id 优先取 agentId,缺失时回落 objectId', async () => {
+      stubFetch([
+        { body: { code: 200, agents: [{ ...RAW, agentId: undefined, objectId: 'FALLBACK' }] } },
+      ]);
+      const [a] = await api.fetchAgents();
+      expect(a.id).toBe('FALLBACK');
+    });
+
+    it('name 优先 botName,gender/status 有默认值', async () => {
+      stubFetch([
+        { body: { code: 200, agents: [{ objectId: 'x', botName: '显示名' }] } },
+      ]);
+      const [a] = await api.fetchAgents();
+      expect(a.name).toBe('显示名');
+      expect(a.gender).toBe('unknown');
+      expect(a.status).toBe('offline');
+    });
+
+    it('matrixReady 采用账号级口径(matrixConfig.userId)', async () => {
+      stubFetch([
+        {
+          body: {
+            code: 200,
+            agents: [{ ...RAW, channels: '{"matrix":true,"matrixConfig":{"userId":"@x:fmode.cn"}}' }],
+          },
+        },
+      ]);
+      const [a] = await api.fetchAgents();
+      expect(a.matrixReady).toBe(true);
+    });
+
+    it('owner 从 user 指针还原', async () => {
+      stubFetch([{ body: { code: 200, agents: [RAW] } }]);
+      const [a] = await api.fetchAgents();
+      expect(a.owner?.id).toBe('dhv9yTqK6K');
+      expect(a.owner?.name).toBe('用户7536');
+    });
+
+    it('agents 非数组 → 抛错(不伪装成空列表)', async () => {
+      stubFetch([{ body: { code: 200 } }]);
+      await expectAsync(api.fetchAgents()).toBeRejected();
+    });
+  });
+
+  describe('fetchAgentStatus', () => {
+    it('code 404 → 返回 null(页面自行显示不存在)', async () => {
+      stubFetch([{ body: { code: 404 } }]);
+      expect(await api.fetchAgentStatus('nope')).toBeNull();
+    });
+
+    it('code 200 → 返回映射后的 Agent', async () => {
+      stubFetch([{ body: { code: 200, agent: { agentId: 'a1', status: 'idle' } } }]);
+      const a = await api.fetchAgentStatus('a1');
+      expect(a?.id).toBe('a1');
+      expect(a?.status).toBe('idle');
+    });
+  });
+
+  // ---------- provision(契约 §2.3 校验规则) ----------
+
+  describe('provisionMatrix / validateMatrix', () => {
+    const OK = {
+      code: 200,
+      cached: true,
+      bindingId: 'iyHgfhOAZ5',
+      userMxId: '@sr2wipsdyq:fmode.cn',
+      agentMxId: '@zgdcmgnqcd:fmode.cn',
+      homeserver: 'https://matrix.fmode.cn',
+      accessToken: 'tok',
+      deviceId: 'DEV1',
+      roomId: '!room:fmode.cn',
+      status: 'active',
+    };
+
+    it('code 200 + token + userMxId → 通过', async () => {
+      stubFetch([{ body: OK }]);
+      const r = await api.provisionMatrix('agent-node-lj-test');
+      expect(r.agentMxId).toBe('@zgdcmgnqcd:fmode.cn');
+      expect(r.roomId).toBe('!room:fmode.cn');
+    });
+
+    it('🔴 缺 accessToken → 抛错(不得把失败当成功)', async () => {
+      stubFetch([{ body: { ...OK, accessToken: '' } }]);
+      await expectAsync(api.provisionMatrix('a')).toBeRejected();
+    });
+
+    it('🔴 缺 userMxId → 抛错', async () => {
+      stubFetch([{ body: { ...OK, userMxId: null } }]);
+      await expectAsync(api.provisionMatrix('a')).toBeRejected();
+    });
+
+    it('validateMatrix 传 validate:1', async () => {
+      stubFetch([{ body: OK }]);
+      await api.validateMatrix('agent-x');
+      expect((bodyOf()['params'] as Record<string, unknown>)['validate']).toBe(1);
+    });
+  });
+
+  // ---------- 任务看板:显式抛错(SPEC FR-07b 依赖声明) ----------
+
+  describe('fetchTasks(云函数未实现)', () => {
+    it('🔴 必须**抛错**,不得静默返回空数组(空数组会把「未接入」伪装成「没有任务」)', async () => {
+      await expectAsync(api.fetchTasks('a')).toBeRejectedWithError(/未就绪|未实现/);
+      // 且**不发起任何网络请求**
+      expect(calls.length).toBe(0);
+    });
+
+    it('fetchTaskDetail 同样抛错且不发请求', async () => {
+      await expectAsync(api.fetchTaskDetail('t1')).toBeRejectedWithError(/未就绪|未实现/);
+      expect(calls.length).toBe(0);
+    });
+  });
+
+  // ---------- 上传(OBS / S3 预签名,契约 §2.5) ----------
+
+  describe('requestUploadUrl', () => {
+    it('缺 uploadUrl 即抛错(不得静默降级为本地 blob)', async () => {
+      stubFetch([{ body: { code: 200, key: 'k' } }]);
+      await expectAsync(api.requestUploadUrl('a.txt', 'text/plain', 10)).toBeRejected();
+    });
+
+    it('publicUrl 缺失时按 s3.fmode.cn/<key> 兜底', async () => {
+      stubFetch([{ body: { code: 200, uploadUrl: 'https://s3.fmode.cn/put', key: 'dir/a.txt' } }]);
+      const t = await api.requestUploadUrl('a.txt', 'text/plain', 10);
+      expect(t.publicUrl).toBe('https://s3.fmode.cn/dir/a.txt');
+    });
+
+    it('原样透传服务端给的 headers', async () => {
+      stubFetch([
+        {
+          body: {
+            code: 200,
+            uploadUrl: 'u',
+            key: 'k',
+            headers: { 'x-obs-acl': 'private' },
+          },
+        },
+      ]);
+      const t = await api.requestUploadUrl('a.txt', 'text/plain', 1);
+      expect(t.headers?.['x-obs-acl']).toBe('private');
+    });
+  });
+
+  describe('uploadFile(S3 直传)', () => {
+    it('XHR PUT 到预签名地址,默认带 public-read 与 Content-Type', async () => {
+      stubFetch([{ body: { code: 200, uploadUrl: 'https://s3.fmode.cn/put', key: 'dir/x.png' } }]);
+      const opened: { method: string; url: string; headers: Record<string, string> }[] = [];
+      const origXhr = window.XMLHttpRequest;
+      // 最小 XHR 桩:记录 open/setRequestHeader,send 后立刻 onload
+      (window as unknown as { XMLHttpRequest: unknown }).XMLHttpRequest = function () {
+        const rec = { method: '', url: '', headers: {} as Record<string, string> };
+        const self = {
+          upload: { onprogress: null as unknown },
+          status: 200,
+          onload: null as unknown,
+          onerror: null as unknown,
+          open(m: string, u: string) {
+            rec.method = m;
+            rec.url = u;
+          },
+          setRequestHeader(k: string, v: string) {
+            rec.headers[k] = v;
+          },
+          send() {
+            opened.push(rec);
+            (self.onload as () => void)?.();
+          },
+        };
+        return self;
+      };
+
+      try {
+        const file = new File(['hello'], 'x.png', { type: 'image/png' });
+        const r = await api.uploadFile(file);
+        expect(r.key).toBe('dir/x.png');
+        expect(r.publicUrl).toBe('https://s3.fmode.cn/dir/x.png');
+        expect(opened[0].method).toBe('PUT');
+        expect(opened[0].url).toBe('https://s3.fmode.cn/put');
+        expect(opened[0].headers['Content-Type']).toBe('image/png');
+        expect(opened[0].headers['x-obs-acl']).toBe('public-read');
+      } finally {
+        (window as unknown as { XMLHttpRequest: unknown }).XMLHttpRequest = origXhr;
+      }
+    });
+
+    it('上传 HTTP 失败 → 抛错', async () => {
+      stubFetch([{ body: { code: 200, uploadUrl: 'u', key: 'k' } }]);
+      const origXhr = window.XMLHttpRequest;
+      (window as unknown as { XMLHttpRequest: unknown }).XMLHttpRequest = function () {
+        const self = {
+          upload: { onprogress: null as unknown },
+          status: 403,
+          onload: null as unknown,
+          onerror: null as unknown,
+          open() {},
+          setRequestHeader() {},
+          send() {
+            (self.onload as () => void)?.();
+          },
+        };
+        return self;
+      };
+      try {
+        const file = new File(['x'], 'x.txt', { type: 'text/plain' });
+        await expectAsync(api.uploadFile(file)).toBeRejectedWithError(/HTTP 403/);
+      } finally {
+        (window as unknown as { XMLHttpRequest: unknown }).XMLHttpRequest = origXhr;
+      }
+    });
+  });
+
+  // ---------- 交付成果 ----------
+
+  describe('fetchDeliverables', () => {
+    it('status 含 fail/error → state=failed,否则 delivered', async () => {
+      stubFetch([
+        {
+          body: {
+            code: 200,
+            deliverables: [
+              { objectId: '1', title: 'A', status: 'delivered' },
+              { objectId: '2', title: 'B', status: 'failed' },
+              { objectId: '3', title: 'C', status: 'error_timeout' },
+            ],
+          },
+        },
+      ]);
+      const list = await api.fetchDeliverables();
+      expect(list.map((d) => d.state)).toEqual(['delivered', 'failed', 'failed']);
+    });
+
+    it('artifacts 与 tags 缺省为空数组(不产生 undefined 崩溃)', async () => {
+      stubFetch([{ body: { code: 200, deliverables: [{ objectId: '1', title: 'A' }] } }]);
+      const [d] = await api.fetchDeliverables();
+      expect(d.artifacts).toEqual([]);
+      expect(d.tags).toEqual([]);
+    });
+
+    it('agentId 过滤参数被传入 params', async () => {
+      stubFetch([{ body: { code: 200, deliverables: [] } }]);
+      await api.fetchDeliverables({ agentId: 'a1', limit: 12 });
+      const p = bodyOf()['params'] as Record<string, unknown>;
+      expect(p['agentId']).toBe('a1');
+      expect(p['limit']).toBe(12);
+      expect(p['action']).toBe('list');
+    });
+
+    it('fetchDeliverablesByProject 传 action:byProject', async () => {
+      stubFetch([{ body: { code: 200, groups: [] } }]);
+      await api.fetchDeliverablesByProject(200);
+      const p = bodyOf()['params'] as Record<string, unknown>;
+      expect(p['action']).toBe('byProject');
+    });
+  });
+
+  // ---------- 登录 ----------
+
+  describe('登录', () => {
+    it('sendMobileCode:code===1 才算成功(R4 口径)', async () => {
+      stubFetch([{ body: { code: 1 } }]);
+      await expectAsync(api.sendMobileCode('13800000000')).toBeResolved();
+    });
+
+    it('sendMobileCode:code!==1 → 抛错并带服务端文案', async () => {
+      stubFetch([{ body: { code: 200, mess: '频率超限' } }]);
+      await expectAsync(api.sendMobileCode('13800000000')).toBeRejectedWithError(/频率超限/);
+    });
+
+    it('loginWithMobile:code===200 && data.token 才算成功(R5 口径)', async () => {
+      stubFetch([
+        { body: { code: 200, data: { token: 'r:new' } } }, // 登录
+        { body: { objectId: 'u1', nickname: '我' } }, // fetchMe
+        { body: { results: [] } }, // fetchRoles
+      ]);
+      const s = await api.loginWithMobile('13800000000', '123456');
+      expect(s.token).toBe('r:new');
+      expect(s.me.id).toBe('u1');
+    });
+
+    it('loginWithMobile:缺 data.token → 抛错', async () => {
+      stubFetch([{ body: { code: 200, data: {} } }]);
+      await expectAsync(api.loginWithMobile('13800000000', '123456')).toBeRejected();
+    });
+
+    it('🔴 fetchMe 必须用 **GET**(契约曾误写 POST,实测 POST 会 404)', async () => {
+      stubFetch([
+        { body: { objectId: 'u1', nickname: '我', company: { objectId: 'C1' } } },
+        { body: { results: [{ name: 'masters' }] } },
+      ]);
+      const me = await api.fetchMe('r:tok');
+      expect(me.companyId).toBe('C1');
+      expect(me.roles).toContain('masters');
+      expect(calls[0].init.method).toBe('GET');
+      expect(calls[0].url).toContain('/parse/users/me');
+      const h = calls[0].init.headers as Record<string, string>;
+      expect(h['X-Parse-Session-Token']).toBe('r:tok');
+    });
+
+    it('fetchRoles 失败时降级为空数组(不阻塞登录,判级以服务端为准)', async () => {
+      stubFetch([
+        { body: { objectId: 'u1', nickname: '我' } },
+        { status: 500, body: {} },
+      ]);
+      const me = await api.fetchMe('r:tok');
+      expect(me.id).toBe('u1');
+      expect(me.roles).toEqual([]);
+    });
+  });
+});

+ 524 - 0
src/app/core/api/parse-api.ts

@@ -0,0 +1,524 @@
+import { Scope } from '../acl';
+import { FmodeApi } from './fmode-api';
+import {
+  Agent,
+  AgentStatus,
+  AuthSession,
+  Deliverable,
+  DeliverableGroup,
+  MatrixProvision,
+  Me,
+  Task,
+  UploadTicket,
+} from './models';
+
+/**
+ * 真实数据源实现 —— 走 fmode 平台云函数 + REST(《API-契约.md》)
+ *
+ * 依据《数据访问层设计.md》§一/§二:本类是 `FmodeApi` 的**真实实现**,
+ * 由 `api.provider.ts` 在 `environment.useMockData === false` 时装配。
+ *
+ * ## 约定
+ *
+ * - 云函数统一入口 `POST https://server.fmode.cn/api/functions`
+ *   (头 `X-Parse-Application-Id` / `X-Parse-Session-Token`;体 `{id, token, params}`)
+ * - 错误处理:HTTP 200 但 `body.code` 为 401/403 → 视为业务错误并抛出,
+ *   文案取 `msg` → `mess` → `message`
+ * - 云函数返回的 `host` / `channels` / `balance` 是**字符串形态 JSON**,
+ *   映射层兼容「已是对象」与「是字符串」两种形态(存量 `parseJsonLoose` 的教训)
+ *
+ * ## 前端安全边界(黄金规则 6 / NFR-03)
+ *
+ * 前端**不含任何服务端密钥**:无 masterKey、无 OBS AK/SK、无 Matrix 派生密码。
+ * Matrix 凭据一律由 provision 云函数服务端签发。
+ *
+ * ## 一期未实现项
+ *
+ * `fetchTasks` / `fetchTaskDetail` 的云函数接收端**尚未实现**(SPEC FR-07b 依赖声明),
+ * 故**显式抛错**而非静默返回空数组 —— 空数组会把「未接入」伪装成「没有数据」。
+ */
+
+const FUNCTION_API = 'https://server.fmode.cn/api/functions';
+const PARSE_BASE = 'https://server.fmode.cn/parse';
+const PARSE_APP_ID = 'ncloudmaster';
+
+/** 云函数 objectId(《API-契约》§二) */
+const FN = {
+  agentStatus: 'xlosi3m46q',
+  deliverables: 'lfYlgU7SkK',
+  provision: 'RP5Ht55NeX',
+  tokenSync: 'zl4yU8PAEB',
+  uploadUrl: 'AlP56LCKFm',
+} as const;
+
+/** 兼容「已是对象」与「是 JSON 字符串」两种形态 */
+function parseJsonLoose<T = Record<string, unknown>>(v: unknown): T | undefined {
+  if (v == null) return undefined;
+  if (typeof v === 'object') return v as T;
+  if (typeof v !== 'string' || !v.trim()) return undefined;
+  try {
+    return JSON.parse(v) as T;
+  } catch {
+    return undefined;
+  }
+}
+
+export class ParseApi extends FmodeApi {
+  /**
+   * 会话令牌读取器 + 请求范围判定器。
+   *
+   * - **令牌**由 `SessionService` 唯一持有,**每次调用实时读取**(避免登录/退登后持过期副本)
+   * - **scope** 由 `acl.scopeFor(me)` 判定(超管 all / 公司管理员 company / 普通用户 mine);
+   *   🔴 **不得硬编码 `'all'`** —— 那会让每个用户都看到全平台别人的容器(越权)
+   */
+  constructor(
+    private readonly readToken: () => string = () => '',
+    private readonly readScope: () => Scope = () => 'mine',
+  ) {
+    super();
+  }
+
+  /** 当前会话令牌(读取器为空时回落到内部缓存,供单测注入) */
+  private get sessionToken(): string {
+    return this.readToken() || this.fallbackToken;
+  }
+
+  /** 仅单测/无 DI 场景使用的回落令牌 */
+  private fallbackToken = '';
+
+  setToken(token: string): void {
+    this.fallbackToken = token;
+  }
+
+  // ---------- 鉴权 ----------
+
+  async sendMobileCode(mobile: string): Promise<void> {
+    // R4:POST /api/apig/message {mobile, company};🔴 code === 1 才算成功
+    const res = await this.rawFetch(`${PARSE_BASE.replace('/parse', '')}/api/apig/message`, {
+      method: 'POST',
+      body: JSON.stringify({ mobile, company: LOGIN_COMPANY }),
+    });
+    const d = (await res.json()) as { code?: number; msg?: string; mess?: string };
+    if (d.code !== 1) throw new Error(friendlySendCodeError(pickMessage(d), res.status));
+  }
+
+  async loginWithMobile(mobile: string, code: string): Promise<AuthSession> {
+    // R5:GET /api/fmode/mobile?mobile&code&company;🔴 code === 200 && data.token
+    const url = `${PARSE_BASE.replace('/parse', '')}/api/fmode/mobile?mobile=${encodeURIComponent(
+      mobile,
+    )}&code=${encodeURIComponent(code)}&company=${LOGIN_COMPANY}`;
+    const res = await this.rawFetch(url, { method: 'GET' });
+    const d = (await res.json()) as {
+      code?: number;
+      data?: { token?: string };
+      msg?: string;
+      mess?: string;
+    };
+    const token = d.data?.token;
+    if (d.code !== 200 || !token) throw new Error(pickMessage(d) ?? '登录失败');
+    this.setToken(token);
+    const me = await this.fetchMe(token);
+    return { token, me };
+  }
+
+  async loginWithToken(token: string): Promise<AuthSession> {
+    const me = await this.fetchMe(token);
+    this.setToken(token);
+    return { token, me };
+  }
+
+  async fetchMe(token: string): Promise<Me> {
+    // R1:**GET** /parse/users/me(头 X-Parse-Session-Token)
+    // 🔴 实测教训:POST 会 404(返回 HTML 错误页);契约文档曾误写为 POST。
+    const res = await this.rawFetch(`${PARSE_BASE}/users/me`, {
+      method: 'GET',
+      headers: { 'X-Parse-Session-Token': token },
+    });
+    if (!res.ok) throw new Error(`Token 校验失败(HTTP ${res.status})`);
+    const u = (await res.json()) as Record<string, unknown>;
+    const objectId = String(u['objectId'] ?? '');
+    // R2:_User JSON **不透出 relation**,roles 必须单独查(存量 v0.3.3 著名坑:
+    //     超管因缺 roles 被误判为 company-member → 列表空)
+    const roles = objectId
+      ? await this.fetchRoles(token, objectId).catch(() => [] as string[])
+      : [];
+    const companyId = (u['company'] as { objectId?: string } | undefined)?.objectId;
+    // 所属组织名:实测 Company 表有 name(如 E4KpGvTEto → 飞码AI)。
+    // 取不到时为 undefined → UI 显示占位,**不编造**。
+    const companyName = companyId
+      ? await this.fetchCompanyName(token, companyId).catch(() => undefined)
+      : undefined;
+    return {
+      id: objectId,
+      name: String(u['nickname'] ?? u['username'] ?? ''),
+      mobile: u['mobile'] ? String(u['mobile']) : undefined,
+      avatar: u['avatar'] ? String(u['avatar']) : undefined,
+      companyId,
+      companyName,
+      roles,
+    };
+  }
+
+  private async fetchRoles(token: string, userId: string): Promise<string[]> {
+    // R2:GET /parse/roles?where={users:Pointer<_User>}&limit=50
+    const where = JSON.stringify({
+      users: { __type: 'Pointer', className: '_User', objectId: userId },
+    });
+    const res = await this.rawFetch(
+      `${PARSE_BASE}/roles?where=${encodeURIComponent(where)}&limit=50`,
+      { headers: { 'X-Parse-Session-Token': token } },
+    );
+    if (!res.ok) return [];
+    const d = (await res.json()) as { results?: { name?: string }[] };
+    return (d.results ?? []).map((r) => String(r.name ?? '')).filter(Boolean);
+  }
+
+  /** 取组织名(`Company.name`);失败返回 undefined(不回退假值) */
+  private async fetchCompanyName(token: string, companyId: string): Promise<string | undefined> {
+    const res = await this.rawFetch(
+      `${PARSE_BASE}/classes/Company/${encodeURIComponent(companyId)}?keys=name`,
+      { headers: { 'X-Parse-Session-Token': token } },
+    );
+    if (!res.ok) return undefined;
+    const d = (await res.json()) as { name?: string };
+    return d.name ? String(d.name) : undefined;
+  }
+
+  // ---------- 数字生命(实况) ----------
+
+  async fetchAgents(): Promise<Agent[]> {
+    const d = await this.callFunction<{ code: number; agents?: unknown[] }>(FN.agentStatus, {
+      op: 'query',
+      // 🔴 按角色判定(acl.scopeFor)—— 超管 all / 公司管理员 company / 普通用户 mine。
+      //    曾硬编码 'all' → 每个用户都拿到全平台 51 个容器(越权)。
+      scope: this.readScope(),
+    });
+    if (d.code !== 200 || !Array.isArray(d.agents)) {
+      throw new Error(pickMessage(d) ?? '实况数据查询失败');
+    }
+    return d.agents.map((raw) => mapAgent(raw as Record<string, unknown>));
+  }
+
+  async fetchAgentStatus(agentId: string): Promise<Agent | null> {
+    const d = await this.callFunction<{ code: number; agent?: unknown }>(FN.agentStatus, {
+      op: 'query',
+      scope: this.readScope(),
+      agentId,
+    });
+    // 单条查询:code 404/403 → 返回 null(页面自行显示「不存在/无权限」)
+    if (d.code !== 200 || !d.agent) return null;
+    return mapAgent(d.agent as Record<string, unknown>);
+  }
+
+  // ---------- Matrix ----------
+
+  async provisionMatrix(agentId: string): Promise<MatrixProvision> {
+    const d = await this.callFunction<MatrixProvision & { code: number }>(FN.provision, {
+      agentId,
+    });
+    // 校验规则(《API-契约》§2.3):必须 code===200 && accessToken && userMxId
+    if (d.code !== 200 || !d.accessToken || !d.userMxId) {
+      throw new Error(pickMessage(d) ?? 'FMODE 开通失败');
+    }
+    return d;
+  }
+
+  async validateMatrix(agentId: string): Promise<MatrixProvision> {
+    const d = await this.callFunction<MatrixProvision & { code: number }>(FN.provision, {
+      agentId,
+      validate: 1,
+    });
+    if (d.code !== 200 || !d.accessToken || !d.userMxId) {
+      throw new Error(pickMessage(d) ?? 'FMODE 凭据校验失败');
+    }
+    return d;
+  }
+
+  // ---------- 交付成果 ----------
+
+  async fetchDeliverables(opts?: { agentId?: string; limit?: number }): Promise<Deliverable[]> {
+    const params: Record<string, unknown> = { action: 'list', limit: opts?.limit ?? 200 };
+    if (opts?.agentId) params['agentId'] = opts.agentId;
+    const d = await this.callFunction<{ code: number; deliverables?: unknown[] }>(
+      FN.deliverables,
+      params,
+    );
+    if (d.code !== 200) throw new Error(pickMessage(d) ?? '成果查询失败');
+    return (d.deliverables ?? []).map((x) => mapDeliverable(x as Record<string, unknown>));
+  }
+
+  async fetchDeliverablesByProject(limit?: number): Promise<DeliverableGroup[]> {
+    const d = await this.callFunction<{ code: number; groups?: unknown[] }>(FN.deliverables, {
+      action: 'byProject',
+      limit: limit ?? 200,
+    });
+    if (d.code !== 200) throw new Error(pickMessage(d) ?? '成果分组查询失败');
+    return (d.groups ?? []).map((g) => {
+      const raw = g as Record<string, unknown>;
+      const items = Array.isArray(raw['items'])
+        ? raw['items'].map((x) => mapDeliverable(x as Record<string, unknown>))
+        : [];
+      return {
+        project: raw['project'] ? String(raw['project']) : undefined,
+        projectName: String(raw['projectName'] ?? '未归类'),
+        count: Number(raw['count']) || items.length,
+        items,
+      };
+    });
+  }
+
+  // ---------- 任务看板(FR-07b:云函数接收端未实现) ----------
+
+  async fetchTasks(_agentId: string): Promise<Task[]> {
+    // 🔴 不得静默返回空数组(那会把「未接入」伪装成「没有数据」)
+    throw new Error('任务看板数据源未就绪:云函数接收端尚未实现(SPEC FR-07b 依赖声明)');
+  }
+
+  async fetchTaskDetail(_taskId: string): Promise<Task | null> {
+    throw new Error('任务看板数据源未就绪:云函数接收端尚未实现(SPEC FR-07b 依赖声明)');
+  }
+
+  // ---------- 上传(OBS / 华为云,统一走 S3 预签名) ----------
+
+  async requestUploadUrl(
+    filename: string,
+    mimeType: string,
+    size: number,
+  ): Promise<UploadTicket> {
+    const d = await this.callFunction<{
+      code: number;
+      uploadUrl?: string;
+      key?: string;
+      publicUrl?: string;
+      headers?: Record<string, string>;
+    }>(FN.uploadUrl, { filename, mimeType, size });
+    // 《API-契约》§2.5:缺 uploadUrl 即抛错,不得静默降级为本地 blob
+    if (d.code !== 200 || !d.uploadUrl || !d.key) {
+      throw new Error(pickMessage(d) ?? '上传签名获取失败');
+    }
+    return {
+      uploadUrl: d.uploadUrl,
+      key: d.key,
+      publicUrl: d.publicUrl ?? `https://s3.fmode.cn/${d.key}`,
+      headers: d.headers,
+    };
+  }
+
+  async uploadFile(
+    file: File,
+    onProgress?: (pct: number) => void,
+  ): Promise<{ key: string; publicUrl: string }> {
+    const ticket = await this.requestUploadUrl(
+      file.name,
+      file.type || 'application/octet-stream',
+      file.size,
+    );
+    // S3 直传(华为云 OBS 兼容 S3 协议);前端不经手任何 AK/SK
+    const headers: Record<string, string> = {
+      'Content-Type': file.type || 'application/octet-stream',
+      'x-obs-acl': 'public-read',
+      ...ticket.headers,
+    };
+    await this.putWithProgress(ticket.uploadUrl, headers, file, onProgress);
+    return { key: ticket.key, publicUrl: ticket.publicUrl };
+  }
+
+  /** PUT 直传;浏览器 XMLHttpRequest 才能拿到上传进度 */
+  private putWithProgress(
+    url: string,
+    headers: Record<string, string>,
+    file: File,
+    onProgress?: (pct: number) => void,
+  ): Promise<void> {
+    return new Promise((resolve, reject) => {
+      const xhr = new XMLHttpRequest();
+      xhr.open('PUT', url, true);
+      for (const [k, v] of Object.entries(headers)) xhr.setRequestHeader(k, v);
+      xhr.upload.onprogress = (e) => {
+        if (e.lengthComputable && onProgress) onProgress(Math.round((e.loaded / e.total) * 100));
+      };
+      xhr.onload = () => {
+        if (xhr.status >= 200 && xhr.status < 300) resolve();
+        else reject(new Error(`上传失败(HTTP ${xhr.status})`));
+      };
+      xhr.onerror = () => reject(new Error('上传失败:网络错误'));
+      xhr.send(file);
+    });
+  }
+
+  // ---------- 内部 ----------
+
+  /** 云函数统一调用(《API-契约》§1.1) */
+  private async callFunction<T>(id: string, params: Record<string, unknown>): Promise<T> {
+    const res = await this.rawFetch(FUNCTION_API, {
+      method: 'POST',
+      headers: this.authHeaders(),
+      body: JSON.stringify({ id, token: this.sessionToken, params }),
+    });
+    if (!res.ok) throw new Error(`云函数调用失败(HTTP ${res.status})`);
+    return (await res.json()) as T;
+  }
+
+  private authHeaders(): Record<string, string> {
+    const h: Record<string, string> = {
+      'Content-Type': 'application/json',
+      'X-Parse-Application-Id': PARSE_APP_ID,
+    };
+    if (this.sessionToken) h['X-Parse-Session-Token'] = this.sessionToken;
+    return h;
+  }
+
+  private rawFetch(url: string, init: RequestInit): Promise<Response> {
+    const headers: Record<string, string> = {
+      'X-Parse-Application-Id': PARSE_APP_ID,
+      ...((init.headers as Record<string, string>) ?? {}),
+    };
+    /*
+     * 🔴 有 body 的方法必须带 `Content-Type: application/json`。
+     *
+     * 实测教训:`POST /api/apig/message` 若**不带该头**,服务端**读不到 body**,
+     * 于是报「缺少参数company」—— 而 body 里其实带了 company。
+     * 这个误导性报错曾让我误判为「限流」,排查花了很久。
+     *
+     * 故此处对带 body 的请求**自动补默认 Content-Type**(调用方显式指定则尊重其值)。
+     */
+    if (init.body != null && !headers['Content-Type']) {
+      headers['Content-Type'] = 'application/json';
+    }
+    return fetch(url, { ...init, headers });
+  }
+}
+
+/** 登录公司常量(《API-契约》§1.2) */
+const LOGIN_COMPANY = 'E4KpGvTEto';
+
+/** 错误文案取值顺序:msg → mess → message */
+function pickMessage(d: unknown): string | undefined {
+  if (!d || typeof d !== 'object') return undefined;
+  const o = d as Record<string, unknown>;
+  for (const k of ['msg', 'mess', 'message']) {
+    const v = o[k];
+    if (typeof v === 'string' && v.trim()) return v;
+  }
+  return undefined;
+}
+
+/**
+ * 发码错误 → 友好文案。
+ *
+ * ## 🔴 为什么要做这一层(实测教训)
+ *
+ * 服务端在**限流**状态下会返回**误导性文案**,实测对照:
+ *
+ * | 请求 | 服务端返回 |
+ * |---|---|
+ * | 带 company(首次) | `200 {"code":1,"data":{"msg":"发送成功"}}` |
+ * | 带 company(频繁) | `500 {"mess":"该号码发送验证码过于频繁"}` |
+ * | **不带** company | `500 {"mess":"缺少参数company"}` |
+ *
+ * 即:我们的请求**带了** company 且首次能成功,但**短时间重复请求**时
+ * 服务端可能错报成「缺少参数company」,误导用户以为参数配错了。
+ *
+ * 故此处把「缺参数」在**限流上下文**(HTTP 500 而非 400)下转成更可能的真相提示,
+ * 并在文案中保留原始信息以便排查(不吞掉事实)。
+ */
+function friendlySendCodeError(raw: string | undefined, status: number): string {
+  const m = raw ?? '';
+  if (/频繁|过于频繁|too many|rate/i.test(m)) {
+    return '发送过于频繁,请稍后再试(同一号码通常 60 秒内只能发一次)';
+  }
+  // 实测:限流态下服务端会误报「缺少参数company」;保留原始信息供排查
+  if (/缺少参数\s*company/i.test(m)) {
+    return '验证码发送失败:服务端返回「缺少参数company」。若已多次点击,通常是发送过于频繁,请稍后再试。(原始:' +
+      m +
+      ')';
+  }
+  if (m) return m;
+  return `验证码发送失败(HTTP ${status})`;
+}
+
+/** 云函数原始记录 → `Agent` 领域模型(映射只在此处发生) */
+function mapAgent(raw: Record<string, unknown>): Agent {
+  const channels = parseJsonLoose<{
+    matrix?: boolean;
+    homeserver?: string;
+    matrixConfig?: { userId?: string; homeserver?: string; serverName?: string };
+  }>(raw['channels']);
+  const balance = parseJsonLoose<{ remainCny?: number; remainRmb?: number; pageUrl?: string }>(
+    raw['balance'],
+  );
+  const host = parseJsonLoose<{
+    cpuPercent?: number;
+    memPercent?: number;
+    diskPercent?: number;
+  }>(raw['host']);
+  const user = raw['user'] as Record<string, unknown> | undefined;
+
+  return {
+    id: String(raw['agentId'] ?? raw['objectId'] ?? ''),
+    name: String(raw['botName'] ?? raw['name'] ?? ''),
+    avatar: raw['botAvatar'] ? String(raw['botAvatar']) : undefined,
+    gender: (raw['gender'] as Agent['gender']) ?? 'unknown',
+    status: (raw['status'] as AgentStatus) ?? 'offline',
+    currentTask: raw['currentTask'] ? String(raw['currentTask']) : undefined,
+    currentAction: raw['currentAction'] ? String(raw['currentAction']) : undefined,
+    progress: typeof raw['progress'] === 'number' ? (raw['progress'] as number) : undefined,
+    heartbeatAt: raw['heartbeatAt'] ? String(raw['heartbeatAt']) : undefined,
+    updatedAt: raw['updatedAt'] ? String(raw['updatedAt']) : undefined,
+    host,
+    balance: balance
+      ? {
+          // 映射层归一:兼容旧键 remainRmb
+          remainCny: balance.remainCny ?? balance.remainRmb,
+          pageUrl: balance.pageUrl,
+        }
+      : undefined,
+    channels: channels
+      ? {
+          matrix: channels.matrix,
+          homeserver: channels.homeserver,
+          matrixConfig: channels.matrixConfig,
+        }
+      : undefined,
+    owner: user
+      ? {
+          id: String(user['objectId'] ?? ''),
+          name: String(user['nickname'] ?? user['username'] ?? ''),
+          avatar: user['avatar'] ? String(user['avatar']) : undefined,
+          mobile: user['mobile'] ? String(user['mobile']) : undefined,
+        }
+      : undefined,
+    companyId: (raw['company'] as { objectId?: string } | undefined)?.objectId,
+    // 「是否已接入 FMODE」= channels.matrix 为真(或已有 matrixConfig.userId)
+    matrixReady: !!channels?.matrix || !!channels?.matrixConfig?.userId,
+    // 未读数**不来自服务端**:云函数不返回该字段,真实计数由
+    // `UnreadService` 依据 Matrix 事件流在客户端维护(见该服务注释)。
+    // 此处恒为 0,UI 不得消费(消费点一律走 UnreadService.countOf)。
+    unread: 0,
+  };
+}
+
+/** 成果原始记录 → `Deliverable` 领域模型 */
+function mapDeliverable(raw: Record<string, unknown>): Deliverable {
+  const status = String(raw['status'] ?? '');
+  const artifacts = Array.isArray(raw['artifacts'])
+    ? (raw['artifacts'] as Record<string, unknown>[]).map((a) => ({
+        name: String(a['name'] ?? ''),
+        url: String(a['url'] ?? ''),
+        type: String(a['type'] ?? ''),
+      }))
+    : [];
+  return {
+    id: String(raw['objectId'] ?? raw['id'] ?? ''),
+    agentId: String(raw['agentId'] ?? ''),
+    title: String(raw['title'] ?? ''),
+    summary: raw['summary'] ? String(raw['summary']) : undefined,
+    projectId: raw['project'] ? String(raw['project']) : undefined,
+    projectName: raw['projectName'] ? String(raw['projectName']) : undefined,
+    artifacts,
+    tags: Array.isArray(raw['tags']) ? (raw['tags'] as string[]).map(String) : [],
+    deliveredAt: String(raw['deliveredAt'] ?? ''),
+    // 契约 §2.2:status 取值为推断 —— 含 fail/error 子串即失败
+    state: /fail|error/i.test(status) ? 'failed' : 'delivered',
+  };
+}

+ 115 - 0
src/app/core/api/presence.spec.ts

@@ -0,0 +1,115 @@
+import {
+  AGENT_PRESENCE_TEXT,
+  Agent,
+  agentPresence,
+  agentPresenceTone,
+} from './models';
+
+/**
+ * 可对话状态判定单测(`agentPresence`)
+ *
+ * 🔴 本判定是「通讯录 vs 会话列表状态不一致」的修复核心:
+ *    统一以**接入态**(能否对话)为准,不再混用**运行态**(`Agent.status`,
+ *    reporter 上报的历史值 —— 实测芯葶 heartbeat 停在近一月前却仍为 online)。
+ */
+describe('agentPresence 可对话状态', () => {
+  const base = (over: Partial<Agent>): Agent =>
+    ({ id: 'a', name: 'A', status: 'running', matrixReady: true, unread: 0, ...over }) as Agent;
+
+  describe('offline —— 无绑定(不可对话)', () => {
+    it('matrixReady 为假 → offline', () => {
+      expect(agentPresence(base({ matrixReady: false }))).toBe('offline');
+    });
+
+    it('agent 为 undefined / null → offline', () => {
+      expect(agentPresence(undefined)).toBe('offline');
+      expect(agentPresence(null)).toBe('offline');
+    });
+
+    it('🔴 无绑定即使 status=running 也是 offline(不信运行态)', () => {
+      expect(agentPresence(base({ matrixReady: false, status: 'running' }))).toBe('offline');
+    });
+  });
+
+  describe('online —— 接入态已上报', () => {
+    it('channelState=connected → online', () => {
+      expect(agentPresence(base({ channels: { matrixConfig: { channelState: 'connected' } } }))).toBe(
+        'online',
+      );
+    });
+
+    it('listenerOk=true → online(旧字段兼容)', () => {
+      expect(agentPresence(base({ channels: { matrixConfig: { listenerOk: true } } }))).toBe('online');
+    });
+
+    it('channelDetail.matrixState=connected → online', () => {
+      expect(
+        agentPresence(base({ channels: { matrixConfig: { channelDetail: { matrixState: 'connected' } } } })),
+      ).toBe('online');
+    });
+  });
+
+  describe('unreported —— 有绑定但通道状态未上报', () => {
+    it('有绑定但 matrixConfig 为空 → unreported', () => {
+      expect(agentPresence(base({ channels: { matrixConfig: {} } }))).toBe('unreported');
+    });
+
+    it('有绑定但无 channels → unreported', () => {
+      expect(agentPresence(base({}))).toBe('unreported');
+    });
+
+    it('channelState 是非 connected 值 → unreported', () => {
+      expect(agentPresence(base({ channels: { matrixConfig: { channelState: 'stopped' } } }))).toBe(
+        'unreported',
+      );
+    });
+
+    it('🔴 有绑定即使 status=offline 也是 unreported(运行态不参与判定)', () => {
+      expect(agentPresence(base({ status: 'offline' }))).toBe('unreported');
+    });
+  });
+
+  describe('文案与配色(配色 + 文字双通道)', () => {
+    it('三种状态都有中文文案', () => {
+      expect(AGENT_PRESENCE_TEXT.online).toBe('在线');
+      expect(AGENT_PRESENCE_TEXT.unreported).toBe('未上报');
+      expect(AGENT_PRESENCE_TEXT.offline).toBe('未接入');
+    });
+
+    it('配色映射:online=success / unreported=warning / offline=neutral', () => {
+      expect(agentPresenceTone('online')).toBe('success');
+      expect(agentPresenceTone('unreported')).toBe('warning');
+      expect(agentPresenceTone('offline')).toBe('neutral');
+    });
+  });
+
+  describe('不一致修复回归(实测场景)', () => {
+    it('芯葶:有绑定 + channelState=connected → online(不论 status 值)', () => {
+      const xinting = base({
+        id: 'agent-node-xinting',
+        status: 'running',
+        heartbeatAt: '2026-09-23T02:50:38.401Z',
+        channels: { matrix: true, matrixConfig: { channelState: 'connected', userId: '@q:fmode.cn' } },
+      });
+      expect(agentPresence(xinting)).toBe('online');
+    });
+
+    it('兔爷:有绑定但无通道状态 → unreported(而非误显示为 online)', () => {
+      const tuye = base({
+        id: 'agent-node-tuye',
+        status: 'running',
+        channels: { matrix: true, matrixConfig: {} },
+      });
+      expect(agentPresence(tuye)).toBe('unreported');
+    });
+
+    it('lj-test:有绑定 + 无通道状态 → unreported(我补录时未填 channelState)', () => {
+      const lj = base({
+        id: 'agent-node-lj-test',
+        status: 'running',
+        channels: { matrix: true, matrixConfig: { userId: '@zgdcmgnqcd:fmode.cn' } },
+      });
+      expect(agentPresence(lj)).toBe('unreported');
+    });
+  });
+});

+ 9 - 2
src/app/core/auth.guard.ts

@@ -12,8 +12,15 @@ import { SessionService } from './session.service';
  * 🔴 本项目**不使用**「仅挂 canActivate 的 redirectTo 路由」写法
  *   —— 存量为此踩过 NG04014(JIT 下抛错)的坑。
  */
-/** 审核期间临时放行端内页面;恢复正式鉴权时改为 false。 */
-export const AUTH_REVIEW_BYPASS = true;
+/**
+ * 审核期临时放行开关。
+ *
+ * 🔴 已恢复正式鉴权(2026-10-17):先前设为 `true` 会让守卫**直接放行所有端内页面**,
+ *    导致登录流程(FR-02)完全无法验证 —— 表现为「粘 token 没反应」。
+ *    现改回 `false`,未登录访问受保护路由会跳 `/login?redirect=`。
+ *    如需临时放行评审,改回 `true` 即可(仅影响本地开发,不改后端)。
+ */
+export const AUTH_REVIEW_BYPASS = false;
 
 export const authGuard: CanActivateFn = (_route, state) => {
   if (AUTH_REVIEW_BYPASS) return true;

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

@@ -0,0 +1,419 @@
+import { TestBed } from '@angular/core/testing';
+import { ChatService } from './chat.service';
+import { FmodeApi } from './api/fmode-api';
+import { LiveService } from './live.service';
+import { MatrixClientService, MxMessage } from './matrix-client.service';
+import { SessionService } from './session.service';
+import { UnreadService } from './unread.service';
+import { Agent, MatrixProvision } from './api/models';
+import { environment } from '../../environments/environment';
+
+/**
+ * ChatService 单测 —— 会话状态机(SPEC FR-04)
+ *
+ * 覆盖:provision → 连接 → 房间 → 收发 → 分页 的编排与失败分环节报错。
+ * 依赖(FmodeApi / LiveService / MatrixClientService)全部用桩替换,
+ * **不触网、不加载 matrix-js-sdk**。
+ *
+ * 🔴 重点回归:
+ *   ① 切会话时 `seq` 防串扰(过期回包必须被丢弃)
+ *   ② `close()` **不断开**会话级连接(未读依赖 sync 常驻)
+ *   ③ 失败时**分环节**报错(FMODE 开通 / Matrix 登录 / 房间创建)
+ *   ④ `paginate()` 防重入(滚动事件高频触发)
+ */
+describe('ChatService', () => {
+  let svc: ChatService;
+
+  /** 桩:FmodeApi */
+  const apiStub = {
+    provisionMatrix: jasmine.createSpy('provisionMatrix'),
+    validateMatrix: jasmine.createSpy('validateMatrix'),
+    fetchAgents: jasmine.createSpy('fetchAgents'),
+    uploadFile: jasmine.createSpy('uploadFile'),
+    requestUploadUrl: jasmine.createSpy('requestUploadUrl'),
+  };
+
+  /** 桩:LiveService */
+  const liveStub = {
+    ensureLoaded: jasmine.createSpy('ensureLoaded').and.returnValue(Promise.resolve()),
+    agentById: jasmine.createSpy('agentById'),
+    agents: jasmine.createSpy('agents').and.returnValue([] as Agent[]),
+  };
+
+  /** 桩:MatrixClientService */
+  const mxStub = {
+    connectSession: jasmine.createSpy('connectSession').and.returnValue(Promise.resolve()),
+    disconnect: jasmine.createSpy('disconnect'),
+    ensureRoom: jasmine.createSpy('ensureRoom'),
+    syncState: jasmine.createSpy('syncState').and.returnValue('connected'),
+    hasInitialSync: true,
+    paginationToken: jasmine.createSpy('paginationToken').and.returnValue('tok-b'),
+    timelineToMessages: jasmine.createSpy('timelineToMessages').and.returnValue([] as MxMessage[]),
+    onMessage: jasmine.createSpy('onMessage').and.returnValue(() => undefined),
+    onAnyMessage: jasmine.createSpy('onAnyMessage').and.returnValue(() => undefined),
+    onTyping: jasmine.createSpy('onTyping').and.returnValue(() => undefined),
+    sendText: jasmine.createSpy('sendText').and.returnValue(Promise.resolve({ event_id: '$e' })),
+    setTyping: jasmine.createSpy('setTyping').and.returnValue(Promise.resolve()),
+    paginateBack: jasmine.createSpy('paginateBack').and.returnValue(Promise.resolve(true)),
+  };
+
+  const PROV: MatrixProvision = {
+    cached: true,
+    bindingId: 'b1',
+    status: 'active',
+    userMxId: '@sr2wipsdyq:fmode.cn',
+    agentMxId: '@zgdcmgnqcd:fmode.cn',
+    homeserver: 'https://matrix.fmode.cn',
+    accessToken: 'tok',
+    deviceId: 'DEV1',
+    roomId: '!room1:fmode.cn',
+  };
+
+  const AGENT: Agent = {
+    id: 'agent-node-lj-test',
+    name: 'lj-test',
+    status: 'online' as Agent['status'],
+    matrixReady: true,
+    unread: 0,
+  };
+
+  beforeEach(() => {
+    localStorage.clear();
+    for (const k of Object.keys(apiStub)) {
+      (apiStub as Record<string, jasmine.Spy>)[k].calls.reset();
+    }
+    liveStub.ensureLoaded.calls.reset();
+    liveStub.agentById.calls.reset();
+    for (const k of Object.keys(mxStub)) {
+      const v = (mxStub as Record<string, unknown>)[k];
+      if (v && typeof (v as jasmine.Spy).calls === 'object') (v as jasmine.Spy).calls.reset();
+    }
+    mxStub.connectSession.and.returnValue(Promise.resolve());
+    mxStub.ensureRoom.and.returnValue(Promise.resolve('!room1:fmode.cn'));
+    mxStub.syncState.and.returnValue('connected');
+    mxStub.paginationToken.and.returnValue('tok-b');
+    mxStub.timelineToMessages.and.returnValue([]);
+    mxStub.onMessage.and.returnValue(() => undefined);
+    mxStub.onAnyMessage.and.returnValue(() => undefined);
+    mxStub.onTyping.and.returnValue(() => undefined);
+    mxStub.sendText.and.returnValue(Promise.resolve({ event_id: '$e' }));
+    mxStub.paginateBack.and.returnValue(Promise.resolve(true));
+
+    liveStub.agentById.and.returnValue(AGENT);
+    apiStub.provisionMatrix.and.returnValue(Promise.resolve(PROV));
+
+    TestBed.configureTestingModule({
+      providers: [
+        ChatService,
+        { provide: FmodeApi, useValue: apiStub },
+        { provide: LiveService, useValue: liveStub },
+        { provide: MatrixClientService, useValue: mxStub },
+        { provide: SessionService, useValue: { me: () => ({ id: 'u1' }), token: () => 'r:t' } },
+        { provide: UnreadService, useClass: UnreadService },
+      ],
+    });
+    svc = TestBed.inject(ChatService);
+  });
+
+  afterEach(() => localStorage.clear());
+
+  /** 测试统一走真实态(演示态由 MockApi 覆盖,另测) */
+  function realMode(): void {
+    (environment as { useMockData: boolean }).useMockData = false;
+  }
+
+  describe('open() 真实态编排', () => {
+    it('provision → connectSession → ensureRoom 顺序执行', async () => {
+      realMode();
+      const order: string[] = [];
+      apiStub.provisionMatrix.and.callFake(() => {
+        order.push('provision');
+        return Promise.resolve(PROV);
+      });
+      mxStub.connectSession.and.callFake(() => {
+        order.push('connect');
+        return Promise.resolve();
+      });
+      mxStub.ensureRoom.and.callFake(() => {
+        order.push('room');
+        return Promise.resolve('!room1:fmode.cn');
+      });
+
+      await svc.open('agent-node-lj-test');
+
+      expect(order).toEqual(['provision', 'connect', 'room']);
+      expect(svc.agentId()).toBe('agent-node-lj-test');
+      expect(svc.connection()).toBe('connected');
+    });
+
+    it('空房间 → state=empty(不是 error)', async () => {
+      realMode();
+      mxStub.timelineToMessages.and.returnValue([]);
+      await svc.open('agent-node-lj-test');
+      expect(svc.state()).toBe('empty');
+    });
+
+    it('有消息 → state=ready 且按 sender 区分 me/agent', async () => {
+      realMode();
+      mxStub.timelineToMessages.and.returnValue([
+        { roomId: 'r', eventId: '$1', sender: '@sr2wipsdyq:fmode.cn', body: '我发的', ts: 1, kind: 'text', live: false },
+        { roomId: 'r', eventId: '$2', sender: '@zgdcmgnqcd:fmode.cn', body: '它回的', ts: 2, kind: 'text', live: false },
+      ]);
+      await svc.open('agent-node-lj-test');
+
+      expect(svc.state()).toBe('ready');
+      expect(svc.messages().map((m) => m.sender)).toEqual(['me', 'agent']);
+      expect(svc.messages()[0].text).toBe('我发的');
+    });
+
+    it('数字生命不存在 → 报明确错误', async () => {
+      realMode();
+      liveStub.agentById.and.returnValue(undefined);
+      await svc.open('nope');
+      expect(svc.state()).toBe('error');
+      expect(svc.error()).toContain('不存在');
+    });
+  });
+
+  describe('失败分环节报错(FR-04:不得笼统)', () => {
+    it('provision 失败 → 文案含「FMODE 开通失败」', async () => {
+      realMode();
+      apiStub.provisionMatrix.and.returnValue(Promise.reject(new Error('SessionToken 无效')));
+      await svc.open('a');
+      expect(svc.error()).toContain('FMODE 开通失败');
+      expect(svc.error()).toContain('SessionToken 无效');
+    });
+
+    it('SDK 登录失败 → 文案含「Matrix 登录失败」', async () => {
+      realMode();
+      mxStub.connectSession.and.returnValue(Promise.reject(new Error('凭据不完整')));
+      await svc.open('a');
+      expect(svc.error()).toContain('Matrix 登录失败');
+    });
+
+    it('房间创建失败 → 文案含「房间创建失败」', async () => {
+      realMode();
+      mxStub.ensureRoom.and.returnValue(Promise.reject(new Error('403')));
+      await svc.open('a');
+      expect(svc.error()).toContain('房间创建失败');
+    });
+
+    it('失败时 connection=disconnected(不假装连着)', async () => {
+      realMode();
+      apiStub.provisionMatrix.and.returnValue(Promise.reject(new Error('x')));
+      await svc.open('a');
+      expect(svc.connection()).toBe('disconnected');
+    });
+  });
+
+  describe('seq 防串扰(切会话丢弃过期回包)', () => {
+    it('慢的旧请求返回时不覆盖新会话', async () => {
+      realMode();
+      let resolveSlow: (v: MatrixProvision) => void = () => undefined;
+      apiStub.provisionMatrix.and.callFake((id: string) =>
+        id === 'slow'
+          ? new Promise<MatrixProvision>((res) => (resolveSlow = res))
+          : Promise.resolve(PROV),
+      );
+      mxStub.ensureRoom.and.callFake(() => Promise.resolve('!room1:fmode.cn'));
+
+      const slowOpen = svc.open('slow'); // 悬而未决
+      await svc.open('fast'); // 后发先至,成为当前会话
+
+      resolveSlow(PROV); // 旧请求此刻才返回
+      await slowOpen;
+
+      // 当前会话仍是 fast,未被 slow 的迟到回包覆盖
+      expect(svc.agentId()).toBe('fast');
+    });
+  });
+
+  describe('close() 必须**不断开**会话级连接', () => {
+    it('close 后 sync 仍常驻(未读计数依赖)', async () => {
+      realMode();
+      await svc.open('agent-node-lj-test');
+      svc.close();
+
+      // 🔴 关键:close 不得调用 disconnect —— 否则离开会话就收不到消息
+      expect(mxStub.disconnect).not.toHaveBeenCalled();
+      expect(svc.agentId()).toBeNull();
+      expect(svc.messages()).toEqual([]);
+      expect(svc.connection()).toBe('disconnected'); // 仅 UI 状态复位
+    });
+
+    it('close 会解绑本房间的事件订阅(防串扰)', async () => {
+      realMode();
+      const unbindMsg = jasmine.createSpy('unbindMsg');
+      const unbindTyping = jasmine.createSpy('unbindTyping');
+      mxStub.onMessage.and.returnValue(unbindMsg);
+      mxStub.onTyping.and.returnValue(unbindTyping);
+
+      await svc.open('a');
+      svc.close();
+
+      expect(unbindMsg).toHaveBeenCalled();
+      expect(unbindTyping).toHaveBeenCalled();
+    });
+  });
+
+  describe('sendText(AC-04.2 / AC-04.8)', () => {
+    beforeEach(async () => {
+      realMode();
+      await svc.open('agent-node-lj-test');
+    });
+
+    it('乐观插入 → 调用 SDK 发送 → 标记 sent', async () => {
+      await svc.sendText('  你好  ');
+      const last = svc.messages().at(-1)!;
+      expect(last.text).toBe('你好'); // 已 trim
+      expect(last.state).toBe('sent');
+      expect(mxStub.sendText).toHaveBeenCalledWith('!room1:fmode.cn', '你好');
+    });
+
+    it('发送后清空草稿', async () => {
+      svc.setDraft('待发');
+      await svc.sendText('待发');
+      expect(svc.draft()).toBe('');
+    });
+
+    it('空文本不发送(也不插气泡)', async () => {
+      const before = svc.messages().length;
+      await svc.sendText('   ');
+      expect(svc.messages().length).toBe(before);
+      expect(mxStub.sendText).not.toHaveBeenCalled();
+    });
+
+    it('失败 → 留痕 failed 且带错误原因(不丢消息)', async () => {
+      mxStub.sendText.and.returnValue(Promise.reject(new Error('网络错误')));
+      await svc.sendText('会失败');
+
+      const last = svc.messages().at(-1)!;
+      expect(last.state).toBe('failed');
+      expect(last.error).toContain('网络错误');
+    });
+
+    it('retry 把 failed 消息重发并复位为 sent', async () => {
+      mxStub.sendText.and.returnValue(Promise.reject(new Error('临时故障')));
+      await svc.sendText('重试我');
+      const failed = svc.messages().at(-1)!;
+      expect(failed.state).toBe('failed');
+
+      mxStub.sendText.and.returnValue(Promise.resolve({ event_id: '$ok' }));
+      await svc.retry(failed.id);
+
+      const after = svc.messages().find((m) => m.id === failed.id)!;
+      expect(after.state).toBe('sent');
+      expect(after.error).toBeUndefined();
+    });
+
+    it('非 failed 状态的消息 retry 不做事', async () => {
+      await svc.sendText('已成功的');
+      const ok = svc.messages().at(-1)!;
+      mxStub.sendText.calls.reset();
+      await svc.retry(ok.id);
+      expect(mxStub.sendText).not.toHaveBeenCalled();
+    });
+  });
+
+  describe('paginate(AC-04.4 翻历史)', () => {
+    beforeEach(async () => {
+      realMode();
+      await svc.open('agent-node-lj-test');
+    });
+
+    it('分页后条数增长 → hasMoreHistory=true', async () => {
+      // 注:open() 已调用过一次 timelineToMessages(返回 []);
+      // paginate() 只再调用一次,故此处用 returnValue(单值)而非 returnValues。
+      mxStub.timelineToMessages.and.returnValue(
+        Array.from({ length: 5 }, (_, i) => ({
+          roomId: 'r',
+          eventId: `$h${i}`,
+          sender: '@zgdcmgnqcd:fmode.cn',
+          body: `历史${i}`,
+          ts: i,
+          kind: 'text' as const,
+          live: false,
+        })),
+      );
+      const grew = await svc.paginate();
+      expect(grew).toBeTrue();
+      expect(svc.messages().length).toBe(5);
+      expect(svc.hasMoreHistory()).toBeTrue();
+    });
+
+    it('条数未增长 → hasMoreHistory=false(到最早了,停止请求)', async () => {
+      mxStub.timelineToMessages.and.returnValue([]);
+      const grew = await svc.paginate();
+      expect(grew).toBeFalse();
+      expect(svc.hasMoreHistory()).toBeFalse();
+    });
+
+    it('🔴 防重入:并发调用只触发一次 SDK 分页', async () => {
+      let resolveIt: (v: boolean) => void = () => undefined;
+      mxStub.paginateBack.and.callFake(() => new Promise<boolean>((res) => (resolveIt = res)));
+
+      const a = svc.paginate();
+      const b = svc.paginate(); // 立刻重入
+      resolveIt(true);
+      await Promise.all([a, b]);
+
+      expect(mxStub.paginateBack.calls.count()).toBe(1);
+    });
+
+    it('paginating 在完成后复位', async () => {
+      await svc.paginate();
+      expect(svc.paginating()).toBeFalse();
+    });
+  });
+
+  describe('setDraft 触发 typing 通知', () => {
+    it('有内容 → typing=true,清空 → typing=false', async () => {
+      realMode();
+      await svc.open('a');
+      svc.setDraft('在输入');
+      expect(mxStub.setTyping).toHaveBeenCalledWith('!room1:fmode.cn', true);
+      svc.setDraft('');
+      expect(mxStub.setTyping).toHaveBeenCalledWith('!room1:fmode.cn', false);
+    });
+  });
+
+  describe('sendFiles(AC-04.3)', () => {
+    it('上传成功后追加文件卡片消息', async () => {
+      realMode();
+      await svc.open('a');
+      apiStub.uploadFile.and.returnValue(
+        Promise.resolve({ key: 'k', publicUrl: 'https://s3.fmode.cn/k' }),
+      );
+      const f = new File(['x'], 'doc.txt', { type: 'text/plain' });
+      await svc.sendFiles([f]);
+
+      const last = svc.messages().at(-1)!;
+      expect(last.file?.name).toBe('doc.txt');
+      expect(last.file?.url).toBe('https://s3.fmode.cn/k');
+      expect(svc.uploading()).toBeFalse();
+    });
+
+    it('上传失败 → 记录错误且不上传中', async () => {
+      realMode();
+      await svc.open('a');
+      apiStub.uploadFile.and.returnValue(Promise.reject(new Error('签名获取失败')));
+      await svc.sendFiles([new File(['x'], 'a.txt', { type: 'text/plain' })]);
+      expect(svc.error()).toContain('签名获取失败');
+      expect(svc.uploading()).toBeFalse();
+    });
+  });
+
+  describe('进入会话即已读(清未读计数)', () => {
+    it('open 成功后该 agent 未读归零', async () => {
+      realMode();
+      const unread = TestBed.inject(UnreadService);
+      // 先造出未读
+      unread.onIncoming('agent-node-lj-test', 1000, true);
+      unread.onIncoming('agent-node-lj-test', 2000, false);
+      expect(unread.countOf('agent-node-lj-test')).toBe(1);
+
+      await svc.open('agent-node-lj-test');
+      expect(unread.countOf('agent-node-lj-test')).toBe(0);
+    });
+  });
+});

+ 311 - 20
src/app/core/chat.service.ts

@@ -12,6 +12,8 @@ import { DEMO_MESSAGES } from './api/demo-data';
 import { environment } from '../../environments/environment';
 import { LiveService } from './live.service';
 import { SessionService } from './session.service';
+import { UnreadService } from './unread.service';
+import { MxMessage, MatrixClientService } from './matrix-client.service';
 
 export type ConnectionState = 'connecting' | 'connected' | 'disconnected';
 
@@ -20,15 +22,27 @@ export type ConnectionState = 'connecting' | 'connected' | 'disconnected';
  *
  * 职责:provision → 连接 → 房间 → 收发 → 分页,对应 SPEC FR-04 各条 AC。
  *
- * ⚠️ **本文件不直接使用 matrix-js-sdk**:SDK 调用收敛在 `MatrixClientService`
- *    (懒加载 chunk,不进主包 —— NFR-02)。本服务负责状态编排与消息模型归一。
- *    Matrix 客户端在演示模式下不实例化。
+ * ## 两条分支(由 `environment.useMockData` 单开关决定)
+ *
+ * - **演示态**:用 `DEMO_MESSAGES` 填充,发送走 `simulateSend()`(不产生真实请求)
+ * - **真实态**:`provisionMatrix` 取服务端签发凭据 → `MatrixClientService.connect()`
+ *   → `ensureRoom()` → `/sync` 实时收发
+ *
+ * ⚠️ **本文件不直接 import `matrix-js-sdk`**:SDK 调用收敛在 `MatrixClientService`
+ *    (懒加载 chunk,不进主包 —— NFR-02)。本服务只做状态编排与消息模型归一。
+ *
+ * ## 凭据来源(黄金规则 9)
+ *
+ * 真实态下 accessToken **一律来自 provision 云函数**(服务端签发);
+ * 前端不派生任何密码,也不持有任何长期密钥。
  */
 @Injectable({ providedIn: 'root' })
 export class ChatService {
   private readonly api = inject(FmodeApi);
   private readonly live = inject(LiveService);
   private readonly session = inject(SessionService);
+  private readonly mx = inject(MatrixClientService);
+  private readonly unread = inject(UnreadService);
 
   private readonly _agentId = signal<string | null>(null);
   private readonly _messages = signal<Message[]>([]);
@@ -39,6 +53,11 @@ export class ChatService {
   private readonly _uploading = signal(false);
   private readonly _uploadPct = signal(0);
   private readonly _draft = signal('');
+  private readonly _peerTyping = signal(false);
+  /** 正在加载更早历史(防重入,AC-04.4) */
+  private readonly _paginating = signal(false);
+  /** 是否可能还有更早历史 */
+  private readonly _hasMoreHistory = signal(false);
 
   readonly agentId = this._agentId.asReadonly();
   readonly messages = this._messages.asReadonly();
@@ -49,6 +68,12 @@ export class ChatService {
   readonly uploading = this._uploading.asReadonly();
   readonly uploadPct = this._uploadPct.asReadonly();
   readonly draft = this._draft.asReadonly();
+  /** 对方正在输入(真实态由 typing 事件驱动) */
+  readonly peerTyping = this._peerTyping.asReadonly();
+  /** 正在加载更早历史 */
+  readonly paginating = this._paginating.asReadonly();
+  /** 是否可能还有更早历史(真实态:上次分页仍有返回) */
+  readonly hasMoreHistory = this._hasMoreHistory.asReadonly();
 
   readonly agent = computed<Agent | undefined>(() => {
     const id = this._agentId();
@@ -58,6 +83,24 @@ export class ChatService {
   /** 序号防串扰(存量 seq 模式):切会话时丢弃过期回包 */
   private seq = 0;
   private provision: MatrixProvision | null = null;
+  /** 真实态下的当前房间 id */
+  private roomId: string | null = null;
+  /** 事件订阅解绑函数(切会话必须调用,防串扰) */
+  private unbindMessage: (() => void) | null = null;
+  private unbindTyping: (() => void) | null = null;
+  /** 全局未读监听解绑(仅登出/销毁时调用) */
+  private unbindUnread: (() => void) | null = null;
+  /**
+   * agentId → roomId 映射(未读归属反查)。
+   *
+   * 🔴 **持久化**(localStorage,按用户隔离):未读计数需要「某房间的新消息属于哪个
+   *    Agent」,而 roomId 只有在**打开过该会话**后才由 provision 返回。
+   *    不持久化的话,每次刷新都会丢失映射 → 只有本次打开过的会话能计未读。
+   *
+   * ⚠️ **已知限制**:从未打开过的 Agent 会话,其消息不计未读
+   *    (因为拿不到 roomId ↔ agentId 的对应关系)。属可接受折损,已在文档披露。
+   */
+  private readonly roomAgentMap = new Map<string, string>();
 
   get provisionInfo(): MatrixProvision | null {
     return this.provision;
@@ -68,52 +111,214 @@ export class ChatService {
     if (this._agentId() === agentId && this._state() === 'ready') return;
     const my = ++this.seq;
 
+    this.teardownSubscriptions();
     this._agentId.set(agentId);
     this._state.set('loading');
     this._error.set(null);
     this._messages.set([]);
     this._draft.set('');
+    this._peerTyping.set(false);
 
     try {
       await this.live.ensureLoaded();
-      const agent = this.live.agentById(agentId);
       if (my !== this.seq) return;
 
+      const agent = this.live.agentById(agentId);
       if (!agent) {
         this._state.set('error');
         this._error.set('数字生命不存在');
         return;
       }
 
-      // 未接入的数字生命不应能进入会话(路由层已拦截,此处兜底)
       this._connection.set('connecting');
-      await this.api.provisionMatrix(agentId);
-      if (my !== this.seq) return;
 
-      this._connection.set('connected');
-      this._messages.set(this.seedMessages(agentId));
-      this._state.set(this._messages().length === 0 ? 'empty' : 'ready');
+      if (environment.useMockData) {
+        // ---- 演示态 ----
+        this.provision = await this.api.provisionMatrix(agentId);
+        if (my !== this.seq) return;
+        this._connection.set('connected');
+        this._messages.set(this.seedMessages(agentId));
+        this._state.set(this._messages().length === 0 ? 'empty' : 'ready');
+        return;
+      }
+
+      // ---- 真实态 ----
+      await this.openReal(agentId, my);
     } catch (e) {
       if (my !== this.seq) return;
       this._connection.set('disconnected');
       this._state.set('error');
-      this._error.set(e instanceof Error ? e.message : '会话连接失败');
+      this._error.set(msgOf(e));
     }
   }
 
+  /**
+   * 真实态开启流程。
+   *
+   * 失败时**分环节报错**(SPEC FR-04:不得用笼统文案掩盖具体环节):
+   * FMODE 开通 / Matrix 登录 / 房间创建。
+   */
+  private async openReal(agentId: string, my: number): Promise<void> {
+    // ① provision:拿服务端签发凭据(含绑定表命中的 roomId)
+    let prov: MatrixProvision;
+    try {
+      prov = await this.api.provisionMatrix(agentId);
+    } catch (e) {
+      if (my !== this.seq) return;
+      throw new Error(`FMODE 开通失败:${msgOf(e)}`);
+    }
+    if (my !== this.seq) return;
+    this.provision = prov;
+
+    // ② 会话级连接(幂等;同身份重复进入不打断 sync —— 未读计数依赖它常驻)
+    try {
+      await this.mx.connectSession(prov.accessToken, prov.userMxId ?? '', prov.deviceId);
+    } catch (e) {
+      if (my !== this.seq) return;
+      throw new Error(`Matrix 登录失败:${msgOf(e)}`);
+    }
+    if (my !== this.seq) return;
+
+    // ③ 房间(绑定表 roomId 优先)
+    const agentMxId = prov.agentMxId ?? '';
+    if (!agentMxId) throw new Error('房间创建失败:服务端未返回 Agent 身份');
+
+    let rid: string;
+    try {
+      rid = await this.mx.ensureRoom(agentMxId, {
+        roomId: prov.roomId,
+        agentName: this.live.agentById(agentId)?.name,
+      });
+    } catch (e) {
+      if (my !== this.seq) return;
+      throw new Error(`房间创建失败:${msgOf(e)}`);
+    }
+    if (my !== this.seq) return;
+    this.roomId = rid;
+    // 记录 agentId ↔ roomId 映射并持久化(未读计数按房间归属到 Agent 时需要)
+    this.roomAgentMap.set(agentId, rid);
+    this.persistRoomMap();
+
+    // ④ 订阅实时事件(先订阅再读 timeline,避免中间漏事件)
+    const myUserId = prov.userMxId ?? '';
+    this.unbindMessage = this.mx.onMessage(rid, (m) => {
+      if (my !== this.seq) return;
+      this.appendIncoming(m, myUserId);
+    });
+    this.unbindTyping = this.mx.onTyping(rid, (typing) => {
+      if (my !== this.seq) return;
+      this._peerTyping.set(typing);
+    });
+
+    // ⑤ 读当前 timeline
+    this._messages.set(this.mapTimeline(this.mx.timelineToMessages(rid), myUserId));
+
+    // ⑥ 连接状态由 SDK 同步事件驱动
+    this._connection.set(this.mx.syncState() === 'connected' ? 'connected' : 'connecting');
+    this._state.set(this._messages().length === 0 ? 'empty' : 'ready');
+    // timeline pagination token 是否存在 = 更早可能有历史
+    this._hasMoreHistory.set(this.mx.paginationToken(rid) != null);
+
+    // ⑦ 进入会话 = 已读:基线推进到最新消息时间(清未读计数)
+    const latest = this._messages().reduce((max, m) => {
+      const t = Date.parse(m.ts);
+      return Number.isFinite(t) && t > max ? t : max;
+    }, 0);
+    this.unread.markRead(agentId, latest || Date.now());
+    this.ensureUnreadListener();
+  }
+
+  /**
+   * 全局未读监听:Agent 消息到达且**当前不在该会话**时 +1。
+   *
+   * 只注册一次(幂等);由 `MatrixClientService` 的全局消息流多路复用,
+   * 故不会随会话切换重复挂监听。
+   *
+   * 由 `MatrixSessionService.bootstrap()` 在**建连前**调用 —— 保证连接建立后
+   * 到用户首次进入会话之间的消息也会被计入未读。
+   */
+  ensureUnreadListener(): void {
+    if (this.unbindUnread) return;
+    this.unbindUnread = this.mx.onAnyMessage((m) => {
+      const myUserId = this.provision?.userMxId ?? '';
+      // 只计 Agent 发来的
+      if (myUserId && m.sender === myUserId) return;
+      const agentId = this.agentIdByRoom(m.roomId);
+      if (!agentId) return;
+      // 正在看的会话 → 直接推进已读基线,不产生未读
+      if (agentId === this._agentId()) {
+        this.unread.markRead(agentId, m.ts);
+        return;
+      }
+      // 判定交给 UnreadService:初次同步回填阶段只推基线、不计数
+      this.unread.onIncoming(agentId, m.ts, !this.mx.hasInitialSync);
+    });
+  }
+
+  /** roomId → agentId(用已记录的会话映射反查) */
+  private agentIdByRoom(roomId: string): string | undefined {
+    for (const [agentId, rid] of this.roomAgentMap) {
+      if (rid === roomId) return agentId;
+    }
+    return undefined;
+  }
+
+  // ---------- agentId ↔ roomId 映射持久化 ----------
+
+  private roomMapKey(): string {
+    const uid = this.session.me()?.id ?? 'anon';
+    return `fmode_room_map_${uid}`;
+  }
+
+  private persistRoomMap(): void {
+    try {
+      localStorage.setItem(this.roomMapKey(), JSON.stringify([...this.roomAgentMap]));
+    } catch {
+      /* 隐私模式忽略 */
+    }
+  }
+
+  /** 从 localStorage 载入映射(Shell 启动时调用一次) */
+  loadRoomMap(): void {
+    try {
+      const raw = localStorage.getItem(this.roomMapKey());
+      if (!raw) return;
+      for (const [agentId, rid] of JSON.parse(raw) as [string, string][]) {
+        if (agentId && rid) this.roomAgentMap.set(agentId, rid);
+      }
+    } catch {
+      /* 忽略 */
+    }
+  }
+
+  /**
+   * 关闭会话(离开聊天页)。
+   *
+   * 🔴 **不断开 Matrix 会话级连接** —— sync 常驻,否则离开后收不到消息,
+   *    未读计数永远无法累加。只解绑本房间的事件订阅。
+   */
   close(): void {
     this.seq++;
+    this.teardownSubscriptions();
     this.provision = null;
+    this.roomId = null;
     this._agentId.set(null);
     this._messages.set([]);
     this._state.set('idle');
     this._error.set(null);
     this._connection.set('disconnected');
     this._draft.set('');
+    this._peerTyping.set(false);
+    this._paginating.set(false);
+    this._hasMoreHistory.set(false);
   }
 
   setDraft(text: string): void {
     this._draft.set(text);
+    // 真实态下发 typing 通知(失败非致命)
+    if (this.roomId && !environment.useMockData) {
+      void this.mx.setTyping(this.roomId, text.length > 0);
+    }
   }
 
   /** 发送文本(AC-04.2);失败留痕可重试(AC-04.8) */
@@ -138,11 +343,14 @@ export class ChatService {
     this._sending.set(true);
 
     try {
-      // 真实实现:把文本交给 MatrixClientService 发送到房间
-      await this.simulateSend();
+      if (environment.useMockData) {
+        await this.simulateSend();
+      } else {
+        await this.sendReal(body);
+      }
       this.markSent(msg.id);
     } catch (e) {
-      this.markFailed(msg.id, e instanceof Error ? e.message : '发送失败');
+      this.markFailed(msg.id, msgOf(e));
     } finally {
       this._sending.set(false);
     }
@@ -181,13 +389,35 @@ export class ChatService {
         ]);
       }
     } catch (e) {
-      this._error.set(e instanceof Error ? e.message : '文件上传失败');
+      this._error.set(msgOf(e));
     } finally {
       this._uploading.set(false);
       this._uploadPct.set(0);
     }
   }
 
+  /** 向上翻历史(AC-04.4);返回是否还有更早的消息 */
+  async paginate(): Promise<boolean> {
+    if (environment.useMockData || !this.roomId) return false;
+    // 防重入:滚动事件高频触发,加载中直接忽略,避免重复分页
+    if (this._paginating()) return true;
+    this._paginating.set(true);
+    const my = this.seq;
+    try {
+      const before = this._messages().length;
+      const ok = await this.mx.paginateBack(this.roomId, 30);
+      if (my !== this.seq) return false;
+      const myUserId = this.provision?.userMxId ?? '';
+      const next = this.mapTimeline(this.mx.timelineToMessages(this.roomId), myUserId);
+      this._messages.set(next);
+      // 条数未增长 = 已到最早,停止继续请求
+      this._hasMoreHistory.set(ok && next.length > before);
+      return this._hasMoreHistory();
+    } finally {
+      if (my === this.seq) this._paginating.set(false);
+    }
+  }
+
   /** 重试失败消息(AC-04.8) */
   async retry(messageId: string): Promise<void> {
     const msg = this._messages().find((m) => m.id === messageId);
@@ -196,18 +426,72 @@ export class ChatService {
       list.map((m) => (m.id === messageId ? { ...m, state: 'sending', error: undefined } : m)),
     );
     try {
-      await this.simulateSend();
+      if (environment.useMockData) {
+        await this.simulateSend();
+      } else if (msg.text) {
+        await this.sendReal(msg.text);
+      }
       this.markSent(messageId);
     } catch (e) {
-      this.markFailed(messageId, e instanceof Error ? e.message : '发送失败');
+      this.markFailed(messageId, msgOf(e));
     }
   }
 
-  /** 模拟断线(演示控制台用;真实态由 MatrixClientService 的同步事件驱动) */
+  /** 模拟断线(演示控制台用;真实态由 SDK 同步事件驱动) */
   setConnection(state: ConnectionState): void {
     this._connection.set(state);
   }
 
+  // ---------- 内部 ----------
+
+  /** 发真实消息(真实态由 MatrixClientService 落地到房间) */
+  private async sendReal(text: string): Promise<void> {
+    if (!this.roomId) throw new Error('会话未就绪,请稍后重试');
+    await this.mx.sendText(this.roomId, text);
+  }
+
+  /** 归一 timeline → 领域消息 */
+  private mapTimeline(list: MxMessage[], myUserId: string): Message[] {
+    const cid = this._agentId() ?? '';
+    return list.map((m) => {
+      const file = MatrixClientService.parseFileCard(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,
+        file: file
+          ? {
+              kind: 'file' as const,
+              name: file.name,
+              size: file.size,
+              mimeType: file.mimeType,
+              url: file.url,
+            }
+          : undefined,
+        ts: new Date(m.ts).toISOString(),
+        state: 'sent' as const,
+      };
+    });
+  }
+
+  /** 追加一条新到消息(按 eventId 去重) */
+  private appendIncoming(m: MxMessage, myUserId: string): void {
+    if (!this._agentId()) return;
+    if (m.eventId && this._messages().some((x) => x.id === m.eventId)) return;
+    const [mapped] = this.mapTimeline([m], myUserId);
+    this._messages.update((list) => [...list, mapped]);
+    if (this._state() === 'empty') this._state.set('ready');
+  }
+
+  private teardownSubscriptions(): void {
+    this.unbindMessage?.();
+    this.unbindTyping?.();
+    this.unbindMessage = null;
+    this.unbindTyping = null;
+  }
+
   private markSent(id: string): void {
     this._messages.update((list) =>
       list.map((m) => (m.id === id ? { ...m, state: 'sent', error: undefined } : m)),
@@ -215,11 +499,13 @@ export class ChatService {
   }
 
   private markFailed(id: string, error: string): void {
-    this._messages.update((list) => list.map((m) => (m.id === id ? { ...m, state: 'failed', error } : m)));
+    this._messages.update((list) =>
+      list.map((m) => (m.id === id ? { ...m, state: 'failed', error } : m)),
+    );
   }
 
   private async simulateSend(): Promise<void> {
-    if (!environment.useMockData) return; // 真实模式由 Matrix 客户端负责
+    // 仅演示态使用
     if (this._connection() !== 'connected') {
       throw new Error('连接已断开,请稍后重试');
     }
@@ -257,3 +543,8 @@ export class ChatService {
     return y.toISOString();
   }
 }
+
+/** 统一错误文案提取 */
+function msgOf(e: unknown): string {
+  return e instanceof Error && e.message ? e.message : '未知错误';
+}

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

@@ -80,6 +80,16 @@ export class DeliverablesService {
     await this.load();
   }
 
+  /**
+   * 强制重新拉取(手动「刷新」按钮用)。
+   *
+   * 与 `ensureLoaded` 的区别:**跳过缓存直接加载** —— 用户点刷新就是要拿最新数据。
+   * 事件页的刷新依赖本方法(事件派生自成果)。
+   */
+  async refresh(): Promise<void> {
+    await this.load();
+  }
+
   async load(): Promise<void> {
     this._state.set('loading');
     this._error.set(null);

+ 225 - 0
src/app/core/device.service.spec.ts

@@ -0,0 +1,225 @@
+import { DeviceService, NARROW_PX } from './device.service';
+
+/**
+ * 端判定与路由归一单测
+ *
+ * 依据 SPEC FR-01(端分流)、FR-16(旧版替换迁移)。
+ * 🔴 `normalize()` 是**旧路径重定向的唯一事实源**(AC-16.2),
+ *    存量曾因 6 处重复实现导致重定向不一致,故必须有回归保护。
+ *
+ * ⚠️ `DeviceService` 在**构造时**读取 UA/视口并一次性确定端(ADR-004:
+ *    端判定不随窗口缩放变化),故测试必须 `new` 出独立实例,
+ *    且环境需在构造**之前**就位。
+ */
+describe('DeviceService', () => {
+  const origUA = Object.getOwnPropertyDescriptor(navigator, 'userAgent');
+  const origMatch = window.matchMedia;
+  const origWidth = Object.getOwnPropertyDescriptor(window, 'innerWidth');
+
+  afterEach(() => {
+    if (origUA) Object.defineProperty(navigator, 'userAgent', origUA);
+    if (origWidth) Object.defineProperty(window, 'innerWidth', origWidth);
+    window.matchMedia = origMatch;
+    localStorage.clear();
+  });
+
+  beforeEach(() => localStorage.clear());
+
+  /** 在指定 UA / 视口下构造实例(环境在构造前生效,构造后即还原) */
+  function make(
+    ua: string,
+    opts?: { coarse?: boolean; width?: number; keepStorage?: boolean },
+  ): DeviceService {
+    // 端偏好会覆盖自动判定 → 默认构造前清掉,避免上个用例的 force() 泄漏
+    if (!opts?.keepStorage) localStorage.clear();
+    Object.defineProperty(navigator, 'userAgent', { value: ua, configurable: true });
+    Object.defineProperty(window, 'innerWidth', { value: opts?.width ?? 1440, configurable: true });
+    window.matchMedia = ((q: string) =>
+      ({
+        matches: q.includes('coarse') ? !!opts?.coarse : false,
+        media: q,
+        addEventListener: () => undefined,
+        removeEventListener: () => undefined,
+      }) as unknown as MediaQueryList) as typeof window.matchMedia;
+
+    const svc = new DeviceService();
+
+    // 还原环境,避免污染后续用例
+    if (origUA) Object.defineProperty(navigator, 'userAgent', origUA);
+    if (origWidth) Object.defineProperty(window, 'innerWidth', origWidth);
+    window.matchMedia = origMatch;
+    return svc;
+  }
+
+  const DESKTOP_UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)';
+  const IPHONE_UA = 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X)';
+
+  describe('normalize(旧路径 → 当前端路径,FR-16 唯一事实源)', () => {
+    it('/messages 与 /tabs 按端落点(桌面进会话、移动进消息列表)', () => {
+      expect(make(DESKTOP_UA).normalize('/messages')).toBe('/desktop/chat');
+      expect(make(DESKTOP_UA).normalize('/tabs')).toBe('/desktop/chat');
+      expect(make(IPHONE_UA, { width: 390 }).normalize('/messages')).toBe('/mobile/messages');
+      expect(make(IPHONE_UA, { width: 390 }).normalize('/tabs')).toBe('/mobile/messages');
+    });
+
+    it('/chat/:id 保留 id 并加端前缀', () => {
+      expect(make(DESKTOP_UA).normalize('/chat/agent-node-xinting')).toBe(
+        '/desktop/chat/agent-node-xinting',
+      );
+      expect(make(IPHONE_UA, { width: 390 }).normalize('/chat/agent-node-xinting')).toBe(
+        '/mobile/chat/agent-node-xinting',
+      );
+    });
+
+    it('/chat 无 id 时落端会话页', () => {
+      expect(make(DESKTOP_UA).normalize('/chat')).toBe('/desktop/chat');
+      expect(make(IPHONE_UA, { width: 390 }).normalize('/chat')).toBe('/mobile/chat');
+    });
+
+    it('/agent/:id 保留 id', () => {
+      expect(make(DESKTOP_UA).normalize('/agent/a1')).toBe('/desktop/agent/a1');
+    });
+
+    it('/call/:agentId 落详情页(FR-11 裁决:语音暂不实现,禁建通话路由)', () => {
+      expect(make(DESKTOP_UA).normalize('/call/agent-a')).toBe('/desktop/agent/agent-a');
+      expect(make(IPHONE_UA, { width: 390 }).normalize('/call/agent-a')).toBe(
+        '/mobile/agent/agent-a',
+      );
+    });
+
+    it('简单旧路径按端加前缀(含 tasks/home)', () => {
+      const mob = make(IPHONE_UA, { width: 390 });
+      for (const p of [
+        'workbench',
+        'deliverables',
+        'mine',
+        'contacts',
+        'projects',
+        'events',
+        'home',
+        'tasks',
+      ]) {
+        expect(mob.normalize(`/${p}`)).toBe(`/mobile/${p}`);
+      }
+    });
+
+    it('已带端前缀的路径原样返回(幂等)', () => {
+      const svc = make(DESKTOP_UA);
+      expect(svc.normalize('/desktop/chat')).toBe('/desktop/chat');
+      expect(svc.normalize('/mobile/home')).toBe('/mobile/home');
+    });
+
+    it('未知路径 / 空值回落到端默认页', () => {
+      expect(make(DESKTOP_UA).normalize('/unknown-thing')).toBe('/desktop/chat');
+      expect(make(IPHONE_UA, { width: 390 }).normalize('/unknown-thing')).toBe('/mobile/home');
+      expect(make(DESKTOP_UA).normalize('')).toBe('/desktop/chat');
+    });
+
+    it('查询串被保留(登录回跳等场景需要)', () => {
+      expect(make(DESKTOP_UA).normalize('/chat/abc?x=1')).toBe('/desktop/chat/abc?x=1');
+      expect(make(IPHONE_UA, { width: 390 }).normalize('/messages?tab=unread')).toBe(
+        '/mobile/messages?tab=unread',
+      );
+    });
+  });
+
+  describe('detect(UA / 视口判定)', () => {
+    it('iPhone UA → mobile', () => {
+      expect(make(IPHONE_UA, { width: 390 }).mode()).toBe('mobile');
+    });
+
+    it('Android 手机 UA(含 Mobile)→ mobile', () => {
+      expect(
+        make('Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 Mobile').mode(),
+      ).toBe('mobile');
+    });
+
+    it('Android 平板(无 Mobile 标记)→ desktop', () => {
+      expect(make('Mozilla/5.0 (Linux; Android 14; SM-X700) AppleWebKit/537.36').mode()).toBe(
+        'desktop',
+      );
+    });
+
+    it('iPad UA → desktop', () => {
+      expect(make('Mozilla/5.0 (iPad; CPU OS 17_0 like Mac OS X)').mode()).toBe('desktop');
+    });
+
+    it('桌面 UA → desktop', () => {
+      expect(make(DESKTOP_UA).mode()).toBe('desktop');
+    });
+
+    it('粗指针 + 窄视口 → mobile', () => {
+      expect(
+        make('Mozilla/5.0 (X11; Linux x86_64)', { coarse: true, width: NARROW_PX - 1 }).mode(),
+      ).toBe('mobile');
+    });
+
+    it('粗指针 + 宽视口 → desktop', () => {
+      expect(
+        make('Mozilla/5.0 (X11; Linux x86_64)', { coarse: true, width: 1440 }).mode(),
+      ).toBe('desktop');
+    });
+  });
+
+  describe('端切换与恢复自动识别(FR-10 AC-10.1)', () => {
+    it('force 写偏好并覆盖自动判定;normalize 跟随', () => {
+      const svc = make(DESKTOP_UA);
+      expect(svc.mode()).toBe('desktop');
+      svc.force('mobile');
+      expect(svc.mode()).toBe('mobile');
+      expect(svc.normalize('/chat/a')).toBe('/mobile/chat/a');
+    });
+
+    it('resetToAuto 清除偏好并回到自动判定', () => {
+      const svc = make(DESKTOP_UA);
+      svc.force('mobile');
+      expect(svc.mode()).toBe('mobile');
+      expect(svc.resetToAuto()).toBe('desktop');
+    });
+
+    it('偏好键名固定为 fmode_ui_mode_override(FR-16 迁移兼容)', () => {
+      const svc = make(DESKTOP_UA);
+      svc.force('mobile');
+      expect(localStorage.getItem('fmode_ui_mode_override')).toBe('mobile');
+      svc.resetToAuto();
+      expect(localStorage.getItem('fmode_ui_mode_override')).toBeNull();
+    });
+
+    it('旧键能正确读取(AC-16.1:不报错、不串端)', () => {
+      localStorage.setItem('fmode_ui_mode_override', 'mobile');
+      const svc = make(DESKTOP_UA, { keepStorage: true });
+      expect(svc.mode()).toBe('mobile');
+      expect(svc.isForced()).toBe(true);
+    });
+
+    it('非法偏好值被安全忽略(回落自动判定)', () => {
+      localStorage.setItem('fmode_ui_mode_override', 'garbage');
+      expect(make(DESKTOP_UA).mode()).toBe('desktop');
+    });
+  });
+
+  describe('entryPath(AC-17.1 两端默认落地页)', () => {
+    it('桌面落 /desktop/chat、移动落 /mobile/home', () => {
+      expect(make(DESKTOP_UA).entryPath()).toBe('/desktop/chat');
+      expect(make(IPHONE_UA, { width: 390 }).entryPath()).toBe('/mobile/home');
+    });
+  });
+
+  describe('path(逻辑路径 → 带端前缀)', () => {
+    it('按当前端拼前缀', () => {
+      expect(make(DESKTOP_UA).path('/chat/a')).toBe('/desktop/chat/a');
+      expect(make(IPHONE_UA, { width: 390 }).path('/chat/a')).toBe('/mobile/chat/a');
+    });
+
+    it('🔴 回归:不得重复拼端前缀(normalize 返回的已带前缀)', () => {
+      // 曾出现 /desktop/desktop/chat/a —— normalize 对旧路径已加前缀,
+      // path() 再拼一次导致双前缀
+      const svc = make(DESKTOP_UA);
+      expect(svc.path('/chat/a')).not.toContain('/desktop/desktop');
+      expect(svc.path('/chat/a')).toBe('/desktop/chat/a');
+      expect(svc.path('/desktop/chat/a')).toBe('/desktop/chat/a');
+      expect(svc.path('/messages')).toBe('/desktop/chat');
+      expect(svc.path('/messages')).not.toContain('/desktop/desktop');
+    });
+  });
+});

+ 24 - 7
src/app/core/device.service.ts

@@ -18,8 +18,14 @@ export const NARROW_PX = 768;
  */
 @Injectable({ providedIn: 'root' })
 export class DeviceService {
-  /** 当前端(可被手动覆盖) */
-  private readonly _mode = signal<UiMode>(this.detect());
+  /**
+   * 当前端。
+   *
+   * 🔴 **优先读手动偏好**(`fmode_ui_mode_override`),无偏好才自动识别。
+   *    只取 `detect()` 会导致「手动切端刷新即丢失」——手机 UA 用户切到桌面端后,
+   *    刷新又被打回移动端。偏好持久化的意义就在于跨刷新保持(FR-10 AC-10.1)。
+   */
+  private readonly _mode = signal<UiMode>(this.readOverride() ?? this.detect());
   readonly mode = this._mode.asReadonly();
 
   /** 是否处于手动强制(覆盖了自动识别) */
@@ -83,9 +89,15 @@ export class DeviceService {
     return { mode: next, path: next === 'mobile' ? '/mobile/home' : '/desktop/chat' };
   }
 
-  /** 生成当前端下的绝对路径:`path('/chat/x')` → `/desktop/chat/x` */
+  /**
+   * 生成当前端下的绝对路径:`path('/chat/x')` → `/desktop/chat/x`
+   *
+   * 🔴 `normalize()` 对「旧路径」返回**已带端前缀**的路径,
+   *    故此处必须先判重,否则 `/chat/x` 会被拼成 `/desktop/desktop/chat/x`。
+   */
   path(logical: string): string {
-    return `/${this._mode()}${this.normalize(logical)}`;
+    const n = this.normalize(logical);
+    return n.startsWith('/desktop') || n.startsWith('/mobile') ? n : `/${this._mode()}${n}`;
   }
 
   /**
@@ -138,12 +150,17 @@ export class DeviceService {
     return id ? `/${this._mode()}${base}/${id}${qs}` : `/${this._mode()}${base}${qs}`;
   }
 
-  private hasOverride(): boolean {
+  /** 读取手动端偏好;无有效值返回 null */
+  private readOverride(): UiMode | null {
     try {
       const v = localStorage.getItem(MODE_KEY);
-      return v === 'desktop' || v === 'mobile';
+      return v === 'desktop' || v === 'mobile' ? v : null;
     } catch {
-      return false;
+      return null;
     }
   }
+
+  private hasOverride(): boolean {
+    return this.readOverride() !== null;
+  }
 }

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

@@ -0,0 +1,115 @@
+import {
+  fmtBalance,
+  fmtPercent,
+  fmtSize,
+  fmtTokens,
+  isMatrixReady,
+  normProgress,
+  usageOf,
+} from './format';
+import { Agent } from './api/models';
+
+/**
+ * 纯函数单测 —— 格式化与派生口径
+ *
+ * 依据 SPEC NFR-06(可测)与《数据访问层设计.md》§五(格式化唯一实现)。
+ * 这些口径曾在存量项目里出现 4 份重复实现,故必须有回归保护。
+ */
+describe('format 纯函数', () => {
+  describe('fmtSize', () => {
+    it('按 1024 进制换算并保留 1 位小数', () => {
+      expect(fmtSize(0)).toBe('0 B');
+      expect(fmtSize(512)).toBe('512 B');
+      expect(fmtSize(1024)).toBe('1.0 KB');
+      expect(fmtSize(1536)).toBe('1.5 KB');
+      expect(fmtSize(1024 * 1024)).toBe('1.0 MB');
+    });
+
+    it('空值返回占位符', () => {
+      expect(fmtSize(undefined)).toBe('—');
+    });
+  });
+
+  describe('fmtTokens', () => {
+    it('按 1000 进制分档(k / M)', () => {
+      expect(fmtTokens(0)).toBe('0');
+      expect(fmtTokens(999)).toBe('999');
+      expect(fmtTokens(1500)).toBe('1.5k');
+      expect(fmtTokens(1_500_000)).toBe('1.5M');
+    });
+
+    it('空值返回占位符', () => {
+      expect(fmtTokens(undefined)).toBe('—');
+    });
+  });
+
+  describe('fmtBalance(FR-07 AC-07.4:余额转人民币)', () => {
+    it('有记账时显示 ¥ xx.xx(两位小数)', () => {
+      expect(fmtBalance({ remainCny: 17842.31 })).toBe('¥ 17842.31');
+      expect(fmtBalance({ remainCny: 0 })).toBe('¥ 0.00');
+      expect(fmtBalance({ remainCny: 86.4 })).toBe('¥ 86.40');
+    });
+
+    it('无记账时显示占位符,不伪造 ¥0', () => {
+      expect(fmtBalance(undefined)).toBe('—');
+      expect(fmtBalance({})).toBe('—');
+    });
+  });
+
+  describe('normProgress(契约 §六-5:百分比 or 比例语义推断)', () => {
+    it('0<x<=1 视为小数比例,乘 100', () => {
+      expect(normProgress(0.5)).toBe(50);
+      expect(normProgress(0.25)).toBe(25);
+      expect(normProgress(1)).toBe(100);
+    });
+
+    it('>1 视为整数百分比,原样返回', () => {
+      expect(normProgress(50)).toBe(50);
+      expect(normProgress(100)).toBe(100);
+    });
+
+    it('越界被夹到 [0,100](防脏数据撑破进度条)', () => {
+      expect(normProgress(150)).toBe(100);
+      expect(normProgress(-5)).toBe(0);
+    });
+
+    it('0 与空值:0 返回 0,空值返回 undefined', () => {
+      expect(normProgress(0)).toBe(0);
+      expect(normProgress(undefined)).toBeUndefined();
+      expect(normProgress(NaN)).toBeUndefined();
+    });
+  });
+
+  describe('fmtPercent', () => {
+    it('归一后带百分号', () => {
+      expect(fmtPercent(0.5)).toBe('50%');
+      expect(fmtPercent(72)).toBe('72%');
+    });
+
+    it('空值返回占位符', () => {
+      expect(fmtPercent(undefined)).toBe('—');
+    });
+  });
+
+  describe('usageOf / isMatrixReady(映射口径)', () => {
+    it('usageOf 容忍缺失的 usage 字段', () => {
+      expect(usageOf({ id: 'a', name: 'a', status: 'idle', matrixReady: false, unread: 0 })).toEqual(
+        {},
+      );
+    });
+
+    it('isMatrixReady 采用**账号级**口径(matrixConfig.userId),不只看 homeserver 可达', () => {
+      const base = { id: 'a', name: 'a', status: 'idle', unread: 0 } as unknown as Agent;
+      // 未接入
+      expect(isMatrixReady({ ...base, matrixReady: false })).toBe(false);
+      // 仅 channels.matrix=true 不算就绪(homeserver 可达 ≠ 该 Agent 有账号)
+      expect(isMatrixReady({ ...base, channels: { matrix: true } })).toBe(false);
+      // 账号级就绪:matrixConfig.userId 存在
+      expect(
+        isMatrixReady({ ...base, channels: { matrixConfig: { userId: '@x:fmode.cn' } } }),
+      ).toBe(true);
+      // 映射层已判定就绪时直接放行
+      expect(isMatrixReady({ ...base, matrixReady: true })).toBe(true);
+    });
+  });
+});

+ 263 - 0
src/app/core/live.service.spec.ts

@@ -0,0 +1,263 @@
+import { TestBed } from '@angular/core/testing';
+import { LiveService } from './live.service';
+import { FmodeApi } from './api/fmode-api';
+import { Agent } from './api/models';
+
+/**
+ * LiveService 单测 —— **全项目唯一轮询器**(SPEC FR-14)
+ *
+ * 🔴 本文件守护 FR-14 的核心断言:
+ *   ① **只有一个轮询器**(AC-14.1;存量有 4 个重叠)
+ *   ② `stop()` **真实停止**,不得空实现(AC-14.3;存量 `stopPolling()` 是空的)
+ *   ③ 区分「空」与「失败」—— 空列表是合法状态,失败必须进 error 态
+ *      (禁止用空态掩盖请求失败,AC-03.4)
+ *
+ * 手法:用 Jasmine 假时钟(`jasmine.clock()`)推进 15s 节拍,断言请求次数。
+ */
+describe('LiveService', () => {
+  let svc: LiveService;
+  const apiStub = { fetchAgents: jasmine.createSpy('fetchAgents') };
+
+  const AGENTS: Agent[] = [
+    { id: 'a1', name: 'A', status: 'running', matrixReady: true, unread: 0 },
+    { id: 'a2', name: 'B', status: 'idle', matrixReady: true, unread: 0 },
+  ];
+
+  beforeEach(() => {
+    apiStub.fetchAgents.calls.reset();
+    apiStub.fetchAgents.and.returnValue(Promise.resolve(AGENTS));
+    TestBed.configureTestingModule({
+      providers: [LiveService, { provide: FmodeApi, useValue: apiStub }],
+    });
+    svc = TestBed.inject(LiveService);
+  });
+
+  afterEach(() => {
+    svc.stop();
+    jasmine.clock().uninstall();
+  });
+
+  describe('load() 三态与错误语义', () => {
+    it('成功且有数据 → ready,agents 已填充', async () => {
+      await svc.load();
+      expect(svc.state()).toBe('ready');
+      expect(svc.agents().length).toBe(2);
+      expect(svc.hasData()).toBeTrue();
+      expect(svc.error()).toBeNull();
+    });
+
+    it('成功但空列表 → empty(空是合法状态,非错误)', async () => {
+      apiStub.fetchAgents.and.returnValue(Promise.resolve([]));
+      await svc.load();
+      expect(svc.state()).toBe('empty');
+      expect(svc.error()).toBeNull();
+    });
+
+    it('🔴 请求失败 → error 态(**不得**用空态掩盖,AC-03.4)', async () => {
+      apiStub.fetchAgents.and.returnValue(Promise.reject(new Error('SessionToken 无效')));
+      await svc.load();
+      expect(svc.state()).toBe('error');
+      expect(svc.error()).toContain('SessionToken 无效');
+      // 关键区分:不是 empty
+      expect(svc.state()).not.toBe('empty');
+    });
+
+    it('失败后保留上一次的 agents(不清空已有数据)', async () => {
+      await svc.load();
+      expect(svc.agents().length).toBe(2);
+
+      apiStub.fetchAgents.and.returnValue(Promise.reject(new Error('网络抖动')));
+      await svc.load();
+      expect(svc.state()).toBe('error');
+      expect(svc.agents().length).toBe(2); // 旧数据仍在,UI 不至于全白
+    });
+
+    it('load 会记录 lastUpdated', async () => {
+      await svc.load();
+      expect(svc.lastUpdated()).toBeTruthy();
+    });
+  });
+
+  describe('并发防串扰(seq 模式)', () => {
+    it('慢的旧请求不会覆盖新结果', async () => {
+      let resolveSlow: (v: Agent[]) => void = () => undefined;
+      apiStub.fetchAgents.and.callFake(
+        () => new Promise<Agent[]>((res) => (resolveSlow = res)).then((v) => v),
+      );
+      const slow = svc.load();
+
+      apiStub.fetchAgents.and.returnValue(Promise.resolve([AGENTS[0]]));
+      await svc.load(); // 后发先至
+
+      resolveSlow([AGENTS[0], AGENTS[1]]); // 旧请求迟到
+      await slow;
+
+      expect(svc.agents().length).toBe(1); // 未被迟到回包覆盖
+    });
+  });
+
+  describe('🔴 数据未变不扰动界面(闪烁修复)', () => {
+    it('轮询两次返回等价数据 → agents 数组**引用不变**(避免整列重渲染)', async () => {
+      await svc.load();
+      const first = svc.agents();
+      await svc.load(true); // 静默轮询,数据相同
+      expect(svc.agents()).toBe(first); // 同一引用
+    });
+
+    it('数据真变了 → 换引用(不丢更新)', async () => {
+      await svc.load();
+      const first = svc.agents();
+      apiStub.fetchAgents.and.returnValue(
+        Promise.resolve([{ ...AGENTS[0], status: 'error' as const }, AGENTS[1]]),
+      );
+      await svc.load(true);
+      expect(svc.agents()).not.toBe(first);
+      expect(svc.agents()[0].status).toBe('error');
+    });
+
+    it('顺序变化视为变化(列表顺序属可见变化)', async () => {
+      await svc.load();
+      const first = svc.agents();
+      apiStub.fetchAgents.and.returnValue(Promise.resolve([AGENTS[1], AGENTS[0]]));
+      await svc.load(true);
+      expect(svc.agents()).not.toBe(first);
+    });
+
+    it('数量变化视为变化', async () => {
+      await svc.load();
+      const first = svc.agents();
+      apiStub.fetchAgents.and.returnValue(Promise.resolve([AGENTS[0]]));
+      await svc.load(true);
+      expect(svc.agents()).not.toBe(first);
+      expect(svc.agents().length).toBe(1);
+    });
+  });
+
+  describe('start()/stop() —— 唯一轮询器(AC-14.1 / AC-14.3)', () => {
+    it('start 立即取一次数,并进入轮询态', async () => {
+      jasmine.clock().install();
+      svc.start();
+      expect(apiStub.fetchAgents.calls.count()).toBe(1);
+      expect(svc.polling()).toBeTrue();
+    });
+
+    it('🔴 15s 节拍触发,且**只有一个**定时器(不叠加)', async () => {
+      jasmine.clock().install();
+      svc.start();
+      const afterStart = apiStub.fetchAgents.calls.count(); // 1
+
+      // 重复 start 不得产生第二个定时器
+      svc.start();
+      expect(apiStub.fetchAgents.calls.count()).toBe(afterStart);
+
+      jasmine.clock().tick(15_000);
+      expect(apiStub.fetchAgents.calls.count()).toBe(afterStart + 1);
+      jasmine.clock().tick(15_000);
+      expect(apiStub.fetchAgents.calls.count()).toBe(afterStart + 2);
+    });
+
+    it('🔴 轮询时**不得**回到 loading(否则列表每 15s 闪骨架屏)', async () => {
+      jasmine.clock().install();
+      svc.start();
+      // 首屏加载完成
+      await Promise.resolve();
+      await Promise.resolve();
+      expect(svc.state()).toBe('ready');
+
+      // 推进一个节拍(轮询触发的静默刷新)
+      jasmine.clock().tick(15_000);
+      // 此刻请求已发出但响应未回 —— 状态必须**仍是 ready**,不得是 loading
+      expect(svc.state()).not.toBe('loading');
+      expect(svc.state()).toBe('ready');
+    });
+
+    it('🔴 stop() **真实停止**(AC-14.3:存量是空实现)', () => {
+      jasmine.clock().install();
+      svc.start();
+      const n = apiStub.fetchAgents.calls.count();
+
+      svc.stop();
+      expect(svc.polling()).toBeFalse();
+
+      jasmine.clock().tick(60_000); // 推进 4 个节拍
+      expect(apiStub.fetchAgents.calls.count()).toBe(n); // 一次都不再触发
+    });
+
+    it('stop 后可再次 start(不残留旧定时器)', () => {
+      jasmine.clock().install();
+      svc.start();
+      svc.stop();
+      const n = apiStub.fetchAgents.calls.count();
+
+      svc.start();
+      jasmine.clock().tick(15_000);
+      // 若旧定时器残留,这里会多触发一次
+      expect(apiStub.fetchAgents.calls.count()).toBe(n + 2); // start 立即 1 次 + 1 个节拍
+    });
+  });
+
+  describe('ensureLoaded / refresh / reset', () => {
+    it('ensureLoaded 已有数据时不重复请求', async () => {
+      await svc.ensureLoaded(); // 首次请求
+      await svc.ensureLoaded(); // 应短路
+      expect(apiStub.fetchAgents.calls.count()).toBe(1);
+    });
+
+    it('ensureLoaded 在 loading 中不重复请求', async () => {
+      const p1 = svc.ensureLoaded();
+      const p2 = svc.ensureLoaded();
+      await Promise.all([p1, p2]);
+      expect(apiStub.fetchAgents.calls.count()).toBe(1);
+    });
+
+    it('ensureLoaded 在 error 态会重试(允许恢复)', async () => {
+      apiStub.fetchAgents.and.returnValue(Promise.reject(new Error('x')));
+      await svc.ensureLoaded();
+      expect(svc.state()).toBe('error');
+
+      apiStub.fetchAgents.and.returnValue(Promise.resolve(AGENTS));
+      await svc.ensureLoaded();
+      expect(svc.state()).toBe('ready');
+    });
+
+    it('refresh 强制重新请求(即使已 ready)', async () => {
+      await svc.load();
+      await svc.refresh();
+      expect(apiStub.fetchAgents.calls.count()).toBe(2);
+    });
+
+    it('reset 停止轮询并清空状态(退出登录用)', async () => {
+      jasmine.clock().install();
+      await svc.load();
+      svc.start();
+      svc.reset();
+
+      expect(svc.polling()).toBeFalse();
+      expect(svc.agents()).toEqual([]);
+      expect(svc.state()).toBe('idle');
+      expect(svc.error()).toBeNull();
+    });
+  });
+
+  describe('派生视图', () => {
+    it('agentById 从已加载列表取(不发新请求)', async () => {
+      await svc.load();
+      apiStub.fetchAgents.calls.reset();
+      expect(svc.agentById('a1')?.name).toBe('A');
+      expect(svc.agentById('nope')).toBeUndefined();
+      expect(apiStub.fetchAgents).not.toHaveBeenCalled();
+    });
+
+    it('blockedAgents 只含 error 状态', async () => {
+      apiStub.fetchAgents.and.returnValue(
+        Promise.resolve([
+          { ...AGENTS[0], status: 'error' as const },
+          { ...AGENTS[1], status: 'idle' as const },
+        ]),
+      );
+      await svc.load();
+      expect(svc.blockedAgents().length).toBe(1);
+      expect(svc.blockedAgents()[0].id).toBe('a1');
+    });
+  });
+});

+ 62 - 5
src/app/core/live.service.ts

@@ -48,14 +48,31 @@ export class LiveService {
     await this.load();
   }
 
-  async load(): Promise<void> {
+  /**
+   * 加载实况数据。
+   *
+   * @param silent `true` = **静默更新**(轮询触发的常规刷新):
+   *   ① **不置 `loading`** —— 否则列表每 15s 闪一次骨架屏(实测反馈)
+   *   ② 数据**未变化时不换数组引用** —— 否则 `@for` 重算、滚动位置与选中态抖动
+   *
+   * 口径:
+   *   · 首次加载 / 手动「刷新」→ 非静默(用户需要看到加载反馈)
+   *   · 定时轮询 → 静默
+   */
+  async load(silent = false): Promise<void> {
     const my = ++this.seq;
-    this._state.set('loading');
+    if (!silent) {
+      this._state.set('loading');
+    }
     this._error.set(null);
     try {
       const agents = await this.api.fetchAgents();
       if (my !== this.seq) return; // 过期回包丢弃
-      this._agents.set(agents);
+
+      // 数据未变 → 不换引用(避免整列重渲染导致闪烁/滚动跳动)
+      if (!sameAgents(this._agents(), agents)) {
+        this._agents.set(agents);
+      }
       // 区分「空」与「就绪」:空列表是合法但需显示空态的状态(AC-03.4 禁止用空态掩盖失败)
       this._state.set(agents.length === 0 ? 'empty' : 'ready');
       this._lastUpdated.set(new Date().toISOString());
@@ -75,8 +92,8 @@ export class LiveService {
   start(): void {
     if (this.timer) return;
     this.polling.set(true);
-    void this.load();
-    this.timer = setInterval(() => void this.load(), POLL_MS);
+    void this.load(); // 首屏:非静默,让骨架屏生效
+    this.timer = setInterval(() => void this.load(true), POLL_MS); // 轮询:静默
   }
 
   /**
@@ -106,3 +123,43 @@ export class LiveService {
     return this._agents().find((a) => a.id === id);
   }
 }
+
+/**
+ * 两个 agent 列表是否**实质相同**(决定要不要换数组引用)。
+ *
+ * 比较 id 与所有可能影响界面的字段;顺序敏感(列表顺序变化属可见变化)。
+ * 目的:轮询周期内数据没变时**不触发重渲染**,避免骨架屏/滚动位置抖动。
+ */
+function sameAgents(a: Agent[], b: Agent[]): boolean {
+  if (a === b) return true;
+  if (a.length !== b.length) return false;
+  for (let i = 0; i < a.length; i++) {
+    if (!sameAgent(a[i], b[i])) return false;
+  }
+  return true;
+}
+
+function sameAgent(x: Agent, y: Agent): boolean {
+  return (
+    x.id === y.id &&
+    x.name === y.name &&
+    x.avatar === y.avatar &&
+    x.status === y.status &&
+    x.statusText === y.statusText &&
+    x.currentTask === y.currentTask &&
+    x.currentAction === y.currentAction &&
+    x.progress === y.progress &&
+    x.heartbeatAt === y.heartbeatAt &&
+    x.updatedAt === y.updatedAt &&
+    x.matrixReady === y.matrixReady &&
+    x.unread === y.unread &&
+    x.companyId === y.companyId &&
+    // 嵌套对象按关键字段比(避免深比较开销)
+    x.host?.cpuPercent === y.host?.cpuPercent &&
+    x.host?.memPercent === y.host?.memPercent &&
+    x.host?.diskPercent === y.host?.diskPercent &&
+    x.balance?.remainCny === y.balance?.remainCny &&
+    x.channels?.matrix === y.channels?.matrix &&
+    x.channels?.matrixConfig?.userId === y.channels?.matrixConfig?.userId
+  );
+}

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

@@ -0,0 +1,469 @@
+import { effect, inject, Injectable, OnDestroy, signal } from '@angular/core';
+import type { MatrixClient, MatrixEvent, Room, RoomMember } from 'matrix-js-sdk';
+import { SessionService } from './session.service';
+
+/**
+ * Matrix 客户端 —— `matrix-js-sdk` 的**唯一收敛点**
+ *
+ * 依据《数据访问层设计.md》§五(每域唯一 owner)与 SPEC FR-04。
+ *
+ * ## NFR-02:SDK 不得进主包
+ *
+ * `matrix-js-sdk` **只在首次真正需要时动态导入**(`await import()`),
+ * 顶层仅 `import type`(编译期擦除,零运行时依赖)。
+ * 验证:主包 chunk 中不得出现 `matrix-js-sdk` 字样。
+ *
+ * ## 身份规则(fmode-hub v4.2 P3 约定)
+ *
+ * - 用户 `mxid` = `@<用户 objectId 小写>:fmode.cn`
+ * - Agent `mxid` = `@<FmodeAgent.objectId 小写>:fmode.cn`
+ * - 凭据一律来自 provision 云函数(`RP5Ht55NeX`)**服务端签发**;
+ *   本服务**不派生任何密码**(黄金规则 9:前端零密钥)。
+ *
+ * ## 不包含的能力
+ *
+ * 语音通话(`m.call.*` / WebRTC)按 SPEC FR-11 裁决**暂不实现**,
+ * 故不引入 TURN 配置,也不依赖 `navigator.mediaDevices`。
+ */
+
+/** 一条归一化后的文本消息(视图层只见此结构) */
+export interface MxMessage {
+  /** 所属房间(未读计数需要知道消息来自哪个 Agent) */
+  roomId: string;
+  eventId: string;
+  sender: string;
+  body: string;
+  ts: number;
+  kind: 'text' | 'notice' | 'emote';
+  /**
+   * 是否**实时到达**的消息(`data.liveEvent === true`)。
+   *
+   * 🔴 `/sync` 初次同步会把**历史消息**逐条塞进 timeline,这些也会触发
+   *    `Room.timeline` 事件。若不加区分,未读计数会把整个历史都算成未读
+   *    (实测曾出现「刚连接就有 39 条未读」)。
+   *
+   * 未读计数**只计 `live === true`** 的消息;timeline 渲染两者都要。
+   */
+  live: boolean;
+}
+
+/** 同步连接状态(驱动 ChatService 的 ConnectionState) */
+export type MxSyncState = 'connecting' | 'connected' | 'disconnected';
+
+function kindOf(msgtype: string | undefined): MxMessage['kind'] {
+  if (msgtype === 'm.notice') return 'notice';
+  if (msgtype === 'm.emote') return 'emote';
+  return 'text';
+}
+
+const TEXT_MSGTYPES = ['m.text', 'm.notice', 'm.emote'];
+
+@Injectable({ providedIn: 'root' })
+export class MatrixClientService implements OnDestroy {
+  /** 懒加载的 SDK 模块(首次 `load()` 时填充) */
+  private sdk: typeof import('matrix-js-sdk') | null = null;
+  private client: MatrixClient | null = null;
+
+  // ---------- 连接状态(供 chat 页徽标消费) ----------
+
+  private readonly _syncState = signal<MxSyncState>('disconnected');
+  readonly syncState = this._syncState.asReadonly();
+
+  private readonly _userId = signal<string>('');
+  /** 当前登录的 Matrix 用户 id(`@xxx:fmode.cn`) */
+  readonly userId = this._userId.asReadonly();
+
+  private token = '';
+  private deviceId = '';
+
+  /** 显式停止标志:避免 disconnect() 后 sync 回调再改状态 */
+  private stopped = false;
+
+  /**
+   * 初次同步是否已完成。
+   *
+   * 🔴 未读计数的**分界线**:初次 `/sync` 会把房间历史逐条回填(且按时间
+   *    升序),若直接计数会把整个历史都算成未读。故此标志为 false 期间,
+   *    未读只推进基线、不计数。
+   */
+  private initialSyncDone = false;
+
+  /** 初次同步是否完成(未读计数用于区分历史与实时) */
+  get hasInitialSync(): boolean {
+    return this.initialSyncDone;
+  }
+
+  /**
+   * 全局消息流 —— **全项目唯一一处** `Room.timeline` 订阅。
+   *
+   * 多路复用给:① 聊天页(当前房间)② 未读计数(所有房间)。
+   * 这样 sync 常驻时也只有一个监听器,不会重复触发。
+   */
+  private readonly messageHandlers = new Set<(m: MxMessage) => void>();
+  private globalBound = false;
+
+  private readonly session = inject(SessionService);
+
+  constructor() {
+    // 🔴 登出即断连:不依赖页面记得调用 disconnect()。
+    //    任一页面的 doLogout() 清掉 token 后,这里自动收敛。
+    effect(() => {
+      if (!this.session.token() && this.client) this.disconnect();
+    });
+  }
+
+  ngOnDestroy(): void {
+    this.disconnect();
+  }
+  // ---------- 生命周期(会话级常驻) ----------
+
+  /**
+   * 建立**会话级**连接(整个登录会话只调用一次,由 SessionService 持有者驱动)。
+   *
+   * 🔴 与旧实现的关键差异:这是**常驻**的 —— 不在聊天页 `close()` 时断开,
+   *    否则离开会话后收不到消息,未读计数永远无法累加。
+   *    断开只发生在**退出登录**时(`disconnect()`)。
+   *
+   * @param accessToken 云函数返回的 token(**不落 localStorage**,仅存内存)
+   * @param userId      形如 `@sr2wipsdyq:fmode.cn`
+   * @param deviceId    云函数返回的 deviceId(可空)
+   */
+  async connectSession(accessToken: string, userId: string, deviceId?: string): Promise<void> {
+    if (!accessToken || !userId) {
+      throw new Error('Matrix 凭据不完整(缺 accessToken 或 userId)');
+    }
+    // 同身份重复调用:幂等,不打断现有 sync
+    if (this.client && this.token === accessToken && this._userId() === userId) {
+      return;
+    }
+    this.disconnect();
+
+    const sdk = await this.load();
+    this.token = accessToken;
+    this.deviceId = deviceId ?? '';
+    this._userId.set(userId);
+    this.stopped = false;
+    this.initialSyncDone = false;
+    this._syncState.set('connecting');
+
+    this.client = sdk.createClient({
+      baseUrl: MATRIX_HS,
+      accessToken,
+      userId,
+      ...(deviceId ? { deviceId } : {}),
+    });
+
+    // sync 状态 → 连接状态(FR-04 AC-04.9:断线可自动重连)
+    this.client.on(sdk.ClientEvent.Sync, (state: string) => {
+      if (this.stopped) return;
+      if (state === 'SYNCING' || state === 'PREPARED') this._syncState.set('connected');
+      else if (state === 'ERROR') this._syncState.set('disconnected');
+      else if (state === 'RECONNECTING') this._syncState.set('connecting');
+      // 🔴 首个 SYNCING = 初次同步完成 → 之后的消息才算「实时」
+      if (state === 'SYNCING') this.initialSyncDone = true;
+    });
+
+    // 收到邀请自动接受(Agent 侧建房的场景)
+    this.client.on(sdk.RoomMemberEvent.Membership, (_ev: MatrixEvent, member: RoomMember) => {
+      if (this.stopped) return;
+      if (member.membership === 'invite' && member.userId === userId) {
+        void this.client?.joinRoom(member.roomId).catch(() => undefined);
+      }
+    });
+
+    // 全局消息流(未读计数 + 聊天页复用)
+    this.bindGlobalMessageStream();
+
+    this.client.startClient({ initialSyncLimit: 20 });
+  }
+
+  /** 断开并清理(**仅退出登录时**调用) */
+  disconnect(): void {
+    this.stopped = true;
+    this.initialSyncDone = false;
+    this.messageHandlers.clear();
+    this.globalBound = false;
+    if (this.client) {
+      try {
+        this.client.stopClient();
+      } catch {
+        /* 忽略:销毁期异常不阻塞清理 */
+      }
+      try {
+        this.client.removeAllListeners();
+      } catch {
+        /* 忽略 */
+      }
+    }
+    this.client = null;
+    this.token = '';
+    this.deviceId = '';
+    this._userId.set('');
+    this._syncState.set('disconnected');
+  }
+
+  /** 是否已建立会话级连接 */
+  get connected(): boolean {
+    return !!this.client;
+  }
+
+  // ---------- 房间 ----------
+
+  /**
+   * 确保与目标 Agent 的 DM 房间可用。
+   *
+   * 优先用 provision 返回的 `roomId`(绑定表真相);未提供时按
+   * `m.direct` accountData 查找,仍无则创建。
+   */
+  async ensureRoom(
+    agentMxId: string,
+    opts?: { roomId?: string | null; agentName?: string },
+  ): Promise<string> {
+    const c = this.requireClient();
+
+    // ① 绑定表给的 roomId 优先(与容器侧同一房间)
+    if (opts?.roomId) {
+      const room = c.getRoom(opts.roomId);
+      if (room && room.getMyMembership() === 'join') return opts.roomId;
+      // 本地尚未 join → 尝试加入(可能是别的设备建的房)
+      const joined = await c.joinRoom(opts.roomId).catch(() => null);
+      if (joined) return opts.roomId;
+    }
+
+    // ② m.direct 里已有记录
+    const directEv = c.getAccountData('m.direct' as never);
+    const direct = directEv?.getContent() as Record<string, string[]> | undefined;
+    for (const rid of direct?.[agentMxId] ?? []) {
+      const room = c.getRoom(rid);
+      if (room?.getMyMembership() === 'join') return rid;
+    }
+
+    // ③ 兜底:遍历已 join 房间找共同成员
+    for (const room of c.getRooms()) {
+      if (room.getMyMembership() !== 'join') continue;
+      if (room.getMember(agentMxId)) return room.roomId;
+    }
+
+    // ④ 都没有 → 新建 DM
+    const sdk = await this.load();
+    const created = await c.createRoom({
+      invite: [agentMxId],
+      is_direct: true,
+      preset: sdk.Preset.TrustedPrivateChat,
+      name: opts?.agentName,
+    });
+    return created.room_id;
+  }
+
+  /** 该房间是否已是「已加入」状态 */
+  isJoined(roomId: string): boolean {
+    return !!this.client?.getRoom(roomId)?.getMyMembership?.();
+  }
+
+  // ---------- 消息收发 ----------
+
+  /** 发文本(AC-04.2) */
+  async sendText(roomId: string, text: string): Promise<{ event_id?: string }> {
+    const c = this.requireClient();
+    const sdk = await this.load();
+    return c.sendEvent(roomId, sdk.EventType.RoomMessage, {
+      msgtype: sdk.MsgType.Text,
+      body: text,
+    });
+  }
+
+  /** 发文件卡片(`m.text` + JSON body,与容器侧解析约定一致) */
+  async sendFileCard(
+    roomId: string,
+    card: { name: string; size: number; mimeType: string; url: string },
+  ): Promise<{ event_id?: string }> {
+    const c = this.requireClient();
+    const sdk = await this.load();
+    return c.sendEvent(roomId, sdk.EventType.RoomMessage, {
+      msgtype: sdk.MsgType.Text,
+      body: JSON.stringify({ kind: 'file', ...card }),
+    });
+  }
+
+  /** 当前 timeline → 归一消息(时间正序) */
+  timelineToMessages(roomId: string): MxMessage[] {
+    const room = this.client?.getRoom(roomId);
+    if (!room) return [];
+    return room
+      .getLiveTimeline()
+      .getEvents()
+      .filter((e) => e.getType() === 'm.room.message')
+      .filter((e) => TEXT_MSGTYPES.includes(String(e.getContent()['msgtype'] ?? '')))
+      .map((e) => ({
+        roomId,
+        eventId: e.getId() ?? '',
+        sender: e.getSender() ?? '',
+        body: (e.getContent()['body'] as string) ?? '',
+        ts: e.getTs(),
+        kind: kindOf(String(e.getContent()['msgtype'] ?? '')),
+        // 从 timeline 读出的都视为历史(未读不依赖此值)
+        live: false,
+      }));
+  }
+
+  /** 向上翻历史(AC-04.4) */
+  async paginateBack(roomId: string, limit = 30): Promise<boolean> {
+    const c = this.requireClient();
+    let room = c.getRoom(roomId);
+    if (!room) {
+      await c.joinRoom(roomId).catch(() => null);
+      room = c.getRoom(roomId);
+    }
+    if (!room) return false;
+    return c.paginateEventTimeline(room.getLiveTimeline(), { backwards: true, limit });
+  }
+
+  /**
+   * 是否还有更早历史(live timeline 的 pagination token 是否耗尽)。
+   * 用于 UI 决定要不要显示「加载更早」入口,以及分页到顶后停止请求。
+   */
+  paginationToken(roomId: string): string | null {
+    const room = this.client?.getRoom(roomId);
+    if (!room || !this.sdk) return null;
+    return room.getLiveTimeline().getPaginationToken(this.sdk.Direction.Backward) ?? null;
+  }
+
+  /** 已读回执 */
+  async sendReceipt(roomId: string, eventId: string): Promise<void> {
+    const c = this.requireClient();
+    const ev = c
+      .getRoom(roomId)
+      ?.getLiveTimeline()
+      .getEvents()
+      .find((e) => e.getId() === eventId);
+    if (!ev) return;
+    await c.sendReadReceipt(ev);
+  }
+
+  /** 输入中通知(失败非致命) */
+  async setTyping(roomId: string, typing: boolean, timeoutMs = 8000): Promise<void> {
+    try {
+      await this.requireClient().sendTyping(roomId, typing, timeoutMs);
+    } catch {
+      /* 非致命:typing 丢失不影响消息链路 */
+    }
+  }
+
+  // ---------- 事件订阅 ----------
+
+  /**
+   * 订阅房间新消息;返回解绑函数。
+   *
+   * 不过滤 sender —— 自己发的消息也要接住(local echo 依赖 Timeline 事件)。
+   */
+  onMessage(roomId: string, cb: (m: MxMessage) => void): () => void {
+    return this.onAnyMessage((m) => {
+      if (m.roomId === roomId) cb(m);
+    });
+  }
+
+  /**
+   * 订阅**所有房间**的新消息(未读计数用);返回解绑函数。
+   *
+   * 与 `onMessage` 共用同一个底层订阅(`bindGlobalMessageStream`),
+   * 因此无论多少订阅者,SDK 侧只有**一个** `Room.timeline` 监听器。
+   */
+  onAnyMessage(cb: (m: MxMessage) => void): () => void {
+    this.messageHandlers.add(cb);
+    return () => {
+      this.messageHandlers.delete(cb);
+    };
+  }
+
+  /** 订阅对方 typing 状态;返回解绑函数 */
+  onTyping(roomId: string, cb: (typing: boolean, userId: string) => void): () => void {
+    const c = this.requireClient();
+    const handler = (ev: MatrixEvent, member: RoomMember) => {
+      if (member?.roomId !== roomId || ev.getType() !== 'm.typing') return;
+      cb(!!member.typing, member.userId ?? '');
+    };
+    c.on('RoomMember.typing' as never, handler as never);
+    return () => {
+      try {
+        c.off('RoomMember.typing' as never, handler as never);
+      } catch {
+        /* 忽略 */
+      }
+    };
+  }
+
+  /** 绑定全局消息流(只绑一次,幂等) */
+  private bindGlobalMessageStream(): void {
+    const c = this.client;
+    if (!c || this.globalBound) return;
+    this.globalBound = true;
+    c.on('Room.timeline' as never, ((ev: MatrixEvent, _room?: unknown, _toStart?: boolean, data?: { liveEvent?: boolean }) => {
+      if (ev.getType() !== 'm.room.message') return;
+      // 跳过本地未发送回执(echo 会再触发一次)
+      const sending = (ev as unknown as { isSending?: () => boolean }).isSending;
+      if (typeof sending === 'function' && sending.call(ev)) return;
+      const msgtype = String(ev.getContent()['msgtype'] ?? '');
+      if (!TEXT_MSGTYPES.includes(msgtype)) return;
+      const msg: MxMessage = {
+        roomId: ev.getRoomId() ?? '',
+        eventId: ev.getId() ?? '',
+        sender: ev.getSender() ?? '',
+        body: (ev.getContent()['body'] as string) ?? '',
+        ts: ev.getTs(),
+        kind: kindOf(msgtype),
+        // 仅真正的实时事件为 true;初次 /sync 的历史回填为 false
+        live: !!data?.liveEvent,
+      };
+      for (const h of this.messageHandlers) {
+        try {
+          h(msg);
+        } catch {
+          /* 单个订阅者异常不影响其它订阅者 */
+        }
+      }
+    }) as never);
+  }
+
+  // ---------- 工具 ----------
+
+  /** `@<objectId 小写>:fmode.cn`(v4.2 P3 约定) */
+  static agentMxId(objectId: string): string {
+    return `@${objectId.toLowerCase()}:${MATRIX_SERVER_NAME}`;
+  }
+
+  /** 解析文件卡片 body;非卡片返回 null */
+  static parseFileCard(
+    body: string,
+  ): { name: string; size: number; mimeType: string; url: string } | null {
+    if (!body || body[0] !== '{') return null;
+    try {
+      const j = JSON.parse(body);
+      if ((j.kind === 'file' || j.type === 'fcard') && j.url && j.name) {
+        return {
+          name: String(j.name),
+          size: Number(j.size) || 0,
+          mimeType: String(j.mimeType ?? j.mime ?? ''),
+          url: String(j.url),
+        };
+      }
+      return null;
+    } catch {
+      return null;
+    }
+  }
+
+  private requireClient(): MatrixClient {
+    if (!this.client) throw new Error('Matrix 客户端未连接');
+    return this.client;
+  }
+
+  /** 动态导入 SDK(NFR-02:不进主包) */
+  private async load(): Promise<typeof import('matrix-js-sdk')> {
+    if (!this.sdk) this.sdk = await import('matrix-js-sdk');
+    return this.sdk;
+  }
+}
+
+/** homeserver 常量(与《API-契约》§1.2 一致) */
+export const MATRIX_HS = 'https://matrix.fmode.cn';
+export const MATRIX_SERVER_NAME = 'fmode.cn';

+ 102 - 0
src/app/core/matrix-session.service.ts

@@ -0,0 +1,102 @@
+import { effect, inject, Injectable } from '@angular/core';
+import { FmodeApi } from './api/fmode-api';
+import { ChatService } from './chat.service';
+import { LiveService } from './live.service';
+import { MatrixClientService } from './matrix-client.service';
+import { SessionService } from './session.service';
+import { UnreadService } from './unread.service';
+import { environment } from '../../environments/environment';
+
+/**
+ * Matrix 会话引导 —— 登录后建立**常驻**的 Matrix 连接
+ *
+ * ## 为什么需要它
+ *
+ * Matrix 的 `/sync` 必须**常驻**才能:
+ *   ① 在用户没打开聊天页时收到 Agent 消息(未读计数的基础)
+ *   ② 让会话列表/未读徽标反映真实新消息
+ *
+ * 若把连接绑在聊天页(页面开关 = 连接开关),离开页面就收不到消息,
+ * 未读永远是 0 —— 那是**错的**。
+ *
+ * ## 设计
+ *
+ * - 作为**唯一轮询器**(`LiveService`)的**兄弟**:都在 Shell 构造时启动
+ * - 用 provision 取凭据(`agentId` 传空 = 只确保**用户账号**存在,
+ *   不建具体 Agent 的房间;这是 provision 的合法用法)
+ * - 幂等:重复调用不重建连接
+ * - 登出(`SessionService.token` 变 null)→ `disconnect()`
+ *
+ * ## 与 ChatService 的关系
+ *
+ * `ChatService.open()` 也会调 `connectSession()`,但它带的是**具体 Agent 的**
+ * provision 结果。两者凭据在同一用户下**token 相同**(都是用户 token),
+ * 故 `connectSession` 的幂等判断(token + userId 相同则复用)会命中,
+ * 不会重复建连。
+ */
+@Injectable({ providedIn: 'root' })
+export class MatrixSessionService {
+  private readonly api = inject(FmodeApi);
+  private readonly mx = inject(MatrixClientService);
+  private readonly session = inject(SessionService);
+  private readonly unread = inject(UnreadService);
+  private readonly live = inject(LiveService);
+  private readonly chat = inject(ChatService);
+
+  private booted = false;
+
+  /** 在 Shell 构造时调用(与 `LiveService.start()` 同级) */
+  bootstrap(): void {
+    if (this.booted) return;
+    this.booted = true;
+    // 登录态变化 → 建连 / 断连
+    effect(() => {
+      const token = this.session.token();
+      if (token && !environment.useMockData) {
+        // 载入按用户隔离的 roomId 映射与未读计数(未读归属需要)
+        this.chat.loadRoomMap();
+        this.unread.reload();
+        // 未读监听必须在**连接建立前**挂好,否则建连后到首次进会话之间的消息会漏计
+        this.chat.ensureUnreadListener();
+        void this.ensureConnected();
+      } else if (!token) {
+        this.mx.disconnect();
+        this.unread.reset();
+      }
+    });
+  }
+
+  /**
+   * 确保 Matrix 会话连接已建立。
+   *
+   * 取凭据的两条路:
+   *   ① 优先用「当前会话已知的 provision 结果」——避免为建连而多调云函数
+   *   ② 否则按 `agentId: ''` 调 provision(只确保用户账号,不建房间)
+   */
+  private async ensureConnected(): Promise<void> {
+    if (this.mx.connected) return;
+
+    const info = this.chat.provisionInfo;
+    if (info?.accessToken && info.userMxId) {
+      await this.mx.connectSession(info.accessToken, info.userMxId, info.deviceId).catch(() => {
+        /* 建连失败不阻塞应用;聊天页打开时会重试并给出具体错误 */
+      });
+      return;
+    }
+
+    // 无已知凭据:用空 agentId 取「用户账号级」凭据
+    try {
+      const prov = await this.api.provisionMatrix('');
+      if (prov.accessToken && prov.userMxId) {
+        await this.mx.connectSession(prov.accessToken, prov.userMxId, prov.deviceId);
+      }
+    } catch {
+      /* 静默:未开通时打开聊天页会给出分环节错误提示 */
+    }
+  }
+
+  /** 供登录成功后主动触发(登录页 → Shell 之间可能有竞态) */
+  async kick(): Promise<void> {
+    await this.ensureConnected();
+  }
+}

+ 187 - 0
src/app/core/tasks.service.spec.ts

@@ -0,0 +1,187 @@
+import { TestBed } from '@angular/core/testing';
+import { TasksService } from './tasks.service';
+import { FmodeApi } from './api/fmode-api';
+import { LiveService } from './live.service';
+import { Agent, Task } from './api/models';
+
+/**
+ * TasksService 单测 —— 任务看板域(SPEC FR-07b)
+ *
+ * 🔴 本文件守护 FR-07b 的**依赖声明**(最难守住的一条):
+ *    云函数接收端**未实现**时,必须进 **error 态**,
+ *    **禁止**把「未接入」伪装成「没有任务」(即不得显示空态)。
+ *
+ * 另覆盖:AC-07b.1 状态筛选与时间倒序、AC-07b.4 双端同一服务、AC-07b.6 只读。
+ */
+describe('TasksService', () => {
+  let svc: TasksService;
+  const apiStub = { fetchTasks: jasmine.createSpy('fetchTasks') };
+  const liveStub = {
+    ensureLoaded: jasmine.createSpy('ensureLoaded').and.returnValue(Promise.resolve()),
+    agents: jasmine.createSpy('agents').and.returnValue([] as Agent[]),
+  };
+
+  const T = (id: string, status: Task['status'], updatedAt: string): Task => ({
+    id,
+    agentId: 'a1',
+    title: `任务${id}`,
+    status,
+    updatedAt,
+    runs: [],
+    comments: [],
+    attachments: [],
+  });
+
+  beforeEach(() => {
+    apiStub.fetchTasks.calls.reset();
+    liveStub.ensureLoaded.calls.reset();
+    liveStub.agents.calls.reset();
+    liveStub.ensureLoaded.and.returnValue(Promise.resolve());
+    liveStub.agents.and.returnValue([
+      { id: 'a1', name: 'A', status: 'running', matrixReady: true, unread: 0 },
+    ]);
+    TestBed.configureTestingModule({
+      providers: [
+        TasksService,
+        { provide: FmodeApi, useValue: apiStub },
+        { provide: LiveService, useValue: liveStub },
+      ],
+    });
+    svc = TestBed.inject(TasksService);
+  });
+
+  describe('🔴 云函数未就绪 → error 态(不得伪装成空态)', () => {
+    it('loadFor 抛错 → state=error,且**不是** empty(FR-07b 依赖声明)', async () => {
+      apiStub.fetchTasks.and.returnValue(
+        Promise.reject(new Error('任务看板数据源未就绪:云函数接收端尚未实现')),
+      );
+      await svc.loadFor('a1');
+      expect(svc.state()).toBe('error');
+      expect(svc.state()).not.toBe('empty');
+      expect(svc.error()).toContain('未就绪');
+    });
+
+    it('ensureAllLoaded 抛错 → error 态', async () => {
+      apiStub.fetchTasks.and.returnValue(Promise.reject(new Error('未就绪')));
+      await svc.ensureAllLoaded();
+      expect(svc.state()).toBe('error');
+      expect(svc.state()).not.toBe('empty');
+    });
+
+    it('云函数就绪但确实无任务 → empty(两种状态必须可区分)', async () => {
+      apiStub.fetchTasks.and.returnValue(Promise.resolve([]));
+      await svc.loadFor('a1');
+      expect(svc.state()).toBe('empty');
+      expect(svc.error()).toBeNull();
+    });
+  });
+
+  describe('加载与缓存', () => {
+    it('loadFor 拉取并缓存该 agent 的任务', async () => {
+      apiStub.fetchTasks.and.returnValue(Promise.resolve([T('t1', 'running', '2026-01-02')]));
+      await svc.loadFor('a1');
+      expect(svc.state()).toBe('ready');
+      expect(svc.all().length).toBe(1);
+      expect(svc.agentId()).toBe('a1');
+    });
+
+    it('同一 agent 二次 loadFor 命中缓存,不重复请求', async () => {
+      apiStub.fetchTasks.and.returnValue(Promise.resolve([T('t1', 'running', '2026-01-01')]));
+      await svc.loadFor('a1');
+      await svc.loadFor('a1');
+      expect(apiStub.fetchTasks.calls.count()).toBe(1);
+    });
+
+    it('ensureAllLoaded 遍历所有数字生命', async () => {
+      liveStub.agents.and.returnValue([
+        { id: 'a1', name: 'A', status: 'running', matrixReady: true, unread: 0 },
+        { id: 'a2', name: 'B', status: 'idle', matrixReady: true, unread: 0 },
+      ]);
+      apiStub.fetchTasks.and.returnValue(Promise.resolve([]));
+      await svc.ensureAllLoaded();
+      expect(apiStub.fetchTasks.calls.count()).toBe(2);
+    });
+  });
+
+  describe('AC-07b.1 状态筛选与时间倒序', () => {
+    beforeEach(async () => {
+      apiStub.fetchTasks.and.returnValue(
+        Promise.resolve([
+          T('t1', 'running', '2026-01-01'),
+          T('t2', 'blocked', '2026-01-03'),
+          T('t3', 'done', '2026-01-02'),
+          T('t4', 'running', '2026-01-04'),
+        ]),
+      );
+      await svc.loadFor('a1');
+    });
+
+    it('默认按 updatedAt 倒序', () => {
+      expect(svc.current().map((t) => t.id)).toEqual(['t4', 't2', 't3', 't1']);
+    });
+
+    it('按状态筛选只留该状态', () => {
+      svc.setStatusFilter('running');
+      expect(svc.filtered().map((t) => t.id)).toEqual(['t4', 't1']);
+    });
+
+    it('all 筛选返回全部', () => {
+      svc.setStatusFilter('all');
+      expect(svc.filtered().length).toBe(4);
+    });
+
+    it('七态计数正确', () => {
+      const c = svc.counts();
+      expect(c.all).toBe(4);
+      expect(c.running).toBe(2);
+      expect(c.blocked).toBe(1);
+      expect(c.done).toBe(1);
+      expect(c.triage).toBe(0);
+    });
+
+    it('blocked 派生只含阻塞任务(AC-07b.3 上浮用)', () => {
+      expect(svc.blocked().map((t) => t.id)).toEqual(['t2']);
+    });
+  });
+
+  describe('选中态', () => {
+    beforeEach(async () => {
+      apiStub.fetchTasks.and.returnValue(
+        Promise.resolve([T('t1', 'running', '2026-01-02'), T('t2', 'done', '2026-01-01')]),
+      );
+      await svc.loadFor('a1');
+    });
+
+    it('select 设定选中任务,selected 可取出', () => {
+      svc.select('t1');
+      expect(svc.selected()?.id).toBe('t1');
+    });
+
+    it('select(null) 清空选中', () => {
+      svc.select('t1');
+      svc.select(null);
+      expect(svc.selected()).toBeUndefined();
+    });
+
+    it('selectFirstIfNeeded 在未选中时选第一条', () => {
+      svc.selectFirstIfNeeded();
+      expect(svc.selected()?.id).toBe(svc.filtered()[0].id);
+      expect(svc.selected()?.id).toBe('t1'); // 倒序后首条
+    });
+
+    it('selectFirstIfNeeded 已选中时不改变', () => {
+      svc.select('t2');
+      svc.selectFirstIfNeeded();
+      expect(svc.selected()?.id).toBe('t2');
+    });
+  });
+
+  describe('AC-07b.6 一期只读(无写操作)', () => {
+    it('服务不暴露任何写方法(派发/评论/修改)', () => {
+      const writes = ['dispatch', 'assign', 'comment', 'update', 'create', 'delete', 'remove'];
+      for (const w of writes) {
+        expect((svc as unknown as Record<string, unknown>)[w]).toBeUndefined();
+      }
+    });
+  });
+});

+ 10 - 0
src/app/core/tasks.service.ts

@@ -66,6 +66,16 @@ export class TasksService {
   /** 阻塞任务(工作台/首页上浮用) */
   readonly blocked = computed(() => this.all().filter((t) => t.status === 'blocked'));
 
+  /**
+   * 强制重新拉取全部任务(手动「刷新」用);跳过缓存。
+   * 事件派生的「执行」类事件依赖任务数据,故刷新须带上本方法。
+   */
+  async refresh(): Promise<void> {
+    this._byAgent.set({});
+    this._state.set('idle');
+    await this.ensureAllLoaded();
+  }
+
   /** 确保**全部**数字生命的任务已加载(事件派生需要全量) */
   async ensureAllLoaded(): Promise<void> {
     if (this._state() === 'ready' || this._state() === 'loading') return;

+ 151 - 0
src/app/core/unread.service.spec.ts

@@ -0,0 +1,151 @@
+import { TestBed } from '@angular/core/testing';
+import { UnreadService } from './unread.service';
+import { SessionService } from './session.service';
+
+/**
+ * 未读计数单测 —— **直接守护本次开发抓出的两个 bug**
+ *
+ * ① 「刚连接就有 39 条未读」:初次 `/sync` 的历史回填被算成未读
+ *    → 用 `catchingUp` 分界覆盖
+ * ② `data.liveEvent` 不能区分历史与实时(实测初次同步历史也为 `true`)
+ *    → 改用「初次同步完成分界 + 已读时间戳」双判据
+ *
+ * 覆盖 SPEC FR-04 的未读口径与 FR-14(每域唯一 owner)。
+ */
+describe('UnreadService', () => {
+  let svc: UnreadService;
+  let session: SessionService;
+
+  beforeEach(() => {
+    localStorage.clear();
+    TestBed.configureTestingModule({});
+    session = TestBed.inject(SessionService);
+    svc = TestBed.inject(UnreadService);
+  });
+
+  afterEach(() => localStorage.clear());
+
+  describe('catchingUp 分界(bug①回归)', () => {
+    it('初次同步回填阶段:历史消息一律不计未读', () => {
+      // 模拟 5 条历史按时间升序回填 —— 全部不应计数
+      const base = 1_700_000_000_000;
+      for (let i = 0; i < 5; i++) {
+        const counted = svc.onIncoming('agent-a', base + i * 1000, /*catchingUp*/ true);
+        expect(counted).toBe(false);
+      }
+      expect(svc.countOf('agent-a')).toBe(0);
+    });
+
+    it('分界之后:晚于基线的消息计入', () => {
+      const base = 1_700_000_000_000;
+      svc.onIncoming('agent-a', base, true); // 回填阶段建立基线
+      const counted = svc.onIncoming('agent-a', base + 5000, false);
+      expect(counted).toBe(true);
+      expect(svc.countOf('agent-a')).toBe(1);
+    });
+  });
+
+  describe('时间戳口径(bug②回归)', () => {
+    it('早于已读基线的消息不计(已读 / 重复投递)', () => {
+      const base = 1_700_000_000_000;
+      svc.onIncoming('a', base, true);
+      expect(svc.onIncoming('a', base - 1000, false)).toBe(false);
+      expect(svc.onIncoming('a', base, false)).toBe(false); // 等于基线也不计
+      expect(svc.countOf('a')).toBe(0);
+    });
+
+    it('首次见到房间时只推进基线,不计未读', () => {
+      expect(svc.onIncoming('new-agent', 1_700_000_000_000, false)).toBe(false);
+      expect(svc.countOf('new-agent')).toBe(0);
+      // 基线已建立,后续更新的消息才计
+      expect(svc.onIncoming('new-agent', 1_700_000_001_000, false)).toBe(true);
+      expect(svc.countOf('new-agent')).toBe(1);
+    });
+
+    it('离线期间积累的消息(晚于基线)全部计入', () => {
+      const base = 1_700_000_000_000;
+      svc.onIncoming('a', base, true);
+      for (let i = 1; i <= 7; i++) svc.onIncoming('a', base + i * 1000, false);
+      expect(svc.countOf('a')).toBe(7);
+    });
+  });
+
+  describe('markRead', () => {
+    it('清零该 agent 的未读并推进基线', () => {
+      const base = 1_700_000_000_000;
+      svc.onIncoming('a', base, true);
+      svc.onIncoming('a', base + 1000, false);
+      svc.onIncoming('a', base + 2000, false);
+      expect(svc.countOf('a')).toBe(2);
+
+      svc.markRead('a', base + 2000);
+      expect(svc.countOf('a')).toBe(0);
+      // 基线已推进 → 早于它的消息不再计
+      expect(svc.onIncoming('a', base + 1500, false)).toBe(false);
+    });
+
+    it('只影响目标 agent,不误伤其它', () => {
+      const t = 1_700_000_000_000;
+      svc.onIncoming('a', t, true);
+      svc.onIncoming('a', t + 1, false);
+      svc.onIncoming('b', t, true);
+      svc.onIncoming('b', t + 1, false);
+
+      svc.markRead('a', t + 1);
+      expect(svc.countOf('a')).toBe(0);
+      expect(svc.countOf('b')).toBe(1);
+    });
+  });
+
+  describe('total 与多 agent', () => {
+    it('total 为各 agent 之和', () => {
+      const t = 1_700_000_000_000;
+      for (const id of ['a', 'b', 'c']) {
+        svc.onIncoming(id, t, true);
+        svc.onIncoming(id, t + 1, false);
+        svc.onIncoming(id, t + 2, false);
+      }
+      expect(svc.countOf('a')).toBe(2);
+      expect(svc.total()).toBe(6);
+    });
+  });
+
+  describe('持久化(退出浏览器不丢)', () => {
+    it('按用户隔离:不同用户互不串号', () => {
+      // 用户1
+      session.setSession('t1', { id: 'u1', name: 'A' });
+      svc.reload();
+      const t = 1_700_000_000_000;
+      svc.onIncoming('a', t, true);
+      svc.onIncoming('a', t + 1, false);
+      expect(svc.countOf('a')).toBe(1);
+
+      // 换用户2 → 重新载入应为空
+      session.setSession('t2', { id: 'u2', name: 'B' });
+      svc.reload();
+      expect(svc.countOf('a')).toBe(0);
+
+      // 切回用户1 → 计数恢复
+      session.setSession('t1', { id: 'u1', name: 'A' });
+      svc.reload();
+      expect(svc.countOf('a')).toBe(1);
+    });
+
+    it('reset 清空并移除存储', () => {
+      session.setSession('t1', { id: 'u1', name: 'A' });
+      const t = 1_700_000_000_000;
+      svc.onIncoming('a', t, true);
+      svc.onIncoming('a', t + 1, false);
+      svc.reset();
+      expect(svc.countOf('a')).toBe(0);
+      expect(svc.total()).toBe(0);
+    });
+  });
+
+  describe('边界', () => {
+    it('空 agentId 不计数', () => {
+      expect(svc.onIncoming('', 1_700_000_000_000, false)).toBe(false);
+      expect(svc.total()).toBe(0);
+    });
+  });
+});

+ 140 - 0
src/app/core/unread.service.ts

@@ -0,0 +1,140 @@
+import { computed, inject, Injectable, signal } from '@angular/core';
+import { SessionService } from './session.service';
+
+/**
+ * 未读计数 —— 唯一 owner
+ *
+ * 依据《数据访问层设计.md》§五(每域唯一 owner)与 SPEC FR-04。
+ *
+ * ## 口径
+ *
+ * ```
+ * 未读(agentId) = Agent 在该房间发来的、时间戳晚于我「最后已读时刻」的消息条数
+ * ```
+ *
+ * ## 为什么用「已读时间戳」而不是 SDK 的 liveEvent
+ *
+ * Matrix 的 `/sync` 初次同步会把**历史消息**逐条塞进 live timeline,并同样
+ * 触发 `Room.timeline`(实测 `data.liveEvent === true` 对历史回填**也为真**)。
+ * 若用 `liveEvent` 判据,刚连接就会把整个历史算成未读(实测出现过「39 条未读」)。
+ *
+ * 时间戳口径能同时正确处理三种情况:
+ * | 场景 | 行为 |
+ * |---|---|
+ * | 首次连接发现的历史 | 首次见到房间时把基线设为最新消息时间 → **不计** |
+ * | 离线期间 Agent 积累的消息 | 时间戳晚于已读基线 → **计入** ✅ |
+ * | 运行中实时到达的消息 | 时间戳晚于已读基线 → **计入** ✅ |
+ *
+ * ## 持久化
+ *
+ * `counts` 与 `lastReadAt` 都按用户隔离存 localStorage(退出浏览器不丢)。
+ */
+@Injectable({ providedIn: 'root' })
+export class UnreadService {
+  private readonly session = inject(SessionService);
+
+  /** agentId → 未读数 */
+  private readonly _counts = signal<Record<string, number>>({});
+  /** agentId → 最后已读时刻(epoch ms)。无记录 = 尚未见过该房间 */
+  private readonly _lastReadAt = signal<Record<string, number>>({});
+
+  readonly counts = this._counts.asReadonly();
+
+  /** 未读总数(底部 Tab / 导航红点用) */
+  readonly total = computed(() => Object.values(this._counts()).reduce((s, n) => s + n, 0));
+
+  /** 某数字生命的未读数 */
+  countOf(agentId: string): number {
+    return this._counts()[agentId] ?? 0;
+  }
+
+  /**
+   * 消息到达时的判定入口;返回是否应计入未读。
+   *
+   * @param agentId 消息所属数字生命
+   * @param ts      消息时间(epoch ms)
+   * @param catchingUp 🔴 `true` = 处于**初次同步回填阶段**。
+   *   该阶段收到的都是房间历史(且按时间升序到达),只推进已读基线、
+   *   **一律不计数**;否则整个历史都会被算成未读(实测出现过「39 条未读」)。
+   */
+  onIncoming(agentId: string, ts: number, catchingUp = false): boolean {
+    if (!agentId) return false;
+    const base = this._lastReadAt()[agentId];
+
+    // 回填阶段,或首次见到该房间 → 只推进基线,不计数
+    if (catchingUp || base === undefined || ts > base === false) {
+      if (ts > (base ?? 0)) this._lastReadAt.update((m) => ({ ...m, [agentId]: ts }));
+      if (catchingUp || base === undefined) this.persist();
+      return false;
+    }
+
+    if (ts <= base) return false; // 早于已读基线(历史 / 已读)
+
+    this._counts.update((c) => ({ ...c, [agentId]: (c[agentId] ?? 0) + 1 }));
+    this.persist();
+    return true;
+  }
+
+  /** 标记已读(进入会话 / 用户看到消息) */
+  markRead(agentId: string, at: number = Date.now()): void {
+    if (!agentId) return;
+    this._lastReadAt.update((m) => ({ ...m, [agentId]: at }));
+    this._counts.update((c) => {
+      if (!c[agentId]) return c;
+      const next = { ...c };
+      delete next[agentId];
+      return next;
+    });
+    this.persist();
+  }
+
+  /** 退出登录时清空(避免跨用户串号) */
+  reset(): void {
+    this._counts.set({});
+    this._lastReadAt.set({});
+    try {
+      localStorage.removeItem(this.storageKey());
+    } catch {
+      /* 隐私模式忽略 */
+    }
+  }
+
+  // ---------- 持久化(按用户隔离) ----------
+
+  private storageKey(): string {
+    const uid = this.session.me()?.id ?? 'anon';
+    return `fmode_unread_${uid}`;
+  }
+
+  private persist(): void {
+    try {
+      localStorage.setItem(
+        this.storageKey(),
+        JSON.stringify({ counts: this._counts(), lastReadAt: this._lastReadAt() }),
+      );
+    } catch {
+      /* 忽略 */
+    }
+  }
+
+  /** 登录态变化后重新载入(按用户隔离,防串号) */
+  reload(): void {
+    try {
+      const raw = localStorage.getItem(this.storageKey());
+      if (!raw) {
+        this._counts.set({});
+        this._lastReadAt.set({});
+        return;
+      }
+      const d = JSON.parse(raw) as {
+        counts?: Record<string, number>;
+        lastReadAt?: Record<string, number>;
+      };
+      this._counts.set(d.counts ?? {});
+      this._lastReadAt.set(d.lastReadAt ?? {});
+    } catch {
+      this._counts.set({});
+      this._lastReadAt.set({});
+    }
+  }
+}

+ 183 - 19
src/app/desktop/pages/chat/chat.ts

@@ -21,6 +21,10 @@ import {
 } from '../../../ui';
 import { ComposerComponent } from '../../../components/domain/composer/composer';
 import { MessageBubbleComponent } from '../../../components/domain/message-bubble/message-bubble';
+import { TypingIndicatorComponent } from '../../../components/domain/typing-indicator/typing-indicator';
+import { FilePreviewComponent } from '../../../components/preview/file-preview/file-preview';
+import { PresenceBadgeComponent } from '../../../components/domain/presence-badge/presence-badge';
+import { AgentPresence, agentPresence } from '../../../core/api/models';
 import { StatusBadgeComponent } from '../../../components/domain/status-badge/status-badge';
 import { ChatService } from '../../../core/chat.service';
 import { Deliverable, TASK_STATUS_TEXT } from '../../../core/api/models';
@@ -28,6 +32,7 @@ import { DeliverablesService } from '../../../core/deliverables.service';
 import { DeviceService } from '../../../core/device.service';
 import { fmtPercent, fmtTokens, usageOf } from '../../../core/format';
 import { LiveService } from '../../../core/live.service';
+import { UnreadService } from '../../../core/unread.service';
 import { TasksService } from '../../../core/tasks.service';
 
 /**
@@ -56,6 +61,9 @@ import { TasksService } from '../../../core/tasks.service';
     UiTooltip,
     ComposerComponent,
     MessageBubbleComponent,
+    TypingIndicatorComponent,
+    FilePreviewComponent,
+    PresenceBadgeComponent,
     StatusBadgeComponent,
   ],
   template: `
@@ -111,8 +119,13 @@ import { TasksService } from '../../../core/tasks.service';
                     <span class="conv-info">
                       <span class="conv-line">
                         <strong class="ellipsis">{{ c.name }}</strong>
-                        @if (c.unread > 0) {
-                          <span class="unread">{{ c.unread }}</span>
+                        <!--
+                          可对话状态:**配色 + 文字**胶囊(用户要求,比圆点清晰)。
+                          判定唯一实现在 models.agentPresence()(以接入态为准)。
+                        -->
+                        <app-presence-badge [agent]="live.agentById(c.id)" />
+                        @if (unreadOf(c.id) > 0) {
+                          <span class="unread">{{ unreadOf(c.id) }}</span>
                         }
                       </span>
                       <span class="conv-preview ellipsis">{{ c.currentTask || c.specialty }}</span>
@@ -168,21 +181,31 @@ import { TasksService } from '../../../core/tasks.service';
             </div>
           </header>
 
-          <!-- AC-04.9:连接状态可见 + 可重连 -->
-          @if (chat.connection() !== 'connected') {
+          <!--
+            连接状态提示(AC-04.9)。
+            🔴 区分两种「连不上」:容器离线 vs 自己在连接中 ——
+               否则用户分不清是网络问题还是对方容器没跑(实测反馈)。
+          -->
+          @if (chat.connection() !== 'connected' || currentContainerState() === 'unreported') {
             <div class="banner-hold">
-              <ui-alert
-                [type]="chat.connection() === 'connecting' ? 'info' : 'warning'"
-                [title]="chat.connection() === 'connecting' ? '正在连接…' : '连接已断开'"
-              >
-                @if (chat.connection() === 'disconnected') {
-                  <ui-button variant="outline" size="sm" (click)="reconnect()">重新连接</ui-button>
-                }
-              </ui-alert>
+              @if (currentContainerState() === 'unreported' && chat.connection() === 'connected') {
+                <ui-alert type="warning" title="容器状态未知,可能无法应答">
+                  已连接矩阵服务,但该容器的接入通道未上报在线状态,消息可能无人应答。
+                </ui-alert>
+              } @else {
+                <ui-alert
+                  [type]="chat.connection() === 'connecting' ? 'info' : 'warning'"
+                  [title]="chat.connection() === 'connecting' ? '正在连接…' : '连接已断开'"
+                >
+                  @if (chat.connection() === 'disconnected') {
+                    <ui-button variant="outline" size="sm" (click)="reconnect()">重新连接</ui-button>
+                  }
+                </ui-alert>
+              }
             </div>
           }
 
-          <div class="messages" #messages>
+          <div class="messages" #messages (scroll)="onMessagesScroll($event)">
             @switch (chat.state()) {
               @case ('loading') {
                 <div class="skeleton-hold">
@@ -205,14 +228,31 @@ import { TasksService } from '../../../core/tasks.service';
                 <ui-empty title="还没有消息" description="发送第一条消息开始协作。" />
               }
               @default {
+                <!-- AC-04.4:向上翻历史(滚动到顶部自动加载) -->
+                <div class="history-hold">
+                  @if (chat.paginating()) {
+                    <ui-skeleton variant="rect" height="40px" />
+                  } @else if (chat.hasMoreHistory()) {
+                    <ui-button variant="ghost" size="sm" (click)="loadEarlier()">
+                      加载更早的消息
+                    </ui-button>
+                  } @else {
+                    <span class="history-end">已到最早</span>
+                  }
+                </div>
                 @for (m of chat.messages(); track m.id) {
                   <app-message-bubble
                     [message]="m"
                     [own]="m.sender === 'me'"
                     (retry)="chat.retry($event)"
                     (openTask)="openTaskRail()"
+                    (preview)="openFile($event)"
                   />
                 }
+                <!-- 对方正在输入(FR-04;容器侧已实测在发 m.typing) -->
+                @if (chat.peerTyping()) {
+                  <app-typing-indicator [name]="chat.agent()?.name ?? '对方'" />
+                }
               }
             }
           </div>
@@ -349,15 +389,28 @@ import { TasksService } from '../../../core/tasks.service';
           }
         </div>
         <div class="preview-actions">
+          <!--
+            一个成果可能有多个附件(如 EN 版 / ZH 版),
+            按钮**必须带文件名**,否则用户不知道下的是哪一个(实测反馈:两个一样的「下载」)。
+          -->
           @for (art of d.artifacts; track art.name) {
-            <a class="download" [href]="art.url" [attr.download]="art.name">
-              <i class="fa-solid fa-download" aria-hidden="true"></i>下载
+            <a class="download" [href]="art.url" [attr.download]="art.name" [title]="art.name">
+              <i class="fa-solid fa-download" aria-hidden="true"></i>
+              <span class="dl-name">{{ art.name }}</span>
             </a>
           }
         </div>
       }
     </ui-modal>
 
+    <!-- 消息附件预览(按类型分发:图片/视频/pdf/md/文本/office) -->
+    <app-file-preview
+      [open]="!!previewFile()"
+      [url]="previewFile()?.url ?? ''"
+      [name]="previewFile()?.name ?? ''"
+      [mimeType]="previewFile()?.mimeType ?? ''"
+      (closed)="previewFile.set(null)"
+    />
   `,
   styles: [
     `
@@ -501,6 +554,31 @@ import { TasksService } from '../../../core/tasks.service';
         font-size: var(--fs-15);
         color: var(--ink);
       }
+      /* 预览操作区:多个附件时按钮带文件名,过长截断 */
+      .preview-actions {
+        display: flex;
+        flex-wrap: wrap;
+        gap: var(--sp-2);
+        margin-top: var(--sp-3);
+      }
+      .download {
+        display: inline-flex;
+        align-items: center;
+        gap: var(--sp-1);
+        max-width: 240px;
+        min-height: var(--touch-min);
+        padding: 0 var(--sp-3);
+        border-radius: var(--r-sm);
+        background: var(--primary);
+        color: var(--primary-content);
+        font-size: var(--fs-13);
+        text-decoration: none;
+      }
+      .dl-name {
+        overflow: hidden;
+        text-overflow: ellipsis;
+        white-space: nowrap;
+      }
       .conv-preview {
         font-size: var(--fs-12);
         color: var(--muted);
@@ -570,6 +648,18 @@ import { TasksService } from '../../../core/tasks.service';
         align-content: start;
       }
 
+      /* AC-04.4:历史加载区(顶部) */
+      .history-hold {
+        display: flex;
+        justify-content: center;
+        padding: var(--sp-1) 0;
+      }
+
+      .history-end {
+        font-size: var(--fs-12);
+        color: var(--muted);
+      }
+
       .rail-body {
         flex: 1;
         min-height: 0;
@@ -685,6 +775,7 @@ import { TasksService } from '../../../core/tasks.service';
 export class DkChatPage {
   readonly chat = inject(ChatService);
   readonly live = inject(LiveService);
+  private readonly unread = inject(UnreadService);
   private readonly deliverables = inject(DeliverablesService);
   private readonly tasks = inject(TasksService);
   private readonly route = inject(ActivatedRoute);
@@ -720,9 +811,45 @@ export class DkChatPage {
         currentTask: a.currentTask,
         unread: a.unread,
       }))
-      .sort((x, y) => y.unread - x.unread),
+      /*
+       * 🔴 **固定顺序**(用户实测反馈:选中项位置会乱跳)。
+       *    曾按未读数排序 → 未读一变整列重排,用户正在看的会话会跳位。
+       *    改为按 name 稳定排序(同 name 再按 id),顺序**不随运行期数据变化**。
+       */
+      .sort((x, y) => x.name.localeCompare(y.name, 'zh') || x.id.localeCompare(y.id)),
   );
 
+  /** 待预览的消息附件(null = 关闭) */
+  readonly previewFile = signal<{ url: string; name: string; mimeType: string } | null>(null);
+
+  /**
+   * 上一次「最后一条消息」的 id。
+   *
+   * 用于区分「尾部新增」(要滚底)与「头部插入」(分页,保持锚点)。
+   */
+  private lastTailId = '';
+
+  /** 打开消息附件预览(按类型分发由 app-file-preview 负责) */
+  openFile(f: { url: string; name: string; mimeType: string }): void {
+    this.previewFile.set(f);
+  }
+
+  /** 真实未读数(UnreadService 唯一来源;不用 Agent.unread 的占位值) */
+  unreadOf(agentId: string): number {
+    return this.unread.countOf(agentId);
+  }
+
+  /** 可对话状态(判定唯一实现在 models.agentPresence) */
+  containerState(agentId: string): AgentPresence {
+    return agentPresence(this.live.agentById(agentId));
+  }
+
+  /** 当前打开会话的容器状态(用于区分「容器离线」与「自己在连接中」) */
+  readonly currentContainerState = computed<AgentPresence>(() => {
+    const id = this.chat.agentId();
+    return id ? agentPresence(this.live.agentById(id)) : 'offline';
+  });
+
   readonly filteredConversations = computed(() => {
     const q = this.keyword().trim().toLowerCase();
     if (!q) return this.conversations();
@@ -737,10 +864,22 @@ export class DkChatPage {
   });
 
   constructor() {
-    // AC-04.2:新消息到达后滚动到底
+    /*
+     * AC-04.2:新消息到达后滚动到底。
+     *
+     * 🔴 **只在「尾部新增了消息」时滚底**(用户实测反馈:上翻历史时也被拉到底)。
+     *    上翻历史走的是 `paginate()` → `messages` 变长但**尾部未变** →
+     *    此处按「最后一条消息 id 是否变化」判定,分页时保持锚点不动。
+     */
     effect(() => {
-      this.chat.messages();
-      queueMicrotask(() => this.scrollToBottom());
+      const list = this.chat.messages();
+      const tailId = list.length ? list[list.length - 1].id : '';
+      const prev = this.lastTailId;
+      this.lastTailId = tailId;
+      // 首次(prev 为空)或尾部确实变了 → 滚底
+      if (prev === '' || tailId !== prev) {
+        queueMicrotask(() => this.scrollToBottom());
+      }
     });
 
     // 窄窗默认收起右栏(<1150)
@@ -846,4 +985,29 @@ export class DkChatPage {
     const el = this.messagesEl()?.nativeElement;
     if (el) el.scrollTop = el.scrollHeight;
   }
+
+  /**
+   * AC-04.4:滚动到顶部附近(<80px)自动加载更早历史。
+   *
+   * 加载前记录 `scrollHeight`,加载后按差值补偿 `scrollTop`,
+   * 使视口**锚定在原来的消息上**(不跳变)。
+   */
+  onMessagesScroll(_e: Event): void {
+    const el = this.messagesEl()?.nativeElement;
+    if (!el || el.scrollTop > 80) return;
+    if (this.chat.paginating() || !this.chat.hasMoreHistory()) return;
+    void this.loadEarlier();
+  }
+
+  /** 加载更早历史,并保持滚动锚点 */
+  async loadEarlier(): Promise<void> {
+    const el = this.messagesEl()?.nativeElement;
+    const beforeHeight = el?.scrollHeight ?? 0;
+    const grew = await this.chat.paginate();
+    if (!grew || !el) return;
+    // 内容变高后,把视口下移同样的增量 → 视觉上停在原位置
+    queueMicrotask(() => {
+      el.scrollTop = el.scrollHeight - beforeHeight;
+    });
+  }
 }

+ 4 - 2
src/app/desktop/pages/contacts/contacts.ts

@@ -3,6 +3,7 @@ import { Router } from '@angular/router';
 import { UiAvatar, UiButton, UiCard, UiEmpty, UiInput, UiSegmented, UiSkeleton } from '../../../ui';
 import { PageComponent } from '../../../components/shell/page/page';
 import { StatusBadgeComponent } from '../../../components/domain/status-badge/status-badge';
+import { PresenceBadgeComponent } from '../../../components/domain/presence-badge/presence-badge';
 import { Agent } from '../../../core/api/models';
 import { ContactsService } from '../../../core/contacts.service';
 import { DeviceService } from '../../../core/device.service';
@@ -30,6 +31,7 @@ import { fmtTokens, isMatrixReady } from '../../../core/format';
     UiSkeleton,
     PageComponent,
     StatusBadgeComponent,
+    PresenceBadgeComponent,
   ],
   template: `
     <app-page [fixedHeader]="true" title="通讯录" subtitle="AI 数字生命与同事,点击进入会话或档案。">
@@ -97,7 +99,7 @@ import { fmtTokens, isMatrixReady } from '../../../core/format';
                       <span class="agent-info">
                         <span class="agent-line">
                           <strong class="ellipsis">{{ a.name }}</strong>
-                          <app-status-badge [agent]="a" />
+                          <app-presence-badge [agent]="a" />
                         </span>
                         <span class="agent-specialty ellipsis">{{ a.specialty || '数字生命' }}</span>
                         <span class="agent-meta ellipsis">
@@ -150,7 +152,7 @@ import { fmtTokens, isMatrixReady } from '../../../core/format';
                                   <strong class="ellipsis">{{ a.name }}</strong>
                                   <small class="ellipsis">{{ a.specialty }}</small>
                                 </span>
-                                <app-status-badge [agent]="a" />
+                                <app-presence-badge [agent]="a" />
                               </button>
                             </li>
                           }

+ 69 - 5
src/app/desktop/pages/deliverables/deliverables.ts

@@ -1,4 +1,4 @@
-import { ChangeDetectionStrategy, Component, inject, OnInit, signal } from '@angular/core';
+import { ChangeDetectionStrategy, Component, computed, inject, OnInit, signal, viewChild } from '@angular/core';
 import { ActivatedRoute } from '@angular/router';
 import {
   UiButton,
@@ -11,6 +11,7 @@ import {
   UiTag,
 } from '../../../ui';
 import { PageComponent } from '../../../components/shell/page/page';
+import { InfiniteScrollDirective } from '../../../shared/infinite-scroll.directive';
 import { Deliverable } from '../../../core/api/models';
 import { DeliverablesService } from '../../../core/deliverables.service';
 import { LiveService } from '../../../core/live.service';
@@ -39,10 +40,11 @@ import { fmtDate } from '../../../core/time';
     UiSkeleton,
     UiTag,
     PageComponent,
+    InfiniteScrollDirective,
   ],
   template: `
     <app-page [fixedHeader]="true" [title]="'交付成果'" [subtitle]="'共 ' + deliverables.all().length + ' 份成果'">
-      <div class="toolbar">
+      <div pageToolbar class="toolbar">
         <ui-segmented
           [options]="viewOptions"
           [value]="deliverables.view()"
@@ -89,7 +91,7 @@ import { fmtDate } from '../../../core/time';
         @default {
           @if (deliverables.view() === 'project') {
             <div data-testid="results-project">
-              @for (g of deliverables.groups(); track g.projectName) {
+              @for (g of visibleGroups(); track g.projectName) {
                 <section class="group">
                   <h2 class="group-head">
                     {{ g.projectName }}
@@ -148,6 +150,16 @@ import { fmtDate } from '../../../core/time';
           }
         }
       }
+      <!-- 滚到底加载更多(分页) -->
+      @if (deliverables.state() === 'ready') {
+        @if (hasMore()) {
+          <div appInfiniteScroll (reachedBottom)="loadMore()" class="scroll-sentinel">
+            <ui-button variant="ghost" size="sm" (click)="loadMore()">加载更多</ui-button>
+          </div>
+        } @else if (deliverables.all().length > 0) {
+          <p class="list-end">已加载全部成果</p>
+        }
+      }
     </app-page>
 
     <!-- 预览:底座 ui-modal(内置遮罩/Esc/焦点陷阱) -->
@@ -211,13 +223,24 @@ import { fmtDate } from '../../../core/time';
       .group {
         margin-bottom: var(--sp-5);
       }
+      /*
+       * 分组头(项目名 / 月份):滚动时**粘顶**。
+       * sticky 顶走式 —— 下一个分组到达时顶走上一个(用户确认此效果)。
+       * 需背景色,否则内容透出。
+       */
       .group-head {
+        position: sticky;
+        top: 0;
+        z-index: var(--z-affix);
         display: flex;
         align-items: center;
         gap: var(--sp-2);
-        margin: 0 0 var(--sp-3);
+        margin: 0;
+        padding: var(--sp-2) 0;
+        background: var(--canvas);
         font-size: var(--fs-13);
-        color: var(--ink-2);
+        color: var(--muted);
+        font-weight: 500;
       }
       .count {
         font-size: var(--fs-11);
@@ -344,6 +367,47 @@ export class DkDeliverablesPage implements OnInit {
 
   readonly previewing = signal<Deliverable | null>(null);
   readonly date = fmtDate;
+  /*
+   * 成果分页:按「条」分页(时间线视图)与按「项目组」分页(项目视图)。
+   * 滚到底加载更多;搜索/切换视图时重置。
+   */
+  private readonly PAGE_SIZE = 20;
+  private readonly PAGE_GROUPS = 5;
+  private readonly shownItems = signal(this.PAGE_SIZE);
+  private readonly shownGroups = signal(this.PAGE_GROUPS);
+
+  private readonly infiniteScroll = viewChild(InfiniteScrollDirective);
+
+  /** 分页后的时间线条目 */
+  readonly visibleItems = computed(() => this.deliverables.filtered().slice(0, this.shownItems()));
+
+  /** 分页后的项目分组 */
+  readonly visibleGroups = computed(() => this.deliverables.groups().slice(0, this.shownGroups()));
+
+  /** 是否还有更多(按当前视图判断) */
+  readonly hasMore = computed(() =>
+    this.deliverables.view() === 'timeline'
+      ? this.shownItems() < this.deliverables.filtered().length
+      : this.shownGroups() < this.deliverables.groups().length,
+  );
+
+  /** 加载下一页 */
+  loadMore(): void {
+    if (!this.hasMore()) return;
+    if (this.deliverables.view() === 'timeline') {
+      this.shownItems.update((n) => n + this.PAGE_SIZE);
+    } else {
+      this.shownGroups.update((n) => n + this.PAGE_GROUPS);
+    }
+    this.infiniteScroll()?.reset();
+  }
+
+  /** 重置分页(切换视图 / 搜索 / 刷新时) */
+  resetPaging(): void {
+    this.shownItems.set(this.PAGE_SIZE);
+    this.shownGroups.set(this.PAGE_GROUPS);
+  }
+
   readonly viewOptions = [
     { label: '按项目', value: 'project' },
     { label: '按时间线', value: 'timeline' },

+ 86 - 5
src/app/desktop/pages/events/events.ts

@@ -1,12 +1,15 @@
-import { ChangeDetectionStrategy, Component, computed, inject, OnInit } from '@angular/core';
+import { ChangeDetectionStrategy, Component, computed, inject, OnInit, signal, viewChild } from '@angular/core';
 import { Router } from '@angular/router';
 import { UiAvatar, UiButton, UiCard, UiEmpty, UiSkeleton } from '../../../ui';
 import { PageComponent } from '../../../components/shell/page/page';
 import { FilterChipsComponent } from '../../../components/domain/filter-chips/filter-chips';
+import { InfiniteScrollDirective } from '../../../shared/infinite-scroll.directive';
 import { EventCategory, EventItem } from '../../../core/api/models';
 import { DeviceService } from '../../../core/device.service';
 import { EventsService } from '../../../core/events.service';
+import { DeliverablesService } from '../../../core/deliverables.service';
 import { LiveService } from '../../../core/live.service';
+import { TasksService } from '../../../core/tasks.service';
 import { fmtTime } from '../../../core/time';
 
 /**
@@ -28,6 +31,7 @@ import { fmtTime } from '../../../core/time';
     UiSkeleton,
     PageComponent,
     FilterChipsComponent,
+    InfiniteScrollDirective,
   ],
   template: `
     <app-page [fixedHeader]="true" title="事件" subtitle="交付、执行与告警,按时间聚合。">
@@ -42,7 +46,8 @@ import { fmtTime } from '../../../core/time';
         </ui-button>
       </ng-container>
 
-      <div class="toolbar">
+      <!-- 筛选区放入固定工具栏(pageToolbar 插槽)→ 不随列表滚动 -->
+      <div pageToolbar class="toolbar">
         <app-filter-chips
           ariaLabel="事件分类"
           [chips]="chips()"
@@ -83,7 +88,7 @@ import { fmtTime } from '../../../core/time';
         }
 
         @default {
-          @for (g of events.dayGroups(); track g.day) {
+          @for (g of visibleGroups(); track g.day) {
             <section class="day-group">
               <h2 class="day-head">
                 {{ g.day }}
@@ -119,6 +124,14 @@ import { fmtTime } from '../../../core/time';
               </ul>
             </section>
           }
+          <!-- 滚到底加载更多(分页) -->
+          @if (visibleGroups().length < events.dayGroups().length) {
+            <div appInfiniteScroll (reachedBottom)="loadMore()" class="scroll-sentinel">
+              <ui-button variant="ghost" size="sm" (click)="loadMore()">加载更多</ui-button>
+            </div>
+          } @else if (events.dayGroups().length > 0) {
+            <p class="list-end">已加载全部 {{ events.dayGroups().length }} 天的事件</p>
+          }
         }
       }
     </app-page>
@@ -141,11 +154,21 @@ import { fmtTime } from '../../../core/time';
       .day-group {
         margin-bottom: var(--sp-5);
       }
+      /*
+       * 日期分组头:滚动时**粘顶**(用户要求「标签黏在最上面直到下一个标签」)。
+       * 用的是 CSS sticky 顶走式 —— 下一个日期到达时把上一个向上顶走(iOS 通讯录手感)。
+       * 必须设背景色,否则内容会从下方透出。
+       */
       .day-head {
+        position: sticky;
+        top: 0;
+        z-index: var(--z-affix);
         display: flex;
         align-items: center;
         gap: var(--sp-2);
-        margin: 0 0 var(--sp-3);
+        margin: 0;
+        padding: var(--sp-2) 0;
+        background: var(--canvas);
         font-size: var(--fs-13);
         color: var(--muted);
         font-weight: 500;
@@ -225,17 +248,62 @@ import { fmtTime } from '../../../core/time';
         font-size: var(--fs-11);
         color: var(--muted);
       }
+      /* 分页哨兵与结尾提示 */
+      .scroll-sentinel {
+        display: flex;
+        justify-content: center;
+        padding: var(--sp-4) 0;
+      }
+      .list-end {
+        margin: var(--sp-4) 0 0;
+        text-align: center;
+        font-size: var(--fs-12);
+        color: var(--muted);
+      }
     `,
   ],
 })
 export class DkEventsPage implements OnInit {
   readonly events = inject(EventsService);
   private readonly live = inject(LiveService);
+  private readonly deliverables = inject(DeliverablesService);
+  private readonly tasks = inject(TasksService);
   private readonly router = inject(Router);
   private readonly device = inject(DeviceService);
 
   readonly time = fmtTime;
 
+  /*
+   * 事件分页:按「天」分页(一次展示 N 天,滚到底加载更多)。
+   * 事件本身已按天分组,按组分页比按条分页更符合阅读习惯
+   * (避免同一组被从中间截断)。
+   */
+  private readonly PAGE_DAYS = 5;
+  private readonly shownDays = signal(this.PAGE_DAYS);
+
+  /** 当前可见的天分组(分页切片) */
+  readonly visibleGroups = computed(() => this.events.dayGroups().slice(0, this.shownDays()));
+
+  /** 加载下一页 */
+  loadMore(): void {
+    if (this.shownDays() >= this.events.dayGroups().length) return;
+    this.shownDays.update((n) => n + this.PAGE_DAYS);
+    this.scrollReset();
+  }
+
+  /** 通知指令解除冷却(下次可再触发) */
+  private scrollReset(): void {
+    this.infiniteScroll()?.reset();
+  }
+
+  /** 分页哨兵指令实例 */
+  private readonly infiniteScroll = viewChild(InfiniteScrollDirective);
+
+  /** 切换筛选 / 刷新时重置分页(否则新数据被旧的"已显示天数"截断) */
+  private resetPaging(): void {
+    this.shownDays.set(this.PAGE_DAYS);
+  }
+
   readonly chips = computed(() => {
     const c = this.events.counts();
     return [
@@ -248,15 +316,28 @@ export class DkEventsPage implements OnInit {
 
   async ngOnInit(): Promise<void> {
     await this.events.ensureLoaded();
+    this.resetPaging();
   }
 
+  /**
+   * 刷新事件流。
+   *
+   * 🔴 事件是**派生数据**(来源:成果 + 实况 + 任务,见 `EventsService`),
+   *    故必须刷新**全部三个源头**。曾只调 `live.refresh()`(实况)→
+   *    成果变了事件却不更新,表现为「刷新按钮没效果」。
+   */
   async reload(): Promise<void> {
+    await Promise.all([
+      this.deliverables.refresh(),
+      this.live.refresh(),
+      this.tasks.refresh(),
+    ]);
     await this.events.ensureLoaded();
-    await this.live.refresh();
   }
 
   onSelect(key: string): void {
     this.events.setFilter(key as EventCategory | 'all');
+    this.resetPaging(); // 换筛选 → 从第一页看起
   }
 
   agentOf(id: string) {

+ 22 - 6
src/app/desktop/pages/mine/mine.ts

@@ -114,8 +114,12 @@ import { environment } from '../../../../environments/environment';
                 <h4>登录凭证</h4>
                 <p>如遇连接异常可尝试修复。</p>
               </div>
-              <ui-button variant="outline" icon="fa-solid fa-wrench" (click)="fixToken()">
-                修复 Token
+              <!--
+                「修复 Token」= 跳回登录页重新获取凭证。
+                原名含糊(用户实测问「这是什么意思」),故改名 + 补说明。
+              -->
+              <ui-button variant="outline" icon="fa-solid fa-rotate" (click)="fixToken()">
+                重新登录
               </ui-button>
             </div>
           </ui-card>
@@ -430,10 +434,22 @@ export class DkMinePage {
 
   readonly me = computed(() => this.session.me());
 
-  readonly accountFacts = computed(() => [
-    { label: '所属组织', value: this.me()?.companyId ? '企业空间' : '未绑定组织' },
-    { label: '工作空间', value: '团队协作空间' },
-  ]);
+  /**
+   * 账号信息。
+   *
+   * 🔴 此前是**硬编码假文案**(「企业空间」/「团队协作空间」)—— 用户实测反馈「没加载正确信息」。
+   *    现在:组织名取真实的 `Me.companyName`(实测 Company 表可拿到,如 `飞码AI`);
+   *    取不到时显示占位符,**不编造**(黄金规则 1)。
+   *    「工作空间」原为编造项,已移除(无对应数据源)。
+   */
+  readonly accountFacts = computed(() => {
+    const me = this.me();
+    const facts: { label: string; value: string }[] = [
+      { label: '所属组织', value: me?.companyName ?? (me?.companyId ? '(名称未取到)' : '未绑定组织') },
+    ];
+    if (me?.mobile) facts.push({ label: '手机号', value: me.mobile });
+    return facts;
+  });
 
   readonly themeMode = signal<'light' | 'dark'>(
     typeof document !== 'undefined' && document.documentElement.classList.contains('dark')

+ 133 - 7
src/app/desktop/pages/workbench/workbench.ts

@@ -1,4 +1,4 @@
-import { ChangeDetectionStrategy, Component, computed, inject, OnInit, signal } from '@angular/core';
+import { ChangeDetectionStrategy, Component, computed, inject, OnInit, signal, viewChild } from '@angular/core';
 import { Router } from '@angular/router';
 import {
   UiAvatar,
@@ -6,15 +6,18 @@ import {
   UiCard,
   UiDrawer,
   UiEmpty,
+  UiInput,
   UiProgress,
   UiSkeleton,
   UiStatistic,
 } from '../../../ui';
 import { PageComponent } from '../../../components/shell/page/page';
+import { InfiniteScrollDirective } from '../../../shared/infinite-scroll.directive';
+import { FilterChipsComponent } from '../../../components/domain/filter-chips/filter-chips';
 import { StatusBadgeComponent } from '../../../components/domain/status-badge/status-badge';
 import { Agent } from '../../../core/api/models';
 import { DeviceService } from '../../../core/device.service';
-import { fmtBalance, fmtPercent, fmtTokens, isMatrixReady, usageOf } from '../../../core/format';
+import { hasMetrics as hasMetricsFn, fmtBalance, fmtPercent, fmtTokens, isMatrixReady, usageOf } from '../../../core/format';
 import { LiveService } from '../../../core/live.service';
 import { TasksService } from '../../../core/tasks.service';
 import { fmtDuration } from '../../../core/time';
@@ -39,14 +42,34 @@ import { fmtDuration } from '../../../core/time';
     UiCard,
     UiDrawer,
     UiEmpty,
+    UiInput,
     UiProgress,
     UiSkeleton,
     UiStatistic,
+    InfiniteScrollDirective,
+    FilterChipsComponent,
     PageComponent,
     StatusBadgeComponent,
   ],
   template: `
     <app-page [fixedHeader]="true" title="工作台" subtitle="所有数字生命的运行实况与任务进展。">
+      <!-- 工具条:搜索 + 状态筛选(固定区,不随卡片滚动) -->
+      <div pageToolbar class="wb-toolbar">
+        <ui-input
+          prefixIcon="fa-solid fa-magnifying-glass"
+          [clearable]="true"
+          placeholder="搜索数字生命"
+          [value]="keyword()"
+          (valueChange)="onSearch($any($event))"
+        />
+        <app-filter-chips
+          ariaLabel="运行状态"
+          [chips]="statusChips()"
+          [active]="statusFilter()"
+          (select)="onStatusSelect($event)"
+        />
+      </div>
+
       <ng-container pageActions>
         <ui-button
           variant="outline"
@@ -149,7 +172,7 @@ import { fmtDuration } from '../../../core/time';
 
           <!-- 实况卡片网格:流动列宽,窗口变宽就多排几列 -->
           <div class="grid">
-            @for (a of live.agents(); track a.id) {
+            @for (a of visibleAgents(); track a.id) {
               <ui-card>
                 <div class="card-head">
                   <ui-avatar [name]="a.name" />
@@ -165,7 +188,7 @@ import { fmtDuration } from '../../../core/time';
                   <p class="ellipsis">{{ a.currentTask || '暂无进行中的任务' }}</p>
                 </div>
 
-                @if (ready(a)) {
+                @if (hasMetrics(a)) {
                   <div class="metrics">
                     <div class="metric">
                       <span class="metric-label">CPU</span>
@@ -185,10 +208,15 @@ import { fmtDuration } from '../../../core/time';
                       {{ balance(a) }}
                     </ui-button>
                   </div>
-                } @else {
+                } @else if (ready(a)) {
+                  <!--
+                    已接入但容器**未上报 host 指标**。
+                    🔴 此前直接落到「尚未接入」或显示 CPU 0%(误导)——
+                        0% 与「未上报」是两回事,如实区分(不编造数据)。
+                  -->
                   <p class="not-ready">
-                    <i class="fa-solid fa-plug-circle-xmark" aria-hidden="true"></i>
-                    尚未接入 FMODE,暂无运行指标。
+                    <i class="fa-solid fa-clock-rotate-left" aria-hidden="true"></i>
+                    容器已接入但尚未上报运行指标(CPU/内存)。
                   </p>
                 }
 
@@ -205,6 +233,16 @@ import { fmtDuration } from '../../../core/time';
           </div>
         }
       }
+      <!-- 滚到底加载更多(分页) -->
+      @if (live.state() === 'ready') {
+        @if (hasMore()) {
+          <div appInfiniteScroll (reachedBottom)="loadMore()" class="scroll-sentinel">
+            <ui-button variant="ghost" size="sm" (click)="loadMore()">加载更多</ui-button>
+          </div>
+        } @else if (filteredAgents().length > 0) {
+          <p class="list-end">已加载全部 {{ filteredAgents().length }} 个数字生命</p>
+        }
+      }
     </app-page>
 
     <!-- 通道详情:底座 ui-drawer(内置遮罩/Esc/焦点陷阱) -->
@@ -248,6 +286,24 @@ import { fmtDuration } from '../../../core/time';
       }
 
       /* 卡片网格:同上,minmax 决定断点,无需媒体查询 */
+      .wb-toolbar {
+        display: flex;
+        align-items: center;
+        gap: var(--sp-3);
+        flex-wrap: wrap;
+        margin-bottom: var(--sp-4);
+      }
+      .scroll-sentinel {
+        display: flex;
+        justify-content: center;
+        padding: var(--sp-4) 0;
+      }
+      .list-end {
+        margin: var(--sp-4) 0 0;
+        text-align: center;
+        font-size: var(--fs-12);
+        color: var(--muted);
+      }
       .grid {
         display: grid;
         grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
@@ -458,6 +514,71 @@ export class DkWorkbenchPage implements OnInit {
     await this.tasks.ensureAllLoaded();
   }
 
+  /*
+   * 工作台列表:搜索 + 状态筛选 + 分页(用户要求统一为「搜索+参数筛选+分页」)。
+   */
+  readonly keyword = signal('');
+  readonly statusFilter = signal<'all' | 'running' | 'idle' | 'problem'>('all');
+
+  private readonly PAGE_SIZE = 12;
+  private readonly shown = signal(this.PAGE_SIZE);
+  private readonly infiniteScroll = viewChild(InfiniteScrollDirective);
+
+  /** 筛选后的全集(未分页) */
+  readonly filteredAgents = computed(() => {
+    const q = this.keyword().trim().toLowerCase();
+    const f = this.statusFilter();
+    return this.live.agents().filter((a) => {
+      if (f === 'running' && a.status !== 'running') return false;
+      if (f === 'idle' && a.status !== 'idle') return false;
+      // 「异常」= error/offline/stopped
+      if (f === 'problem' && !['error', 'offline', 'stopped'].includes(a.status)) return false;
+      if (!q) return true;
+      return (
+        a.name.toLowerCase().includes(q) || (a.specialty ?? '').toLowerCase().includes(q)
+      );
+    });
+  });
+
+  /** 分页后可见的数字生命 */
+  readonly visibleAgents = computed(() => this.filteredAgents().slice(0, this.shown()));
+
+  readonly hasMore = computed(() => this.shown() < this.filteredAgents().length);
+
+  readonly statusChips = computed(() => {
+    const all = this.live.agents();
+    return [
+      { key: 'all', label: '全部', count: all.length },
+      { key: 'running', label: '执行中', count: all.filter((a) => a.status === 'running').length },
+      { key: 'idle', label: '空闲', count: all.filter((a) => a.status === 'idle').length },
+      {
+        key: 'problem',
+        label: '异常',
+        count: all.filter((a) => ['error', 'offline', 'stopped'].includes(a.status)).length,
+      },
+    ];
+  });
+
+  onSearch(v: string): void {
+    this.keyword.set(v);
+    this.resetPaging();
+  }
+
+  onStatusSelect(key: string): void {
+    this.statusFilter.set(key as 'all' | 'running' | 'idle' | 'problem');
+    this.resetPaging();
+  }
+
+  loadMore(): void {
+    if (!this.hasMore()) return;
+    this.shown.update((n) => n + this.PAGE_SIZE);
+    this.infiniteScroll()?.reset();
+  }
+
+  resetPaging(): void {
+    this.shown.set(this.PAGE_SIZE);
+  }
+
   async refresh(): Promise<void> {
     await this.live.refresh();
   }
@@ -466,6 +587,11 @@ export class DkWorkbenchPage implements OnInit {
     return isMatrixReady(a);
   }
 
+  /** 是否有可展示的运行指标(已接入 **且** 容器上报过 host);模板判定用 */
+  hasMetrics(a: Agent): boolean {
+    return hasMetricsFn(a);
+  }
+
   /** 进度条需要 0–100 的数值;未接入或无数据时归零 */
   raw(v?: number): number {
     return Math.min(100, Math.max(0, v ?? 0));

+ 24 - 1
src/app/desktop/shell/desktop-shell.ts

@@ -2,6 +2,7 @@ import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/cor
 import { Router, RouterOutlet } from '@angular/router';
 import { DeviceService } from '../../core/device.service';
 import { LiveService } from '../../core/live.service';
+import { MatrixSessionService } from '../../core/matrix-session.service';
 import { PaletteService } from '../../core/palette.service';
 import { UiTag } from '../../ui';
 import { DkNavComponent } from './dk-nav/dk-nav';
@@ -37,6 +38,11 @@ import { DkNavComponent } from './dk-nav/dk-nav';
           <h2>当前窗口适合手机端</h2>
           <p>窗口宽度小于 768px。你可以切到手机端继续,或放大窗口回到桌面布局。</p>
           <button type="button" class="narrow-btn" (click)="goMobile()">切换到手机端</button>
+          @if (device.isForced()) {
+            <button type="button" class="narrow-btn ghost" (click)="resetToAuto()">
+              恢复自动识别
+            </button>
+          }
         </div>
       </div>
     }
@@ -112,12 +118,19 @@ import { DkNavComponent } from './dk-nav/dk-nav';
         font-size: var(--fs-13);
         cursor: pointer;
       }
+      /* 后置覆盖:次要按钮(恢复自动识别) */
+      .narrow-btn.ghost {
+        background: transparent;
+        color: var(--primary);
+        border: 1px solid var(--edge);
+      }
     `,
   ],
 })
 export class DesktopShellComponent {
-  private readonly device = inject(DeviceService);
+  readonly device = inject(DeviceService);
   private readonly live = inject(LiveService);
+  private readonly matrix = inject(MatrixSessionService);
   private readonly palette = inject(PaletteService);
   private readonly router = inject(Router);
 
@@ -127,6 +140,8 @@ export class DesktopShellComponent {
   constructor() {
     // 🔴 唯一轮询器的启停点
     this.live.start();
+    // 🔴 Matrix 会话级连接(常驻,未读计数依赖它)
+    this.matrix.bootstrap();
     this.palette.init();
     if (typeof window !== 'undefined') {
       window.addEventListener('resize', () => this.narrowBelow768.set(this.isNarrow()));
@@ -138,6 +153,14 @@ export class DesktopShellComponent {
     void this.router.navigateByUrl(path, { replaceUrl: true });
   }
 
+  /** FR-10 AC-10.1:恢复自动识别(清除手动端偏好,按 UA/视口重新判定) */
+  resetToAuto(): void {
+    const mode = this.device.resetToAuto();
+    void this.router.navigateByUrl(mode === 'mobile' ? '/mobile/home' : '/desktop/chat', {
+      replaceUrl: true,
+    });
+  }
+
   private isNarrow(): boolean {
     return typeof window !== 'undefined' && window.innerWidth < 768;
   }

+ 58 - 3
src/app/mobile/pages/chat/chat.ts

@@ -5,14 +5,17 @@ import {
   ChangeDetectionStrategy,
   Component,
   computed,
+  ElementRef,
   inject,
   OnInit,
   signal,
+  viewChild,
 } from '@angular/core';
 import { ActivatedRoute, Router } from '@angular/router';
 import { ComposerComponent } from '../../../components/domain/composer/composer';
 import { MessageBubbleComponent } from '../../../components/domain/message-bubble/message-bubble';
-import { UiMButton, UiMNoticeBar, UiMPopup } from '../../../ui';
+import { TypingIndicatorComponent } from '../../../components/domain/typing-indicator/typing-indicator';
+import { UiMButton, UiMNoticeBar, UiMPopup, UiMSkeleton } from '../../../ui';
 import { Deliverable } from '../../../core/api/models';
 import { ChatService } from '../../../core/chat.service';
 import { ProjectsService } from '../../../core/projects.service';
@@ -29,7 +32,7 @@ import { LiveService } from '../../../core/live.service';
 @Component({
   selector: 'app-mb-chat-page',
   changeDetection: ChangeDetectionStrategy.OnPush,
-  imports: [ComposerComponent, MessageBubbleComponent, MStatePanelComponent, MStatusBadgeComponent, MAvatarComponent, UiMButton, UiMNoticeBar, UiMPopup],
+  imports: [ComposerComponent, MessageBubbleComponent, TypingIndicatorComponent, MStatePanelComponent, MStatusBadgeComponent, MAvatarComponent, UiMButton, UiMNoticeBar, UiMPopup, UiMSkeleton],
   template: `
     <div class="full-chat m-page--fixed">
       <header class="chat-head">
@@ -59,7 +62,7 @@ import { LiveService } from '../../../core/live.service';
         />
       }
 
-      <div class="messages" #messages>
+      <div class="messages" #messages (scroll)="onMessagesScroll($event)">
         @switch (chat.state()) {
           @case ('loading') {
             <app-m-state-panel state="loading" loadingText="正在加载消息…" />
@@ -71,6 +74,18 @@ import { LiveService } from '../../../core/live.service';
             <app-m-state-panel state="empty" title="还没有消息" [retryable]="false" />
           }
           @default {
+            <!-- AC-04.4:向上翻历史(滚动到顶部自动加载) -->
+            <div class="history-hold">
+              @if (chat.paginating()) {
+                <ui-m-skeleton height="16px" />
+              } @else if (chat.hasMoreHistory()) {
+                <ui-m-button variant="ghost" size="small" (click)="loadEarlier()">
+                  加载更早的消息
+                </ui-m-button>
+              } @else {
+                <span class="history-end">已到最早</span>
+              }
+            </div>
             @for (m of chat.messages(); track m.id) {
               <app-message-bubble
                 [message]="m"
@@ -78,6 +93,10 @@ import { LiveService } from '../../../core/live.service';
                 (retry)="chat.retry($event)"
               />
             }
+            <!-- 对方正在输入(FR-04;容器侧已实测在发 m.typing) -->
+            @if (chat.peerTyping()) {
+              <app-typing-indicator [name]="chat.agent()?.name ?? '对方'" />
+            }
           }
         }
       </div>
@@ -311,6 +330,18 @@ import { LiveService } from '../../../core/live.service';
         /* 输入栏在文档流内(非 fixed),故此处仅留呼吸空间 */
         background: var(--canvas);
       }
+
+      /* AC-04.4:历史加载区(顶部) */
+      .history-hold {
+        display: flex;
+        justify-content: center;
+        padding: var(--sp-1) 0;
+      }
+
+      .history-end {
+        font-size: var(--fs-12);
+        color: var(--muted);
+      }
       .sheet {
         width: 100%;
         max-height: min(88dvh, 760px);
@@ -668,6 +699,9 @@ export class MbChatPage implements OnInit {
   /** 抽屉内联预览的成果(用户要求:直接预览,不跳转) */
   readonly previewing = signal<Deliverable | null>(null);
 
+  /** 消息区容器(分页滚动锚点用) */
+  private readonly messagesEl = viewChild<ElementRef<HTMLElement>>('messages');
+
   readonly agentDeliverables = computed(() => {
     const id = this.chat.agentId();
     return id ? this.deliverables.byAgent(id) : [];
@@ -700,6 +734,27 @@ export class MbChatPage implements OnInit {
     if (id) void this.chat.open(id);
   }
 
+  /**
+   * AC-04.4:滚动到顶部附近(<80px)自动加载更早历史。
+   * 加载后按 scrollHeight 差值补偿 scrollTop,保持滚动锚点不跳变。
+   */
+  onMessagesScroll(_e: Event): void {
+    const el = this.messagesEl()?.nativeElement;
+    if (!el || el.scrollTop > 80) return;
+    if (this.chat.paginating() || !this.chat.hasMoreHistory()) return;
+    void this.loadEarlier();
+  }
+
+  async loadEarlier(): Promise<void> {
+    const el = this.messagesEl()?.nativeElement;
+    const beforeHeight = el?.scrollHeight ?? 0;
+    const grew = await this.chat.paginate();
+    if (!grew || !el) return;
+    queueMicrotask(() => {
+      el.scrollTop = el.scrollHeight - beforeHeight;
+    });
+  }
+
   /** 内联预览(不跳路由) */
   preview(d: Deliverable): void {
     this.previewing.set(d);

+ 26 - 4
src/app/mobile/pages/deliverables/deliverables.ts

@@ -1,12 +1,13 @@
-import { ChangeDetectionStrategy, Component, computed, inject, OnInit, signal } from '@angular/core';
+import { ChangeDetectionStrategy, Component, computed, inject, OnInit, signal, viewChild } from '@angular/core';
 import { MFilterRowComponent } from '../../components/m-filter-row/m-filter-row';
 import { MStatePanelComponent } from '../../components/m-state-panel/m-state-panel';
 import { ActivatedRoute } from '@angular/router';
-import { UiMPopup,  UiMTag } from '../../../ui';
+import { UiMButton, UiMPopup,  UiMTag } from '../../../ui';
 import { UiMSearch } from '../../../ui';
 import { Deliverable } from '../../../core/api/models';
 import { DeliverablesService } from '../../../core/deliverables.service';
 import { LiveService } from '../../../core/live.service';
+import { InfiniteScrollDirective } from '../../../shared/infinite-scroll.directive';
 import { fmtDate } from '../../../core/time';
 
 /**
@@ -16,7 +17,7 @@ import { fmtDate } from '../../../core/time';
 @Component({
   selector: 'app-mb-deliverables-page',
   changeDetection: ChangeDetectionStrategy.OnPush,
-  imports: [MStatePanelComponent, UiMTag, UiMSearch, UiMPopup, MFilterRowComponent],
+  imports: [MStatePanelComponent, UiMTag, UiMSearch, UiMPopup, MFilterRowComponent, InfiniteScrollDirective, UiMButton],
   template: `
     <div class="m-page">
       <header class="m-head">
@@ -57,7 +58,7 @@ import { fmtDate } from '../../../core/time';
             <app-m-state-panel state="empty" title="没有匹配的成果" message="试试项目名称、文件名或成果标题。" [retryable]="false" />
           } @else if (deliverables.view() === 'project') {
             <div data-testid="results-project">
-              @for (g of deliverables.groups(); track g.projectName) {
+              @for (g of visibleGroups(); track g.projectName) {
                 <h2 class="group-head">{{ g.projectName }} <span class="count">{{ g.count }}</span></h2>
                 <div class="rows">
                   @for (d of g.items; track d.id) {
@@ -449,6 +450,27 @@ export class MbDeliverablesPage implements OnInit {
   readonly previewing = signal<Deliverable | null>(null);
   readonly date = fmtDate;
 
+  /*
+   * 成果分页(移动):项目视图按「组」(一次 5 组);与桌面同口径。
+   */
+  private readonly PAGE_GROUPS = 5;
+  private readonly shownGroups = signal(this.PAGE_GROUPS);
+  private readonly infiniteScroll = viewChild(InfiniteScrollDirective);
+
+  readonly visibleGroups = computed(() => this.deliverables.groups().slice(0, this.shownGroups()));
+
+  readonly hasMore = computed(() => this.shownGroups() < this.deliverables.groups().length);
+
+  loadMore(): void {
+    if (!this.hasMore()) return;
+    this.shownGroups.update((n) => n + this.PAGE_GROUPS);
+    this.infiniteScroll()?.reset();
+  }
+
+  resetPaging(): void {
+    this.shownGroups.set(this.PAGE_GROUPS);
+  }
+
   readonly viewFilterItems = [
     { key: 'project', label: '按项目' },
     { key: 'timeline', label: '按时间线' },

+ 34 - 4
src/app/mobile/pages/events/events.ts

@@ -1,12 +1,13 @@
-import { ChangeDetectionStrategy, Component, computed, inject, OnInit, signal } from '@angular/core';
+import { ChangeDetectionStrategy, Component, computed, inject, OnInit, signal, viewChild } from '@angular/core';
 import { MFilterRowComponent } from '../../components/m-filter-row/m-filter-row';
 import { MStatePanelComponent } from '../../components/m-state-panel/m-state-panel';
 import { Router } from '@angular/router';
-import { UiMToastService } from '../../../ui';
+import { UiMButton, UiMToastService } from '../../../ui';
 import { EventCategory } from '../../../core/api/models';
 import { EventsService } from '../../../core/events.service';
 import { LiveService } from '../../../core/live.service';
 import { fmtTime } from '../../../core/time';
+import { InfiniteScrollDirective } from '../../../shared/infinite-scroll.directive';
 
 /**
  * 移动·事件(SPEC FR-06 移动形态)
@@ -15,7 +16,7 @@ import { fmtTime } from '../../../core/time';
 @Component({
   selector: 'app-mb-events-page',
   changeDetection: ChangeDetectionStrategy.OnPush,
-  imports: [MStatePanelComponent, MFilterRowComponent],
+  imports: [MStatePanelComponent, MFilterRowComponent, InfiniteScrollDirective, UiMButton],
   template: `
     <div class="m-page">
       <header class="m-head">
@@ -43,7 +44,7 @@ import { fmtTime } from '../../../core/time';
           <app-m-state-panel state="empty" title="暂无事件" [retryable]="false" />
         }
         @default {
-          @for (g of events.dayGroups(); track g.day) {
+          @for (g of visibleGroups(); track g.day) {
             <section class="day-group">
               <h2 class="day-head">{{ g.day }} <span class="count">{{ g.items.length }}</span></h2>
               <div class="rows">
@@ -69,6 +70,14 @@ import { fmtTime } from '../../../core/time';
               </div>
             </section>
           }
+          <!-- 滚到底加载更多(分页) -->
+          @if (visibleGroups().length < events.dayGroups().length) {
+            <div appInfiniteScroll (reachedBottom)="loadMore()" class="scroll-sentinel">
+              <ui-m-button variant="ghost" size="small" (click)="loadMore()">加载更多</ui-m-button>
+            </div>
+          } @else if (events.dayGroups().length > 0) {
+            <p class="list-end">已加载全部 {{ events.dayGroups().length }} 天</p>
+          }
         }
       }
     </div>
@@ -190,6 +199,27 @@ export class MbEventsPage implements OnInit {
   private readonly toast = inject(UiMToastService);
 
   readonly time = fmtTime;
+
+  /*
+   * 事件分页(移动):按天分组分页,与桌面同口径(一次 5 天)。
+   */
+  private readonly PAGE_DAYS = 5;
+  private readonly shownDays = signal(this.PAGE_DAYS);
+  private readonly infiniteScroll = viewChild(InfiniteScrollDirective);
+
+  readonly visibleGroups = computed(() =>
+    this.events.dayGroups().slice(0, this.shownDays()),
+  );
+
+  loadMore(): void {
+    if (this.shownDays() >= this.events.dayGroups().length) return;
+    this.shownDays.update((n) => n + this.PAGE_DAYS);
+    this.infiniteScroll()?.reset();
+  }
+
+  private resetPaging(): void {
+    this.shownDays.set(this.PAGE_DAYS);
+  }
   readonly refreshing = signal(false);
 
   readonly filterItems = computed(() => {

+ 9 - 2
src/app/mobile/pages/home/home.ts

@@ -6,6 +6,7 @@ import { DeliverablesService } from '../../../core/deliverables.service';
 import { EventsService } from '../../../core/events.service';
 import { isMatrixReady } from '../../../core/format';
 import { LiveService } from '../../../core/live.service';
+import { UnreadService } from '../../../core/unread.service';
 import { SessionService } from '../../../core/session.service';
 import { TasksService } from '../../../core/tasks.service';
 import { fmtRelTime } from '../../../core/time';
@@ -177,8 +178,8 @@ import { fmtRelTime } from '../../../core/time';
                     </span>
                     <span class="conv-preview ellipsis">{{ a.currentTask || a.specialty }}</span>
                   </span>
-                  @if (a.unread > 0) {
-                    <span class="unread">{{ a.unread }}</span>
+                  @if (unreadOf(a.id) > 0) {
+                    <span class="unread">{{ unreadOf(a.id) }}</span>
                   }
                 </button>
               }
@@ -438,6 +439,7 @@ import { fmtRelTime } from '../../../core/time';
 export class MbHomePage implements OnInit {
   readonly events = inject(EventsService);
   readonly live = inject(LiveService);
+  private readonly unread = inject(UnreadService);
   readonly deliverables = inject(DeliverablesService);
   private readonly tasks = inject(TasksService);
   private readonly session = inject(SessionService);
@@ -455,6 +457,11 @@ export class MbHomePage implements OnInit {
   readonly readyAgents = computed(() => this.live.agents().filter((a) => isMatrixReady(a)));
   readonly recentAgents = computed(() => this.readyAgents().slice(0, 6));
 
+  /** 真实未读数(UnreadService 唯一来源) */
+  unreadOf(agentId: string): number {
+    return this.unread.countOf(agentId);
+  }
+
   async ngOnInit(): Promise<void> {
     await this.reload();
   }

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

@@ -6,6 +6,7 @@ import { MFilterRowComponent } from '../../components/m-filter-row/m-filter-row'
 import { MStatePanelComponent } from '../../components/m-state-panel/m-state-panel';
 import { isMatrixReady } from '../../../core/format';
 import { LiveService } from '../../../core/live.service';
+import { UnreadService } from '../../../core/unread.service';
 import { fmtRelTime } from '../../../core/time';
 
 /**
@@ -252,6 +253,7 @@ import { fmtRelTime } from '../../../core/time';
 export class MbMessagesPage implements OnInit {
   readonly live = inject(LiveService);
   private readonly router = inject(Router);
+  private readonly unread = inject(UnreadService);
 
   readonly tab = signal<'all' | 'unread'>('all');
   readonly keyword = signal('');
@@ -266,7 +268,8 @@ export class MbMessagesPage implements OnInit {
         name: a.name,
         specialty: a.specialty,
         currentTask: a.currentTask,
-        unread: a.unread,
+        // 真实未读数(UnreadService 唯一来源)
+        unread: this.unread.countOf(a.id),
         updatedAt: a.updatedAt,
       })),
   );

+ 11 - 3
src/app/mobile/pages/mine/mine.ts

@@ -32,7 +32,7 @@ import { environment } from '../../../../environments/environment';
 
       <dl class="facts">
         <div><dt>所属组织</dt><dd>{{ orgName() }}</dd></div>
-        <div><dt>工作空间</dt><dd>团队协作空间</dd></div>
+        <!-- 「工作空间」原为编造项(无数据源),已移除(黄金规则 1:不编造) -->
         <div><dt>当前端</dt><dd>{{ mode() === 'mobile' ? '手机端' : '桌面端' }}</dd></div>
       </dl>
 
@@ -91,7 +91,8 @@ import { environment } from '../../../../environments/environment';
             <h4>登录凭证</h4>
             <p>如遇异常可尝试修复。</p>
           </div>
-          <ui-m-button variant="secondary" (click)="fixToken()">修复 Token</ui-m-button>
+          <!-- 「修复 Token」= 跳登录页重新取证;原名含糊,已改为「重新登录」 -->
+          <ui-m-button variant="secondary" (click)="fixToken()">重新登录</ui-m-button>
         </div>
       </section>
 
@@ -503,7 +504,14 @@ export class MbMinePage {
   readonly initial = computed(() => (this.me()?.name ?? '?').slice(0, 1));
 
   /** 组织名:真实实现由服务端 Company 返回;未就绪时显示中性占位 */
-  readonly orgName = computed(() => this.me()?.companyId ? '企业空间' : '未绑定组织');
+  /**
+   * 所属组织名 —— 取真实的 `Me.companyName`(实测 Company 表可拿到,如「飞码AI」)。
+   * 🔴 此前是硬编码「企业空间」(假文案,用户实测反馈「没加载正确信息」)。
+   */
+  readonly orgName = computed(() => {
+    const me = this.me();
+    return me?.companyName ?? (me?.companyId ? '(名称未取到)' : '未绑定组织');
+  });
 
   readonly themeMode = signal<'light' | 'dark'>(
     typeof document !== 'undefined' && document.documentElement.classList.contains('dark')

+ 58 - 5
src/app/mobile/pages/workbench/workbench.ts

@@ -1,14 +1,15 @@
 import { MAvatarComponent } from '../../components/m-avatar/m-avatar';
 import { MStatePanelComponent } from '../../components/m-state-panel/m-state-panel';
 import { MStatusBadgeComponent } from '../../components/m-status-badge/m-status-badge';
-import { ChangeDetectionStrategy, Component, computed, inject, OnInit, signal } from '@angular/core';
+import { ChangeDetectionStrategy, Component, computed, inject, OnInit, signal, viewChild } from '@angular/core';
 import { Router } from '@angular/router';
 import { UiMNoticeBar, UiMProgress, UiMSearch, UiMToastService } from '../../../ui';
 import { Agent } from '../../../core/api/models';
 import { LiveService } from '../../../core/live.service';
 import { TasksService } from '../../../core/tasks.service';
-import { fmtBalance, fmtPercent, fmtTokens, isMatrixReady, usageOf } from '../../../core/format';
+import { hasMetrics as hasMetricsFn, fmtBalance, fmtPercent, fmtTokens, isMatrixReady, usageOf } from '../../../core/format';
 import { fmtDuration } from '../../../core/time';
+import { InfiniteScrollDirective } from '../../../shared/infinite-scroll.directive';
 
 /**
  * 移动·工作台(SPEC FR-07 移动形态)
@@ -19,7 +20,7 @@ import { fmtDuration } from '../../../core/time';
 @Component({
   selector: 'app-mb-workbench-page',
   changeDetection: ChangeDetectionStrategy.OnPush,
-  imports: [MAvatarComponent, MStatePanelComponent, MStatusBadgeComponent, UiMNoticeBar, UiMProgress, UiMSearch],
+  imports: [MAvatarComponent, MStatePanelComponent, MStatusBadgeComponent, UiMNoticeBar, UiMProgress, UiMSearch, InfiniteScrollDirective],
   template: `
     <div class="m-page m-page--fixed">
       <header class="m-head">
@@ -93,7 +94,7 @@ import { fmtDuration } from '../../../core/time';
         }
         @default {
           <div class="cards">
-            @for (a of visibleAgents(); track a.id) {
+            @for (a of pagedAgents(); track a.id) {
               <article class="card">
                 <header class="card-head">
                   <app-m-avatar [name]="a.name" size="sm" />
@@ -106,7 +107,7 @@ import { fmtDuration } from '../../../core/time';
 
                 <p class="card-task truncate">{{ a.currentTask || '暂无进行中的任务' }}</p>
 
-                @if (ready(a)) {
+                @if (hasMetrics(a)) {
                   <div class="metrics">
                     <div class="metric">
                       <span>CPU {{ pct(a.host?.cpuPercent) }}</span>
@@ -123,6 +124,9 @@ import { fmtDuration } from '../../../core/time';
                       {{ balance(a) }}
                     </button>
                   </div>
+                } @else if (ready(a)) {
+                  <!-- 已接入但容器未上报 host 指标(0% 与「未上报」是两回事,如实区分) -->
+                  <p class="not-ready">容器已接入但尚未上报运行指标。</p>
                 } @else {
                   <p class="not-ready">尚未接入 FMODE,暂无运行指标。</p>
                 }
@@ -134,6 +138,12 @@ import { fmtDuration } from '../../../core/time';
               </article>
             }
           </div>
+          <!-- 滚到底加载更多(分页):搜索过滤后再分页 -->
+          @if (hasMore()) {
+            <div appInfiniteScroll (reachedBottom)="loadMore()" class="scroll-sentinel">
+              <button type="button" class="load-more" (click)="loadMore()">加载更多</button>
+            </div>
+          }
         }
         }
       }
@@ -289,6 +299,21 @@ import { fmtDuration } from '../../../core/time';
         cursor: pointer;
         flex-shrink: 0;
       }
+      .scroll-sentinel {
+        display: flex;
+        justify-content: center;
+        padding: var(--sp-4) 0;
+      }
+      .load-more {
+        min-height: var(--touch-min);
+        padding: 0 var(--sp-4);
+        border: 1px solid var(--edge);
+        border-radius: var(--r-tag);
+        background: var(--surface);
+        color: var(--primary);
+        font-size: var(--fs-13);
+        cursor: pointer;
+      }
       .cards {
         display: grid;
         gap: var(--sp-2);
@@ -602,6 +627,28 @@ export class MbWorkbenchPage implements OnInit {
     );
   });
 
+  /*
+   * 分页(移动工作台):搜索过滤后再分页,一次 12 个。
+   */
+  private readonly PAGE_SIZE = 12;
+  private readonly shown = signal(this.PAGE_SIZE);
+  private readonly infiniteScroll = viewChild(InfiniteScrollDirective);
+
+  /** 分页后可见(搜索 → 分页) */
+  readonly pagedAgents = computed(() => this.visibleAgents().slice(0, this.shown()));
+
+  readonly hasMore = computed(() => this.shown() < this.visibleAgents().length);
+
+  loadMore(): void {
+    if (!this.hasMore()) return;
+    this.shown.update((n) => n + this.PAGE_SIZE);
+    this.infiniteScroll()?.reset();
+  }
+
+  resetPaging(): void {
+    this.shown.set(this.PAGE_SIZE);
+  }
+
   readonly blockedSummary = computed(() =>
     this.blockedTasks()
       .map((t) => `${this.agentName(t.agentId)} · ${t.title}`)
@@ -618,6 +665,7 @@ export class MbWorkbenchPage implements OnInit {
 
   onSearch(v: unknown): void {
     this.keyword.set(String(v));
+      this.resetPaging();
   }
 
   async refresh(): Promise<void> {
@@ -638,6 +686,11 @@ export class MbWorkbenchPage implements OnInit {
     return isMatrixReady(a);
   }
 
+  /** 是否有可展示的运行指标(已接入 **且** 上报过 host);模板判定用 */
+  hasMetrics(a: Agent): boolean {
+    return hasMetricsFn(a);
+  }
+
   pct(v?: number): string {
     return fmtPercent(v);
   }

+ 28 - 2
src/app/mobile/shell/mobile-shell.ts

@@ -3,8 +3,10 @@ import { NavigationEnd, Router, RouterOutlet } from '@angular/router';
 import { filter } from 'rxjs';
 import { MTabbarItem, UiMTabbar } from '../../ui';
 import { LiveService } from '../../core/live.service';
+import { MatrixSessionService } from '../../core/matrix-session.service';
 import { PaletteService } from '../../core/palette.service';
 import { DeviceService } from '../../core/device.service';
+import { UnreadService } from '../../core/unread.service';
 
 /**
  * 移动外壳 —— 顶栏 + 底部 5 Tab + 安全区
@@ -35,6 +37,11 @@ import { DeviceService } from '../../core/device.service';
           <h2>当前窗口适合桌面端</h2>
           <p>已检测到窗口宽度达到 768px,可以切换到桌面布局继续使用。</p>
           <button type="button" class="wide-btn" (click)="goDesktop()">切换到桌面端</button>
+          @if (device.isForced()) {
+            <button type="button" class="wide-btn ghost" (click)="resetToAuto()">
+              恢复自动识别
+            </button>
+          }
         </div>
       </div>
     }
@@ -114,6 +121,12 @@ import { DeviceService } from '../../core/device.service';
         font-size: var(--fs-13);
         cursor: pointer;
       }
+      /* 后置覆盖:次要按钮(恢复自动识别) */
+      .wide-btn.ghost {
+        background: transparent;
+        color: var(--primary);
+        border: 1px solid var(--edge);
+      }
 
       @media (prefers-reduced-motion: no-preference) {
         .m-main {
@@ -125,8 +138,10 @@ import { DeviceService } from '../../core/device.service';
 })
 export class MobileShellComponent {
   private readonly live = inject(LiveService);
+  private readonly matrix = inject(MatrixSessionService);
   private readonly palette = inject(PaletteService);
-  private readonly device = inject(DeviceService);
+  readonly device = inject(DeviceService);
+  private readonly unread = inject(UnreadService);
   private readonly router = inject(Router);
 
   /** 5 个 Tab 的落点与图标(顺序即底栏顺序) */
@@ -138,8 +153,9 @@ export class MobileShellComponent {
     { path: '/mine', label: '我的', icon: 'fa-solid fa-user' },
   ];
 
+  /** 底栏会话 Tab 的未读总数(UnreadService 唯一来源) */
   readonly totalUnread = computed(() =>
-    this.live.agents().reduce((n, a) => n + (a.unread ?? 0), 0),
+    Object.values(this.unread.counts()).reduce((n, v) => n + v, 0),
   );
 
   /** 当前激活的 Tab 索引(由 URL 推导) */
@@ -160,6 +176,8 @@ export class MobileShellComponent {
   constructor() {
     // 🔴 唯一轮询器的启停点
     this.live.start();
+    // 🔴 Matrix 会话级连接(常驻,未读计数依赖它)
+    this.matrix.bootstrap();
     this.palette.init();
 
     if (typeof window !== 'undefined') {
@@ -184,6 +202,14 @@ export class MobileShellComponent {
     void this.router.navigateByUrl(path, { replaceUrl: true });
   }
 
+  /** FR-10 AC-10.1:恢复自动识别(清除手动端偏好,按 UA/视口重新判定) */
+  resetToAuto(): void {
+    const mode = this.device.resetToAuto();
+    void this.router.navigateByUrl(mode === 'mobile' ? '/mobile/home' : '/desktop/chat', {
+      replaceUrl: true,
+    });
+  }
+
   private isWideViewport(): boolean {
     return typeof window !== 'undefined' && window.innerWidth >= 768;
   }

+ 103 - 0
src/app/shared/infinite-scroll.directive.ts

@@ -0,0 +1,103 @@
+import { Directive, ElementRef, OnDestroy, OnInit, inject, output } from '@angular/core';
+
+/**
+ * 滚动到底自动加载更多(列表分页)
+ *
+ * 依据用户要求「列表项做分页」+ SPEC NFR-06。
+ * **统一实现**:避免各页各写一套 IntersectionObserver / scroll 监听
+ * (存量「4 个重叠轮询器」同类教训)。
+ *
+ * ## 用法
+ * ```html
+ * <!-- 放在 app-page 内容区内部(该区已由 split-mode 提供 overflow-y:auto) -->
+ * <div appInfiniteScroll (reachedBottom)="loadMore()" [disabled]="!hasMore()"></div>
+ * ```
+ * 也可直接挂在**滚动容器**上(指令会向上寻找最近的滚动祖先):
+ * ```html
+ * <app-page [fixedHeader]="true">
+ *   <div appInfiniteScroll (reachedBottom)="loadMore()">…</div>
+ * </app-page>
+ * ```
+ *
+ * ## 行为
+ * - 距底部 **`threshold` 像素内**即触发(默认 200px,提前量避免用户看到空白)
+ * - **防重入**:触发后进入冷却,直到 `reset()` 被调用(父组件加载完数据后调)
+ * - `disabled` 为真时不触发(无更多数据 / 加载中)
+ *
+ * ## 为什么不用 IntersectionObserver
+ * 滚动容器是**祖先元素**(`app-page` 的 `.scroll-zone`),
+ * 观察自身与容器的交叉需要额外配置 root;直接监听容器 scroll 更直观可控,
+ * 且能在同一处做防重入与阈值判断。
+ */
+@Directive({
+  selector: '[appInfiniteScroll]',
+  standalone: true,
+})
+export class InfiniteScrollDirective implements OnInit, OnDestroy {
+  /** 距底部多少像素触发 */
+  readonly threshold = 200;
+
+  /** 到达底部(父组件应加载下一页) */
+  readonly reachedBottom = output<void>();
+
+  private readonly host = inject(ElementRef<HTMLElement>);
+  private scroller: HTMLElement | Window | null = null;
+  /** 冷却中:避免连续滚动触发多次加载 */
+  private cooling = false;
+
+  ngOnInit(): void {
+    this.scroller = resolveMobileScroller(this.host.nativeElement);
+    const target = this.scroller ?? window;
+    target.addEventListener('scroll', this.onScroll, { passive: true });
+  }
+
+  ngOnDestroy(): void {
+    const target = this.scroller ?? window;
+    target.removeEventListener('scroll', this.onScroll);
+    this.scroller = null;
+  }
+
+  /** 父组件加载完成后调用,解除冷却(允许下一次触发) */
+  reset(): void {
+    this.cooling = false;
+  }
+
+  private readonly onScroll = (): void => {
+    if (this.cooling) return;
+    const el = this.host.nativeElement;
+    const rect = el.getBoundingClientRect();
+
+    // 可视区高度:滚动祖先的 clientHeight,否则窗口高
+    const viewportH =
+      this.scroller instanceof HTMLElement ? this.scroller.clientHeight : window.innerHeight;
+
+    // 元素顶部相对视口的位置 < 视口高 + 阈值 ⇒ 已接近底部
+    if (rect.top <= viewportH + this.threshold) {
+      this.cooling = true;
+      this.reachedBottom.emit();
+    }
+  };
+
+}
+
+/**
+ * 移动端页面滚动容器的选择器。
+ *
+ * 移动端不是 `app-page` 结构,滚动发生在 `MobileShell` 的 `.m-main`(整页滚动),
+ * 故此处**优先全局查找** `.m-main`,找不到再回退到「向上找滚动祖先」。
+ *
+ * 🔴 若不这样处理:指令挂在页面内部元素上,向上找不到 `overflow:auto` 的祖先,
+ *    就会 fallback 到 `window` —— 而移动端滚的是 `.m-main`,**监听 window 永不触发**。
+ */
+export function resolveMobileScroller(node: HTMLElement): HTMLElement | null {
+  // 向上找滚动祖先(桌面 app-page 的 .scroll-zone 命中)
+  let p: HTMLElement | null = node.parentElement;
+  while (p) {
+    const oy = getComputedStyle(p).overflowY;
+    if ((oy === 'auto' || oy === 'scroll') && p.scrollHeight > p.clientHeight) return p;
+    p = p.parentElement;
+  }
+  // 移动端兜底:整页滚动容器
+  const m = document.querySelector<HTMLElement>('.m-main');
+  return m ?? null;
+}

+ 11 - 6
src/environments/environment.ts

@@ -2,20 +2,25 @@
  * 环境配置(开发)
  *
  * 🔴 `useMockData` 是**全局唯一数据源开关**(《数据访问层设计.md》§二):
- *    true  = 演示数据(当前默认;云函数未就绪前的开发路径)
- *    false = 真实云函数(走 fmode-ng → FmodeParse.with('nova'))
+ *    true  = 演示数据(MockApi)
+ *    false = 真实云函数(ParseApi → https://server.fmode.cn)
  *
  * 切换真实数据的动作清单:
  *   ① useMockData: false
- *   ② 补 ParseApi 各方法(按 docs/03-interface/api/API-契约.md)
- *   ③ 核对 API-契约 §六 的 8 项待服务端确认项
+ *   ② ParseApi 已实现(按 docs/03-interface/api/API-契约.md)
+ *   ③ 登录后 SessionService 会把 sessionToken 注入 ParseApi
+ *
  * 业务服务与页面代码**零改动** —— 这是抽象层隔离的收益点。
  */
 export const environment = {
   production: false,
 
-  /** 演示数据开关 */
-  useMockData: true,
+  /**
+   * 演示数据开关。
+   *
+   * 🔴 接入真实数据时改为 false(当前为 Matrix 真实接入验证阶段,默认已切换)。
+   */
+  useMockData: false,
 
   /** 演示模式下的模拟网络延迟(ms),用于验证加载态 */
   mockLatency: 320,

+ 20 - 24
src/styles.scss

@@ -20,32 +20,28 @@ body {
   font-size: var(--fs-15); line-height: 1.6;
   -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
 }
-input, textarea, select { font-size: var(--fs-17); }
+/* AC-19.3:移动端输入框 ≥16px(底座用 Tailwind text-sm=14px,需 !important 压制)*/
+input, textarea, select { font-size: var(--fs-17) !important; }
 
-:root[data-palette] .ui-m-tabbar--glass {
-  background: linear-gradient(
-    to top,
-    color-mix(in srgb, var(--canvas) 94%, transparent) 45%,
-    color-mix(in srgb, var(--canvas) 72%, transparent) 78%,
-    transparent
-  );
-}
-:root[data-palette] .ui-m-tabpill {
-  background: color-mix(in srgb, var(--nav-surface) 90%, var(--surface));
-  border-color: color-mix(in srgb, var(--nav-line) 70%, transparent);
-  box-shadow: 0 8px 30px color-mix(in srgb, var(--nav-surface) 24%, transparent);
-}
-:root[data-palette] .ui-m-tabpill::before {
-  background: linear-gradient(to bottom, color-mix(in srgb, var(--nav-ink) 18%, transparent), transparent 45%);
+:root[data-palette] {
+  .ui-m-tabbar--glass {
+    background: linear-gradient(to top,
+      color-mix(in srgb, var(--canvas) 94%, transparent) 45%,
+      color-mix(in srgb, var(--canvas) 72%, transparent) 78%, transparent);
+  }
+  .ui-m-tabpill {
+    background: color-mix(in srgb, var(--nav-surface) 90%, var(--surface));
+    border-color: color-mix(in srgb, var(--nav-line) 70%, transparent);
+    box-shadow: 0 8px 30px color-mix(in srgb, var(--nav-surface) 24%, transparent);
+  }
+  .ui-m-tabpill::before {
+    background: linear-gradient(to bottom, color-mix(in srgb, var(--nav-ink) 18%, transparent), transparent 45%);
+  }
+  .ui-m-tab, .ui-m-tab i, .ui-m-tab em { color: var(--nav-text) !important; }
+  .ui-m-tab.on { background: var(--nav-accent) !important; }
+  .ui-m-tab.on i, .ui-m-tab.on em { color: var(--nav-on-brand) !important; }
+  .ui-m-tab-badge, .ui-m-tab-dot { background: var(--danger) !important; }
 }
-:root[data-palette] .ui-m-tab,
-:root[data-palette] .ui-m-tab i,
-:root[data-palette] .ui-m-tab em { color: var(--nav-text) !important; }
-:root[data-palette] .ui-m-tab.on { background: var(--nav-accent) !important; }
-:root[data-palette] .ui-m-tab.on i,
-:root[data-palette] .ui-m-tab.on em { color: var(--nav-on-brand) !important; }
-:root[data-palette] .ui-m-tab-badge,
-:root[data-palette] .ui-m-tab-dot { background: var(--danger) !important; }
 
 .m-page--fixed { min-height: 100%; }
 

Некоторые файлы не были показаны из-за большого количества измененных файлов