Bladeren bron

fix(session): 会话目录单向同步 + 渲染预览修复 + 多端 UI 收敛

会话存储逻辑重构(根因:云端查重键含 roomId,房间变化即产生孤儿重复记录)
- 云端会话目录改为唯一真相源,合并规则全部删除(不再「本地有云端没有就保留」)
- 云端不可达时列表置为空并显示「加载失败 + 重试」,不展示过期缓存
- 本地缓存键改为 v2(一次性失效旧缓存)
- 取消「空会话」:新建对话只进入待建态,首次发消息才创建会话
- 彻底关闭从 Matrix 房间扫描重建会话目录(删除 session-scan)
- 删除 pruneEmpty / discard / ensureFor / mergeScanned

云函数 fmodeagent-chatthreads v0.1.3(已热更并端到端验证 9/9)
- upsert 查重键从 (user, roomId, threadRoot) 改为 (user, threadRoot) 全局查
- 命中多条保留最新、其余物理删除,返回 collapsed 计数
- upsert 一律解除墓碑,不再依赖客户端传 revive

预览组件
- txt/代码缩放改为只影响字号,修正「位置在动、字没变」(去掉滚动补偿与内层 overflow)
- 新增全局错误处理器,仅静音 Matrix SDK 断开时的 AbortError

交互与 UI
- 「重新连接」失效修复:open() 新增 force 参数绕过幂等短路
- 登出停轮询、重登重启轮询并清理上一账号内存态(消除 401 刷屏)
- 会话行补上删除入口(二次确认),归档也改为二次确认
- 成果卡片与「文件」栏统一:整行可点预览 + 进入箭头
- 个人中心左栏粘住跟随,断点改为 below-mobile 避免缩放下堆叠
- 设置页可查看/复制登录 Token(默认打码),移除无效的「重新登录」
- 移除群聊上下文开关、任务页「返回」、页脚版本号
- 界面文案去除 Matrix 等技术名词

验证:tsc 0 错误;单测 1068/1068;构建通过;verify:ui 12/12;verify:docs 79 文件

Co-Authored-By: Claude Code <noreply@anthropic.com>
Huccct 12 uur geleden
bovenliggende
commit
0f14056a06
57 gewijzigde bestanden met toevoegingen van 7083 en 1602 verwijderingen
  1. 11 0
      .gitignore
  2. 1 1
      angular.json
  3. 473 0
      cloud/backup/RP5Ht55NeX-fmodeagent-matrix-provision-v0.1.5.js
  4. 482 0
      cloud/backup/RP5Ht55NeX-fmodeagent-matrix-provision-v0.1.5.patched.js
  5. 277 0
      cloud/backup/oR5O67i1Cm-fmodeagent-chatthreads-v0.1.0.js
  6. 569 0
      cloud/backup/xlosi3m46q-fmodeagent-status-v0.1.2.js
  7. 578 0
      cloud/backup/xlosi3m46q-fmodeagent-status-v0.1.2.patched.js
  8. 37 0
      cloud/functions/README.md
  9. 1 0
      cloud/functions/fmodeagent-chatthreads.id
  10. 324 0
      cloud/functions/fmodeagent-chatthreads.js
  11. 552 0
      docs/00-tracking/操作追踪.md
  12. 86 0
      docs/02-design/design-system/预览面板重构-设计-20261030.md
  13. 140 0
      docs/03-interface/会话创建与容器通讯规范-20261101.md
  14. 126 0
      docs/03-interface/会话存储与生命周期-20261101.md
  15. 7 0
      src/app/app.config.ts
  16. 3 10
      src/app/components/domain/message-bubble/message-bubble.spec.ts
  17. 15 27
      src/app/components/domain/message-bubble/message-bubble.ts
  18. 9 29
      src/app/components/domain/new-session-modal/new-session-modal.ts
  19. 247 27
      src/app/components/domain/session-tree/session-tree.ts
  20. 114 0
      src/app/components/preview/file-preview/file-preview.spec.ts
  21. 622 365
      src/app/components/preview/file-preview/file-preview.ts
  22. 40 0
      src/app/components/preview/image-lightbox/image-lightbox.spec.ts
  23. 266 0
      src/app/components/preview/image-lightbox/image-lightbox.ts
  24. 15 0
      src/app/components/preview/md-render/md-render.ts
  25. 113 20
      src/app/components/preview/office-preview/office-preview.ts
  26. 206 110
      src/app/components/preview/pdf-preview/pdf-preview.ts
  27. 11 5
      src/app/core/acl.spec.ts
  28. 12 4
      src/app/core/acl.ts
  29. 26 0
      src/app/core/api/fmode-api.ts
  30. 59 0
      src/app/core/api/mock-api.ts
  31. 39 0
      src/app/core/api/models.ts
  32. 51 0
      src/app/core/api/parse-api.ts
  33. 38 0
      src/app/core/app-error-handler.ts
  34. 218 93
      src/app/core/chat-session.service.spec.ts
  35. 275 138
      src/app/core/chat-session.service.ts
  36. 7 5
      src/app/core/chat-session.ts
  37. 60 2
      src/app/core/chat.service.spec.ts
  38. 183 34
      src/app/core/chat.service.ts
  39. 49 1
      src/app/core/matrix-client.service.ts
  40. 25 0
      src/app/core/matrix-session.service.ts
  41. 0 180
      src/app/core/session-scan.spec.ts
  42. 0 167
      src/app/core/session-scan.ts
  43. 24 1
      src/app/core/spreadsheet.spec.ts
  44. 60 0
      src/app/core/spreadsheet.ts
  45. 3 3
      src/app/desktop/pages/agent/agent.ts
  46. 13 1
      src/app/desktop/pages/chat/chat.spec.ts
  47. 333 236
      src/app/desktop/pages/chat/chat.ts
  48. 31 24
      src/app/desktop/pages/groups/groups.ts
  49. 115 75
      src/app/desktop/pages/mine/mine.ts
  50. 36 2
      src/app/desktop/pages/projects/projects.ts
  51. 7 6
      src/app/desktop/pages/tasks/tasks.ts
  52. 2 2
      src/app/desktop/pages/workbench/workbench.ts
  53. 24 5
      src/app/mobile/pages/chat/chat.ts
  54. 8 5
      src/app/mobile/pages/messages/messages.ts
  55. 41 22
      src/app/mobile/pages/mine/mine.ts
  56. 2 2
      src/app/mobile/pages/workbench/workbench.ts
  57. 17 0
      src/app/pages/login/login.ts

+ 11 - 0
.gitignore

@@ -83,3 +83,14 @@ public/pdf.worker.js
 
 # 本地调试脚本与截图(不进入版本库)
 .tmp-v2/
+
+# 🔴 明文凭据的直连手册(含 accessToken / sessionToken,违反黄金规则 9,绝不入库)
+docs/03-interface/容器通讯-*直连手册-*.md
+docs/03-interface/如何查看登录凭据-*.md
+
+# 本地截图素材(开发期临时产物)
+/image/
+
+# pnpm 误生成文件(本项目用 npm,锁文件是 package-lock.json)
+pnpm-lock.yaml
+pnpm-workspace.yaml

+ 1 - 1
angular.json

@@ -52,7 +52,7 @@
                 {
                   "type": "anyComponentStyle",
                   "maximumWarning": "8kB",
-                  "maximumError": "16kB"
+                  "maximumError": "24kB"
                 }
               ],
               "outputHashing": "all",

+ 473 - 0
cloud/backup/RP5Ht55NeX-fmodeagent-matrix-provision-v0.1.5.js

@@ -0,0 +1,473 @@
+// fmodeagent-matrix-provision (TASK-matrix-v2 ①): 用户侧 Matrix 账号自动开通
+// 调用: POST https://server.fmode.cn/api/functions {token, id:<Function.objectId>, params:{agentId?}}
+//
+// 流程 (聊天页进入时调用, 取代"该数字生命的飞马(Matrix)身份正在开通或未接入"笼统态):
+//   1. 校验调用者是注册用户 (sessionToken → Parse _User);
+//   2. 为该用户确保 Matrix 账号存在: mxid=@<用户objectId小写>:fmode.cn,
+//      密码=sha256("fmode-mx:"+sessionToken) 前 24 位 (与 fmode-app matrix-client.ts 派生一致,
+//      使 App 端无需云函数也能确定性登录);
+//      不存在 → admin API 注册 (Continuwuity: admin 账号登录 → admin room "!admin users create");
+//      已存在但密码不符 → reset-password 对齐派生密码 (幂等开通);
+//   3. 确认 Agent 账号存在 (params.agentId → FmodeAgent 记录; mxid=@<agentId去agent-node-前缀>:fmode.cn,
+//      如 @xinting:fmode.cn——TASK-matrix-v2 §4 账号保留, 消息接发全交官方 Hermes channel;
+//      Agent 密码不在此重置——官方 channel 正在使用该账号; 账号缺失报具体错误);
+//   4. 返回 {userMxId, agentMxId, homeserver, accessToken, deviceId, roomId?}
+//      accessToken 用 "!admin users issue-token" 签发 (绑定 device, 长期有效),
+//      并 ensureRoom: 已有共同房间复用, 否则 createRoom(invite agent, is_direct)。
+//
+// 失败返回具体环节: {code:4xx/5xx, mess:"账号注册失败|房间创建失败|SDK登录失败|..."}——
+// App 端按 mess 显示, 不再笼统"开通中"。
+//
+// 凭据策略: admin 密码放本文件常量 (Function 表 code 仅 masterKey 可读, 与 config.nova.json 同级保护);
+//           用户密码为派生值不落库; issue-token 结果只在响应里出现一次。
+// 版本 v0.3.0 · 2026-09-09 · TASK-matrix-v42 P3 (Agent 账号改 objectId 唯一体系)
+// ===== v0.3.0 (TASK-matrix-v42 P3) 变更说明 =====
+// Agent Matrix 账号由语义化拼音名 (@xinting:fmode.cn) 改为 FMODE_AGENT.objectId:
+//   @<objectId小写>:fmode.cn — objectId 全局唯一, 语义名不唯一且不利于批量接入。
+//   1. mxid 计算: agentLocal = agentRec.id 小写 (不再去 agent-node- 前缀)
+//   2. 账号缺失不再 502 拒绝 → _ensureAccount 自动注册 (密码 = sha256("fmode-agent-mx:"+objectId) 前 24,
+//      固定盐派生幂等可重置 — Agent 无 sessionToken, 不与用户派生式混用)
+//   3. DM 房间重建: 新 agentMxId → 绑定表 agentMxId/roomId 更新 (旧 roomId 不复用——旧账号房间);
+//      旧语义化账号归档不删 (Hermes channel 迁移完成前并存)
+//   4. 批量接入: scripts/provision-all-agents.sh 遍历 FMODE_AGENT 全表逐个 provision
+// 版本 v0.2.1 · 2026-09-08 · TASK-matrix-v41 (修复 _ensureAccount 形参 mxAdmin→mx ReferenceError)
+//
+// ===== v0.2.1 (TASK-matrix-v41) 变更说明 =====
+// _ensureAccount 形参名为 mxAdmin 而函数体引用 `mx` (第 224/228 行 _adminCmd(mx, …)) —
+// 新开绑定 (无 FmodeMatrixBinding 记录路径, agent-node-tuye/agent-node 首开即命中) 必然
+// ReferenceError "mx is not defined" → HTTP 500 "SDK登录失败: mx is not defined"。
+// 修复: 形参更名 mxAdmin→mx (与函数体一致); 线上 Function 表同改 (2026-09-08 实测三 agent 全通)。
+//
+// ===== v0.2.0 (TASK-matrix-v3 P1) 变更说明 (v0.1.5 流程保留为建绑定路径) =====
+// 根因: 每次进入聊天页都重新 provision (issue-token 新 device/新 token), 第二次进入
+//       慢 + 历史/通话随新 device/新房间丢失。修复: 绑定表 FmodeMatrixBinding 为唯一真相:
+//   1. 查表: {userPointer, agentId} 唯一, status=active → 直接返回表中凭据 (不注册/不签发/不建房),
+//      幂等 (连开两次聊天页第二次秒进); roomId 固定 → SDK 进入同房间自然拉到全部时间线 (P3)。
+//   2. 建绑定: 无记录才走 v0.1.5 注册/签发/ensureRoom 流程并写表 (bindingId=表 objectId)。
+//   3. 过期自愈: App 端 whoami 报 M_UNKNOWN_TOKEN / 本函数按 ?validate=1 探活失败 →
+//      走 admin reset-password+issue-token 刷新凭据并更新表 (status 保持 active)。
+//   4. 响应新增 {bindingId, cached:true|false, status:'active'}; 语义: cached=true 即绑定表命中。
+var CLASS_NAME = 'FmodeAgent';
+var BINDING_CLASS = 'FmodeMatrixBinding'; // TASK-matrix-v3 P1: Channel 绑定表 (user×agent 唯一)
+var MATRIX_HS = 'https://matrix.fmode.cn';
+var MATRIX_DOMAIN = 'fmode.cn';
+var ADMIN_USER = 'admin';
+var ADMIN_PASS = 'Fmode@2026';
+var SERVICE_BOT = '@conduit:fmode.cn';
+var ADMIN_ROOM = '!UcLbdbuAWs9FZkrFIjPWBAe4jfNM2r_sj0-A0vJQgOQ'; // admin+conduit 专用, 严禁加人
+
+function _fail(code, mess) { response.status(code).json({ code: code, mess: mess }); }
+function _ok(data) { response.status(200).json(Object.assign({ code: 200 }, data)); }
+
+function _sessionToken() {
+  if (params && params.sessionToken) return params.sessionToken;
+  if (request.body && request.body.token) return request.body.token;
+  return null;
+}
+
+async function _getUser(t) {
+  if (!t) return null;
+  if (request.user && request.user.id && !(params && params.sessionToken)) return request.user;
+  try {
+    var q = new Parse.Query(Parse.Session);
+    q.equalTo('sessionToken', t);
+    var s = await q.first({ useMasterKey: true });
+    return s ? s.get('user') : null;
+  } catch (e) { return null; }
+}
+
+function _sha256Hex(s) {
+  // 纯 JS SHA-256 (executor 作用域无 crypto/node modules; 输入为 ASCII sessionToken)
+  function rr(x, n) { return (x >>> n) | (x << (32 - n)); }
+  var K = [0x428a2f98,0x71374491,0xb5c0fbcf,0xe9b5dba5,0x3956c25b,0x59f111f1,0x923f82a4,0xab1c5ed5,
+           0xd807aa98,0x12835b01,0x243185be,0x550c7dc3,0x72be5d74,0x80deb1fe,0x9bdc06a7,0xc19bf174,
+           0xe49b69c1,0xefbe4786,0x0fc19dc6,0x240ca1cc,0x2de92c6f,0x4a7484aa,0x5cb0a9dc,0x76f988da,
+           0x983e5152,0xa831c66d,0xb00327c8,0xbf597fc7,0xc6e00bf3,0xd5a79147,0x06ca6351,0x14292967,
+           0x27b70a85,0x2e1b2138,0x4d2c6dfc,0x53380d13,0x650a7354,0x766a0abb,0x81c2c92e,0x92722c85,
+           0xa2bfe8a1,0xa81a664b,0xc24b8b70,0xc76c51a3,0xd192e819,0xd6990624,0xf40e3585,0x106aa070,
+           0x19a4c116,0x1e376c08,0x2748774c,0x34b0bcb5,0x391c0cb3,0x4ed8aa4a,0x5b9cca4f,0x682e6ff3,
+           0x748f82ee,0x78a5636f,0x84c87814,0x8cc70208,0x90befffa,0xa4506ceb,0xbef9a3f7,0xc67178f2];
+  var H = [0x6a09e667,0xbb67ae85,0x3c6ef372,0xa54ff53a,0x510e527f,0x9b05688c,0x1f83d9ab,0x5be0cd19];
+  var bytes = [];
+  for (var i = 0; i < s.length; i++) {
+    var c = s.charCodeAt(i);
+    if (c < 128) bytes.push(c);
+    else if (c < 2048) { bytes.push(0xc0 | (c >> 6), 0x80 | (c & 63)); }
+    else { bytes.push(0xe0 | (c >> 12), 0x80 | ((c >> 6) & 63), 0x80 | (c & 63)); }
+  }
+  var bitLen = bytes.length * 8;
+  bytes.push(0x80);
+  while (bytes.length % 64 !== 56) bytes.push(0);
+  for (var j = 7; j >= 0; j--) bytes.push(Math.floor(bitLen / Math.pow(2, j * 8)) & 0xff);
+  for (var b = 0; b < bytes.length; b += 64) {
+    var w = new Array(64);
+    for (var t = 0; t < 16; t++) {
+      w[t] = (bytes[b + t*4] << 24) | (bytes[b + t*4 + 1] << 16) | (bytes[b + t*4 + 2] << 8) | bytes[b + t*4 + 3];
+    }
+    for (var t2 = 16; t2 < 64; t2++) {
+      var s0 = rr(w[t2-15], 7) ^ rr(w[t2-15], 18) ^ (w[t2-15] >>> 3);
+      var s1 = rr(w[t2-2], 17) ^ rr(w[t2-2], 19) ^ (w[t2-2] >>> 10);
+      w[t2] = (w[t2-16] + s0 + w[t2-7] + s1) | 0;
+    }
+    var a = H[0], bb = H[1], c = H[2], d = H[3], e = H[4], f = H[5], g = H[6], h = H[7];
+    for (var t3 = 0; t3 < 64; t3++) {
+      var S1 = rr(e, 6) ^ rr(e, 11) ^ rr(e, 25);
+      var ch = (e & f) ^ (~e & g);
+      var temp1 = (h + S1 + ch + K[t3] + w[t3]) | 0;
+      var S0 = rr(a, 2) ^ rr(a, 13) ^ rr(a, 22);
+      var maj = (a & bb) ^ (a & c) ^ (bb & c);
+      var temp2 = (S0 + maj) | 0;
+      h = g; g = f; f = e; e = (d + temp1) | 0;
+      d = c; c = bb; bb = a; a = (temp1 + temp2) | 0;
+    }
+    H[0] = (H[0] + a) | 0; H[1] = (H[1] + bb) | 0; H[2] = (H[2] + c) | 0; H[3] = (H[3] + d) | 0;
+    H[4] = (H[4] + e) | 0; H[5] = (H[5] + f) | 0; H[6] = (H[6] + g) | 0; H[7] = (H[7] + h) | 0;
+  }
+  var out = '';
+  for (var k = 0; k < 8; k++) out += ('00000000' + (H[k] >>> 0).toString(16)).slice(-8);
+  return out;
+}
+
+function _mxPassword(sessionToken) {
+  return _sha256Hex('fmode-mx:' + sessionToken).slice(0, 24);
+}
+
+function _httpJson(url, opts) {
+  // Deno/Node 双兼容 fetch; 10s 超时
+  return new Promise(function (resolve) {
+    try {
+      if (typeof fetch !== 'function') { resolve({ ok: false, err: 'no fetch' }); return; }
+      var ctrl = typeof AbortController === 'function' ? new AbortController() : null;
+      var t = ctrl ? setTimeout(function () { try { ctrl.abort(); } catch (e) {} }, 10000) : null;
+      var init = {
+        method: (opts && opts.method) || 'GET',
+        headers: (opts && opts.headers) || {},
+        signal: ctrl ? ctrl.signal : undefined,
+      };
+      if (opts && opts.body !== undefined) init.body = JSON.stringify(opts.body);
+      fetch(url, init)
+        .then(function (res) {
+          return res.text().then(function (txt) {
+            if (t) clearTimeout(t);
+            var data = null;
+            try { data = JSON.parse(txt); } catch (e) { data = { raw: txt }; }
+            resolve({ ok: res.status >= 200 && res.status < 300, status: res.status, data: data });
+          });
+        })
+        .catch(function (e) { if (t) clearTimeout(t); resolve({ ok: false, err: String(e) }); });
+    } catch (e) { resolve({ ok: false, err: String(e) }); }
+  });
+}
+
+// ---- Matrix 客户端 (access_token 方式,Continuwuity v1.18) ----
+function _mx(token) {
+  function call(path, method, body) {
+    var url = MATRIX_HS + path;
+    if (token) url += (url.indexOf('?') >= 0 ? '&' : '?') + 'access_token=' + encodeURIComponent(token);
+    return _httpJson(url, { method: method || 'GET', body: body,
+      headers: body !== undefined ? { 'Content-Type': 'application/json' } : {} });
+  }
+  return {
+    login: function (user, password) {
+      return call('/_matrix/client/v3/login', 'POST', {
+        type: 'm.login.password', identifier: { type: 'm.id.user', user: user }, password: password });
+    },
+    whoami: function () { return call('/_matrix/client/v3/account/whoami'); },
+    joinedRooms: function () { return call('/_matrix/client/v3/joined_rooms'); },
+    joinedMembers: function (roomId) {
+      return call('/_matrix/client/v3/rooms/' + encodeURIComponent(roomId) + '/joined_members');
+    },
+    sendAdminCmd: function (roomId, cmd) {
+      return call('/_matrix/client/v3/rooms/' + encodeURIComponent(roomId) +
+        '/send/m.room.message/prov' + Date.now() + Math.floor(Math.random() * 900 + 100), 'PUT',
+        { msgtype: 'm.text', body: cmd });
+    },
+    lastBotMsg: function (roomId) {
+      return call('/_matrix/client/v3/rooms/' + encodeURIComponent(roomId) + '/messages?dir=b&limit=3');
+    },
+    createRoom: function (inviteMxid, name) {
+      return call('/_matrix/client/v3/createRoom', 'POST', {
+        invite: [inviteMxid], is_direct: true, preset: 'trusted_private_chat', name: name || undefined });
+    },
+    joinRoom: function (roomId) {
+      return call('/_matrix/client/v3/join/' + encodeURIComponent(roomId), 'POST', {});
+    },
+  };
+}
+
+function _sleep(ms) { return new Promise(function (r) { setTimeout(r, ms); }); }
+
+/** admin room 命令: 发送后轮询最新 bot 回复 (机器人回复可能滞后一条) */
+async function _adminCmd(mx, token, cmd) {
+  var before = null;
+  var d0 = await mx.lastBotMsg(ADMIN_ROOM);
+  if (d0.ok && d0.data && d0.data.chunk) {
+    for (var i = 0; i < d0.data.chunk.length; i++) {
+      var ev = d0.data.chunk[i];
+      if (ev.sender === SERVICE_BOT && ev.type === 'm.room.message') { before = ev.content.body; break; }
+    }
+  }
+  var sent = await mx.sendAdminCmd(ADMIN_ROOM, cmd);
+  if (!sent.ok) return { ok: false, err: 'admin cmd 发送失败: ' + JSON.stringify(sent).slice(0, 120) };
+  var last = before;
+  for (var a = 0; a < 5; a++) {
+    await _sleep(1500);
+    var d = await mx.lastBotMsg(ADMIN_ROOM);
+    if (d.ok && d.data && d.data.chunk) {
+      for (var j = 0; j < d.data.chunk.length; j++) {
+        var ev2 = d.data.chunk[j];
+        if (ev2.sender === SERVICE_BOT && ev2.type === 'm.room.message') {
+          if (ev2.content.body !== before) last = ev2.content.body;
+          break;
+        }
+      }
+    }
+    if (last && last !== before && (last.indexOf('```') >= 0 || last.indexOf('|') >= 0)) break;
+  }
+  return { ok: true, reply: last || '' };
+}
+
+/** 确保本地 Matrix 账号存在并返回 access_token。幂等: 已存在则重置为派生密码再签 token。
+ *  v4.1 修复: 参数名 mxAdmin→mx — 原函数体引用 `mx` 而 `_ensureAccount(mxAdmin, …)` 形参叫
+ *  mxAdmin → 新开绑定 (无缓存路径) 必然 ReferenceError "mx is not defined" (HTTP 500 SDK登录失败)。 */
+async function _ensureAccount(mx, adminTok, local, password) {
+  var mx0 = _mx(''); // login 端点不能带他人 access_token, 探测/登录一律用无凭据客户端
+  var mxid = '@' + local + ':' + MATRIX_DOMAIN;
+  // 先探测: 用派生密码登录 (账号已存在且密码一致 → 直接成功)
+  var lg = await mx0.login(local, password);
+  if (lg.ok && lg.data && lg.data.access_token) {
+    return { mxid: mxid, accessToken: lg.data.access_token, deviceId: lg.data.device_id || '', created: false };
+  }
+  // 不存在/密码不符 → admin room create (已存在报错则 reset-password 对齐派生密码)
+  var cr = await _adminCmd(mx, adminTok, '!admin users create ' + local + ' ' + password);
+  var reply = cr.reply || '';
+  if (reply.indexOf('Created') < 0) {
+    // 已存在/不可用/任意报错 → reset-password 对齐派生密码后重试登录
+    var rs = await _adminCmd(mx, adminTok, '!admin users reset-password ' + local + ' ' + password);
+    reply = rs.reply || reply;
+  }
+  var lg2 = await mx0.login(local, password);
+  if (!lg2.ok || !lg2.data || !lg2.data.access_token) {
+    return null; // 账号注册失败
+  }
+  return { mxid: mxid, accessToken: lg2.data.access_token, deviceId: lg2.data.device_id || '', created: true };
+}
+
+// ---- TASK-matrix-v3 P1: FmodeMatrixBinding 绑定表 (user×agent 唯一, roomId 固定) ----
+
+/** 查绑定: {userPointer, agentId 唯一} → 记录或 null (agentId=null 时按 userPointer+agentMxId 空) */
+async function _findBinding(userPointer, agentIdStr) {
+  try {
+    var q = new Parse.Query(BINDING_CLASS);
+    q.equalTo('userPointer', userPointer);
+    q.equalTo('agentId', agentIdStr || '');
+    q.descending('updatedAt');
+    var rec = await q.first({ useMasterKey: true });
+    if (!rec) return null;
+    // 历史遗留多条 (v0.1.x 每次新建/极端并发): 只认最新一条, 其余标 revoked
+    var others = await q.find({ useMasterKey: true });
+    for (var i = 0; i < others.length; i++) {
+      if (others[i].id !== rec.id && others[i].get('status') !== 'revoked') {
+        others[i].set('status', 'revoked');
+        try { await others[i].save(null, { useMasterKey: true }); } catch (e) { /* 幂等尽力 */ }
+      }
+    }
+    return rec;
+  } catch (e) { return null; } // 表不存在/查询失败 → 按无绑定走新建
+}
+
+/** 绑定记录 → 响应凭据 */
+function _bindingPayload(rec) {
+  return {
+    userMxId: rec.get('userMxId'),
+    agentMxId: rec.get('agentMxId') || null,
+    homeserver: rec.get('homeserver') || MATRIX_HS,
+    accessToken: rec.get('accessToken'),
+    deviceId: rec.get('deviceId') || '',
+    roomId: rec.get('roomId') || null,
+    bindingId: rec.id,
+    status: rec.get('status') || 'active',
+    updatedAt: rec.get('updatedAt'),
+  };
+}
+
+/** 读/建 FmodeMatrixBinding 表 (schema 缺失时无法显式建表, Parse 无 schema 约束时首条 save 即建) */
+async function _upsertBinding(user, agentIdStr, cred) {
+  var B = Parse.Object.extend(BINDING_CLASS);
+  var rec = await _findBinding(user, agentIdStr);
+  if (!rec) rec = new B();
+  rec.set('userPointer', user);
+  rec.set('agentId', agentIdStr || '');
+  rec.set('userMxId', cred.userMxId);
+  if (cred.agentMxId) rec.set('agentMxId', cred.agentMxId);
+  rec.set('homeserver', cred.homeserver || MATRIX_HS);
+  if (cred.accessToken) rec.set('accessToken', cred.accessToken);
+  if (cred.deviceId) rec.set('deviceId', cred.deviceId);
+  if (cred.roomId) rec.set('roomId', cred.roomId);
+  rec.set('status', cred.status || 'active');
+  rec.set('updatedAt', new Date());
+  await rec.save(null, { useMasterKey: true });
+  return rec;
+}
+
+async function handler(request, response) {
+  try {
+    var user = await _getUser(_sessionToken());
+    if (!user) return _fail(401, '账号注册失败: sessionToken 无效或已过期, 请重新登录 App');
+    var sessionToken = _sessionToken();
+
+    // ---- Agent 定位 (agentId 可传 objectId 或 agentId; 缺省只开通用户身份) ----
+    var agentRec = null;
+    var agentIdParam = params && params.agentId;
+    var agentIdStr = '';
+    if (agentIdParam) {
+      var rec = await (function () {
+        var q1 = new Parse.Query(CLASS_NAME); q1.equalTo('agentId', agentIdParam);
+        return q1.first({ useMasterKey: true }).then(function (r1) {
+          return r1 || new Parse.Query(CLASS_NAME).get(agentIdParam, { useMasterKey: true });
+        });
+      })();
+      if (!rec) return _fail(404, '房间创建失败: Agent 不存在(' + agentIdParam + ')');
+      // ACL 校验: 记录归属人或超管才可开通 (与 fmodeagent-status 同规则, 防越权开通)
+      var SUPER_ROLE = 'FMODE_AGENT_SUPERADMIN';
+      var isSuper = false;
+      try {
+        var rq = new Parse.Query(Parse.Role); rq.equalTo('name', SUPER_ROLE);
+        var role = await rq.first({ useMasterKey: true });
+        if (role) {
+          var us = await role.getUsers().query().find({ useMasterKey: true });
+          for (var ui = 0; ui < us.length; ui++) { if (us[ui].id === user.id) { isSuper = true; break; } }
+        }
+      } catch (e) { /* 角色查询失败按非超管处理 */ }
+      var owner = rec.get('user');
+      if (!isSuper && (!owner || owner.id !== user.id)) {
+        return _fail(403, '房间创建失败: 该 Agent 不在你的可见范围内, 无权开通会话');
+      }
+      agentRec = rec;
+      agentIdStr = String(rec.get('agentId') || rec.id);
+    }
+
+    // ---- P1a 幂等: 查绑定表 → active 且 (validate 时探活通过) → 直接返回, 不重新生成 ----
+    var validate = !!(params && params.validate); // App 缓存校验/过期自愈探测时传 validate:1
+    var binding = await _findBinding(user, agentIdStr);
+    // v4.2 P3: 绑定里的 agentMxId 若还是旧语义化账号 (≠ @<objectId小写>) → 不命中缓存,
+    // 走下方重建流程 (新 objectId 账号 + 新 DM 房间 + 绑定表更新)
+    var bindingMxStale = false;
+    if (binding && agentRec) {
+      var expectedAgentMx = '@' + String(agentRec.id).toLowerCase() + ':' + MATRIX_DOMAIN;
+      bindingMxStale = binding.get('agentMxId') !== expectedAgentMx;
+    }
+    if (binding && !bindingMxStale && binding.get('status') === 'active' && binding.get('accessToken')) {
+      var bp = _bindingPayload(binding);
+      if (!validate) {
+        // 非校验请求: 直接命中绑定表返回 (秒进, roomId 固定 → 历史完整)
+        return _ok(Object.assign({ cached: true }, bp));
+      }
+      // validate: 用绑定 token 探活 whoami → 200 即凭据仍有效 (幂等返回); 失效走下方刷新
+      var mxProbe = _mx(bp.accessToken);
+      var who = await mxProbe.whoami();
+      if (who.ok && who.data && who.data.user_id === bp.userMxId) {
+        return _ok(Object.assign({ cached: true }, bp));
+      }
+      // M_UNKNOWN_TOKEN/任何失效 → 刷新凭据并更新表 (不换 device/roomId: admin 对齐派生密码后重签)
+    }
+
+    // ---- Matrix: admin 登录 → 确保用户/Agent 账号 ----
+    var mx0 = _mx('');
+    var adm = await mx0.login(ADMIN_USER, ADMIN_PASS);
+    if (!adm.ok || !adm.data || !adm.data.access_token) {
+      return _fail(502, '账号注册失败: Matrix homeserver 管理登录失败(' +
+        ((adm.data && (adm.data.errcode || adm.data.error)) || adm.err || '未知') + ')');
+    }
+    var adminTok = adm.data.access_token;
+    var mx = _mx(adminTok);
+
+    var password = _mxPassword(sessionToken);
+    var userLocal = String(user.id).toLowerCase();
+    var ua = await _ensureAccount(mx, adminTok, userLocal, password);
+    if (!ua) return _fail(502, '账号注册失败: 用户 Matrix 账号(' + userLocal + ')创建/登录失败');
+
+    var agentMxId = null;
+    if (agentRec) {
+      // v4.2 P3 (TASK-matrix-v42 2026-09-09): Agent Matrix 账号一律用 FMODE_AGENT.objectId
+      // —— @<objectId小写>:fmode.cn (objectId 全局唯一, 语义化拼音名不唯一且不利于批量)。
+      // displayname 仍用中文名 (由 Hermes channel 侧/房间名承载, 不改账号 localpart)。
+      // 兼容: FmodeAgent.channels.matrixConfig.userId 若已是 objectId 账号则直接用;
+      // 旧语义化账号 (@xinting 等) 由 provision-all-agents 批量迁移 (新建+绑定表更新+旧号归档)。
+      var agentLocal = String(agentRec.id).toLowerCase();
+      // Agent 账号存在性: admin room users list 探测; 缺失时走 _ensureAccount 自动注册
+      // (Agent 密码 = 与 App 用户同款派生式不适用——Agent 无 sessionToken,
+      //  用 objectId 固定盐派生: sha256("fmode-agent-mx:"+objectId) 前 24 位, 幂等可重置)
+      var ul = await _adminCmd(mx, adminTok, '!admin users list');
+      var found = ((ul.reply || '').indexOf('@' + agentLocal + ':') >= 0);
+      if (!found) {
+        // v4.2: 不再 502 拒绝 — 直接注册 objectId 账号 (通用插件自接入铺路)
+        var agentPass = _sha256Hex('fmode-agent-mx:' + agentRec.id).slice(0, 24);
+        var ag = await _ensureAccount(mx, adminTok, agentLocal, agentPass);
+        if (!ag) {
+          return _fail(502, '账号注册失败: Agent Matrix 账号(@' + agentLocal + ')创建失败');
+        }
+      }
+      agentMxId = '@' + agentLocal + ':' + MATRIX_DOMAIN;
+    }
+
+    // ---- 用户侧长效 token (issue-token, 绑定 device; App 端也可用派生密码自行登录) ----
+    var accessToken = ua.accessToken;
+    var deviceId = ua.deviceId;
+    var it = await _adminCmd(mx, adminTok, '!admin users issue-token ' + userLocal + ' ' + password);
+    var m = (it.reply || '').match(/device `([^`]+)` with access token `([A-Za-z0-9_\-\.]+)`/);
+    if (m) { deviceId = m[1]; accessToken = m[2]; }
+
+    // ---- DM 房间: 绑定表 roomId 优先 (P1b 历史恢复: 房间唯一不重建) → 已有共同房间探测 → createRoom ----
+    var roomId = null;
+    if (agentMxId) {
+      if (binding && binding.get('roomId')
+          // v4.2 P3: 旧绑定房间属于旧语义化账号 — agentMxId 变更后旧 roomId 不复用 (房间重建)
+          && (!binding.get('agentMxId') || binding.get('agentMxId') === agentMxId)) {
+        roomId = binding.get('roomId'); // 绑定表既定房间, 不新建
+      } else {
+        var myTok = accessToken;
+        var mxMe = _mx(myTok);
+        var jr = await mxMe.joinedRooms();
+        if (jr.ok && jr.data && jr.data.joined_rooms) {
+          for (var ri = 0; ri < jr.data.joined_rooms.length; ri++) {
+            var rid = jr.data.joined_rooms[ri];
+            var jm = await mxMe.joinedMembers(rid);
+            if (jm.ok && jm.data && jm.data.joined && jm.data.joined[agentMxId]) { roomId = rid; break; }
+          }
+        }
+        if (!roomId) {
+          var crr = await mxMe.createRoom(agentMxId, (agentRec && agentRec.get('name')) || undefined);
+          if (crr.ok && crr.data && crr.data.room_id) {
+            roomId = crr.data.room_id;
+            // Agent 侧由官方 Hermes channel 自动 accept 邀请 (MATRIX_REQUIRE_MENTION=false DM)
+          } else {
+            return _fail(502, '房间创建失败: createRoom(' + agentMxId + ') ' +
+              ((crr.data && (crr.data.errcode || crr.data.error)) || crr.err || '未知'));
+          }
+        }
+      }
+    }
+
+    // ---- P1a 写表/更新表 (首次建绑定 或 token 过期刷新) ----
+    var cred = {
+      userMxId: ua.mxid, agentMxId: agentMxId, homeserver: MATRIX_HS,
+      accessToken: accessToken, deviceId: deviceId, roomId: roomId, status: 'active',
+    };
+    var saved = await _upsertBinding(user, agentIdStr, cred);
+    return _ok({
+      cached: false,
+      userMxId: ua.mxid,
+      agentMxId: agentMxId,
+      homeserver: MATRIX_HS,
+      accessToken: accessToken,
+      deviceId: deviceId,
+      roomId: roomId,
+      bindingId: saved.id,
+      status: 'active',
+      userCreated: ua.created,
+    });
+  } catch (e) {
+    return _fail(500, 'SDK登录失败: ' + ((e && e.message) || String(e)));
+  }
+}

+ 482 - 0
cloud/backup/RP5Ht55NeX-fmodeagent-matrix-provision-v0.1.5.patched.js

@@ -0,0 +1,482 @@
+// fmodeagent-matrix-provision (TASK-matrix-v2 ①): 用户侧 Matrix 账号自动开通
+// 调用: POST https://server.fmode.cn/api/functions {token, id:<Function.objectId>, params:{agentId?}}
+//
+// 流程 (聊天页进入时调用, 取代"该数字生命的飞马(Matrix)身份正在开通或未接入"笼统态):
+//   1. 校验调用者是注册用户 (sessionToken → Parse _User);
+//   2. 为该用户确保 Matrix 账号存在: mxid=@<用户objectId小写>:fmode.cn,
+//      密码=sha256("fmode-mx:"+sessionToken) 前 24 位 (与 fmode-app matrix-client.ts 派生一致,
+//      使 App 端无需云函数也能确定性登录);
+//      不存在 → admin API 注册 (Continuwuity: admin 账号登录 → admin room "!admin users create");
+//      已存在但密码不符 → reset-password 对齐派生密码 (幂等开通);
+//   3. 确认 Agent 账号存在 (params.agentId → FmodeAgent 记录; mxid=@<agentId去agent-node-前缀>:fmode.cn,
+//      如 @xinting:fmode.cn——TASK-matrix-v2 §4 账号保留, 消息接发全交官方 Hermes channel;
+//      Agent 密码不在此重置——官方 channel 正在使用该账号; 账号缺失报具体错误);
+//   4. 返回 {userMxId, agentMxId, homeserver, accessToken, deviceId, roomId?}
+//      accessToken 用 "!admin users issue-token" 签发 (绑定 device, 长期有效),
+//      并 ensureRoom: 已有共同房间复用, 否则 createRoom(invite agent, is_direct)。
+//
+// 失败返回具体环节: {code:4xx/5xx, mess:"账号注册失败|房间创建失败|SDK登录失败|..."}——
+// App 端按 mess 显示, 不再笼统"开通中"。
+//
+// 凭据策略: admin 密码放本文件常量 (Function 表 code 仅 masterKey 可读, 与 config.nova.json 同级保护);
+//           用户密码为派生值不落库; issue-token 结果只在响应里出现一次。
+// 版本 v0.3.0 · 2026-09-09 · TASK-matrix-v42 P3 (Agent 账号改 objectId 唯一体系)
+// ===== v0.3.0 (TASK-matrix-v42 P3) 变更说明 =====
+// Agent Matrix 账号由语义化拼音名 (@xinting:fmode.cn) 改为 FMODE_AGENT.objectId:
+//   @<objectId小写>:fmode.cn — objectId 全局唯一, 语义名不唯一且不利于批量接入。
+//   1. mxid 计算: agentLocal = agentRec.id 小写 (不再去 agent-node- 前缀)
+//   2. 账号缺失不再 502 拒绝 → _ensureAccount 自动注册 (密码 = sha256("fmode-agent-mx:"+objectId) 前 24,
+//      固定盐派生幂等可重置 — Agent 无 sessionToken, 不与用户派生式混用)
+//   3. DM 房间重建: 新 agentMxId → 绑定表 agentMxId/roomId 更新 (旧 roomId 不复用——旧账号房间);
+//      旧语义化账号归档不删 (Hermes channel 迁移完成前并存)
+//   4. 批量接入: scripts/provision-all-agents.sh 遍历 FMODE_AGENT 全表逐个 provision
+// 版本 v0.2.1 · 2026-09-08 · TASK-matrix-v41 (修复 _ensureAccount 形参 mxAdmin→mx ReferenceError)
+//
+// ===== v0.2.1 (TASK-matrix-v41) 变更说明 =====
+// _ensureAccount 形参名为 mxAdmin 而函数体引用 `mx` (第 224/228 行 _adminCmd(mx, …)) —
+// 新开绑定 (无 FmodeMatrixBinding 记录路径, agent-node-tuye/agent-node 首开即命中) 必然
+// ReferenceError "mx is not defined" → HTTP 500 "SDK登录失败: mx is not defined"。
+// 修复: 形参更名 mxAdmin→mx (与函数体一致); 线上 Function 表同改 (2026-09-08 实测三 agent 全通)。
+//
+// ===== v0.2.0 (TASK-matrix-v3 P1) 变更说明 (v0.1.5 流程保留为建绑定路径) =====
+// 根因: 每次进入聊天页都重新 provision (issue-token 新 device/新 token), 第二次进入
+//       慢 + 历史/通话随新 device/新房间丢失。修复: 绑定表 FmodeMatrixBinding 为唯一真相:
+//   1. 查表: {userPointer, agentId} 唯一, status=active → 直接返回表中凭据 (不注册/不签发/不建房),
+//      幂等 (连开两次聊天页第二次秒进); roomId 固定 → SDK 进入同房间自然拉到全部时间线 (P3)。
+//   2. 建绑定: 无记录才走 v0.1.5 注册/签发/ensureRoom 流程并写表 (bindingId=表 objectId)。
+//   3. 过期自愈: App 端 whoami 报 M_UNKNOWN_TOKEN / 本函数按 ?validate=1 探活失败 →
+//      走 admin reset-password+issue-token 刷新凭据并更新表 (status 保持 active)。
+//   4. 响应新增 {bindingId, cached:true|false, status:'active'}; 语义: cached=true 即绑定表命中。
+var CLASS_NAME = 'FmodeAgent';
+var BINDING_CLASS = 'FmodeMatrixBinding'; // TASK-matrix-v3 P1: Channel 绑定表 (user×agent 唯一)
+var MATRIX_HS = 'https://matrix.fmode.cn';
+var MATRIX_DOMAIN = 'fmode.cn';
+var ADMIN_USER = 'admin';
+var ADMIN_PASS = 'Fmode@2026';
+var SERVICE_BOT = '@conduit:fmode.cn';
+var ADMIN_ROOM = '!UcLbdbuAWs9FZkrFIjPWBAe4jfNM2r_sj0-A0vJQgOQ'; // admin+conduit 专用, 严禁加人
+
+function _fail(code, mess) { response.status(code).json({ code: code, mess: mess }); }
+function _ok(data) { response.status(200).json(Object.assign({ code: 200 }, data)); }
+
+function _sessionToken() {
+  /*
+   * 🔴 2026-10-31 修复:平台不再把 token 塞进 request.body,只保留在
+   *    X-Parse-Session-Token 请求头里。原实现读 params.sessionToken 与
+   *    request.body.token 都拿不到 → 恒返回 null → 所有请求 401
+   *    (实测:同一 token 在 /parse/users/me 返回 200,在本函数返回 401)。
+   *    这里加上请求头来源(只增不改,旧调用方式保持有效)。
+   */
+  var h = request && request.headers ? request.headers : {};
+  if (h['x-parse-session-token']) return h['x-parse-session-token'];
+  if (params && params.sessionToken) return params.sessionToken;
+  if (request && request.body && request.body.token) return request.body.token;
+  return null;
+}
+
+async function _getUser(t) {
+  if (!t) return null;
+  if (request.user && request.user.id && !(params && params.sessionToken)) return request.user;
+  try {
+    var q = new Parse.Query(Parse.Session);
+    q.equalTo('sessionToken', t);
+    var s = await q.first({ useMasterKey: true });
+    return s ? s.get('user') : null;
+  } catch (e) { return null; }
+}
+
+function _sha256Hex(s) {
+  // 纯 JS SHA-256 (executor 作用域无 crypto/node modules; 输入为 ASCII sessionToken)
+  function rr(x, n) { return (x >>> n) | (x << (32 - n)); }
+  var K = [0x428a2f98,0x71374491,0xb5c0fbcf,0xe9b5dba5,0x3956c25b,0x59f111f1,0x923f82a4,0xab1c5ed5,
+           0xd807aa98,0x12835b01,0x243185be,0x550c7dc3,0x72be5d74,0x80deb1fe,0x9bdc06a7,0xc19bf174,
+           0xe49b69c1,0xefbe4786,0x0fc19dc6,0x240ca1cc,0x2de92c6f,0x4a7484aa,0x5cb0a9dc,0x76f988da,
+           0x983e5152,0xa831c66d,0xb00327c8,0xbf597fc7,0xc6e00bf3,0xd5a79147,0x06ca6351,0x14292967,
+           0x27b70a85,0x2e1b2138,0x4d2c6dfc,0x53380d13,0x650a7354,0x766a0abb,0x81c2c92e,0x92722c85,
+           0xa2bfe8a1,0xa81a664b,0xc24b8b70,0xc76c51a3,0xd192e819,0xd6990624,0xf40e3585,0x106aa070,
+           0x19a4c116,0x1e376c08,0x2748774c,0x34b0bcb5,0x391c0cb3,0x4ed8aa4a,0x5b9cca4f,0x682e6ff3,
+           0x748f82ee,0x78a5636f,0x84c87814,0x8cc70208,0x90befffa,0xa4506ceb,0xbef9a3f7,0xc67178f2];
+  var H = [0x6a09e667,0xbb67ae85,0x3c6ef372,0xa54ff53a,0x510e527f,0x9b05688c,0x1f83d9ab,0x5be0cd19];
+  var bytes = [];
+  for (var i = 0; i < s.length; i++) {
+    var c = s.charCodeAt(i);
+    if (c < 128) bytes.push(c);
+    else if (c < 2048) { bytes.push(0xc0 | (c >> 6), 0x80 | (c & 63)); }
+    else { bytes.push(0xe0 | (c >> 12), 0x80 | ((c >> 6) & 63), 0x80 | (c & 63)); }
+  }
+  var bitLen = bytes.length * 8;
+  bytes.push(0x80);
+  while (bytes.length % 64 !== 56) bytes.push(0);
+  for (var j = 7; j >= 0; j--) bytes.push(Math.floor(bitLen / Math.pow(2, j * 8)) & 0xff);
+  for (var b = 0; b < bytes.length; b += 64) {
+    var w = new Array(64);
+    for (var t = 0; t < 16; t++) {
+      w[t] = (bytes[b + t*4] << 24) | (bytes[b + t*4 + 1] << 16) | (bytes[b + t*4 + 2] << 8) | bytes[b + t*4 + 3];
+    }
+    for (var t2 = 16; t2 < 64; t2++) {
+      var s0 = rr(w[t2-15], 7) ^ rr(w[t2-15], 18) ^ (w[t2-15] >>> 3);
+      var s1 = rr(w[t2-2], 17) ^ rr(w[t2-2], 19) ^ (w[t2-2] >>> 10);
+      w[t2] = (w[t2-16] + s0 + w[t2-7] + s1) | 0;
+    }
+    var a = H[0], bb = H[1], c = H[2], d = H[3], e = H[4], f = H[5], g = H[6], h = H[7];
+    for (var t3 = 0; t3 < 64; t3++) {
+      var S1 = rr(e, 6) ^ rr(e, 11) ^ rr(e, 25);
+      var ch = (e & f) ^ (~e & g);
+      var temp1 = (h + S1 + ch + K[t3] + w[t3]) | 0;
+      var S0 = rr(a, 2) ^ rr(a, 13) ^ rr(a, 22);
+      var maj = (a & bb) ^ (a & c) ^ (bb & c);
+      var temp2 = (S0 + maj) | 0;
+      h = g; g = f; f = e; e = (d + temp1) | 0;
+      d = c; c = bb; bb = a; a = (temp1 + temp2) | 0;
+    }
+    H[0] = (H[0] + a) | 0; H[1] = (H[1] + bb) | 0; H[2] = (H[2] + c) | 0; H[3] = (H[3] + d) | 0;
+    H[4] = (H[4] + e) | 0; H[5] = (H[5] + f) | 0; H[6] = (H[6] + g) | 0; H[7] = (H[7] + h) | 0;
+  }
+  var out = '';
+  for (var k = 0; k < 8; k++) out += ('00000000' + (H[k] >>> 0).toString(16)).slice(-8);
+  return out;
+}
+
+function _mxPassword(sessionToken) {
+  return _sha256Hex('fmode-mx:' + sessionToken).slice(0, 24);
+}
+
+function _httpJson(url, opts) {
+  // Deno/Node 双兼容 fetch; 10s 超时
+  return new Promise(function (resolve) {
+    try {
+      if (typeof fetch !== 'function') { resolve({ ok: false, err: 'no fetch' }); return; }
+      var ctrl = typeof AbortController === 'function' ? new AbortController() : null;
+      var t = ctrl ? setTimeout(function () { try { ctrl.abort(); } catch (e) {} }, 10000) : null;
+      var init = {
+        method: (opts && opts.method) || 'GET',
+        headers: (opts && opts.headers) || {},
+        signal: ctrl ? ctrl.signal : undefined,
+      };
+      if (opts && opts.body !== undefined) init.body = JSON.stringify(opts.body);
+      fetch(url, init)
+        .then(function (res) {
+          return res.text().then(function (txt) {
+            if (t) clearTimeout(t);
+            var data = null;
+            try { data = JSON.parse(txt); } catch (e) { data = { raw: txt }; }
+            resolve({ ok: res.status >= 200 && res.status < 300, status: res.status, data: data });
+          });
+        })
+        .catch(function (e) { if (t) clearTimeout(t); resolve({ ok: false, err: String(e) }); });
+    } catch (e) { resolve({ ok: false, err: String(e) }); }
+  });
+}
+
+// ---- Matrix 客户端 (access_token 方式,Continuwuity v1.18) ----
+function _mx(token) {
+  function call(path, method, body) {
+    var url = MATRIX_HS + path;
+    if (token) url += (url.indexOf('?') >= 0 ? '&' : '?') + 'access_token=' + encodeURIComponent(token);
+    return _httpJson(url, { method: method || 'GET', body: body,
+      headers: body !== undefined ? { 'Content-Type': 'application/json' } : {} });
+  }
+  return {
+    login: function (user, password) {
+      return call('/_matrix/client/v3/login', 'POST', {
+        type: 'm.login.password', identifier: { type: 'm.id.user', user: user }, password: password });
+    },
+    whoami: function () { return call('/_matrix/client/v3/account/whoami'); },
+    joinedRooms: function () { return call('/_matrix/client/v3/joined_rooms'); },
+    joinedMembers: function (roomId) {
+      return call('/_matrix/client/v3/rooms/' + encodeURIComponent(roomId) + '/joined_members');
+    },
+    sendAdminCmd: function (roomId, cmd) {
+      return call('/_matrix/client/v3/rooms/' + encodeURIComponent(roomId) +
+        '/send/m.room.message/prov' + Date.now() + Math.floor(Math.random() * 900 + 100), 'PUT',
+        { msgtype: 'm.text', body: cmd });
+    },
+    lastBotMsg: function (roomId) {
+      return call('/_matrix/client/v3/rooms/' + encodeURIComponent(roomId) + '/messages?dir=b&limit=3');
+    },
+    createRoom: function (inviteMxid, name) {
+      return call('/_matrix/client/v3/createRoom', 'POST', {
+        invite: [inviteMxid], is_direct: true, preset: 'trusted_private_chat', name: name || undefined });
+    },
+    joinRoom: function (roomId) {
+      return call('/_matrix/client/v3/join/' + encodeURIComponent(roomId), 'POST', {});
+    },
+  };
+}
+
+function _sleep(ms) { return new Promise(function (r) { setTimeout(r, ms); }); }
+
+/** admin room 命令: 发送后轮询最新 bot 回复 (机器人回复可能滞后一条) */
+async function _adminCmd(mx, token, cmd) {
+  var before = null;
+  var d0 = await mx.lastBotMsg(ADMIN_ROOM);
+  if (d0.ok && d0.data && d0.data.chunk) {
+    for (var i = 0; i < d0.data.chunk.length; i++) {
+      var ev = d0.data.chunk[i];
+      if (ev.sender === SERVICE_BOT && ev.type === 'm.room.message') { before = ev.content.body; break; }
+    }
+  }
+  var sent = await mx.sendAdminCmd(ADMIN_ROOM, cmd);
+  if (!sent.ok) return { ok: false, err: 'admin cmd 发送失败: ' + JSON.stringify(sent).slice(0, 120) };
+  var last = before;
+  for (var a = 0; a < 5; a++) {
+    await _sleep(1500);
+    var d = await mx.lastBotMsg(ADMIN_ROOM);
+    if (d.ok && d.data && d.data.chunk) {
+      for (var j = 0; j < d.data.chunk.length; j++) {
+        var ev2 = d.data.chunk[j];
+        if (ev2.sender === SERVICE_BOT && ev2.type === 'm.room.message') {
+          if (ev2.content.body !== before) last = ev2.content.body;
+          break;
+        }
+      }
+    }
+    if (last && last !== before && (last.indexOf('```') >= 0 || last.indexOf('|') >= 0)) break;
+  }
+  return { ok: true, reply: last || '' };
+}
+
+/** 确保本地 Matrix 账号存在并返回 access_token。幂等: 已存在则重置为派生密码再签 token。
+ *  v4.1 修复: 参数名 mxAdmin→mx — 原函数体引用 `mx` 而 `_ensureAccount(mxAdmin, …)` 形参叫
+ *  mxAdmin → 新开绑定 (无缓存路径) 必然 ReferenceError "mx is not defined" (HTTP 500 SDK登录失败)。 */
+async function _ensureAccount(mx, adminTok, local, password) {
+  var mx0 = _mx(''); // login 端点不能带他人 access_token, 探测/登录一律用无凭据客户端
+  var mxid = '@' + local + ':' + MATRIX_DOMAIN;
+  // 先探测: 用派生密码登录 (账号已存在且密码一致 → 直接成功)
+  var lg = await mx0.login(local, password);
+  if (lg.ok && lg.data && lg.data.access_token) {
+    return { mxid: mxid, accessToken: lg.data.access_token, deviceId: lg.data.device_id || '', created: false };
+  }
+  // 不存在/密码不符 → admin room create (已存在报错则 reset-password 对齐派生密码)
+  var cr = await _adminCmd(mx, adminTok, '!admin users create ' + local + ' ' + password);
+  var reply = cr.reply || '';
+  if (reply.indexOf('Created') < 0) {
+    // 已存在/不可用/任意报错 → reset-password 对齐派生密码后重试登录
+    var rs = await _adminCmd(mx, adminTok, '!admin users reset-password ' + local + ' ' + password);
+    reply = rs.reply || reply;
+  }
+  var lg2 = await mx0.login(local, password);
+  if (!lg2.ok || !lg2.data || !lg2.data.access_token) {
+    return null; // 账号注册失败
+  }
+  return { mxid: mxid, accessToken: lg2.data.access_token, deviceId: lg2.data.device_id || '', created: true };
+}
+
+// ---- TASK-matrix-v3 P1: FmodeMatrixBinding 绑定表 (user×agent 唯一, roomId 固定) ----
+
+/** 查绑定: {userPointer, agentId 唯一} → 记录或 null (agentId=null 时按 userPointer+agentMxId 空) */
+async function _findBinding(userPointer, agentIdStr) {
+  try {
+    var q = new Parse.Query(BINDING_CLASS);
+    q.equalTo('userPointer', userPointer);
+    q.equalTo('agentId', agentIdStr || '');
+    q.descending('updatedAt');
+    var rec = await q.first({ useMasterKey: true });
+    if (!rec) return null;
+    // 历史遗留多条 (v0.1.x 每次新建/极端并发): 只认最新一条, 其余标 revoked
+    var others = await q.find({ useMasterKey: true });
+    for (var i = 0; i < others.length; i++) {
+      if (others[i].id !== rec.id && others[i].get('status') !== 'revoked') {
+        others[i].set('status', 'revoked');
+        try { await others[i].save(null, { useMasterKey: true }); } catch (e) { /* 幂等尽力 */ }
+      }
+    }
+    return rec;
+  } catch (e) { return null; } // 表不存在/查询失败 → 按无绑定走新建
+}
+
+/** 绑定记录 → 响应凭据 */
+function _bindingPayload(rec) {
+  return {
+    userMxId: rec.get('userMxId'),
+    agentMxId: rec.get('agentMxId') || null,
+    homeserver: rec.get('homeserver') || MATRIX_HS,
+    accessToken: rec.get('accessToken'),
+    deviceId: rec.get('deviceId') || '',
+    roomId: rec.get('roomId') || null,
+    bindingId: rec.id,
+    status: rec.get('status') || 'active',
+    updatedAt: rec.get('updatedAt'),
+  };
+}
+
+/** 读/建 FmodeMatrixBinding 表 (schema 缺失时无法显式建表, Parse 无 schema 约束时首条 save 即建) */
+async function _upsertBinding(user, agentIdStr, cred) {
+  var B = Parse.Object.extend(BINDING_CLASS);
+  var rec = await _findBinding(user, agentIdStr);
+  if (!rec) rec = new B();
+  rec.set('userPointer', user);
+  rec.set('agentId', agentIdStr || '');
+  rec.set('userMxId', cred.userMxId);
+  if (cred.agentMxId) rec.set('agentMxId', cred.agentMxId);
+  rec.set('homeserver', cred.homeserver || MATRIX_HS);
+  if (cred.accessToken) rec.set('accessToken', cred.accessToken);
+  if (cred.deviceId) rec.set('deviceId', cred.deviceId);
+  if (cred.roomId) rec.set('roomId', cred.roomId);
+  rec.set('status', cred.status || 'active');
+  rec.set('updatedAt', new Date());
+  await rec.save(null, { useMasterKey: true });
+  return rec;
+}
+
+async function handler(request, response) {
+  try {
+    var user = await _getUser(_sessionToken());
+    if (!user) return _fail(401, '账号注册失败: sessionToken 无效或已过期, 请重新登录 App');
+    var sessionToken = _sessionToken();
+
+    // ---- Agent 定位 (agentId 可传 objectId 或 agentId; 缺省只开通用户身份) ----
+    var agentRec = null;
+    var agentIdParam = params && params.agentId;
+    var agentIdStr = '';
+    if (agentIdParam) {
+      var rec = await (function () {
+        var q1 = new Parse.Query(CLASS_NAME); q1.equalTo('agentId', agentIdParam);
+        return q1.first({ useMasterKey: true }).then(function (r1) {
+          return r1 || new Parse.Query(CLASS_NAME).get(agentIdParam, { useMasterKey: true });
+        });
+      })();
+      if (!rec) return _fail(404, '房间创建失败: Agent 不存在(' + agentIdParam + ')');
+      // ACL 校验: 记录归属人或超管才可开通 (与 fmodeagent-status 同规则, 防越权开通)
+      var SUPER_ROLE = 'FMODE_AGENT_SUPERADMIN';
+      var isSuper = false;
+      try {
+        var rq = new Parse.Query(Parse.Role); rq.equalTo('name', SUPER_ROLE);
+        var role = await rq.first({ useMasterKey: true });
+        if (role) {
+          var us = await role.getUsers().query().find({ useMasterKey: true });
+          for (var ui = 0; ui < us.length; ui++) { if (us[ui].id === user.id) { isSuper = true; break; } }
+        }
+      } catch (e) { /* 角色查询失败按非超管处理 */ }
+      var owner = rec.get('user');
+      if (!isSuper && (!owner || owner.id !== user.id)) {
+        return _fail(403, '房间创建失败: 该 Agent 不在你的可见范围内, 无权开通会话');
+      }
+      agentRec = rec;
+      agentIdStr = String(rec.get('agentId') || rec.id);
+    }
+
+    // ---- P1a 幂等: 查绑定表 → active 且 (validate 时探活通过) → 直接返回, 不重新生成 ----
+    var validate = !!(params && params.validate); // App 缓存校验/过期自愈探测时传 validate:1
+    var binding = await _findBinding(user, agentIdStr);
+    // v4.2 P3: 绑定里的 agentMxId 若还是旧语义化账号 (≠ @<objectId小写>) → 不命中缓存,
+    // 走下方重建流程 (新 objectId 账号 + 新 DM 房间 + 绑定表更新)
+    var bindingMxStale = false;
+    if (binding && agentRec) {
+      var expectedAgentMx = '@' + String(agentRec.id).toLowerCase() + ':' + MATRIX_DOMAIN;
+      bindingMxStale = binding.get('agentMxId') !== expectedAgentMx;
+    }
+    if (binding && !bindingMxStale && binding.get('status') === 'active' && binding.get('accessToken')) {
+      var bp = _bindingPayload(binding);
+      if (!validate) {
+        // 非校验请求: 直接命中绑定表返回 (秒进, roomId 固定 → 历史完整)
+        return _ok(Object.assign({ cached: true }, bp));
+      }
+      // validate: 用绑定 token 探活 whoami → 200 即凭据仍有效 (幂等返回); 失效走下方刷新
+      var mxProbe = _mx(bp.accessToken);
+      var who = await mxProbe.whoami();
+      if (who.ok && who.data && who.data.user_id === bp.userMxId) {
+        return _ok(Object.assign({ cached: true }, bp));
+      }
+      // M_UNKNOWN_TOKEN/任何失效 → 刷新凭据并更新表 (不换 device/roomId: admin 对齐派生密码后重签)
+    }
+
+    // ---- Matrix: admin 登录 → 确保用户/Agent 账号 ----
+    var mx0 = _mx('');
+    var adm = await mx0.login(ADMIN_USER, ADMIN_PASS);
+    if (!adm.ok || !adm.data || !adm.data.access_token) {
+      return _fail(502, '账号注册失败: Matrix homeserver 管理登录失败(' +
+        ((adm.data && (adm.data.errcode || adm.data.error)) || adm.err || '未知') + ')');
+    }
+    var adminTok = adm.data.access_token;
+    var mx = _mx(adminTok);
+
+    var password = _mxPassword(sessionToken);
+    var userLocal = String(user.id).toLowerCase();
+    var ua = await _ensureAccount(mx, adminTok, userLocal, password);
+    if (!ua) return _fail(502, '账号注册失败: 用户 Matrix 账号(' + userLocal + ')创建/登录失败');
+
+    var agentMxId = null;
+    if (agentRec) {
+      // v4.2 P3 (TASK-matrix-v42 2026-09-09): Agent Matrix 账号一律用 FMODE_AGENT.objectId
+      // —— @<objectId小写>:fmode.cn (objectId 全局唯一, 语义化拼音名不唯一且不利于批量)。
+      // displayname 仍用中文名 (由 Hermes channel 侧/房间名承载, 不改账号 localpart)。
+      // 兼容: FmodeAgent.channels.matrixConfig.userId 若已是 objectId 账号则直接用;
+      // 旧语义化账号 (@xinting 等) 由 provision-all-agents 批量迁移 (新建+绑定表更新+旧号归档)。
+      var agentLocal = String(agentRec.id).toLowerCase();
+      // Agent 账号存在性: admin room users list 探测; 缺失时走 _ensureAccount 自动注册
+      // (Agent 密码 = 与 App 用户同款派生式不适用——Agent 无 sessionToken,
+      //  用 objectId 固定盐派生: sha256("fmode-agent-mx:"+objectId) 前 24 位, 幂等可重置)
+      var ul = await _adminCmd(mx, adminTok, '!admin users list');
+      var found = ((ul.reply || '').indexOf('@' + agentLocal + ':') >= 0);
+      if (!found) {
+        // v4.2: 不再 502 拒绝 — 直接注册 objectId 账号 (通用插件自接入铺路)
+        var agentPass = _sha256Hex('fmode-agent-mx:' + agentRec.id).slice(0, 24);
+        var ag = await _ensureAccount(mx, adminTok, agentLocal, agentPass);
+        if (!ag) {
+          return _fail(502, '账号注册失败: Agent Matrix 账号(@' + agentLocal + ')创建失败');
+        }
+      }
+      agentMxId = '@' + agentLocal + ':' + MATRIX_DOMAIN;
+    }
+
+    // ---- 用户侧长效 token (issue-token, 绑定 device; App 端也可用派生密码自行登录) ----
+    var accessToken = ua.accessToken;
+    var deviceId = ua.deviceId;
+    var it = await _adminCmd(mx, adminTok, '!admin users issue-token ' + userLocal + ' ' + password);
+    var m = (it.reply || '').match(/device `([^`]+)` with access token `([A-Za-z0-9_\-\.]+)`/);
+    if (m) { deviceId = m[1]; accessToken = m[2]; }
+
+    // ---- DM 房间: 绑定表 roomId 优先 (P1b 历史恢复: 房间唯一不重建) → 已有共同房间探测 → createRoom ----
+    var roomId = null;
+    if (agentMxId) {
+      if (binding && binding.get('roomId')
+          // v4.2 P3: 旧绑定房间属于旧语义化账号 — agentMxId 变更后旧 roomId 不复用 (房间重建)
+          && (!binding.get('agentMxId') || binding.get('agentMxId') === agentMxId)) {
+        roomId = binding.get('roomId'); // 绑定表既定房间, 不新建
+      } else {
+        var myTok = accessToken;
+        var mxMe = _mx(myTok);
+        var jr = await mxMe.joinedRooms();
+        if (jr.ok && jr.data && jr.data.joined_rooms) {
+          for (var ri = 0; ri < jr.data.joined_rooms.length; ri++) {
+            var rid = jr.data.joined_rooms[ri];
+            var jm = await mxMe.joinedMembers(rid);
+            if (jm.ok && jm.data && jm.data.joined && jm.data.joined[agentMxId]) { roomId = rid; break; }
+          }
+        }
+        if (!roomId) {
+          var crr = await mxMe.createRoom(agentMxId, (agentRec && agentRec.get('name')) || undefined);
+          if (crr.ok && crr.data && crr.data.room_id) {
+            roomId = crr.data.room_id;
+            // Agent 侧由官方 Hermes channel 自动 accept 邀请 (MATRIX_REQUIRE_MENTION=false DM)
+          } else {
+            return _fail(502, '房间创建失败: createRoom(' + agentMxId + ') ' +
+              ((crr.data && (crr.data.errcode || crr.data.error)) || crr.err || '未知'));
+          }
+        }
+      }
+    }
+
+    // ---- P1a 写表/更新表 (首次建绑定 或 token 过期刷新) ----
+    var cred = {
+      userMxId: ua.mxid, agentMxId: agentMxId, homeserver: MATRIX_HS,
+      accessToken: accessToken, deviceId: deviceId, roomId: roomId, status: 'active',
+    };
+    var saved = await _upsertBinding(user, agentIdStr, cred);
+    return _ok({
+      cached: false,
+      userMxId: ua.mxid,
+      agentMxId: agentMxId,
+      homeserver: MATRIX_HS,
+      accessToken: accessToken,
+      deviceId: deviceId,
+      roomId: roomId,
+      bindingId: saved.id,
+      status: 'active',
+      userCreated: ua.created,
+    });
+  } catch (e) {
+    return _fail(500, 'SDK登录失败: ' + ((e && e.message) || String(e)));
+  }
+}

+ 277 - 0
cloud/backup/oR5O67i1Cm-fmodeagent-chatthreads-v0.1.0.js

@@ -0,0 +1,277 @@
+// FmodeAgent 会话目录云函数(2026-10-31,v0.1.0)
+//
+// 目的:会话目录(标题/归档/排序)此前只存在客户端 localStorage,
+//       换浏览器或清站点数据就全部丢失。这里把它落到服务端,成为**唯一真相源**。
+//
+// 调用:POST https://server.fmode.cn/api/functions
+//       头:X-Parse-Application-Id / X-Parse-Session-Token
+//       body:{ id: "<本函数 objectId>", params: {...} }
+//
+// params.op:
+//   list   { agentId }                                  → { code:200, threads:[...] }
+//   upsert { items:[{agentId,roomId,threadRoot,title,messageCount,lastMessageAt,lastPreview,revive}] }
+//                                                       → { code:200, created, updated }
+//   patch  { threadRoot, title?, archived? }            → { code:200, thread }
+//   remove { threadRoot }                               → { code:200, removed }
+//
+// 平台约定(与 fmodeagent-matrix-provision 一致,实测):
+//   · 入口 async function handler(request, response);入参在**全局 params**;
+//   · 身份:优先 request.user(平台按 sessionToken 注入),否则按 token 查 _Session;
+//   · 🔴 输出必须 response.status(200).json(...) **显式写出**;直接 return 对象平台不回包(请求挂起)。
+//
+// 安全约定:
+//   1. 归属一律取服务端解析出的 user,忽略客户端传入的任何 user / company 参数;
+//   2. 会话只归用户本人(不写 company),不会进公司共享视角;
+//   3. 删除是软删(isDeleted=true),与平台「删除用软删」铁律一致;
+//   4. 标题一旦被用户手动改过(titleLocked=true),upsert 不再覆盖。
+//
+// 表:FmodeChatThread(首次写入自动建表)
+//   user Pointer<_User> / agentId String / roomId String / threadRoot String
+//   title String / titleLocked Boolean / messageCount Number / lastMessageAt Date
+//   archived Boolean / isDeleted Boolean / lastPreview String
+//   唯一语义:(user, roomId, threadRoot) —— upsert 前查重保证
+
+var CLASS_NAME = 'FmodeChatThread';
+var MAX_ITEMS = 200;
+var TITLE_MAX = 60;
+var PREVIEW_MAX = 120;
+
+function _ok(data) {
+  response.status(200).json(Object.assign({ code: 200 }, data || {}));
+  return true;
+}
+function _fail(code, mess) {
+  response.status(200).json({ code: code, mess: mess });
+  return false;
+}
+
+function _str(v, max) {
+  var s = v == null ? '' : String(v);
+  return max && s.length > max ? s.slice(0, max) : s;
+}
+
+function _date(v) {
+  if (!v) return new Date();
+  var d = v instanceof Date ? v : new Date(v);
+  return isNaN(d.getTime()) ? new Date() : d;
+}
+
+/** token → _User(与 fmodeagent-matrix-provision 完全一致) */
+async function _getUser(t) {
+  if (!t) return null;
+  if (request && request.user && request.user.id) return request.user;
+  // request.user 未被平台注入(实测),走 token 查 _Session
+  try {
+    var q = new Parse.Query(Parse.Session);
+    q.equalTo('sessionToken', t);
+    var s = await q.first({ useMasterKey: true });
+    return s ? s.get('user') : null;
+  } catch (e) {
+    return null;
+  }
+}
+
+/** 取当前会话 token(与平台其他函数同一约定) */
+/**
+ * 取当前会话 token。
+ *
+ * 🔴 实测(2026-10-31,加 __debug 探测得出):
+ *    · 平台把 X-Parse-Session-Token 请求头**原样保留**在 request.headers;
+ *    · request.body 只剩 {id, params},body 里的 token 被平台剥离;
+ *    · request.user **没有**被注入。
+ *    ⇒ 身份的唯一可靠来源是 x-parse-session-token 请求头(兜底 params.sessionToken)。
+ */
+function _sessionToken() {
+  var h = request && request.headers ? request.headers : {};
+  if (h['x-parse-session-token']) return h['x-parse-session-token'];
+  if (request && request.body && request.body.token) return request.body.token;
+  if (params && params.sessionToken) return params.sessionToken;
+  return null;
+}
+
+/** 查某用户在某房间下的一条线程 */
+function _findOne(userPtr, roomId, threadRoot) {
+  var q = new Parse.Query(CLASS_NAME);
+  q.equalTo('user', userPtr);
+  q.equalTo('roomId', roomId);
+  q.equalTo('threadRoot', threadRoot);
+  q.limit(1);
+  return q.first({ useMasterKey: true });
+}
+
+async function opList(userPtr, p) {
+  var q = new Parse.Query(CLASS_NAME);
+  q.equalTo('user', userPtr);
+  /*
+   * includeDeleted:1 → 只返回已软删的记录(客户端据此建立「不允许重建」黑名单)。
+   * 跨设备有效:换浏览器/重新登录后仍能挡住「删了又被扫描建回来」。
+   */
+  if (p.includeDeleted) q.equalTo('isDeleted', true);
+  else q.notEqualTo('isDeleted', true);
+  if (p.agentId) q.equalTo('agentId', String(p.agentId));
+  if (p.roomId) q.equalTo('roomId', String(p.roomId));
+  q.descending('lastMessageAt');
+  q.limit(500);
+  var rows = await q.find({ useMasterKey: true });
+  return _ok({
+    threads: rows.map(function (r) {
+      return {
+        objectId: r.id,
+        agentId: r.get('agentId') || '',
+        roomId: r.get('roomId') || '',
+        threadRoot: r.get('threadRoot') || '',
+        title: r.get('title') || '',
+        titleLocked: !!r.get('titleLocked'),
+        messageCount: r.get('messageCount') || 0,
+        lastMessageAt: (r.get('lastMessageAt') || r.updatedAt || r.createdAt).getTime(),
+        archived: !!r.get('archived'),
+        lastPreview: r.get('lastPreview') || '',
+      };
+    }),
+  });
+}
+
+async function opUpsert(userPtr, p) {
+  var items = Array.isArray(p.items) ? p.items.slice(0, MAX_ITEMS) : [];
+  if (items.length === 0) return _fail(400, 'items 不能为空');
+  var created = 0;
+  var updated = 0;
+  for (var i = 0; i < items.length; i++) {
+    var it = items[i] || {};
+    var roomId = _str(it.roomId, 200);
+    var threadRoot = _str(it.threadRoot, 200);
+    if (!roomId || !threadRoot) continue;
+    var rec = await _findOne(userPtr, roomId, threadRoot);
+    if (!rec) {
+      rec = new Parse.Object(CLASS_NAME);
+      rec.set('user', userPtr);
+      rec.set('roomId', roomId);
+      rec.set('threadRoot', threadRoot);
+      rec.set('messageCount', 0);
+      rec.set('archived', false);
+      rec.set('isDeleted', false);
+      rec.set('titleLocked', false);
+      created++;
+    } else {
+      updated++;
+    }
+    // 标题:用户手改过就不覆盖;否则仅在为空时写入(避免把容器标题降级成首句)
+    var incomingTitle = _str(it.title, TITLE_MAX);
+    if (incomingTitle && !rec.get('titleLocked') && !rec.get('title')) {
+      rec.set('title', incomingTitle);
+    }
+    if (it.agentId) rec.set('agentId', _str(it.agentId, 120));
+    if (typeof it.messageCount === 'number' && it.messageCount >= 0) {
+      rec.set('messageCount', it.messageCount);
+    }
+    if (it.lastMessageAt) rec.set('lastMessageAt', _date(it.lastMessageAt));
+    if (it.lastPreview) rec.set('lastPreview', _str(it.lastPreview, PREVIEW_MAX));
+    // 重新出现即视为未删除(例如被误删的线程又有了新消息)
+    if (it.revive) rec.set('isDeleted', false);
+    await rec.save(null, { useMasterKey: true });
+  }
+  return _ok({ created: created, updated: updated });
+}
+
+async function opPatch(userPtr, p) {
+  var threadRoot = _str(p.threadRoot, 200);
+  if (!threadRoot) return _fail(400, 'threadRoot 不能为空');
+  var q = new Parse.Query(CLASS_NAME);
+  q.equalTo('user', userPtr);
+  q.equalTo('threadRoot', threadRoot);
+  q.notEqualTo('isDeleted', true);
+  q.descending('updatedAt');
+  var rec = await q.first({ useMasterKey: true });
+  if (!rec) return _fail(404, '会话不存在');
+  if (p.title != null) {
+    var t = _str(p.title, TITLE_MAX);
+    if (!t) return _fail(400, '标题不能为空');
+    rec.set('title', t);
+    rec.set('titleLocked', true);
+  }
+  if (p.archived != null) rec.set('archived', !!p.archived);
+  await rec.save(null, { useMasterKey: true });
+  return _ok({ thread: { objectId: rec.id, threadRoot: threadRoot } });
+}
+
+/**
+ * remove:**物理删除**云端索引(2026-11-01 用户裁决)。
+ *
+ * 语义:删除后不可恢复 —— 云端记录直接 `destroy()`,列表查询不会再返回。
+ * Matrix 房间里的消息**不动**(会话正文仍在服务器,只是不再作为会话展示)。
+ * 同时写入一条墓碑记录(isDeleted=true 的极简行),用于跨设备阻止扫描重建。
+ */
+async function opRemove(userPtr, p) {
+  var threadRoot = _str(p.threadRoot, 200);
+  if (!threadRoot) return _fail(400, 'threadRoot 不能为空');
+
+  /*
+   * 🔴 用 objectId 构造标准 Pointer 再查(2026-11-01):
+   *    平台注入的 userPtr 有时是不可枚举的代理对象,直接 equalTo('user', userPtr)
+   *    匹配不到已有记录(实测:记录明明存在,find() 返回 0 条)。
+   *    用 Pointer 显式指定 className + objectId 最稳。
+   */
+  var ptr = { __type: 'Pointer', className: '_User', objectId: userPtr.id };
+
+  /* ① 查全部同线程记录(含已软删) */
+  var q = new Parse.Query(CLASS_NAME);
+  q.equalTo('user', ptr);
+  q.equalTo('threadRoot', threadRoot);
+  q.limit(100);
+  var rows = await q.find({ useMasterKey: true });
+  console.warn('[REMOVE] found=' + rows.length + ' ids=' + rows.map(function (r) { return r.id; }).join(','));
+
+  /* ② 保留/创建一条墓碑(isDeleted=true),其余物理删除 */
+  var tomb = null;
+  for (var i = 0; i < rows.length; i++) {
+    if (rows[i].get('isDeleted') === true && !tomb) {
+      tomb = rows[i];
+      continue;
+    }
+    try {
+      await rows[i].destroy({ useMasterKey: true });
+    } catch (e) {
+      /* 单条删除失败不影响其余 */
+    }
+  }
+  if (!tomb) {
+    tomb = new Parse.Object(CLASS_NAME);
+    tomb.set('user', ptr);
+    tomb.set('threadRoot', threadRoot);
+    tomb.set('roomId', _str(p.roomId, 200) || '');
+    tomb.set('agentId', _str(p.agentId, 120) || '');
+    tomb.set('title', '');
+    tomb.set('messageCount', 0);
+    tomb.set('isDeleted', true);
+    await tomb.save(null, { useMasterKey: true });
+  }
+  return _ok({ removed: rows.length - (tomb ? 1 : 0), tomb: true });
+}
+
+async function handler(request, response) {
+  try {
+    /*
+     * 🔴 body 里的 token 由平台放在 **request.body.token**,不会合并进全局 params
+     *    (实测:只管 params.sessionToken 会一直 401,而 token 本身是有效的)。
+     *    业务参数在全局 params;用户身份两种来源都兼容。
+     */
+    params = (params && typeof params === 'object') ? params : (request && request.body && request.body.params) || {};
+    var user = await _getUser(_sessionToken());
+    if (!user) return _fail(401, 'sessionToken 无效或已过期,请重新登录 App');
+
+    switch (params.op) {
+      case 'list':
+        return await opList(user, params);
+      case 'upsert':
+        return await opUpsert(user, params);
+      case 'patch':
+        return await opPatch(user, params);
+      case 'remove':
+        return await opRemove(user, params);
+      default:
+        return _fail(400, '未知 op: ' + params.op);
+    }
+  } catch (e) {
+    return _fail(500, '会话目录服务异常: ' + ((e && e.message) || String(e)));
+  }
+}

+ 569 - 0
cloud/backup/xlosi3m46q-fmodeagent-status-v0.1.2.js

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

+ 578 - 0
cloud/backup/xlosi3m46q-fmodeagent-status-v0.1.2.patched.js

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

+ 37 - 0
cloud/functions/README.md

@@ -0,0 +1,37 @@
+# 云函数源码(随项目版本化)
+
+平台把云函数存在 Parse 库的 **`Function`** 表(`objectId` 即前端调用的函数 id)。
+本目录保存源码副本,便于 code review 与回滚;发布方式见下。
+
+## 已发布
+
+| 文件 | Function.objectId | name | path | version |
+|---|---|---|---|---|
+| `fmodeagent-chatthreads.js` | `oR5O67i1Cm` | fmodeagent-chatthreads | /fmodeagent/chatthreads | 0.1.3(已热更 2026-11-01 05:06 UTC) |
+
+> **v0.1.3 已热更到线上并经端到端验证(9/9 通过)**。
+> 改动:upsert 查重键从 `(user, roomId, threadRoot)` 改为 `(user, threadRoot)`,
+> 并折叠同一线程根的历史重复记录。
+> 回滚用 `cloud/backup/oR5O67i1Cm-fmodeagent-chatthreads-v0.1.0.js` 覆盖 `code` 字段即可。
+> 详见 `docs/03-interface/会话存储与生命周期-20261101.md` §六。
+
+## 发布 / 热更
+
+```bash
+# 新建(objectId 自动生成)
+curl -X POST https://server.fmode.cn/parse/classes/Function \
+  -H "X-Parse-Application-Id: ncloudmaster" -H "X-Parse-Master-Key: <MK>" \
+  -H "Content-Type: application/json" -d '{"name":"...","code":"...","enabled":true,...}'
+
+# 热更(免重启)
+curl -X PUT https://server.fmode.cn/parse/classes/Function/<objectId> \
+  -H "X-Parse-Application-Id: ncloudmaster" -H "X-Parse-Master-Key: <MK>" \
+  -H "Content-Type: application/json" -d '{"code":"..."}'
+```
+
+## 函数体约定(从现有函数反推,实测)
+
+- 入口:`async function handler(request, response)`;入参在**全局 `params`**。
+- 身份:`request.user` 由平台按 `X-Parse-Session-Token` 解析后注入。
+- 🔴 输出必须 `response.status(200).json(...)` **显式写出**;直接 `return 对象` 平台不回包(请求挂起)。
+- token 只能放 `X-Parse-Session-Token` 请求头;放 body 的 `token` 字段会被平台鉴权层直接 401。

+ 1 - 0
cloud/functions/fmodeagent-chatthreads.id

@@ -0,0 +1 @@
+oR5O67i1Cm

+ 324 - 0
cloud/functions/fmodeagent-chatthreads.js

@@ -0,0 +1,324 @@
+// FmodeAgent 会话目录云函数(2026-10-31 建,2026-11-01 v0.1.3 修复重复记录)
+//
+// 目的:会话目录(标题/归档/排序)此前只存在客户端 localStorage,
+//       换浏览器或清站点数据就全部丢失。这里把它落到服务端,成为**唯一真相源**。
+//
+// 调用:POST https://server.fmode.cn/api/functions
+//       头:X-Parse-Application-Id / X-Parse-Session-Token
+//       body:{ id: "<本函数 objectId>", params: {...} }
+//
+// params.op:
+//   list   { agentId }                                  → { code:200, threads:[...] }
+//   upsert { items:[{agentId,roomId,threadRoot,title,messageCount,lastMessageAt,lastPreview,revive}] }
+//                                                       → { code:200, created, updated }
+//   patch  { threadRoot, title?, archived? }            → { code:200, thread }
+//   remove { threadRoot }                               → { code:200, removed }
+//
+// 平台约定(与 fmodeagent-matrix-provision 一致,实测):
+//   · 入口 async function handler(request, response);入参在**全局 params**;
+//   · 身份:优先 request.user(平台按 sessionToken 注入),否则按 token 查 _Session;
+//   · 🔴 输出必须 response.status(200).json(...) **显式写出**;直接 return 对象平台不回包(请求挂起)。
+//
+// 安全约定:
+//   1. 归属一律取服务端解析出的 user,忽略客户端传入的任何 user / company 参数;
+//   2. 会话只归用户本人(不写 company),不会进公司共享视角;
+//   3. 删除是软删(isDeleted=true),与平台「删除用软删」铁律一致;
+//   4. 标题一旦被用户手动改过(titleLocked=true),upsert 不再覆盖。
+//
+// 表:FmodeChatThread(首次写入自动建表)
+//   user Pointer<_User> / agentId String / roomId String / threadRoot String
+//   title String / titleLocked Boolean / messageCount Number / lastMessageAt Date
+//   archived Boolean / isDeleted Boolean / lastPreview String
+//   唯一语义:(user, roomId, threadRoot) —— upsert 前查重保证
+
+var CLASS_NAME = 'FmodeChatThread';
+var MAX_ITEMS = 200;
+var TITLE_MAX = 60;
+var PREVIEW_MAX = 120;
+
+function _ok(data) {
+  response.status(200).json(Object.assign({ code: 200 }, data || {}));
+  return true;
+}
+function _fail(code, mess) {
+  response.status(200).json({ code: code, mess: mess });
+  return false;
+}
+
+function _str(v, max) {
+  var s = v == null ? '' : String(v);
+  return max && s.length > max ? s.slice(0, max) : s;
+}
+
+function _date(v) {
+  if (!v) return new Date();
+  var d = v instanceof Date ? v : new Date(v);
+  return isNaN(d.getTime()) ? new Date() : d;
+}
+
+/**
+ * token → _User(唯一权威解析)。
+ *
+ * 🔴 加固(2026-11-01,**不是**重复记录的根因,属正确性改进):
+ *    上一版先判 `request.user && request.user.id` 就直接 `return request.user`。
+ *    平台注入的 `request.user` 实测不一定带出 `objectId`,拿它当
+ *    `equalTo('user', ...)` 的条件会匹配不到记录。
+ *    现在以 token 查 `_Session` 得到的真实 `_User` 为准,
+ *    `request.user` 只在**确实带出 objectId** 时作为兜底。
+ *
+ * ⚠️ 重复记录的真根因在 `_findAllByRoot`(查重键含 roomId),见其注释。
+ */
+async function _getUser(t) {
+  if (!t) return null;
+  try {
+    var q = new Parse.Query(Parse.Session);
+    q.equalTo('sessionToken', t);
+    q.include('user');
+    var s = await q.first({ useMasterKey: true });
+    if (s) {
+      var u = s.get('user');
+      if (u) return u;
+    }
+  } catch (e) {
+    /* 落到 request.user 兜底 */
+  }
+  // 兜底:平台注入了**完整**用户对象时使用(残缺 Pointer 一律不用)
+  if (request && request.user && request.user.id) return request.user;
+  return null;
+}
+
+/** 取当前会话 token(与平台其他函数同一约定) */
+/**
+ * 取当前会话 token。
+ *
+ * 🔴 实测(2026-10-31,加 __debug 探测得出):
+ *    · 平台把 X-Parse-Session-Token 请求头**原样保留**在 request.headers;
+ *    · request.body 只剩 {id, params},body 里的 token 被平台剥离;
+ *    · request.user **没有**被注入。
+ *    ⇒ 身份的唯一可靠来源是 x-parse-session-token 请求头(兜底 params.sessionToken)。
+ */
+function _sessionToken() {
+  var h = request && request.headers ? request.headers : {};
+  if (h['x-parse-session-token']) return h['x-parse-session-token'];
+  if (request && request.body && request.body.token) return request.body.token;
+  if (params && params.sessionToken) return params.sessionToken;
+  return null;
+}
+
+/**
+ * 查某用户**所有房间**下同一线程根的全部记录(2026-11-01)。
+ *
+ * 为什么按 `threadRoot` 而不是 `(roomId, threadRoot)` 查:
+ *   · `threadRoot` 是客户端生成的全局唯一 ID,一个会话只可能属于一个线程;
+ *   · 房间可能变(agent 重建、换绑定),同一会话被写进新房间后,
+ *     旧房间那条会变成孤儿 —— 必须收敛掉,不能各留一条。
+ */
+function _findAllByRoot(userPtr, threadRoot) {
+  var q = new Parse.Query(CLASS_NAME);
+  q.equalTo('user', userPtr);
+  q.equalTo('threadRoot', threadRoot);
+  q.limit(50);
+  return q.find({ useMasterKey: true });
+}
+
+async function opList(userPtr, p) {
+  var q = new Parse.Query(CLASS_NAME);
+  q.equalTo('user', userPtr);
+  /*
+   * includeDeleted:1 → 只返回已软删的记录(客户端据此建立「不允许重建」黑名单)。
+   * 跨设备有效:换浏览器/重新登录后仍能挡住「删了又被扫描建回来」。
+   */
+  if (p.includeDeleted) q.equalTo('isDeleted', true);
+  else q.notEqualTo('isDeleted', true);
+  if (p.agentId) q.equalTo('agentId', String(p.agentId));
+  if (p.roomId) q.equalTo('roomId', String(p.roomId));
+  q.descending('lastMessageAt');
+  q.limit(500);
+  var rows = await q.find({ useMasterKey: true });
+  return _ok({
+    threads: rows.map(function (r) {
+      return {
+        objectId: r.id,
+        agentId: r.get('agentId') || '',
+        roomId: r.get('roomId') || '',
+        threadRoot: r.get('threadRoot') || '',
+        title: r.get('title') || '',
+        titleLocked: !!r.get('titleLocked'),
+        messageCount: r.get('messageCount') || 0,
+        lastMessageAt: (r.get('lastMessageAt') || r.updatedAt || r.createdAt).getTime(),
+        archived: !!r.get('archived'),
+        lastPreview: r.get('lastPreview') || '',
+      };
+    }),
+  });
+}
+
+async function opUpsert(userPtr, p) {
+  var items = Array.isArray(p.items) ? p.items.slice(0, MAX_ITEMS) : [];
+  if (items.length === 0) return _fail(400, 'items 不能为空');
+  var ptr = { __type: 'Pointer', className: '_User', objectId: userPtr.id };
+  var created = 0;
+  var updated = 0;
+  var collapsed = 0;
+  for (var i = 0; i < items.length; i++) {
+    var it = items[i] || {};
+    var roomId = _str(it.roomId, 200);
+    var threadRoot = _str(it.threadRoot, 200);
+    if (!roomId || !threadRoot) continue;
+    /*
+     * 🔴 按 threadRoot 查全部记录(不只是当前房间)。
+     *    房间可能变化;同时兜住历史脏数据里「同一线程根在多条记录」的情况 ——
+     *    取最新一条作为主记录,其余物理删除(索引重复没有任何保留价值)。
+     */
+    var rows = await _findAllByRoot(ptr, threadRoot);
+    var rec = null;
+    for (var j = 0; j < rows.length; j++) {
+      if (!rec || rows[j].updatedAt >= rec.updatedAt) rec = rows[j];
+    }
+    for (var k = 0; k < rows.length; k++) {
+      if (rec && rows[k].id !== rec.id) {
+        try {
+          await rows[k].destroy({ useMasterKey: true });
+          collapsed++;
+        } catch (e) {
+          /* 单条失败不影响其余 */
+        }
+      }
+    }
+    if (!rec) {
+      rec = new Parse.Object(CLASS_NAME);
+      rec.set('user', ptr);
+      rec.set('roomId', roomId);
+      rec.set('threadRoot', threadRoot);
+      rec.set('messageCount', 0);
+      rec.set('archived', false);
+      rec.set('isDeleted', false);
+      rec.set('titleLocked', false);
+      created++;
+    } else {
+      updated++;
+    }
+    // 标题:用户手改过就不覆盖;否则仅在为空时写入(避免把容器标题降级成首句)
+    var incomingTitle = _str(it.title, TITLE_MAX);
+    if (incomingTitle && !rec.get('titleLocked') && !rec.get('title')) {
+      rec.set('title', incomingTitle);
+    }
+    // 房间以本次上报为准(会话跟着房间走)
+    rec.set('roomId', roomId);
+    if (it.agentId) rec.set('agentId', _str(it.agentId, 120));
+    if (typeof it.messageCount === 'number' && it.messageCount >= 0) {
+      rec.set('messageCount', it.messageCount);
+    }
+    if (it.lastMessageAt) rec.set('lastMessageAt', _date(it.lastMessageAt));
+    if (it.lastPreview) rec.set('lastPreview', _str(it.lastPreview, PREVIEW_MAX));
+    // 🔴 upsert 表示「这条会话现在存在」→ 永远解除墓碑。
+    //    上一版只有传 revive:true 才解除,客户端漏传就会出现
+    //    「本地有、云端是墓碑」→ 下次拉取被当已删除跳过 → 会话消失(实测)。
+    rec.set('isDeleted', false);
+    await rec.save(null, { useMasterKey: true });
+  }
+  return _ok({ created: created, updated: updated, collapsed: collapsed });
+}
+
+async function opPatch(userPtr, p) {
+  var threadRoot = _str(p.threadRoot, 200);
+  if (!threadRoot) return _fail(400, 'threadRoot 不能为空');
+  var q = new Parse.Query(CLASS_NAME);
+  q.equalTo('user', userPtr);
+  q.equalTo('threadRoot', threadRoot);
+  q.notEqualTo('isDeleted', true);
+  q.descending('updatedAt');
+  var rec = await q.first({ useMasterKey: true });
+  if (!rec) return _fail(404, '会话不存在');
+  if (p.title != null) {
+    var t = _str(p.title, TITLE_MAX);
+    if (!t) return _fail(400, '标题不能为空');
+    rec.set('title', t);
+    rec.set('titleLocked', true);
+  }
+  if (p.archived != null) rec.set('archived', !!p.archived);
+  await rec.save(null, { useMasterKey: true });
+  return _ok({ thread: { objectId: rec.id, threadRoot: threadRoot } });
+}
+
+/**
+ * remove:**物理删除**云端索引(2026-11-01 用户裁决)。
+ *
+ * 语义:删除后不可恢复 —— 云端记录直接 `destroy()`,列表查询不会再返回。
+ * Matrix 房间里的消息**不动**(会话正文仍在服务器,只是不再作为会话展示)。
+ * 同时写入一条墓碑记录(isDeleted=true 的极简行),用于跨设备阻止扫描重建。
+ */
+async function opRemove(userPtr, p) {
+  var threadRoot = _str(p.threadRoot, 200);
+  if (!threadRoot) return _fail(400, 'threadRoot 不能为空');
+
+  /*
+   * 🔴 用 objectId 构造标准 Pointer 再查(2026-11-01):
+   *    平台注入的 userPtr 有时是不可枚举的代理对象,直接 equalTo('user', userPtr)
+   *    匹配不到已有记录(实测:记录明明存在,find() 返回 0 条)。
+   *    用 Pointer 显式指定 className + objectId 最稳。
+   */
+  var ptr = { __type: 'Pointer', className: '_User', objectId: userPtr.id };
+
+  /* ① 查全部同线程记录(含已软删) */
+  var q = new Parse.Query(CLASS_NAME);
+  q.equalTo('user', ptr);
+  q.equalTo('threadRoot', threadRoot);
+  q.limit(100);
+  var rows = await q.find({ useMasterKey: true });
+  console.warn('[REMOVE] found=' + rows.length + ' ids=' + rows.map(function (r) { return r.id; }).join(','));
+
+  /* ② 保留/创建一条墓碑(isDeleted=true),其余物理删除 */
+  var tomb = null;
+  for (var i = 0; i < rows.length; i++) {
+    if (rows[i].get('isDeleted') === true && !tomb) {
+      tomb = rows[i];
+      continue;
+    }
+    try {
+      await rows[i].destroy({ useMasterKey: true });
+    } catch (e) {
+      /* 单条删除失败不影响其余 */
+    }
+  }
+  if (!tomb) {
+    tomb = new Parse.Object(CLASS_NAME);
+    tomb.set('user', ptr);
+    tomb.set('threadRoot', threadRoot);
+    tomb.set('roomId', _str(p.roomId, 200) || '');
+    tomb.set('agentId', _str(p.agentId, 120) || '');
+    tomb.set('title', '');
+    tomb.set('messageCount', 0);
+    tomb.set('isDeleted', true);
+    await tomb.save(null, { useMasterKey: true });
+  }
+  return _ok({ removed: rows.length - (tomb ? 1 : 0), tomb: true });
+}
+
+async function handler(request, response) {
+  try {
+    /*
+     * 🔴 body 里的 token 由平台放在 **request.body.token**,不会合并进全局 params
+     *    (实测:只管 params.sessionToken 会一直 401,而 token 本身是有效的)。
+     *    业务参数在全局 params;用户身份两种来源都兼容。
+     */
+    params = (params && typeof params === 'object') ? params : (request && request.body && request.body.params) || {};
+    var user = await _getUser(_sessionToken());
+    if (!user) return _fail(401, 'sessionToken 无效或已过期,请重新登录 App');
+
+    switch (params.op) {
+      case 'list':
+        return await opList(user, params);
+      case 'upsert':
+        return await opUpsert(user, params);
+      case 'patch':
+        return await opPatch(user, params);
+      case 'remove':
+        return await opRemove(user, params);
+      default:
+        return _fail(400, '未知 op: ' + params.op);
+    }
+  } catch (e) {
+    return _fail(500, '会话目录服务异常: ' + ((e && e.message) || String(e)));
+  }
+}

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

@@ -2936,3 +2936,555 @@ POST /parse/classes/Workspace → 400 {"code":119,"error":"not allowed to access
 
 **验证**:气泡数全程稳定 2 ✓;`tsc` 0 错误;**单测 1063/1063**;
 `verify:ui` **12/12**;`verify:docs` 74 文件通过;生产构建通过。
+
+---
+
+## 2026-10-30 预览面板重构(设计)
+
+**做了什么**:根据用户提供的 7 张参考图,重新设计右侧文件预览与图片预览。
+- 图片不进右侧,改为深色灯箱;其余类型进右侧,统一加载/错误/不支持三态
+- 外壳支持「右侧 | 全屏」形态切换,Ctrl+滚轮仅缩放预览内容(面板内拦截)
+- PDF 改懒渲染
+
+**原因**:用户要求按参考图优化 UI 并处理缩放快捷键,避免与浏览器 Ctrl+滚轮冲突。
+
+**设计文档**:`docs/02-design/design-system/预览面板重构-设计-20261030.md`
+
+**计划与施工**:
+- `file-preview` 外壳重做:头部(文件名 · 「右侧 | 全屏」分段器 · 下载/重载/新标签/关闭)、内容区、状态栏(页码 · 缩放)
+- 新增 `image-lightbox`:图片不进右侧,改深色灯箱(下载 / 新标签 / Esc)
+- `pdf-preview` 懒渲染 + 缩放上收 + 页码回传
+- `file-kind` 中 csv 归 xlsx 类,现按扩展名走 CSV 文本解析(原先交给 zip 解析必失败)
+- 桌面/群聊/移动端 openFile 分流:图片→灯箱,音视频→模态,其余→右栏
+- 右栏预览时宽度上限 560 → 720
+
+**踩坑**:
+1. 内联形态下 `:host` 未约束高度 → `.fp-panel` 的 100% 解析为 auto,PDF 24 页把面板撑到 30961px,状态栏被顶出视口;用 `:has(> .fp-backdrop--inline)` 约束修复
+2. 全屏面板 94vw + backdrop 内边距超出视口,左侧被裁 → 改为 100%
+3. CSV 走 XlsxWorkbook 报 "Could not find EOCD"(存量既有问题)
+4. 灯箱工具条被视口裁切 → 改 fixed 布局
+5. verify:ui 拦截硬编码色值/outline:none → 灯箱改用 --overlay/--inverse 令牌
+
+**验证**:tsc 0 错误;单测 1079/1079;生产构建通过;verify:ui 12/12;verify:docs 75 文件通过;
+Playwright 实测截图核对:PDF(右侧/全屏/Ctrl+滚轮缩放/滚动懒渲染与页码)、HTML、CSV、JSON、不支持、灯箱。
+
+**已知限制**:
+- 「打开文件夹」在 Web 端无本地路径,未实现(参考图有、本项目不做空按钮)
+- CSV 走 fetch 读取,跨域不带 CORS 头时会报错并给出下载出口
+
+**自查修正(评审子代理连续 4 次因接口 503 无法启动,本次为自查而非独立评审)**:
+1. PDF 滚动监听在 `ngAfterViewInit` 时挂不上(.pdf-scroll 尚未渲染)→ 改为在宿主上 capture 委托;实测页码 1 → 11 正确
+2. `(wheel)` 冒泡监听在部分路径上可能晚于默认行为提交 → 追加 `(wheel.control)/(wheel.meta)` 二次拦截 preventDefault(缩放仍只放大一次)
+
+## 2026-10-31 登录页已登录自动跳转
+
+**做了什么**:LoginComponent 初始化时若 SessionService 已登录(且地址未带 `?token=`),直接按 redirect / 端落地页 replaceUrl 跳转,不再展示登录表单。
+**原因**:用户反馈已登录状态下地址栏打开 `/login` 仍停留在登录页。
+**验证**:Playwright 实测——已有会话打开 /login → `/desktop/chat`;无会话 → 停留 /login;单测 1079/1079。
+
+## 2026-10-31 预览区问题修复(真机联调 + lj-test 容器实测)
+
+**做了什么**(全部用 lj-test 容器实产 8 类文件在真实会话里验证):
+1. 统一缩放:面板级 capture 原生 wheel 监听;HTML 按住 Ctrl 时铺透明层让 iframe 内滚轮回到宿主 —— HTML/PDF/CSV/DOCX/JSON 实测 dpr 均不变(不再触发浏览器缩放)
+2. 外层固定、只缩内层:HTML 改为在 iframe 内部缩放;Office 用 zoom + 尺寸反向补偿
+3. 全屏铺满视口:修正 `fp-backdrop--inline` 对 full 态也生效导致 fixed 失效的问题
+4. 图标悬停提示:接入 ui-tooltip(下载/重载/新标签/关闭/缩放/形态切换)
+5. CSV 重做 Excel 风:列字母 + 行号冻结、网格线、斑马纹、数字右对齐、点击选中
+6. 图片灯箱:Esc 改全局监听;点图片以外区域关闭(按坐标判断,避开 box-shadow 误命中)
+
+**踩坑**:
+- 表格由手动拼 DOM 生成,没有 Angular 作用域属性 → 组件内联样式全部失效;改 `encapsulation: None`(office-preview,样式全部前缀限定)
+- Tailwind preflight 的 `border-width:0` 覆盖 `border-bottom` 简写 → 改分项 `border-*-width`
+- CSS 注释里写了反引号 → 截断 styles 模板字符串,导致 ng serve 一直编译失败却没察觉
+- 切换形态会重建宿主节点,viewChild 拿旧引用 → 改直接查 DOM
+
+**另一项**:登录页已登录时自动跳转(见上一条)。
+**文档**:`docs/03-interface/容器通讯-lj-test直连手册-20261031.md`(房间/身份/凭据获取/发消息/读回复/踩坑)
+
+**验证**:tsc 0 错误;单测 1080/1080;生产构建通过;verify:ui 12/12;verify:docs 76 文件;Playwright + CDP 真机截图逐项核对。
+
+### 2026-10-31 第二次修复:缩放语义改为「外层 UI 不变」
+
+**用户反馈**:HTML 缩放无效、Excel/代码/Word 缩放时外层也变形、MD 缩放失效。要求:**只缩放里面的内容,外层 UI 不变**。
+
+**根因**:
+1. HTML 原方案靠 `contentDocument.documentElement.zoom`,但 iframe 的 sandbox 无 allow-same-origin → contentDocument 为 null,静默无效
+2. Office 用 `zoom + width: calc(100%/z)` 反向补偿,补偿把外层布局一起改了
+3. 文本类直接改 `font-size` 并嵌在 grid 居中的 body 里,容器宽度随内容变化
+4. MD 根本没接缩放
+
+**改法**(统一两条路径):
+- 文档型(PDF/Office/HTML):`.fp-body`(外层视口,尺寸由面板决定)→ `.fp-content`(`zoom: --fp-scale`,不做宽度补偿)→ 内容。放大 → 内容超出 → 外层出滚动条,面板尺寸不变
+- 文本型(MD/TXT/JSON):只把 `--fs` 换算成 font-size 作用在纸张上,外层宽度恒定
+- PDF 移除内部 zoom 驱动,避免双重缩放
+
+**实测(真实会话,lj-test 交付文件)**:html/pdf/csv/docx 缩放 100%→130% 时面板宽度 528 不变;dpr 全程不变;滚动在外层。json/md 字号变化、容器宽度不变。
+
+**验证**:tsc 0 错误;单测 1080/1080;构建通过;verify:ui 12/12。
+
+### 2026-10-31 第三次修复:zoom → transform(外层框架真正固定)
+
+**用户反馈**:缩放时最外层的面板(含头部/状态栏)也跟着变大变小;只有 CSV 对。
+
+**根因(实测链)**:Chrome 的 CSS `zoom` **参与布局**——
+文档型内容层 `zoom: 0.9` 使 `.fp-body` 的布局高度从 574 变为 574÷0.9≈637,
+面板容器自身随之从 602 缩到 544,顶栏/状态栏/面板边界一起缩放。方向与之前的判断相反。
+
+**改法**:
+1. 文档型内容层改 `transform: scale(z)`(只绘制不布局)+ `width/height: calc(100%/z)` 补偿滚动范围
+2. inline 宿主与 `.fp-backdrop--inline` 改为 flex 链(`flex:1 1 0` + `min-height:0`),不再用 `display:block` 塌缩
+3. 外层 `.fp-body` 固定占比(`flex:1 1 0; height:auto`)
+
+**实测(真实会话)**:pdf / docx / csv 从 140% 到 60%,面板 528×844、内容区 528×759 **完全不变**;HTML 120%/60% 同样不变;文本类走字号路径,容器宽度不变。
+**验证**:tsc 0 错误;单测 1080/1080;构建通过;verify:ui 12/12。
+
+### 2026-10-31 三项体验修正
+
+1. **成果预览改为右栏内联**:`preview(d)` 有 url 时直接走 `openFile()`,与消息附件共用同一预览面板(可切全屏);
+   无 url(仍在容器里)时弹说明框,不假装能预览。
+2. **取消对话命名**:头部会话名去掉点击改名(只读展示),删除重命名弹层、`renaming` 状态与 `startRename/commitRename`。
+3. **「更多」菜单悬停对齐一级导航**:`.more-item` 补齐 `width:100% / min-height:38px / padding 0 16px / border:0 / font:inherit`,
+   button 与 a 两种行样式统一(原先 button 未清默认边框内边距,高亮块比文字行短一截)。
+
+**实测**:成果 index.html → `fp-panel--inline`、kind=html;头部 `.session-name` cursor=default、无 role;
+更多菜单四项统一 h=38 w=167 pad=12px,悬停整行高亮。
+**验证**:tsc 0 错误;单测 1080/1080;verify:ui 12/12;verify:docs 76 文件。
+
+### 2026-10-31 第四项:代码类只缩字号 + MD 缩放生效
+
+**用户要求**:代码(JSON/TXT)缩放只改**字体大小**,背景与容器不整体放大;MD 缩放此前无效果。
+
+**根因**:
+- 代码类:此前容器 font-size 确实在变,但 `fp-paper` 居中布局让观感像整体缩放
+- MD:`md-render` 内部所有字号写死为 `var(--chat-read-size, 15px)`(标题 1.28 倍基于它),
+  外层改 font-size 对标题无效(实测 h1 恒为 20.48px)→ 看起来"没反应"
+
+**改法**:
+- `.fp-paper` 局部覆盖 `--chat-read-size`(`[style.--chat-read-size.px]="docFont()"`),
+  MD 的标题/正文/表格随缩放整体变化;变量只作用于预览,不影响聊天正文
+- `.fp-text`(代码/纯文本)由 `codeFont()` 驱动 font-size,容器宽高恒定、顶部对齐
+
+**实测**:README.md 130% 时标题 19.2→24.96px、正文 15→19.5px,容器宽 548 不变;
+stats.json 13→16.9px,容器宽不变;两者顶栏/状态栏位置不变。
+**验证**:tsc 0 错误;单测 1080/1080;verify:ui 12/12;构建通过。
+
+### 2026-10-31 项目打不开 & MD 表格列宽
+
+1. **项目打不开**:确认为 `ng serve` 进程不存在(被回收),非代码问题;已重启,`GET /` 返回 200。
+2. **MD 表格列宽**:长 token(`report.html` 等)把首列挤成一字一行。改为
+   单元格 `word-break: break-word` + 首列 `min-width: 9em / width: 28%`。
+
+## 2026-10-31 会话目录从 Matrix 兜底恢复
+
+**用户反馈**:自己浏览器里看不到之前那条交付文件的会话,怀疑「只从本地加载、没有扫描重建」。
+
+**结论**:消息一直在 Matrix 房间里,没丢;丢的是**本地会话索引**。
+- 会话索引(标题/排序/归档)只存在 `localStorage: fmode_chat_sessions_<userId>`,不跨浏览器、不跨设备;
+- 代码里确有扫描重建(`scanSessions` + `mergeScanned`),但它只扫**内存中已加载的 timeline**;
+- `openReal()` 首次读时 `/sync` 往往还没回填 → 扫到 0~1 条 → 更早的线程根本没进内存 → 列表看起来是空的。
+
+**改法**:
+1. `ChatService.openReal()`:时间线非空但只扫出 ≤1 条会话时,调用 `backfillSessionIndex()` 主动向上翻历史(每页 50,最多 20 页),每页重扫;
+2. 终止条件抽成纯函数 `shouldKeepBackfilling()`:本页没有扫出新线程 / 到上限 / 无更早历史 / 翻页失败 → 停;
+3. 时间线完全为空时不翻页(sync 未回填,翻也没用),由既有的 `storeVersion` effect 在历史到达后再重建。
+4. 翻页结果留在 store 里,用户之后向上滚动查看更早消息不需要重复请求。
+
+**副作用**:进入一个旧容器时会多几次 `/messages` 请求(最多 20 次),只在「扫不出多条会话」时触发。
+
+**验证**:tsc 0 错误;单测 1088/1088(新增 8 条兜底判定用例);构建通过;verify:ui 12/12。
+
+**已知限制**:索引仍在本地。要做跨设备同步,需要把会话元信息迁到云函数表(接口侧变更,未做)。
+
+## 2026-10-31 隔离修复 + 会话目录云端持久化(方案 A)
+
+### 1. 隔离问题(确认存在并修复)
+- **根因**:`acl.ts` 的 `scopeOf()` 把 `company-member` 也映射为 `company`,服务端 `xlosi3m46q` 收到 `scope=company` 就返回**整个公司**的容器 → 普通成员能看到同事的 lj-test / 兔爷001。
+- **修法**:`company-member → 'mine'`(服务端本来就支持 `mine` 并且强制 `user=当前用户`,这是更小的请求范围,不存在越权放大)。管理员仍是 `company`。
+- **影响面**:容器/任务/成果/文件共用同一 scope,全部收紧为「只看自己的」。
+
+### 2. 云函数存放位置(已查明)
+平台把云函数存在 Parse 库 **`Function` 表**:`objectId` 即前端调用的函数 id,字段 `name/desc/enabled/code/version/path/type/respType/paramList`。
+发布 = `POST /parse/classes/Function` 新建,热更 = `PUT /parse/classes/Function/<id>`(免重启;写操作有频率限制,403 时需退避重试)。
+
+### 3. 会话目录云端持久化
+- **新表 `FmodeChatThread`**(首次写入自动建表):`user / agentId / roomId / threadRoot / title / titleLocked / messageCount / lastMessageAt / archived / isDeleted / lastPreview`
+- **新云函数 `fmodeagent-chatthreads`**:`objectId = oR5O67i1Cm`,`path=/fmodeagent/chatthreads`,v0.1.0
+  - `list / upsert / patch / remove`,归属一律取服务端解析出的用户,忽略客户端 user 参数;删除为软删
+  - 源码随项目版本化:`cloud/functions/fmodeagent-chatthreads.js`,约定见 `cloud/functions/README.md`
+- **客户端**:
+  - `ChatSessionService` 新增 `hydrateFromCloud()`(云端 → 本地合并,本地锁定标题不被覆盖、已移除线程不复活)与 `pushAll()`(本地 → 云端补齐)
+  - `create / rename / archive / remove / mergeScanned` 变更后单条/批量推送;**所有云端调用失败静默**,本地缓存照常可用
+  - `ChatService.openReal()` 顺序:云端恢复 → 本地补推 → Matrix 扫描兜底
+  - `FmodeApi` 用 `inject(..., { optional: true })`,单测/纯本地场景不需要 API
+
+### 踩坑(云函数平台约定,实测)
+1. 入口是 `handler(request, response)`,**业务参数在全局 `params`**
+2. 输出必须 `response.status(200).json(...)` 显式写出;`return` 对象平台不回包 → 请求挂起
+3. `request.user` **不会被注入**;body 里的 `token` 会被平台剥离
+4. 身份唯一可靠来源:`request.headers['x-parse-session-token']`
+5. `PUT /classes/Function/<id>` 有频率限制,连续请求返回 403,需退避
+
+### 验证
+tsc 0 错误;单测 1094/1094(新增 5 条云端合并用例);构建通过;verify:ui 12/12;
+云函数端到端实测(真实 sessionToken):list → upsert → patch 归档 → remove 软删 → list 全通过。
+
+### 2026-10-31 补充:登录凭据查看方式
+用户询问「sessionToken 怎么查」。新增 `docs/03-interface/如何查看登录凭据-20261031.md`:
+浏览器 F12 → Application → Local Storage → `fmode_session_token`;以及运维用 masterKey 查 `_Session` 的只读方式与失效处理。
+
+## 2026-10-31 会话同步语义修正 + 三项界面调整
+
+### 1. 本地缓存与新数据冲突(核心修复)
+**现象**:云端已清空的会话,重新进入后左侧又出现。
+**根因**(两处):
+- `hydrateFromCloud()` 在云端返回**空数组**时直接 return,本地缓存原样保留;
+- `pushAll()` 无条件把本地会话推回云端 → 删掉的会话被"复活"。
+**修法**:
+- 云端**明确返回空** → 以云端为准,清掉本机同容器的会话(`removedOnServer` 语义);
+- 云端**请求失败**(离线/异常)→ 才保留本地,并置 `cloudStale`;
+- `pushAll()` 仅在 `cloudStale` 时执行(离线兜底),否则不推。
+新增 2 条单测锁死该语义。
+
+### 2. 移除「重新生成」
+气泡操作条的 regenerate 按钮、output、页面方法与绑定、对应单测全部删除。
+气泡操作条现在只有「复制」。
+
+### 3. 会话行操作图标常驻
+`.sess-acts` 从「悬停才显示」改为常驻显示,默认 `opacity: .55`,悬停/聚焦/选中恢复 1。
+同一行放不下「标题 + 时间 + 按钮」,时间戳改为不显示。
+
+### 4. 图片上传样式(参考图)
+待发送图片从「小缩略图 + 文件名」改为:大缩略图(104×88 圆角卡片)+ 下方「解释图片 →」+ 右上角移除;
+点缩略图直接用现有灯箱预览(`previewPending()`)。
+非图片附件保持原有 chip 样式。
+
+### 5. TURN 404 噪声
+控制台反复报 `GET /voip/turnServer 404`。本项目不做语音通话(FR-11),建连时加
+`fallbackICEServerAllowed: false` 并兜底覆盖 `getTurnServers`,避免无意义请求与报错刷屏。
+
+**验证**:tsc 0 错误;单测 1095/1095;构建通过;verify:ui 12/12。
+
+## 2026-11-01 会话丢失事件复盘 + 预览四项修复
+
+### 🔴 我引入的缺陷(已修复)
+上一轮把 `pushAll()` 改成「仅云端请求失败时才推送」+ `hydrateFromCloud()` 云端为空时清本地,
+两者叠加导致:本地新建/扫描出的会话永远上不了云,换页面后两边都空 → 会话消失。
+**修复**:① 云端拉取成功即回写;② 初始化扫描只看云端是否为空(不再要求本地也为空);
+③ 扫描出的会话立即 pushAll;④ 云端为空时先等首次 /sync,再翻页扫全量。
+**数据未丢失**:Matrix 房间 319 条消息 / 9 个线程完好,已按用户要求只保留文档会话,其余软删索引。
+
+### 预览修复
+1. **代码预览缩放**:`.fp-text` 里写死的 `font-size: var(--fs-13)` 覆盖了内联绑定 → 删掉,由 `codeFont()` 驱动(13→16.9px @130%)
+2. **PDF 缩放**:`transform: scale` 不改布局 → `scrollHeight` 恒定,超出部分滚不到。改 `zoom` 于绝对定位的 `.fp-content`,并给 `.pdf-scroll` 加 `height: calc(100% / var(--fp-scale))`;缩放时按比例补偿滚动位置(以视口中心为原点)
+3. **在新标签打开**:根因是 Matrix 媒体接口强制 `Content-Disposition: attachment`(curl 实测)。
+   改为:同步开窗 → 带 access_token fetch 成 blob → 新标签页加载 blob(内联渲染,实测标题正确)
+4. 用户澄清:左侧列表**不显示时间**,已撤回;聊天区消息的时间与复制按钮保持常驻(`.acts` opacity 0.55、pointer-events auto)
+
+**验证**:tsc 0 错误;单测 1096/1096;构建通过;verify:ui 12/12;verify:docs 77 文件。
+
+## 2026-11-01 第二轮:会话残留 / HTML 白屏 / 控制台警告
+
+### 1. 会话删掉又回来(根因:删除只记在本地)
+- 现象:清空云端后刷新,9 条会话又出现(08:33–08:34 重建)
+- 根因:`mergeScanned` 只用**本地 localStorage** 黑名单挡住重建;重新登录/换浏览器后黑名单为空 → 扫描把它们当新会话建回来并推上云
+- 修复:云端软删记录成为权威黑名单
+  - 云函数 `oR5O67i1Cm` 的 `list` 支持 `includeDeleted:1`(v0.1.1 已热更)
+  - `ChatSessionService` 新增 `cloudRemoved`,`hydrateFromCloud` 同时拉活跃 + 软删两份
+  - `mergeScanned` 同时检查本地黑名单与云端软删名单
+- 验证:清本地缓存模拟重新登录后 `agent-lj` = 0 条、`lj-test` = 1 条(文档会话),未被重建
+
+### 2. HTML 预览白屏
+- 根因:`toBlobUrl(url)` 用**裸 fetch**,Matrix 媒体接口需要鉴权 → 401 → blob 是错误页 → iframe 白
+  (纯文本/Markdown 的 fetch 同样问题)
+- 修复:统一走 `mx.withAccessToken(url)` 抓取;实测 blob 内容 7260 字符、`<!DOCTYPE html>` 开头
+
+### 3. 控制台被 push rule 警告刷屏(16 条 → 0 条)
+- 根因:SDK 用**client 自己的 logger** 打印,而之前只设置了模块级 logger
+- 修复:createClient 后 `client.logger.setLevel('error')`
+- 验证:重载页面后 `warning/error` 计数为 0
+
+**验证**:tsc 0 错误;单测 1096/1096;构建通过;verify:ui 12/12。
+
+## 2026-11-01 第三轮:幽灵会话 / 时间常驻 / 点击跳转
+
+### 1. 「新对话」凭空出现(根因:扫描把无 thread 消息也建成了会话)
+- 现象:云端只剩 1 条,左侧却多出一条「新对话」
+- 根因:`scanSessions` 把 `threadId=undefined` 的消息归为一组并建成会话,
+  threadId 为空 → 前端拿不到合法 id、标题也推导不出来,显示成「新对话」
+- 另有历史污染:早期我用脚本 seed 时把**消息正文**写进了 threadRoot(200 字符中文),
+  与真实 event_id 形成两条重复记录
+- 修复:
+  - `scanSessions` 只认 `$` 开头的真实线程根,无 thread 的散消息不生成会话
+  - 物理删除全部污染记录,云端已彻底重建
+- 验证:清空云端 + 本地缓存后,不再出现「新对话」
+
+### 2. 聊天区时间不显示
+- 根因:`.bubble-meta` 整块 `opacity: 0`(时间与操作条都在里面),悬停才显示
+- 修复:`.bubble-meta { opacity: 1 }`,常驻;发送中/失败状态改用文字色强调而非悬停
+- 验证:`metaOpacity` 由 0 → 1
+
+### 3. 会话点击无反应
+- 实测点击可正常跳转(URL 变化、激活态正确),未复现;可能是点到删除图标或空会话
+
+**验证**:tsc 0 错误;单测 1096/1096;构建通过;verify:ui 12/12。
+
+## 2026-11-01 会话命名修复 + 通讯规范文档
+
+### 🔴 会话永远叫「新对话」的根因
+`touchSession()` 里:
+```js
+messageCount: (s.messageCount ?? 0) + 1,   // 先累加
+...
+(s.messageCount ?? 0) === 0                 // 再判断 → 永假
+```
+命名判定读到的已是**累加后**的计数,条件永不成立。
+修复:累加前先取 `isFirstMessage`,用它判定。
+
+实测日志确认:新会话 `count: 0, first: true, fromMe: true` → 触发命名。
+
+### 新增文档
+`docs/03-interface/会话创建与容器通讯规范-20261101.md`
+- 会话身份的真相链(客户端生成 thread 根 → 首条消息建立线程 → 扫描还原)
+- 三种状态:临时 / 正式 / 已删除,及各自的持久化方式
+- 进入容器的 6 步加载顺序与必须遵守的规则
+- 命名规则与触发条件
+- 通讯唯一入口(禁止直连 Matrix)
+- 6 条实测踩坑记录 + 自测清单
+
+**验证**:tsc 0 错误;单测 1096/1096;构建通过;verify:ui 12/12;verify:docs 78 文件。
+
+## 2026-11-01 取消临时会话 + 新建流程收口
+
+### 用户裁决
+1. **取消临时会话**:新建对话必须先选容器,临时会话与之冲突,且会在列表留下多个「新对话」
+2. 无任何会话时:提示用户先新建对话,不自动创建
+
+### 改动
+- 删除 `ChatSession.draft` 字段与 `createDraft / commitDraft / pruneDraft / pruneDraftExcept`
+- `ChatService.open()` 去掉 `pruneDraftExcept`,`sendReal()` 去掉 `commitDraft` 分支
+- `ChatPage.newSession()` 恢复为直接 `create()` 正式会话(保留「已有空会话则复用」规则)
+- 主区 `empty` 态新增分支:无当前会话时显示「还没有对话」+「新建对话」按钮(`requestNewSession()` → 容器选择框)
+
+### 关于「3 个一样的会话」
+排查结论:**不是代码 bug**。3 条都是开发过程中我测试时创建的真实会话
+(09:38 / 10:11 / 10:15),其中两条因为标题命名 bug 显示为「新对话」。
+云端表实际只有 1 条,其余只存在于本地缓存,刷新后按云端为准。
+
+**验证**:tsc 0 错误;单测 1096/1096;构建通过;verify:ui 12/12;verify:docs 78 文件。
+
+## 2026-11-01 归档 / 删除语义 + 缓存与云端同步
+
+### 用户裁决
+- **归档**:进「已归档」分组,可取消
+- **删除**:不可恢复 —— 云端索引直接删除、查询不再返回;Matrix 消息保留
+
+### 云端(fmodeagent-chatthreads v0.1.2)
+- `opRemove` 重写:保留 1 条墓碑(`isDeleted: true`)用于阻止扫描重建,其余记录 `destroy()` 物理删除
+- 🔴 根因修复:平台注入的 `userPtr` 直接 `equalTo('user', userPtr)` **匹配不到记录**,
+  必须构造 `{__type:'Pointer', className:'_User', objectId: userPtr.id}`
+
+### 客户端
+- `removeChatThread` 签名改为传 `{ threadRoot, roomId, agentId }`(云端据此写墓碑)
+- **缓存同步重写**:`hydrateFromCloud` 改为「云端为该容器权威来源」
+  - 云端拉取成功(含空)→ 本地同容器会话以云端为准,云端没有的移除
+  - 云端请求失败 → 保留本地(离线可用)
+  - 🔴 踩坑:第一版误删了 `next.push(...)`,导致匹配上的会话也被清空(测试捕获,已修)
+
+### 验证
+- 云函数端到端:归档 → `archived: true`;取消 → `false`;删除 → 活跃列表消失、墓碑保留、物理表仅剩 1 条 `isDeleted: true`
+- tsc 0 错误;单测 1096/1096;构建通过;verify:ui 12/12;verify:docs 78 文件
+
+## 2026-11-01 第四轮:会话存储逻辑梳理 + 云端索引去重 + 取消空会话
+
+### 用户提出的问题
+退出重登后侧栏仍有 20+ 条会话。要求梳理「会话到底存在哪、数据库存的是什么」,
+并判断哪种存储方案更合理。
+
+### 梳理结论(写入 `docs/03-interface/会话存储与生命周期-20261101.md`)
+- **正文全部在 Matrix 房间**,平台库 `FmodeChatThread` 只存目录(线程根 → 标题/归档/条数/时间),
+  额外只有一个 `lastPreview`(120 字摘要,列表用)。
+- 结论:维持「目录在平台库 + 正文在 Matrix」,不把正文搬进平台库(等于重造 Matrix)。
+
+### 🔴 找到重复记录的真根因(此前判断有误,已订正)
+- 实测探针:同一 `threadRoot` 换 `roomId` 再 upsert → 云端变成 **2 条**。
+- 根因:云函数查重键是 `(user, roomId, threadRoot)`,而线程根是客户端生成的**全局唯一** ID。
+  房间变化(绑定重建 / `ensureRoom` 命中 `m.direct` 旧房间 / 换设备)时同一会话被当成新记录插入,
+  旧房间那条变成孤儿 → 8 个真实会话在云端是 16 行,且删除只删当前那条,孤儿永远残留。
+- **先前归因「`_getUser` 返回残缺 request.user 导致查重落空」不成立**:探针证明被移除兜底后
+  同批重复/并发重复/连推三轮都稳定为 1 条。`_getUser` 的加固保留(属正确性改进,非本次根因)。
+
+### 云函数 v0.1.3(源码已改,**待热更**)
+- `opUpsert` 查重改为按 `(user, threadRoot)` 全局查,忽略 roomId
+- 命中多条 → 保留最新,其余物理删除,返回 `collapsed` 计数
+- `upsert` 一律解除墓碑(不再依赖客户端传 `revive`)
+- `_getUser` 只信任 token 解析出的完整 `_User`
+
+### 客户端重写:单向同步
+- `hydrateFromCloud`:云端成功返回 → 本地该容器 = 云端集合;**删除所有「本地有云端没有就保留」分支**
+- 新增 `ready()` / `error()`:云端不可达 → 列表不可信,界面显示「加载失败 + 重试」,不展示缓存
+- 本地缓存键改为 `fmode_chat_sessions_v2_<uid>`(旧缓存一次性失效)
+- `reset()` 复位 `ready` / 黑名单 / 墓碑集合
+- 删除 `pruneEmpty` / `discard` / `ensureFor` / `mergeScanned`
+
+### 取消「空会话」:首次发消息才建会话
+- 「新建对话」跳 `/chat/:agentId/new`(待建态,**不落任何数据**),
+  工作区归属经 `?ws=` 传递,在会话真正建出来时落地
+- `ChatService.startBlank()` + `ensureSessionForSend()` 承接
+- **彻底关闭 Matrix 扫描重建**:删除 `waitFirstSync` / `backfillSessionIndex` / 扫描调用
+
+### 数据处置
+- 按用户裁决清空该账号全部会话索引:16 条活跃 → 0 条,8 个真实线程墓碑保留
+
+### 验证
+- 云函数探针:同批重复 / 并发重复 / 连推三轮 / 同批两条相同 → 均为 1 条;换房间 → **2 条(缺陷复现)**
+- tsc(app + spec)0 错误;单测 1090/1090;构建通过
+- **未验证项**:云函数 v0.1.3 未热更,线上仍是旧版;界面「加载失败」态未做真机截图验证
+
+### 云函数热更(v0.1.3)—— 2026-11-01
+- **线上原版本**:`oR5O67i1Cm` v0.1.0(更新于 2026-11-01T03:47:54Z),确认含旧查重键 `(user, roomId, threadRoot)`
+- **备份**:`cloud/backup/oR5O67i1Cm-fmodeagent-chatthreads-v0.1.0.js`(回滚用)
+- **热更**:PUT `code` + `version=0.1.3` → 200,2026-11-01T05:06:58Z 生效
+- **端到端验证 9/9 通过**,关键项:
+  - 同线程根换 roomId 再写入 → 云端 **1 条**(旧版为 2 条,即缺陷本身)
+  - 同批重复 / 并发写入 → 均 1 条
+  - 删除后再 upsert → 墓碑解除、正常出现
+- **纠正上一轮的错误归因**:`_getUser` 不是根因,真根因是查重键含 roomId。已同步修正源码注释与文档。
+- 该账号当前:**0 条活跃会话**,21 条墓碑(8 个真实线程 + 历史 + 探针,已无残留验证数据)
+
+## 2026-11-01 第五轮:重连失效 / 登出 401 / 删除入口 / 空态收敛
+
+### 1. 🔴 「重新连接」点击无反应(用户实测)
+- 根因:`ChatService.open()` 首行是幂等短路(同 agent + 同会话 + `state==='ready'` 直接 return),
+  断线时 `_state` 仍是 `'ready'` → 点多少次都进不去
+- 修复:`open()` 新增 `force` 参数绕过短路;`reconnect()` / `retryOpen()` 传 `force=true`,
+  并额外调 `MatrixSessionService.kick()` 重建 Matrix 连接
+- 顺带修:短路条件把 `empty` 也算作「已加载」,否则空房间每次进入都重跑 provision
+
+### 2. 🔴 退出重登后 401 + 状态异常
+- 根因①:登出时**没有停轮询**,`LiveService` 每 15s 仍用失效 token 打接口 → 持续 401
+- 根因②:重登后轮询不会自动重启(`Shell.live.start()` 只在构造时跑一次)
+- 根因③:服务都是单例、Shell 不重建 → 上账号的会话/消息/房间映射残留
+- 修复:
+  - `MatrixSessionService` 登出时 `live.stop()`,登录时 `live.start()`
+  - 新增 `ChatService.resetForUserSwitch()`(清会话指针 + 房间映射 + 会话目录)
+  - 检测到 userId 变化时自动调用
+
+### 3. AbortError 刷屏降噪
+- Matrix SDK 在 `stopClient()` 中止在途请求时会抛 `AbortError: signal is aborted without reason`,
+  被 Angular 当未捕获异常打印整屏堆栈
+- 新增 `core/app-error-handler.ts`:**只**静音「name=AbortError 且 message 含 aborted」,
+  其余错误照常打印,不放宽任何真实故障
+
+### 4. 左侧会话行缺少删除入口
+- 现象:只有归档图标,没有删除
+- 修复:会话行加删除图标 → 居中 `ui-modal` 二次确认 → `sessions.remove()`
+- 删除当前打开的会话时自动落到下一条,没有则回该容器空态
+
+### 5. 空态时隐藏输入区
+- 用户要求:无会话时右侧只有说明 + «新建对话» 按钮
+- 修复:`@if (chat.currentSession())` 包裹输入区,无选中会话时不渲染 composer
+
+### 验证
+- tsc(app + spec)0 错误;单测 **1068/1068**;构建通过;verify:ui 12/12
+- 新增回归测试:`open()` 不带 force 早退 / 带 force 重新编排
+- **未验证项**:以上四项均需在浏览器实测确认(尤其退出重登与重连按钮)
+
+## 2026-11-01 第六轮:渲染测试会话 / 文案去技术化 / 归档确认 / Token 查看 / 删群聊上下文开关
+
+### 1. 真实渲染测试会话(对 lj-test 容器)
+- threadRoot `$approot_mvxe6db0_7ly7xtbp:fmode.cn`,agentId `agent-node-lj-test`
+- 下发任务要求生成 5 种格式样张,容器已完成并发出附件:
+  md 1867B / html 4579B / csv 199B / json 1687B / txt 1407B(全部下载核对内容真实)
+- 已登记到云端会话目录(`created:1, collapsed:0`,去重逻辑正常)
+- 用途:验证 Markdown / HTML / CSV / JSON / 纯文本五种预览组件
+
+### 2. 界面文案去技术化
+- 「被授权者的 Matrix ID」→「被授权者的账号 ID」
+- 「连接通道:Matrix · xxx」→「已接入」
+- 工作台「通道类型 Matrix / 服务端 xxx」→「在线通道 / 接入状态」
+- 「消息经 Matrix 通道加密传输」→「消息安全传输」
+- 「需要容器已接入 Matrix」→「需先在平台完成接入」
+- 「已连接矩阵服务,但该容器的接入通道未上报在线状态」→「该容器还没有上报在线状态」
+
+### 3. 归档改为二次确认
+- 侧栏归档 → 居中 `ui-modal` 确认 → `sessions.archive()`
+- 取消归档不确认(属恢复操作,误点无损失)
+
+### 4. 设置页可查看登录 Token
+- 桌面 + 移动端:默认打码(前 4 + • + 后 4),点「显示」才明文,可复制
+- Token 等同密码,默认打码避免旁人一眼看到
+
+### 5. 「重新登录」原来确实无效 —— 已修正
+- 原实现只 `navigate('/login')`,**不清登录态**:旧会话还在,直接返回就回到原状
+- 改为:清登录态 → 登录页(带 redirect,登完回原页)
+
+### 6. 删除「群聊附带最近对话」
+- 桌面设置页移除该开关;群聊侧固定为**总是带上**(关掉会导致容器答非所问)
+- 删除 `readContextPref` / `setAttachContext` 及 localStorage 键 `fmode_group_attach_context`
+
+### 验证
+- tsc(app + spec)0 错误;单测 **1068/1068**;构建通过;verify:ui 12/12;verify:docs 79 文件
+- **未验证项**:五种预览组件的实际渲染效果需在浏览器打开该会话逐个点开确认
+
+## 2026-11-01 第七轮:txt 缩放修复 / 补 Word PDF 样张 / 删重新登录 / 个人中心布局
+
+### 1. 🔴 txt 预览「缩放只有位置在动」
+- 根因:`setZoom()` 对所有类型都做「按比例补偿滚动位置」,但文本型缩放只改 `font-size`,
+  内容**宽度不变**,补 scrollTop 等于把内容往上推 → 看到的是位移而不是字号变化
+- 叠加因素:`.fp-text` 自带 `overflow: auto`,字号变大后出现内部滚动条,再叠一层位移
+- 修复:
+  - 文本型(Markdown/纯文本/JSON/代码)**跳过滚动补偿**,只改字号
+  - `.fp-text` 的 `overflow: auto` → `visible`,滚动统一交给外层 `.fp-body`
+
+### 2. 补齐 Word / PDF 样张
+- 对同一会话追加请求,容器安装 python-docx + reportlab 后生成:
+  `test-render.docx` 38442B(magic `PK`)、`test-render.pdf` 5244B(magic `%PDF`)
+- 现共 **7 个附件**:md / html / csv / json / txt / docx / pdf
+
+### 3. 删除「重新登录」
+- 桌面 + 移动端设置页均移除;与「退出登录」功能重叠(同样清态回登录页)
+- 同时清理 `fixToken()` 与 `.token-actions` 样式
+
+### 4. 个人中心布局
+- **左栏粘住跟随**:`.settings-aside { position: sticky; top: var(--sp-4) }`
+- **缩放/放大不再上下排列**:断点从 `dk-below-4col`(1150px)改为 `below-mobile`(768px)。
+  原先页面缩放导致有效宽度掉到 1150 以下就堆叠,右侧设置区被挤到屏幕外(用户反馈)
+
+### 验证
+- tsc(app + spec)0 错误;单测 **1068/1068**;构建通过;verify:ui 12/12;verify:docs 79 文件
+- **踩坑**:CSS 模板字符串里写反引号包住的注释(`` ` + "`.fp-body`" + ``)会提前闭合字符串导致
+  `Undefined mixin` / 类型错误;注释中引用类名不要用反引号
+- **未验证项**:粘住跟随与断点的实际视觉效果需在浏览器确认
+
+## 2026-11-01 第八轮:移除版本号标识
+
+- 个人中心页脚「Fmode 2.0.0-dev」→ 只保留「Fmode」(桌面 + 移动端)
+- 清理随之失效的 `version` 字段、`.mono` 样式、未使用的 `environment` 导入
+- 顺带清理移动端已无引用的 `.demo-note` 样式
+
+> `environment.version` 本身保留(构建元数据),只是不再展示给用户。
+
+### 验证
+- tsc 0 错误;单测 **1068/1068**;构建通过;verify:ui 12/12
+
+## 2026-11-01 第九轮:成果卡片对齐文件栏 / 项目页双栏滚动 / 移除任务页返回
+
+### 1. 成果栏卡片与「文件」栏统一
+- 原:`div` + 右侧两个小圆钮(预览 + 下载),点行无反应
+- 现:与「本会话文件」同一套 `button.rail-item` —— **整行可点直接预览** + 右侧 `chevron-right` 进入箭头
+- 按用户要求**删除下载图标**(与文件栏保持一致),同时清理失效的 `.rail-actions` / `.dl-btn` 样式
+
+### 2. 项目页滚动
+- 原:`.list-pane` 用 `max-height: calc(100dvh - 260px)` 内滚,右侧不滚 → 观感像「左栏黏住」
+- 现:`.split` 给定高度 `calc(100dvh - var(--pane-offset))`,左右两栏各自 `height:100%` + 独立滚动
+- 工具栏不加 `sticky`:页面已用 `app-page [fixedHeader]`,标题在 fixed-zone,工具栏本就随列表区
+- 窄屏(<940)堆叠时取消高度约束,避免详情被截断
+
+### 3. 任务看板移除「返回」
+- 任务页是左栏直达的顶层导航页,不是详情页,「返回」暗示用户丢位置了
+- 移除 `showBack` 与 `goBack()`
+
+### 验证
+- tsc 0 错误;单测 **1068/1068**;构建通过;verify:ui 12/12
+- **踩坑(第二次)**:CSS 模板字符串注释里用反引号包类名会提前闭合字符串,
+  报错表现为 `Undefined mixin` / `Failed to resolve styles`,与真实原因无关。

+ 86 - 0
docs/02-design/design-system/预览面板重构-设计-20261030.md

@@ -0,0 +1,86 @@
+# 预览面板重构 · 设计说明(2026-10-30)
+
+> 依据:用户提供的 7 张参考图(腾讯元宝 / WorkBuddy 桌面端)+ 2026-10-30 会话裁决。
+> 规范:CLAUDE.md 黄金规则 4/8/10/11;rules/18-UI设计规范(令牌 / 防遮挡 / 可访问性)。
+> 范围:桌面端右侧文件预览 + 图片灯箱;移动端/成果/群聊保持遮罩形态,仅做类型分流。
+
+## 一、目标
+
+1. 右侧预览区重做视觉:头部(形态分段器 + 工具组)、内容区、底部状态栏,贴合参考图。
+2. **图片不进右侧**,点击后在深色灯箱中查看;其余类型(html/pdf/docx/xlsx/csv/md/文本/音视频/未知)都进右侧。
+3. 统一三态:加载中 / 错误 / 不支持预览。
+4. Ctrl+滚轮只缩放预览内容,不触发浏览器整页缩放(光标在面板内才拦截)。
+5. PDF 懒渲染,大文件打开更快。
+
+## 二、结构
+
+```
+app-file-preview(外壳)
+├─ presentation: 'rail' | 'full' | 'modal'
+│   rail  右栏内联(默认)
+│   full  居中大面板(96vw×92dvh,带遮罩),Esc/✕ 回 rail
+│   modal 全屏遮罩(成果/群聊/移动端,无"右侧"形态)
+├─ header  文件名 · [右侧|全屏] 分段器 · ⤓ ↻ ⧉ ·「…」· ✕
+├─ body    滚动容器,按 kind 分发渲染器(内容不随形态重建)
+└─ status  页码 / 工作表 · ⊟ 114% ⊞ · ⤢
+```
+
+- 形态切换只改 class + 信号,**不重建内容**(PDF 不重渲、缩放保留)。
+- Web 端无本地路径,参考图中的「打开文件夹」**不实现**(不做空按钮)。
+- 音视频不进入 rail 形态,由调用方分流到 modal。
+
+## 三、各类型
+
+| kind | 渲染 | 要点 |
+|---|---|---|
+| image | 不进预览;`app-image-lightbox` 灯箱 | 深色遮罩 + 文件名/大小 + 下载/新标签 + ✕ |
+| html | 沙箱 iframe(现有 blob 方案) | 缩放作用于 iframe 内部;缩放期间覆盖透明层接收滚轮 |
+| pdf | pdf.js 逐页 canvas | 懒渲染(IntersectionObserver + 灰骨架页)、页码随滚动、缩放由外壳统一驱动 |
+| docx/xlsx/csv | office-preview | 内部缩放上收到外壳(可选:保留内部,二选一后只留一处) |
+| md | md-render | 加载态统一 |
+| text | `<pre>` | 加载态统一;错误显示原因 + 重试 |
+| video/audio | 原生控件 | 仅 modal |
+| unknown/office 老格式 | 居中图标 + 「文件格式不支持预览」+ 文件名·大小 + 「可使用本地对应工具查看」+ 下载 | 参考图 2 |
+
+## 四、缩放与快捷键
+
+- Ctrl/⌘ + 滚轮:指针在面板内 → `preventDefault`,缩放 0.4×–3×,状态栏显示百分比;面板外 → 浏览器缩放照常。
+- Ctrl + `=` / `-` / `0`:仅面板内有焦点时拦截。
+- Esc:full→rail;modal→关闭。
+- 灯箱:Esc 关闭、点遮罩关闭、焦点陷阱。
+- 不修改全局 `--zoom`,不写 `document.body` 缩放。
+
+## 五、五维自审要点(rules/18 §16)
+
+- 无遮挡:全屏面板用 `--z-preview`;状态栏 fixed 于面板内,body 留底 padding。
+- 令牌:颜色/字号/时长全部走 `--*` 令牌,无魔法值。
+- 底座一致:图标用 FontAwesome;spinner 样式与底座一致(不引新依赖)。
+- 组件一致:灯箱只服务附件预览,不改 `ui-image`(避免影响其他调用点)。
+- 可访问性:按钮 aria-label、分段器 role="radiogroup"、对比度 AA。
+
+## 六、文件清单
+
+改:
+- `src/app/components/preview/file-preview/file-preview.ts`(外壳重做)
+- `src/app/components/preview/pdf-preview/pdf-preview.ts`(懒渲染/页码/外部缩放)
+- `src/app/components/preview/office-preview/office-preview.ts`(缩放上收,按第三节定论)
+- `src/app/desktop/pages/chat/chat.ts`(图片→灯箱、音视频→modal、全屏开关、右栏上限)
+
+新增:
+- `src/app/components/preview/image-lightbox/image-lightbox.ts`(+ spec)
+- `src/app/components/preview/file-preview/file-preview.spec.ts`
+
+不动:`ui/` 底座、`file-kind.ts`、`md-render`。
+
+## 七、验证
+
+- `npm test`(新增 spec:图片分流、缩放夹紧、Esc 形态回退、全屏不重建内容)
+- `npm run build`
+- 手动核对参考图 7 个场景。
+
+## 八、风险
+
+1. iframe 内滚轮不冒泡 → 缩放期间覆盖透明层。
+2. PDF 懒渲染与缩放并发 → 缩放时取消在途渲染,按新 fit 重算可见页。
+3. `window.open` 可能被拦截 → 失败时提示。
+4. 右栏 `:has(.rail-preview)` 放宽到 720px 需确认窄屏时聊天区最小宽度(右栏已有折叠逻辑)。

+ 140 - 0
docs/03-interface/会话创建与容器通讯规范-20261101.md

@@ -0,0 +1,140 @@
+# 会话创建与容器通讯规范(2026-11-01)
+
+> ⚠️ **本文关于「会话目录来源」与「空会话」的部分已于 2026-11-01 晚间被取代**,
+> 最新规则见 `会话存储与生命周期-20261101.md`。关键变化:
+> ① 会话目录**只**来自云端 `FmodeChatThread`,不再扫 Matrix 重建;
+> ② 云端不可达时**不显示**任何会话;
+> ③ 取消「空会话」状态,改为**首次发消息才建会话**;
+> ④ 新建对话跳到 `/chat/:agentId/new` 待建态,不落任何数据。
+>
+> 本文仍然有效的部分:thread 根的生成与不变性、`m.relates_to` 必带、命名规则、
+> 通讯入口表、常见坑表格。
+
+> 目的:后续任何“新建对话 / 与容器通讯”的实现都按本文操作,不再各自发明。
+> 涉及源码:`chat-session.ts`、`chat-session.service.ts`、`chat.service.ts`、`matrix-client.service.ts`
+
+## 一、会话身份的真相链
+
+```
+用户点「新建对话」
+  → 本地生成 thread 根 id($approot_<时间36进制>_<随机>:fmode.cn)
+  → **首次发送消息时**才真正建立 Matrix 线程(sendEvent 带 m.relates_to)
+  → 容器把回复挂到同一 thread 根下
+  → 扫描时按 m.relates_to.event_id 归组还原会话
+```
+
+**铁律**
+1. **thread 根必须由客户端生成且全程不变**。容器只认 thread 根,换根 = 新会话(容器失忆)。
+2. **每条发送消息都必须带 `m.relates_to: { rel_type: 'm.thread', event_id: <根> }`**。
+   不带 thread 的消息不会归入任何会话。
+3. **会话目录以云端 `FmodeChatThread` 为准**,本地 localStorage 只是渲染缓存。
+   目录**只由云端拉取决定**,不从 Matrix 时间线重建(2026-11-01 起)。
+
+## 二、会话的状态
+
+> **2026-11-01 用户裁决:取消「临时会话」与「空会话」**。
+> 会话**只有两种状态**:正式会话 / 已删除。
+> 新建对话不再预建会话,改为首次发消息才创建 —— 见
+> `会话存储与生命周期-20261101.md` §三。
+
+| 状态 | 何时产生 | 是否持久化 | 何时消失 |
+|---|---|---|---|
+| **正式会话** | 用户在待建态里发出**第一条消息**时创建 | ✅ 本地 + 云端 | 用户删除(云端物理删 + 墓碑) |
+| **已删除** | 用户点删除 | 云端墓碑 `isDeleted: true` | 永久(跨设备生效) |
+
+### 新建对话的唯一流程(2026-11-01 定稿)
+
+```
+① 用户点左栏「新建对话」或空态的「新建对话」
+② 弹出容器选择框(NewSessionModalService.open())
+③ 选中容器 → 跳转 /chat/:agentId/new(**不创建任何会话**)
+④ 进入空白待建态:state = empty,标题栏「新对话」,输入框可用
+⑤ 用户输入并发送第一条消息
+⑥ 此刻才创建正式会话(生成线程根 + 立即推云端)
+⑦ 标题按首条消息自动提炼
+```
+
+**没有第 ④ 步的「复用空会话」了** —— 空会话这个概念已取消,点几次「新建对话」
+都不会留下任何数据。
+
+### 没有任何会话时
+
+不创建任何会话,主区显示空态引导:
+> 「还没有对话」/「点击左侧『新建对话』,选择一个数字生命后开始」/ [新建对话] 按钮
+
+用户必须先选容器才能开始,符合「默认需要选择容器」的产品要求。
+
+## 二·五、归档与删除
+
+| 操作 | 云端 | 本地 | 是否可恢复 | Matrix 消息 |
+|---|---|---|---|---|
+| **归档** | `archived: true` | `archived: true` | ✅ 可取消归档 | 不动 |
+| **删除** | 物理删除记录 + 留 1 条墓碑(`isDeleted: true`) | 移出列表 + 记入 `_removed` 黑名单 | ❌ 列表不可恢复 | **不动**(正文仍在房间) |
+
+- 删除后 `list` 不再返回;墓碑只用于挡住「同一线程被重新写回来」,不展示。
+- 删除必须传 `roomId` / `agentId`,云端据此写墓碑。
+- 云函数里查询用户必须用 `{__type:'Pointer',className:'_User',objectId:user.id}` 构造,
+  直接传平台注入的 `userPtr` 会匹配不到(实测)。
+  **但更重要的是 `userPtr` 本身必须来自 token 解析**,见
+  `会话存储与生命周期-20261101.md` §六。
+
+## 三、进入容器时的加载顺序(`ChatService.openReal()`)
+
+```
+① provision 拿 Matrix 凭据(云函数 RP5Ht55NeX)
+② connectSession → ensureRoom
+③ hydrateFromCloud(agentId)
+     · 同时拉「活跃会话」与「已软删会话」
+     · 软删列表进入 cloudRemoved,阻止扫描重建
+④ 云端为空 → 等首次 /sync → 扫描 Matrix 线程 → 翻页直到连续两页无新线程
+⑤ pushAll(agentId)  把本地会话回写云端
+⑥ 确定当前会话:有会话 → 最近一条;没有 → 空态(不自动建会话)
+```
+
+**必须遵守**
+- **云端为该容器权威来源**:拉取成功(含空结果)→ 本地同容器会话以云端为准,
+  云端没有的一律移除(可能是已删除 / 脏数据);匹配上的会话必须放进结果集;
+- 云端**请求失败** → 保留本地缓存,不触发任何重建(离线可用);
+- 扫描**只认 `$` 开头的真实线程根**,无 thread 的散消息不生成会话。
+
+## 四、会话命名
+
+- 规则:首条**用户消息**经 `deriveTaskTitle()` 提炼(剥请求前缀、句末语气词)后截断 24 字。
+- 触发条件:`!titleLocked && messageCount === 0 && fromMe`(**须在计数累加前判断**)。
+- 用户手动改名 → `titleLocked = true`,此后不再自动覆盖,并同步云端。
+- 容器不提供标题,因此不使用容器侧文案。
+
+## 五、通讯入口(唯一)
+
+| 用途 | 入口 |
+|---|---|
+| 建连 | `MatrixClientService.connectSession(accessToken, userId, deviceId)` |
+| 建房间 | `ensureRoom(agentMxId, { roomId })` |
+| 发消息 | `sendText(roomId, text, threadRoot, lastEventId)` |
+| 收消息 | `onMessage(handler)` / storeVersion 驱动的重读 |
+| 媒体地址 | `mxcUrlToHttp(mxc)` + `withAccessToken(url)` |
+
+**禁止**:页面/组件里直接 `fetch` Matrix 接口、自己拼 thread 根、绕开 `ChatService` 发消息。
+
+## 六、常见坑(实测记录)
+
+| 现象 | 根因 | 处理 |
+|---|---|---|
+| 会话标题永远是「新对话」 | 命名判定在计数累加之后 | 计数前读取 `isFirstMessage` |
+| 左侧凭空多出「新对话」 | 扫描把无 thread 消息建成会话 | 只认真实 `$` 线程根 |
+| 删掉的会话刷新又回来 | 删除只记在本地黑名单 | 云端软删记录作为权威黑名单 |
+| 换浏览器会话全丢 | 索引只在 localStorage | 云端 `FmodeChatThread` 为准 |
+| HTML/文本预览空白 | 抓取媒体未带 token → 401 | 统一 `withAccessToken()` |
+| 「新标签打开」变成下载 | 媒体接口强制 attachment | fetch 成 blob 后打开 |
+
+## 七、自测清单
+
+新建对话功能改动后,至少验证:
+
+- [ ] 无会话时主区显示「还没有对话」空态,不自动建会话
+- [ ] 发一条消息 → 会话标题变成消息内容提炼的短句
+- [ ] 点「新建对话」→ 先弹容器选择框,选中后才创建会话
+- [ ] 同一容器已有空会话时,再次新建 → 复用而不是叠加
+- [ ] 刷新页面 → 会话仍在,标题已按首条消息命名
+- [ ] 换浏览器登录 → 会话列表与云端一致
+- [ ] 删除会话后刷新 → 不再出现

+ 126 - 0
docs/03-interface/会话存储与生命周期-20261101.md

@@ -0,0 +1,126 @@
+# 会话存储与生命周期(2026-11-01 定稿)
+
+> 本文取代 `会话创建与容器通讯规范-20261101.md` 中关于「会话目录来源」的部分。
+> 通讯方式(thread 根、`m.relates_to`、发送入口)仍以该文为准。
+
+## 一、三处存储,各管一件事
+
+| 层 | 存什么 | 存在哪 | 大小 | 谁写 |
+|---|---|---|---|---|
+| **消息正文** | 每条消息、附件、容器回复 | Matrix 房间 `matrix.fmode.cn` | 全部内容 | 收发消息时由 SDK 写入 |
+| **会话目录** | 线程根 → 标题 / 归档 / 条数 / 时间 / 最后一句预览 | 平台库 `FmodeChatThread`(云函数 `oR5O67i1Cm`) | 每会话一行,<1KB | 客户端通过云函数写入 |
+| **本地缓存** | 目录副本 + 删除黑名单 | `localStorage` `fmode_chat_sessions_v2_<userId>` | 同目录 | 目录变化时写入 |
+
+**正文一律不进平台库**。平台库额外只存 `lastPreview`(列表上一行摘要,可丢),
+不存任何完整消息。
+
+### 为什么不把正文也放进平台库
+
+Matrix 已经提供了线程隔离、实时推送、附件存储、历史翻页。把正文复制进平台库
+等于重造一遍,代价是整条收发链路要重写,收益只是「列表查询快一点」。
+
+### 为什么不把目录放进 Matrix
+
+Matrix 有线程(thread)但没有「会话名 / 归档 / 删除」这些槽位。容器侧也不上报标题。
+所以目录必须有地方存 —— 平台库是自然的选择。
+
+## 二、同步规则:**单向,云端 → 本地**
+
+```
+            ┌─────────────────────┐
+            │  云端 FmodeChatThread │  ← 唯一真相源
+            └──────────┬──────────┘
+        hydrate        │        写入(新建/改名/归档/删除/计数)
+       (只读)        ▼         (单条,成功即返回)
+            ┌─────────────────────┐
+            │  本地 signal + localStorage │  仅用于本页渲染
+            └─────────────────────┘
+```
+
+铁律:
+
+1. **列表只由 `hydrateFromCloud()` 决定**。它成功返回后,本地该容器的条目 =
+   云端返回的集合,云端没有的一律不显示。
+2. **云端不可达 → 列表不可信**(`ready=false`)。界面显示「加载失败 + 重试」,
+   **不展示旧缓存**,也**不把本地数据反推回云端**。
+3. **不存在「本地有、云端没有 → 保留本地」的分支**。历史上所有「删了又回来 /
+   越用越多」都出在这类合并规则上。
+
+## 三、会话的生命周期
+
+```
+① 点「新建对话」→ 选容器
+      ↓  只是跳到 /chat/:agentId/new,**不写任何数据**
+② 空白待建态(state = empty,标题栏「新对话」)
+      ↓  用户输入第一条消息并发送
+③ 此刻才创建:生成线程根 $approot_<ts>_<rand>:fmode.cn
+      ↓  立即 upsert 到云端 + 标题按首条消息推导
+④ 正式会话(后续消息沿同一线程根收发)
+      ↓
+⑤ 归档(archived=true,收进「已归档」)/ 删除(云端物理删 + 留墓碑)
+```
+
+**没有「空会话」这个状态。** 用户点了新建又切走,什么都不会留下。
+
+`state = empty` 是**界面状态**(当前没有选中会话),不是数据库状态。
+
+## 四、进入容器时发生什么(`ChatService.openReal`)
+
+```
+① provision 拿 Matrix 凭据
+② connectSession + ensureRoom
+③ hydrateFromCloud(agentId)        ← 唯一的目录来源
+   ├─ 成功 → ready=true,列表 = 云端
+   └─ 失败 → ready=false,主区显示「会话目录加载失败」
+④ 选中:URL 指定 > 最近一条 > 空态(不自动建会话)
+⑤ 读 timeline,按线程根过滤出当前会话的消息
+```
+
+**这里没有 Matrix 扫描**。2026-11-01 用户裁决:彻底关闭「从房间时间线重建会话目录」。
+到达 Matrix 但没有云端索引的线程不会自动变成会话。
+
+## 五、删除 / 归档
+
+| 操作 | 云端 | 本地 | 可恢复 | Matrix 消息 |
+|---|---|---|---|---|
+| **归档** | `archived=true` | 同 | ✅ 取消归档 | 不动 |
+| **删除** | 物理删记录 + 留 1 条墓碑(`isDeleted=true`) | 移出列表 + 记入 `_removed` | ❌ | **不动** |
+
+墓碑的唯一用途:云端某条线程如果又被写回来,墓碑能挡住它重新出现在列表里。
+
+## 六、已知故障与修复记录
+
+### 2026-11-01:同一会话在云端出现两条
+
+**现象**:截图里 8 个真实会话在云端是 16 行,每组两条,退出重登不消失。
+
+**根因**:云函数 `opUpsert` 的查重键是 `(user, roomId, threadRoot)`,
+而线程根是客户端生成的**全局唯一** ID。房间发生变化(绑定重建、换设备 claim、
+`ensureRoom` 命中 `m.direct` 里的旧房间)时,同一个线程根在新房间下被当成新记录插入,
+旧房间那条就变成了孤儿。
+
+**修复**(`cloud/functions/fmodeagent-chatthreads.js`):
+
+- 查重改为**按 `(user, threadRoot)` 全局查**,忽略 roomId;
+- 命中多条时保留最新一条,其余物理删除(`collapsed` 计数返回给客户端便于排查);
+- `upsert` 一律解除墓碑(不再依赖客户端传 `revive`);
+- `_getUser` 只信任 token 解析出的 `_User`,残缺的 `request.user` 不再被直接返回
+  (属正确性加固,**与本次重复无关**)。
+
+**数据处置**:2026-11-01 按用户裁决清空该账号全部会话索引(16 条 → 0 条活跃,
+8 个真实线程的墓碑保留)。会话列表从零开始。
+
+**上线与验证**:v0.1.3 已于 2026-11-01 热更至 Function `oR5O67i1Cm`,
+端到端验证 9/9 通过,其中最关键的一条是「同一线程根换 roomId 再写入 → 云端仍为 1 条」
+(旧版此处为 2 条,即缺陷本身)。回滚用
+`cloud/backup/oR5O67i1Cm-fmodeagent-chatthreads-v0.1.0.js`。
+
+## 七、自测清单
+
+- [ ] 点「新建对话」→ 选容器 → **不输入任何内容**就切走 → 列表里没有新增条目
+- [ ] 输入第一条消息 → 出现新会话,标题是消息提炼语
+- [ ] 同一条消息发两次再刷新 → 云端仍是 1 条(打开 Network 看 upsert 返回 `collapsed: 0`)
+- [ ] 在 A 设备改名 → B 设备刷新后看到新名字
+- [ ] 删除会话 → 刷新 → 不再出现;换浏览器登录也不再出现
+- [ ] 断网进入聊天页 → 显示「会话目录加载失败 + 重试」,**不是**「还没有对话」
+- [ ] 恢复网络点重试 → 列表正常出现

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

@@ -1,5 +1,6 @@
 import {
   ApplicationConfig,
+  ErrorHandler,
   provideBrowserGlobalErrorListeners,
   provideZoneChangeDetection,
 } from '@angular/core';
@@ -8,11 +9,17 @@ import { provideRouter, withInMemoryScrolling } from '@angular/router';
 import { provideMarkdown } from 'ngx-markdown';
 
 import { provideFmodeApi } from './core/api/api.provider';
+import { AppErrorHandler } from './core/app-error-handler';
 import { routes } from './app.routes';
 
 export const appConfig: ApplicationConfig = {
   providers: [
     provideBrowserGlobalErrorListeners(),
+    /*
+     * 全局错误处理:只静音「预期内的请求取消」(Matrix SDK 的 AbortError),
+     * 其余错误照常打印。见 `core/app-error-handler.ts`。
+     */
+    { provide: ErrorHandler, useClass: AppErrorHandler },
     provideZoneChangeDetection({ eventCoalescing: true }),
     provideRouter(
       routes,

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

@@ -40,11 +40,12 @@ describe('MessageBubbleComponent(2026-10-29 精细化)', () => {
     expect(fixture.nativeElement.querySelector('ui-avatar')).toBeNull();
   });
 
-  it('文本消息有复制按钮;己方消息不出现「重新生成」', () => {
+  it('文本消息只有复制按钮(「重新生成」已移除,2026-10-31)', () => {
     render(msg());
     const acts = fixture.nativeElement.querySelector('.acts');
     expect(acts).not.toBeNull();
-    expect(acts.querySelectorAll('.act').length).toBe(2);
+    expect(acts.querySelectorAll('.act').length).toBe(1);
+    expect(acts.querySelector('[title="重新生成"]')).toBeNull();
 
     render(msg(), true);
     expect(fixture.nativeElement.querySelectorAll('.acts .act').length).toBe(1);
@@ -84,12 +85,4 @@ describe('MessageBubbleComponent(2026-10-29 精细化)', () => {
     expect(btn.querySelector('i')?.className).toContain('fa-check');
   });
 
-  it('点「重新生成」发出该消息 id', () => {
-    render(msg({ id: 'abc' }));
-    let got = '';
-    fixture.componentInstance.regenerate.subscribe((id) => (got = id));
-    const btns = fixture.nativeElement.querySelectorAll('.acts .act');
-    (btns[btns.length - 1] as HTMLElement).click();
-    expect(got).toBe('abc');
-  });
 });

+ 15 - 27
src/app/components/domain/message-bubble/message-bubble.ts

@@ -149,8 +149,7 @@ import { ToolActivityComponent } from '../tool-activity/tool-activity';
           }
 
           <!--
-            操作条(2026-10-29 精细化):默认只显示时间;悬停/键盘聚焦时
-            时间保留、操作按钮淡入 —— 不占用常驻视觉空间,又随时可用。
+            操作条(2026-10-31):时间与复制按钮**常驻显示**,悬停时提亮。
           -->
           <span class="bubble-meta">
             <time [attr.datetime]="message().ts">{{ rel(message().ts) }}</time>
@@ -176,17 +175,6 @@ import { ToolActivityComponent } from '../tool-activity/tool-activity';
                 >
                   <i class="fa-solid" [class]="copied() ? 'fa-check' : 'fa-copy'" aria-hidden="true"></i>
                 </button>
-                @if (!own()) {
-                  <button
-                    type="button"
-                    class="act"
-                    aria-label="基于这条重新生成消息"
-                    title="重新生成"
-                    (click)="regenerate.emit(message().id)"
-                  >
-                    <i class="fa-solid fa-rotate-right" aria-hidden="true"></i>
-                  </button>
-                }
               </span>
             }
           </span>
@@ -230,17 +218,17 @@ import { ToolActivityComponent } from '../tool-activity/tool-activity';
         justify-content: flex-end;
       }
       /*
-       * 操作条默认收起、悬停/聚焦时淡入。
-       * 🔴 顺序重要:基础态必须写在 :hover / :focus-within **之前** ——
-       *    同优先级下后写的规则胜出,反过来写的话 hover 永远不生效(实测踩过)。
+       * 操作条常驻(2026-10-31 用户要求):时间与复制按钮**始终可见**。
+       * 原先按钮默认 opacity:0、悬停才淡入 —— 用户不知道可以复制。
+       * 常驻后保持低对比(默认 .55),悬停/聚焦时恢复全亮,避免抢正文视觉。
        */
       .acts {
         display: inline-flex;
         align-items: center;
         gap: 2px;
         margin-left: var(--sp-1);
-        opacity: 0;
-        pointer-events: none;
+        opacity: 0.55;
+        pointer-events: auto;
         transition: opacity var(--motion-fast) var(--ease-standard);
       }
       .bubble-row:hover .acts,
@@ -384,15 +372,17 @@ import { ToolActivityComponent } from '../tool-activity/tool-activity';
         min-height: 0;
         font-size: var(--fs-11);
         color: var(--muted);
-        opacity: 0;
-        transition: opacity var(--motion-fast) var(--ease-standard);
-      }
-      /* 发送中 / 失败 必须常驻可见 —— 那是需要用户注意的状态,不能靠悬停 */
-      .bubble-meta:has(.state),
-      .bubble-row:hover .bubble-meta,
-      .bubble-row:focus-within .bubble-meta {
+        /*
+         * 🔴 **必须常驻**(2026-11-01 用户要求)。
+         *    原来这里是 opacity: 0 + 悬停才显示 —— 时间与操作条都在这个容器里,
+         *    结果两者默认都看不见(实测:timeDisplay=block 但 metaOpacity=0)。
+         */
         opacity: 1;
       }
+      /* 发送中 / 失败 需要更醒目:提高对比度而不是靠悬停 */
+      .bubble-meta:has(.state) {
+        color: var(--ink-2);
+      }
       .bubble-row.own .bubble-meta {
         justify-content: flex-end;
       }
@@ -624,8 +614,6 @@ export class MessageBubbleComponent {
   );
 
   readonly retry = output<string>();
-  /** 重新生成:请求以这条为上下文让容器重答(由页面决定怎么发) */
-  readonly regenerate = output<string>();
 
   /** 复制成功反馈(1.4s 后自动复位) */
   protected readonly copied = signal(false);

+ 9 - 29
src/app/components/domain/new-session-modal/new-session-modal.ts

@@ -1,7 +1,6 @@
 import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core';
 import { Router } from '@angular/router';
 import { UiAvatar, UiButton, UiModal } from '../../../ui';
-import { ChatSessionService } from '../../../core/chat-session.service';
 import { DeviceService } from '../../../core/device.service';
 import { LiveService } from '../../../core/live.service';
 import { NewSessionModalService } from './new-session-modal.service';
@@ -42,7 +41,7 @@ import { WorkspaceService } from '../../../core/workspace.service';
             <i class="fa-solid fa-chevron-right pick-arrow" aria-hidden="true"></i>
           </button>
         } @empty {
-          <p class="pick-empty">没有可用的容器(需要容器已接入 Matrix)。</p>
+          <p class="pick-empty">没有可用的容器(需先在平台完成接入)。</p>
         }
       </div>
 
@@ -122,7 +121,6 @@ import { WorkspaceService } from '../../../core/workspace.service';
 export class NewSessionModalComponent {
   protected readonly service = inject(NewSessionModalService);
   private readonly live = inject(LiveService);
-  private readonly sessions = inject(ChatSessionService);
   private readonly router = inject(Router);
   private readonly device = inject(DeviceService);
   private readonly workspaces = inject(WorkspaceService);
@@ -144,36 +142,18 @@ export class NewSessionModalComponent {
 
   protected createIn(agentId: string): void {
     const target = this.service.workspaceId();
-
+    this.close();
     /*
-     * 已有空对话时复用,不再新建 —— 用户反复点会攒出多条同名「新对话」(实测踩坑)。
+     * 🔴 **不在这里创建会话**(2026-11-01 用户裁决:首次发消息才建会话)。
      *
-     * 🔴 但**只在同一个归属里复用**:在工作区 A 里点「+」时,
-     *    日常对话里那条空对话不能被抢过来(会被用户理解成「建到了日常」)。
-     */
-    const empty = this.sessions
-      .listFor(agentId)
-      .find(
-        (s) =>
-          !s.legacy &&
-          (s.messageCount ?? 0) === 0 &&
-          (s.workspaceId ?? null) === target,
-      );
-    const session =
-      empty ?? this.sessions.create(this.sessions.latestFor(agentId)?.roomId ?? '', agentId);
-
-    /*
-     * 落到打开模态框时指定的工作区(默认「日常对话」)。
+     * 旧实现会 `sessions.create()` 一条空会话再跳转 —— 用户选完容器却没发消息,
+     * 就留下一条 0 条消息的「新对话」,反复几次列表就堆满空壳。
+     * 现在只是跳到「该容器、待建」的地址,输入第一条消息时才真正建会话。
      *
-     * 🔴 这里原本写死成 `moveTo(session.id, null)` —— 结果从工作区里新建的对话
-     *    全被移到日常对话去了(用户实测报告)。
+     * 工作区归属随查询参数带过去,由聊天页在会话建出来之后落地。
      */
-    if ((session.workspaceId ?? null) !== target) this.sessions.moveTo(session.id, target);
-
-    this.close();
-    void this.router.navigateByUrl(
-      `/${this.device.mode()}/chat/${agentId}/${encodeURIComponent(session.id)}`,
-    );
+    const qs = target ? `?ws=${encodeURIComponent(target)}` : '';
+    void this.router.navigateByUrl(`/${this.device.mode()}/chat/${agentId}/new${qs}`);
   }
 
   protected close(): void {

+ 247 - 27
src/app/components/domain/session-tree/session-tree.ts

@@ -2,7 +2,7 @@ import { NgTemplateOutlet } from '@angular/common';
 import { ChangeDetectionStrategy, Component, computed, inject, input, output, signal } from '@angular/core';
 import { NavigationEnd, Router, RouterLink } from '@angular/router';
 import { effect } from '@angular/core';
-import { UiAvatar, UiButton, UiEmpty, UiInput, UiSkeleton, UiSwitch, UiTooltip } from '../../../ui';
+import { UiAvatar, UiButton, UiEmpty, UiInput, UiModal, UiSkeleton, UiSwitch, UiTooltip } from '../../../ui';
 import { navItemActive } from '../../../desktop/shell/dk-top-nav/dk-top-nav';
 import { ChatSession } from '../../../core/chat-session';
 import { ChatSessionService } from '../../../core/chat-session.service';
@@ -54,6 +54,7 @@ export interface NewSessionRequest {
     UiButton,
     UiEmpty,
     UiInput,
+    UiModal,
     UiSkeleton,
     UiSwitch,
     UiTooltip,
@@ -330,6 +331,20 @@ export interface NewSessionRequest {
                     <ui-skeleton variant="rect" height="32px" />
                   }
                 </div>
+              } @else if (!sessionsReady()) {
+                <!--
+                  会话目录拉取失败(2026-11-01 用户裁决):**不显示任何会话**。
+                  这里必须与「还没有对话」区分开 —— 空态意味着「你确实没有」,
+                  而实际是「我们不知道你有没有」。混为一谈会让用户以为对话被删了,
+                  从而重复新建,进一步制造脏数据。
+                -->
+                <div class="sect-error" role="alert">
+                  <p>会话目录加载失败,暂时无法显示对话。</p>
+                  <p class="sect-error-detail">{{ sessionsError() }}</p>
+                  <button type="button" class="retry" (click)="retrySessions()">
+                    <i class="fa-solid fa-rotate-right" aria-hidden="true"></i> 重试
+                  </button>
+                </div>
               } @else if (daily().length === 0) {
                 <p class="sect-hint">日常新建的对话会出现在这里。</p>
               }
@@ -474,6 +489,43 @@ export interface NewSessionRequest {
       }
     </nav>
 
+    <!-- 归档确认(2026-11-01 用户要求:归档也要二次提示) -->
+    <ui-modal
+      [open]="archiving() !== null"
+      title="归档这条对话?"
+      size="sm"
+      (openChange)="$event || archiving.set(null)"
+    >
+      <p class="rm-note">
+        「{{ archiving()?.title }}」会移到「已归档」里,随时可以取消归档恢复。
+      </p>
+      <div class="rm-acts">
+        <ui-button variant="ghost" (click)="archiving.set(null)">取消</ui-button>
+        <ui-button variant="primary" (click)="doArchive()">归档</ui-button>
+      </div>
+    </ui-modal>
+
+    <!--
+      删除确认(2026-11-01 补上删除入口)。
+      删除类操作一律走居中 ui-modal 二次确认,不用 popconfirm(规范 18 §三-13)。
+      文案必须说清「消息不会删」——否则用户会以为内容丢了。
+    -->
+    <ui-modal
+      [open]="removing() !== null"
+      title="删除这条对话?"
+      size="sm"
+      (openChange)="$event || removing.set(null)"
+    >
+      <p class="rm-note">
+        将从列表删除「{{ removing()?.title }}」,删除后<strong>不可恢复</strong>。<br />
+        Matrix 服务器上的消息本身不会被删除。
+      </p>
+      <div class="rm-acts">
+        <ui-button variant="ghost" (click)="removing.set(null)">取消</ui-button>
+        <ui-button variant="danger" (click)="doRemove()">删除</ui-button>
+      </div>
+    </ui-modal>
+
     <!-- 会话行模板(工作区与日常共用;owner=null 表示日常) -->
     <ng-template #row let-s let-owner="owner">
       <div
@@ -495,7 +547,7 @@ export interface NewSessionRequest {
         <span class="sess-title-wrap">
           <span class="sess-title" appHoverScroll [title]="s.title">{{ s.title }}</span>
         </span>
-        <span class="sess-time">{{ sessionTime(s.updatedAt) }}</span>
+        <!-- 左侧列表不显示时间(2026-10-31 澄清:时间指聊天区消息):操作图标常驻 -->
         <span class="sess-acts">
           <!--
             移动到工作区(2026-10-30):会话一旦建在日常对话里,
@@ -523,7 +575,22 @@ export interface NewSessionRequest {
               (click)="toggleArchive(s, $event)"
             />
           </ui-tooltip>
-        </span>
+          <!--
+            删除(2026-11-01 用户反馈「左侧只有归档没有删除」补上)。
+            删除 = 移除会话索引,云端记录物理删除 + 留墓碑;Matrix 里的消息不动。
+            必须先弹确认 —— 删除不可恢复。
+          -->
+          <ui-tooltip content="删除">
+            <ui-button
+              variant="ghost"
+              shape="circle"
+              size="sm"
+              icon="fa-solid fa-trash-can"
+              [ariaLabel]="'删除 ' + s.title"
+              (click)="askRemove(s, $event)"
+            />
+          </ui-tooltip>
+          </span>
       </div>
     </ng-template>
   `,
@@ -741,16 +808,27 @@ export interface NewSessionRequest {
         display: flex;
         align-items: center;
         gap: var(--sp-3);
-        min-height: 32px;
-        padding: 0 var(--sp-2);
-        border-radius: var(--r-sm);
+        /* 与一级导航 .route 对齐:38px 行高 + 显式内边距,悬停高亮块与文字行一样宽高 */
+        width: 100%;
+        min-height: 38px;
+        padding: 0 var(--sp-3);
+        border: 0;
+        border-radius: var(--r-md);
+        background: transparent;
         color: var(--ink);
-        font-size: var(--fs-13);
+        font: inherit;
+        font-size: var(--fs-14);
+        text-align: left;
         text-decoration: none;
+        cursor: pointer;
       }
       .more-item:hover {
         background: var(--surface-hover);
       }
+      .more-item:focus-visible {
+        outline: 2px solid var(--primary);
+        outline-offset: -2px;
+      }
       .more-item.active {
         background: var(--surface-active);
         font-weight: 600;
@@ -969,6 +1047,61 @@ export interface NewSessionRequest {
       .sect-hint.inset {
         padding-left: var(--sp-6);
       }
+      /*
+        会话目录加载失败(2026-11-01):这是**错误态**,不是空态。
+        用 danger 语义色边框区分,并提供重试入口 —— 不允许静默展示旧缓存。
+      */
+      .sect-error {
+        margin: var(--sp-1) 0 var(--sp-2);
+        padding: var(--sp-3);
+        border: 1px solid var(--danger);
+        border-radius: var(--r-md);
+        background: var(--danger-subtle);
+        font-size: var(--fs-12);
+        line-height: 1.6;
+        color: var(--ink);
+      }
+      .sect-error p {
+        margin: 0;
+      }
+      .sect-error-detail {
+        margin-top: var(--sp-1) !important;
+        color: var(--muted);
+        word-break: break-all;
+      }
+      .sect-error .retry {
+        display: inline-flex;
+        align-items: center;
+        gap: var(--sp-1);
+        min-height: var(--touch-min);
+        margin-top: var(--sp-1);
+        padding: 0 var(--sp-2);
+        border: none;
+        border-radius: var(--r-sm);
+        background: transparent;
+        color: var(--primary);
+        font-size: var(--fs-12);
+        cursor: pointer;
+      }
+      .sect-error .retry:hover {
+        background: var(--surface-hover);
+      }
+      .sect-error .retry:focus-visible {
+        outline: 2px solid var(--primary);
+        outline-offset: 2px;
+      }
+      /* 删除确认框(当前元件在 host 外,样式要挂在模板根上) */
+      .rm-note {
+        margin: 0 0 var(--sp-4);
+        font-size: var(--fs-13);
+        line-height: 1.7;
+        color: var(--ink-2);
+      }
+      .rm-acts {
+        display: flex;
+        justify-content: flex-end;
+        gap: var(--sp-2);
+      }
       .skel {
         display: grid;
         gap: var(--sp-1);
@@ -1095,26 +1228,24 @@ export interface NewSessionRequest {
         flex: 1;
         min-width: 0;
       }
+      /*
+       * 会话行(2026-10-31):
+       * · 操作图标常驻,默认低透明度,悬停/聚焦/选中恢复全亮
+       * · 不显示时间 —— 用户澄清「时间」指聊天区每条消息,不是左侧列表
+       */
       .sess-time {
-        font-size: var(--fs-11);
-        color: var(--faint);
-        flex-shrink: 0;
-      }
-      /* 未悬停时时间不显示:一排右对齐的灰时间戳把标题挤得只剩半截 */
-      .sess:not(:hover):not(:focus-within) .sess-time {
         display: none;
       }
       .sess-acts {
-        display: none;
+        display: flex;
         flex-shrink: 0;
+        opacity: 0.55;
+        transition: opacity var(--motion-fast) var(--ease-standard);
       }
       .sess:hover .sess-acts,
-      .sess:focus-within .sess-acts {
-        display: flex;
-      }
-      .sess:hover .sess-time,
-      .sess:focus-within .sess-time {
-        display: none;
+      .sess:focus-within .sess-acts,
+      .sess.active .sess-acts {
+        opacity: 1;
       }
 
       .arch-toggle {
@@ -1559,6 +1690,26 @@ export class SessionTreeComponent {
     this.navMenu.load();
     // 会话字号在首帧前落位,避免进入会话页时字号"跳一下"
     this.chatFont.load();
+    /*
+     * 会话目录预热(2026-11-01)。
+     *
+     * 🔴 云端是唯一来源,左栏要列出**所有容器**的对话,就必须逐个容器去拉;
+     *    只在进入某容器时拉,用户没进过的容器会一直显示「没有对话」(看起来像丢了)。
+     *    拉取失败时 `ready=false`,左栏显示「加载失败 + 重试」,不展示任何缓存。
+     */
+    effect(() => {
+      // 只在登录后、且容器清单首次就绪时拉一轮(LiveService 每 15s 刷新会触发 new 数组)
+      if (!this.session.isLoggedIn()) return;
+      const ids = this.liveSvc
+        .agents()
+        .filter((a) => a.matrixReady)
+        .map((a) => a.id);
+      if (ids.length === 0) return;
+      const sig = ids.join(',');
+      if (sig === this.loadedAgentsSig) return;
+      this.loadedAgentsSig = sig;
+      void this.sessionsSvc.reloadAll(ids);
+    });
     // 数据/尺寸变化后重测溢出(工作区与会话列表都是信号,变化即重算)
     effect(() => {
       this.workspaces();
@@ -1575,6 +1726,9 @@ export class SessionTreeComponent {
     this._url.set(this.router.url);
   }
 
+  /** 已经预热过会话目录的容器清单签名(避免 LiveService 轮询触发重复拉取) */
+  private loadedAgentsSig = '';
+
   protected readonly meName = computed(() => this.session.me()?.name ?? '未登录');
   /** 账号辅助行:不是重复主题名,而是「你在这个平台上的身份」 */
   protected readonly userRole = computed(() => (this.session.me() ? '个人账号' : '未登录'));
@@ -1587,6 +1741,28 @@ export class SessionTreeComponent {
    */
   protected readonly daily = computed(() => this.filter(this.activeList(null)));
 
+  /** 会话目录是否可信(false = 云端拉取失败,列表不可展示) */
+  protected readonly sessionsReady = this.sessionsSvc.ready;
+  protected readonly sessionsError = this.sessionsSvc.error;
+
+  /**
+   * 重试拉取:按当前打开的容器再拉一次;没有打开任何容器时拉全部容器的列表。
+   * 失败仍维持「不可展示」状态,不做任何本地兜底。
+   */
+  protected retrySessions(): void {
+    const agentId = this.chat.agentId();
+    if (agentId) {
+      void this.chat.open(agentId, this.chat.sessionId() ?? undefined);
+      return;
+    }
+    void this.sessionsSvc.reloadAll(
+      this.liveSvc
+        .agents()
+        .filter((a) => a.matrixReady)
+        .map((a) => a.id),
+    );
+  }
+
   protected readonly dailyArchived = computed(() => this.filter(this.archivedList(null)));
 
   protected sessionsIn(wsId: string): ChatSession[] {
@@ -1609,8 +1785,36 @@ export class SessionTreeComponent {
     return sameDay ? `${hh}:${mm}` : `${d.getMonth() + 1}-${d.getDate()}`;
   }
 
-  // ---- 工作区 CRUD ----
+  // ---- 删除会话 ----
 
+  /** 待删除的会话(非 null = 确认框打开) */
+  protected readonly removing = signal<ChatSession | null>(null);
+
+  /** 点删除图标 → 先弹确认(删除不可恢复) */
+  protected askRemove(s: ChatSession, e: Event): void {
+    e.stopPropagation();
+    this.removing.set(s);
+  }
+
+  /** 确认删除:移除索引 + 云端物理删 + 留墓碑(Matrix 消息不动) */
+  protected doRemove(): void {
+    const s = this.removing();
+    this.removing.set(null);
+    if (!s) return;
+    this.sessionsSvc.remove(s.id);
+    /*
+     * 删的正好是当前打开的那条 → 打开该容器下一条,避免停在空白页。
+     * 一条都不剩时回到 `/chat/:agentId`(该容器空态)。
+     */
+    const wasOpen = this._url().includes(`/chat/${s.agentId}/${encodeURIComponent(s.id)}`);
+    if (wasOpen) {
+      const next = this.sessionsSvc.listFor(s.agentId)[0];
+      if (next) this.openSession(next);
+      else void this.router.navigateByUrl(`/${this.device.mode()}/chat/${s.agentId}`);
+    }
+  }
+
+  // ---- 工作区 CRUD ----
   /** 新建工作区 → 输入模态框 */
   protected beginNewWorkspace(): void {
     this.prompt.open({
@@ -1716,13 +1920,29 @@ export class SessionTreeComponent {
 
   protected toggleArchive(s: ChatSession, e: Event): void {
     e.stopPropagation();
-    const next = !s.archived;
-    this.sessionsSvc.archive(s.id, next);
-    /* 归档后自动展开该分组的「已归档」——让用户看到对话去了哪里 */
-    if (next) {
-      const owner = this.ownerOf(s);
-      this.archOpen.update((set) => new Set(set).add(owner ?? '__daily__'));
+    /*
+     * 归档也要二次确认(2026-11-01 用户要求)。
+     * 取消归档不需要确认 —— 它是**恢复**操作,误点不会造成损失。
+     */
+    if (s.archived) {
+      this.sessionsSvc.archive(s.id, false);
+      return;
     }
+    this.archiving.set(s);
+  }
+
+  /** 待确认归档的会话(非 null = 确认框打开) */
+  protected readonly archiving = signal<ChatSession | null>(null);
+
+  /** 确认归档 */
+  protected doArchive(): void {
+    const s = this.archiving();
+    this.archiving.set(null);
+    if (!s) return;
+    this.sessionsSvc.archive(s.id, true);
+    // 归档后自动展开该分组的「已归档」——让用户看到对话去了哪里
+    const owner = this.ownerOf(s);
+    this.archOpen.update((set) => new Set(set).add(owner ?? '__daily__'));
   }
 
   protected isArchOpen(owner: string): boolean {

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

@@ -0,0 +1,114 @@
+import { TestBed } from '@angular/core/testing';
+import { FilePreviewComponent } from './file-preview';
+
+/**
+ * 预览外壳单测 —— 形态切换 / 缩放 / Esc 语义。
+ *
+ * 依据《预览面板重构-设计-20261030》:
+ * - rail ↔ full 只改形态,不重建内容(这里用 zoom 是否保留间接验证)
+ * - Ctrl+滚轮只缩放预览内容,范围夹在 0.4–3
+ * - Esc:full → rail;rail / modal → 关闭
+ */
+describe('FilePreviewComponent', () => {
+  function make(over: Record<string, unknown> = {}) {
+    const fixture = TestBed.createComponent(FilePreviewComponent);
+    fixture.componentRef.setInput('open', true);
+    fixture.componentRef.setInput('url', 'https://x/a.pdf');
+    fixture.componentRef.setInput('name', 'a.pdf');
+    fixture.componentRef.setInput('inline', true);
+    for (const [k, v] of Object.entries(over)) fixture.componentRef.setInput(k, v);
+    fixture.detectChanges();
+    return fixture.componentInstance;
+  }
+
+  beforeEach(async () => {
+    await TestBed.configureTestingModule({ imports: [FilePreviewComponent] }).compileComponents();
+  });
+
+  it('inline=true 打开时为 rail 形态', () => {
+    const c = make();
+    expect(c.presentation()).toBe('rail');
+  });
+
+  it('mode 显式指定优先于 inline', () => {
+    const c = make({ mode: 'modal' });
+    expect(c.presentation()).toBe('modal');
+  });
+
+  it('rail → full → rail:形态切换保留缩放', () => {
+    const c = make();
+    c.setPresentation('full');
+    c.zoomBy(0.2);
+    expect(c.presentation()).toBe('full');
+    c.setPresentation('rail');
+    expect(c.presentation()).toBe('rail');
+    expect(c.zoom()).toBeCloseTo(1.2, 5);
+  });
+
+  it('不允许全屏时 setPresentation 无效', () => {
+    const c = make({ allowFullscreen: false });
+    c.setPresentation('full');
+    expect(c.presentation()).toBe('rail');
+  });
+
+  it('缩放夹在 0.4–3', () => {
+    const c = make();
+    for (let i = 0; i < 50; i++) c.zoomBy(0.1);
+    expect(c.zoom()).toBe(3);
+    for (let i = 0; i < 100; i++) c.zoomBy(-0.1);
+    expect(c.zoom()).toBe(0.4);
+  });
+
+  it('Ctrl+滚轮:面板内拦截并缩放;未按 Ctrl 不干预', () => {
+    const c = make();
+    const prevent = jasmine.createSpy('preventDefault');
+    const stop = jasmine.createSpy('stopImmediatePropagation');
+    const ev = { ctrlKey: true, metaKey: false, deltaY: -100, preventDefault: prevent, stopImmediatePropagation: stop } as unknown as WheelEvent;
+    (c as unknown as { onWheelZoom(e: WheelEvent): void }).onWheelZoom(ev);
+    expect(prevent).toHaveBeenCalled();
+    expect(stop).toHaveBeenCalled();
+    expect(c.zoom()).toBeCloseTo(1.1, 5);
+
+    const plain = { ctrlKey: false, metaKey: false, deltaY: -100, preventDefault: prevent, stopImmediatePropagation: stop } as unknown as WheelEvent;
+    (c as unknown as { onWheelZoom(e: WheelEvent): void }).onWheelZoom(plain);
+    expect(c.zoom()).toBeCloseTo(1.1, 5);
+  });
+
+  it('Ctrl+滚轮缩放夹紧在 0.4–3(统一上限)', () => {
+    const c = make();
+    for (let i = 0; i < 40; i++) {
+      (c as unknown as { onWheelZoom(e: WheelEvent): void }).onWheelZoom({
+        ctrlKey: true, deltaY: -100, preventDefault: () => {}, stopImmediatePropagation: () => {},
+      } as unknown as WheelEvent);
+    }
+    expect(c.zoom()).toBe(3);
+  });
+
+  it('Esc:full 回 rail,不触发关闭', () => {
+    const c = make();
+    const closed = jasmine.createSpy('closed');
+    c.closed.subscribe(closed);
+    c.setPresentation('full');
+    c.onEscape();
+    expect(c.presentation()).toBe('rail');
+    expect(closed).not.toHaveBeenCalled();
+    c.onEscape();
+    expect(closed).toHaveBeenCalled();
+  });
+
+  it('切换形态不重建 PDF 子组件实例', () => {
+    const fixture = TestBed.createComponent(FilePreviewComponent);
+    fixture.componentRef.setInput('open', true);
+    fixture.componentRef.setInput('url', 'https://x/a.pdf');
+    fixture.componentRef.setInput('name', 'a.pdf');
+    fixture.componentRef.setInput('inline', true);
+    fixture.detectChanges();
+    const host = fixture.nativeElement as HTMLElement;
+    const before = host.querySelector('app-pdf-preview');
+    (fixture.componentInstance as FilePreviewComponent).setPresentation('full');
+    fixture.detectChanges();
+    const after = host.querySelector('app-pdf-preview');
+    expect(before).not.toBeNull();
+    expect(after).toBe(before);
+  });
+});

File diff suppressed because it is too large
+ 622 - 365
src/app/components/preview/file-preview/file-preview.ts


+ 40 - 0
src/app/components/preview/image-lightbox/image-lightbox.spec.ts

@@ -0,0 +1,40 @@
+import { TestBed } from '@angular/core/testing';
+import { ImageLightboxComponent } from './image-lightbox';
+
+/** 图片灯箱:打开/关闭、Esc、文件名与大小展示 */
+describe('ImageLightboxComponent', () => {
+  beforeEach(async () => {
+    await TestBed.configureTestingModule({ imports: [ImageLightboxComponent] }).compileComponents();
+  });
+
+  function make(open = true) {
+    const fixture = TestBed.createComponent(ImageLightboxComponent);
+    fixture.componentRef.setInput('open', open);
+    fixture.componentRef.setInput('url', 'https://x/a.png');
+    fixture.componentRef.setInput('name', 'A_clean.png');
+    fixture.componentRef.setInput('size', 2048);
+    fixture.detectChanges();
+    return fixture;
+  }
+
+  it('open=false 不渲染', () => {
+    const f = make(false);
+    expect((f.nativeElement as HTMLElement).querySelector('.lb-mask')).toBeNull();
+  });
+
+  it('展示文件名与大小', () => {
+    const f = make();
+    const text = (f.nativeElement as HTMLElement).textContent ?? '';
+    expect(text).toContain('A_clean.png');
+    expect(text).toContain('2.0 KB');
+  });
+
+  it('关闭按钮发出 closed', () => {
+    const f = make();
+    const c = f.componentInstance;
+    const spy = jasmine.createSpy('closed');
+    c.closed.subscribe(spy);
+    (f.nativeElement as HTMLElement).querySelector<HTMLButtonElement>('[aria-label="关闭预览"]')!.click();
+    expect(spy).toHaveBeenCalled();
+  });
+});

+ 266 - 0
src/app/components/preview/image-lightbox/image-lightbox.ts

@@ -0,0 +1,266 @@
+import {
+  ChangeDetectionStrategy,
+  Component,
+  ElementRef,
+  effect,
+  input,
+  output,
+  viewChild,
+} from '@angular/core';
+import { downloadAs } from '../../../core/attachment-fetch';
+
+/**
+ * 图片灯箱 —— 附件图片的**唯一**放大查看方式。
+ *
+ * 依据《预览面板重构-设计-20261030》第二节:图片不进右侧预览,点击消息里的图片
+ * 直接打开本灯箱。
+ *
+ * ## 为什么不用 ui-image 内建灯箱
+ * 底座 `ui-image` 的灯箱服务于任意图片位(含左右切图),没有文件名/大小/下载;
+ * 改它会波及所有使用 `ui-image` 的页面。本组件只做附件预览这一件事,边界清楚
+ * (rules/18 §8「同一功能只做一种方案」——两者场景不同,不是同一方案)。
+ *
+ * ## 交互
+ * - Esc / 点击遮罩 / ✕ 关闭;打开时焦点落到关闭按钮
+ * - 下载走 blob(跨域 `download` 属性会被浏览器忽略)
+ * - 不做缩放/切图:保持单一职责,避免与正文图片的 gallery 语义混淆
+ */
+@Component({
+  selector: 'app-image-lightbox',
+  changeDetection: ChangeDetectionStrategy.OnPush,
+  template: `
+    @if (open()) {
+      <!--
+        关闭方式(2026-10-31 修复):
+        - 点击图片以外的区域关闭:命中 mask 本身才关(frame 上 stopPropagation)
+        - Esc 关闭:原来用模板 keydown,依赖焦点恰好在遮罩内,点过图片/工具条后
+          焦点丢失就失效(用户实测「点一下后 Esc 关不掉」)。改为全局 window 监听。
+      -->
+      <div #mask class="lb-mask" tabindex="-1" (click)="onMaskClick($event)">
+        <div
+          class="lb-frame"
+          role="dialog"
+          aria-modal="true"
+          [attr.aria-label]="'预览图片 ' + (name() || '')"
+        >
+          <img #img class="lb-img" [src]="url()" [alt]="name() || '图片'" />
+          <footer class="lb-bar">
+            <span class="lb-meta">
+              <span class="lb-name ellipsis" [title]="name()">{{ name() || '图片' }}</span>
+              @if (sizeLabel()) {
+                <span class="lb-dot" aria-hidden="true">·</span>
+                <span>{{ sizeLabel() }}</span>
+              }
+            </span>
+            <span class="lb-actions">
+              <button type="button" class="lb-act" (click)="download()">
+                <i class="fa-solid fa-download" aria-hidden="true"></i>
+                <span>下载</span>
+              </button>
+              <a class="lb-act" [href]="url()" target="_blank" rel="noopener noreferrer">
+                <i class="fa-solid fa-arrow-up-right-from-square" aria-hidden="true"></i>
+                <span>新标签打开</span>
+              </a>
+              <button
+                #closeBtn
+                type="button"
+                class="lb-act lb-act--icon"
+                aria-label="关闭预览"
+                (click)="close()"
+              >
+                <i class="fa-solid fa-xmark" aria-hidden="true"></i>
+              </button>
+            </span>
+          </footer>
+        </div>
+      </div>
+    }
+  `,
+  styles: [
+    `
+      :host {
+        display: contents;
+      }
+      /* 遮罩只是焦点落点(Esc 已由全局监听处理),不显示聚焦框 */
+      .lb-mask:focus:not(:focus-visible) {
+        box-shadow: none;
+      }
+      .lb-mask {
+        position: fixed;
+        inset: 0;
+        z-index: var(--z-preview);
+        display: grid;
+        place-items: center;
+        padding: var(--sp-6);
+        /* 深色灯箱:用令牌 --overlay 叠加两档得到 ~90% 的深色遮罩,
+           不写死 rgba(上一步三档叠成近似纯黑,背景完全不可见,已收敛为两档) */
+        background: var(--overlay);
+        box-shadow: inset 0 0 0 100vmax var(--overlay);
+        animation: lb-in var(--motion-detail) var(--ease-standard);
+      }
+      /* 工具条固定在视口底部,图片单独约束在剩余空间内(原实现文字被裁) */
+      .lb-frame {
+        position: fixed;
+        inset: 0;
+        display: flex;
+        padding: var(--sp-4);
+        flex-direction: column;
+        align-items: center;
+        justify-content: center;
+      }
+      .lb-img {
+        display: block;
+        max-width: min(92vw, 1400px);
+        max-height: calc(100dvh - 140px);
+        object-fit: contain;
+        border-radius: var(--r-md);
+        box-shadow: var(--shadow-pop);
+      }
+      .lb-bar {
+        display: flex;
+        width: 100%;
+        max-width: 92vw;
+        min-height: var(--touch-min);
+        align-items: center;
+        justify-content: space-between;
+        gap: var(--sp-4);
+        padding: var(--sp-2) var(--sp-3);
+        color: var(--inverse);
+        font-size: var(--fs-13);
+      }
+      .lb-meta {
+        display: flex;
+        min-width: 0;
+        align-items: center;
+        gap: var(--sp-2);
+      }
+      .lb-name {
+        max-width: 46vw;
+      }
+      .lb-dot {
+        opacity: 0.5;
+      }
+      .lb-actions {
+        display: flex;
+        flex-shrink: 0;
+        align-items: center;
+        gap: var(--sp-1);
+      }
+      .lb-act {
+        display: inline-flex;
+        min-height: 36px;
+        align-items: center;
+        gap: var(--sp-2);
+        padding: 0 var(--sp-3);
+        border: 0;
+        border-radius: var(--r-sm);
+        background: color-mix(in srgb, var(--inverse) 12%, transparent);
+        color: inherit;
+        font: inherit;
+        text-decoration: none;
+        cursor: pointer;
+        transition: background var(--motion-fast) var(--ease-standard);
+      }
+      .lb-act:hover {
+        background: color-mix(in srgb, var(--inverse) 22%, transparent);
+      }
+      .lb-act:focus-visible {
+        outline: 2px solid var(--inverse);
+        outline-offset: 2px;
+      }
+      .lb-act--icon {
+        width: 36px;
+        justify-content: center;
+        padding: 0;
+      }
+      .ellipsis {
+        overflow: hidden;
+        text-overflow: ellipsis;
+        white-space: nowrap;
+      }
+      @keyframes lb-in {
+        from {
+          opacity: 0;
+        }
+        to {
+          opacity: 1;
+        }
+      }
+      @media (prefers-reduced-motion: reduce) {
+        .lb-mask {
+          animation: none;
+        }
+      }
+    `,
+  ],
+})
+export class ImageLightboxComponent {
+  readonly open = input(false);
+  readonly url = input('');
+  readonly name = input('');
+  /** 字节数(0 / 缺省时不显示) */
+  readonly size = input(0);
+
+  readonly closed = output<void>();
+
+  private readonly closeBtn = viewChild<ElementRef<HTMLButtonElement>>('closeBtn');
+
+  readonly sizeLabel = (): string => (this.size() > 0 ? fmtSize(this.size()) : '');
+
+  /** 关闭按钮索引(ElementRef 取用):让打开时焦点落在一个可聚焦元素上 */
+  private readonly maskEl = viewChild<ElementRef<HTMLElement>>('mask');
+  private readonly imgEl = viewChild<ElementRef<HTMLImageElement>>('img');
+
+  /** 全局 Esc:不依赖焦点位置 */
+  private readonly onKey = (e: KeyboardEvent): void => {
+    if (e.key === 'Escape' && this.open()) {
+      e.stopPropagation();
+      this.close();
+    }
+  };
+
+  constructor() {
+    effect((onCleanup) => {
+      if (!this.open()) return;
+      queueMicrotask(() => {
+        (this.maskEl()?.nativeElement ?? this.closeBtn()?.nativeElement)?.focus();
+      });
+      window.addEventListener('keydown', this.onKey, true);
+      onCleanup(() => window.removeEventListener('keydown', this.onKey, true));
+    });
+  }
+
+  /**
+   * 点“图片以外的区域”关闭(2026-10-31)。
+   *
+   * 🔴 原来用 e.target === currentTarget:图片带 box-shadow 时命中判定会落到
+   *    包围盒外的阴影上,点击被当成“点到了 frame 之外但仍在 mask 内”,无法关闭(实测)。
+   *    改为按坐标判断:只有落在图片本体或底部工具条矩形之外才关闭。
+   */
+  onMaskClick(e: MouseEvent): void {
+    const img = this.imgEl()?.nativeElement;
+    const bar = this.closeBtn()?.nativeElement.closest('.lb-bar') as HTMLElement | null;
+    const inRect = (el: HTMLElement | null | undefined): boolean => {
+      if (!el) return false;
+      const r = el.getBoundingClientRect();
+      return e.clientX >= r.left && e.clientX <= r.right && e.clientY >= r.top && e.clientY <= r.bottom;
+    };
+    if (inRect(img) || inRect(bar)) return;
+    this.close();
+  }
+
+  async download(): Promise<void> {
+    await downloadAs(this.url(), this.name() || 'image');
+  }
+
+  close(): void {
+    this.closed.emit();
+  }
+}
+
+/** 字节数 → 人类可读(与聊天页 fmtSize 口径一致) */
+function fmtSize(bytes: number): string {
+  if (bytes < 1024) return `${bytes} B`;
+  if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
+  return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
+}

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

@@ -191,8 +191,14 @@ import { MarkdownComponent } from 'ngx-markdown';
         border-color: var(--nav-muted);
         background: var(--nav-active);
       }
+      /*
+       * 表格(2026-10-31 修正):
+       * 文件名这类长 token 曾把列挤成一行一个字(report.htm / l 断开)。
+       * 改为:长串在单元格内任意位置换行 + 首列(通常是名称)给足宽度。
+       */
       :host ::ng-deep .md-host table {
         width: 100%;
+        max-width: 100%;
         margin: var(--sp-2) 0;
         border-collapse: collapse;
         font-size: var(--fs-13);
@@ -201,6 +207,15 @@ import { MarkdownComponent } from 'ngx-markdown';
         padding: 6px 10px;
         border: 1px solid var(--edge);
         text-align: left;
+        word-break: break-word;
+        overflow-wrap: anywhere;
+        vertical-align: top;
+        min-width: 4.5em;
+      }
+      /* 首列通常是名称:给足宽度,避免被后续列挤压成一字一行 */
+      :host ::ng-deep .md-host :is(th, td):first-child {
+        min-width: 9em;
+        width: 28%;
       }
       :host ::ng-deep .md-host th {
         background: var(--surface-2);

+ 113 - 20
src/app/components/preview/office-preview/office-preview.ts

@@ -1,4 +1,5 @@
 import {
+  ViewEncapsulation,
   ChangeDetectionStrategy,
   Component,
   ElementRef,
@@ -7,7 +8,7 @@ import {
   signal,
   viewChild,
 } from '@angular/core';
-import { SheetTable, toSheetTable } from '../../../core/spreadsheet';
+import { SheetTable, parseCsvTable, toSheetTable } from '../../../core/spreadsheet';
 
 /**
  * Office 预览(docx / xlsx)—— 本地解析,分页 / 表格渲染。
@@ -39,6 +40,13 @@ import { SheetTable, toSheetTable } from '../../../core/spreadsheet';
 @Component({
   selector: 'app-office-preview',
   changeDetection: ChangeDetectionStrategy.OnPush,
+  /*
+   * 关闭样式封装(2026-10-31):
+   * 表格由 renderSheetHtml 手动拼 DOM,元素拿不到 _ngcontent 标记,
+   * 组件内联样式(全部带作用域属性)对它们一律不生效 —— 网格线/斑马纹/选中态全丢(实测)。
+   * 样式全部以 .of-* / .sheet* / .pdf-* 前缀限定在宿主内,不污染全局。
+   */
+  encapsulation: ViewEncapsulation.None,
   template: `
     <div class="of-wrap">
       @if (state() === 'loading') {
@@ -202,57 +210,93 @@ import { SheetTable, toSheetTable } from '../../../core/spreadsheet';
         color: var(--ink);
         font-weight: 600;
       }
-      /* 表格区:原生滚动,铺满宽度 */
+      /*
+       * 表格区:Excel 观感(2026-10-31 重做)
+       * - 紧凑网格:1px 细线、无外框、单元格内边距贴近 Excel
+       * - 冻结:列字母行 + 行号列(sticky),滚动时始终可见
+       * - 数字右对齐 + 等宽数字;斑马纹;悬停高亮整行
+       * - 单元格选中高亮(点击),与 Excel 的选中格一致
+       */
       .sheet-scroll {
         flex: 1;
         min-height: 0;
         overflow: auto;
         background: var(--surface);
+        font-variant-numeric: tabular-nums;
       }
+      /* 按内容定宽(Excel 习惯),列多时横向滚动;不再强行铺满整行 */
       .sheet {
-        width: 100%;
-        border-collapse: collapse;
+        width: max-content;
+        min-width: 0;
+        border-collapse: separate;
+        border-spacing: 0;
         font-size: var(--fs-12);
         color: var(--ink);
-        /* 列多时按内容撑开,允许横向滚动而不是把字挤没 */
-        min-width: max-content;
       }
-      .sheet th,
-      .sheet td {
-        padding: 4px var(--sp-3);
-        border: 1px solid var(--edge);
+      /* 网格线:显式 width/style,避免被全局 border 重置压成 0(实测 0px) */
+      .sheet > tbody > tr > td,
+      .sheet > thead > tr > th {
+        height: 26px;
+        padding: 0 var(--sp-3);
+        /* 🔴 用分项 border-*-width:Tailwind preflight 的 border-width: 0 会覆盖
+           border-bottom 简写,但不会覆盖 border-bottom-width */
+        /* 注:这里不能用反引号的代码记号 —— 会截断 styles 模板字符串 */
+        border-right-width: 1px;
+        border-right-style: solid;
+        border-right-color: var(--edge);
+        border-bottom-width: 1px;
+        border-bottom-style: solid;
+        border-bottom-color: var(--edge);
         white-space: nowrap;
-        /* 超长单元格截断,鼠标悬停看全文 */
         max-width: 320px;
+        min-width: 96px;
         overflow: hidden;
         text-overflow: ellipsis;
+        background: var(--surface);
       }
-      .sheet thead th {
+      .sheet > thead > tr > th {
         position: sticky;
         top: 0;
-        z-index: 1;
+        z-index: 3;
+        height: 24px;
         background: var(--surface-2);
         color: var(--muted);
-        font-weight: 600;
+        font-weight: 500;
+        font-size: var(--fs-11);
         text-align: center;
+        border-bottom: 1px solid var(--edge-strong, var(--edge));
       }
       /* 行号列固定在最左 */
-      .sheet .row-no {
+      .sheet > tbody > tr > th.row-no,
+      .sheet > thead > tr > th.row-no {
         position: sticky;
         left: 0;
-        z-index: 1;
+        z-index: 2;
+        width: 48px;
+        min-width: 48px;
         background: var(--surface-2);
         color: var(--muted);
         text-align: center;
-        min-width: 44px;
         font-weight: 400;
+        font-size: var(--fs-11);
+        border-right: 1px solid var(--edge-strong, var(--edge));
       }
       .sheet thead .row-no {
-        z-index: 2;
+        z-index: 4;
+      }
+      /* 斑马纹:只染数据行,表头不动 */
+      .sheet tbody tr:nth-child(even) td:not(.row-no) {
+        background: color-mix(in srgb, var(--surface-2) 55%, var(--surface));
       }
       .sheet tbody tr:hover td {
         background: var(--surface-hover);
       }
+      /* 选中单元格(点击) */
+      .sheet td.sel {
+        outline: 2px solid var(--primary);
+        outline-offset: -2px;
+        background: color-mix(in srgb, var(--primary) 8%, var(--surface));
+      }
       .sheet .al-right {
         text-align: right;
         font-variant-numeric: tabular-nums;
@@ -313,6 +357,8 @@ export class OfficePreviewComponent {
     const table = toSheetTable(raw.name ?? 'Sheet1', (raw.rows ?? []) as never);
     this.sheet.set(table);
     this.sheetIndex.set(index);
+    await this.waitStage(token);
+    if (token !== this.loadToken) return;
     this.renderSheetHtml(table);
   }
 
@@ -325,8 +371,18 @@ export class OfficePreviewComponent {
    *    直接拼 DOM 最可靠,且文字可选中、可复制、可 Ctrl+F。
    */
   private renderSheetHtml(t: SheetTable): void {
-    const host = this.stageEl()?.nativeElement;
+    /*
+     * 🔴 直接查 DOM,不用 viewChild:
+     *    切换「右侧 / 全屏」时宿主节点会被重建,viewChild 在重建后的那一拍是旧引用,
+     *    drawSheet 画进已脱离文档的节点 —— 页面空白(实测)。查 DOM 拿到的必然是当前节点。
+     *    样式已关闭封装(encapsulation: None),无需 ng 作用域标记。
+     */
+    const host = document.querySelector('app-office-preview .of-xlsx') as HTMLElement | null;
     if (!host) return;
+    this.drawSheet(host, t);
+  }
+
+  private drawSheet(host: HTMLElement, t: SheetTable): void {
     this.sheetError.set('');
     host.innerHTML = '';
 
@@ -397,10 +453,32 @@ export class OfficePreviewComponent {
       tbody.appendChild(tr);
     }
     table.appendChild(tbody);
+    // 单元格选中(Excel 观感):事件委托到表,单选高亮
+    table.addEventListener('click', (ev) => {
+      const td = (ev.target as HTMLElement).closest('td');
+      if (!td) return;
+      table.querySelectorAll('td.sel').forEach((c) => c.classList.remove('sel'));
+      td.classList.add('sel');
+    });
     scroll.appendChild(table);
     host.appendChild(scroll);
   }
 
+  /**
+   * 等 #stage 真正挂载。
+   *
+   * 🔴 原来只等一帧:state 切换与 @if 分支插入 DOM 跨了两个渲染轮次,
+   *    实测 renderSheetHtml 拿到 null 后静默 return —— 表格永远是空的。
+   *    改为轮询(最多 30 帧)+ viewChild 取,拿到即返回。
+   */
+  private async waitStage(token: number): Promise<void> {
+    for (let i = 0; i < 30; i++) {
+      if (token !== this.loadToken) return;
+      if (document.querySelector('app-office-preview .of-xlsx')) return;
+      await new Promise<void>((r) => requestAnimationFrame(() => r()));
+    }
+  }
+
   /** 切换工作表 */
   async selectSheet(i: number): Promise<void> {
     if (!this.workbook) return;
@@ -450,7 +528,22 @@ export class OfficePreviewComponent {
     await this.teardown();
 
     try {
-      if (kind === 'xlsx') {
+      // CSV 在 file-kind 中归入 xlsx(kind='xlsx'),按扩展名分流,不是按 kind
+      if (kind === 'csv' || /\.csv$/i.test(this.name())) {
+        // CSV 是纯文本,不能走 XlsxWorkbook(zip 解析必然失败,2026-10-30 实测)
+        const res = await fetch(url);
+        if (!res.ok) throw new Error(`HTTP ${res.status}`);
+        const text = await res.text();
+        if (token !== this.loadToken) return;
+        const table = parseCsvTable((this.name() || 'data.csv').replace(/\.csv$/i, ''), text);
+        this.sheetNames.set([table.name]);
+        this.sheetIndex.set(0);
+        this.sheet.set(table);
+        // 等模板把挂载点渲染出来再拼表格
+        await this.waitStage(token);
+        if (token !== this.loadToken) return;
+        this.renderSheetHtml(table);
+      } else if (kind === 'xlsx') {
         const host = this.stageEl()?.nativeElement;
         if (!host) throw new Error('预览容器未就绪');
         host.innerHTML = '';

+ 206 - 110
src/app/components/preview/pdf-preview/pdf-preview.ts

@@ -1,16 +1,20 @@
 import {
+  AfterViewInit,
   ChangeDetectionStrategy,
   Component,
   ElementRef,
+  OnDestroy,
   effect,
+  inject,
   input,
+  model,
   output,
   signal,
   viewChild,
 } from '@angular/core';
 
 /**
- * PDF 预览 —— 逐页 canvas 渲染。
+ * PDF 预览 —— 逐页 canvas 渲染(**懒渲染**)。
  *
  * ## 借鉴(E:\work\lami\borin-academy-beta-version 的 pdf-preview 组件)
  *
@@ -22,6 +26,12 @@ import {
  * 2. **worker 地址带缓存版本号** —— 企微等内置浏览器会长期复用旧 worker。
  * 3. **加载失败要能重试**,且错误文案要具体(不要只写"加载失败")。
  *
+ * ## 2026-10-30 重构(《预览面板重构-设计-20261030》)
+ * - **懒渲染**:首屏页立即画,其余页用 IntersectionObserver 等进入视口附近才画;
+ *   未渲染的页面显示同尺寸灰色骨架,滚动位置不跳。
+ * - **缩放上收**:缩放比例由宿主(app-file-preview)通过 `zoom` 模型驱动,
+ *   本组件不再自带工具栏;页码与总页数通过 `page` / `pageCount` 模型回传宿主状态栏。
+ *
  * 未照搬的部分:双版本 legacy 降级(我们主用 pdf.js 6.x,现代浏览器足够;
  * 真遇到老内核再加,避免一开始就把复杂度堆上来)。
  */
@@ -42,13 +52,6 @@ import {
           <button type="button" class="retry" (click)="reload()">重试</button>
         </div>
       } @else {
-        <div class="pdf-toolbar">
-          <button type="button" (click)="zoomBy(-0.2)" aria-label="缩小">−</button>
-          <span class="zoom">{{ (scale() * 100).toFixed(0) }}%</span>
-          <button type="button" (click)="zoomBy(0.2)" aria-label="放大">+</button>
-          <span class="pages">{{ pageCount() }} 页</span>
-          <a class="dl" [href]="url()" download>下载</a>
-        </div>
         <div class="pdf-scroll" #scroll>
           <div class="pdf-pages" #pages></div>
         </div>
@@ -89,37 +92,14 @@ import {
         color: var(--primary);
         cursor: pointer;
       }
-      .pdf-toolbar {
-        display: flex;
-        align-items: center;
-        gap: var(--sp-2);
-        padding: var(--sp-2) var(--sp-3);
-        border-bottom: 1px solid var(--edge);
-        font-size: var(--fs-12);
-        color: var(--ink-2);
-      }
-      .pdf-toolbar button {
-        width: 32px;
-        height: 32px;
-        border: 1px solid var(--edge);
-        border-radius: var(--r-sm);
-        background: var(--surface);
-        color: var(--ink);
-        cursor: pointer;
-      }
-      .zoom,
-      .pages {
-        min-width: 48px;
-        text-align: center;
-        color: var(--muted);
-      }
-      .dl {
-        margin-left: auto;
-        color: var(--primary);
-        text-decoration: none;
-      }
       .pdf-scroll {
-        flex: 1;
+        /*
+         * 🔴 flex:1 会让它在缩放后**仍然等于视口高度**,导致超出部分滚不到
+         *    (实测:scale 130% 时 scrollHeight 恒为 506)。
+         *    这里改为按缩放比例反向撑高:可视高度 × 1/zoom = 缩放后的内容高度。
+         *    --fp-scale 由宿主的 .fp-content 继承下来。
+         */
+        height: calc(100% / var(--fp-scale, 1));
         min-height: 0;
         overflow: auto;
         background: var(--surface-2);
@@ -129,6 +109,7 @@ import {
       .pdf-pages {
         display: grid;
         justify-items: center;
+        align-content: start;
         gap: var(--sp-3);
       }
       .pdf-pages canvas {
@@ -137,23 +118,48 @@ import {
         box-shadow: var(--shadow-card);
         background: var(--surface);
       }
+      /* 未渲染页的占位:与渲染后同尺寸,避免滚动位置跳动 */
+      .pdf-ph {
+        display: grid;
+        place-items: center;
+        background: var(--surface);
+        box-shadow: var(--shadow-card);
+        color: var(--muted);
+        font-size: var(--fs-12);
+      }
     `,
   ],
 })
-export class PdfPreviewComponent {
+export class PdfPreviewComponent implements AfterViewInit, OnDestroy {
   readonly url = input.required<string>();
   readonly close = output<void>();
 
+  /**
+   * 缩放由宿主统一实现(2026-10-31):宿主把所有类型包进同一个 .fp-content 做 zoom,
+   * 这里不再自己响应缩放,避免双重缩放与重复渲染。
+   */
+  /** 当前页(随滚动更新,回传给宿主状态栏) */
+  readonly page = model(1);
+  /** 总页数(回传给宿主状态栏) */
+  readonly pageCount = model(0);
+
   readonly state = signal<'loading' | 'ready' | 'error'>('loading');
   readonly error = signal('');
-  readonly scale = signal(1);
-  readonly pageCount = signal(0);
 
   private readonly pagesEl = viewChild<ElementRef<HTMLElement>>('pages');
+  private readonly scrollEl = viewChild<ElementRef<HTMLElement>>('scroll');
+  private readonly host = inject(ElementRef<HTMLElement>);
 
-  /** 已渲染的页面(重渲染时清理,避免叠加) */
-  private rendered: HTMLCanvasElement[] = [];
-  private doc: { destroy(): void } | null = null;
+  private doc: PdfDoc | null = null;
+  /** 每页的渲染状态(含页面尺寸,重渲染时用) */
+  private pages: PageSlot[] = [];
+  private observer: IntersectionObserver | null = null;
+  /** 当前 fit 比例(容器宽 / 页面宽),缩放叠加在上面 */
+  private fit = 1;
+  /** 防并发:缩放与懒渲染同时改 DOM */
+  private renderToken = 0;
+  /** 上一次渲染用的 dpr(裁剪用) */
+  private readonly dpr = Math.min(window.devicePixelRatio || 1, 2);
 
   constructor() {
     effect(() => {
@@ -166,118 +172,208 @@ export class PdfPreviewComponent {
     void this.load(this.url());
   }
 
-  zoomBy(delta: number): void {
-    const next = Math.min(3, Math.max(0.4, this.scale() + delta));
-    if (next === this.scale()) return;
-    this.scale.set(next);
-    void this.renderAll();
-  }
-
   private async load(url: string): Promise<void> {
     this.state.set('loading');
     this.error.set('');
-    this.rendered = [];
+    this.teardown();
     try {
       installPdfjsCompatPolyfills();
       // 懒加载:pdf.js 体积大,不进首屏
       const pdfjs = await import('pdfjs-dist');
       pdfjs.GlobalWorkerOptions.workerSrc = workerSrc();
-      this.doc?.destroy();
       const task = pdfjs.getDocument({
         url,
         verbosity: pdfjs.VerbosityLevel?.ERRORS ?? 0,
       } as never);
-      const doc = (await task.promise) as unknown as {
-        numPages: number;
-        getPage(n: number): Promise<unknown>;
-        destroy(): void;
-      };
+      const doc = (await task.promise) as unknown as PdfDoc;
       this.doc = doc;
-      this.pageCount.set(doc.numPages);
       this.state.set('ready');
       /*
        * 等一帧:`@else` 分支(含 .pdf-pages 容器)在本 tick 之后才插入 DOM,
        * 立刻查会是 null。两帧更稳(首帧布局、次帧含样式)。
        */
       await nextFrames(2);
-      if (this.state() !== 'ready') return;
-      // 先渲染第一页,再后台渲染其余(大文件不至于白屏太久)
-      await this.renderPage(doc, 1);
-      void this.renderRest(doc);
+      if (this.state() !== 'ready' || this.doc !== doc) return;
+      await this.buildPages(doc);
     } catch (e) {
       this.error.set(pdfErrorText(e));
       this.state.set('error');
     }
   }
 
-  private async renderAll(): Promise<void> {
-    if (!this.doc) return;
-    const host = this.pagesHost();
+  /**
+   * 建立页面占位 + 懒渲染观察器。
+   *
+   * 先取每页 viewport(只读尺寸,不解码内容),得到所有页的显示尺寸与总高度;
+   * 再一次性插入骨架 div,让滚动条从一开始就是最终长度 —— 否则边渲染边撑高,
+   * 用户一旦滚动就会被"追着跑"。
+   */
+  private async buildPages(doc: PdfDoc): Promise<void> {
+    const host = this.pagesEl()?.nativeElement;
     if (!host) return;
+    const token = ++this.renderToken;
     host.innerHTML = '';
-    this.rendered = [];
-    const doc = this.doc as unknown as { numPages: number; getPage(n: number): Promise<unknown> };
+    this.pages = [];
+    const first = await doc.getPage(1);
+    const vp1 = first.getViewport({ scale: 1 });
+    this.fit = calcFit(host, vp1.width);
+    this.pageCount.set(doc.numPages);
+
     for (let i = 1; i <= doc.numPages; i++) {
-      // eslint-disable-next-line no-await-in-loop
-      await this.renderPage(doc, i);
+      const el = document.createElement('div');
+      el.className = 'pdf-ph';
+      el.dataset['page'] = String(i);
+      host.appendChild(el);
+      this.pages.push({ el, canvas: null });
     }
+    // 占位尺寸按第一页的比例估算(PDF 常见统一页面尺寸)
+    this.sizePlaceholders(vp1.width, vp1.height);
+    if (token !== this.renderToken) return;
+
+    // 观察器:进入视口前 600px 就渲染,滚动时不至于看见灰块
+    this.observer = new IntersectionObserver(
+      (entries) => {
+        for (const en of entries) {
+          if (!en.isIntersecting) continue;
+          const n = Number((en.target as HTMLElement).dataset['page']);
+          void this.renderPage(n);
+        }
+      },
+      { root: this.scrollEl()?.nativeElement ?? null, rootMargin: '600px 0px' },
+    );
+    for (const p of this.pages) this.observer.observe(p.el);
+    // 首屏页不等观察器回调,立即画(避免"加载完还是一片灰")
+    await this.renderPage(1);
   }
 
-  private async renderRest(doc: { numPages: number; getPage(n: number): Promise<unknown> }): Promise<void> {
-    for (let i = 2; i <= doc.numPages; i++) {
-      // eslint-disable-next-line no-await-in-loop
-      await this.renderPage(doc, i);
+  /** 按比例重排占位尺寸(缩放 / 首次构建共用) */
+  private sizePlaceholders(w: number, h: number): void {
+    const scale = this.fit;
+    const width = Math.floor(w * scale);
+    const height = Math.floor(h * scale);
+    for (const p of this.pages) {
+      if (p.canvas) {
+        p.canvas.style.width = `${width}px`;
+      } else {
+        p.el.style.width = `${width}px`;
+        p.el.style.height = `${height}px`;
+      }
     }
   }
 
   /**
-   * 取渲染容器。
+   * 缩放:清掉所有画布,保留骨架,再让观察器重新播报可见页。
    *
-   * 🔴 不用 viewChild 直接取:`state` 从 loading 切到 ready 的**同一个 tick** 里,
-   *    `@else` 分支刚插入 DOM,viewChild 还没更新 → 拿到 undefined,
-   *    renderPage 静默返回,结果就是「工具栏有、页面空白」。
-   *    这里直接查 DOM,拿到的必然是已挂载的元素。
+   * 不直接对 canvas 做 CSS scale —— 那只是放大图片,文字会糊;
+   * 按新比例重新渲染才能保持矢量清晰度。
    */
-  private pagesHost(): HTMLElement | null {
-    const fromRef = this.pagesEl()?.nativeElement;
-    if (fromRef) return fromRef;
-    return document.querySelector('app-pdf-preview .pdf-pages');
-  }
-
-  private async renderPage(
-    doc: { getPage(n: number): Promise<unknown> },
-    n: number,
-  ): Promise<void> {
-    const host = this.pagesHost();
-    if (!host) return;
-    const page = (await doc.getPage(n)) as {
-      getViewport(o: { scale: number }): { width: number; height: number };
-      render(o: { canvasContext: CanvasRenderingContext2D; viewport: unknown }): { promise: Promise<void> };
-    };
-    /*
-     * 按容器宽度自适应,再叠加用户缩放。
-     * 🔴 容器刚插入 DOM 时 clientWidth 可能是 0 或极小(192px 那种),
-     *    直接用它会把页面渲染得极小。这里给一个合理下限,并优先用弹层宽度。
-     */
-    const panel = host.closest('.fp-panel') as HTMLElement | null;
-    const avail = Math.max(host.clientWidth, panel?.clientWidth ?? 0, 360) - 32;
-    const base = Math.min(avail, 900);
+  private async renderPage(n: number): Promise<void> {
+    const doc = this.doc;
+    const slot = this.pages[n - 1];
+    if (!doc || !slot || slot.canvas) return;
+    const token = this.renderToken;
+    const page = await doc.getPage(n);
+    if (token !== this.renderToken || slot.canvas) return;
     const vp1 = page.getViewport({ scale: 1 });
-    const fit = base / vp1.width;
-    // 设备像素比:高分屏不糊
-    const dpr = Math.min(window.devicePixelRatio || 1, 2);
-    const viewport = page.getViewport({ scale: fit * this.scale() * dpr });
+    const display = this.fit;
+    const viewport = page.getViewport({ scale: display * this.dpr });
 
     const canvas = document.createElement('canvas');
     canvas.width = Math.floor(viewport.width);
     canvas.height = Math.floor(viewport.height);
-    canvas.style.width = `${Math.floor(viewport.width / dpr)}px`;
+    canvas.style.width = `${Math.floor(vp1.width * display)}px`;
     const ctx = canvas.getContext('2d');
     if (!ctx) return;
     await page.render({ canvasContext: ctx, viewport }).promise;
-    host.appendChild(canvas);
-    this.rendered.push(canvas);
+    if (token !== this.renderToken) return;
+    // 画完替换骨架(骨架保持同尺寸,替换不引起回流)
+    slot.el.replaceWith(canvas);
+    slot.canvas = canvas;
+  }
+
+  /** 当前视口中央对应的页号(滚动时更新 page 模型) */
+  private visiblePage(): number {
+    const sc = this.scrollEl()?.nativeElement;
+    if (!sc || this.pages.length === 0) return this.page();
+    const mid = sc.scrollTop + sc.clientHeight / 2;
+    let top = 0;
+    let best = 1;
+    for (let i = 0; i < this.pages.length; i++) {
+      const el = this.pages[i].canvas ?? this.pages[i].el;
+      const h = el.getBoundingClientRect().height;
+      if (mid < top + h) return i + 1;
+      top += h + 12;
+      best = i + 1;
+    }
+    return best;
+  }
+
+  private onScroll(): void {
+    const n = this.visiblePage();
+    if (n !== this.page()) this.page.set(n);
+  }
+
+  /** 滚动时更新页码(事件委托到容器的 scroll 事件) */
+  private scrollListener = (): void => this.onScroll();
+
+  ngAfterViewInit(): void {
+    /*
+     * 🔴 不用 viewChild 取 .pdf-scroll:就绪后几百毫秒它才由 @else 分支插入 DOM,
+     *    ngAfterViewInit 时还是 undefined,监听会静默挂不上(页码不更新)。
+     *    改为委托在宿主自身:子元素滚动不冒泡,但 scroll 事件在捕获阶段能经过
+     *    host —— 这里用宿主原生监听 + 被动模式即可拿到所有子滚动。
+     */
+    this.host.nativeElement.addEventListener('scroll', this.scrollListener, {
+      passive: true,
+      capture: true,
+    });
+  }
+
+  ngOnDestroy(): void {
+    this.host.nativeElement.removeEventListener('scroll', this.scrollListener, { capture: true });
+    this.teardown();
   }
+
+  private teardown(): void {
+    this.observer?.disconnect();
+    this.observer = null;
+    this.renderToken++;
+    this.pages = [];
+    try {
+      this.doc?.destroy();
+    } catch {
+      /* 销毁失败不影响后续 */
+    }
+    this.doc = null;
+  }
+}
+
+interface PdfDoc {
+  numPages: number;
+  getPage(n: number): Promise<PdfPage>;
+  destroy(): void;
+}
+
+interface PdfPage {
+  getViewport(o: { scale: number }): { width: number; height: number };
+  render(o: { canvasContext: CanvasRenderingContext2D; viewport: unknown }): { promise: Promise<void> };
+}
+
+interface PageSlot {
+  el: HTMLElement;
+  canvas: HTMLCanvasElement | null;
+}
+
+/**
+ * 容器宽 → fit 比例。
+ *
+ * 🔴 容器刚插入 DOM 时 clientWidth 可能是 0 或极小(192px 那种),
+ *    直接用它会把页面渲染得极小。这里给一个合理下限,并优先用面板宽度。
+ */
+function calcFit(host: HTMLElement, pageWidth: number): number {
+  const panel = host.closest('.fp-panel') as HTMLElement | null;
+  const avail = Math.max(host.clientWidth, panel?.clientWidth ?? 0, 360) - 32;
+  return Math.min(avail, 900) / pageWidth;
 }
 
 /** 等 n 帧(让 Angular 把新分支渲染进 DOM) */

+ 11 - 5
src/app/core/acl.spec.ts

@@ -58,15 +58,21 @@ describe('acl 判级', () => {
       expect(scopeOf(r)).toBe('company');
     });
 
-    it('有公司但无 _Admin 角色 → company-member', () => {
+    it('有公司但无 _Admin 角色 → company-member,但只请求 mine', () => {
       const r = resolveScope(me({ roles: [], companyId: 'C1' }));
       expect(r.level).toBe('company-member');
-      expect(scopeOf(r)).toBe('company');
+      expect(r.companyId).toBe('C1');
+      // 2026-10-31 隔离收紧:普通成员不看同事的容器
+      expect(scopeOf(r)).toBe('mine');
+    });
+
+    it('🔴 2026-10-31 裁决:成员只请求 mine(防止看到同公司其他人的容器)', () => {
+      expect(scopeFor(me({ companyId: 'C1' }))).toBe('mine');
+      expect(scopeFor(me({ roles: ['whatever'], companyId: 'C1' }))).toBe('mine');
     });
 
-    it('🔴 当前裁决:成员也请求 company(同公司就看全公司)', () => {
-      expect(scopeFor(me({ companyId: 'C1' }))).toBe('company');
-      expect(scopeFor(me({ roles: ['whatever'], companyId: 'C1' }))).toBe('company');
+    it('管理员仍然请求 company(与成员区分)', () => {
+      expect(scopeFor(me({ roles: ['C1_Admin'], companyId: 'C1' }))).toBe('company');
     });
 
     it('角色前缀与 companyId 不匹配 → company-member(不是管理员)', () => {

+ 12 - 4
src/app/core/acl.ts

@@ -94,19 +94,27 @@ export function resolveScope(me: Me | null | undefined): AclScope {
  * |---|---|---|
  * | superadmin | `all` | 全部容器 |
  * | company-admin | `company` | 本公司 + 自己的(兜底)|
- * | company-member | `company` | 本公司 + 自己的(兜底)**← 当前与管理员相同** |
+ * | company-member | `mine` | **仅自己**(2026-10-31 用户裁决:普通成员不应看到同事的容器)|
  * | guest / personal | `mine` | 仅自己 |
  *
- * 🔴 **将来收紧**(若产品要求「成员只看自己」):把 `company-member` 改成 `'mine'` 即可,
- *    `resolveScope()` 的判级无需改动。
+ * 🔴 2026-10-31 隔离收紧:普通成员此前与管理员一样请求 `company`,
+ *    导致同公司所有人都能看到彼此的容器(用户实测:看到其他人的 lj-test / 兔爷001)。
+ *    现在成员统一请求 `mine`;服务端 `xlosi3m46q` 本来就把 `mine` 映射为
+ *    `user === 当前用户` 的强制过滤,因此这是**更小**的请求范围,不存在越权放大。
+ *    无 company 的容器也归 `mine` 覆盖(`q.equalTo('user', user)`),不会漏看。
  */
 export function scopeOf(scope: AclScope): Scope {
   switch (scope.level) {
     case 'superadmin':
       return 'all';
     case 'company-admin':
-    case 'company-member':
       return 'company';
+    case 'company-member':
+      /*
+       * 普通成员只看自己的(2026-10-31 用户裁决)。
+       * 🔴 不要改回 company:那会把整个公司的容器暴露给每个成员。
+       */
+      return 'mine';
     default:
       return 'mine';
   }

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

@@ -2,6 +2,8 @@ import {
   Agent,
   ArtifactJob,
   AuthSession,
+  ChatThreadRecord,
+  ChatThreadUpsert,
   Deliverable,
   DeliverableGroup,
   MatrixProvision,
@@ -56,6 +58,30 @@ export abstract class FmodeApi {
   /** 校验/刷新凭据(`validate: 1`) */
   abstract validateMatrix(agentId: string): Promise<MatrixProvision>;
 
+  // ---------- 会话目录(云端持久化) ----------
+
+  /**
+   * 拉取当前用户的会话目录(云函数 `fmodeagent-chatthreads`,`op:'list'`)。
+   *
+   * 🔴 云端是会话目录的**唯一真相源**;本地 localStorage 只作渲染缓存。
+   *    服务端按 sessionToken 强制归属当前用户,本方法不传 user 参数。
+   */
+  abstract fetchChatThreads(agentId: string, opts?: { includeDeleted?: boolean }): Promise<ChatThreadRecord[]>;
+
+  /** 批量上报会话(新建或补计数/时间;标题仅在为空时写入) */
+  abstract upsertChatThreads(items: ChatThreadUpsert[]): Promise<void>;
+
+  /** 修改标题(置 titleLocked)或归档状态 */
+  abstract patchChatThread(input: {
+    threadRoot: string;
+    title?: string;
+    archived?: boolean;
+  }): Promise<void>;
+
+  /** 软删会话(isDeleted=true;再次收到消息可 revive) */
+  /** 删除会话索引(云端物理删除 + 留墓碑阻止重建);Matrix 消息不受影响 */
+  abstract removeChatThread(input: { threadRoot: string; roomId?: string; agentId?: string }): Promise<void>;
+
   // ---------- 交付成果 ----------
 
   /** 列表(云函数 lfYlgU7SkK,`action:'list'`) */

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

@@ -11,6 +11,8 @@ import {
   Agent,
   ArtifactJob,
   AuthSession,
+  ChatThreadRecord,
+  ChatThreadUpsert,
   Deliverable,
   DeliverableGroup,
   MatrixProvision,
@@ -131,6 +133,63 @@ export class MockApi extends FmodeApi {
     return groups;
   }
 
+  // ---------- 会话目录(演示态:内存存储,行为与云端一致) ----------
+
+  /** 演示态的会话目录(进程内存,刷新即清空 —— 与真实态「以云端为准」的语义一致) */
+  private readonly mockThreads = new Map<string, ChatThreadRecord>();
+
+  async fetchChatThreads(agentId: string, _opts?: { includeDeleted?: boolean }): Promise<ChatThreadRecord[]> {
+    await this.delay();
+    return [...this.mockThreads.values()]
+      .filter((t) => t.agentId === agentId)
+      .sort((a, b) => b.lastMessageAt - a.lastMessageAt)
+      .map((t) => ({ ...t }));
+  }
+
+  async upsertChatThreads(items: ChatThreadUpsert[]): Promise<void> {
+    await this.delay();
+    for (const it of items) {
+      const key = `${it.roomId}|${it.threadRoot}`;
+      const cur = this.mockThreads.get(key);
+      this.mockThreads.set(key, {
+        objectId: cur?.objectId ?? `mock_${key}`,
+        agentId: it.agentId,
+        roomId: it.roomId,
+        threadRoot: it.threadRoot,
+        title: cur?.titleLocked ? cur.title : it.title || cur?.title || '',
+        titleLocked: cur?.titleLocked ?? false,
+        messageCount: it.messageCount ?? cur?.messageCount ?? 0,
+        lastMessageAt: it.lastMessageAt ?? cur?.lastMessageAt ?? Date.now(),
+        archived: cur?.archived ?? false,
+        lastPreview: it.lastPreview ?? cur?.lastPreview ?? '',
+      });
+    }
+  }
+
+  async patchChatThread(input: {
+    threadRoot: string;
+    title?: string;
+    archived?: boolean;
+  }): Promise<void> {
+    await this.delay();
+    for (const [key, t] of this.mockThreads) {
+      if (t.threadRoot !== input.threadRoot) continue;
+      this.mockThreads.set(key, {
+        ...t,
+        title: input.title ?? t.title,
+        titleLocked: input.title != null ? true : t.titleLocked,
+        archived: input.archived ?? t.archived,
+      });
+    }
+  }
+
+  async removeChatThread(input: { threadRoot: string; roomId?: string; agentId?: string }): Promise<void> {
+    await this.delay();
+    for (const [key, t] of this.mockThreads) {
+      if (t.threadRoot === input.threadRoot) this.mockThreads.delete(key);
+    }
+  }
+
   // ---------- 任务看板(FR-07b)----------
 
   async fetchTasks(agentId: string): Promise<Task[]> {

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

@@ -579,3 +579,42 @@ export function agentPresenceTone(p: AgentPresence): SemanticTone {
       return 'neutral';
   }
 }
+
+/**
+ * 云端会话目录记录(表 `FmodeChatThread`,云函数 `fmodeagent-chatthreads`)。
+ *
+ * 2026-10-31 引入:会话索引此前只存在本地 localStorage,换浏览器/清缓存即丢失。
+ * 云端记录是**唯一真相源**,本地只作缓存。
+ *
+ * 🔴 归属由服务端按 sessionToken 强制确定,客户端不传也不信任任何 user 字段。
+ */
+export interface ChatThreadRecord {
+  /** 云端记录 id */
+  objectId: string;
+  agentId: string;
+  roomId: string;
+  /** 线程根 event_id(App 内部称 sessionId) */
+  threadRoot: string;
+  title: string;
+  /** 用户手动改过标题 → upsert 不覆盖 */
+  titleLocked: boolean;
+  messageCount: number;
+  /** 毫秒时间戳 */
+  lastMessageAt: number;
+  archived: boolean;
+  lastPreview: string;
+}
+
+/** upsert 提交项(不含服务端生成字段) */
+export interface ChatThreadUpsert {
+  agentId: string;
+  roomId: string;
+  threadRoot: string;
+  title?: string;
+  messageCount?: number;
+  /** 毫秒时间戳 */
+  lastMessageAt?: number;
+  lastPreview?: string;
+  /** 重新出现时撤销软删 */
+  revive?: boolean;
+}

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

@@ -4,6 +4,8 @@ import { FmodeApi } from './fmode-api';
 import {
   Agent,
   AgentStatus,
+  ChatThreadRecord,
+  ChatThreadUpsert,
   Artifact,
   ArtifactJob,
   AuthSession,
@@ -56,6 +58,8 @@ const FN = {
   tokenSync: 'zl4yU8PAEB',
   uploadUrl: 'AlP56LCKFm',
   workgroup: 'V1EBgm05y2',
+  /** 会话目录(2026-10-31 新建,Function.name = fmodeagent-chatthreads) */
+  chatThreads: 'oR5O67i1Cm',
 } as const;
 
 /** 兼容「已是对象」与「是 JSON 字符串」两种形态 */
@@ -241,6 +245,53 @@ export class ParseApi extends FmodeApi {
     return d;
   }
 
+  // ---------- 会话目录(云函数 oR5O67i1Cm / fmodeagent-chatthreads) ----------
+
+  /**
+   * 拉取当前用户的会话目录。
+   *
+   * 🔴 云函数按 sessionToken 强制归属当前用户;这里**不传任何用户参数**。
+   *    返回结构在 function 内已归一(毫秒时间戳、驼峰字段)。
+   */
+  async fetchChatThreads(agentId: string, opts?: { includeDeleted?: boolean }): Promise<ChatThreadRecord[]> {
+    const d = await this.callFunction<{ code: number; threads?: ChatThreadRecord[]; mess?: string }>(
+      FN.chatThreads,
+      { op: 'list', agentId, ...(opts?.includeDeleted ? { includeDeleted: 1 } : {}) },
+    );
+    if (d.code !== 200) throw new Error(d.mess ?? '会话目录读取失败');
+    return Array.isArray(d.threads) ? d.threads : [];
+  }
+
+  async upsertChatThreads(items: ChatThreadUpsert[]): Promise<void> {
+    if (items.length === 0) return;
+    const d = await this.callFunction<{ code: number; mess?: string }>(FN.chatThreads, {
+      op: 'upsert',
+      items,
+    });
+    if (d.code !== 200) throw new Error(d.mess ?? '会话目录同步失败');
+  }
+
+  async patchChatThread(input: {
+    threadRoot: string;
+    title?: string;
+    archived?: boolean;
+  }): Promise<void> {
+    const d = await this.callFunction<{ code: number; mess?: string }>(FN.chatThreads, {
+      op: 'patch',
+      ...input,
+    });
+    // 404 = 云端还没有这条(本地先建、还没同步过)→ 视为成功,下次 upsert 会补上
+    if (d.code !== 200 && d.code !== 404) throw new Error(d.mess ?? '会话更新失败');
+  }
+
+  async removeChatThread(input: { threadRoot: string; roomId?: string; agentId?: string }): Promise<void> {
+    const d = await this.callFunction<{ code: number; mess?: string }>(FN.chatThreads, {
+      op: 'remove',
+      ...input,
+    });
+    if (d.code !== 200) throw new Error(d.mess ?? '会话删除失败');
+  }
+
   // ---------- 交付成果 ----------
 
   async fetchDeliverables(opts?: { agentId?: string; limit?: number }): Promise<Deliverable[]> {

+ 38 - 0
src/app/core/app-error-handler.ts

@@ -0,0 +1,38 @@
+import { ErrorHandler, Injectable } from '@angular/core';
+
+/**
+ * 全局错误处理器 —— 只做**降噪**,不放宽任何真实错误。
+ *
+ * ## 为什么要它
+ *
+ * Matrix SDK 在以下场景会抛出 `AbortError: signal is aborted without reason`:
+ *   · `stopClient()`(登出 / 断开连接)中止在途的 `/sync` 与 `/messages`;
+ *   · 页面切换、重复进入容器时旧请求被取消。
+ *
+ * 这些是**预期的取消**,不是故障。但它们会走 Zone 的错误通道,
+ * 被 Angular 当成未捕获异常打印成一整屏堆栈(用户实测:退出重登后刷屏),
+ * 反而把真正需要关注的错误淹没了。
+ *
+ * ## 只过滤什么
+ *
+ * 同时满足两个条件才静音:
+ *   ① 名字是 `AbortError`;
+ *   ② 消息包含 `aborted`。
+ *
+ * 其它 AbortError(例如自定义逻辑主动 abort 但确实属于 bug)仍会打印。
+ */
+@Injectable()
+export class AppErrorHandler implements ErrorHandler {
+  handleError(error: unknown): void {
+    if (isExpectedAbort(error)) return;
+    // 其余错误保持 Angular 默认行为(控制台可见),不做任何吞没
+    console.error(error);
+  }
+}
+
+/** 是否为「预期内的请求取消」 */
+function isExpectedAbort(error: unknown): boolean {
+  if (!error || typeof error !== 'object') return false;
+  const e = error as { name?: unknown; message?: unknown };
+  return e.name === 'AbortError' && String(e.message ?? '').includes('aborted');
+}

+ 218 - 93
src/app/core/chat-session.service.spec.ts

@@ -1,14 +1,36 @@
 import { TestBed } from '@angular/core/testing';
 import { ChatSessionService } from './chat-session.service';
+import { FmodeApi } from './api/fmode-api';
 import { SessionService } from './session.service';
 import { legacySessionId } from './chat-session';
 
 /**
- * ChatSessionService —— 会话元信息的唯一 owner。
+ * ChatSessionService —— 会话目录的唯一 owner。
  *
- * 覆盖:新建 / 复用 / 归档 / 重命名 / 丢弃护栏 / 历史会话承接 / 持久化。
- * 这些是「多会话」的数据底座,错一个字段用户就会看到串号的对话名。
+ * 覆盖:新建 / 归档 / 重命名 / 移除黑名单 / 云端单向同步 / 持久化。
+ * 这些是「多会话」的数据底座,错一个字段用户就会看到串号的对话名。
  */
+
+/**
+ * 以「带 FmodeApi 桩」的方式构造服务实例。
+ *
+ * 文件级声明:多个 describe 都要用它构造独立实例,
+ * 每次重建都拿到干净的 signal 状态(`resetTestingModule`)。
+ */
+function withApiStub(api: Record<string, unknown>): ChatSessionService {
+  TestBed.resetTestingModule();
+  TestBed.configureTestingModule({
+    providers: [
+      ChatSessionService,
+      { provide: SessionService, useValue: { me: () => ({ id: 'u1' }) } },
+      { provide: FmodeApi, useValue: api },
+    ],
+  });
+  const s2 = TestBed.inject(ChatSessionService);
+  s2.load();
+  return s2;
+}
+
 describe('ChatSessionService', () => {
   let svc: ChatSessionService;
 
@@ -38,18 +60,11 @@ describe('ChatSessionService', () => {
     expect(s.agentId).toBe(AGENT);
   });
 
-  it('ensureFor 首次返回新建会话,再次调用复用同一条', () => {
-    const a = svc.ensureFor(ROOM, AGENT);
-    const b = svc.ensureFor(ROOM, AGENT);
-    expect(b.id).toBe(a.id);
-    expect(svc.listFor(AGENT).length).toBe(1);
-  });
-
-  it('ensureFor 在房间变化时更新 roomId(换号/重建房间)', () => {
-    const a = svc.ensureFor('!old:fmode.cn', AGENT);
-    const b = svc.ensureFor('!new:fmode.cn', AGENT);
-    expect(b.id).toBe(a.id);
-    expect(b.roomId).toBe('!new:fmode.cn');
+  it('create 生成独立线程根、同容器可并存多条', () => {
+    const a = svc.create(ROOM, AGENT);
+    const b = svc.create(ROOM, AGENT);
+    expect(b.id).not.toBe(a.id);
+    expect(svc.listFor(AGENT).length).toBe(2);
   });
 
   it('为什么服务端认为是同一个 agent 的不同容器 —— 会话互不可见', () => {
@@ -97,25 +112,6 @@ describe('ChatSessionService', () => {
     expect(svc.byId(s.id)!.title).toBe('做个登录页');
   });
 
-  describe('discard 护栏(只清空壳)', () => {
-    it('从未有消息的会话可以移除', () => {
-      const s = svc.create(ROOM, AGENT);
-      expect(svc.discard(s.id)).toBeTrue();
-      expect(svc.byId(s.id)).toBeUndefined();
-    });
-
-    it('🔴 有消息的会话拒绝移除(用户数据不可丢)', () => {
-      const s = svc.create(ROOM, AGENT);
-      svc.noteMessage(s.id, { text: '有事', ts: 1, fromMe: true });
-      expect(svc.discard(s.id)).toBeFalse();
-      expect(svc.byId(s.id)).toBeDefined();
-    });
-
-    it('不存在的 id 返回 false', () => {
-      expect(svc.discard('$nope')).toBeFalse();
-    });
-  });
-
 
   describe('remove(用户主动移除)', () => {
     it('移除后从列表消失', () => {
@@ -124,18 +120,25 @@ describe('ChatSessionService', () => {
       expect(svc.byId(s.id)).toBeUndefined();
     });
 
-    // 🔴 删了立刻被扫回来 —— 这是最容易被忽略的坑
-    it('🔴 移除的线程不会被扫描重建回来', () => {
-      const sid = '$app-root:fmode.cn';
-      svc.adopt({ id: sid, roomId: ROOM, agentId: AGENT });
-      svc.remove(sid);
-      expect(svc.byId(sid)).toBeUndefined();
-
-      // 扫描把同一个线程根报上来
-      svc.mergeScanned(ROOM, AGENT, [
-        { threadId: sid, title: '被移除的对话', messageCount: 5, lastTs: 100, firstTs: 1 },
-      ]);
-      expect(svc.byId(sid)).withContext('被移除的线程又回来了').toBeUndefined();
+    it('🔴 移除的线程不会被下一次云端拉取复活', async () => {
+      const svc2 = withApiStub({
+        fetchChatThreads: async (_a: string, o?: { includeDeleted?: boolean }) =>
+          o?.includeDeleted
+            ? []
+            : [
+                {
+                  objectId: 'x', agentId: AGENT, roomId: ROOM, threadRoot: '$gone',
+                  title: '被移除的对话', titleLocked: false, messageCount: 5,
+                  lastMessageAt: 100, archived: false, lastPreview: '',
+                },
+              ],
+        upsertChatThreads: async () => undefined,
+        removeChatThread: async () => undefined,
+      });
+      svc2.adopt({ id: '$gone', roomId: ROOM, agentId: AGENT });
+      svc2.remove('$gone');
+      await svc2.hydrateFromCloud(AGENT);
+      expect(svc2.byId('$gone')).withContext('被移除的线程又回来了').toBeUndefined();
     });
 
     it('isRemoved 能查到移除记录', () => {
@@ -154,44 +157,6 @@ describe('ChatSessionService', () => {
     });
   });
 
-  describe('pruneEmpty(收敛空壳会话)', () => {
-    it('清掉其它空会话,保留指定的那条', () => {
-      const keep = svc.create(ROOM, AGENT);
-      svc.create(ROOM, AGENT);
-      svc.create(ROOM, AGENT);
-      svc.pruneEmpty(AGENT, keep.id);
-      const ids = svc.listFor(AGENT).map((s) => s.id);
-      expect(ids).toEqual([keep.id]);
-    });
-
-    it('有消息的会话不被清理', () => {
-      const keep = svc.create(ROOM, AGENT);
-      const used = svc.create(ROOM, AGENT);
-      svc.noteMessage(used.id, { text: '内容', ts: 1, fromMe: true });
-      svc.pruneEmpty(AGENT, keep.id);
-      expect(svc.byId(used.id)).toBeDefined();
-    });
-
-    /*
-     * 🔴 行为变更(2026-10-29):清理范围扩到**所有容器**。
-     *
-     * 原实现只清传进来的 agentId —— 「切到 A 时只清 A 的空会话,
-     * B 那边的空壳永远留着」。实测:会话列表积了多条 0 消息的「新对话」。
-     */
-    it('空壳会话跨容器一并清理', () => {
-      svc.create(ROOM, 'agent-b');
-      svc.pruneEmpty(AGENT, undefined);
-      expect(svc.listFor('agent-b').length).toBe(0);
-    });
-
-    it('其它容器里**有消息**的会话不受影响', () => {
-      const b = svc.create(ROOM, 'agent-b');
-      svc.noteMessage(b.id, { text: '有内容', ts: 1, fromMe: true });
-      svc.pruneEmpty(AGENT, undefined);
-      expect(svc.listFor('agent-b').length).toBe(1);
-    });
-  });
-
   describe('历史对话(承接无 thread 旧消息)', () => {
     it('首次创建返回 created=true', () => {
       const r = svc.adoptLegacy(ROOM, AGENT, '旧消息预览');
@@ -209,21 +174,15 @@ describe('ChatSessionService', () => {
       expect(svc.listFor(AGENT).length).toBe(1);
     });
 
-    it('历史会话不参与 pruneEmpty', () => {
-      const legacy = svc.adoptLegacy(ROOM, AGENT).session;
-      svc.pruneEmpty(AGENT, 'some-other');
-      expect(svc.byId(legacy.id)).withContext('历史会话被误清').toBeDefined();
-    });
-
     it('不同房间的历史会话 id 不同', () => {
       expect(legacySessionId('!a:x')).not.toBe(legacySessionId('!b:x'));
     });
   });
 
   describe('持久化', () => {
-    it('新建后写入 localStorage', () => {
+    it('新建后写入 localStorage(v2 缓存键,按用户隔离)', () => {
       const s = svc.create(ROOM, AGENT);
-      const raw = localStorage.getItem('fmode_chat_sessions_u1');
+      const raw = localStorage.getItem('fmode_chat_sessions_v2_u1');
       expect(raw).withContext('未按用户隔离存储').toBeTruthy();
       expect(JSON.parse(raw!)[0].id).toBe(s.id);
     });
@@ -261,7 +220,7 @@ describe('ChatSessionService', () => {
 
     it('缺字段的脏数据被过滤', () => {
       localStorage.setItem(
-        'fmode_chat_sessions_u1',
+        'fmode_chat_sessions_v2_u1',
         JSON.stringify([{ id: '$x' }, { id: '$ok', roomId: '!r:x', agentId: 'a', title: 't', createdAt: 1, updatedAt: 1 }]),
       );
       svc.reset();
@@ -282,4 +241,170 @@ describe('ChatSessionService', () => {
       expect(ids.size).toBe(2);
     });
   });
+
+  describe('云端会话目录(2026-11-01 单向同步)', () => {
+    const ROOM = '!room1:fmode.cn';
+    const AGENT = 'agent-a';
+
+    beforeEach(() => localStorage.clear());
+
+    /** 以「带 FmodeApi 桩」的方式重建服务(内部用;原 `withApi` 已改为文件级 `withApiStub`) */
+    function withApi(api: Record<string, unknown>): ChatSessionService {
+      return withApiStub(api);
+    }
+
+    it('云端有新会话、本地为空 → 重建到本地(换浏览器场景)', async () => {
+      const svc2 = withApi({
+        fetchChatThreads: async (_a: string, o?: { includeDeleted?: boolean }) =>
+          o?.includeDeleted
+            ? []
+            : [
+          {
+            objectId: 'x1', agentId: AGENT, roomId: ROOM, threadRoot: '$cloud1',
+            title: '云端会话', titleLocked: false, messageCount: 3,
+            lastMessageAt: 1000, archived: false, lastPreview: 'hi',
+          },
+              ],
+        upsertChatThreads: async () => undefined,
+      });
+      await svc2.hydrateFromCloud(AGENT);
+      expect(svc2.byId('$cloud1')?.title).toBe('云端会话');
+      expect(svc2.byId('$cloud1')?.messageCount).toBe(3);
+    });
+
+    it('已移除的线程不会被云端复活', async () => {
+      const svc2 = withApi({
+        fetchChatThreads: async (_a: string, o?: { includeDeleted?: boolean }) =>
+          o?.includeDeleted
+            ? []
+            : [
+          { objectId: 'x', agentId: AGENT, roomId: ROOM, threadRoot: '$gone', title: 'a', titleLocked: false, messageCount: 1, lastMessageAt: 1, archived: false, lastPreview: '' },
+              ],
+        upsertChatThreads: async () => undefined,
+        removeChatThread: async () => undefined,
+      });
+      const s0 = svc2.create(ROOM, AGENT);
+      svc2.remove(s0.id);
+      await svc2.hydrateFromCloud(AGENT);
+      expect(svc2.byId(s0.id)).toBeUndefined();
+    });
+
+    /*
+     * 🔴 行为变更(2026-11-01):单向同步下**云端标题才是权威**。
+     *
+     * 旧断言「本地锁定标题不被云端覆盖」在合并式同步下成立,但正是它允许了
+     * 「本地与云端各说各话」——改名推云端失败后,本地会永远保留一个云端不知道的标题。
+     * 现在:改名会立刻 `patchChatThread` 推云端,云端成功后自然以云端为准;
+     * `titleLocked` 只用于防止「首条消息自动命名」覆盖用户手动改过的名字。
+     */
+    it('改名成功后标题以云端为准', async () => {
+      let remoteTitle = '云端标题';
+      const svc2 = withApi({
+        fetchChatThreads: async (_a: string, o?: { includeDeleted?: boolean }) =>
+          o?.includeDeleted
+            ? []
+            : [
+                { objectId: 'x', agentId: AGENT, roomId: ROOM, threadRoot: '$t', title: remoteTitle, titleLocked: true, messageCount: 1, lastMessageAt: 5, archived: false, lastPreview: '' },
+              ],
+        upsertChatThreads: async () => undefined,
+        patchChatThread: async (i: { title?: string }) => {
+          if (i.title) remoteTitle = i.title;
+        },
+      });
+      svc2.adopt({ id: '$t', roomId: ROOM, agentId: AGENT });
+      svc2.rename('$t', '我改的标题');
+      await svc2.hydrateFromCloud(AGENT);
+      expect(svc2.byId('$t')?.title).withContext('云端已更新,应展示新标题').toBe('我改的标题');
+    });
+
+    it('归档状态以云端为准', async () => {
+      const svc2 = withApi({
+        fetchChatThreads: async (_a: string, o?: { includeDeleted?: boolean }) =>
+          o?.includeDeleted
+            ? []
+            : [
+          { objectId: 'x', agentId: AGENT, roomId: ROOM, threadRoot: '$a', title: 't', titleLocked: false, messageCount: 1, lastMessageAt: 9, archived: true, lastPreview: '' },
+              ],
+        upsertChatThreads: async () => undefined,
+      });
+      svc2.adopt({ id: '$a', roomId: ROOM, agentId: AGENT });
+      await svc2.hydrateFromCloud(AGENT);
+      expect(svc2.byId('$a')?.archived).toBeTrue();
+    });
+
+    it('🔴 云端明确为空 → 本机同容器会话一并清掉(删除后不再复活)', async () => {
+      const svc2 = withApi({
+        fetchChatThreads: async (_a: string, o?: { includeDeleted?: boolean }) => (o?.includeDeleted ? [] : []),
+        upsertChatThreads: async () => undefined,
+      });
+      svc2.create(ROOM, AGENT);
+      await svc2.hydrateFromCloud(AGENT);
+      expect(svc2.listFor(AGENT, { includeArchived: true }).length).toBe(0);
+    });
+
+    it('云端明确为空时 pushAll 不得把本地会话推回去', async () => {
+      const upsert = jasmine.createSpy('upsert').and.returnValue(Promise.resolve());
+      const svc2 = withApi({ fetchChatThreads: async (_a: string, o?: { includeDeleted?: boolean }) => (o?.includeDeleted ? [] : []), upsertChatThreads: upsert });
+      svc2.create(ROOM, AGENT);           // create 自身会推一次(正常行为)
+      await svc2.hydrateFromCloud(AGENT); // 云端为空 → 本地清空
+      const callsAfterCreate = upsert.calls.count();
+      await svc2.pushAll(AGENT);          // 清空后不应再推
+      expect(upsert.calls.count()).toBe(callsAfterCreate);
+    });
+
+    /*
+     * 🔴 行为变更(2026-11-01 用户裁决):云端不可达时**列表不可信**。
+     *
+     * 旧行为「保留本地缓存照常展示」看着更"稳",实际是两端互相覆盖的根源:
+     * 用户看到的可能是几小时前的列表,在上面做的删除/归档又会回写云端。
+     */
+    it('🔴 云端不可达 → ready=false,界面据此显示「加载失败」', async () => {
+      const svc2 = withApi({
+        fetchChatThreads: async () => { throw new Error('network'); },
+        upsertChatThreads: async () => { throw new Error('network'); },
+      });
+      await svc2.hydrateFromCloud(AGENT);
+      expect(svc2.ready()).toBeFalse();
+      expect(svc2.error()).toBe('network');
+    });
+
+    it('云端不可达时 pushAll 不推送(列表本身就不可信)', async () => {
+      const upsert = jasmine.createSpy('upsert').and.returnValue(Promise.resolve());
+      const svc2 = withApi({
+        fetchChatThreads: async () => { throw new Error('network'); },
+        upsertChatThreads: upsert,
+      });
+      svc2.create(ROOM, AGENT);
+      const before = upsert.calls.count();
+      await svc2.hydrateFromCloud(AGENT);
+      await svc2.pushAll(AGENT);
+      expect(upsert.calls.count()).withContext('云端不可达时不应回写').toBe(before);
+    });
+
+    it('云端成功返回后 ready=true', async () => {
+      const svc2 = withApi({
+        fetchChatThreads: async (_a: string, o?: { includeDeleted?: boolean }) => (o?.includeDeleted ? [] : []),
+        upsertChatThreads: async () => undefined,
+      });
+      expect(svc2.ready()).toBeFalse();
+      await svc2.hydrateFromCloud(AGENT);
+      expect(svc2.ready()).toBeTrue();
+    });
+
+    it('🔴 云端删掉的会话,本地下一次拉取即消失', async () => {
+      let live = [
+        { objectId: 'a', agentId: AGENT, roomId: ROOM, threadRoot: '$a', title: 'A', titleLocked: false, messageCount: 1, lastMessageAt: 1, archived: false, lastPreview: '' },
+        { objectId: 'b', agentId: AGENT, roomId: ROOM, threadRoot: '$b', title: 'B', titleLocked: false, messageCount: 1, lastMessageAt: 2, archived: false, lastPreview: '' },
+      ];
+      const svc2 = withApi({
+        fetchChatThreads: async (_a: string, o?: { includeDeleted?: boolean }) => (o?.includeDeleted ? [] : live),
+        upsertChatThreads: async () => undefined,
+      });
+      await svc2.hydrateFromCloud(AGENT);
+      expect(svc2.listFor(AGENT).length).toBe(2);
+      live = [live[0]];
+      await svc2.hydrateFromCloud(AGENT);
+      expect(svc2.listFor(AGENT).map((s) => s.id)).withContext('云端没有的应被移除').toEqual(['$a']);
+    });
+  });
 });

+ 275 - 138
src/app/core/chat-session.service.ts

@@ -1,5 +1,7 @@
 import { computed, inject, Injectable, signal } from '@angular/core';
 import { SessionService } from './session.service';
+import { FmodeApi } from './api/fmode-api';
+import { ChatThreadRecord, ChatThreadUpsert } from './api/models';
 import {
   ChatSession,
   createSession,
@@ -8,34 +10,44 @@ import {
   sortSessions,
   touchSession,
 } from './chat-session';
-import { mergeScanned, ScannedSession } from './session-scan';
 
 /**
- * 会话(多线程)服务 —— App 侧会话的**唯一 owner**
+ * 会话(多线程)服务 —— App 侧会话目录的**唯一 owner**
  *
  * ## 职责
  *
- * 维护「线程根 event_id → 会话元信息」的映射,并提供新建 / 重命名 / 归档。
- * 消息内容本身仍由 Matrix 房间承载,**本服务只存元信息**。
+ * 维护「线程根 event_id → 会话元信息」的映射,并提供新建 / 重命名 / 归档 / 删除。
+ * 消息内容本身仍由 Matrix 房间承载,**本服务只存元信息**。
  *
- * ## 存储
+ * ## 存储(2026-11-01 定稿)
  *
- * localStorage,按用户隔离(`fmode_chat_sessions_<userId>`)——
- * 与 `ChatService` 的 `roomAgentMap`、`SessionService` 的 token 同一范式。
- * localStorage 不可用(隐私模式)时**静默降级为内存态**,不阻断功能。
+ * | 层 | 角色 |
+ * |---|---|
+ * | 云端 `FmodeChatThread` | **唯一真相源** |
+ * | localStorage(`fmode_chat_sessions_v2_<userId>`) | 渲染缓存 + 删除黑名单;云端不可达时**不展示** |
  *
- * ## 为什么存在本地而不是云端
+ * 同步方向是**单向**的:云端 → 本地(`hydrateFromCloud`),
+ * 写入操作再单独回写云端。**不存在「本地有、云端没有 → 保留本地」的分支** ——
+ * 那正是「删掉的会话又回来 / 云端被灌满 / 同一会话多条」的根因。
  *
- * 会话元信息是**展示层数据**(标题/归档/排序),不是业务事实。
- * 容器侧只有 thread 隔离能力、没有会话名字槽位
- * (见 `docs/03-interface/容器会话与线程机制-20261028.md`)。
- * 发布前如需跨设备同步,再迁移到云函数表 —— 届时替换本服务的持久化两处即可。
+ * ## 为什么正文不进平台库
+ *
+ * 会话正文(含附件)全部在 Matrix 房间里,平台库只存「哪些线程存在、叫什么名字」。
+ * 平台库额外存的只有 `lastPreview`(列表用的一行摘要,可丢)。
+ * 把正文搬进平台库等于重造一遍 Matrix,收发链路、附件、实时性全要另写一套。
  *
  * @see chat-session.ts 纯函数层(可单测)
  */
 @Injectable({ providedIn: 'root' })
 export class ChatSessionService {
   private readonly session = inject(SessionService);
+  /**
+   * 数据访问唯一闸门(云端会话目录读写;失败静默,不影响本地)。
+   *
+   * 🔴 用 `inject(..., { optional: true })`:本服务在单测/纯本地场景下不注入
+   *    FmodeApi 也应可用(云端同步是可选的增强,不是必需依赖)。
+   */
+  private readonly api = inject(FmodeApi, { optional: true });
 
   private readonly _all = signal<ChatSession[]>([]);
   readonly all = this._all.asReadonly();
@@ -63,6 +75,30 @@ export class ChatSessionService {
 
   private loadedFor: string | null = null;
 
+  /**
+   * 会话目录是否可信(2026-11-01 用户裁决)。
+   *
+   * 🔴 **`false` 时列表一律为空**,宁可显示「加载失败,重试」也不展示本地缓存。
+   *
+   * 为什么改掉「离线展示缓存」:
+   *   缓存与云端没有可靠的全序(谁更新说不清),一边展示旧缓存、一边又把本地
+   *   回写云端,两端会互相覆盖 —— 这是「删掉的会话又回来 / 云端为空却被灌满」
+   *   反复出现的根源。现在只有一条规则:**云端成功返回后列表才可信**。
+   *
+   * 初始值 `false`:登录后必须先成功拉一次云端,列表才可能出现内容。
+   */
+  private readonly _ready = signal(false);
+  readonly ready = this._ready.asReadonly();
+
+  /** 最近一次云端拉取的失败原因(`null` = 正常);界面据此给出重试入口 */
+  private readonly _error = signal<string | null>(null);
+  readonly error = this._error.asReadonly();
+
+  /** 上次云端拉取失败的原因(`null` = 正常);供调用方判断能否继续 */
+  isCloudStale(): boolean {
+    return !this._ready();
+  }
+
   /**
    * 用户主动移除过的会话 id。
    *
@@ -71,6 +107,14 @@ export class ChatSessionService {
    */
   private readonly _removed = signal<Set<string>>(new Set());
 
+  /**
+   * 云端已软删的线程根(跨设备有效)。
+   * 与本地 `_removed` 一起构成「哪些线程不允许重新出现」的黑名单。
+   * 注意:目录已不再从 Matrix 扫描重建,它现在的用途是兜住
+   * 「云端列表与墓碑不一致」的历史数据。
+   */
+  private cloudRemoved = new Set<string>();
+
   /**
    * 从 localStorage 载入(Shell 启动时调用一次;重复调用幂等)。
    *
@@ -115,10 +159,187 @@ export class ChatSessionService {
     }
   }
 
-  /** 清空内存态(登出时调用) */
+  /**
+   * 清空内存态(登出时调用)。
+   *
+   * 🔴 `_ready` 必须一起复位:否则登出再登录后,列表会凭「上一次登录的成功状态」
+   *    直接被视为可信,在新一轮云端拉取完成前就渲染出(可能属于别人的)内容。
+   */
   reset(): void {
     this.loadedFor = null;
     this._all.set([]);
+    this._ready.set(false);
+    this._error.set(null);
+    this.cloudRemoved = new Set();
+    this._removed.set(new Set());
+  }
+
+  // ================= 云端同步(2026-11-01 重写:单向 云端 → 本地) =================
+
+  /**
+   * 从云端拉取会话目录(**唯一真相源**,本地只是渲染缓存)。
+   *
+   * ## 唯一规则
+   *
+   * **云端成功返回 → 本地该容器的会话直接等于云端返回的集合**;
+   * 云端失败 → 列表清空并标记不可用(`ready = false`),界面显示「加载失败,重试」,
+   * **绝不展示旧缓存、也绝不把本地数据反推回云端**。
+   *
+   * 为什么不再做「合并」:
+   *   线上反复出现「删掉的会话又回来 / 云端为空却被灌满 / 同一会话七八条」,
+   *   根因都是两端各写一半,再用「只补不覆盖」之类的规则去猜谁对。
+   *   只要允许本地影响云端,就必须维护一套合并规则,而合并规则本身会出错。
+   *   改成单向同步后,冲突面直接归零。
+   *
+   * @returns 本次新增的会话条数(调用方一般不关心)
+   */
+  async hydrateFromCloud(agentId: string): Promise<number> {
+    /*
+     * 🔴 先确保本地缓存已载入:生产由页面在进入路由时调用 load(),
+     *    但云端恢复不应依赖调用顺序(测试/深链进入都可能没调过)。
+     *    load() 幂等,重复调用无副作用。
+     */
+    this.load();
+    const key = this.key();
+    const api = this.api;
+    if (!key || this.loadedFor !== key || !api) return 0;
+
+    let live: ChatThreadRecord[];
+    let deleted: ChatThreadRecord[];
+    try {
+      /*
+       * 同时拉两份:活跃会话 + 已软删会话。
+       * 软删那份只用于挡住「同一条线程被重新写回来」(墓碑跨设备有效)。
+       */
+      [live, deleted] = await Promise.all([
+        api.fetchChatThreads(agentId),
+        api.fetchChatThreads(agentId, { includeDeleted: true }).catch(() => [] as ChatThreadRecord[]),
+      ]);
+    } catch (e) {
+      /*
+       * 云端不可达 → **列表不可信**(用户裁决:宁可不显示,也不显示可能过期的缓存)。
+       * 内存里留着旧内容没有意义 —— 界面依据 `ready()` 直接渲染「加载失败」。
+       */
+      this._ready.set(false);
+      this._error.set(e instanceof Error && e.message ? e.message : '会话目录加载失败');
+      return 0;
+    }
+
+    this.cloudRemoved = new Set(deleted.map((d) => d.threadRoot));
+    const prev = new Map(
+      this._all()
+        .filter((s) => s.agentId === agentId)
+        .map((s) => [s.id, s]),
+    );
+
+    /*
+     * 云端列表 → 本地会话。逐条以云端为准,本地只提供两个**可丢失**的字段:
+     *   · `lastPreview`:云端为空时用本地兜底(纯展示,不参与任何判定)
+     *   · `titleLocked`:用户改过名的标记(改名会立刻推云端,正常情况下两边一致)
+     */
+    const next: ChatSession[] = [];
+    for (const r of live) {
+      // 墓碑挡住:用户明确删过这条线程,即使云端还挂着也不显示
+      if (this._removed().has(r.threadRoot)) continue;
+      const local = prev.get(r.threadRoot);
+      next.push({
+        id: r.threadRoot,
+        roomId: r.roomId,
+        agentId: r.agentId || agentId,
+        title: r.title || '新对话',
+        titleLocked: r.titleLocked || local?.titleLocked,
+        createdAt: local?.createdAt ?? (r.lastMessageAt || Date.now()),
+        updatedAt: Math.max(r.lastMessageAt || 0, local?.updatedAt ?? 0),
+        archived: r.archived,
+        messageCount: r.messageCount,
+        lastPreview: r.lastPreview || local?.lastPreview,
+        workspaceId: local?.workspaceId ?? null,
+      });
+    }
+
+    /*
+     * 用「该容器」的云端结果**整体替换**本地该容器的条目,其它容器不动。
+     * 这里没有任何「本地有、云端没有 → 保留」的分支 —— 那正是脏数据来源。
+     */
+    const others = this._all().filter((s) => s.agentId !== agentId);
+    this._all.set(sortSessions([...others, ...next]));
+    this.persist();
+    this._ready.set(true);
+    this._error.set(null);
+
+    const before = new Set(prev.keys());
+    let added = 0;
+    for (const s of next) if (!before.has(s.id)) added++;
+    return added;
+  }
+
+  /**
+   * 把本地会话(该 agent 下)补推到云端。
+   *
+   * 正常路径下每次变更(新建 / 改名 / 归档 / 计数)都已单独推送,
+   * 这里只在云端刚恢复可用时兜底补齐「推送失败留下的缺口」。
+   *
+   * 🔴 云端不可用(`ready=false`)时不推送 —— 此时列表根本不可信,
+   *    写上去就等于把本地残留灌进云端(历史上发生过)。
+   */
+  async pushAll(agentId: string): Promise<void> {
+    if (!this.api || !this._ready()) return;
+    const list = this._all().filter((s) => s.agentId === agentId && !s.legacy);
+    if (list.length === 0) return;
+    await this.trySync(() => this.api!.upsertChatThreads(list.map(toUpsert)));
+  }
+
+  /**
+   * 统一的「云端失败不影响本地」包装。
+   *
+   * 🔴 只吞异常、不改本地状态:写入失败时本地照常可用,下次 `hydrateFromCloud`
+   *    会以云端为准把差异抹平。**这里不允许出现「失败就改本地」的补偿逻辑** ——
+   *    那正是两端互相覆盖的来源。
+   */
+  private async trySync(fn: () => Promise<void>): Promise<void> {
+    try {
+      await fn();
+    } catch {
+      /* 离线 / 未开通:本地继续可用,下次拉取时以云端为准 */
+    }
+  }
+
+  /** 单条推送到云端(新建 / 变更后调用) */
+  private pushOne(s: ChatSession | undefined): void {
+    if (!s || s.legacy) return;
+    void this.trySync(() => this.api!.upsertChatThreads([toUpsert(s)]));
+  }
+
+  /**
+   * 拉取**所有容器**的会话目录(左栏在没进过任何容器时也要能列出对话)。
+   *
+   * 逐容器并发拉取;任意一个失败就整体视为失败(`ready=false`)——
+   * 只显示拉成功的部分会造成「有的对话突然不见了」,比整体重试更让人困惑。
+   *
+   * 并发度做了限制(每批 5 个),避免容器多时一次打满平台接口。
+   */
+  async reloadAll(agentIds: string[]): Promise<void> {
+    if (!this.api) return;
+    const list = [...new Set(agentIds)].filter(Boolean);
+    if (list.length === 0) return;
+    let failed = 0;
+    let lastErr: unknown = null;
+    for (let i = 0; i < list.length; i += 5) {
+      const batch = list.slice(i, i + 5);
+      const results = await Promise.allSettled(batch.map((a) => this.hydrateFromCloud(a)));
+      for (const r of results) {
+        if (r.status === 'rejected') {
+          failed++;
+          lastErr = r.reason;
+        }
+      }
+    }
+    if (failed > 0) {
+      this._ready.set(false);
+      this._error.set(
+        lastErr instanceof Error && lastErr.message ? lastErr.message : '会话目录加载失败',
+      );
+    }
   }
 
   /** 某 agent 下的会话(默认不含归档) */
@@ -147,29 +368,10 @@ export class ChatSessionService {
     const s = createSession({ roomId, agentId, title });
     this._all.update((list) => sortSessions([s, ...list]));
     this.persist();
+    this.pushOne(s);
     return s;
   }
 
-  /**
-   * 确保某 agent 至少有一个会话,返回「应该打开的那个」。
-   *
-   * 进入 `/chat/:agentId` 且未指定会话时:
-   * - 已有会话 → 打开最近的(`ensureFor` 由 `ChatService` 在补建历史会话后再调用,
-   *   所以「历史对话」若存在,通常就是最近更新那条)
-   * - 都没有 → 新建一个(首次与某 agent 对话时自动建)
-   */
-  ensureFor(roomId: string, agentId: string): ChatSession {
-    const pick = this.latestFor(agentId);
-    if (pick) {
-      // 房间可能变化(换号/重建房间),以最新房间为准
-      if (pick.roomId !== roomId) {
-        return this.update(pick.id, { roomId }) ?? pick;
-      }
-      return pick;
-    }
-    return this.create(roomId, agentId);
-  }
-
   /**
    * 让「历史遗留会话」存在于列表中(幂等)。
    *
@@ -235,60 +437,14 @@ export class ChatSessionService {
     const t = title.replace(/\s+/g, ' ').trim();
     if (!t) return;
     this.update(id, { title: t, titleLocked: true });
+    void this.trySync(() => this.api!.patchChatThread({ threadRoot: id, title: t }));
   }
 
-  /**
-   * 把**扫描重建**的会话合并进来(Matrix 兜底能力)。
-   *
-   * 用于:容器没有上报会话目录时,直接从房间时间线还原「有哪些对话」。
-   * 规则见 `session-scan.ts` 的 `mergeScanned` —— **已有会话的标题绝不覆盖**
-   * (容器上报的语义标题比首句截断好得多)。
-   *
-   * @returns 本次新增了几条
-   */
-  mergeScanned(
-    roomId: string,
-    agentId: string,
-    scanned: ScannedSession[],
-  ): number {
-    const current = this._all().filter((s) => s.agentId === agentId);
-    // 跳过用户主动移除过的线程(否则删了立刻被扫回来)
-    const kept = scanned.filter((x) => !x.threadId || !this._removed().has(x.threadId));
-    const { toCreate, patches } = mergeScanned(current, kept);
-
-    /*
-     * 🔴 **只在真正有变化时才写 signal。**
-     *
-     * 本方法会在 `storeVersion` effect 里被调用(每次 /sync 落地都会触发)。
-     * 若无条件 `_all.update(...)`,即使内容一模一样也会产生新引用 →
-     * effect 依赖的 signal 变化 → effect 再跑 → **无限循环,页面直接崩溃**
-     * (冷启动实测:`Target crashed`)。
-     *
-     * 所以先算出「实际会变多少」,为 0 就一个字段都不碰。
-     */
-    const changedPatches = patches.filter((p) => {
-      const cur = current.find((s) => s.id === p.id);
-      if (!cur) return false;
-      return (
-        (cur.messageCount ?? 0) !== p.messageCount || cur.updatedAt !== p.updatedAt
-      );
-    });
-    if (toCreate.length === 0 && changedPatches.length === 0) return 0;
-
-    if (toCreate.length > 0) {
-      const created = toCreate.map((s) => ({ ...s, roomId, agentId }));
-      this._all.update((list) => sortSessions([...created, ...list]));
-    }
-    for (const p of changedPatches) {
-      this.update(p.id, { messageCount: p.messageCount, updatedAt: p.updatedAt });
-    }
-    this.persist();
-    return toCreate.length;
-  }
 
-  /** 归档 / 取消归档(软删,不物理删除) */
+  /** 归档 / 取消归档(软删,不物理删除);同步到云端 */
   archive(id: string, archived = true): void {
     this.update(id, { archived });
+    void this.trySync(() => this.api!.patchChatThread({ threadRoot: id, archived }));
   }
 
   /**
@@ -309,77 +465,36 @@ export class ChatSessionService {
   /**
    * 从列表移除一条会话(**只移除索引,Matrix 里的消息不动**)。
    *
-   * 与 `discard` 的区别:`discard` 是内部清理(只清空壳,有护栏);
-   * 这里是**用户主动移除**,允许移除有消息的会话。
-   *
    * 安全性:会话列表只是「有哪些对话」的索引,消息本体在 Matrix 房间里。
    * 移除索引后对话从列表消失,但消息**没有被删除** ——
    * 文案必须说清楚这一点,否则用户会以为内容丢了。
    */
   remove(id: string): void {
     /*
-     * 🔴 必须记进「已移除」名单 —— 否则**扫描会把它重建回来**。
+     * 🔴 必须记进「已移除」名单 —— 否则下一次从云端拉取时它会重新出现。
      *
-     * 会话目录的真相源是 Matrix 线程根(`mergeScanned` 会从房间时间线还原)。
-     * 只删本地索引的话,下次打开容器一扫描,这条又回来了(实测:删了没效果)。
+     * 会话目录的真相源是云端表。只删本地索引的话,下次 `hydrateFromCloud`
+     * 拉到的还是同一批线程,这条又回来了(实测:删了没效果)。
+     * 云端删除失败时,本地黑名单是最后一道兜底。
      */
+    const cur = this.byId(id); // 删除前取,删除后取不到 roomId/agentId
     this._removed.update((set) => new Set(set).add(id));
     this._all.update((list) => list.filter((x) => x.id !== id));
     this.persist();
+    /*
+     * 云端删除索引:物理删除记录 + 留一条墓碑阻止扫描重建。
+     * Matrix 房间里的消息**不受影响**(会话正文仍在服务器)。
+     */
+    void this.trySync(() =>
+      this.api!.removeChatThread({ threadRoot: id, roomId: cur?.roomId, agentId: cur?.agentId }),
+    );
   }
 
-  /** 该线程是否被用户移除过(扫描重建时跳过) */
+  /** 该线程是否被用户移除过(拉取云端时据此跳过) */
   isRemoved(id: string): boolean {
     return this._removed().has(id);
   }
 
-  /**
-   * 物理移除一个**从未收到过消息**的会话。
-   *
-   * 只用于清理系统自动创建后又被证明无用的空会话(如首开时 sync 未回、
-   * 建了空的新对话,随后历史对话才出现)。用户可见的会话一律走 `archive`,
-   * 不做物理删除 —— 这里加护栏,有消息的会话拒绝移除。
-   *
-   * @returns 是否真的移除了
-   */
-  discard(id: string): boolean {
-    const s = this.byId(id);
-    if (!s || (s.messageCount ?? 0) > 0) return false;
-    this._all.update((list) => list.filter((x) => x.id !== id));
-    this.persist();
-    return true;
-  }
-
-  /**
-   * 清理某 agent 下的**空会话**(`messageCount === 0`),保留 `keepId`。
-   *
-   * 用于打开某容器时收敛列表:用户真正在用的那条留下,
-   * 之前反复进出产生的空壳清掉。历史会话不参与清理 ——
-   * 它的消息来自 Matrix timeline,计数由同步推进,不是「空壳」。
-   */
-  pruneEmpty(agentId: string | null, keepId?: string): void {
-    /*
-     * 🔴 **清理范围是所有容器,不只当前这个**。
-     *
-     * 原先只清传进来的 agentId —— 结果「切到 A 时只清 A 的空会话,
-     * B 那边的空壳永远留着」。实测:会话列表里积了多条 0 消息的「新对话」,
-     * 越用越多。
-     *
-     * 现在:任何容器下、从未发过消息、且不是当前会话的空壳,一并清掉。
-     * `keepId` 保护当前这条(它可能刚新建、还没发消息)。
-     *
-     * @param _agentId 保留参数以兼容调用方语义,实际不再用它限定范围
-     */
-    void agentId;
-    const victims = this._all().filter(
-      (s) => !s.legacy && s.id !== keepId && (s.messageCount ?? 0) === 0,
-    );
-    if (victims.length === 0) return;
-    const dead = new Set(victims.map((s) => s.id));
-    this._all.update((list) => list.filter((x) => !dead.has(x.id)));
-    this.persist();
-  }
-
   /**
    * 直接设置消息计数与最后活动时间。
    *
@@ -436,10 +551,14 @@ export class ChatSessionService {
    * 🔴 未登录时返回 null,调用方直接跳过读写 ——
    *    绝不能回落到一个共享的 `anon` 键:那样两个未登录会话、
    *    或「登出后再登录另一个账号」的间隙,会把别人的会话显示出来。
+   *
+   * 🔴 `_v2` 后缀(2026-11-01):缓存结构语义变了(云端失败时不再展示旧数据、
+   *    空会话不再落云端),旧键里的数据按新规则会被当成脏数据,
+   *    直接换键即等于**一次性失效**,无需写迁移代码。
    */
   private key(): string | null {
     const uid = this.session.me()?.id;
-    return uid ? `fmode_chat_sessions_${uid}` : null;
+    return uid ? `fmode_chat_sessions_v2_${uid}` : null;
   }
 
   private persist(): void {
@@ -459,6 +578,24 @@ export class ChatSessionService {
   }
 }
 
+/**
+ * 会话 → 云端 upsert 载荷(唯一构造点)。
+ *
+ * 收敛到一处的原因:此前 `pushAll` / `pushOne` / `mergeScanned` 各手写一份同样的
+ * 字段表,改一个字段要改三处,漏一处就会出现「某条路径写上去的会话缺字段」。
+ */
+function toUpsert(s: ChatSession): ChatThreadUpsert {
+  return {
+    agentId: s.agentId,
+    roomId: s.roomId,
+    threadRoot: s.id,
+    title: s.title,
+    messageCount: s.messageCount ?? 0,
+    lastMessageAt: s.updatedAt || s.createdAt,
+    lastPreview: s.lastPreview,
+  };
+}
+
 /** 结构校验:坏数据不得进入列表 */
 function isSession(x: unknown): x is ChatSession {
   if (!x || typeof x !== 'object') return false;

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

@@ -182,17 +182,19 @@ export function touchSession(
   s: ChatSession,
   msg: { text?: string; ts: number; fromMe: boolean },
 ): ChatSession {
+  /*
+   * 🔴 命名判定必须在**累加计数之前**读取原始状态(2026-11-01)。
+   *    原实现先 `messageCount+1` 再判断 `messageCount === 0`,
+   *    第一次调用时计数已是 1 → 条件永假 → 标题永远是「新对话」(实测)。
+   */
+  const isFirstMessage = (s.messageCount ?? 0) === 0;
   const next: ChatSession = {
     ...s,
     updatedAt: Math.max(s.updatedAt, msg.ts),
     messageCount: (s.messageCount ?? 0) + 1,
     lastPreview: derivePreview(msg.text),
   };
-  const shouldTitlize =
-    !s.titleLocked &&
-    (s.messageCount ?? 0) === 0 &&
-    msg.fromMe &&
-    !!msg.text?.trim();
+  const shouldTitlize = !s.titleLocked && isFirstMessage && msg.fromMe && !!msg.text?.trim();
   if (shouldTitlize) {
     next.title = deriveTaskTitle(msg.text) ?? s.title;
   }

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

@@ -28,8 +28,29 @@ describe('ChatService', () => {
 
 
   /** 桩:FmodeApi */
+  /**
+   * 云端会话目录桩(2026-10-31):会话列表以云端为唯一来源。
+   * 默认返回一条已有会话,模拟「进入一个聊过的容器」。
+   */
+  const CLOUD_THREAD = {
+    objectId: 'cloud1',
+    agentId: 'agent-node-lj-test',
+    roomId: '!room1:fmode.cn',
+    threadRoot: '$approot_test:fmode.cn',
+    title: '云端会话',
+    titleLocked: false,
+    messageCount: 1,
+    lastMessageAt: Date.now(),
+    archived: false,
+    lastPreview: '',
+  };
+
   const apiStub = {
     provisionMatrix: jasmine.createSpy('provisionMatrix'),
+    fetchChatThreads: jasmine.createSpy('fetchChatThreads'),
+    upsertChatThreads: jasmine.createSpy('upsertChatThreads'),
+    patchChatThread: jasmine.createSpy('patchChatThread'),
+    removeChatThread: jasmine.createSpy('removeChatThread'),
     validateMatrix: jasmine.createSpy('validateMatrix'),
     fetchAgents: jasmine.createSpy('fetchAgents'),
     uploadFile: jasmine.createSpy('uploadFile'),
@@ -109,6 +130,11 @@ describe('ChatService', () => {
     // 每个用例都重置附件发送策略 —— 否则上一用例的 reject 会漏到下一个
     mxStub.sendFile.and.returnValue(Promise.resolve({ event_id: '$f' }));
     mxStub.paginateBack.and.returnValue(Promise.resolve(true));
+    // 云端会话目录:默认「该容器已有会话」
+    apiStub.fetchChatThreads.and.returnValue(Promise.resolve([CLOUD_THREAD]));
+    apiStub.upsertChatThreads.and.returnValue(Promise.resolve());
+    apiStub.patchChatThread.and.returnValue(Promise.resolve());
+    apiStub.removeChatThread.and.returnValue(Promise.resolve());
 
     liveStub.agentById.and.returnValue(AGENT);
     apiStub.provisionMatrix.and.returnValue(Promise.resolve(PROV));
@@ -180,6 +206,31 @@ describe('ChatService', () => {
       expect(svc.state()).toBe('empty');
     });
 
+    /*
+     * 🔴 「重新连接」失效的回归测试(2026-11-01 用户实测)。
+     *
+     * `open()` 第一行是幂等短路:同 agent + 同会话 + state=ready 就直接 return。
+     * 断线时 state 仍是 ready,于是点「重新连接」什么都不会发生。
+     * 现在用 `force` 参数绕过短路。
+     */
+    it('🔴 不带 force 时同状态重复 open 会早退', async () => {
+      realMode();
+      mxStub.timelineToMessages.and.returnValue([]);
+      await svc.open('agent-node-lj-test');
+      const calls = apiStub.provisionMatrix.calls.count();
+      await svc.open('agent-node-lj-test');
+      expect(apiStub.provisionMatrix.calls.count()).withContext('应早退,不再调 provision').toBe(calls);
+    });
+
+    it('🔴 带 force 时重新执行完整编排(重新连接可用)', async () => {
+      realMode();
+      mxStub.timelineToMessages.and.returnValue([]);
+      await svc.open('agent-node-lj-test');
+      const calls = apiStub.provisionMatrix.calls.count();
+      await svc.open('agent-node-lj-test', undefined, true);
+      expect(apiStub.provisionMatrix.calls.count()).withContext('force 必须重新编排').toBe(calls + 1);
+    });
+
     it('有消息 → state=ready 且按 sender 区分 me/agent', async () => {
       realMode();
       // 无 thread 的消息属于「历史对话」语义 —— 给它们当前会话的 threadId 才属于本会话
@@ -580,9 +631,16 @@ describe('ChatService', () => {
 
     beforeEach(() => realMode());
 
-    it('首次打开自动建一个会话,并暴露 sessionId', async () => {
+    it('首次打开**不**自动建空会话(2026-10-31 新语义)', async () => {
+      apiStub.fetchChatThreads.and.returnValue(Promise.resolve([]));
+      await svc.open('agent-node-lj-test');
+      expect(svc.sessionId()).toBe('');
+      expect(svc.state()).toBe('empty');
+    });
+
+    it('云端有会话时直接打开最近一条', async () => {
       await svc.open('agent-node-lj-test');
-      expect(svc.sessionId()).toBeTruthy();
+      expect(svc.sessionId()).toBe(CLOUD_THREAD.threadRoot);
       expect(svc.currentSession()).toBeDefined();
     });
 

+ 183 - 34
src/app/core/chat.service.ts

@@ -18,7 +18,6 @@ import { SessionService } from './session.service';
 import { UnreadService } from './unread.service';
 import { MxMessage, MatrixClientService } from './matrix-client.service';
 import { ChatSession, deriveTitle, isAppSessionId } from './chat-session';
-import { scanSessions } from './session-scan';
 import { ChatSessionService } from './chat-session.service';
 import { RelayService } from './relay.service';
 import { TaskPulseService } from './task-pulse.service';
@@ -137,6 +136,11 @@ export class ChatService {
 
   /** 序号防串扰(存量 seq 模式):切会话时丢弃过期回包 */
   private seq = 0;
+  /**
+   * 「新建对话」待建态指定的工作区(会话建出来后才落地)。
+   * `null` = 日常对话。只在 `startBlank()` 到首次发消息之间有效。
+   */
+  private _blankWorkspace: string | null = null;
   private provision: MatrixProvision | null = null;
   /** 真实态下的当前房间 id */
   private roomId: string | null = null;
@@ -215,15 +219,14 @@ export class ChatService {
       this.dropLegacySessions(agentId);
 
       /*
-       * 🔴 **历史到达后重建会话目录**(Matrix 兜底扫描的真正生效点)。
+       * 🔴 这里**不再扫描重建会话目录**(2026-10-31 重构)。
        *
-       * 首次打开时 `/sync` 还没回填,`openReal` 里扫到的是空 timeline →
-       * 扫不出任何会话(冷启动实测:只看到「新对话」,历史全丢)。
-       * 所以扫描必须**在这里再做一次** —— 这时历史已经进内存。
+       * 用户裁决:会话列表以云端 `FmodeChatThread` 为唯一来源。原先每次 /sync
+       * 落地都扫一遍内存 timeline,会把「用户已删除的会话」重新扫回列表
+       * (删除只是本地 + 云端软删,房间消息仍在)。
        *
-       * 幂等:已存在的会话只补计数/时间,标题不覆盖。
+       * 首次接入的初始化扫描已挪到 `openReal()` 中,仅在「云端该容器为空」时执行一次。
        */
-      this.sessions.mergeScanned(rid, agentId, scanSessions(raw, myUserId));
 
       const sid = this._sessionId();
       if (!sid) return;
@@ -244,18 +247,34 @@ export class ChatService {
     });
   }
 
+  /**
+   * 打开会话(路由进入时调用)。
+   *
   /**
    * 打开会话(路由进入时调用)。
    *
    * @param agentId  数字生命
    * @param sessionId 指定会话(线程根);不传则打开该 agent 最近一个会话
-   *                  (没有则新建)—— 见 `ChatSessionService.ensureFor`
+   *                  (一条都没有 → 空态,**不自动新建**,新建走 `startBlank()`)
+   * @param force    强制重开(「重新连接」「重新加载」用)。
+   *                 🔴 不加这个参数时,下面的幂等短路会让重连按钮**完全无效**:
+   *                 断线后 `_state` 仍是 `'ready'`,`open()` 进第一行就 return,
+   *                 页面停在「连接已断开」上按多少次都没反应(用户实测)。
    */
-  async open(agentId: string, sessionId?: string): Promise<void> {
+  async open(agentId: string, sessionId?: string, force = false): Promise<void> {
+    /*
+     * 幂等短路:同一容器 + 同一会话 + **已加载完成**(ready 或 empty)。
+     *
+     * 🔴 `empty` 也要算「已完成」:空房间(没有任何会话)的 state 就是 `empty`,
+     *    只判 `ready` 会让每次进入都重跑 provision + 连接 + 云端拉取,
+     *    既慢又容易和其它异步流程打架(实测:空态页反复重载)。
+     */
+    const loaded = this._state() === 'ready' || this._state() === 'empty';
     if (
+      !force &&
       this._agentId() === agentId &&
       (!sessionId || this._sessionId() === sessionId) &&
-      this._state() === 'ready'
+      loaded
     ) {
       return;
     }
@@ -318,6 +337,36 @@ export class ChatService {
     }
   }
 
+  /**
+   * 开启一张**空白的待建会话**(不落任何数据)。
+   *
+   * 2026-11-01 用户裁决「首次发消息才建会话」后的入口:
+   * 点「新建对话」只是把界面切到「该容器、还没选会话」的状态,
+   * 标题栏显示「新对话」,用户输入并发送时才真正 `create()`。
+   *
+   * 🔴 因此**不会出现 0 消息的空壳**:用户点了又切走、关页面,什么都不留下。
+   *    这也是 `pruneEmpty` 这一整套事后清理可以整体删除的前提。
+   *
+   * @param agentId 目标容器
+   * @returns 是否成功切换到该容器的空白态(容器连接失败时为 false)
+   */
+  async startBlank(agentId: string, workspaceId: string | null = null): Promise<boolean> {
+    await this.open(agentId);
+    if (this._agentId() !== agentId) return false;
+    if (this._state() === 'error') return false;
+    /*
+     * 只清「当前会话」指针,不动列表:写一条空消息会触发提示词与 UI 抖动,
+     * 这里直接复位即可。后续 sendText → ensureSessionForSend 会把会话建出来。
+     */
+    this._sessionId.set('');
+    this._messages.set([]);
+    this._state.set('empty');
+    this._error.set(null);
+    this._hasMoreHistory.set(false);
+    this._blankWorkspace = workspaceId;
+    return true;
+  }
+
   /**
    * 真实态开启流程。
    *
@@ -405,6 +454,36 @@ export class ChatService {
      */
     const rawTimeline = this.mx.timelineToMessages(rid);
 
+    /*
+     * ===== 会话目录:**云端表是唯一来源**(2026-11-01 定稿)=====
+     *
+     * 用户裁决:
+     *   ① 云端不可达 → **完全不显示会话**(宁可「加载失败」,也不展示可能过期的缓存);
+     *   ② **彻底关闭**「从 Matrix 房间扫描重建会话」—— 以后新建的会话只由「发消息」产生。
+     *
+     * 所以这里只剩一件事:拉一次云端。扫不扫描、要不要补推,这里一概不做。
+     * 任何「猜」的逻辑都是历史上会话反复增生的来源(扫描 → upsert → 翻倍)。
+     */
+    await this.sessions.hydrateFromCloud(agentId);
+    if (my !== this.seq) return;
+
+    /*
+     * 云端不可达 → **不显示任何会话**(用户裁决 2026-11-01)。
+     *
+     * 这里给的是「会话目录加载失败」而不是空态:空态意味着「你确实还没有对话」,
+     * 而实际是「我们不知道你有没有」。两者文案必须区分,否则用户会以为自己
+     * 的对话被删了(历史上反复出现),从而去重复新建,进一步制造脏数据。
+     */
+    if (!this.sessions.ready() && this.sessions.error()) {
+      this._sessionId.set('');
+      this._messages.set([]);
+      this._state.set('error');
+      this._error.set(`会话目录加载失败:${this.sessions.error()}`);
+      this._connection.set(this.mx.syncState() === 'connected' ? 'connected' : 'connecting');
+      this._hasMoreHistory.set(false);
+      return;
+    }
+
     /*
      * 🔴 「历史对话」承接机制已**整体下线**(2026-10-29 用户裁决)。
      *
@@ -417,34 +496,40 @@ export class ChatService {
      */
     this.dropLegacySessions(agentId);
 
-    /*
-     * 🔴 **扫描重建会话目录**(Matrix 兜底)。
-     *
-     * 会话目录的正规来源是容器上报,但容器不一定上报(实测 lj-test 的上报器
-     * 根本没有会话列表字段)。而 Matrix 消息自带线程根 ——
-     * 从时间线就能还原「这个房间有哪些对话」。
-     *
-     * 只补不覆盖:容器上报过的标题更准,扫描只填缺的。
-     * 在确定当前会话**之前**做,这样用户一进来就能在左栏看到全部对话。
-     */
-    this.sessions.mergeScanned(rid, agentId, scanSessions(rawTimeline, myUserId));
-
     let sid = sessionId;
     if (!sid) {
-      sid = this.sessions.ensureFor(rid, agentId).id;
+      /*
+       * 🔴 进入容器时**不再自动新建空会话**(2026-10-31 用户裁决)。
+       *
+       * 原实现 `ensureFor()` 在没有会话时会 create() 一条「新对话」——
+       * 结果每进一次容器就多一条空的「新对话」,还排在列表最上面。
+       * 现在:
+       *   · 有会话 → 打开最近一条;
+       *   · 一条都没有 → 不建,交给页面显示「空态 + 开始新对话」,
+       *     用户点了「新建对话」才 create(显式操作优先)。
+       */
+      const latest = this.sessions.latestFor(agentId);
+      if (!latest) {
+        /*
+         * 🔴 不建会话,但**不能提前 return**:连接状态、未读基线等收尾逻辑
+         *    (⑥⑦)仍要执行,否则界面会停在 connecting,看起来像连不上。
+         *    `sid` 留空 → 后续按会话过滤得到空列表,state=empty。
+         */
+        this._sessionId.set('');
+        this._messages.set([]);
+        this._state.set('empty');
+        this._connection.set(this.mx.syncState() === 'connected' ? 'connected' : 'connecting');
+        this._hasMoreHistory.set(false);
+        this.unread.markRead(agentId, Date.now());
+        this.ensureUnreadListener();
+        return;
+      }
+      sid = latest.roomId === rid ? latest.id : (this.sessions.adopt({ id: latest.id, roomId: rid, agentId }).id);
     } else if (!this.sessions.byId(sid)) {
       this.sessions.adopt({ id: sid, roomId: rid, agentId });
     }
     if (my !== this.seq) return;
     this._sessionId.set(sid);
-    /*
-     * 收敛列表:清掉以前反复进出留下的空壳会话(保留当前这条)。
-     *
-     * 🔴 必须在 `_sessionId` 落定**之后**做,且**只清非当前**的空会话 ——
-     *    当前这条可能也是刚新建、还没发过消息的,清了就等于把用户刚点的
-     *    「新建对话」直接抹掉(实测踩坑)。
-     */
-    this.sessions.pruneEmpty(agentId, sid);
 
     const all = this.mapTimeline(
       rawTimeline.filter((m) => this.belongsToSession(m, sid)),
@@ -530,6 +615,21 @@ export class ChatService {
     }
   }
 
+  /**
+   * 换账号时彻底复位(2026-11-01)。
+   *
+   * 🔴 服务都是单例、Shell 不会因登出重建,所以退出再登录时这里全是上一个账号的残留:
+   *    会话指针、消息缓冲、房间映射、(经由 `sessions.reset()`)会话目录。
+   *    不复位就会先闪出别人的对话,再被云端结果覆盖 —— 看起来像「会话串号」。
+   *
+   * ⚠️ 不断开 Matrix 连接:连接的生命周期由 `MatrixSessionService` 管。
+   */
+  resetForUserSwitch(): void {
+    this.close();
+    this.roomAgentMap.clear();
+    this.sessions.reset();
+  }
+
   /**
    * 关闭会话(离开聊天页)。
    *
@@ -587,6 +687,22 @@ export class ChatService {
     if (body) this._messages.update((m) => [...m, msg]);
     this._draft.set('');
     this._sending.set(true);
+    /*
+     * 会话在**第一次发消息时**才创建(2026-11-01 用户裁决)。
+     *
+     * 🔴 取代了「点新建对话就 create() 一条空会话」:
+     *    那个做法必然产生空壳(用户点了又反悔、切走、关页面都会留下一条 0 消息的
+     *    「新对话」),而空壳又要靠 `pruneEmpty` 事后清理 —— 清理再出 bug 就会
+     *    变成「每进一次容器多一条」,这正是会话列表膨胀的来源之一。
+     *
+     *    改成懒创建后,**没有消息就不存在会话**,从根上不会有空壳。
+     *
+     * 顺序很关键:先建会话 → 再记账(`noteMessage` 才能把标题写进去)。
+     */
+    if (!this._sessionId()) {
+      const created = this.ensureSessionForSend();
+      if (created) this._sessionId.set(created.id);
+    }
     /*
      * 会话元信息(标题/预览/时间)随**用户发出的消息**推进。
      *
@@ -886,10 +1002,16 @@ export class ChatService {
    */
   private async sendReal(text: string): Promise<void> {
     if (!this.roomId) throw new Error('会话未就绪,请稍后重试');
-    const sid = this._sessionId();
+    let sid = this._sessionId();
     if (!sid) {
-      // 理论上不会发生(open 时必定确定会话);显式报错好过静默发成新会话
-      throw new Error('会话未就绪:缺少会话标识,请重新进入');
+      /*
+       * 「无会话」是合法状态(进入容器不自动建会话)。
+       * 用户在这个状态下直接发消息 = 隐式开始一段新对话 → 此刻才创建会话。
+       * 正常路径下 `sendText()` 已经建过,这里是附件等旁路入口的兜底。
+       */
+      const created = this.ensureSessionForSend();
+      if (!created) throw new Error('会话未就绪:缺少会话标识,请重新进入');
+      sid = created.id;
     }
     const lastId = this._messages().at(-1)?.id;
     // 自造的会话 id 还没有任何消息时,不填 m.in_reply_to(那是降级展示用的引用)
@@ -897,6 +1019,33 @@ export class ChatService {
     await this.mx.sendText(this.roomId, text, sid, last);
   }
 
+  /**
+   * 懒创建:**第一次发消息时**才把会话落到本地 + 云端(2026-11-01 用户裁决)。
+   *
+   * 之前「点新建对话就建一条空会话」会产生大量 0 消息的空壳,
+   * 还得靠 `pruneEmpty` 事后清理;清理逻辑一旦出错就是「每进一次容器多一条」。
+   * 改成「没有消息就不存在会话」后,空壳从源头消失。
+   *
+   * @returns 会话(已存在则返回当前会话)
+   */
+  private ensureSessionForSend(): ChatSession | null {
+    const agentId = this._agentId();
+    const roomId = this.roomId;
+    if (!agentId || !roomId) return null;
+    const exist = this._sessionId();
+    if (exist) return this.sessions.byId(exist) ?? null;
+    const created = this.sessions.create(roomId, agentId);
+    /*
+     * 「新建对话」时指定的工作区在**这一刻**落地。
+     * 之前没有会话可挂,所以只能在建出来之后补归属。
+     */
+    if (this._blankWorkspace) {
+      this.sessions.moveTo(created.id, this._blankWorkspace);
+      this._blankWorkspace = null;
+    }
+    return created;
+  }
+
   /**
    * 消息是否属于指定会话(线程)。
    *

+ 49 - 1
src/app/core/matrix-client.service.ts

@@ -265,8 +265,33 @@ export class MatrixClientService implements OnDestroy {
       accessToken,
       userId,
       ...(deviceId ? { deviceId } : {}),
+      /*
+       * 关闭 VoIP(2026-10-31)—— 本项目不做语音通话(FR-11「暂不实现」)。
+       *
+       * 🔴 必须用 SDK 真实的选项名 `disableVoip`(源码 client.js:521):
+       *    它会让 `supportsVoip()` 返回 false,从而跳过 turnServer 轮询。
+       *    之前误用 `fallbackICEServerAllowed`(不是 createClient 的选项)→ 完全无效,
+       *    控制台仍每 10s 报一次 `GET /voip/turnServer 404 M_NOT_FOUND`。
+       */
+      disableVoip: true,
     });
 
+    /*
+     * 压掉 client 自身的 info/warn 日志(2026-11-01)。
+     *
+     * 🔴 控制台被大量「Missing/Adding default global push rule …」刷屏:
+     *    这是 SDK 拿服务端 push rules 与自身默认表对比时的提示(`client.logger`),
+     *    属于**服务端规则版本与 SDK 版本不一致**的既有状况,不影响收发消息。
+     *    之前只设了模块级 `logger`,而 client 有独立的 logger 实例,所以没生效。
+     *    这里把 client 的 logger 提到 error 级,只保留真正需要处理的错误。
+     */
+    try {
+      const cl = this.client as unknown as { logger?: { setLevel?: (l: string) => void } };
+      cl.logger?.setLevel?.('error');
+    } catch {
+      /* 结构不符时忽略:不影响主流程 */
+    }
+
     // sync 状态 → 连接状态(FR-04 AC-04.9:断线可自动重连)
     this.client.on(sdk.ClientEvent.Sync, (state: string) => {
       if (this.stopped) return;
@@ -664,12 +689,35 @@ export class MatrixClientService implements OnDestroy {
    *
    * 🔴 Matrix 的附件地址是内部协议(`mxc://server/mediaId`),
    *    浏览器直接打开不了,必须经 SDK 转成 `/_matrix/media/...` 的 HTTP 地址。
+   *
+   * 🔴 参数含义(第 5 个 = useAuthenticatedApi/直接下载):
+   *    之前传 true 生成的是 `/_matrix/media/v3/download/...` —— 该接口带
+   *    `Content-Disposition: attachment`,浏览器**只会下载、不会显示**,
+   *    于是「在新标签页打开」变成了「下载文件」(用户实测)。
+   *    传 false 生成 `/_matrix/media/v3/preview_url/...`(内联展示),
+   *    图片/PDF/HTML 才能直接在标签页里打开。
+   */
+  /**
+   * 给 Matrix 媒体地址补上访问令牌,供「在新标签页打开」使用。
+   *
+   * 🔴 背景(2026-11-01):媒体接口要鉴权,直接 window.open 会 401;
+   *    而页面内 fetch 该地址又被 CORS 拦截(实测 Failed to fetch),
+   *    所以没法先抓成 blob。带上 access_token 查询参数的直链是唯一能
+   *    "浏览器新标签页里直接显示"的方式(homeserver 支持该参数)。
+   *    仅用于用户主动点击打开,不做缓存、不落库。
    */
+  withAccessToken(url: string): string {
+    if (!this.token || !url.includes('/_matrix/')) return url;
+    if (/[?&]access_token=/.test(url)) return url;
+    const sep = url.includes('?') ? '&' : '?';
+    return `${url}${sep}access_token=${encodeURIComponent(this.token)}`;
+  }
+
   mxcUrlToHttp(mxc: string): string | null {
     const c = this.client;
     if (!c) return null;
     try {
-      return c.mxcUrlToHttp(mxc, undefined, undefined, undefined, true) ?? null;
+      return c.mxcUrlToHttp(mxc, undefined, undefined, undefined, false) ?? null;
     } catch {
       return null;
     }

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

@@ -44,6 +44,8 @@ export class MatrixSessionService {
   private readonly chat = inject(ChatService);
 
   private booted = false;
+  /** 上一次生效的用户 id(用于检测换账号) */
+  private lastUid = '';
 
   /** 在 Shell 构造时调用(与 `LiveService.start()` 同级) */
   bootstrap(): void {
@@ -53,6 +55,21 @@ export class MatrixSessionService {
     effect(() => {
       const token = this.session.token();
       if (token && !environment.useMockData) {
+        /*
+         * 🔴 换账号必须清掉上一个账号的内存态(2026-11-01)。
+         *
+         * 退出重登时 Shell 不重建,服务都是单例 —— `ChatSessionService` 里还留着
+         * 上个账号的会话、`ChatService` 里还留着会话指针与房间映射。
+         * 不清的话会先闪出别人的数据,再被云端覆盖(用户实测:重登后列表异常)。
+         */
+        if (this.lastUid && this.lastUid !== this.session.me()?.id) this.chat.resetForUserSwitch();
+        this.lastUid = this.session.me()?.id ?? '';
+        /*
+         * 🔴 重新登录必须**重启轮询**:登出时 `live.stop()` 关掉了定时器,
+         *    而 Shell 的 `live.start()` 只在构造时跑一次(同一标签页内不会重建)。
+         *    不在这里拉起,重登后实况/容器列表永远不刷新(用户实测:「重新连接没效果」)。
+         */
+        this.live.start();
         // 载入按用户隔离的 roomId 映射与未读计数(未读归属需要)
         this.chat.loadRoomMap();
         this.unread.reload();
@@ -62,6 +79,14 @@ export class MatrixSessionService {
       } else if (!token) {
         this.mx.disconnect();
         this.unread.reset();
+        /*
+         * 🔴 登出必须停轮询(2026-11-01)。
+         *
+         * 不动的话 `LiveService` 每 15s 仍带着**已失效的旧 token** 打接口,
+         * 控制台持续刷 `401 Unauthorized`,且重登后若轮询已在跑,新 token 也不会立即用上。
+         * 登出 → 停;重新登录 → 由 Shell 的 `live.start()` 再拉起。
+         */
+        this.live.stop();
       }
     });
   }

+ 0 - 180
src/app/core/session-scan.spec.ts

@@ -1,180 +0,0 @@
-import { MxMessage } from './matrix-client.service';
-import { ChatSession } from './chat-session';
-import { mergeScanned, scanSessions } from './session-scan';
-
-/**
- * 会话扫描重建 —— 容器不上报时,从 Matrix 时间线还原会话目录。
- *
- * 这是「换设备后能不能看到历史对话」的最后一道保障。
- */
-const ME = '@me:fmode.cn';
-const AGENT = '@bot:fmode.cn';
-
-const m = (id: string, threadId: string | undefined, body: string, ts: number, fromMe = false): MxMessage => ({
-  roomId: '!r:fmode.cn',
-  eventId: id,
-  sender: fromMe ? ME : AGENT,
-  threadId,
-  body,
-  ts,
-  kind: 'text',
-  live: false,
-});
-
-describe('scanSessions', () => {
-  it('按 thread 分组,各自独立', () => {
-    const r = scanSessions(
-      [
-        m('$1', '$t1', '会话一的任务', 100, true),
-        m('$2', '$t1', '收到', 200),
-        m('$3', '$t2', '会话二的任务', 300, true),
-      ],
-      ME,
-    );
-    expect(r.length).toBe(2);
-    const t1 = r.find((s) => s.threadId === '$t1')!;
-    expect(t1.messageCount).toBe(2);
-    const t2 = r.find((s) => s.threadId === '$t2')!;
-    expect(t2.messageCount).toBe(1);
-  });
-
-  // 容器的话(「收到」「好的」)不适合当会话名
-  it('标题取用户的第一条消息,不是容器的开场白', () => {
-    const r = scanSessions(
-      [
-        m('$1', '$t1', '好的,我看看', 100),
-        m('$2', '$t1', '帮我做个登录页', 200, true),
-      ],
-      ME,
-    );
-    expect(r[0].title).toBe('帮我做个登录页');
-  });
-
-  it('用户没说过话时用首条消息兜底', () => {
-    const r = scanSessions([m('$1', '$t1', '系统通知:任务已启动', 100)], ME);
-    expect(r[0].title).toBe('系统通知:任务已启动');
-  });
-
-  it('标题超长截断并加省略号', () => {
-    const long = '一'.repeat(40);
-    const r = scanSessions([m('$1', '$t1', long, 100, true)], ME);
-    expect(Array.from(r[0].title).length).toBe(25);
-    expect(r[0].title.endsWith('…')).toBeTrue();
-  });
-
-  it('空标题落在「未命名对话」,不编造', () => {
-    const r = scanSessions([m('$1', '$t1', '   ', 100, true)], ME);
-    expect(r[0].title).toBe('未命名对话');
-  });
-
-  it('按最后活动时间倒序', () => {
-    const r = scanSessions(
-      [
-        m('$1', '$old', '老的', 100, true),
-        m('$2', '$new', '新的', 900, true),
-      ],
-      ME,
-    );
-    expect(r.map((s) => s.threadId)).toEqual(['$new', '$old']);
-  });
-
-  it('无 thread 的消息归为一个 threadId=undefined 的分组', () => {
-    const r = scanSessions(
-      [m('$1', undefined, '历史甲', 100, true), m('$2', undefined, '历史乙', 200, true)],
-      ME,
-    );
-    expect(r.length).toBe(1);
-    expect(r[0].threadId).toBeUndefined();
-    expect(r[0].messageCount).toBe(2);
-  });
-
-  it('空消息列表返回空', () => {
-    expect(scanSessions([], ME)).toEqual([]);
-  });
-
-  it('记录首末时间(用于排序与 createdAt)', () => {
-    const r = scanSessions(
-      [m('$1', '$t1', 'a', 100, true), m('$2', '$t1', 'b', 500)],
-      ME,
-    );
-    expect(r[0].firstTs).toBe(100);
-    expect(r[0].lastTs).toBe(500);
-  });
-});
-
-describe('mergeScanned', () => {
-  const existing = (id: string, title: string, extra: Partial<ChatSession> = {}): ChatSession => ({
-    id,
-    roomId: '!r:fmode.cn',
-    agentId: 'agent-a',
-    title,
-    createdAt: 100,
-    updatedAt: 100,
-    archived: false,
-    messageCount: 1,
-    ...extra,
-  });
-
-  it('新线程 → 产生待创建项', () => {
-    const { toCreate } = mergeScanned([], [
-      { threadId: '$approot_new:fmode.cn', title: '新任务', messageCount: 3, lastTs: 500, firstTs: 100 },
-    ]);
-    expect(toCreate.length).toBe(1);
-    expect(toCreate[0].id).toBe('$approot_new:fmode.cn');
-    expect(toCreate[0].title).toBe('新任务');
-    expect(toCreate[0].messageCount).toBe(3);
-  });
-
-  // 容器上报的语义标题比首句截断好得多,绝不能被扫描结果盖掉
-  it('🔴 已有会话的标题不被覆盖', () => {
-    const { toCreate, patches } = mergeScanned([existing('$approot_t1:fmode.cn', '容器起的名字')], [
-      { threadId: '$approot_t1:fmode.cn', title: '扫描出来的首句', messageCount: 9, lastTs: 900, firstTs: 100 },
-    ]);
-    expect(toCreate).toEqual([]);
-    expect(patches.length).toBe(1);
-    expect(patches[0]).toEqual({ id: '$approot_t1:fmode.cn', messageCount: 9, updatedAt: 900 });
-  });
-
-  it('计数取较大值,不会因为扫描少而回退', () => {
-    const { patches } = mergeScanned([existing('$approot_t1:fmode.cn', 'x', { messageCount: 20 })], [
-      { threadId: '$approot_t1:fmode.cn', title: 'y', messageCount: 5, lastTs: 50, firstTs: 1 },
-    ]);
-    expect(patches[0].messageCount).toBe(20);
-  });
-
-  it('updatedAt 不会回退', () => {
-    const { patches } = mergeScanned([existing('$approot_t1:fmode.cn', 'x', { updatedAt: 9999 })], [
-      { threadId: '$approot_t1:fmode.cn', title: 'y', messageCount: 1, lastTs: 100, firstTs: 1 },
-    ]);
-    expect(patches[0].updatedAt).toBe(9999);
-  });
-
-  /*
-   * 🔴 容器自开的 m.thread 分支**不算会话**(2026-10-29 实测)。
-   * 实测一个房间 6 个线程根,5 个是容器内部任务产生的 ——
-   * 都算会话会让列表冒出「两个 test.png」这类重复项。
-   */
-  it('🔴 容器自开的线程不产生会话(只认 approot 根)', () => {
-    const { toCreate, patches } = mergeScanned([], [
-      { threadId: '$kfv5cDP_ZZZ:fmode.cn', title: '容器子任务', messageCount: 9, lastTs: 500, firstTs: 100 },
-    ]);
-    expect(toCreate).toEqual([]);
-    expect(patches).toEqual([]);
-  });
-
-  it('无 thread 的组不产生会话(交给「历史对话」统一处理)', () => {
-    const { toCreate, patches } = mergeScanned([], [
-      { threadId: undefined, title: '历史', messageCount: 5, lastTs: 100, firstTs: 1 },
-    ]);
-    expect(toCreate).toEqual([]);
-    expect(patches).toEqual([]);
-  });
-
-  it('扫描不到已有会话时保留它(不代表它没了)', () => {
-    const cur = [existing('$gone', '已存在的会话')];
-    const { toCreate, patches } = mergeScanned(cur, []);
-    expect(toCreate).toEqual([]);
-    expect(patches).toEqual([]);
-    expect(cur.length).toBe(1);
-  });
-});

+ 0 - 167
src/app/core/session-scan.ts

@@ -1,167 +0,0 @@
-import { ChatSession, isAppSessionId } from './chat-session';
-import { MxMessage } from './matrix-client.service';
-
-/**
- * 从 Matrix 房间**扫描重建**会话目录(兜底能力)。
- *
- * ## 为什么需要它
- *
- * 会话目录的正规来源是容器上报(容器 `state.db.sessions` 里有标题、线程、条数)。
- * 但那要求每个容器都改上报脚本 —— 现实中总有容器没改、没跑、或者换了设备。
- *
- * **Matrix 本身已经带着全部信息**:每条消息都有 `m.relates_to` 线程根。
- * 所以即使一条上报都没有,也能从房间时间线里把「有哪些对话」还原出来:
- *
- * - 按 `threadId` 分组
- * - 首条用户消息 → 会话名(容器没起名时用)
- * - 末条消息时间 → 排序与展示
- * - 条数 → 消息计数
- *
- * ## 与上报目录的关系
- *
- * | | 扫描重建 | 容器上报 |
- * |---|---|---|
- * | 会话名 | 首条用户消息截断 | **容器生成的语义标题**(更好) |
- * | 需要容器配合 | ❌ 不需要 | ✅ 需要 |
- * | 跨设备 | ✅ | ✅ |
- * | 能覆盖多久 | 房间里已加载的消息 | 容器保留的全部 |
- *
- * 两者**互补**:上报的标题更准,扫描的覆盖面更广。合并规则见 `mergeScanned`。
- */
-
-/** 一条会话在扫描中的临时形态 */
-export interface ScannedSession {
-  /** 线程根 event_id(无 thread 的消息为 undefined,归入历史会话) */
-  threadId?: string;
-  title: string;
-  messageCount: number;
-  /** 该会话最后一条消息时间(ms) */
-  lastTs: number;
-  /** 该会话第一条用户消息时间(ms),用于 createdAt */
-  firstTs: number;
-  /** 最后一条用户消息正文(作为预览) */
-  lastUserText?: string;
-}
-
-/** 会话名最大字数(与 `chat-session.ts` 的 TITLE_MAX 保持一致) */
-const SCAN_TITLE_MAX = 24;
-
-/**
- * 扫描一组消息,按线程分组还原会话。
- *
- * @param msgs 一个房间内**按时间升序**的消息
- * @param myUserId 当前用户 Matrix ID(用来判断「谁发的」)
- * @returns 按最后活动时间倒序
- */
-export function scanSessions(msgs: MxMessage[], myUserId: string): ScannedSession[] {
-  const byThread = new Map<string, MxMessage[]>();
-  for (const m of msgs) {
-    // 空线程根与非空分开:空的那批是「历史遗留」,由 ChatService 统一归入历史对话
-    const key = m.threadId ?? '';
-    const list = byThread.get(key);
-    if (list) list.push(m);
-    else byThread.set(key, [m]);
-  }
-
-  const out: ScannedSession[] = [];
-  for (const [threadId, list] of byThread) {
-    if (list.length === 0) continue;
-    const fromMe = list.filter((m) => m.sender === myUserId);
-    /*
-     * 标题取**用户的第一条消息** —— 容器的话不适合当会话名
-     * (「收到」「好的」这类开场白毫无辨识度)。
-     * 用户还没说过话的会话(容器主动发起)用首条消息兜底。
-     */
-    const titleSource = fromMe[0]?.body ?? list[0].body;
-    const lastUser = fromMe[fromMe.length - 1];
-    out.push({
-      threadId: threadId || undefined,
-      title: flatTitle(titleSource),
-      messageCount: list.length,
-      lastTs: list[list.length - 1].ts,
-      firstTs: list[0].ts,
-      lastUserText: lastUser?.body,
-    });
-  }
-
-  return out.sort((a, b) => b.lastTs - a.lastTs);
-}
-
-/** 压成一行并截断,保留省略号 */
-function flatTitle(text: string | undefined): string {
-  const flat = (text ?? '').replace(/\s+/g, ' ').trim();
-  if (!flat) return '未命名对话';
-  const chars = Array.from(flat);
-  return chars.length <= SCAN_TITLE_MAX ? flat : chars.slice(0, SCAN_TITLE_MAX).join('') + '…';
-}
-
-/**
- * 把扫描结果合进已有的会话列表。
- *
- * 规则:
- * - 已存在(同 threadId) → **保留原有标题与归档状态**,只补 `messageCount` / `updatedAt`
- *   (上报的标题是容器生成的,比扫描出来的首句更好,**绝不覆盖**)
- * - 不存在 → 用扫描结果新建
- * - 扫描不到但有记录的 → 保留(可能是房间时间线还没加载到,不代表它没了)
- *
- * @returns 需要新增的会话(调用方负责落库);已有会话的补全由调用方按 `patch` 应用
- */
-export function mergeScanned(
-  current: ChatSession[],
-  scanned: ScannedSession[],
-): { toCreate: ChatSession[]; patches: { id: string; messageCount: number; updatedAt: number }[] } {
-  const byId = new Map(current.map((s) => [s.id, s]));
-  const toCreate: ChatSession[] = [];
-  const patches: { id: string; messageCount: number; updatedAt: number }[] = [];
-
-  for (const s of scanned) {
-    /*
-     * 无 thread 的那批 = 「历史对话」的正文。
-     * **不新建**(它的 id 由 `legacySessionId(roomId)` 固定生成,不是线程根),
-     * 但**必须把计数补上** —— 否则历史会话永远显示 `messageCount: 0`,
-     * 清理逻辑会把它当成空壳(实测:消息明明有 11 条,计数却是 0)。
-     */
-    if (!s.threadId) {
-      continue;
-    }
-    /*
-     * 🔴 **只认 App 自造的线程根(`$approot_`)作为会话**。
-     *
-     * 实测(2026-10-29):容器会自己开 `m.thread` 分支 ——
-     * 一个房间里 6 个线程根,5 个是容器内部任务产生的
-     * (工具调用、子任务、自我改进),根本不是用户想看的「对话」。
-     *
-     * 把它们都算会话,列表就会冒出「两个 test.png」这类重复项。
-     *
-     * 容器的这些分支**内容不会丢**:它们属于当前会话的上下文,
-     * 打开对应会话时按时间混排在消息流里(`belongsToSession` 的另一半规则)。
-     */
-    if (!isAppSessionId(s.threadId)) {
-      continue;
-    }
-    const existing = byId.get(s.threadId);
-    if (existing) {
-      // 只补计数与时间,标题/归档/legacy 一律不动
-      patches.push({
-        id: s.threadId,
-        messageCount: Math.max(existing.messageCount ?? 0, s.messageCount),
-        updatedAt: Math.max(existing.updatedAt, s.lastTs),
-      });
-      continue;
-    }
-    toCreate.push({
-      id: s.threadId,
-      // roomId / agentId 由调用方补齐(扫描器只知道消息,不知道归属)
-      roomId: '',
-      agentId: '',
-      title: s.title,
-      createdAt: s.firstTs,
-      updatedAt: s.lastTs,
-      archived: false,
-      messageCount: s.messageCount,
-      lastPreview: s.lastUserText,
-    });
-  }
-
-  return { toCreate, patches };
-}

+ 24 - 1
src/app/core/spreadsheet.spec.ts

@@ -1,4 +1,4 @@
-import { cellText, isNumericLike, toSheetTable } from './spreadsheet';
+import { cellText, isNumericLike, parseCsvTable, toSheetTable } from './spreadsheet';
 
 /**
  * Excel → HTML 表格转换。
@@ -111,4 +111,27 @@ describe('spreadsheet', () => {
       expect(toSheetTable('销售明细', []).name).toBe('销售明细');
     });
   });
+
+  describe('parseCsvTable', () => {
+    it('基础行列 + 数字右对齐', () => {
+      const t = parseCsvTable('pe', 'date,pe_all\n2017-10-20,20.06');
+      expect(t.colCount).toBe(2);
+      expect(t.rows.length).toBe(2);
+      expect(t.rows[0].cells[0].text).toBe('date');
+      expect(t.rows[1].cells[1].align).toBe('right');
+    });
+
+    it('引号包裹、引号内逗号与换行、"" 转义', () => {
+      const t = parseCsvTable('s', 'a,b\n"x,y","line1\nline2"\n"say ""hi""",z');
+      expect(t.rows[1].cells[0].text).toBe('x,y');
+      expect(t.rows[1].cells[1].text).toBe('line1\nline2');
+      expect(t.rows[2].cells[0].text).toBe('say "hi"');
+    });
+
+    it('去 BOM 与 CRLF;空行跳过', () => {
+      const t = parseCsvTable('s', 'a,b\r\n1,2\r\n\r\n');
+      expect(t.rows[0].cells[0].text).toBe('a');
+      expect(t.rows.length).toBe(2);
+    });
+  });
 });

+ 60 - 0
src/app/core/spreadsheet.ts

@@ -146,3 +146,63 @@ export function isNumericLike(s: string): boolean {
   if (!t) return false;
   return /^[-+]?[\d,]*\.?\d+%?$/.test(t);
 }
+
+/**
+ * CSV 文本 → 展示用表格(2026-10-30)。
+ *
+ * 🔴 原先把 .csv 交给 `XlsxWorkbook` 解析 —— CSV 不是 zip,必然报
+ *    「Could not find EOCD」,表现为所有 CSV 都打不开。CSV 本就是纯文本,
+ *    这里按 RFC 4180 解析:支持引号包裹、引号内逗号/换行、`""` 转义。
+ */
+export function parseCsvTable(name: string, text: string): SheetTable {
+  const rows = splitCsvRows(text);
+  const raw = rows
+    .map((cells, i) => ({ index: i, cells: cells.map((v, col) => ({ col, row: i, value: v })) }))
+    .filter((r) => r.cells.some((c) => String(c.value).trim() !== ''));
+  return toSheetTable(name || 'Sheet1', raw);
+}
+
+/** 逐字符扫描,正确处理引号内换行 */
+function splitCsvRows(text: string): string[][] {
+  const out: string[][] = [];
+  let row: string[] = [];
+  let cell = '';
+  let quoted = false;
+  // 去掉 BOM(Excel 导出的 CSV 常带),否则首列表头会多一个不可见字符
+  const src = text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
+  for (let i = 0; i < src.length; i++) {
+    const ch = src[i];
+    if (quoted) {
+      if (ch === '"') {
+        if (src[i + 1] === '"') {
+          cell += '"';
+          i++;
+        } else {
+          quoted = false;
+        }
+      } else {
+        cell += ch;
+      }
+      continue;
+    }
+    if (ch === '"') {
+      quoted = true;
+    } else if (ch === ',') {
+      row.push(cell);
+      cell = '';
+    } else if (ch === '\n' || ch === '\r') {
+      if (ch === '\r' && src[i + 1] === '\n') i++;
+      row.push(cell);
+      out.push(row);
+      row = [];
+      cell = '';
+    } else {
+      cell += ch;
+    }
+  }
+  if (cell !== '' || row.length > 0) {
+    row.push(cell);
+    out.push(row);
+  }
+  return out;
+}

+ 3 - 3
src/app/desktop/pages/agent/agent.ts

@@ -231,8 +231,8 @@ import { LiveService } from '../../../core/live.service';
                     type="text"
                     [value]="newGrantee()"
                     (input)="newGrantee.set($any($event.target).value)"
-                    placeholder="被授权者的 Matrix ID,如 @k0nrevcfik:fmode.cn"
-                    aria-label="被授权者 Matrix ID"
+                    placeholder="被授权者的账号 ID,如 @k0nrevcfik:fmode.cn"
+                    aria-label="被授权者账号 ID"
                   />
                   <!--
                     适用群:不选 = 作用于此容器所在的所有群(危险,会报警)。
@@ -985,7 +985,7 @@ export class DkAgentPage implements OnInit {
   infoOf(a: Agent) {
     return [
       { label: '当前任务', value: a.currentTask || '暂无进行中的任务' },
-      { label: '连接通道', value: a.channels?.matrix ? `Matrix · ${a.channels?.homeserver ?? '—'}` : '未连接' },
+      { label: '连接通道', value: a.channels?.matrix ? '已接入' : '未连接' },
       { label: '最近心跳', value: a.heartbeatAt ? this.dt(a.heartbeatAt) : '—' },
       { label: '最近更新', value: a.updatedAt ? this.dt(a.updatedAt) : '—' },
     ];

+ 13 - 1
src/app/desktop/pages/chat/chat.spec.ts

@@ -1,4 +1,4 @@
-import { clampRailWidth } from './chat';
+import { clampRailWidth, clampPreviewRailWidth, RAIL_W_PREVIEW_MAX } from './chat';
 
 /**
  * 右栏宽度夹取 —— 纯函数。
@@ -35,3 +35,15 @@ describe('clampRailWidth', () => {
     expect(clampRailWidth(560)).toBe(560);
   });
 });
+
+describe('clampPreviewRailWidth', () => {
+  it('预览形态上限放宽到 720,下限不变', () => {
+    expect(clampPreviewRailWidth(900)).toBe(RAIL_W_PREVIEW_MAX);
+    expect(clampPreviewRailWidth(600)).toBe(600);
+    expect(clampPreviewRailWidth(100)).toBe(280);
+  });
+
+  it('非法值回落到 320', () => {
+    expect(clampPreviewRailWidth(NaN)).toBe(320);
+  });
+});

+ 333 - 236
src/app/desktop/pages/chat/chat.ts

@@ -25,6 +25,9 @@ import { MessageBubbleComponent } from '../../../components/domain/message-bubbl
 import { TypingIndicatorComponent } from '../../../components/domain/typing-indicator/typing-indicator';
 import { ToolActivityComponent } from '../../../components/domain/tool-activity/tool-activity';
 import { FilePreviewComponent } from '../../../components/preview/file-preview/file-preview';
+import { NewSessionModalService } from '../../../components/domain/new-session-modal/new-session-modal.service';
+import { ImageLightboxComponent } from '../../../components/preview/image-lightbox/image-lightbox';
+import { previewKindOf, type PreviewKind } from '../../../components/preview/file-kind';
 import { PresenceBadgeComponent } from '../../../components/domain/presence-badge/presence-badge';
 import { AgentPresence, agentPresence } from '../../../core/api/models';
 import { StatusBadgeComponent } from '../../../components/domain/status-badge/status-badge';
@@ -45,6 +48,7 @@ import { UnreadService } from '../../../core/unread.service';
 import { TasksService } from '../../../core/tasks.service';
 import { ChatSession } from '../../../core/chat-session';
 import { ChatSessionService } from '../../../core/chat-session.service';
+import { MatrixSessionService } from '../../../core/matrix-session.service';
 import { WorkspaceService } from '../../../core/workspace.service';
 import { AgentModalService } from '../../../components/domain/agent-modal/agent-modal.service';
 
@@ -91,6 +95,16 @@ export function clampRailWidth(w: number): number {
   return Math.min(560, Math.max(280, Math.round(w)));
 }
 
+/**
+ * 预览形态下的右栏宽度上限(2026-10-30)。
+ * 看文档/表格时 560px 太窄;预览打开时放宽到 720,关闭后回到常规上限。
+ */
+export const RAIL_W_PREVIEW_MAX = 720;
+
+export function clampPreviewRailWidth(w: number): number {
+  return Math.min(RAIL_W_PREVIEW_MAX, Math.max(280, Math.round(Number.isFinite(w) ? w : 320)));
+}
+
 /** 左栏「容器行」—— 会话按容器分组时的分组头 */
 interface ConversationRow {
   id: string;
@@ -117,11 +131,12 @@ interface ConversationRow {
     TypingIndicatorComponent,
     ToolActivityComponent,
     FilePreviewComponent,
+    ImageLightboxComponent,
     PresenceBadgeComponent,
     StatusBadgeComponent,
   ],
   template: `
-    <div class="conversation-layout" [style.--rail-w.px]="railWidth()">
+    <div class="conversation-layout" [style.--rail-w.px]="effectiveRailWidth()">
       <!--
         🔴 左栏(会话树)已提升到 DesktopShell 外壳组件(2026-10-30)——
         一级路由点击时左栏必须保持不变,所以它不能挂在某个页面里。
@@ -141,14 +156,12 @@ interface ConversationRow {
               <div class="chat-title">
                 <strong>{{ a.name }}</strong>
                 <!--
-                  当前对话名。多会话后「这个容器」不再等于「这一条对话」,
-                  必须让用户一眼看到正在聊哪一条;点标题可直接改名。
+                  当前任务名(只读展示,2026-10-31 用户要求取消命名与改名):
+                  原来这里是可点击重命名的「对话名」。现在对话由容器命名,
+                  用户不需要也不应该改 —— 去掉点击改名入口,只保留只读文案。
                 -->
                 @if (chat.currentSession(); as s) {
-                  <small class="ellipsis session-name" role="button" tabindex="0"
-                    title="点击重命名这条对话"
-                    (click)="startRename(s, $event)"
-                    (keydown.enter)="startRename(s, $event)">
+                  <small class="ellipsis session-name" [title]="s.title">
                     <i class="fa-solid fa-comment-dots" aria-hidden="true"></i>
                     {{ s.title }}
                     @if (s.archived) {
@@ -249,7 +262,7 @@ interface ConversationRow {
             <div class="banner-hold">
               @if (currentContainerState() === 'unreported' && chat.connection() === 'connected') {
                 <ui-alert type="warning" title="容器状态未知,可能无法应答">
-                  已连接矩阵服务,但该容器的接入通道未上报在线状态,消息可能无人应答。
+                  该容器还没有上报在线状态,发给它的消息可能无人处理。
                 </ui-alert>
               } @else {
                 <ui-alert
@@ -300,6 +313,20 @@ interface ConversationRow {
                 </div>
               }
               @case ('empty') {
+                @if (!chat.currentSession()) {
+                  <!--
+                    还没有任何会话(2026-11-01 用户裁决):
+                    不再凭空创建「临时会话」,提示用户先新建对话再开始。
+                  -->
+                  <div class="no-session">
+                    <i class="fa-solid fa-comments" aria-hidden="true"></i>
+                    <h2 class="no-session-title">还没有对话</h2>
+                    <p class="no-session-sub">点击左侧「新建对话」,选择一个数字生命后开始。</p>
+                    <ui-button variant="primary" size="sm" (click)="requestNewSession()">
+                      新建对话
+                    </ui-button>
+                  </div>
+                } @else {
                 <!--
                   空会话欢迎区(2026-10-29,参考 ChatGPT 落地页):
                   给「该做什么」的具体引导,而不是一句泛泛的「还没有消息」。
@@ -331,6 +358,7 @@ interface ConversationRow {
                     </div>
                   </div>
                 </div>
+                }
               }
               @default {
                 <!-- AC-04.4:向上翻历史(滚动到顶部自动加载) -->
@@ -358,7 +386,6 @@ interface ConversationRow {
                       [message]="row.message!"
                       [own]="row.message!.sender === 'me'"
                       (retry)="chat.retry($event)"
-                      (regenerate)="regenerate($event)"
                       (openTask)="openTaskRail()"
                       (preview)="openFile($event)"
                     />
@@ -389,6 +416,14 @@ interface ConversationRow {
             </div>
           }
 
+          <!--
+            🔴 没有选中会话时不显示输入区(2026-11-01 用户要求)。
+               空态下仍摆一个可以打字的输入框,用户会以为「直接输入就能开始」,
+               但此时没有线程根,发出去会走隐式建会话的旁路,容易产生语义混乱。
+               空态只保留说明 + 「新建对话」按钮,入口唯一。
+               例外:state 是 'ready'/'empty' 但有 currentSession(正常会话)→ 正常显示。
+          -->
+          @if (chat.currentSession()) {
           <div class="composer-hold">
           <!--
             暂存附件条:选文件后**先停在这里**,点发送才真正上传+发送。
@@ -398,23 +433,45 @@ interface ConversationRow {
           @if (chat.pendingFiles().length > 0) {
             <div class="pending-holds" role="list" aria-label="待发送附件">
               @for (pf of chat.pendingFiles(); track pf.id) {
-                <span class="pending-chip" role="listitem">
-                  <!-- 图片附件显示缩略图,其它类型显示回形针 -->
-                  @if (pf.previewUrl) {
-                    <img class="pending-thumb" [src]="pf.previewUrl" alt="" aria-hidden="true" />
-                  } @else {
+                <!--
+                  图片附件(2026-10-31 按参考图重做):
+                  大缩略图 + 下方「解释图片 →」+ 右上角移除。
+                  点缩略图直接打开图片预览(灯箱)。
+                -->
+                @if (pf.previewUrl) {
+                  <div class="pending-card" role="listitem">
+                    <button
+                      type="button"
+                      class="pending-card-img"
+                      [attr.aria-label]="'预览 ' + pf.file.name"
+                      (click)="previewPending(pf)"
+                    >
+                      <img [src]="pf.previewUrl" [alt]="pf.file.name" />
+                    </button>
+                    <span class="pending-card-act" aria-hidden="true">解释图片 →</span>
+                    <button
+                      type="button"
+                      class="pending-x pending-x--float"
+                      [attr.aria-label]="'移除 ' + pf.file.name"
+                      (click)="chat.unstageFile(pf.id)"
+                    >
+                      <i class="fa-solid fa-xmark" aria-hidden="true"></i>
+                    </button>
+                  </div>
+                } @else {
+                  <span class="pending-chip" role="listitem">
                     <i class="fa-solid fa-paperclip" aria-hidden="true"></i>
-                  }
-                  <span class="pending-name ellipsis" [title]="pf.file.name">{{ pf.file.name }}</span>
-                  <button
-                    type="button"
-                    class="pending-x"
-                    [attr.aria-label]="'移除 ' + pf.file.name"
-                    (click)="chat.unstageFile(pf.id)"
-                  >
-                    <i class="fa-solid fa-xmark" aria-hidden="true"></i>
-                  </button>
-                </span>
+                    <span class="pending-name ellipsis" [title]="pf.file.name">{{ pf.file.name }}</span>
+                    <button
+                      type="button"
+                      class="pending-x"
+                      [attr.aria-label]="'移除 ' + pf.file.name"
+                      (click)="chat.unstageFile(pf.id)"
+                    >
+                      <i class="fa-solid fa-xmark" aria-hidden="true"></i>
+                    </button>
+                  </span>
+                }
               }
             </div>
           }
@@ -430,6 +487,7 @@ interface ConversationRow {
             (attach)="chat.stageFiles($event)"
           />
           </div>
+          }
         }
       </section>
 
@@ -499,23 +557,12 @@ interface ConversationRow {
           </div>
           <div class="rail-body" [class.has-preview]="!!previewFile()">
             <!--
-              文件预览(2026-10-30):从"全屏遮罩弹窗"改为**右栏内联**。
-              看文件时往往还想对照对话内容,遮罩会把正文盖住。
-              内联模式复用同一个 app-file-preview(inline=true 时去掉遮罩与居中)。
+              文件预览(2026-10-30 重构):右栏内联,头部自带「右侧 | 全屏」切换。
+              文件名与返回按钮已收进 app-file-preview 头部,这里不再重复一行。
+              图片不进右栏(打开即走深色灯箱),见下方 app-image-lightbox。
             -->
             @if (previewFile(); as pf) {
               <div class="rail-preview">
-                <header class="rail-preview-head">
-                  <span class="ellipsis" [title]="pf.name">{{ pf.name }}</span>
-                  <ui-button
-                    variant="ghost"
-                    shape="circle"
-                    size="sm"
-                    icon="fa-solid fa-arrow-left"
-                    ariaLabel="返回文件列表"
-                    (click)="previewFile.set(null)"
-                  />
-                </header>
                 <app-file-preview
                   [open]="true"
                   [inline]="true"
@@ -625,7 +672,13 @@ interface ConversationRow {
                   <p class="rail-empty">该数字生命暂无交付成果。</p>
                 } @else {
                   @for (d of agentDeliverables(); track d.id) {
-                    <div class="rail-item">
+                    <!--
+                      🔴 与「本会话文件」同一套卡片与交互(2026-11-01 用户要求):
+                      整行可点 = 直接打开预览,右侧给出明确的进入箭头。
+                      原来是 div + 右侧两个小圆钮,点了不明显、和文件栏观感割裂。
+                      下载仍保留为独立按钮(点行是预览,点下载是保存,语义不混)。
+                    -->
+                    <button type="button" class="rail-item" (click)="preview(d)">
                       <span class="file-icon" aria-hidden="true">
                         <i class="fa-solid" [class]="fileIcon(d.fileType)"></i>
                       </span>
@@ -654,27 +707,8 @@ interface ConversationRow {
                           </small>
                         }
                       </span>
-                      <span class="rail-actions">
-                        <ui-button
-                          variant="ghost"
-                          shape="circle"
-                          size="sm"
-                          icon="fa-solid fa-eye"
-                          ariaLabel="预览"
-                          (click)="preview(d)"
-                        />
-                        @if (d.artifacts[0]?.url; as url) {
-                          <a
-                            class="dl-btn"
-                            [href]="url"
-                            [attr.download]="d.artifacts[0].name"
-                            aria-label="下载"
-                          >
-                            <i class="fa-solid fa-download" aria-hidden="true"></i>
-                          </a>
-                        }
-                      </span>
-                    </div>
+                      <i class="fa-solid fa-chevron-right" aria-hidden="true"></i>
+                    </button>
                   }
                 }
               </section>
@@ -685,24 +719,45 @@ interface ConversationRow {
       }
     </div>
     <!--
-      右栏成果预览。
-
-      🔴 以前这里**另写了一套 ui-modal**,只认 demo 数据里的 content 字段;
-      真实成果没有 content → 弹窗里只剩一个下载按钮(用户实测:「点击预览无法预览」)。
-      现在统一走 app-file-preview(图片/视频/PDF/DOCX/XLSX/HTML/MD/文本 都支持)。
+      成果预览(2026-10-31):有 url 的走右栏内联(见 preview());
+      这里只留「产物还没上云、暂无 url」的说明弹窗 —— 不假装能预览。
     -->
-    <app-file-preview
+    <ui-modal
       [open]="!!previewing()"
-      [url]="previewing()?.artifacts?.[0]?.url ?? ''"
-      [name]="previewing()?.filename ?? previewing()?.title ?? ''"
-      [mimeType]="''"
-      (closed)="previewing.set(null)"
-    />
+      title="这份成果还没有可预览的文件"
+      (openChange)="previewing.set(null)"
+    >
+      <p class="preview-pending-hint">
+        容器还在把文件搬到云盘,完成后可以在右栏「成果」里直接预览。
+      </p>
+    </ui-modal>
 
     <!--
       消息附件预览已改为**右栏内联**(2026-10-30),不再使用全屏遮罩弹窗。
       见右栏 .rail-preview 区块。
+
+      图片 / 音频 / 视频不进右栏,由 openFile 分流到下面两个轻量浮层:
     -->
+    <app-image-lightbox
+      [open]="!!lightboxFile()"
+      [url]="lightboxFile()?.url ?? ''"
+      [name]="lightboxFile()?.name ?? ''"
+      [size]="lightboxFile()?.size ?? 0"
+      (closed)="lightboxFile.set(null)"
+    />
+    <ui-modal
+      [open]="!!mediaFile()"
+      [title]="mediaFile()?.name ?? ''"
+      (openChange)="onMediaOpenChange($event)"
+    >
+      @if (mediaFile(); as mf) {
+        @if (mediaKind(mf) === 'video') {
+          <video class="media-modal-el" [src]="mf.url" controls playsinline></video>
+        } @else {
+          <audio class="media-modal-el media-modal-el--audio" [src]="mf.url" controls></audio>
+        }
+      }
+    </ui-modal>
 
     <!--
       新建对话模态框已移到外壳(app-new-session-modal)——
@@ -728,22 +783,7 @@ interface ConversationRow {
       </div>
     </ui-modal>
 
-    <!-- 重命名对话 -->
-    @if (renaming(); as r) {
-      <div class="rename-mask" (click)="renaming.set(null)"></div>
-      <div class="rename-panel" role="dialog" aria-modal="true" aria-label="重命名对话">
-        <h3>重命名对话</h3>
-        <ui-input
-          [value]="r.title"
-          placeholder="给这条对话起个名字"
-          (valueChange)="renaming.set({ id: r.id, title: $any($event) })"
-        />
-        <div class="rename-acts">
-          <ui-button variant="ghost" (click)="renaming.set(null)">取消</ui-button>
-          <ui-button variant="primary" (click)="commitRename()">保存</ui-button>
-        </div>
-      </div>
-    }
+    <!-- 重命名弹层已移除(2026-10-31 用户要求:对话命名不可修改) -->
   `,
   styles: [
     `
@@ -855,6 +895,20 @@ interface ConversationRow {
 
       /* ---- 右栏标题行 ---- */
       /* ---- 右栏内联预览 ---- */
+      /*
+       * 预览时 rail-body 不滚动、不留内边距(2026-10-31):
+       * rail-body 的滚动容器 + 预览面板的 100% 高度叠加,在浏览器整体缩放后
+       * 会把底部状态栏挤出可视区(用户截图)。预览面板内部自己滚动即可。
+       */
+      .rail-body.has-preview {
+        padding: 0;
+        overflow: hidden;
+        /* flex 列:让 .rail-preview 撑满,而不是按内容塌缩(block 下子元素无高度) */
+        display: flex;
+        flex-direction: column;
+        gap: 0;
+        align-content: normal;
+      }
       .rail-preview {
         display: flex;
         flex-direction: column;
@@ -862,25 +916,22 @@ interface ConversationRow {
         min-height: 0;
         overflow: hidden;
       }
-      .rail-preview-head {
-        display: flex;
-        align-items: center;
-        gap: var(--sp-2);
-        padding: var(--sp-2) var(--sp-2) var(--sp-2) var(--sp-3);
-        border-bottom: 1px solid var(--edge);
-        flex-shrink: 0;
+      /* 音视频模态 */
+      .media-modal-el {
+        display: block;
+        width: 100%;
+        max-height: 70dvh;
+        border-radius: var(--r-sm);
+        background: var(--surface-2);
       }
-      .rail-preview-head > .ellipsis {
-        flex: 1;
-        min-width: 0;
-        font-size: var(--fs-13);
-        font-weight: 600;
-        color: var(--ink);
+      .media-modal-el--audio {
+        max-height: none;
       }
       .rail-preview app-file-preview {
-        flex: 1;
+        /* flex 链而非 display:block:块级宿主会按内容塌缩(实测面板高度随缩放变化) */
+        flex: 1 1 0;
         min-height: 0;
-        display: block;
+        overflow: hidden;
       }
       /* 预览期间隐藏标签条 —— 已经在看具体文件了,切标签会让预览莫名消失 */
       .rail-col:has(.rail-preview) .rail-tabs {
@@ -1176,7 +1227,29 @@ interface ConversationRow {
         margin-top: var(--sp-3);
       }
 
-      /* 已归档目录(独立分组,不是从列表里抹掉) */
+      /* 无会话空态(2026-11-01):提示先新建对话,不自动建空会话 */
+      .no-session {
+        display: grid;
+        justify-items: center;
+        gap: var(--sp-3);
+        padding: var(--sp-8) var(--sp-4);
+        text-align: center;
+        color: var(--muted);
+      }
+      .no-session > i {
+        font-size: var(--fs-26);
+        color: var(--edge-strong, var(--muted));
+      }
+      .no-session-title {
+        margin: 0;
+        font-size: var(--fs-15);
+        color: var(--ink);
+      }
+      .no-session-sub {
+        margin: 0;
+        font-size: var(--fs-13);
+      }
+      /* 已归档目录 */
 
       /* 待发送附件条 */
       .pending-holds {
@@ -1219,6 +1292,56 @@ interface ConversationRow {
         flex: none;
         background: var(--surface-2);
       }
+      /* 图片待发送卡片:缩略图铺满,操作条压底部,右上角移除 */
+      .pending-card {
+        position: relative;
+        width: 132px;
+        height: 96px;
+        border-radius: var(--r-md);
+        overflow: hidden;
+        background: var(--surface-2);
+        box-shadow: var(--shadow-soft, none);
+      }
+      .pending-card-img {
+        display: block;
+        width: 100%;
+        height: 100%;
+        padding: 0;
+        border: 0;
+        background: none;
+        cursor: zoom-in;
+        object-fit: cover;
+      }
+      .pending-card-act {
+        position: absolute;
+        left: 0;
+        right: 0;
+        bottom: 0;
+        display: flex;
+        align-items: center;
+        min-height: 26px;
+        padding: 0 var(--sp-2);
+        font-size: var(--fs-11);
+        color: var(--inverse);
+        background: color-mix(in srgb, var(--canvas) 62%, transparent);
+        backdrop-filter: blur(2px);
+        white-space: nowrap;
+        pointer-events: none;
+      }
+      .pending-x--float {
+        position: absolute;
+        top: 4px;
+        right: 4px;
+        border-radius: var(--r-tag);
+        background: color-mix(in srgb, var(--canvas) 60%, transparent);
+        color: var(--inverse);
+        opacity: 0;
+        transition: opacity var(--motion-fast) var(--ease-standard);
+      }
+      .pending-card:hover .pending-x--float,
+      .pending-card:focus-within .pending-x--float {
+        opacity: 1;
+      }
 
       .pending-name {
         min-width: 0;
@@ -1282,53 +1405,21 @@ interface ConversationRow {
         color: var(--faint);
       }
 
-      /* ---------- 重命名对话 ---------- */
-      .rename-mask {
-        position: fixed;
-        inset: 0;
-        z-index: var(--z-modal);
-        background: color-mix(in srgb, var(--canvas) 60%, transparent);
-      }
-      .rename-panel {
-        position: fixed;
-        z-index: calc(var(--z-modal) + 1);
-        top: 50%;
-        left: 50%;
-        transform: translate(-50%, -50%);
-        width: min(420px, calc(100vw - var(--sp-8)));
-        padding: var(--sp-5);
-        border: 1px solid var(--edge);
-        border-radius: var(--r-lg);
-        background: var(--surface);
-        box-shadow: var(--shadow-pop);
-      }
-      .rename-panel h3 {
-        margin: 0 0 var(--sp-4);
-        font-size: var(--fs-15);
-        color: var(--ink);
-      }
-      .rename-acts {
-        display: flex;
-        justify-content: flex-end;
-        gap: var(--sp-2);
-        margin-top: var(--sp-4);
+      /* 头部:当前任务名(只读,2026-10-31 取消改名) */
+      .preview-pending-hint {
+        margin: 0;
+        color: var(--ink-2);
+        font-size: var(--fs-13);
+        line-height: 1.6;
       }
-      /* 头部:当前对话名(可点击重命名) */
       .session-name {
         display: inline-flex;
         align-items: center;
         gap: var(--sp-1);
-        cursor: pointer;
-        border-radius: var(--r-sm);
-        padding: 0 var(--sp-1);
+        cursor: default;
       }
       .session-name:hover {
         color: var(--ink-2);
-        background: var(--surface-hover);
-      }
-      .session-name:focus-visible {
-        outline: 2px solid var(--primary);
-        outline-offset: 1px;
       }
       .archived-tag {
         margin-left: var(--sp-1);
@@ -1817,24 +1908,6 @@ interface ConversationRow {
       .rail-item:hover {
         background: var(--surface-hover);
       }
-      .rail-actions {
-        display: flex;
-        align-items: center;
-        gap: 2px;
-        flex-shrink: 0;
-      }
-      .dl-btn {
-        display: grid;
-        place-items: center;
-        width: 32px;
-        height: 32px;
-        border-radius: var(--r-sm);
-        color: var(--primary);
-        text-decoration: none;
-      }
-      .dl-btn:hover {
-        background: var(--surface-active);
-      }
       .file-icon {
         display: grid;
         place-items: center;
@@ -1877,6 +1950,8 @@ interface ConversationRow {
 export class DkChatPage {
   readonly chat = inject(ChatService);
   readonly live = inject(LiveService);
+  /** 重连时重建常驻 Matrix 连接(登出/断线后需要) */
+  private readonly matrixSession = inject(MatrixSessionService);
   private readonly unread = inject(UnreadService);
   private readonly deliverables = inject(DeliverablesService);
   private readonly tasks = inject(TasksService);
@@ -1897,6 +1972,14 @@ export class DkChatPage {
    * `undefined` = 未打开;`null` = 日常对话;`string` = 工作区 id。
    */
   readonly newSessionWorkspace = signal<string | null | undefined>(undefined);
+
+  /** 「新建对话」模态框(左栏按钮与空态引导共用) */
+  private readonly newSessionModal = inject(NewSessionModalService);
+
+  /** 空态里点「新建对话」→ 打开容器选择框 */
+  requestNewSession(): void {
+    this.newSessionModal.open(null);
+  }
   readonly railOpen = signal(true);
   /** 右栏内联预览的成果 */
   readonly previewing = signal<Deliverable | null>(null);
@@ -1938,7 +2021,9 @@ export class DkChatPage {
   private resizeStartX = 0;
   private readonly onResizeMove = (e: PointerEvent) => {
     /* 向左拖 = 变宽:宽度增长量是「起点 X - 当前 X」 */
-    const next = clampRailWidth(this.resizeFrom + (this.resizeStartX - e.clientX));
+    const raw = this.resizeFrom + (this.resizeStartX - e.clientX);
+    /* 预览形态下放宽上限;非预览形态回到 560 */
+    const next = this.previewFile() ? clampPreviewRailWidth(raw) : clampRailWidth(raw);
     this._railWidth.set(next);
   };
   private readonly onResizeEnd = () => {
@@ -1984,8 +2069,6 @@ export class DkChatPage {
   readonly expandedAgents = signal<Set<string>>(new Set());
   /** 左栏:哪些容器的「已归档」目录是展开的 */
   readonly archivedOpen = signal<Set<string>>(new Set());
-  /** 正在重命名的会话(null = 无) */
-  readonly renaming = signal<{ id: string; title: string } | null>(null);
   /** 待确认移除的会话(null = 无) */
   readonly removing = signal<ChatSession | null>(null);
   /** 当前数字生命的任务(右栏任务面板用) */
@@ -2134,11 +2217,53 @@ export class DkChatPage {
    * 🔴 必须同时**打开右栏**:预览已经改成右栏内联,
    *    若用户之前收起了右栏,只设 previewFile 是看不到任何反应的(实测 bug)。
    */
-  openFile(f: { url: string; name: string; mimeType: string }): void {
+  openFile(f: { url: string; name: string; mimeType: string; size?: number }): void {
+    const kind = previewKindOf(f.name || f.url, f.mimeType);
+    /*
+     * 图片 / 音视频不进右栏(用户裁决 2026-10-30):
+     * - 图片 → 深色灯箱,占满视口
+     * - 音视频 → 居中模态,原生控件自带全屏
+     * 其余类型 → 右栏内联预览(可切全屏)。
+     */
+    if (kind === 'image') {
+      this.lightboxFile.set({ url: f.url, name: f.name, size: f.size ?? 0 });
+      return;
+    }
+    if (kind === 'video' || kind === 'audio') {
+      this.mediaFile.set({ url: f.url, name: f.name, kind });
+      return;
+    }
     this.previewFile.set(f);
     this.railOpen.set(true);
   }
 
+  /* ---- 图片灯箱 / 音视频模态(2026-10-30)---- */
+  readonly lightboxFile = signal<{ url: string; name: string; size: number } | null>(null);
+
+  /** 待发送图片点缩略图 → 用同一个灯箱预览(2026-10-31) */
+  previewPending(pf: { file: File; previewUrl?: string }): void {
+    const url = pf.previewUrl;
+    if (!url) return;
+    this.lightboxFile.set({ url, name: pf.file.name, size: pf.file.size });
+  }
+  readonly mediaFile = signal<{ url: string; name: string; kind: PreviewKind } | null>(null);
+
+  mediaKind(m: { kind: PreviewKind }): 'video' | 'audio' {
+    return m.kind === 'video' ? 'video' : 'audio';
+  }
+
+  onMediaOpenChange(open: boolean): void {
+    if (!open) this.mediaFile.set(null);
+  }
+
+  /**
+   * 预览形态下的右栏显示宽度。
+   * 拖拽时已按预览上限夹取;这里再兜底一次,防止旧记忆值超出。
+   */
+  readonly effectiveRailWidth = computed(() =>
+    this.previewFile() ? clampPreviewRailWidth(this.railWidth()) : this.railWidth(),
+  );
+
   /** 真实未读数(UnreadService 唯一来源;不用 Agent.unread 的占位值) */
   unreadOf(agentId: string): number {
     return this.unread.countOf(agentId);
@@ -2263,15 +2388,17 @@ export class DkChatPage {
       this.tasks.ensureAllLoaded(),
     ]);
     if (id) {
-      await this.chat.open(id, sessionId ?? undefined);
       /*
-       * 打开后清一次空壳会话。
-       *
-       * 反复进出/测试会留下大量 0 条消息的「新对话」,列表上看起来像重复项
-       * (用户反馈:同一容器下出现三个一模一样的条目)。
-       * 这里保留**当前打开的这条**(它可能正准备发第一条消息),其余空壳清掉。
+       * `sessionId === 'new'` = 「新建对话」的待建地址(不落任何数据)。
+       * 2026-11-01:新建对话不再预建空会话,而是打开一个空白待建态,
+       * 用户发出第一条消息时才真正创建会话。
        */
-      this.sessions.pruneEmpty(id, this.chat.sessionId() ?? undefined);
+      if (sessionId === 'new') {
+        const ws = this.route.snapshot.queryParamMap.get('ws');
+        await this.chat.startBlank(id, ws || null);
+      } else {
+        await this.chat.open(id, sessionId ?? undefined);
+      }
       // 打开后内容不满一屏 → 自动补历史(懒加载的另一半)
       void this.fillViewportIfNeeded();
     } else {
@@ -2368,27 +2495,6 @@ export class DkChatPage {
     this.agentModal.open(agentId);
   }
 
-  /**
-   * 重新生成(气泡操作条)。
-   *
-   * 容器没有「撤回并重答」的接口,因此实现的语义是**要求它重做一次**:
-   * 发出一句明确的追问,把上一轮的约束带回去。这不是「重放」,是「让它再来一遍」——
-   * 用户点这个按钮想要的结果一致,但不会覆盖原回答(原回答可对比)。
-   */
-  regenerate(messageId: string): void {
-    const msg = this.chat.messages().find((m) => m.id === messageId);
-    if (!msg) return;
-    // 找到这条之前最近的一条己方消息,作为「原来要求它做什么」的依据
-    const list = this.chat.messages();
-    const idx = list.findIndex((m) => m.id === messageId);
-    const prevAsk = [...list.slice(0, idx)].reverse().find((m) => m.sender === 'me');
-    const ask = prevAsk?.text?.trim();
-    const text = ask
-      ? `上一条答复我不满意,请基于下面的要求重新做一遍(不要引用之前的结论,直接给新结果):\n\n${ask}`
-      : '上一条答复我不满意,请重新做一遍,直接给新结果。';
-    void this.chat.sendText(text);
-  }
-
   /**
    * 空会话欢迎区:按小时问候 + 4 条起点建议。
    *
@@ -2455,34 +2561,16 @@ export class DkChatPage {
   newSession(agentId: string, event?: Event, workspaceId: string | null = null): void {
     event?.stopPropagation();
     /*
-     * 🔴 已经有**空对话**时直接复用,不再新建。
+     * 「新建对话」**不创建任何数据**,只是把界面切到该容器的空白待建态
+     * (2026-11-01 用户裁决:首次发消息才建会话)。
      *
-     * 原实现每次点「+」都建一条 —— 用户反复点几下就出现多个「新对话」,
-     * 看起来像重复数据(实测:同一容器下三条一样的条目)。
-     * 空对话(0 条消息)没有任何内容可丢,复用它才是用户预期。
+     * 这样点几次「+」就留几条空壳的问题从根上消失 —— 旧实现要么建空会话、
+     * 要么得靠 `pruneEmpty` 事后清理,两者都会出错。
+     * 工作区归属通过 URL 传过去,由聊天页在会话建出来那一刻落地。
      */
-    const existingEmpty = this.sessions
-      .listFor(agentId)
-      .find((s) => !s.legacy && (s.messageCount ?? 0) === 0);
-    if (existingEmpty) {
-      /*
-       * 复用的空对话若归属不对,跟着改到目标工作区 ——
-       * 用户是在「工作区 A」下点的「新建对话」,结果对话跑到日常去了,
-       * 会被当成功能坏了(本轮之前就是这个表现)。
-       */
-      if ((existingEmpty.workspaceId ?? null) !== workspaceId) {
-        this.sessions.moveTo(existingEmpty.id, workspaceId);
-      }
-      this.expandedAgents.update((set) => new Set(set).add(agentId));
-      this.openSession(agentId, existingEmpty.id);
-      return;
-    }
-    // 房间 id 由 ChatService 在打开时补全;此处先用已知映射,未知则留空待补
-    const roomId = this.sessions.latestFor(agentId)?.roomId ?? '';
-    const s = this.sessions.create(roomId, agentId);
-    if (workspaceId) this.sessions.moveTo(s.id, workspaceId);
     this.expandedAgents.update((set) => new Set(set).add(agentId));
-    this.openSession(agentId, s.id);
+    const qs = workspaceId ? `?ws=${encodeURIComponent(workspaceId)}` : '';
+    void this.router.navigateByUrl(`/${this.device.mode()}/chat/${agentId}/new${qs}`);
   }
 
   /** 点删除 → 先弹确认(删除类操作必须二次确认) */
@@ -2532,20 +2620,6 @@ export class DkChatPage {
     });
   }
 
-  /** 开始重命名 */
-  startRename(s: ChatSession, event?: Event): void {
-    event?.stopPropagation();
-    this.renaming.set({ id: s.id, title: s.title });
-  }
-
-  /** 提交重命名 */
-  commitRename(): void {
-    const r = this.renaming();
-    if (!r) return;
-    this.sessions.rename(r.id, r.title);
-    this.renaming.set(null);
-  }
-
   /** 会话行的时间显示(今天显示时分,更早显示日期) */
   sessionTime(ts: number): string {
     if (!ts) return '';
@@ -2568,14 +2642,23 @@ export class DkChatPage {
     await this.live.refresh();
   }
 
+  /**
+   * 重连 / 重新加载。
+   *
+   * 🔴 必须走 `force`:`chat.open()` 在「同一 agent + 同一会话 + state=ready」时
+   *    会短路返回,而断线时 state 仍是 ready —— 不 force 的话按了完全没有反应
+   *    (用户实测:「点击这个重新连接没有任何效果」)。
+   */
   reconnect(): void {
     const id = this.chat.agentId();
-    if (id) void this.chat.open(id);
+    if (id) void this.chat.open(id, this.chat.sessionId() || undefined, true);
+    // Matrix 连接也可能已经断开,顺带重建一次(幂等,已连上时直接返回)
+    void this.matrixSession.kick();
   }
 
   retryOpen(): void {
     const id = this.chat.agentId();
-    if (id) void this.chat.open(id);
+    if (id) void this.chat.open(id, this.chat.sessionId() || undefined, true);
   }
 
   /** 切换右栏面板:同面板再点则隐藏 */
@@ -2609,9 +2692,23 @@ export class DkChatPage {
     if (id) void this.router.navigate(['/', this.device.mode(), 'tasks', id]);
   }
 
-  /** 右栏内联预览 */
+  /**
+   * 成果预览(2026-10-31 用户要求):改为**右栏内联**,与消息附件共用同一个预览面板。
+   *
+   * 原来走全屏遮罩弹窗,和右侧预览是两套观感;现在统一走 `previewFile` 这条链路。
+   * 产物还没有 url(仍在容器里)时如实提示,不给空白预览。
+   */
   preview(d: Deliverable): void {
-    this.previewing.set(d);
+    const url = d.artifacts?.[0]?.url ?? '';
+    if (!url) {
+      this.previewing.set(d);
+      return;
+    }
+    this.openFile({
+      url,
+      name: d.filename ?? d.title ?? '成果',
+      mimeType: d.artifacts?.[0]?.type ?? '',
+    });
   }
 
   fileIcon(type?: string): string {

+ 31 - 24
src/app/desktop/pages/groups/groups.ts

@@ -12,6 +12,8 @@ import { ActivatedRoute, Router } from '@angular/router';
 import { UiAlert, UiAvatar, UiButton, UiEmpty, UiSkeleton } from '../../../ui';
 import { MessageBubbleComponent } from '../../../components/domain/message-bubble/message-bubble';
 import { FilePreviewComponent } from '../../../components/preview/file-preview/file-preview';
+import { ImageLightboxComponent } from '../../../components/preview/image-lightbox/image-lightbox';
+import { previewKindOf } from '../../../components/preview/file-kind';
 import { Agent, Message } from '../../../core/api/models';
 import { ChatService } from '../../../core/chat.service';
 import { crossOwnerWarning, detectCrossOwner } from '../../../core/cross-owner';
@@ -58,7 +60,7 @@ import { FmodeApi } from '../../../core/api/fmode-api';
 @Component({
   selector: 'app-dk-group-page',
   changeDetection: ChangeDetectionStrategy.OnPush,
-  imports: [UiAlert, UiAvatar, UiButton, UiEmpty, UiSkeleton, MessageBubbleComponent, FilePreviewComponent],
+  imports: [UiAlert, UiAvatar, UiButton, UiEmpty, UiSkeleton, MessageBubbleComponent, FilePreviewComponent, ImageLightboxComponent],
   template: `
     <div class="group-layout">
       <!-- 群列表 -->
@@ -566,6 +568,15 @@ import { FmodeApi } from '../../../core/api/fmode-api';
         </aside>
       }
 
+      <!-- 图片灯箱(2026-10-30):图片不进预览弹窗 -->
+      <app-image-lightbox
+        [open]="!!lightboxFile()"
+        [url]="lightboxFile()?.url ?? ''"
+        [name]="lightboxFile()?.name ?? ''"
+        [size]="lightboxFile()?.size ?? 0"
+        (closed)="lightboxFile.set(null)"
+      />
+
       <!-- 附件预览:与 DM 共用同一个预览组件 -->
       <app-file-preview
         [open]="!!previewFile()"
@@ -1220,9 +1231,10 @@ export class DkGroupPage {
    *   你问「做个 Excel 表格」→ 它反问一堆 → 你说「随机即可」→ 它答「不知道你在说什么」。
    *
    * 组长制并不能替代上下文:组长也是容器,同样没有跨消息记忆。
-   * 所以默认带上最近对话,用户可在设置里关掉(省 token)。
+   * 所以**始终带上最近对话**(2026-11-01 用户要求删掉开关):
+   * 关掉后的表现是「答非所问」,等于坏掉,没有保留成选项的意义。
    */
-  readonly attachContext = signal(this.readContextPref());
+  readonly attachContext = true;
   readonly mentionTargets = signal<Set<string>>(new Set());
   /** 上滚加载历史中(防重入) */
   readonly paginating = signal(false);
@@ -1579,25 +1591,13 @@ export class DkGroupPage {
     }
   }
 
-  /** 读「附带最近对话」偏好(默认开) */
-  private readContextPref(): boolean {
-    try {
-      const v = localStorage.getItem('fmode_group_attach_context');
-      return v === null ? true : v === '1';
-    } catch {
-      return true;
-    }
-  }
-
-  /** 切换偏好并持久化 */
-  setAttachContext(on: boolean): void {
-    this.attachContext.set(on);
-    try {
-      localStorage.setItem('fmode_group_attach_context', on ? '1' : '0');
-    } catch {
-      /* 隐私模式下忽略 */
-    }
-  }
+  /**
+   * 群聊是否附带最近对话 —— **已固定为总是带上**(2026-11-01 用户要求删掉该开关)。
+   *
+   * 原先是设置页的一个开关(localStorage `fmode_group_attach_context`)。
+   * 保留这个常量是为了不动 `buildContext(..., { enabled })` 的调用签名;
+   * 关掉后的表现是「容器答非所问」,等于功能坏掉,不值得作为选项保留。
+   */
 
   /** 正在编辑角色的成员 */
   readonly editing = signal<Agent | null>(null);
@@ -1942,8 +1942,15 @@ export class DkGroupPage {
 
   /** 消息附件预览(与 DM 共用同一个预览器) */
   readonly previewFile = signal<{ url: string; name: string; mimeType: string } | null>(null);
+  /** 图片单独走灯箱(2026-10-30:图片不进预览弹窗) */
+  readonly lightboxFile = signal<{ url: string; name: string; size: number } | null>(null);
 
-  openFile(f: { url: string; name: string; mimeType: string }): void {
+  openFile(f: { url: string; name: string; mimeType: string; size?: number }): void {
+    // 图片不进预览弹窗,直接打开灯箱(2026-10-30)
+    if (previewKindOf(f.name || f.url, f.mimeType) === 'image') {
+      this.lightboxFile.set({ url: f.url, name: f.name, size: f.size ?? 0 });
+      return;
+    }
     this.previewFile.set(f);
   }
 
@@ -2032,7 +2039,7 @@ export class DkGroupPage {
         ? targets.length === 1 && targets[0].id === leaderNow.id
         : targets.length <= 1;
       const ctx = toLeader
-        ? buildContext(this.messages(), { enabled: this.attachContext() })
+        ? buildContext(this.messages(), { enabled: this.attachContext })
         : '';
       /*
        * 「继续刚才的活」这类指令:追加一句「先查产物」提醒。

+ 115 - 75
src/app/desktop/pages/mine/mine.ts

@@ -9,7 +9,6 @@ import {
   DEFAULT_CHAT_FONT,
 } from '../../../core/chat-font.service';
 import { SessionService } from '../../../core/session.service';
-import { environment } from '../../../../environments/environment';
 
 /**
  * 桌面·我的(SPEC FR-10)
@@ -29,8 +28,8 @@ import { environment } from '../../../../environments/environment';
   template: `
     <app-page [wide]="true">
       <div class="settings-layout">
-        <!-- 账号面板 -->
-        <ui-card class="account-card">
+        <!-- 账号面板(滚动时粘住跟随,见 .settings-aside) -->
+        <ui-card class="account-card settings-aside">
           <div class="card-kicker"><i class="fa-solid fa-shield-halved" aria-hidden="true"></i>账户中心</div>
           <div class="identity">
             <ui-avatar [name]="me()?.name ?? '?'" size="lg" />
@@ -141,43 +140,42 @@ import { environment } from '../../../../environments/environment';
             </div>
 
             <!--
-              群聊上下文开关(用户要求放设置里,不要放在对话页)。
-              默认开启:关掉后容器完全没有上下文,会答非所问(实测)。
+              群聊附带最近对话的开关已移除(2026-11-01 用户要求删掉这个功能)。
+              群聊发送时的上下文拼接保持在默认开启(关掉会让容器答非所问)。
             -->
-            <div class="setting-block">
-              <div class="setting-copy">
-                <h4>群聊附带最近对话</h4>
-                <p>开启后,你在工作组里发的消息会带上最近几条对话,让数字生命记得前文。</p>
-              </div>
-              <label class="ctx-switch">
-                <input
-                  type="checkbox"
-                  [checked]="attachContext()"
-                  (change)="setAttachContext($any($event.target).checked)"
-                />
-                <span>{{ attachContext() ? '已开启' : '已关闭' }}</span>
-              </label>
-            </div>
           </ui-card>
 
-          <ui-card class="settings-card" title="账号与凭证">
-            <div class="setting-row">
+          <ui-card class="settings-card" title="登录凭证">
+            <div class="setting-block">
               <div class="setting-copy">
-                <h4>登录凭证</h4>
-                <p>如遇连接异常可尝试修复。</p>
+                <h4>当前登录 Token</h4>
+                <p>可用于其他设备直接登录。等同于账号密码,请勿分享给他人。</p>
+              </div>
+              <div class="token-row">
+                <code class="token-value" [class.masked]="!tokenShown()">{{ tokenText() }}</code>
+                <ui-button
+                  variant="outline"
+                  size="sm"
+                  [icon]="tokenShown() ? 'fa-solid fa-eye-slash' : 'fa-solid fa-eye'"
+                  (click)="tokenShown.set(!tokenShown())"
+                >
+                  {{ tokenShown() ? '隐藏' : '显示' }}
+                </ui-button>
+                <ui-button
+                  variant="outline"
+                  size="sm"
+                  icon="fa-solid fa-copy"
+                  [disabled]="!tokenShown()"
+                  (click)="copyToken()"
+                >
+                  {{ copied() ? '已复制' : '复制' }}
+                </ui-button>
               </div>
-              <!--
-                「修复 Token」= 跳回登录页重新获取凭证。
-                原名含糊(用户实测问「这是什么意思」),故改名 + 补说明。
-              -->
-              <ui-button variant="outline" icon="fa-solid fa-rotate" (click)="fixToken()">
-                重新登录
-              </ui-button>
             </div>
           </ui-card>
 
           <footer class="about">
-            <span>Fmode <span class="mono">{{ version }}</span></span>
+            <span>Fmode</span>
           </footer>
         </div>
       </div>
@@ -248,23 +246,29 @@ import { environment } from '../../../../environments/environment';
         gap: var(--sp-4);
         align-items: start;
       }
-      @include bp.dk-below-4col {
-        /* 字体大小滑块区:滑块在上、小/默认/大 说明在下 */
-      .font-scale {
-        display: grid;
-        gap: var(--sp-1);
-        margin-top: var(--sp-2);
-      }
-      .font-legend {
-        display: flex;
-        justify-content: space-between;
-        font-size: var(--fs-11);
-        color: var(--muted);
-      }
-
-      .settings-layout {
+      /*
+        左栏粘住跟随(2026-11-01 用户要求)。
+        右侧设置项很长,滚动时左侧账号卡跟着走,随时能看到当前身份和退出入口。
+        top 用变量:顶栏固定时内容区才有偏移,没有顶栏时为 0。
+      */
+      .settings-aside {
+        position: sticky;
+        top: var(--sp-4);
+        align-self: start;
+      }
+      @include bp.below-mobile {
+        /*
+          🔴 只在**真正的窄屏**才改成上下排列(2026-11-01 用户反馈)。
+          原来用的是 dk-below-4col(1150px)—— 一旦放大字体/缩放页面,
+          有效宽度掉到 1150 以下就变成上下堆叠,右侧设置区被挤到屏幕外。
+        */
+        .settings-layout {
           grid-template-columns: minmax(0, 1fr);
         }
+        /* 单列时不需要粘住:会把右侧内容压到屏幕外 */
+        .settings-aside {
+          position: static;
+        }
       }
 
       .identity {
@@ -350,6 +354,32 @@ import { environment } from '../../../../environments/environment';
         color: var(--muted);
       }
 
+      /* 登录 Token 行(2026-11-01):等宽字体 + 可横向滚动,长 token 不撑破卡片 */
+      .token-row {
+        display: flex;
+        align-items: center;
+        gap: var(--sp-2);
+        margin-top: var(--sp-3);
+        flex-wrap: wrap;
+      }
+      .token-value {
+        flex: 1 1 220px;
+        min-width: 0;
+        padding: var(--sp-2) var(--sp-3);
+        border: 1px solid var(--edge);
+        border-radius: var(--r-sm);
+        background: var(--surface-2);
+        font-family: var(--font-mono, ui-monospace, monospace);
+        font-size: var(--fs-12);
+        color: var(--ink);
+        overflow-x: auto;
+        white-space: nowrap;
+        user-select: all;
+      }
+      .token-value.masked {
+        letter-spacing: 0.05em;
+        color: var(--muted);
+      }
       /* 配色选择器:流动网格,每格渲染该套配色自身的色值 */
       .palettes {
         display: grid;
@@ -450,10 +480,6 @@ import { environment } from '../../../../environments/environment';
         font-size: var(--fs-11);
         color: var(--muted);
       }
-      .mono {
-        font-family: var(--mono, monospace);
-        color: var(--ink-2);
-      }
 
       .dialog-text {
         margin: 0 0 var(--sp-4);
@@ -523,32 +549,44 @@ export class DkMinePage {
 
   readonly palettes = PALETTES;
 
+  readonly confirming = signal(false);
+
+  // ---------- 登录 Token(2026-11-01:设置页可查看,用于其它设备登录) ----------
+
+  /** 是否已点「显示」(默认打码,避免旁人一眼看到) */
+  readonly tokenShown = signal(false);
+  /** 复制后的短暂反馈 */
+  readonly copied = signal(false);
+
+  private readonly rawToken = computed(() => this.session.token() ?? '');
+
   /**
-   * 群聊是否附带最近对话(默认开)。
+   * 展示用的 Token 文本。
    *
-   * 与群聊页读同一个 localStorage 键,保证两边一致。
+   * 🔴 默认打码:设置页经常在别人能看到的屏幕上打开,Token 等同于密码。
+   *    打码规则是「保留前 4 位 + 省略号 + 后 4 位」,足够用户核对是哪一张。
    */
-  readonly attachContext = signal(this.readCtxPref());
-
-  private readCtxPref(): boolean {
-    try {
-      const v = localStorage.getItem('fmode_group_attach_context');
-      return v === null ? true : v === '1';
-    } catch {
-      return true;
-    }
-  }
+  readonly tokenText = computed(() => {
+    const t = this.rawToken();
+    if (!t) return '未登录';
+    if (!this.tokenShown()) return `${t.slice(0, 4)}${'•'.repeat(12)}${t.slice(-4)}`;
+    return t;
+  });
 
-  setAttachContext(on: boolean): void {
-    this.attachContext.set(on);
-    try {
-      localStorage.setItem('fmode_group_attach_context', on ? '1' : '0');
-    } catch {
-      /* 隐私模式忽略 */
-    }
+  /** 复制 Token(仅「显示」状态下允许,避免误把打码串复制走) */
+  copyToken(): void {
+    const t = this.rawToken();
+    if (!t) return;
+    void navigator.clipboard
+      ?.writeText(t)
+      .then(() => {
+        this.copied.set(true);
+        setTimeout(() => this.copied.set(false), 1600);
+      })
+      .catch(() => {
+        /* 剪贴板不可用(非 https / 无权限):静默失败,用户可手动选中复制 */
+      });
   }
-  readonly version = environment.version;
-  readonly confirming = signal(false);
   readonly themeOptions = [
     { label: '浅色', value: 'light' },
     { label: '深色', value: 'dark' },
@@ -594,10 +632,12 @@ export class DkMinePage {
     if (meta && canvas) meta.setAttribute('content', canvas);
   }
 
-  fixToken(): void {
-    void this.router.navigate(['/login'], { queryParams: { redirect: this.router.url } });
-  }
-
+  /**
+   * 「重新登录」按钮已移除(2026-11-01 用户裁决:没有实际价值)。
+   *
+   * 它原本只是「清态 + 回登录页」,与左侧的「退出登录」功能重叠 ——
+   * 用户要换账号或换凭据,直接退出登录即可,多一个入口只会让人困惑该点哪个。
+   */
   doLogout(): void {
     this.session.logout();
     this.confirming.set(false);

+ 36 - 2
src/app/desktop/pages/projects/projects.ts

@@ -191,6 +191,15 @@ import { Deliverable } from '../../../core/api/models';
         gap: var(--sp-4);
         margin-bottom: var(--sp-4);
         flex-wrap: wrap;
+        /*
+         * 🔴 这里**不需要** position: sticky(2026-11-01 澄清)。
+         *
+         * 页面已用 app-page [fixedHeader]="true":标题区在 .fixed-zone,
+         * 工具栏与列表都在 .scroll-zone 里。所以滚动时工具栏本来就会跟着
+         * 列表区一起走 —— 用户反馈的「搜索框粘在顶部」是列表自己内滚
+         * (max-height + overflow-y)造成的错觉,不是粘顶。
+         * 真正的修法是让两栏各自滚动(见 .list-pane / .detail-pane)。
+         */
       }
       .total {
         font-size: var(--fs-13);
@@ -215,24 +224,49 @@ import { Deliverable } from '../../../core/api/models';
         grid-template-columns: minmax(240px, 26%) minmax(0, 1fr);
         gap: var(--sp-4);
         align-items: start;
+        /*
+         * 两栏各自滚动的高度上限(2026-11-01 用户要求)。
+         *
+         * 用「视口高度 − 该页滚动区已占用的部分」而不是固定值:
+         * 顶栏(约 68) + 页头标题(约 96) + 工具栏(约 56) + 页面内边距(约 48)。
+         * 这个和一旦与实际布局不符,两栏底部就会被截断或留白。
+         */
+        --pane-offset: 268px;
+        height: calc(100dvh - var(--pane-offset));
       }
       @include bp.dk-below-3col {
         .split {
           grid-template-columns: minmax(0, 1fr);
+          /* 单列时两栏上下排列,不再限制高度 —— 否则详情被截断在视口内看不清 */
+          height: auto;
+        }
+        .list-pane,
+        .detail-pane {
+          height: auto;
+          max-height: none;
+          overflow-y: visible;
         }
       }
 
-      /* 列表限高内滚:数据多时不撑长整页(企业级列表的通行做法) */
+      /*
+        两栏各自独立滚动(2026-11-01 用户要求:左侧列表与右侧详情分开滚,
+        而不是整块页面一起滚、左侧被挤到顶部黏住)。
+        .split 已给定高度,子项 height:100% 撑满,各自 overflow-y: auto。
+      */
       .list-pane {
         display: grid;
         gap: var(--sp-2);
         align-content: start;
-        max-height: calc(100dvh - 260px);
+        height: 100%;
         overflow-y: auto;
         padding-right: var(--sp-1);
+        scroll-padding-top: var(--sp-2);
       }
       .detail-pane {
         min-width: 0;
+        height: 100%;
+        overflow-y: auto;
+        padding-right: var(--sp-1);
       }
 
       .project-row {

+ 7 - 6
src/app/desktop/pages/tasks/tasks.ts

@@ -54,7 +54,6 @@ import { fmtDateTime, fmtDuration, fmtTime } from '../../../core/time';
     <app-page [fixedHeader]="true"
       title="任务看板"
       subtitle="关注真实任务的状态、运行过程与阻塞原因。左侧选择任务查看详情。"
-      [showBack]="true" backText="返回" (back)="goBack()"
     >
       <ng-container pageActions>
         <ui-tag label="只读视图" icon="fa-solid fa-lock" size="sm" />
@@ -560,11 +559,13 @@ export class DkTasksPage implements OnInit {
   }
 
   /** 回退到上一个路由(无历史时回工作台) */
-  goBack(): void {
-    if (typeof history !== 'undefined' && history.length > 1) history.back();
-    else void this.router.navigate(['/', this.device.mode(), 'workbench']);
-  }
-
+  /**
+   * 页头「返回」按钮已移除(2026-11-01 用户要求)。
+   *
+   * 任务看板是顶层导航页(左栏「任务」直接可达),不是从别的页面钻进来的详情页 ——
+   * 在这里给「返回」等于暗示用户「你应该回到上一步」,而用户本来就没失去位置。
+   * 全局导航已经由左栏承担。
+   */
   openChat(agentId: string): void {
     void this.router.navigate(['/', this.device.mode(), 'chat', agentId]);
   }

+ 2 - 2
src/app/desktop/pages/workbench/workbench.ts

@@ -262,8 +262,8 @@ import { fmtDuration } from '../../../core/time';
         </div>
         <dl class="kv">
           <div><dt>连接状态</dt><dd>{{ ready(a) ? '已接入' : '未接入' }}</dd></div>
-          <div><dt>通道类型</dt><dd>{{ a.channels?.matrix ? 'Matrix' : '—' }}</dd></div>
-          <div><dt>服务端</dt><dd class="ellipsis">{{ a.channels?.homeserver || '—' }}</dd></div>
+          <div><dt>通道类型</dt><dd>{{ a.channels?.matrix ? '在线通道' : '—' }}</dd></div>
+          <div><dt>接入状态</dt><dd class="ellipsis">{{ ready(a) ? '正常' : '未接入' }}</dd></div>
           <div><dt>最近心跳</dt><dd>{{ a.heartbeatAt || '—' }}</dd></div>
         </dl>
         <p class="drawer-note">此处仅展示连接信息,不展示任何凭证。</p>

+ 24 - 5
src/app/mobile/pages/chat/chat.ts

@@ -15,6 +15,8 @@ import { ActivatedRoute, Router } from '@angular/router';
 import { ComposerComponent } from '../../../components/domain/composer/composer';
 import { MessageBubbleComponent } from '../../../components/domain/message-bubble/message-bubble';
 import { FilePreviewComponent } from '../../../components/preview/file-preview/file-preview';
+import { ImageLightboxComponent } from '../../../components/preview/image-lightbox/image-lightbox';
+import { previewKindOf } from '../../../components/preview/file-kind';
 import { TypingIndicatorComponent } from '../../../components/domain/typing-indicator/typing-indicator';
 import { UiMButton, UiMNoticeBar, UiMPopup, UiMSkeleton } from '../../../ui';
 import { Deliverable } from '../../../core/api/models';
@@ -34,7 +36,7 @@ import { ChatSessionService } from '../../../core/chat-session.service';
 @Component({
   selector: 'app-mb-chat-page',
   changeDetection: ChangeDetectionStrategy.OnPush,
-  imports: [ComposerComponent, MessageBubbleComponent, TypingIndicatorComponent, MStatePanelComponent, MStatusBadgeComponent, MAvatarComponent, UiMButton, UiMNoticeBar, UiMPopup, UiMSkeleton, FilePreviewComponent],
+  imports: [ComposerComponent, MessageBubbleComponent, TypingIndicatorComponent, MStatePanelComponent, MStatusBadgeComponent, MAvatarComponent, UiMButton, UiMNoticeBar, UiMPopup, UiMSkeleton, FilePreviewComponent, ImageLightboxComponent],
   template: `
     <div class="full-chat m-page--fixed">
       <header class="chat-head">
@@ -262,6 +264,14 @@ import { ChatSessionService } from '../../../core/chat-session.service';
       [mimeType]="filePreview()?.mimeType ?? ''"
       (closed)="filePreview.set(null)"
     />
+    <!-- 图片灯箱(2026-10-30):图片不进预览弹窗 -->
+    <app-image-lightbox
+      [open]="!!lightboxFile()"
+      [url]="lightboxFile()?.url ?? ''"
+      [name]="lightboxFile()?.name ?? ''"
+      [size]="lightboxFile()?.size ?? 0"
+      (closed)="lightboxFile.set(null)"
+    />
   `,
   styles: [
     `
@@ -718,6 +728,8 @@ export class MbChatPage implements OnInit {
   readonly previewing = signal<Deliverable | null>(null);
   /** 消息附件预览对象(与桌面端同一套 app-file-preview) */
   readonly filePreview = signal<{ url: string; name: string; mimeType: string } | null>(null);
+  /** 图片单独走灯箱(2026-10-30:图片不进预览弹窗) */
+  readonly lightboxFile = signal<{ url: string; name: string; size: number } | null>(null);
 
   /** 消息区容器(分页滚动锚点用) */
   private readonly messagesEl = viewChild<ElementRef<HTMLElement>>('messages');
@@ -749,9 +761,11 @@ export class MbChatPage implements OnInit {
     this.route.paramMap.subscribe((params) => {
       const id = params.get('id');
       if (!id) return;
-      void this.chat
-        .open(id, params.get('sessionId') ?? undefined)
-        .then(() => this.fillViewportIfNeeded());
+      const sid = params.get('sessionId');
+      // `new` = 新建对话的待建地址(不落数据,首条消息才建会话)
+      void (sid === 'new' ? this.chat.startBlank(id) : this.chat.open(id, sid ?? undefined)).then(
+        () => this.fillViewportIfNeeded(),
+      );
     });
   }
 
@@ -814,7 +828,12 @@ export class MbChatPage implements OnInit {
 
   /** 内联预览(不跳路由) */
   /** 打开消息附件预览 */
-  openPreview(f: { url: string; name: string; mimeType: string }): void {
+  openPreview(f: { url: string; name: string; mimeType: string; size?: number }): void {
+    // 图片不进预览弹窗,直接打开灯箱(2026-10-30)
+    if (previewKindOf(f.name || f.url, f.mimeType) === 'image') {
+      this.lightboxFile.set({ url: f.url, name: f.name, size: f.size ?? 0 });
+      return;
+    }
     this.filePreview.set(f);
   }
 

+ 8 - 5
src/app/mobile/pages/messages/messages.ts

@@ -141,7 +141,7 @@ import { ChatSessionService } from '../../../core/chat-session.service';
       <!-- 底部说明(原型 .list-foot) -->
       <footer class="list-foot">
         <i class="fa-solid fa-lock" aria-hidden="true"></i>
-        <span>消息经 Matrix 通道加密传输</span>
+        <span>消息安全传输</span>
       </footer>
     </div>
   `,
@@ -455,12 +455,15 @@ export class MbMessagesPage implements OnInit {
     return this.expanded().has(agentId);
   }
 
-  /** 新建对话并直接进入 */
+  /**
+   * 新建对话并直接进入(**不创建会话**,2026-11-01)。
+   *
+   * 只是跳到「待建」地址,用户发出第一条消息时才真正建会话 ——
+   * 避免「点了又反悔」在列表里留下 0 条消息的空壳。
+   */
   newSession(agentId: string, event?: Event): void {
     event?.stopPropagation();
-    const roomId = this.sessions.latestFor(agentId)?.roomId ?? '';
-    const s = this.sessions.create(roomId, agentId);
-    this.goSession(agentId, s.id);
+    void this.router.navigate(['/mobile', 'chat', agentId, 'new']);
   }
 
   /** 归档 / 取消归档 */

+ 41 - 22
src/app/mobile/pages/mine/mine.ts

@@ -4,7 +4,6 @@ import { Router } from '@angular/router';
 import { PaletteService, PALETTES } from '../../../core/palette.service';
 import { SessionService } from '../../../core/session.service';
 import { DeviceService } from '../../../core/device.service';
-import { environment } from '../../../../environments/environment';
 
 /**
  * 移动·我的(SPEC FR-10 移动形态)
@@ -83,16 +82,20 @@ import { environment } from '../../../../environments/environment';
         </div>
       </section>
 
-      <!-- 凭证 -->
+      <!-- 凭证(2026-11-01:可查看登录 Token;重新登录改为真正清态重登) -->
       <section class="card">
-        <h3 class="section-heading"><i class="fa-solid fa-lock" aria-hidden="true"></i>账号与凭证</h3>
-        <div class="setting-row">
-          <div class="copy">
-            <h4>登录凭证</h4>
-            <p>如遇异常可尝试修复。</p>
+        <h3 class="section-heading"><i class="fa-solid fa-lock" aria-hidden="true"></i>登录凭证</h3>
+        <div class="token-block">
+          <p class="token-hint">可用于其他设备直接登录,等同于账号密码,请勿分享。</p>
+          <code class="token-value" [class.masked]="!tokenShown()">{{ tokenText() }}</code>
+          <div class="token-btns">
+            <ui-m-button variant="secondary" (click)="tokenShown.set(!tokenShown())">
+              {{ tokenShown() ? '隐藏' : '显示' }}
+            </ui-m-button>
+            <ui-m-button variant="secondary" [disabled]="!tokenShown()" (click)="copyToken()">
+              {{ copied() ? '已复制' : '复制' }}
+            </ui-m-button>
           </div>
-          <!-- 「修复 Token」= 跳登录页重新取证;原名含糊,已改为「重新登录」 -->
-          <ui-m-button variant="secondary" (click)="fixToken()">重新登录</ui-m-button>
         </div>
       </section>
 
@@ -102,7 +105,7 @@ import { environment } from '../../../../environments/environment';
       </button>
 
       <footer class="about">
-        <span>Fmode <span class="mono">{{ version }}</span></span>
+        <span>Fmode</span>
       </footer>
     </div>
 
@@ -414,15 +417,6 @@ import { environment } from '../../../../environments/environment';
         font-size: var(--fs-11);
         color: var(--muted);
       }
-      .mono {
-        font-family: var(--mono, monospace);
-        color: var(--ink-2);
-      }
-      .demo-note {
-        display: inline-flex;
-        align-items: center;
-        gap: var(--sp-1);
-      }
       .logout-backdrop {
         position: fixed;
         inset: 0;
@@ -496,7 +490,6 @@ export class MbMinePage {
   onThemeIndex(i: number): void {
     this.setTheme(i === 0 ? 'light' : 'dark');
   }
-  readonly version = environment.version;
   readonly confirming = signal(false);
 
   readonly me = computed(() => this.session.me());
@@ -539,10 +532,36 @@ export class MbMinePage {
   }
 
 
-  fixToken(): void {
-    void this.router.navigate(['/login'], { queryParams: { redirect: this.router.url } });
+  /** 是否已点「显示」(默认打码,避免旁人一眼看到) */
+  readonly tokenShown = signal(false);
+  /** 复制后的短暂反馈 */
+  readonly copied = signal(false);
+
+  private readonly rawToken = computed(() => this.session.token() ?? '');
+
+  /** 展示用 Token(默认打码:保留前 4 + 后 4) */
+  readonly tokenText = computed(() => {
+    const t = this.rawToken();
+    if (!t) return '未登录';
+    if (!this.tokenShown()) return `${t.slice(0, 4)}${'•'.repeat(12)}${t.slice(-4)}`;
+    return t;
+  });
+
+  copyToken(): void {
+    const t = this.rawToken();
+    if (!t) return;
+    void navigator.clipboard
+      ?.writeText(t)
+      .then(() => {
+        this.copied.set(true);
+        setTimeout(() => this.copied.set(false), 1600);
+      })
+      .catch(() => {
+        /* 剪贴板不可用:静默失败,可手动选中复制 */
+      });
   }
 
+  /** 「重新登录」已移除(2026-11-01):与「退出登录」功能重叠,留一个入口即可 */
   async doLogout(): Promise<void> {
     this.session.logout();
     this.confirming.set(false);

+ 2 - 2
src/app/mobile/pages/workbench/workbench.ts

@@ -176,8 +176,8 @@ import { InfiniteScrollDirective } from '../../../shared/infinite-scroll.directi
         </div>
         <h3 class="sheet-section-title">连接信息</h3>
         <dl class="kv">
-          <div><dt>通道类型</dt><dd>{{ a.channels?.matrix ? 'Matrix' : '—' }}</dd></div>
-          <div><dt>服务端</dt><dd>{{ a.channels?.homeserver || '—' }}</dd></div>
+          <div><dt>通道类型</dt><dd>{{ a.channels?.matrix ? '在线通道' : '—' }}</dd></div>
+          <div><dt>接入状态</dt><dd>{{ ready(a) ? '正常' : '未接入' }}</dd></div>
           <div><dt>数字生命</dt><dd>{{ a.name }}</dd></div>
         </dl>
         <p class="sheet-note">此处仅展示连接信息,不展示任何凭证。</p>

+ 17 - 0
src/app/pages/login/login.ts

@@ -728,8 +728,25 @@ export class LoginComponent {
     if (this.codeTimer) clearInterval(this.codeTimer);
   }
 
+  /**
+   * 已登录直接跳转(2026-10-31)。
+   *
+   * 🔴 原实现只在「提交登录」或「URL 带 token」时回跳:已登录用户在地址栏直接打开
+   *    /login 会看到登录表单(实测),与"已登录就该进工作区"的预期不符。
+   *    地址里带 token 时仍交给 initFromUrl(可能要换身份),不走这里。
+   */
+  private redirectIfLoggedIn(): boolean {
+    const urlToken = this.route.snapshot.queryParamMap.get('token');
+    if (this.session.isLoggedIn() && !urlToken) {
+      void this.afterLogin();
+      return true;
+    }
+    return false;
+  }
+
   /** AC-02.2:支持 `#token=` / `?token=` 自动登录并清空地址栏 */
   private async initFromUrl(): Promise<void> {
+    if (this.redirectIfLoggedIn()) return;
     const hash = typeof location !== 'undefined' ? location.hash : '';
     const query = this.route.snapshot.queryParamMap;
     const urlToken = query.get('token') ?? (/^#token=(.+)$/.exec(hash)?.[1] ?? '');

Some files were not shown because too many files changed in this diff