2 Commits cc0bf23eda ... 4ed298b7c7

Author SHA1 Message Date
  Huccct 4ed298b7c7 chore: 撤回 .gitignore 忽略规则,补齐此前未入库的文档与素材 3 days ago
  Huccct 0f14056a06 fix(session): 会话目录单向同步 + 渲染预览修复 + 多端 UI 收敛 3 days ago
68 changed files with 15257 additions and 1602 deletions
  1. 1 1
      angular.json
  2. 473 0
      cloud/backup/RP5Ht55NeX-fmodeagent-matrix-provision-v0.1.5.js
  3. 482 0
      cloud/backup/RP5Ht55NeX-fmodeagent-matrix-provision-v0.1.5.patched.js
  4. 277 0
      cloud/backup/oR5O67i1Cm-fmodeagent-chatthreads-v0.1.0.js
  5. 569 0
      cloud/backup/xlosi3m46q-fmodeagent-status-v0.1.2.js
  6. 578 0
      cloud/backup/xlosi3m46q-fmodeagent-status-v0.1.2.patched.js
  7. 37 0
      cloud/functions/README.md
  8. 1 0
      cloud/functions/fmodeagent-chatthreads.id
  9. 324 0
      cloud/functions/fmodeagent-chatthreads.js
  10. 552 0
      docs/00-tracking/操作追踪.md
  11. 86 0
      docs/02-design/design-system/预览面板重构-设计-20261030.md
  12. 140 0
      docs/03-interface/会话创建与容器通讯规范-20261101.md
  13. 126 0
      docs/03-interface/会话存储与生命周期-20261101.md
  14. 47 0
      docs/03-interface/如何查看登录凭据-20261031.md
  15. 95 0
      docs/03-interface/容器通讯-lj-test直连手册-20261031.md
  16. BIN
      image/QQ20261030-203352.png
  17. BIN
      image/QQ20261030-203403.png
  18. BIN
      image/QQ20261030-203428.png
  19. BIN
      image/QQ20261030-203446.png
  20. BIN
      image/QQ20261030-203502.png
  21. BIN
      image/QQ20261030-204116.png
  22. BIN
      image/QQ20261030-204128.png
  23. BIN
      image/QQ20261030-204141.png
  24. 8037 0
      pnpm-lock.yaml
  25. 6 0
      pnpm-workspace.yaml
  26. 7 0
      src/app/app.config.ts
  27. 3 10
      src/app/components/domain/message-bubble/message-bubble.spec.ts
  28. 15 27
      src/app/components/domain/message-bubble/message-bubble.ts
  29. 9 29
      src/app/components/domain/new-session-modal/new-session-modal.ts
  30. 247 27
      src/app/components/domain/session-tree/session-tree.ts
  31. 114 0
      src/app/components/preview/file-preview/file-preview.spec.ts
  32. 622 365
      src/app/components/preview/file-preview/file-preview.ts
  33. 40 0
      src/app/components/preview/image-lightbox/image-lightbox.spec.ts
  34. 266 0
      src/app/components/preview/image-lightbox/image-lightbox.ts
  35. 15 0
      src/app/components/preview/md-render/md-render.ts
  36. 113 20
      src/app/components/preview/office-preview/office-preview.ts
  37. 206 110
      src/app/components/preview/pdf-preview/pdf-preview.ts
  38. 11 5
      src/app/core/acl.spec.ts
  39. 12 4
      src/app/core/acl.ts
  40. 26 0
      src/app/core/api/fmode-api.ts
  41. 59 0
      src/app/core/api/mock-api.ts
  42. 39 0
      src/app/core/api/models.ts
  43. 51 0
      src/app/core/api/parse-api.ts
  44. 38 0
      src/app/core/app-error-handler.ts
  45. 218 93
      src/app/core/chat-session.service.spec.ts
  46. 275 138
      src/app/core/chat-session.service.ts
  47. 7 5
      src/app/core/chat-session.ts
  48. 60 2
      src/app/core/chat.service.spec.ts
  49. 183 34
      src/app/core/chat.service.ts
  50. 49 1
      src/app/core/matrix-client.service.ts
  51. 25 0
      src/app/core/matrix-session.service.ts
  52. 0 180
      src/app/core/session-scan.spec.ts
  53. 0 167
      src/app/core/session-scan.ts
  54. 24 1
      src/app/core/spreadsheet.spec.ts
  55. 60 0
      src/app/core/spreadsheet.ts
  56. 3 3
      src/app/desktop/pages/agent/agent.ts
  57. 13 1
      src/app/desktop/pages/chat/chat.spec.ts
  58. 333 236
      src/app/desktop/pages/chat/chat.ts
  59. 31 24
      src/app/desktop/pages/groups/groups.ts
  60. 115 75
      src/app/desktop/pages/mine/mine.ts
  61. 36 2
      src/app/desktop/pages/projects/projects.ts
  62. 7 6
      src/app/desktop/pages/tasks/tasks.ts
  63. 2 2
      src/app/desktop/pages/workbench/workbench.ts
  64. 24 5
      src/app/mobile/pages/chat/chat.ts
  65. 8 5
      src/app/mobile/pages/messages/messages.ts
  66. 41 22
      src/app/mobile/pages/mine/mine.ts
  67. 2 2
      src/app/mobile/pages/workbench/workbench.ts
  68. 17 0
      src/app/pages/login/login.ts

+ 1 - 1
angular.json

@@ -52,7 +52,7 @@
                 {
                 {
                   "type": "anyComponentStyle",
                   "type": "anyComponentStyle",
                   "maximumWarning": "8kB",
                   "maximumWarning": "8kB",
-                  "maximumError": "16kB"
+                  "maximumError": "24kB"
                 }
                 }
               ],
               ],
               "outputHashing": "all",
               "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**;
 **验证**:气泡数全程稳定 2 ✓;`tsc` 0 错误;**单测 1063/1063**;
 `verify:ui` **12/12**;`verify:docs` 74 文件通过;生产构建通过。
 `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 设备刷新后看到新名字
+- [ ] 删除会话 → 刷新 → 不再出现;换浏览器登录也不再出现
+- [ ] 断网进入聊天页 → 显示「会话目录加载失败 + 重试」,**不是**「还没有对话」
+- [ ] 恢复网络点重试 → 列表正常出现

+ 47 - 0
docs/03-interface/如何查看登录凭据-20261031.md

@@ -0,0 +1,47 @@
+# 如何查看自己的登录凭据(sessionToken)
+
+> 用于排查「登录态异常 / 需要以你的身份调接口」的场景。**不是日常需要**的东西。
+
+## 一、sessionToken 是什么
+
+App 登录成功后,服务端签发的一串凭证(形如 `r:xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx`,约 34 位)。
+App 存在浏览器 **localStorage** 的 `fmode_session_token` 里,每次请求通过
+`X-Parse-Session-Token` 请求头带给服务端。**它不是密码**,但等同于登录态,不要发给无关的人。
+
+## 二、自己查看(Chrome / Edge,30 秒)
+
+1. 打开已登录的 App 页面(`http://localhost:4200/desktop/chat`)
+2. 按 **F12** 打开开发者工具
+3. 切到 **Application(应用)** 标签 → 左侧 **Local Storage** → 点 `http://localhost:4200`
+4. 找到 `fmode_session_token`,右侧的 Value 就是
+
+> 也可以在 **Console(控制台)** 里输入:
+> ```js
+> localStorage.getItem('fmode_session_token')
+> ```
+> 回车即可看到。
+
+## 三、由运维 / 开发者陪查
+
+有 **masterKey**(仅服务端持有)时可查 `_Session` 表,按用户定位当前有效会话:
+
+```bash
+curl -s "https://server.fmode.cn/parse/classes/_Session?where=%7B%22user%22%3A%7B%22__type%22%3A%22Pointer%22%2C%22className%22%3A%22_User%22%2C%22objectId%22%3A%22<用户objectId>%22%7D%7D&order=-createdAt" \
+  -H "X-Parse-Application-Id: ncloudmaster" \
+  -H "X-Parse-Master-Key: <MK>" | jq '.results[0] | {createdAt, sessionToken}'
+```
+
+验证该 token 属于谁:
+
+```bash
+curl -s https://server.fmode.cn/parse/users/me \
+  -H "X-Parse-Application-Id: ncloudmaster" \
+  -H "X-Parse-Session-Token: <token>" | jq '{objectId, username}'
+```
+
+**注意**:`_Session` 表里可能有大量历史会话,取 `order=-createdAt` 的第一条未必就是当前浏览器在用的那条;
+最终以**自己浏览器 localStorage 里的值**为准。
+
+## 四、token 失效怎么办
+
+返回 `{"error":"认证失败","message":"无效的 sessionToken"}` 或业务码 `401` → 在 App 里**退出重新登录**即可。

+ 95 - 0
docs/03-interface/容器通讯-lj-test直连手册-20261031.md

@@ -0,0 +1,95 @@
+# lj 容器(lj-test)直连通信手册(2026-10-31 实测)
+
+> 用途:**后续会话无需重新探测**,按本文即可用真实账号与 lj-test 容器收发消息、触发交付物、验证预览。
+> 用户明确要求“不需要考虑安全性”,故本文记录完整凭据。⚠️ 凭据可能随服务端重置而失效;失效时按下文「三、凭据失效时如何重新获取」重取并更新本文。
+
+## 一、身份与房间(实测值)
+
+| 项 | 值 |
+|---|---|
+| 容器 agentId | `agent-node-lj-test` |
+| 显示名 | `lj-test` |
+| Agent Matrix ID | `@zgdcmgnqcd:fmode.cn` |
+| 用户 Matrix ID | `@xhvrjtmwqh:fmode.cn` |
+| 房间 roomId | `!miyB1DxRE5E9Aj5d7lE1wcgPWXWx1Mkaw4P-yiQIYdU` |
+| 绑定 bindingId | `vYuW3w9y1U` |
+| homeserver | `https://matrix.fmode.cn` |
+
+## 二、通信方式
+
+### 2.1 拿凭据(云函数 provision)
+
+```
+POST https://server.fmode.cn/api/functions
+X-Parse-Application-Id: ncloudmaster
+X-Parse-Session-Token: <App 登录态 sessionToken>
+Content-Type: application/json
+
+{"id":"RP5Ht55NeX","token":"<同上>","params":{"agentId":"agent-node-lj-test"}}
+```
+
+返回(`code:200` 时):`accessToken`(Matrix,24 字符)、`userMxId`、`agentMxId`、`roomId`、`deviceId`、`bindingId`。
+⚠️ `accessToken` **只在本进程内存中**、不落 localStorage;每次调用 provision 都重新拿(服务端有缓存 `cached:true`,很快)。
+
+### 2.2 发消息(必须 @ 容器)
+
+容器配置了 `MATRIX_REQUIRE_MENTION=true`:**只写 `@lj-test` 文本无效**,必须带结构化 mention。
+
+```
+PUT https://matrix.fmode.cn/_matrix/client/v3/rooms/{roomId}/send/m.room.message/{txnId}
+    ?access_token={accessToken}&user_id={userMxId}&device_id={deviceId}
+Content-Type: application/json
+
+{
+  "msgtype": "m.text",
+  "body": "@lj-test <指令正文>",
+  "format": "org.matrix.custom.html",
+  "formatted_body": "<a href=\"https://matrix.to/#/@zgdcmgnqcd:fmode.cn\">@lj-test</a> <指令正文>",
+  "m.mentions": { "user_ids": ["@zgdcmgnqcd:fmode.cn"] }
+}
+```
+
+`txnId` 用毫秒时间戳即可。返回 `event_id` 表示已送达。
+
+### 2.3 读回复
+
+```
+GET https://matrix.fmode.cn/_matrix/client/v3/rooms/{roomId}/messages
+    ?dir=b&limit=20&access_token=…&user_id=…&device_id=…
+```
+
+- 回复以 `sender = @zgdcmgnqcd:fmode.cn` 的 `m.room.message` 出现;
+- 处理中会对你的消息打 reaction(`👀`),完成后打 `✅` 或其它 reaction;
+- 交付物为 `m.file` / `m.image` 等媒体事件(`content.url` 为 `mxc://`);
+- **分页起点**:不支持手工拼 `from=t<毫秒>`(实测 M_UNKNOWN),首屏不传 `from` 即可。
+
+## 三、凭据失效时如何重新获取
+
+若 provision 返回 `401 sessionToken 无效或已过期`:
+
+1. 需要一个已登录的 App 页面(浏览器登录后 `localStorage.fmode_session_token`)。
+2. 可用 CDP 连接用户浏览器读取 token(2026-10-31 实测可行):
+   - 启动:`chrome --remote-debugging-port=9222 --user-data-dir=<独立目录> http://localhost:4200/login`
+   - Playwright `chromium.connectOverCDP('http://127.0.0.1:9222')` → `localStorage.getItem('fmode_session_token')`
+3. 用该 token 重走 2.1,更新本文表格中的房间与凭据。
+
+## 四、发布示例指令(用于预览功能测试)
+
+已实测可用的一条(会产出 8 类文件):
+
+> 请生成并作为文件附件交付以下 8 个文件:report.html(研报)、report.pdf、data.csv(100 行周度数据)、stats.json、README.md、weekly.docx(周报)、cover.png(插画)、bundle.zip(打包全部)。完成后用一句话告诉我每个文件名。
+
+## 五、App 侧消息形态(与预览相关)
+
+- 容器交付的文件在 App 中以 `FileCard` 展示,点击 → `openFile()`:
+  - `image` → 图片灯箱
+  - `video` / `audio` → 居中模态
+  - 其余 → 右侧预览(可切全屏)
+- 预览分发唯一事实源:`src/app/components/preview/file-kind.ts`
+
+## 六、踩坑记录
+
+1. `messages` 接口传 `from=t<毫秒>` → 500 `M_UNKNOWN`;不传即可。
+2. provision 400/401 多半是 sessionToken 过期,不是 agentId 错。
+3. 只写 `@lj-test` 纯文本 → 容器不响应,必须带 `m.mentions.user_ids`。
+4. Matrix accessToken 不落盘,不能跨进程复用;每次从 provision 取。

BIN
image/QQ20261030-203352.png


BIN
image/QQ20261030-203403.png


BIN
image/QQ20261030-203428.png


BIN
image/QQ20261030-203446.png


BIN
image/QQ20261030-203502.png


BIN
image/QQ20261030-204116.png


BIN
image/QQ20261030-204128.png


BIN
image/QQ20261030-204141.png


+ 8037 - 0
pnpm-lock.yaml

@@ -0,0 +1,8037 @@
+lockfileVersion: '9.0'
+
+settings:
+  autoInstallPeers: true
+  excludeLinksFromLockfile: false
+
+importers:
+
+  .:
+    dependencies:
+      '@angular/common':
+        specifier: ^20.3.0
+        version: 20.3.34(@angular/core@20.3.34(@angular/compiler@20.3.34)(rxjs@7.8.0)(zone.js@0.15.0))(rxjs@7.8.0)
+      '@angular/compiler':
+        specifier: ^20.3.0
+        version: 20.3.34
+      '@angular/core':
+        specifier: ^20.3.0
+        version: 20.3.34(@angular/compiler@20.3.34)(rxjs@7.8.0)(zone.js@0.15.0)
+      '@angular/forms':
+        specifier: ^20.3.0
+        version: 20.3.34(@angular/common@20.3.34(@angular/core@20.3.34(@angular/compiler@20.3.34)(rxjs@7.8.0)(zone.js@0.15.0))(rxjs@7.8.0))(@angular/core@20.3.34(@angular/compiler@20.3.34)(rxjs@7.8.0)(zone.js@0.15.0))(@angular/platform-browser@20.3.34(@angular/common@20.3.34(@angular/core@20.3.34(@angular/compiler@20.3.34)(rxjs@7.8.0)(zone.js@0.15.0))(rxjs@7.8.0))(@angular/core@20.3.34(@angular/compiler@20.3.34)(rxjs@7.8.0)(zone.js@0.15.0)))(rxjs@7.8.0)
+      '@angular/platform-browser':
+        specifier: ^20.3.0
+        version: 20.3.34(@angular/common@20.3.34(@angular/core@20.3.34(@angular/compiler@20.3.34)(rxjs@7.8.0)(zone.js@0.15.0))(rxjs@7.8.0))(@angular/core@20.3.34(@angular/compiler@20.3.34)(rxjs@7.8.0)(zone.js@0.15.0))
+      '@angular/router':
+        specifier: ^20.3.0
+        version: 20.3.34(@angular/common@20.3.34(@angular/core@20.3.34(@angular/compiler@20.3.34)(rxjs@7.8.0)(zone.js@0.15.0))(rxjs@7.8.0))(@angular/core@20.3.34(@angular/compiler@20.3.34)(rxjs@7.8.0)(zone.js@0.15.0))(@angular/platform-browser@20.3.34(@angular/common@20.3.34(@angular/core@20.3.34(@angular/compiler@20.3.34)(rxjs@7.8.0)(zone.js@0.15.0))(rxjs@7.8.0))(@angular/core@20.3.34(@angular/compiler@20.3.34)(rxjs@7.8.0)(zone.js@0.15.0)))(rxjs@7.8.0)
+      '@fortawesome/fontawesome-free':
+        specifier: ^7.3.1
+        version: 7.3.1
+      '@silurus/ooxml':
+        specifier: ^0.66.3
+        version: 0.66.3
+      fmode-ng:
+        specifier: ^0.0.246
+        version: 0.0.246(@angular/common@20.3.34(@angular/core@20.3.34(@angular/compiler@20.3.34)(rxjs@7.8.0)(zone.js@0.15.0))(rxjs@7.8.0))(@angular/core@20.3.34(@angular/compiler@20.3.34)(rxjs@7.8.0)(zone.js@0.15.0))
+      matrix-js-sdk:
+        specifier: ^42.4.0
+        version: 42.4.0
+      ngx-markdown:
+        specifier: ^20.1.0
+        version: 20.1.0(@angular/common@20.3.34(@angular/core@20.3.34(@angular/compiler@20.3.34)(rxjs@7.8.0)(zone.js@0.15.0))(rxjs@7.8.0))(@angular/core@20.3.34(@angular/compiler@20.3.34)(rxjs@7.8.0)(zone.js@0.15.0))(@angular/platform-browser@20.3.34(@angular/common@20.3.34(@angular/core@20.3.34(@angular/compiler@20.3.34)(rxjs@7.8.0)(zone.js@0.15.0))(rxjs@7.8.0))(@angular/core@20.3.34(@angular/compiler@20.3.34)(rxjs@7.8.0)(zone.js@0.15.0)))(marked@16.3.0)(rxjs@7.8.0)(zone.js@0.15.0)
+      pdfjs-dist:
+        specifier: ^6.4.299
+        version: 6.4.299
+      prismjs:
+        specifier: ^1.30.0
+        version: 1.30.0
+      rxjs:
+        specifier: ~7.8.0
+        version: 7.8.0
+      tslib:
+        specifier: ^2.3.0
+        version: 2.8.1
+      zone.js:
+        specifier: ~0.15.0
+        version: 0.15.0
+    devDependencies:
+      '@angular/build':
+        specifier: ^20.3.36
+        version: 20.3.42(@angular/compiler-cli@20.3.34(@angular/compiler@20.3.34)(supports-color@7.1.0)(typescript@5.9.2))(@angular/compiler@20.3.34)(@angular/core@20.3.34(@angular/compiler@20.3.34)(rxjs@7.8.0)(zone.js@0.15.0))(@angular/platform-browser@20.3.34(@angular/common@20.3.34(@angular/core@20.3.34(@angular/compiler@20.3.34)(rxjs@7.8.0)(zone.js@0.15.0))(rxjs@7.8.0))(@angular/core@20.3.34(@angular/compiler@20.3.34)(rxjs@7.8.0)(zone.js@0.15.0)))(@types/node@26.6.6)(chokidar@4.0.0)(jiti@1.21.7)(karma@6.4.4(debug@4.4.3(supports-color@7.1.0))(supports-color@7.1.0))(postcss@8.5.32)(supports-color@7.1.0)(tailwindcss@3.4.19)(tslib@2.8.1)(typescript@5.9.2)
+      '@angular/cli':
+        specifier: ^20.3.36
+        version: 20.3.42(@types/node@26.6.6)(chokidar@4.0.0)(supports-color@7.1.0)
+      '@angular/compiler-cli':
+        specifier: ^20.3.0
+        version: 20.3.34(@angular/compiler@20.3.34)(supports-color@7.1.0)(typescript@5.9.2)
+      '@types/jasmine':
+        specifier: ~5.1.0
+        version: 5.1.15
+      autoprefixer:
+        specifier: ^10.6.1
+        version: 10.6.2(postcss@8.5.32)
+      jasmine-core:
+        specifier: ~5.9.0
+        version: 5.9.0
+      karma:
+        specifier: ~6.4.0
+        version: 6.4.4(debug@4.4.3(supports-color@7.1.0))(supports-color@7.1.0)
+      karma-chrome-launcher:
+        specifier: ~3.2.0
+        version: 3.2.0
+      karma-coverage:
+        specifier: ~2.2.0
+        version: 2.2.1(supports-color@7.1.0)
+      karma-jasmine:
+        specifier: ~5.1.0
+        version: 5.1.0(karma@6.4.4(debug@4.4.3(supports-color@7.1.0))(supports-color@7.1.0))
+      karma-jasmine-html-reporter:
+        specifier: ~2.1.0
+        version: 2.1.0(jasmine-core@5.9.0)(karma-jasmine@5.1.0(karma@6.4.4(debug@4.4.3(supports-color@7.1.0))(supports-color@7.1.0)))(karma@6.4.4(debug@4.4.3(supports-color@7.1.0))(supports-color@7.1.0))
+      playwright:
+        specifier: ^1.64.1
+        version: 1.64.1
+      postcss:
+        specifier: ^8.5.30
+        version: 8.5.32
+      tailwindcss:
+        specifier: ^3.4.19
+        version: 3.4.19
+      typescript:
+        specifier: ~5.9.2
+        version: 5.9.2
+
+packages:
+
+  '@algolia/abtesting@1.1.0':
+    resolution: {integrity: sha512-sEyWjw28a/9iluA37KLGu8vjxEIlb60uxznfTUmXImy7H5NvbpSO6yYgmgH5KiD7j+zTUUihiST0jEP12IoXow==}
+    engines: {node: '>= 14.0.0'}
+
+  '@algolia/client-abtesting@5.35.0':
+    resolution: {integrity: sha512-uUdHxbfHdoppDVflCHMxRlj49/IllPwwQ2cQ8DLC4LXr3kY96AHBpW0dMyi6ygkn2MtFCc6BxXCzr668ZRhLBQ==}
+    engines: {node: '>= 14.0.0'}
+
+  '@algolia/client-analytics@5.35.0':
+    resolution: {integrity: sha512-SunAgwa9CamLcRCPnPHx1V2uxdQwJGqb1crYrRWktWUdld0+B2KyakNEeVn5lln4VyeNtW17Ia7V7qBWyM/Skw==}
+    engines: {node: '>= 14.0.0'}
+
+  '@algolia/client-common@5.35.0':
+    resolution: {integrity: sha512-ipE0IuvHu/bg7TjT2s+187kz/E3h5ssfTtjpg1LbWMgxlgiaZIgTTbyynM7NfpSJSKsgQvCQxWjGUO51WSCu7w==}
+    engines: {node: '>= 14.0.0'}
+
+  '@algolia/client-insights@5.35.0':
+    resolution: {integrity: sha512-UNbCXcBpqtzUucxExwTSfAe8gknAJ485NfPN6o1ziHm6nnxx97piIbcBQ3edw823Tej2Wxu1C0xBY06KgeZ7gA==}
+    engines: {node: '>= 14.0.0'}
+
+  '@algolia/client-personalization@5.35.0':
+    resolution: {integrity: sha512-/KWjttZ6UCStt4QnWoDAJ12cKlQ+fkpMtyPmBgSS2WThJQdSV/4UWcqCUqGH7YLbwlj3JjNirCu3Y7uRTClxvA==}
+    engines: {node: '>= 14.0.0'}
+
+  '@algolia/client-query-suggestions@5.35.0':
+    resolution: {integrity: sha512-8oCuJCFf/71IYyvQQC+iu4kgViTODbXDk3m7yMctEncRSRV+u2RtDVlpGGfPlJQOrAY7OONwJlSHkmbbm2Kp/w==}
+    engines: {node: '>= 14.0.0'}
+
+  '@algolia/client-search@5.35.0':
+    resolution: {integrity: sha512-FfmdHTrXhIduWyyuko1YTcGLuicVbhUyRjO3HbXE4aP655yKZgdTIfMhZ/V5VY9bHuxv/fGEh3Od1Lvv2ODNTg==}
+    engines: {node: '>= 14.0.0'}
+
+  '@algolia/ingestion@1.35.0':
+    resolution: {integrity: sha512-gPzACem9IL1Co8mM1LKMhzn1aSJmp+Vp434An4C0OBY4uEJRcqsLN3uLBlY+bYvFg8C8ImwM9YRiKczJXRk0XA==}
+    engines: {node: '>= 14.0.0'}
+
+  '@algolia/monitoring@1.35.0':
+    resolution: {integrity: sha512-w9MGFLB6ashI8BGcQoVt7iLgDIJNCn4OIu0Q0giE3M2ItNrssvb8C0xuwJQyTy1OFZnemG0EB1OvXhIHOvQwWw==}
+    engines: {node: '>= 14.0.0'}
+
+  '@algolia/recommend@5.35.0':
+    resolution: {integrity: sha512-AhrVgaaXAb8Ue0u2nuRWwugt0dL5UmRgS9LXe0Hhz493a8KFeZVUE56RGIV3hAa6tHzmAV7eIoqcWTQvxzlJeQ==}
+    engines: {node: '>= 14.0.0'}
+
+  '@algolia/requester-browser-xhr@5.35.0':
+    resolution: {integrity: sha512-diY415KLJZ6x1Kbwl9u96Jsz0OstE3asjXtJ9pmk1d+5gPuQ5jQyEsgC+WmEXzlec3iuVszm8AzNYYaqw6B+Zw==}
+    engines: {node: '>= 14.0.0'}
+
+  '@algolia/requester-fetch@5.35.0':
+    resolution: {integrity: sha512-uydqnSmpAjrgo8bqhE9N1wgcB98psTRRQXcjc4izwMB7yRl9C8uuAQ/5YqRj04U0mMQ+fdu2fcNF6m9+Z1BzDQ==}
+    engines: {node: '>= 14.0.0'}
+
+  '@algolia/requester-node-http@5.35.0':
+    resolution: {integrity: sha512-RgLX78ojYOrThJHrIiPzT4HW3yfQa0D7K+MQ81rhxqaNyNBu4F1r+72LNHYH/Z+y9I1Mrjrd/c/Ue5zfDgAEjQ==}
+    engines: {node: '>= 14.0.0'}
+
+  '@alloc/quick-lru@5.3.0':
+    resolution: {integrity: sha512-U4+70Pc5ZS9osnCBCE5Jha/ciHM+Yp+CNMNC/7HvYbNRk1Ldd+f7qO65W5qfhu/TCv+/ozljlXXe9Nj8419DMA==}
+    engines: {node: '>=10'}
+
+  '@ampproject/remapping@2.3.0':
+    resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==}
+    engines: {node: '>=6.0.0'}
+
+  '@angular-devkit/architect@0.2003.42':
+    resolution: {integrity: sha512-yREf9kQOWGS70y8PFrJX7Ptr7dycXlVs0TOkO3Dn5nMSCGulxsID9iSCcAmnhk4ErHXY/2FrxDam+Vihr7SZaQ==}
+    engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'}
+
+  '@angular-devkit/core@20.3.42':
+    resolution: {integrity: sha512-CsRD9+gfIn70Z2CGEdXGC6E72SV/GlHi6krCbhak6LJDk2O5JX06t2ufHOdqJiNShMr+JpaYjyQcYgbr8pNVSA==}
+    engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'}
+    peerDependencies:
+      chokidar: ^4.0.0
+    peerDependenciesMeta:
+      chokidar:
+        optional: true
+
+  '@angular-devkit/schematics@20.3.42':
+    resolution: {integrity: sha512-pgZHQw85gezArD1kPsWMqzamGUvZ2tYWaFJGo8Ws1PZSe2XEsfydzTqYdATE7hqQxO+IY8ZNzmvBYoat9hgaVA==}
+    engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'}
+
+  '@angular/build@20.3.42':
+    resolution: {integrity: sha512-dLoX9SdRlDed5OEDgJDNHgSSdE+BvEIxyl3R7/jxovfpUiSOadqs8XiPxYCk0dnVpQoYDtV15NscthBVLer2Uw==}
+    engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'}
+    peerDependencies:
+      '@angular/compiler': ^20.0.0
+      '@angular/compiler-cli': ^20.0.0
+      '@angular/core': ^20.0.0
+      '@angular/localize': ^20.0.0
+      '@angular/platform-browser': ^20.0.0
+      '@angular/platform-server': ^20.0.0
+      '@angular/service-worker': ^20.0.0
+      '@angular/ssr': ^20.3.42
+      karma: ^6.4.0
+      less: ^4.2.0
+      ng-packagr: ^20.0.0
+      postcss: ^8.4.0
+      tailwindcss: ^2.0.0 || ^3.0.0 || ^4.0.0
+      tslib: ^2.3.0
+      typescript: '>=5.8 <6.0'
+      vitest: ^3.1.1
+    peerDependenciesMeta:
+      '@angular/core':
+        optional: true
+      '@angular/localize':
+        optional: true
+      '@angular/platform-browser':
+        optional: true
+      '@angular/platform-server':
+        optional: true
+      '@angular/service-worker':
+        optional: true
+      '@angular/ssr':
+        optional: true
+      karma:
+        optional: true
+      less:
+        optional: true
+      ng-packagr:
+        optional: true
+      postcss:
+        optional: true
+      tailwindcss:
+        optional: true
+      vitest:
+        optional: true
+
+  '@angular/cli@20.3.42':
+    resolution: {integrity: sha512-la6j/uUzX5rbwliNYY1E2t2KLaEsaBBg+HkUq7W++CZ0+rZWp1uZ9X+KFHLS7TJiS9XbvtbTNh0fzA0DoJhTog==}
+    engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'}
+    hasBin: true
+
+  '@angular/common@20.3.34':
+    resolution: {integrity: sha512-OhB/l0cNldOvQeICqbWqRUzK5fF6zNYy44Q4cIGJZql8HXL/invGiqWLE9f+kU8z4jRisofHIGTBtyJQYYOudg==}
+    engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
+    peerDependencies:
+      '@angular/core': 20.3.34
+      rxjs: ^6.5.3 || ^7.4.0
+
+  '@angular/compiler-cli@20.3.34':
+    resolution: {integrity: sha512-PKHzhSOyDUFh64Ull3fUwgFlxnsQNEAuRyz4KqQ8FsTGZ4pOpUaZntO+2Xn1nX/gT2whSyGBJhD3H37zA8ZGEQ==}
+    engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
+    hasBin: true
+    peerDependencies:
+      '@angular/compiler': 20.3.34
+      typescript: '>=5.8 <6.0'
+    peerDependenciesMeta:
+      typescript:
+        optional: true
+
+  '@angular/compiler@20.3.34':
+    resolution: {integrity: sha512-8agPW384c/JER1ersPTmJJfcCOnlYcBbdj0MYRxNdiYe+7gtnspF3ORuJmxqCEpMS17AljSmk2skwUcYnHB6xA==}
+    engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
+
+  '@angular/core@20.3.34':
+    resolution: {integrity: sha512-xOvSmGoao3SVPz//R7zTrq7+WdrIGi+WNpAdxJEvJJXuSpLGS3kSUqukjY4kOMochg2v0NCEPIdCyH+yPs8JSw==}
+    engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
+    peerDependencies:
+      '@angular/compiler': 20.3.34
+      rxjs: ^6.5.3 || ^7.4.0
+      zone.js: ~0.15.0
+    peerDependenciesMeta:
+      '@angular/compiler':
+        optional: true
+      zone.js:
+        optional: true
+
+  '@angular/forms@20.3.34':
+    resolution: {integrity: sha512-DndQJEoORsdthFFVRvT9WHn8YcA85jkjMxCLtXyf+TDVk0zbqObbFDfrloRnJwsINqfDNsJrz23ko3YU6sjnTw==}
+    engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
+    peerDependencies:
+      '@angular/common': 20.3.34
+      '@angular/core': 20.3.34
+      '@angular/platform-browser': 20.3.34
+      rxjs: ^6.5.3 || ^7.4.0
+
+  '@angular/platform-browser@20.3.34':
+    resolution: {integrity: sha512-0bd2Bj05WPoBTFM0A13IwbzxKVQPstx/N+7X0ZWKCifliyydIc7DBHu+r37ybJpl8ZsCVBe7DoR9Gon4lZ+zlw==}
+    engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
+    peerDependencies:
+      '@angular/animations': 20.3.34
+      '@angular/common': 20.3.34
+      '@angular/core': 20.3.34
+    peerDependenciesMeta:
+      '@angular/animations':
+        optional: true
+
+  '@angular/router@20.3.34':
+    resolution: {integrity: sha512-vQo2f/94nUoNMZaxGY0+fK1t9Oe7zJuxHhoMVYhDbCqKxINVmxMX/9uEHlHeu9UIE7JMazepQjRzBLo7bf9xCw==}
+    engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
+    peerDependencies:
+      '@angular/common': 20.3.34
+      '@angular/core': 20.3.34
+      '@angular/platform-browser': 20.3.34
+      rxjs: ^6.5.3 || ^7.4.0
+
+  '@antfu/install-pkg@2.1.0':
+    resolution: {integrity: sha512-sdg9NxU3zR4Mnawfbc/x6GB5Wf17WYud5qOuEuxXjaKpYpMkISSJEjItGebXJ2bQ4DIcly4NYH23mtkGJjvKUw==}
+
+  '@aws-sdk/checksums@3.1001.7':
+    resolution: {integrity: sha512-hUlYwqlsiDuPyhI5nQLgLZYKo0bFvMKaVk9JRfpb9S2kXxT2ZhyHZ5RTfH3PBpD3dMnDkGrA0/aY1AeAMFLmrQ==}
+    engines: {node: '>=20.0.0'}
+
+  '@aws-sdk/client-s3@3.1159.0':
+    resolution: {integrity: sha512-el/Ihylk4PjJh/uJvYUeAzBiVLR6zt0xBOPIbciybePABWD/cOcxinHEPQslogm61LI2etnRrixSQltX94gzLA==}
+    engines: {node: '>=20.0.0'}
+
+  '@aws-sdk/core@3.978.7':
+    resolution: {integrity: sha512-QsYdn5xhvEBzcDC6bgKuvPv87kFtXK/3f5NqKfR+eok5AAn7RaZnkgBKncX+UW3ZMcjSELyAMATN0iqgZmvWVg==}
+    engines: {node: '>=20.0.0'}
+
+  '@aws-sdk/credential-provider-env@3.972.78':
+    resolution: {integrity: sha512-d8V47OxkM/FKe7bVfk5Rga4aNGUaQNk3ip1PFIYhxtup9r8SuNIHLs4HfBAmHT25ZbzowluMHp833UXfFMQbhA==}
+    engines: {node: '>=20.0.0'}
+
+  '@aws-sdk/credential-provider-http@3.972.80':
+    resolution: {integrity: sha512-OQqanXiRhnl/wBIJc3TtA7TYmnN7VO0mP95eVoHsLXtPwHz6HeccMpi7cqtnhHw+K7p6VIoAt2IK4M9Ixd9uGg==}
+    engines: {node: '>=20.0.0'}
+
+  '@aws-sdk/credential-provider-ini@3.973.23':
+    resolution: {integrity: sha512-pTTRK7xoi8NNbrcjVUSd9YdMTUxiWEPfk8newBQCzV+VS5STh5sDPAy3iO29fSqipqNuCpILJcD6wAawxHvG+w==}
+    engines: {node: '>=20.0.0'}
+
+  '@aws-sdk/credential-provider-login@3.972.85':
+    resolution: {integrity: sha512-UHjnTeS+5OBFfRL+BQwCGJPJvnzjhGD6dIwncBJDa+ziyQjPUte9Q0I4BCkybGrHotnyiiluXj13bgelP14J5Q==}
+    engines: {node: '>=20.0.0'}
+
+  '@aws-sdk/credential-provider-node@3.972.90':
+    resolution: {integrity: sha512-+McgOQuAUze95AumVmoOBRp++Z8IjsZ0qOASb6+igHdKNzA0FiIf5kbTFaZ64txfd9KTBDpgvlEDGQE41lSq/A==}
+    engines: {node: '>=20.0.0'}
+
+  '@aws-sdk/credential-provider-process@3.972.78':
+    resolution: {integrity: sha512-bzB92SriflYf6LVRJcDv/a9I87tmkdmdE8hAqT1oRGKFkEH2rxxHeHZVK/4vsiRsFi44cUj0J9+bUSmqYX+26w==}
+    engines: {node: '>=20.0.0'}
+
+  '@aws-sdk/credential-provider-sso@3.973.22':
+    resolution: {integrity: sha512-MXUkWHgcf6G+TowL+QPvn4kJTEYKd9qHKMV6eNGXSA5lilDgGRrP3EXPHChZ3eZ7z+oprn/ZvOovLoS2WR3D8g==}
+    engines: {node: '>=20.0.0'}
+
+  '@aws-sdk/credential-provider-web-identity@3.972.84':
+    resolution: {integrity: sha512-6I3zYcDHt5ZM0J4gEuRgCHgN505k5haUiiPM46iIQgrHm2p70zpnssHFVYqHYfgJe3Mf+a5+nsF3BZmW4xcZNg==}
+    engines: {node: '>=20.0.0'}
+
+  '@aws-sdk/middleware-sdk-s3@3.972.83':
+    resolution: {integrity: sha512-SPZ4knkYFQfNv423Yu5nCxKwRXjWCRWv2OIzO+t+ffjdu0Ux6HlClFjy10OBKwzzVFJs9YvgiHYSS21p+TG4xg==}
+    engines: {node: '>=20.0.0'}
+
+  '@aws-sdk/nested-clients@3.997.52':
+    resolution: {integrity: sha512-/bNl6b3VEoWQg3Z8yHt3gzYjSFoOOv7+eUglzITmGP8H7jovN8c9NoOI+6bU25+lSLvyRpJOs4n8YSaX+BcUPg==}
+    engines: {node: '>=20.0.0'}
+
+  '@aws-sdk/s3-request-presigner@3.1159.0':
+    resolution: {integrity: sha512-61ydUdPny2+kJ7Jv7LRVSmJ7/HI3lyNcQFr923l7Ib4fkDLOYCvmw203BL6i2+oihxblXf3kmxdAYTjrsLUwrA==}
+    engines: {node: '>=20.0.0'}
+
+  '@aws-sdk/signature-v4-multi-region@3.996.50':
+    resolution: {integrity: sha512-Ob9GCZ3YnGPws696zTYW/rLFYHa9QLh6DgTCnkHb+0jhHNBgHWM8Ui2b+yrIjkfDsRVsglyrOey6tOOG/VWxMA==}
+    engines: {node: '>=20.0.0'}
+
+  '@aws-sdk/token-providers@3.1159.0':
+    resolution: {integrity: sha512-IJvghXXcEpqTWGjrjJwxBdEecHeXp+t9FaybRf8Zf0wYW84KN1snXBsYOdfDBBx78ChwfJxH8KIHqmhNZuDLcw==}
+    engines: {node: '>=20.0.0'}
+
+  '@aws-sdk/types@3.975.0':
+    resolution: {integrity: sha512-WQlhs6juByzWOzj5fwxOx0mPJaEqMcG/sDpl3Id61B3XhKE63xY+0QWcnG+sWR/sCfprmreyUo1lM7IKZmHMzw==}
+    engines: {node: '>=20.0.0'}
+
+  '@aws-sdk/xml-builder@3.972.42':
+    resolution: {integrity: sha512-smX8/gNP8Kjq+CWTBMZQLQ912vOQjiWExzwzvMO+eujPB5RY8IbtF0AGdUUXHKN7KoDWK9dIcuW0WG269kiP6w==}
+    engines: {node: '>=20.0.0'}
+
+  '@aws/lambda-invoke-store@0.3.0':
+    resolution: {integrity: sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==}
+    engines: {node: '>=18.0.0'}
+
+  '@babel/code-frame@7.29.7':
+    resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==}
+    engines: {node: '>=6.9.0'}
+
+  '@babel/compat-data@7.29.7':
+    resolution: {integrity: sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==}
+    engines: {node: '>=6.9.0'}
+
+  '@babel/core@7.29.7':
+    resolution: {integrity: sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==}
+    engines: {node: '>=6.9.0'}
+
+  '@babel/generator@7.29.8':
+    resolution: {integrity: sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==}
+    engines: {node: '>=6.9.0'}
+
+  '@babel/helper-annotate-as-pure@7.27.3':
+    resolution: {integrity: sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==}
+    engines: {node: '>=6.9.0'}
+
+  '@babel/helper-compilation-targets@7.29.7':
+    resolution: {integrity: sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==}
+    engines: {node: '>=6.9.0'}
+
+  '@babel/helper-globals@7.29.7':
+    resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==}
+    engines: {node: '>=6.9.0'}
+
+  '@babel/helper-module-imports@7.29.7':
+    resolution: {integrity: sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==}
+    engines: {node: '>=6.9.0'}
+
+  '@babel/helper-module-transforms@7.29.7':
+    resolution: {integrity: sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==}
+    engines: {node: '>=6.9.0'}
+    peerDependencies:
+      '@babel/core': ^7.0.0
+
+  '@babel/helper-split-export-declaration@7.24.7':
+    resolution: {integrity: sha512-oy5V7pD+UvfkEATUKvIjvIAH/xCzfsFVw7ygW2SI6NClZzquT+mwdTfgfdbUiceh6iQO0CHtCPsyze/MZ2YbAA==}
+    engines: {node: '>=6.9.0'}
+
+  '@babel/helper-string-parser@7.29.11':
+    resolution: {integrity: sha512-snZbO7MEDOof+hvNa97EjEY7qD7h2kwvQr5AJC3ZhQudKW5dnej6k3kP9NQV88hJwaS8vDqRLFo9rivLEhle2g==}
+    engines: {node: '>=6.9.0'}
+
+  '@babel/helper-validator-identifier@7.29.7':
+    resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==}
+    engines: {node: '>=6.9.0'}
+
+  '@babel/helper-validator-option@7.29.7':
+    resolution: {integrity: sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==}
+    engines: {node: '>=6.9.0'}
+
+  '@babel/helpers@7.29.10':
+    resolution: {integrity: sha512-EMCx3AGZ3WfAl6ht7AyQoPm2Lh+BGYEMdc4Q6Hf7uSCDOZFuLq44qNwL+iaim8VlyYhRAo4X43+weadK8ZB4Yg==}
+    engines: {node: '>=6.9.0'}
+
+  '@babel/parser@7.29.11':
+    resolution: {integrity: sha512-OZ6+oaK7GMyg3IfCpj8JOmn8n+w2rJbDT0LLfVDTLb1ZFeKDh3AiNELgCMUw175IJHSkLl74hG0M0VLsvx+gag==}
+    engines: {node: '>=6.0.0'}
+    hasBin: true
+
+  '@babel/runtime-corejs2@7.29.7':
+    resolution: {integrity: sha512-5GidEBf/rOQlv4sUknPRgboZEvzt/je7CpbkCZ2o7ojs9JbldxdXJinGLYE0sYR9mfPpRz3Q3D7rP+oSCal70g==}
+    engines: {node: '>=6.9.0'}
+
+  '@babel/runtime@8.0.8':
+    resolution: {integrity: sha512-dEu8nGlRqjsJsvviUUoSMfcRHpwomCiRlXsc2mPfJ79+ffTpDm+JtfUtTMzUrqCApHQnCsVPD3gT1ZT8RgBRCQ==}
+
+  '@babel/template@7.29.7':
+    resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==}
+    engines: {node: '>=6.9.0'}
+
+  '@babel/traverse@7.29.10':
+    resolution: {integrity: sha512-B3lYAs2nkKskbgQ1nfy8Ma9ao9Q1rntTAQHrzzyDuj+TSO+X3le6PfWwqDvodsb+OumE10eMFfuMCTQSQ5iuIQ==}
+    engines: {node: '>=6.9.0'}
+
+  '@babel/types@7.29.11':
+    resolution: {integrity: sha512-WCsemW40Cpc6gouC1SrY6PBaZ+oLRlEVY9XBvZBI7il/HyUrb2nJ505wE1MHEFyDZNnrWDEuy4jL50WVHfTTSQ==}
+    engines: {node: '>=6.9.0'}
+
+  '@braintree/sanitize-url@7.1.2':
+    resolution: {integrity: sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA==}
+
+  '@cfworker/json-schema@4.0.2':
+    resolution: {integrity: sha512-b11VIhI5ET+sLjY4fyiJvfLtsb/n7wc5+ETMa01jh/qul1Jv07/KFsPtq4qIFdCVV6wWmsV6QMwUMkAA454tfw==}
+
+  '@chevrotain/types@11.1.2':
+    resolution: {integrity: sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw==}
+
+  '@colors/colors@1.5.0':
+    resolution: {integrity: sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==}
+    engines: {node: '>=0.1.90'}
+
+  '@esbuild/aix-ppc64@0.28.1':
+    resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==}
+    engines: {node: '>=18'}
+    cpu: [ppc64]
+    os: [aix]
+
+  '@esbuild/android-arm64@0.28.1':
+    resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==}
+    engines: {node: '>=18'}
+    cpu: [arm64]
+    os: [android]
+
+  '@esbuild/android-arm@0.28.1':
+    resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==}
+    engines: {node: '>=18'}
+    cpu: [arm]
+    os: [android]
+
+  '@esbuild/android-x64@0.28.1':
+    resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==}
+    engines: {node: '>=18'}
+    cpu: [x64]
+    os: [android]
+
+  '@esbuild/darwin-arm64@0.28.1':
+    resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==}
+    engines: {node: '>=18'}
+    cpu: [arm64]
+    os: [darwin]
+
+  '@esbuild/darwin-x64@0.28.1':
+    resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==}
+    engines: {node: '>=18'}
+    cpu: [x64]
+    os: [darwin]
+
+  '@esbuild/freebsd-arm64@0.28.1':
+    resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==}
+    engines: {node: '>=18'}
+    cpu: [arm64]
+    os: [freebsd]
+
+  '@esbuild/freebsd-x64@0.28.1':
+    resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==}
+    engines: {node: '>=18'}
+    cpu: [x64]
+    os: [freebsd]
+
+  '@esbuild/linux-arm64@0.28.1':
+    resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==}
+    engines: {node: '>=18'}
+    cpu: [arm64]
+    os: [linux]
+
+  '@esbuild/linux-arm@0.28.1':
+    resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==}
+    engines: {node: '>=18'}
+    cpu: [arm]
+    os: [linux]
+
+  '@esbuild/linux-ia32@0.28.1':
+    resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==}
+    engines: {node: '>=18'}
+    cpu: [ia32]
+    os: [linux]
+
+  '@esbuild/linux-loong64@0.28.1':
+    resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==}
+    engines: {node: '>=18'}
+    cpu: [loong64]
+    os: [linux]
+
+  '@esbuild/linux-mips64el@0.28.1':
+    resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==}
+    engines: {node: '>=18'}
+    cpu: [mips64el]
+    os: [linux]
+
+  '@esbuild/linux-ppc64@0.28.1':
+    resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==}
+    engines: {node: '>=18'}
+    cpu: [ppc64]
+    os: [linux]
+
+  '@esbuild/linux-riscv64@0.28.1':
+    resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==}
+    engines: {node: '>=18'}
+    cpu: [riscv64]
+    os: [linux]
+
+  '@esbuild/linux-s390x@0.28.1':
+    resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==}
+    engines: {node: '>=18'}
+    cpu: [s390x]
+    os: [linux]
+
+  '@esbuild/linux-x64@0.28.1':
+    resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==}
+    engines: {node: '>=18'}
+    cpu: [x64]
+    os: [linux]
+
+  '@esbuild/netbsd-arm64@0.28.1':
+    resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==}
+    engines: {node: '>=18'}
+    cpu: [arm64]
+    os: [netbsd]
+
+  '@esbuild/netbsd-x64@0.28.1':
+    resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==}
+    engines: {node: '>=18'}
+    cpu: [x64]
+    os: [netbsd]
+
+  '@esbuild/openbsd-arm64@0.28.1':
+    resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==}
+    engines: {node: '>=18'}
+    cpu: [arm64]
+    os: [openbsd]
+
+  '@esbuild/openbsd-x64@0.28.1':
+    resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==}
+    engines: {node: '>=18'}
+    cpu: [x64]
+    os: [openbsd]
+
+  '@esbuild/openharmony-arm64@0.28.1':
+    resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==}
+    engines: {node: '>=18'}
+    cpu: [arm64]
+    os: [openharmony]
+
+  '@esbuild/sunos-x64@0.28.1':
+    resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==}
+    engines: {node: '>=18'}
+    cpu: [x64]
+    os: [sunos]
+
+  '@esbuild/win32-arm64@0.28.1':
+    resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==}
+    engines: {node: '>=18'}
+    cpu: [arm64]
+    os: [win32]
+
+  '@esbuild/win32-ia32@0.28.1':
+    resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==}
+    engines: {node: '>=18'}
+    cpu: [ia32]
+    os: [win32]
+
+  '@esbuild/win32-x64@0.28.1':
+    resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==}
+    engines: {node: '>=18'}
+    cpu: [x64]
+    os: [win32]
+
+  '@fortawesome/fontawesome-free@7.3.1':
+    resolution: {integrity: sha512-wmglKKPDIkgV3aWlZzWECCPoGIkYCulzBwxG9+w7rc5BGapZ6cPMpoPOT8k36J0Ni7PPX6c/rsoMWfS4d1MUMg==}
+    engines: {node: '>=6'}
+
+  '@gar/promise-retry@1.0.3':
+    resolution: {integrity: sha512-GmzA9ckNokPypTg10pgpeHNQe7ph+iIKKmhKu3Ob9ANkswreCx7R3cKmY781K8QK3AqVL3xVh9A42JvIAbkkSA==}
+    engines: {node: ^20.17.0 || >=22.9.0}
+
+  '@hono/node-server@2.1.5':
+    resolution: {integrity: sha512-MgP/I06GdvsilDspMk/qmRzpk8LOUStP6jFAbSRi40W09DhoyUn+Mj7eyKICPqeYKybOjVFLVIAQzvu1bfIa7Q==}
+    engines: {node: '>=20'}
+    peerDependencies:
+      hono: ^4 || ^5.0.0-0
+
+  '@iconify/types@2.0.0':
+    resolution: {integrity: sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==}
+
+  '@iconify/utils@3.1.7':
+    resolution: {integrity: sha512-JZHlwdID+dy+lTgbYC8NEC4zeugqeYsc6jewvzb4c58kHauJn+X7rNwQjxz5p2qSjqaEeQoLkCIQ9v/H4PK0/w==}
+
+  '@inquirer/checkbox@4.2.1':
+    resolution: {integrity: sha512-bevKGO6kX1eM/N+pdh9leS5L7TBF4ICrzi9a+cbWkrxeAeIcwlo/7OfWGCDERdRCI2/Q6tjltX4bt07ALHDwFw==}
+    engines: {node: '>=18'}
+    peerDependencies:
+      '@types/node': '>=18'
+    peerDependenciesMeta:
+      '@types/node':
+        optional: true
+
+  '@inquirer/confirm@5.1.14':
+    resolution: {integrity: sha512-5yR4IBfe0kXe59r1YCTG8WXkUbl7Z35HK87Sw+WUyGD8wNUx7JvY7laahzeytyE1oLn74bQnL7hstctQxisQ8Q==}
+    engines: {node: '>=18'}
+    peerDependencies:
+      '@types/node': '>=18'
+    peerDependenciesMeta:
+      '@types/node':
+        optional: true
+
+  '@inquirer/core@10.1.15':
+    resolution: {integrity: sha512-8xrp836RZvKkpNbVvgWUlxjT4CraKk2q+I3Ksy+seI2zkcE+y6wNs1BVhgcv8VyImFecUhdQrYLdW32pAjwBdA==}
+    engines: {node: '>=18'}
+    peerDependencies:
+      '@types/node': '>=18'
+    peerDependenciesMeta:
+      '@types/node':
+        optional: true
+
+  '@inquirer/editor@4.2.17':
+    resolution: {integrity: sha512-r6bQLsyPSzbWrZZ9ufoWL+CztkSatnJ6uSxqd6N+o41EZC51sQeWOzI6s5jLb+xxTWxl7PlUppqm8/sow241gg==}
+    engines: {node: '>=18'}
+    peerDependencies:
+      '@types/node': '>=18'
+    peerDependenciesMeta:
+      '@types/node':
+        optional: true
+
+  '@inquirer/expand@4.0.17':
+    resolution: {integrity: sha512-PSqy9VmJx/VbE3CT453yOfNa+PykpKg/0SYP7odez1/NWBGuDXgPhp4AeGYYKjhLn5lUUavVS/JbeYMPdH50Mw==}
+    engines: {node: '>=18'}
+    peerDependencies:
+      '@types/node': '>=18'
+    peerDependenciesMeta:
+      '@types/node':
+        optional: true
+
+  '@inquirer/external-editor@1.0.1':
+    resolution: {integrity: sha512-Oau4yL24d2B5IL4ma4UpbQigkVhzPDXLoqy1ggK4gnHg/stmkffJE4oOXHXF3uz0UEpywG68KcyXsyYpA1Re/Q==}
+    engines: {node: '>=18'}
+    peerDependencies:
+      '@types/node': '>=18'
+    peerDependenciesMeta:
+      '@types/node':
+        optional: true
+
+  '@inquirer/figures@1.0.13':
+    resolution: {integrity: sha512-lGPVU3yO9ZNqA7vTYz26jny41lE7yoQansmqdMLBEfqaGsmdg7V3W9mK9Pvb5IL4EVZ9GnSDGMO/cJXud5dMaw==}
+    engines: {node: '>=18'}
+
+  '@inquirer/input@4.2.1':
+    resolution: {integrity: sha512-tVC+O1rBl0lJpoUZv4xY+WGWY8V5b0zxU1XDsMsIHYregdh7bN5X5QnIONNBAl0K765FYlAfNHS2Bhn7SSOVow==}
+    engines: {node: '>=18'}
+    peerDependencies:
+      '@types/node': '>=18'
+    peerDependenciesMeta:
+      '@types/node':
+        optional: true
+
+  '@inquirer/number@3.0.17':
+    resolution: {integrity: sha512-GcvGHkyIgfZgVnnimURdOueMk0CztycfC8NZTiIY9arIAkeOgt6zG57G+7vC59Jns3UX27LMkPKnKWAOF5xEYg==}
+    engines: {node: '>=18'}
+    peerDependencies:
+      '@types/node': '>=18'
+    peerDependenciesMeta:
+      '@types/node':
+        optional: true
+
+  '@inquirer/password@4.0.17':
+    resolution: {integrity: sha512-DJolTnNeZ00E1+1TW+8614F7rOJJCM4y4BAGQ3Gq6kQIG+OJ4zr3GLjIjVVJCbKsk2jmkmv6v2kQuN/vriHdZA==}
+    engines: {node: '>=18'}
+    peerDependencies:
+      '@types/node': '>=18'
+    peerDependenciesMeta:
+      '@types/node':
+        optional: true
+
+  '@inquirer/prompts@7.8.2':
+    resolution: {integrity: sha512-nqhDw2ZcAUrKNPwhjinJny903bRhI0rQhiDz1LksjeRxqa36i3l75+4iXbOy0rlDpLJGxqtgoPavQjmmyS5UJw==}
+    engines: {node: '>=18'}
+    peerDependencies:
+      '@types/node': '>=18'
+    peerDependenciesMeta:
+      '@types/node':
+        optional: true
+
+  '@inquirer/rawlist@4.1.5':
+    resolution: {integrity: sha512-R5qMyGJqtDdi4Ht521iAkNqyB6p2UPuZUbMifakg1sWtu24gc2Z8CJuw8rP081OckNDMgtDCuLe42Q2Kr3BolA==}
+    engines: {node: '>=18'}
+    peerDependencies:
+      '@types/node': '>=18'
+    peerDependenciesMeta:
+      '@types/node':
+        optional: true
+
+  '@inquirer/search@3.1.0':
+    resolution: {integrity: sha512-PMk1+O/WBcYJDq2H7foV0aAZSmDdkzZB9Mw2v/DmONRJopwA/128cS9M/TXWLKKdEQKZnKwBzqu2G4x/2Nqx8Q==}
+    engines: {node: '>=18'}
+    peerDependencies:
+      '@types/node': '>=18'
+    peerDependenciesMeta:
+      '@types/node':
+        optional: true
+
+  '@inquirer/select@4.3.1':
+    resolution: {integrity: sha512-Gfl/5sqOF5vS/LIrSndFgOh7jgoe0UXEizDqahFRkq5aJBLegZ6WjuMh/hVEJwlFQjyLq1z9fRtvUMkb7jM1LA==}
+    engines: {node: '>=18'}
+    peerDependencies:
+      '@types/node': '>=18'
+    peerDependenciesMeta:
+      '@types/node':
+        optional: true
+
+  '@inquirer/type@3.0.7':
+    resolution: {integrity: sha512-PfunHQcjwnju84L+ycmcMKB/pTPIngjUJvfnRhKY6FKPuYXlM4aQCb/nIdTFR6BEhMjFvngzvng/vBAJMZpLSA==}
+    engines: {node: '>=18'}
+    peerDependencies:
+      '@types/node': '>=18'
+    peerDependenciesMeta:
+      '@types/node':
+        optional: true
+
+  '@inquirer/type@3.0.8':
+    resolution: {integrity: sha512-lg9Whz8onIHRthWaN1Q9EGLa/0LFJjyM8mEUbL1eTi6yMGvBf8gvyDLtxSXztQsxMvhxxNpJYrwa1YHdq+w4Jw==}
+    engines: {node: '>=18'}
+    peerDependencies:
+      '@types/node': '>=18'
+    peerDependenciesMeta:
+      '@types/node':
+        optional: true
+
+  '@isaacs/fs-minipass@4.0.1':
+    resolution: {integrity: sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==}
+    engines: {node: '>=18.0.0'}
+
+  '@istanbuljs/schema@0.1.6':
+    resolution: {integrity: sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==}
+    engines: {node: '>=8'}
+
+  '@jridgewell/gen-mapping@0.3.12':
+    resolution: {integrity: sha512-OuLGC46TjB5BbN1dH8JULVVZY4WTdkF7tV9Ys6wLL1rubZnCMstOhNHueU5bLCrnRuDhKPDM4g6sw4Bel5Gzqg==}
+
+  '@jridgewell/gen-mapping@0.3.2':
+    resolution: {integrity: sha512-mh65xKQAzI6iBcFzwv28KVWSmCkdRBWoOh+bYQGW3+6OZvbbN3TqMGo5hqYxQniRcH9F2VZIoJCm4pa3BPDK/A==}
+    engines: {node: '>=6.0.0'}
+
+  '@jridgewell/gen-mapping@0.3.5':
+    resolution: {integrity: sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==}
+    engines: {node: '>=6.0.0'}
+
+  '@jridgewell/remapping@2.3.5':
+    resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==}
+
+  '@jridgewell/resolve-uri@3.1.2':
+    resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==}
+    engines: {node: '>=6.0.0'}
+
+  '@jridgewell/set-array@1.2.1':
+    resolution: {integrity: sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==}
+    engines: {node: '>=6.0.0'}
+
+  '@jridgewell/sourcemap-codec@1.6.0':
+    resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==}
+
+  '@jridgewell/trace-mapping@0.3.31':
+    resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
+
+  '@langchain/core@0.3.80':
+    resolution: {integrity: sha512-vcJDV2vk1AlCwSh3aBm/urQ1ZrlXFFBocv11bz/NBUfLWD5/UDNMzwPdaAd2dKvNmTWa9FM2lirLU3+JCf4cRA==}
+    engines: {node: '>=18'}
+
+  '@listr2/prompt-adapter-inquirer@3.0.1':
+    resolution: {integrity: sha512-3XFmGwm3u6ioREG+ynAQB7FoxfajgQnMhIu8wC5eo/Lsih4aKDg0VuIMGaOsYn7hJSJagSeaD4K8yfpkEoDEmA==}
+    engines: {node: '>=20.0.0'}
+    peerDependencies:
+      '@inquirer/prompts': '>= 3 < 8'
+      listr2: 9.0.1
+
+  '@lmdb/lmdb-darwin-arm64@3.4.2':
+    resolution: {integrity: sha512-NK80WwDoODyPaSazKbzd3NEJ3ygePrkERilZshxBViBARNz21rmediktGHExoj9n5t9+ChlgLlxecdFKLCuCKg==}
+    cpu: [arm64]
+    os: [darwin]
+
+  '@lmdb/lmdb-darwin-x64@3.4.2':
+    resolution: {integrity: sha512-zevaowQNmrp3U7Fz1s9pls5aIgpKRsKb3dZWDINtLiozh3jZI9fBrI19lYYBxqdyiIyNdlyiidPnwPShj4aK+w==}
+    cpu: [x64]
+    os: [darwin]
+
+  '@lmdb/lmdb-linux-arm64@3.4.2':
+    resolution: {integrity: sha512-ZBEfbNZdkneebvZs98Lq30jMY8V9IJzckVeigGivV7nTHJc+89Ctomp1kAIWKlwIG0ovCDrFI448GzFPORANYg==}
+    cpu: [arm64]
+    os: [linux]
+
+  '@lmdb/lmdb-linux-arm@3.4.2':
+    resolution: {integrity: sha512-OmHCULY17rkx/RoCoXlzU7LyR8xqrksgdYWwtYa14l/sseezZ8seKWXcogHcjulBddER5NnEFV4L/Jtr2nyxeg==}
+    cpu: [arm]
+    os: [linux]
+
+  '@lmdb/lmdb-linux-x64@3.4.2':
+    resolution: {integrity: sha512-vL9nM17C77lohPYE4YaAQvfZCSVJSryE4fXdi8M7uWPBnU+9DJabgKVAeyDb84ZM2vcFseoBE4/AagVtJeRE7g==}
+    cpu: [x64]
+    os: [linux]
+
+  '@lmdb/lmdb-win32-arm64@3.4.2':
+    resolution: {integrity: sha512-SXWjdBfNDze4ZPeLtYIzsIeDJDJ/SdsA0pEXcUBayUIMO0FQBHfVZZyHXQjjHr4cvOAzANBgIiqaXRwfMhzmLw==}
+    cpu: [arm64]
+    os: [win32]
+
+  '@lmdb/lmdb-win32-x64@3.4.2':
+    resolution: {integrity: sha512-IY+r3bxKW6Q6sIPiMC0L533DEfRJSXibjSI3Ft/w9Q8KQBNqEIvUFXt+09wV8S5BRk0a8uSF19YWxuRwEfI90g==}
+    cpu: [x64]
+    os: [win32]
+
+  '@matrix-org/matrix-sdk-crypto-wasm@18.9.0':
+    resolution: {integrity: sha512-NtLzNDTEfNASBjNCTGZHuk7N0jrQhS6Zx2CM857rCHZYYh/rpbKGQNrJ7azktm4S1P5Jy3WU3nwJTGohGw26qQ==}
+    engines: {node: '>= 18'}
+
+  '@mermaid-js/parser@1.2.1':
+    resolution: {integrity: sha512-n12NohV3mrUyUL2o93IgG/ifeW9FTyeJn3zDxkhwa8MJ9Fxg3HQMlA3RiGmD/3UnJvheztkjjQAjA2T4LmUcpw==}
+
+  '@modelcontextprotocol/sdk@1.32.1':
+    resolution: {integrity: sha512-2DdE+SJDtzLEEWzY1ZjY7Q+VcPhcV1KisD3zI4u0XZyktsjHum1mwbMI+JaulUBi2OZk+KJAi2uPXzxichPkdw==}
+    engines: {node: '>=18'}
+    peerDependencies:
+      '@cfworker/json-schema': ^4.1.1
+      zod: ^3.25 || ^4.0
+    peerDependenciesMeta:
+      '@cfworker/json-schema':
+        optional: true
+
+  '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4':
+    resolution: {integrity: sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ==}
+    cpu: [arm64]
+    os: [darwin]
+
+  '@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.4':
+    resolution: {integrity: sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w==}
+    cpu: [x64]
+    os: [darwin]
+
+  '@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.4':
+    resolution: {integrity: sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw==}
+    cpu: [arm64]
+    os: [linux]
+
+  '@msgpackr-extract/msgpackr-extract-linux-arm@3.0.4':
+    resolution: {integrity: sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw==}
+    cpu: [arm]
+    os: [linux]
+
+  '@msgpackr-extract/msgpackr-extract-linux-x64@3.0.4':
+    resolution: {integrity: sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ==}
+    cpu: [x64]
+    os: [linux]
+
+  '@msgpackr-extract/msgpackr-extract-win32-x64@3.0.4':
+    resolution: {integrity: sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ==}
+    cpu: [x64]
+    os: [win32]
+
+  '@napi-rs/canvas-android-arm64@1.0.13':
+    resolution: {integrity: sha512-trn7AaDzQ+yeeA0ezhkC7cB1ZDPtp4UYk0EvB5F8qtYL9jEVWtVrU30WOpI52DszoTxaTsCgf5PP4ZVaOlOIHQ==}
+    engines: {node: '>= 10'}
+    cpu: [arm64]
+    os: [android]
+
+  '@napi-rs/canvas-darwin-arm64@1.0.13':
+    resolution: {integrity: sha512-6Y2djEetL8copxfNK6hg7oig/xqTwOchv19hBOm+zBNdKc/02Z3kjZN5wrLzv/l5O04/bAQS6I/AlQpwBlXgPg==}
+    engines: {node: '>= 10'}
+    cpu: [arm64]
+    os: [darwin]
+
+  '@napi-rs/canvas-darwin-x64@1.0.13':
+    resolution: {integrity: sha512-OZoPwwu4TMMg8GTCFBrg8UFExwQTU/55i3uTC/izDat6PHE+ro9dimyQoOboFf91+NY7Pnn70ScR8f8I8+eZXw==}
+    engines: {node: '>= 10'}
+    cpu: [x64]
+    os: [darwin]
+
+  '@napi-rs/canvas-linux-arm-gnueabihf@1.0.13':
+    resolution: {integrity: sha512-dZSYaKROuswU2eUWG5aBKndNVe68elo1YX93xtRax7q658tj8rYE+HwduovWJgs6qwv1r1/CLR60nDeBbyjvyg==}
+    engines: {node: '>= 10'}
+    cpu: [arm]
+    os: [linux]
+
+  '@napi-rs/canvas-linux-arm64-gnu@1.0.13':
+    resolution: {integrity: sha512-W6FzYE8ADHI93In1vi80KBL6btDtAMuj50Ch5ptkWS8xuntyzbR4h5JDUmXoakxZn2yMInksudTEqVSWoFqFGg==}
+    engines: {node: '>= 10'}
+    cpu: [arm64]
+    os: [linux]
+    libc: [glibc]
+
+  '@napi-rs/canvas-linux-arm64-musl@1.0.13':
+    resolution: {integrity: sha512-+6fQ+rR49hkAa4voh2stLIwQ+4t+gmoseM8Bc53/DlMXb73IKymDPWK9A/NtWeqTW4ijWppYuyuxS2hPI8tzNA==}
+    engines: {node: '>= 10'}
+    cpu: [arm64]
+    os: [linux]
+    libc: [musl]
+
+  '@napi-rs/canvas-linux-riscv64-gnu@1.0.13':
+    resolution: {integrity: sha512-17XEMO3t9RW/TtHtSFf2AkaSWuCWdzbPeidz7zrOFawHxoLSJCnpTI1hN5aUvrZNnkNNmt/vZVJCh0ryUaik3A==}
+    engines: {node: '>= 10'}
+    cpu: [riscv64]
+    os: [linux]
+    libc: [glibc]
+
+  '@napi-rs/canvas-linux-x64-gnu@1.0.13':
+    resolution: {integrity: sha512-lc48IwknVHBsNzXn9Xs5OkUsRX38Ml3Z1aOybQ51mMEzZckm8+4A0qROlyYHEoBPzr1kL3IU8ZUAqDb7wtdX8w==}
+    engines: {node: '>= 10'}
+    cpu: [x64]
+    os: [linux]
+    libc: [glibc]
+
+  '@napi-rs/canvas-linux-x64-musl@1.0.13':
+    resolution: {integrity: sha512-bGY1r26S2bs8UNFJcPWcCSNdCFbKep5PATsvtgtC9A5u6SRgr2/jJE/RlhHL5m9ZpixNDhVkLY/5h16Kx5V9YA==}
+    engines: {node: '>= 10'}
+    cpu: [x64]
+    os: [linux]
+    libc: [musl]
+
+  '@napi-rs/canvas-win32-arm64-msvc@1.0.13':
+    resolution: {integrity: sha512-2vRPBZoaIvJrUkDhZTE2Hn0YrnpJRCWVM3EJACoBKjuWI6WgPxGtUoRMsxRviCsyks8bKTSFdyw14CellnG2qA==}
+    engines: {node: '>= 10'}
+    cpu: [arm64]
+    os: [win32]
+
+  '@napi-rs/canvas-win32-x64-msvc@1.0.13':
+    resolution: {integrity: sha512-DUlW3lZPz63kYsf8mxgyyZuevhlH0T3/BGP9l+NazhB0F1+gL6D4hqZdnhZpw24DlWmTpATDcyYSZlJOi+DdfQ==}
+    engines: {node: '>= 10'}
+    cpu: [x64]
+    os: [win32]
+
+  '@napi-rs/canvas@1.0.13':
+    resolution: {integrity: sha512-b87ugZpQ5ViwqVBkjervxpwa0GmJPsKHLBNoZN6GpjWeHzTbDUGryriq+2DYW2XbB+xCqSIINQysC0/I87nPbw==}
+    engines: {node: '>= 10'}
+
+  '@napi-rs/nice-android-arm-eabi@1.1.1':
+    resolution: {integrity: sha512-kjirL3N6TnRPv5iuHw36wnucNqXAO46dzK9oPb0wj076R5Xm8PfUVA9nAFB5ZNMmfJQJVKACAPd/Z2KYMppthw==}
+    engines: {node: '>= 10'}
+    cpu: [arm]
+    os: [android]
+
+  '@napi-rs/nice-android-arm64@1.1.1':
+    resolution: {integrity: sha512-blG0i7dXgbInN5urONoUCNf+DUEAavRffrO7fZSeoRMJc5qD+BJeNcpr54msPF6qfDD6kzs9AQJogZvT2KD5nw==}
+    engines: {node: '>= 10'}
+    cpu: [arm64]
+    os: [android]
+
+  '@napi-rs/nice-darwin-arm64@1.1.1':
+    resolution: {integrity: sha512-s/E7w45NaLqTGuOjC2p96pct4jRfo61xb9bU1unM/MJ/RFkKlJyJDx7OJI/O0ll/hrfpqKopuAFDV8yo0hfT7A==}
+    engines: {node: '>= 10'}
+    cpu: [arm64]
+    os: [darwin]
+
+  '@napi-rs/nice-darwin-x64@1.1.1':
+    resolution: {integrity: sha512-dGoEBnVpsdcC+oHHmW1LRK5eiyzLwdgNQq3BmZIav+9/5WTZwBYX7r5ZkQC07Nxd3KHOCkgbHSh4wPkH1N1LiQ==}
+    engines: {node: '>= 10'}
+    cpu: [x64]
+    os: [darwin]
+
+  '@napi-rs/nice-freebsd-x64@1.1.1':
+    resolution: {integrity: sha512-kHv4kEHAylMYmlNwcQcDtXjklYp4FCf0b05E+0h6nDHsZ+F0bDe04U/tXNOqrx5CmIAth4vwfkjjUmp4c4JktQ==}
+    engines: {node: '>= 10'}
+    cpu: [x64]
+    os: [freebsd]
+
+  '@napi-rs/nice-linux-arm-gnueabihf@1.1.1':
+    resolution: {integrity: sha512-E1t7K0efyKXZDoZg1LzCOLxgolxV58HCkaEkEvIYQx12ht2pa8hoBo+4OB3qh7e+QiBlp1SRf+voWUZFxyhyqg==}
+    engines: {node: '>= 10'}
+    cpu: [arm]
+    os: [linux]
+
+  '@napi-rs/nice-linux-arm64-gnu@1.1.1':
+    resolution: {integrity: sha512-CIKLA12DTIZlmTaaKhQP88R3Xao+gyJxNWEn04wZwC2wmRapNnxCUZkVwggInMJvtVElA+D4ZzOU5sX4jV+SmQ==}
+    engines: {node: '>= 10'}
+    cpu: [arm64]
+    os: [linux]
+    libc: [glibc]
+
+  '@napi-rs/nice-linux-arm64-musl@1.1.1':
+    resolution: {integrity: sha512-+2Rzdb3nTIYZ0YJF43qf2twhqOCkiSrHx2Pg6DJaCPYhhaxbLcdlV8hCRMHghQ+EtZQWGNcS2xF4KxBhSGeutg==}
+    engines: {node: '>= 10'}
+    cpu: [arm64]
+    os: [linux]
+    libc: [musl]
+
+  '@napi-rs/nice-linux-ppc64-gnu@1.1.1':
+    resolution: {integrity: sha512-4FS8oc0GeHpwvv4tKciKkw3Y4jKsL7FRhaOeiPei0X9T4Jd619wHNe4xCLmN2EMgZoeGg+Q7GY7BsvwKpL22Tg==}
+    engines: {node: '>= 10'}
+    cpu: [ppc64]
+    os: [linux]
+    libc: [glibc]
+
+  '@napi-rs/nice-linux-riscv64-gnu@1.1.1':
+    resolution: {integrity: sha512-HU0nw9uD4FO/oGCCk409tCi5IzIZpH2agE6nN4fqpwVlCn5BOq0MS1dXGjXaG17JaAvrlpV5ZeyZwSon10XOXw==}
+    engines: {node: '>= 10'}
+    cpu: [riscv64]
+    os: [linux]
+    libc: [glibc]
+
+  '@napi-rs/nice-linux-s390x-gnu@1.1.1':
+    resolution: {integrity: sha512-2YqKJWWl24EwrX0DzCQgPLKQBxYDdBxOHot1KWEq7aY2uYeX+Uvtv4I8xFVVygJDgf6/92h9N3Y43WPx8+PAgQ==}
+    engines: {node: '>= 10'}
+    cpu: [s390x]
+    os: [linux]
+    libc: [glibc]
+
+  '@napi-rs/nice-linux-x64-gnu@1.1.1':
+    resolution: {integrity: sha512-/gaNz3R92t+dcrfCw/96pDopcmec7oCcAQ3l/M+Zxr82KT4DljD37CpgrnXV+pJC263JkW572pdbP3hP+KjcIg==}
+    engines: {node: '>= 10'}
+    cpu: [x64]
+    os: [linux]
+    libc: [glibc]
+
+  '@napi-rs/nice-linux-x64-musl@1.1.1':
+    resolution: {integrity: sha512-xScCGnyj/oppsNPMnevsBe3pvNaoK7FGvMjT35riz9YdhB2WtTG47ZlbxtOLpjeO9SqqQ2J2igCmz6IJOD5JYw==}
+    engines: {node: '>= 10'}
+    cpu: [x64]
+    os: [linux]
+    libc: [musl]
+
+  '@napi-rs/nice-openharmony-arm64@1.1.1':
+    resolution: {integrity: sha512-6uJPRVwVCLDeoOaNyeiW0gp2kFIM4r7PL2MczdZQHkFi9gVlgm+Vn+V6nTWRcu856mJ2WjYJiumEajfSm7arPQ==}
+    engines: {node: '>= 10'}
+    cpu: [arm64]
+    os: [openharmony]
+
+  '@napi-rs/nice-win32-arm64-msvc@1.1.1':
+    resolution: {integrity: sha512-uoTb4eAvM5B2aj/z8j+Nv8OttPf2m+HVx3UjA5jcFxASvNhQriyCQF1OB1lHL43ZhW+VwZlgvjmP5qF3+59atA==}
+    engines: {node: '>= 10'}
+    cpu: [arm64]
+    os: [win32]
+
+  '@napi-rs/nice-win32-ia32-msvc@1.1.1':
+    resolution: {integrity: sha512-CNQqlQT9MwuCsg1Vd/oKXiuH+TcsSPJmlAFc5frFyX/KkOh0UpBLEj7aoY656d5UKZQMQFP7vJNa1DNUNORvug==}
+    engines: {node: '>= 10'}
+    cpu: [ia32]
+    os: [win32]
+
+  '@napi-rs/nice-win32-x64-msvc@1.1.1':
+    resolution: {integrity: sha512-vB+4G/jBQCAh0jelMTY3+kgFy00Hlx2f2/1zjMoH821IbplbWZOkLiTYXQkygNTzQJTq5cvwBDgn2ppHD+bglQ==}
+    engines: {node: '>= 10'}
+    cpu: [x64]
+    os: [win32]
+
+  '@napi-rs/nice@1.1.1':
+    resolution: {integrity: sha512-xJIPs+bYuc9ASBl+cvGsKbGrJmS6fAKaSZCnT0lhahT5rhA2VVy9/EcIgd2JhtEuFOJNx7UHNn/qiTPTY4nrQw==}
+    engines: {node: '>= 10'}
+
+  '@nodelib/fs.scandir@2.1.5':
+    resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
+    engines: {node: '>= 8'}
+
+  '@nodelib/fs.stat@2.0.5':
+    resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==}
+    engines: {node: '>= 8'}
+
+  '@nodelib/fs.walk@1.2.8':
+    resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==}
+    engines: {node: '>= 8'}
+
+  '@npmcli/agent@4.0.2':
+    resolution: {integrity: sha512-EUEuWAxnL07Sp5/iC/1X6Xj+XThUvnbei9zfRWZdEXa7lss9RTHMhAHBeg+MZ5To9s/gGaSI+UwZTPdYMvKSeg==}
+    engines: {node: ^20.17.0 || >=22.9.0}
+
+  '@npmcli/fs@5.0.0':
+    resolution: {integrity: sha512-7OsC1gNORBEawOa5+j2pXN9vsicaIOH5cPXxoR6fJOmH6/EXpJB2CajXOu1fPRFun2m1lktEFX11+P89hqO/og==}
+    engines: {node: ^20.17.0 || >=22.9.0}
+
+  '@npmcli/git@7.0.2':
+    resolution: {integrity: sha512-oeolHDjExNAJAnlYP2qzNjMX/Xi9bmu78C9dIGr4xjobrSKbuMYCph8lTzn4vnW3NjIqVmw/f8BCfouqyJXlRg==}
+    engines: {node: ^20.17.0 || >=22.9.0}
+
+  '@npmcli/installed-package-contents@4.0.0':
+    resolution: {integrity: sha512-yNyAdkBxB72gtZ4GrwXCM0ZUedo9nIbOMKfGjt6Cu6DXf0p8y1PViZAKDC8q8kv/fufx0WTjRBdSlyrvnP7hmA==}
+    engines: {node: ^20.17.0 || >=22.9.0}
+    hasBin: true
+
+  '@npmcli/node-gyp@5.0.0':
+    resolution: {integrity: sha512-uuG5HZFXLfyFKqg8QypsmgLQW7smiRjVc45bqD/ofZZcR/uxEjgQU8qDPv0s9TEeMUiAAU/GC5bR6++UdTirIQ==}
+    engines: {node: ^20.17.0 || >=22.9.0}
+
+  '@npmcli/package-json@7.0.5':
+    resolution: {integrity: sha512-iVuTlG3ORq2iaVa1IWUxAO/jIp77tUKBhoMjuzYW2kL4MLN1bi/ofqkZ7D7OOwh8coAx1/S2ge0rMdGv8sLSOQ==}
+    engines: {node: ^20.17.0 || >=22.9.0}
+
+  '@npmcli/promise-spawn@9.0.2':
+    resolution: {integrity: sha512-h7b1j0egrruBm/32eVA6/zMw5VJxxaB3vT+uVGGQ4SAAMrQVFHNuzL4+EAXn66j+huj0jApINc1fW3fC+kVoeA==}
+    engines: {node: ^20.17.0 || >=22.9.0}
+
+  '@npmcli/redact@3.0.0':
+    resolution: {integrity: sha512-/1uFzjVcfzqrgCeGW7+SZ4hv0qLWmKXVzFahZGJ6QuJBj6Myt9s17+JL86i76NV9YSnJRcGXJYQbAU0rn1YTCQ==}
+    engines: {node: ^18.17.0 || >=20.5.0}
+
+  '@npmcli/redact@4.0.0':
+    resolution: {integrity: sha512-gOBg5YHMfZy+TfHArfVogwgfBeQnKbbGo3pSUyK/gSI0AVu+pEiDVcKlQb0D8Mg1LNRZILZ6XG8I5dJ4KuAd9Q==}
+    engines: {node: ^20.17.0 || >=22.9.0}
+
+  '@npmcli/run-script@10.0.4':
+    resolution: {integrity: sha512-mGUWr1uMnf0le2TwfOZY4SFxZGXGfm4Jtay/nwAa2FLNAKXUoUwaGwBMNH36UHPtinWfTSJ3nqFQr0091CxVGg==}
+    engines: {node: ^20.17.0 || >=22.9.0}
+
+  '@parcel/watcher-android-arm64@2.6.0':
+    resolution: {integrity: sha512-trgpLSCKRC/huFjXX/Smh+0sWe4+YtKfktIToiMl59ghz7z+qkH6kMvNnUbLyRs9N11t8l4svSCs1+5B3rOAhA==}
+    engines: {node: '>= 10.0.0'}
+    cpu: [arm64]
+    os: [android]
+
+  '@parcel/watcher-darwin-arm64@2.6.0':
+    resolution: {integrity: sha512-Y3QV0gl7Q1zbfueunkWIERICbEojQFCgpyG7YqOGNFLsckXyI1xu9mAIUpKY9QBYzBtSkN8dBPwd3yiAO9ovMw==}
+    engines: {node: '>= 10.0.0'}
+    cpu: [arm64]
+    os: [darwin]
+
+  '@parcel/watcher-darwin-x64@2.6.0':
+    resolution: {integrity: sha512-Ohv6OpzhUfKYD7Beb8kDvG0jbIxORCYY1JRdZnaBtnjjkJxgD7ZVL0nw2sCYd0yTMKTvz3nnTnOF3cDifK+kvw==}
+    engines: {node: '>= 10.0.0'}
+    cpu: [x64]
+    os: [darwin]
+
+  '@parcel/watcher-freebsd-x64@2.6.0':
+    resolution: {integrity: sha512-5HmXvDgs8VK+74jF9y9/2FE3/OnlcKmc56tjmSrEuZjpSZOGL+fvAu+HKJBdPs9uwoP2hE6TlSUpXZ/C5jUFmQ==}
+    engines: {node: '>= 10.0.0'}
+    cpu: [x64]
+    os: [freebsd]
+
+  '@parcel/watcher-linux-arm-glibc@2.6.0':
+    resolution: {integrity: sha512-Ps/hui3A+vMbjdqlqAowK2ZL8+BO8dBjxeWXj6npTBs3jx4wWmbPpaLuqwrQrSqIVMCnpWo238bJ1U37GhQOYg==}
+    engines: {node: '>= 10.0.0'}
+    cpu: [arm]
+    os: [linux]
+    libc: [glibc]
+
+  '@parcel/watcher-linux-arm-musl@2.6.0':
+    resolution: {integrity: sha512-9c6AUHgHoG+IY88MRIHupztQiQnrbqHYQjkM2btA+Bf/wQnQMuiD0Wfk1EVv3TlNT3x41uU71rn6E4xh/+zvkw==}
+    engines: {node: '>= 10.0.0'}
+    cpu: [arm]
+    os: [linux]
+    libc: [musl]
+
+  '@parcel/watcher-linux-arm64-glibc@2.6.0':
+    resolution: {integrity: sha512-yHRqS2owEXe6Hic9z6Mh1ECsCd+ODVOGvZDyciqRd21+v+o+DnXMOrw50DSpIG2sb8GPEaPPmfeCAWKPJdq46g==}
+    engines: {node: '>= 10.0.0'}
+    cpu: [arm64]
+    os: [linux]
+    libc: [glibc]
+
+  '@parcel/watcher-linux-arm64-musl@2.6.0':
+    resolution: {integrity: sha512-WhB2e/V7rqdHHWZusBSPuy5Ei8S6lSz6FE5TKKQz5h3a0O+C+mhY7vxU9b/stqvMb8beLnPY82ZrFTLKs+SrKA==}
+    engines: {node: '>= 10.0.0'}
+    cpu: [arm64]
+    os: [linux]
+    libc: [musl]
+
+  '@parcel/watcher-linux-x64-glibc@2.6.0':
+    resolution: {integrity: sha512-ulGE6x6Oz6iAwg75T8YQSoguBWasniIbX+QWpaYPcCnDOpdWX3k+4xbEYPZVLxOuoJI+svJJPD3sEj8G7lrQ3A==}
+    engines: {node: '>= 10.0.0'}
+    cpu: [x64]
+    os: [linux]
+    libc: [glibc]
+
+  '@parcel/watcher-linux-x64-musl@2.6.0':
+    resolution: {integrity: sha512-tkBYKt7YQrjIJWYDnto2YgO8MRkjlMTSNoRHzsXinBqbLdeOM3L32wPZJvIZxqaLMfSlS/4sUjH/6STVP/XDLw==}
+    engines: {node: '>= 10.0.0'}
+    cpu: [x64]
+    os: [linux]
+    libc: [musl]
+
+  '@parcel/watcher-win32-arm64@2.6.0':
+    resolution: {integrity: sha512-gIZAP23jaHjGWasY/TY6yL7NHFClf0Ga7FN+iINvk+KN94rhm94lYZhFsbYFNcA04/onvGD9kKmiJLJB2HbNwQ==}
+    engines: {node: '>= 10.0.0'}
+    cpu: [arm64]
+    os: [win32]
+
+  '@parcel/watcher-win32-x64@2.6.0':
+    resolution: {integrity: sha512-cA+/pXV2YkfxlIcXOQ5fSWqAzzPyD78/x5qbK/I0vUkrlYHA8TIz+MXjAbGouguKVSI4bOmkTSJ1/poVSsgt+A==}
+    engines: {node: '>= 10.0.0'}
+    cpu: [x64]
+    os: [win32]
+
+  '@parcel/watcher@2.6.0':
+    resolution: {integrity: sha512-7FNeNl8NCE7aINx7WXiKQrPYZWC/hvrTsmk6zmxbI7LTXE7hVek/n8AfVgpe2y82zl3w0HvCHN0bVKMBoJcC0w==}
+    engines: {node: '>= 10.0.0'}
+
+  '@rollup/rollup-android-arm-eabi@4.59.0':
+    resolution: {integrity: sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==}
+    cpu: [arm]
+    os: [android]
+
+  '@rollup/rollup-android-arm64@4.59.0':
+    resolution: {integrity: sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==}
+    cpu: [arm64]
+    os: [android]
+
+  '@rollup/rollup-darwin-arm64@4.59.0':
+    resolution: {integrity: sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==}
+    cpu: [arm64]
+    os: [darwin]
+
+  '@rollup/rollup-darwin-x64@4.59.0':
+    resolution: {integrity: sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==}
+    cpu: [x64]
+    os: [darwin]
+
+  '@rollup/rollup-freebsd-arm64@4.59.0':
+    resolution: {integrity: sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==}
+    cpu: [arm64]
+    os: [freebsd]
+
+  '@rollup/rollup-freebsd-x64@4.59.0':
+    resolution: {integrity: sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==}
+    cpu: [x64]
+    os: [freebsd]
+
+  '@rollup/rollup-linux-arm-gnueabihf@4.59.0':
+    resolution: {integrity: sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==}
+    cpu: [arm]
+    os: [linux]
+    libc: [glibc]
+
+  '@rollup/rollup-linux-arm-musleabihf@4.59.0':
+    resolution: {integrity: sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==}
+    cpu: [arm]
+    os: [linux]
+    libc: [musl]
+
+  '@rollup/rollup-linux-arm64-gnu@4.59.0':
+    resolution: {integrity: sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==}
+    cpu: [arm64]
+    os: [linux]
+    libc: [glibc]
+
+  '@rollup/rollup-linux-arm64-musl@4.59.0':
+    resolution: {integrity: sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==}
+    cpu: [arm64]
+    os: [linux]
+    libc: [musl]
+
+  '@rollup/rollup-linux-loong64-gnu@4.59.0':
+    resolution: {integrity: sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==}
+    cpu: [loong64]
+    os: [linux]
+    libc: [glibc]
+
+  '@rollup/rollup-linux-loong64-musl@4.59.0':
+    resolution: {integrity: sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==}
+    cpu: [loong64]
+    os: [linux]
+    libc: [musl]
+
+  '@rollup/rollup-linux-ppc64-gnu@4.59.0':
+    resolution: {integrity: sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==}
+    cpu: [ppc64]
+    os: [linux]
+    libc: [glibc]
+
+  '@rollup/rollup-linux-ppc64-musl@4.59.0':
+    resolution: {integrity: sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==}
+    cpu: [ppc64]
+    os: [linux]
+    libc: [musl]
+
+  '@rollup/rollup-linux-riscv64-gnu@4.59.0':
+    resolution: {integrity: sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==}
+    cpu: [riscv64]
+    os: [linux]
+    libc: [glibc]
+
+  '@rollup/rollup-linux-riscv64-musl@4.59.0':
+    resolution: {integrity: sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==}
+    cpu: [riscv64]
+    os: [linux]
+    libc: [musl]
+
+  '@rollup/rollup-linux-s390x-gnu@4.59.0':
+    resolution: {integrity: sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==}
+    cpu: [s390x]
+    os: [linux]
+    libc: [glibc]
+
+  '@rollup/rollup-linux-x64-gnu@4.59.0':
+    resolution: {integrity: sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==}
+    cpu: [x64]
+    os: [linux]
+    libc: [glibc]
+
+  '@rollup/rollup-linux-x64-musl@4.59.0':
+    resolution: {integrity: sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==}
+    cpu: [x64]
+    os: [linux]
+    libc: [musl]
+
+  '@rollup/rollup-openbsd-x64@4.59.0':
+    resolution: {integrity: sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==}
+    cpu: [x64]
+    os: [openbsd]
+
+  '@rollup/rollup-openharmony-arm64@4.59.0':
+    resolution: {integrity: sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==}
+    cpu: [arm64]
+    os: [openharmony]
+
+  '@rollup/rollup-win32-arm64-msvc@4.59.0':
+    resolution: {integrity: sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==}
+    cpu: [arm64]
+    os: [win32]
+
+  '@rollup/rollup-win32-ia32-msvc@4.59.0':
+    resolution: {integrity: sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==}
+    cpu: [ia32]
+    os: [win32]
+
+  '@rollup/rollup-win32-x64-gnu@4.59.0':
+    resolution: {integrity: sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==}
+    cpu: [x64]
+    os: [win32]
+
+  '@rollup/rollup-win32-x64-msvc@4.59.0':
+    resolution: {integrity: sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==}
+    cpu: [x64]
+    os: [win32]
+
+  '@schematics/angular@20.3.42':
+    resolution: {integrity: sha512-crdnGg2QwWdpBa/nEyarmYzXemlyqdDVnUEqrV+7MFliXXAwNg3FpzARhq+FtbsZuce8hLLCxzMjsM0vxJbSrw==}
+    engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'}
+
+  '@sigstore/bundle@4.0.0':
+    resolution: {integrity: sha512-NwCl5Y0V6Di0NexvkTqdoVfmjTaQwoLM236r89KEojGmq/jMls8S+zb7yOwAPdXvbwfKDlP+lmXgAL4vKSQT+A==}
+    engines: {node: ^20.17.0 || >=22.9.0}
+
+  '@sigstore/core@3.2.1':
+    resolution: {integrity: sha512-qRsxPnCrbC/puegGxKuynfnxgLiHqWStrSjxkoB4YKqq3Z3s4cyZyj42ZdWFAEblNP65C+rBH8EuREHIXoi83g==}
+    engines: {node: ^20.17.0 || >=22.9.0}
+
+  '@sigstore/protobuf-specs@0.5.2':
+    resolution: {integrity: sha512-SQqvFMt4V78fdjcDdYX6HbiVSOR4QK3ZgwCa2KOsopAgPIHy1rU5UDUmzLl02r5oyyaYcYHR1hpwDRk/yUe+Mw==}
+    engines: {node: ^18.17.0 || >=20.5.0}
+
+  '@sigstore/sign@4.1.1':
+    resolution: {integrity: sha512-Hf4xglukg0XXQ2RiD5vSoLjdPe8OBUPA8XeVjUObheuDcWdYWrnH/BNmxZCzkAy68MzmNCxXLeurJvs6hcP2OQ==}
+    engines: {node: ^20.17.0 || >=22.9.0}
+
+  '@sigstore/tuf@4.0.2':
+    resolution: {integrity: sha512-TCAzTy0xzdP79EnxSjq9KQ3eaR7+FmudLC6eRKknVKZbV7ZNlGLClAAQb/HMNJ5n2OBNk2GT1tEmU0xuPr+SLQ==}
+    engines: {node: ^20.17.0 || >=22.9.0}
+
+  '@sigstore/verify@3.1.1':
+    resolution: {integrity: sha512-qv7+G3J2cc6wwFj3yKvXOamzqhMwSk1ogPGmhpS8iXllcPrJaIIBA+4HbttlHVu1pqWTdmaCH/WE7UOC51kdoA==}
+    engines: {node: ^20.17.0 || >=22.9.0}
+
+  '@silurus/ooxml@0.66.3':
+    resolution: {integrity: sha512-gns0Wiaf4+jtirUFOlyKTA5yFbMsMTbB0Pews2l8s06TTEiMD2XB/BK/xeX/ezjtMu5TAren+MN9ljsWkMddHg==}
+
+  '@smithy/core@3.37.0':
+    resolution: {integrity: sha512-97imTcCyiPd8k0e9+ts/Sp0W65Dn9Ti357J7M09Ov4tI8Q1JDJVFSoAJtjEVKqfHEr3Pdo2jfolqKCaaiszH0g==}
+    engines: {node: '>=18.0.0'}
+
+  '@smithy/credential-provider-imds@4.5.2':
+    resolution: {integrity: sha512-A9uSdn72ozbRUSit0eib0TW7nXuNPlaeM0zcGkJ+nE6tFcSDbnmtwoxbTCFBukVQcszDAyvsd7+rTduPTXpygg==}
+    engines: {node: '>=18.0.0'}
+
+  '@smithy/fetch-http-handler@5.8.0':
+    resolution: {integrity: sha512-ycSJu3tFAQ4v04CBB0agqFMVsSQ1iG3yw+SpgxRqKfaURpQD4CZ8Wn0zPMmSnOuTpTh65Vz+EA0rMrw089wvkA==}
+    engines: {node: '>=18.0.0'}
+
+  '@smithy/node-http-handler@4.13.0':
+    resolution: {integrity: sha512-/GGcPoNJ2nzGytdAanpbx7mpp9Coa8LP+/Rpmbktr8YeLuIXs9B0SEz+X3AeonHdyzOIFJdKbN7jOZ+Tj+pMGw==}
+    engines: {node: '>=18.0.0'}
+
+  '@smithy/signature-v4@5.7.4':
+    resolution: {integrity: sha512-tHy0K0VtqNd5Y7Y41h0a0Lhh0L1GzC08dTWg0F7vRJWFtTENg7IZikf3wQkanYIRdb7ngoIPMTmqgUi401fEeQ==}
+    engines: {node: '>=18.0.0'}
+
+  '@smithy/types@4.19.0':
+    resolution: {integrity: sha512-r7jh49VJxGerfAcTQA6gXcKc+98zOp/tqRwzYjgOE+iSQsP6cEU1hq2QzbuipmP68QtYdY9wKEhiCQZIzHgZ4Q==}
+    engines: {node: '>=18.0.0'}
+
+  '@socket.io/component-emitter@3.1.2':
+    resolution: {integrity: sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==}
+
+  '@tufjs/canonical-json@2.0.0':
+    resolution: {integrity: sha512-yVtV8zsdo8qFHe+/3kw81dSLyF7D576A5cCFCi4X7B39tWT7SekaEFUnvnWJHz+9qO7qJTah1JbrDjWKqFtdWA==}
+    engines: {node: ^16.14.0 || >=18.0.0}
+
+  '@tufjs/models@4.1.0':
+    resolution: {integrity: sha512-Y8cK9aggNRsqJVaKUlEYs4s7CvQ1b1ta2DVPyAimb0I2qhzjNk+A+mxvll/klL0RlfuIUei8BF7YWiua4kQqww==}
+    engines: {node: ^20.17.0 || >=22.9.0}
+
+  '@types/cors@2.8.19':
+    resolution: {integrity: sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==}
+
+  '@types/d3-array@3.2.2':
+    resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==}
+
+  '@types/d3-axis@3.0.6':
+    resolution: {integrity: sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==}
+
+  '@types/d3-brush@3.0.6':
+    resolution: {integrity: sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==}
+
+  '@types/d3-chord@3.0.6':
+    resolution: {integrity: sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==}
+
+  '@types/d3-color@3.1.3':
+    resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==}
+
+  '@types/d3-contour@3.0.6':
+    resolution: {integrity: sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==}
+
+  '@types/d3-delaunay@6.0.4':
+    resolution: {integrity: sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==}
+
+  '@types/d3-dispatch@3.0.7':
+    resolution: {integrity: sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA==}
+
+  '@types/d3-drag@3.0.7':
+    resolution: {integrity: sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==}
+
+  '@types/d3-dsv@3.0.7':
+    resolution: {integrity: sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==}
+
+  '@types/d3-ease@3.0.2':
+    resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==}
+
+  '@types/d3-fetch@3.0.7':
+    resolution: {integrity: sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==}
+
+  '@types/d3-force@3.0.10':
+    resolution: {integrity: sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==}
+
+  '@types/d3-format@3.0.4':
+    resolution: {integrity: sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==}
+
+  '@types/d3-geo@3.1.1':
+    resolution: {integrity: sha512-65Emv9fQiQQqphLlRkuQ5ypPsOmWPhtBGCMv61JDPEPMvsx+gzhGf74yw1a78xFKPj6zw4AgQICJoQv0vK9M2w==}
+
+  '@types/d3-hierarchy@3.1.7':
+    resolution: {integrity: sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==}
+
+  '@types/d3-interpolate@3.0.4':
+    resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==}
+
+  '@types/d3-path@3.1.1':
+    resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==}
+
+  '@types/d3-polygon@3.0.2':
+    resolution: {integrity: sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==}
+
+  '@types/d3-quadtree@3.0.6':
+    resolution: {integrity: sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==}
+
+  '@types/d3-random@3.0.4':
+    resolution: {integrity: sha512-UHYId5WTCx4L4YNel7NU00XUXXgvgpgZOvp10PuvsQENjMDXhh2RyFc0KBjO7B45ne4Ha1yVH7ii0vnzKkuzWA==}
+
+  '@types/d3-scale-chromatic@3.1.0':
+    resolution: {integrity: sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==}
+
+  '@types/d3-scale@4.0.9':
+    resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==}
+
+  '@types/d3-selection@3.0.12':
+    resolution: {integrity: sha512-Qe/KWYhEiIIxGs7HrAAjMfShxKldx19SJtr5zu53f3afPsdZNz7HHtdTLXo/kqeiWNXVycI24kSnfzBYkTzpgw==}
+
+  '@types/d3-shape@3.2.0':
+    resolution: {integrity: sha512-kVd74ta9eof3eJOvbNd1vGKS/XERRyQbT26Og63hIsvDO84cjD5gEOhsXf26w3FSoNlPVz84DOFcKv/oou+fMw==}
+
+  '@types/d3-time-format@4.0.3':
+    resolution: {integrity: sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==}
+
+  '@types/d3-time@3.0.4':
+    resolution: {integrity: sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==}
+
+  '@types/d3-timer@3.0.2':
+    resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==}
+
+  '@types/d3-transition@3.0.9':
+    resolution: {integrity: sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==}
+
+  '@types/d3-zoom@3.0.9':
+    resolution: {integrity: sha512-0sE1406XBYJGiqD3AusTl9ZqC//2mIXix51tbom25gDCA8ri4xnSZg28CaSE8Srl6FClqABUKfsn0qghgdepMA==}
+
+  '@types/d3@7.4.3':
+    resolution: {integrity: sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==}
+
+  '@types/estree@1.0.8':
+    resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
+
+  '@types/events@3.0.3':
+    resolution: {integrity: sha512-trOc4AAUThEz9hapPtSd7wf5tiQKvTtu5b371UxXdTuqzIh0ArcRspRP0i0Viu+LXstIQ1z96t1nsPxT9ol01g==}
+
+  '@types/geojson@7946.0.16':
+    resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==}
+
+  '@types/jasmine@5.1.15':
+    resolution: {integrity: sha512-ZAC8KjmV2MJxbNTrwXFN+HKeajpXQZp6KpPiR6Aa4XvaEnjP6qh23lL/Rqb7AYzlp3h/rcwDrQ7Gg7q28cQTQg==}
+
+  '@types/node@26.6.6':
+    resolution: {integrity: sha512-MzQj+8Kx3XI40OmUAE6Qoh58ZMNstNNMwn2nfd3qWsfe7nDNSqJNzes4njhz6AHwrSHOffSLwXU6yb474Q4aog==}
+
+  '@types/retry@0.12.5':
+    resolution: {integrity: sha512-3xSjTp3v03X/lSQLkczaN9UIEwJMoMCA1+Nb5HfbJEQWogdeQIyVtTvxPXDQjZ5zws8rFQfVfRdz03ARihPJgw==}
+
+  '@types/trusted-types@2.0.7':
+    resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
+
+  '@types/uuid@10.0.0':
+    resolution: {integrity: sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==}
+
+  '@types/ws@8.18.2':
+    resolution: {integrity: sha512-67MQl+fpWKVTT1NYdnmo3U4sc/xPo/zQBncVnI74qmQa0z/b+1g6iYqNmGCPbxO+zz2aklb08a0oHfegiVd0/w==}
+
+  '@upsetjs/venn.js@2.0.0':
+    resolution: {integrity: sha512-WbBhLrooyePuQ1VZxrJjtLvTc4NVfpOyKx0sKqioq9bX1C1m7Jgykkn8gLrtwumBioXIqam8DLxp88Adbue6Hw==}
+
+  '@vitejs/plugin-basic-ssl@2.1.0':
+    resolution: {integrity: sha512-dOxxrhgyDIEUADhb/8OlV9JIqYLgos03YorAueTIeOUskLJSEsfwCByjbu98ctXitUN3znXKp0bYD/WHSudCeA==}
+    engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0}
+    peerDependencies:
+      vite: ^6.0.0 || ^7.0.0
+
+  '@wecom/jssdk@2.4.4':
+    resolution: {integrity: sha512-CoMxyTvlpR2sz+KC+0kfnbdJj19EjJg7HAU/52LUW6dPUDEvW3NV40LNhWUnvqFg49y3zJswyGVFeraDcKc0Kw==}
+
+  '@yarnpkg/lockfile@1.1.0':
+    resolution: {integrity: sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ==}
+
+  abbrev@4.0.0:
+    resolution: {integrity: sha512-a1wflyaL0tHtJSmLSOVybYhy22vRih4eduhhrkcjgrWGnRfrZtovJ2FRjxuTtkkj47O/baf0R86QU5OuYpz8fA==}
+    engines: {node: ^20.17.0 || >=22.9.0}
+
+  accepts@1.3.8:
+    resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==}
+    engines: {node: '>= 0.6'}
+
+  accepts@2.0.0:
+    resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==}
+    engines: {node: '>= 0.6'}
+
+  agent-base@7.1.0:
+    resolution: {integrity: sha512-o/zjMZRhJxny7OyEF+Op8X+efiELC7k7yOjMzgfzVqOzXqkBkWI79YoTdOtsuWd5BWhAGAuOY/Xa6xpiaWXiNg==}
+    engines: {node: '>= 14'}
+
+  agent-base@7.1.1:
+    resolution: {integrity: sha512-H0TSyFNDMomMNJQBn8wFV5YC/2eJ+VXECwOadZJT554xP6cODZHPX3H9QMQECxvrgiSOP1pHjy1sMWQVYJOUOA==}
+    engines: {node: '>= 14'}
+
+  agent-base@7.1.2:
+    resolution: {integrity: sha512-JVzqkCNRT+VfqzzgPWDPnwvDheSAUdiMUn3NoLXpDJF5lRqeJqyC9iGsAxIOAW+mzIdq+uP1TvcX6bMtrH0agg==}
+    engines: {node: '>= 14'}
+
+  ajv-formats@3.0.1:
+    resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==}
+    peerDependencies:
+      ajv: ^8.0.0
+    peerDependenciesMeta:
+      ajv:
+        optional: true
+
+  ajv@8.18.0:
+    resolution: {integrity: sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==}
+
+  ajv@8.20.0:
+    resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==}
+
+  algoliasearch@5.35.0:
+    resolution: {integrity: sha512-Y+moNhsqgLmvJdgTsO4GZNgsaDWv8AOGAaPeIeHKlDn/XunoAqYbA+XNpBd1dW8GOXAUDyxC9Rxc7AV4kpFcIg==}
+    engines: {node: '>= 14.0.0'}
+
+  another-json@0.2.0:
+    resolution: {integrity: sha512-/Ndrl68UQLhnCdsAzEXLMFuOR546o2qbYRqCglaNHbjXrwG1ayTcdwr3zkSGOGtGXDyR5X9nCFfnyG2AFJIsqg==}
+
+  ansi-escapes@4.3.2:
+    resolution: {integrity: sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==}
+    engines: {node: '>=8'}
+
+  ansi-escapes@7.3.0:
+    resolution: {integrity: sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==}
+    engines: {node: '>=18'}
+
+  ansi-regex@4.1.0:
+    resolution: {integrity: sha512-1apePfXM1UOSqw0o9IiFAovVz9M5S1Dg+4TrDwfMewQ6p/rmMueb7tWZjQ1rx4Loy1ArBggoqGpfqqdI4rondg==}
+    engines: {node: '>=6'}
+
+  ansi-regex@5.0.0:
+    resolution: {integrity: sha512-bY6fj56OUQ0hU1KjFNDQuJFezqKdrAyFdIevADiqrWHwSlbmBNMHp5ak2f40Pm8JTFyM2mqxkG6ngkHO11f/lg==}
+    engines: {node: '>=8'}
+
+  ansi-regex@6.4.0:
+    resolution: {integrity: sha512-KzTVk2tCWAHtYrvvvaP8bJKJq2pVinhLcGEQdtLIYPbmNGNyYe8QwNaTUYQp2J7/vIsUKt5QCqAfUkYyG9DkOw==}
+    engines: {node: '>=12'}
+
+  ansi-styles@4.0.0:
+    resolution: {integrity: sha512-8zjUtFJ3db/QoPXuuEMloS2AUf79/yeyttJ7Abr3hteopJu9HK8vsgGviGUMq+zyA6cZZO6gAyZoMTF6TgaEjA==}
+    engines: {node: '>=8'}
+
+  ansi-styles@4.1.0:
+    resolution: {integrity: sha512-Qts4KCLKG+waHc9C4m07weIY8qyeixoS0h6RnbsNVD6Fw+pEZGW3vTyObL3WXpE09Mq4Oi7/lBEyLmOiLtlYWQ==}
+    engines: {node: '>=8'}
+
+  ansi-styles@5.0.0:
+    resolution: {integrity: sha512-6564t0m0fuQMnockqBv7wJxo9T5C2V9JpYXyNScfRDPVLusOQQhkpMGrFC17QbiolraQ1sMXX+Y5nJpjqozL4g==}
+    engines: {node: '>=10'}
+
+  ansi-styles@6.0.0:
+    resolution: {integrity: sha512-4nUCKPhvbhgqc4a1S0w446veDod0i6krCOMF/bPBbBq9eP11BKztPS4Mp5yxUXhr1PFEe6mYcUSAv1jfoMUuOg==}
+    engines: {node: '>=12'}
+
+  ansi-styles@6.2.1:
+    resolution: {integrity: sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==}
+    engines: {node: '>=12'}
+
+  any-promise@1.3.0:
+    resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==}
+
+  anymatch@3.1.3:
+    resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==}
+    engines: {node: '>= 8'}
+
+  arg@5.0.2:
+    resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==}
+
+  autoprefixer@10.6.2:
+    resolution: {integrity: sha512-VOw9/yGTGwPPgzFcODtcv6zhEPIeIWkRSe10L2ri2f5vZ7ID7lCt6jZzRF5/ubgOZ+szYydmEF5AYsA5jxX1tA==}
+    engines: {node: ^10 || ^12 || >=14}
+    hasBin: true
+    peerDependencies:
+      postcss: ^8.1.0
+
+  balanced-match@1.0.0:
+    resolution: {integrity: sha512-9Y0g0Q8rmSt+H33DfKv7FOc3v+iRI+o1lbzt8jGcIosYW37IIW/2XVYq5NPdmaD5NQ59Nk26Kl/vZbwW9Fr8vg==}
+
+  balanced-match@4.0.4:
+    resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==}
+    engines: {node: 18 || 20 || >=22}
+
+  base-x@5.0.1:
+    resolution: {integrity: sha512-M7uio8Zt++eg3jPj+rHMfCC+IuygQHHCOU+IYsVtik6FWjuYpVt/+MRKcgsAMHh8mMFAwnB+Bs+mTrFiXjMzKg==}
+
+  base64-js@1.5.1:
+    resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
+
+  baseline-browser-mapping@2.11.34:
+    resolution: {integrity: sha512-Aqr8mA1kIvyJ3UY5YAY7ib8LSo1DmDMbMnVqGPtsKW1LxaFQFwn9Eb3yvq2fUKJsRvaXjnAV7R5osqETapzNiA==}
+    engines: {node: '>=6.0.0'}
+    hasBin: true
+
+  beasties@0.3.5:
+    resolution: {integrity: sha512-NaWu+f4YrJxEttJSm16AzMIFtVldCvaJ68b1L098KpqXmxt9xOLtKoLkKxb8ekhOrLqEJAbvT6n6SEvB/sac7A==}
+    engines: {node: '>=14.0.0'}
+
+  binary-extensions@2.0.0:
+    resolution: {integrity: sha512-Phlt0plgpIIBOGTT/ehfFnbNlfsDEiqmzE2KRXoX1bLIlir4X/MR+zSyBEkL05ffWgnRSf/DXv+WrUAVr93/ow==}
+    engines: {node: '>=8'}
+
+  body-parser@1.20.9:
+    resolution: {integrity: sha512-FISIQxV5u91Y9gvuFq3q/tA/qTqLJn3AhRcEvyozyhJTh5OBFFtv827AdHU7Rbyct4r+Ba1/99oFa1Y3yuFsdw==}
+    engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16}
+
+  body-parser@2.4.0:
+    resolution: {integrity: sha512-Ifk1KcLrxaGt1occQagQNFNNOqRt1LFuehEnHHzB8Z6cRgIxRU1TBaiIsUiCdA+gy5PL7Y1IX31DNKkRmxtXjQ==}
+    engines: {node: '>=18'}
+
+  boolbase@1.0.0:
+    resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==}
+
+  bowser@2.15.0:
+    resolution: {integrity: sha512-xic6KSZglg/I4ymp45fdM4GWfYpBK79Fxd/TOvJktlaPgQqqDxvmGwOPZl2V0dyeS3Lj5c180bLhXF43iyAjZQ==}
+
+  brace-expansion@1.1.21:
+    resolution: {integrity: sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==}
+
+  brace-expansion@5.0.12:
+    resolution: {integrity: sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==}
+    engines: {node: 20 || >=22}
+
+  braces@3.0.3:
+    resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==}
+    engines: {node: '>=8'}
+
+  browserslist@4.29.5:
+    resolution: {integrity: sha512-khTmKu5NGYTotqcYo8Bew2k4RAE9PuXRSFRXG2lQBrCEQHyeQ/vX6gKMxfokwywBsLiXZMnHsmWMG1pnO8J7uQ==}
+    engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
+    hasBin: true
+
+  bs58@6.0.0:
+    resolution: {integrity: sha512-PD0wEnEYg6ijszw/u8s+iI3H17cTymlrwkKhDhPZq+Sokl3AU4htyBFTjAeNAlCCmg0f53g6ih3jATyCKftTfw==}
+
+  buffer-from@1.1.2:
+    resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
+
+  bytes@3.1.2:
+    resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==}
+    engines: {node: '>= 0.8'}
+
+  cacache@20.0.4:
+    resolution: {integrity: sha512-M3Lab8NPYlZU2exsL3bMVvMrMqgwCnMWfdZbK28bn3pK6APT/Te/I8hjRPNu1uwORY9a1eEQoifXbKPQMfMTOA==}
+    engines: {node: ^20.17.0 || >=22.9.0}
+
+  call-bind-apply-helpers@1.0.2:
+    resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==}
+    engines: {node: '>= 0.4'}
+
+  call-bound@1.0.4:
+    resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==}
+    engines: {node: '>= 0.4'}
+
+  camelcase-css@2.0.1:
+    resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==}
+    engines: {node: '>= 6'}
+
+  camelcase@6.0.0:
+    resolution: {integrity: sha512-8KMDF1Vz2gzOq54ONPJS65IvTUaB1cHJ2DMM7MbPmLZljDH1qpzzLsWdiN9pHh6qvkRVDTi/07+eNGch/oLU4w==}
+    engines: {node: '>=10'}
+
+  caniuse-lite@1.0.30001820:
+    resolution: {integrity: sha512-gfWD6MxqVCqAOwmODfZf7DchWvlrA+SRdNX7ceLfg3xRqe5ssjjpF7EgslC/0tiLiNTajdaNN/q8c/5u/7Gz8w==}
+
+  chalk@4.1.2:
+    resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
+    engines: {node: '>=10'}
+
+  chalk@5.3.0:
+    resolution: {integrity: sha512-dLitG79d+GV1Nb/VYcCDFivJeK1hiukt9QjRNVOsUtTy1rR1YJsmpGGTZ3qJos+uw7WmWF4wUwBd9jxjocFC2w==}
+    engines: {node: ^12.17.0 || ^14.13 || >=16.0.0}
+
+  chardet@2.2.0:
+    resolution: {integrity: sha512-rddelWYNPRrXq6PtNEN2S3f6t9ILzvqaN5pVgi4kqt9jHQaXIial9PznB5iSPVlQSLNaaH22ItWz3EJtQ10+OA==}
+
+  chokidar@3.5.1:
+    resolution: {integrity: sha512-9+s+Od+W0VJJzawDma/gvBNQqkTiqYTWLuZoyAsivsI4AaWTCzHG06/TMjsf1cYe9Cb97UCEhjz7HvnPk2p/tw==}
+    engines: {node: '>= 8.10.0'}
+
+  chokidar@3.6.0:
+    resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==}
+    engines: {node: '>= 8.10.0'}
+
+  chokidar@4.0.0:
+    resolution: {integrity: sha512-mxIojEAQcuEvT/lyXq+jf/3cO/KoA6z4CeNDGGevTybECPOMFCnQy3OPahluUkbqgPNGw5Bi78UC7Po6Lhy+NA==}
+    engines: {node: '>= 14.16.0'}
+
+  chownr@3.0.0:
+    resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==}
+    engines: {node: '>=18'}
+
+  cli-cursor@5.0.0:
+    resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==}
+    engines: {node: '>=18'}
+
+  cli-spinners@2.9.2:
+    resolution: {integrity: sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==}
+    engines: {node: '>=6'}
+
+  cli-truncate@4.0.0:
+    resolution: {integrity: sha512-nPdaFdQ0h/GEigbPClz11D0v/ZJEwxmeVZGeMo3Z5StPtUTkA9o1lD6QwoirYiSDzbcwn2XcjwmCp68W1IS4TA==}
+    engines: {node: '>=18'}
+
+  cli-width@4.1.0:
+    resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==}
+    engines: {node: '>= 12'}
+
+  clipboard@2.0.11:
+    resolution: {integrity: sha512-C+0bbOqkezLIsmWSvlsXS0Q0bmkugu7jcfMIACB+RDEntIzQIkdr148we28AfSloQLRdZlYL/QYyrq05j/3Faw==}
+
+  cliui@7.0.4:
+    resolution: {integrity: sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==}
+
+  cliui@9.0.1:
+    resolution: {integrity: sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==}
+    engines: {node: '>=20'}
+
+  color-convert@2.0.1:
+    resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
+    engines: {node: '>=7.0.0'}
+
+  color-name@1.1.4:
+    resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
+
+  colorette@2.0.20:
+    resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==}
+
+  commander@4.0.0:
+    resolution: {integrity: sha512-SEa2abMBTZuEjLVYpNrAFoRgxPwG4rXP3+SGY6CM/HZGeDzIA7Pzp+7H3AHDukKEpyy2SoSGGPShKqqfH9T9AQ==}
+    engines: {node: '>= 6'}
+
+  commander@7.0.0:
+    resolution: {integrity: sha512-ovx/7NkTrnPuIV8sqk/GjUIIM1+iUQeqA3ye2VNpq9sVoiZsooObWlQy+OPWGI17GDaEoybuAGJm6U8yC077BA==}
+    engines: {node: '>= 10'}
+
+  commander@8.3.0:
+    resolution: {integrity: sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==}
+    engines: {node: '>= 12'}
+
+  concat-map@0.0.1:
+    resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}
+
+  connect@3.7.0:
+    resolution: {integrity: sha512-ZqRXc+tZukToSNmh5C2iWMSoV3X1YUcPbqEM4DkEG5tNQXrQUZCNVGGv3IuicnkMtPfGf3Xtp8WCXs295iQ1pQ==}
+    engines: {node: '>= 0.10.0'}
+
+  console-table-printer@2.16.1:
+    resolution: {integrity: sha512-Sc9FRJ4O9xKGNrvulNdPfK5SyBcZ6lcaRnDE4AQ/uw6IDtjHhsqyzzqcnMikjyGaiOOF2tNOKoBhbVjRvFy9Lw==}
+
+  content-disposition@2.0.1:
+    resolution: {integrity: sha512-e+H0ZXHSWYrENhQzw1LPuP4oF5MzVKmDU6d3hxlvaPEYLLg62MxtQNPRx4SYSuYJSBUgnQIG4HIN2tEtNv7Dog==}
+    engines: {node: '>=18'}
+
+  content-type@1.0.5:
+    resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==}
+    engines: {node: '>= 0.6'}
+
+  content-type@2.1.0:
+    resolution: {integrity: sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==}
+    engines: {node: '>=18'}
+
+  content-type@3.1.1:
+    resolution: {integrity: sha512-GW4qUsfFo59d0HbUibDlWv5wPz+vAAcaTWbKIuKCf0JkC7wWkSyf8f13IpXn5JkeMlB2P8iTSSIjwXljorg2vA==}
+    engines: {node: '>=22'}
+
+  convert-source-map@1.9.0:
+    resolution: {integrity: sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==}
+
+  convert-source-map@2.0.0:
+    resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
+
+  cookie-signature@1.2.2:
+    resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==}
+    engines: {node: '>=6.6.0'}
+
+  cookie@0.7.1:
+    resolution: {integrity: sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==}
+    engines: {node: '>= 0.6'}
+
+  cookie@0.7.2:
+    resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==}
+    engines: {node: '>= 0.6'}
+
+  core-js@2.6.12:
+    resolution: {integrity: sha512-Kb2wC0fvsWfQrgk8HU5lW6U/Lcs8+9aaYcy4ZFc6DDlo4nZ7n70dEgE5rtR0oG6ufKDUnrwfWL1mXR5ljDatrQ==}
+    deprecated: core-js@<3.23.3 is no longer maintained and not recommended for usage due to the number of issues. Because of the V8 engine whims, feature detection in old core-js versions could cause a slowdown up to 100x even if nothing is polyfilled. Some versions have web compatibility issues. Please, upgrade your dependencies to the actual version of core-js.
+
+  cors@2.8.6:
+    resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==}
+    engines: {node: '>= 0.10'}
+
+  cose-base@1.0.3:
+    resolution: {integrity: sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg==}
+
+  cose-base@2.2.0:
+    resolution: {integrity: sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g==}
+
+  cross-spawn@7.0.6:
+    resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
+    engines: {node: '>= 8'}
+
+  crypto-js@4.2.0:
+    resolution: {integrity: sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==}
+    deprecated: Active development of CryptoJS has been discontinued. This library is no longer maintained.
+
+  css-select@6.0.0:
+    resolution: {integrity: sha512-rZZVSLle8v0+EY8QAkDWrKhpgt6SA5OtHsgBnsj6ZaLb5dmDVOWUDtQitd9ydxxvEjhewNudS6eTVU7uOyzvXw==}
+
+  css-what@7.0.0:
+    resolution: {integrity: sha512-wD5oz5xibMOPHzy13CyGmogB3phdvcDaB5t0W/Nr5Z2O/agcB8YwOz6e2Lsp10pNDzBoDO9nVa3RGs/2BttpHQ==}
+    engines: {node: '>= 6'}
+
+  cssesc@3.0.0:
+    resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==}
+    engines: {node: '>=4'}
+    hasBin: true
+
+  custom-event@1.0.1:
+    resolution: {integrity: sha512-GAj5FOq0Hd+RsCGVJxZuKaIDXDf3h6GQoNEjFgbLLI/trgtavwUbSnZ5pVfg27DVCaWjIohryS0JFwIJyT2cMg==}
+
+  cytoscape-cose-bilkent@4.1.0:
+    resolution: {integrity: sha512-wgQlVIUJF13Quxiv5e1gstZ08rnZj2XaLHGoFMYXz7SkNfCDOOteKBE6SYRfA9WxxI/iBc3ajfDoc6hb/MRAHQ==}
+    peerDependencies:
+      cytoscape: ^3.2.0
+
+  cytoscape-fcose@2.2.0:
+    resolution: {integrity: sha512-ki1/VuRIHFCzxWNrsshHYPs6L7TvLu3DL+TyIGEsRcvVERmxokbf5Gdk7mFxZnTdiGtnA4cfSmjZJMviqSuZrQ==}
+    peerDependencies:
+      cytoscape: ^3.2.0
+
+  cytoscape@3.34.4:
+    resolution: {integrity: sha512-msY99tMY8iWTg+sIPGfSqDcPNOt4xcj+v/K/7qUFaCUxxlqnMkUYLBjRUzn9W5CU/R2hrmtJa5+UtovrEM2ctg==}
+    engines: {node: '>=0.10'}
+
+  d3-array@2.12.1:
+    resolution: {integrity: sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==}
+
+  d3-array@3.2.4:
+    resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==}
+    engines: {node: '>=12'}
+
+  d3-axis@3.0.0:
+    resolution: {integrity: sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==}
+    engines: {node: '>=12'}
+
+  d3-brush@3.0.0:
+    resolution: {integrity: sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==}
+    engines: {node: '>=12'}
+
+  d3-chord@3.0.1:
+    resolution: {integrity: sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==}
+    engines: {node: '>=12'}
+
+  d3-color@3.1.0:
+    resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==}
+    engines: {node: '>=12'}
+
+  d3-contour@4.0.2:
+    resolution: {integrity: sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==}
+    engines: {node: '>=12'}
+
+  d3-delaunay@6.0.4:
+    resolution: {integrity: sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==}
+    engines: {node: '>=12'}
+
+  d3-dispatch@3.0.1:
+    resolution: {integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==}
+    engines: {node: '>=12'}
+
+  d3-drag@3.0.0:
+    resolution: {integrity: sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==}
+    engines: {node: '>=12'}
+
+  d3-dsv@3.0.1:
+    resolution: {integrity: sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==}
+    engines: {node: '>=12'}
+    hasBin: true
+
+  d3-ease@3.0.1:
+    resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==}
+    engines: {node: '>=12'}
+
+  d3-fetch@3.0.1:
+    resolution: {integrity: sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==}
+    engines: {node: '>=12'}
+
+  d3-force@3.0.0:
+    resolution: {integrity: sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==}
+    engines: {node: '>=12'}
+
+  d3-format@3.1.2:
+    resolution: {integrity: sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==}
+    engines: {node: '>=12'}
+
+  d3-geo@3.1.1:
+    resolution: {integrity: sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==}
+    engines: {node: '>=12'}
+
+  d3-hierarchy@3.1.2:
+    resolution: {integrity: sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==}
+    engines: {node: '>=12'}
+
+  d3-interpolate@3.0.1:
+    resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==}
+    engines: {node: '>=12'}
+
+  d3-path@1.0.9:
+    resolution: {integrity: sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==}
+
+  d3-path@3.1.0:
+    resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==}
+    engines: {node: '>=12'}
+
+  d3-polygon@3.0.1:
+    resolution: {integrity: sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==}
+    engines: {node: '>=12'}
+
+  d3-quadtree@3.0.1:
+    resolution: {integrity: sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==}
+    engines: {node: '>=12'}
+
+  d3-random@3.0.1:
+    resolution: {integrity: sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==}
+    engines: {node: '>=12'}
+
+  d3-sankey@0.12.3:
+    resolution: {integrity: sha512-nQhsBRmM19Ax5xEIPLMY9ZmJ/cDvd1BG3UVvt5h3WRxKg5zGRbvnteTyWAbzeSvlh3tW7ZEmq4VwR5mB3tutmQ==}
+
+  d3-scale-chromatic@3.1.0:
+    resolution: {integrity: sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==}
+    engines: {node: '>=12'}
+
+  d3-scale@4.0.2:
+    resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==}
+    engines: {node: '>=12'}
+
+  d3-selection@3.0.0:
+    resolution: {integrity: sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==}
+    engines: {node: '>=12'}
+
+  d3-shape@1.3.7:
+    resolution: {integrity: sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==}
+
+  d3-shape@3.2.0:
+    resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==}
+    engines: {node: '>=12'}
+
+  d3-time-format@4.1.0:
+    resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==}
+    engines: {node: '>=12'}
+
+  d3-time@3.1.0:
+    resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==}
+    engines: {node: '>=12'}
+
+  d3-timer@3.0.1:
+    resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==}
+    engines: {node: '>=12'}
+
+  d3-transition@3.0.1:
+    resolution: {integrity: sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==}
+    engines: {node: '>=12'}
+    peerDependencies:
+      d3-selection: 2 - 3
+
+  d3-zoom@3.0.0:
+    resolution: {integrity: sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==}
+    engines: {node: '>=12'}
+
+  d3@7.9.0:
+    resolution: {integrity: sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==}
+    engines: {node: '>=12'}
+
+  dagre-d3-es@7.0.14:
+    resolution: {integrity: sha512-P4rFMVq9ESWqmOgK+dlXvOtLwYg0i7u0HBGJER0LZDJT2VHIPAMZ/riPxqJceWMStH5+E61QxFra9kIS3AqdMg==}
+
+  date-format@4.0.14:
+    resolution: {integrity: sha512-39BOQLs9ZjKh0/patS9nrT8wc3ioX3/eA/zgbKNopnF2wCqJEoxywwwElATYvRsXdnOxA/OQeQoFZ3rFjVajhg==}
+    engines: {node: '>=4.0'}
+
+  dayjs@1.11.23:
+    resolution: {integrity: sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ==}
+
+  debug@2.6.9:
+    resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==}
+    peerDependencies:
+      supports-color: '*'
+    peerDependenciesMeta:
+      supports-color:
+        optional: true
+
+  debug@4.4.3:
+    resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
+    engines: {node: '>=6.0'}
+    peerDependencies:
+      supports-color: '*'
+    peerDependenciesMeta:
+      supports-color:
+        optional: true
+
+  decamelize@1.2.0:
+    resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==}
+    engines: {node: '>=0.10.0'}
+
+  delaunator@5.1.0:
+    resolution: {integrity: sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==}
+
+  delegate@3.2.0:
+    resolution: {integrity: sha512-IofjkYBZaZivn0V8nnsMJGBr4jVLxHDheKSW88PyxS5QC4Vo9ZbZVvhzlSxY87fVq3STR6r+4cGepyHkcWOQSw==}
+
+  depd@2.0.0:
+    resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==}
+    engines: {node: '>= 0.8'}
+
+  destroy@1.2.0:
+    resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==}
+    engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16}
+
+  detect-libc@2.1.2:
+    resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
+    engines: {node: '>=8'}
+
+  di@0.0.1:
+    resolution: {integrity: sha512-uJaamHkagcZtHPqCIHZxnFrXlunQXgBOsZSUOWwFw31QJCAbyTBoHMW75YOTur5ZNx8pIeAKgf6GWIgaqqiLhA==}
+
+  didyoumean@1.2.2:
+    resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==}
+
+  dingtalk-jsapi@3.2.9:
+    resolution: {integrity: sha512-VLt8co92z29bdguxUvJLnhpQn7WFjb5rJTODDczieS7I0yyWj5+KwF04sWfMvyIKX0MQPVL++Fyo6z2JucFpgQ==}
+
+  dlv@1.1.3:
+    resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==}
+
+  dom-serialize@2.2.1:
+    resolution: {integrity: sha512-Yra4DbvoW7/Z6LBN560ZwXMjoNOSAN2wRsKFGc4iBeso+mpIA6qj1vfdf9HpMaKAqG6wXTy+1SYEzmNpKXOSsQ==}
+
+  dom-serializer@2.0.0:
+    resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==}
+
+  domelementtype@2.3.0:
+    resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==}
+
+  domhandler@5.0.3:
+    resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==}
+    engines: {node: '>= 4'}
+
+  dompurify@3.4.17:
+    resolution: {integrity: sha512-B/pSlYVHQ58kqISD/yDjuJ1yfzI34m1gF4lAod2bwSRhiN4K5Cl3H8Af7hqB2PmTL911tdEudnnXn5FnhRoz0w==}
+
+  domutils@3.2.2:
+    resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==}
+
+  dunder-proto@1.0.1:
+    resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
+    engines: {node: '>= 0.4'}
+
+  ee-first@1.1.1:
+    resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==}
+
+  electron-to-chromium@1.5.467:
+    resolution: {integrity: sha512-Nw9X371rtZmYcBSnEijnCDONILjs93biCZO34juC7MBOmK8E+GFnyD4T/WvyrlJkppul7oxg6TwvtKst9SODTw==}
+
+  emoji-regex@10.3.0:
+    resolution: {integrity: sha512-QpLs9D9v9kArv4lfDEgg1X/gN5XLnf/A6l9cs8SPZLRZR3ZkY9+kwIQTxm+fsSej5UMYGE8fdoaZVIBlqG0XTw==}
+
+  emoji-regex@8.0.0:
+    resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
+
+  emoji-toolkit@9.0.1:
+    resolution: {integrity: sha512-sMMNqKNLVHXJfIKoPbrRJwtYuysVNC9GlKetr72zE3SSVbHqoeDLWVrxP0uM0AE0qvdl3hbUk+tJhhwXZrDHaw==}
+
+  encodeurl@1.0.2:
+    resolution: {integrity: sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==}
+    engines: {node: '>= 0.8'}
+
+  encodeurl@2.0.1:
+    resolution: {integrity: sha512-Ev/hIyePLZtpL98x562lvPtenrUfGr0lfaf44HFZ+hDEwso7ccV9QAZrEVLg0hxNAzygaAFxy2dYaAKKM0G06A==}
+    engines: {node: '>= 0.8'}
+
+  encoding@0.1.14:
+    resolution: {integrity: sha512-pqH+jIxIhwX5U4w+Tnu3jjowTo5QTACbqef2C5Cw2RhApr7COQqgnai53u8VbtqjWRcSoAcTf7uZ62dO5kC8Iw==}
+    engines: {node: '>=20.0.0'}
+
+  engine.io-parser@5.2.3:
+    resolution: {integrity: sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==}
+    engines: {node: '>=10.0.0'}
+
+  engine.io@6.6.11:
+    resolution: {integrity: sha512-z34mGpLoY/82ZbkX1LqGMIaf3wjVMPL6gVC681QDqnmzJ26synI8qAE32P+a7UazmpJZ8UGrFIeAavgRfXzYrg==}
+    engines: {node: '>=10.2.0'}
+
+  ent@2.2.2:
+    resolution: {integrity: sha512-kKvD1tO6BM+oK9HzCPpUdRb4vKFQY/FPTFmurMvh6LlN68VMrdj77w8yp51/kDbpkFOS9J8w5W6zIzgM2H8/hw==}
+    engines: {node: '>= 0.4'}
+
+  entities@4.2.0:
+    resolution: {integrity: sha512-wEJa03bJgqEwPnkUqYdgmcfUXfm6+4hePQhntIvRy/1/+C4dFuhYHsgKBRjbQ6OWBh42P+VhAoCDO77DUh0e/Q==}
+    engines: {node: '>=0.12'}
+
+  entities@6.0.0:
+    resolution: {integrity: sha512-aKstq2TDOndCn4diEyp9Uq/Flu2i1GlLkc6XIDQSDMuaFE3OPW5OphLCyQ5SpSJZTb4reN+kTcYru5yIfXoRPw==}
+    engines: {node: '>=0.12'}
+
+  entities@7.0.1:
+    resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==}
+    engines: {node: '>=0.12'}
+
+  entities@8.1.0:
+    resolution: {integrity: sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==}
+    engines: {node: '>=20.19.0'}
+
+  env-paths@2.2.0:
+    resolution: {integrity: sha512-6u0VYSCo/OW6IoD5WCLLy9JUGARbamfSavcNXry/eu8aHVFei6CD3Sw+VGX5alea1i9pgPHW0mbu6Xj0uBh7gA==}
+    engines: {node: '>=6'}
+
+  environment@1.1.0:
+    resolution: {integrity: sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==}
+    engines: {node: '>=18'}
+
+  es-define-property@1.0.1:
+    resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==}
+    engines: {node: '>= 0.4'}
+
+  es-errors@1.3.0:
+    resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==}
+    engines: {node: '>= 0.4'}
+
+  es-object-atoms@1.1.2:
+    resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==}
+    engines: {node: '>= 0.4'}
+
+  es-toolkit@1.53.0:
+    resolution: {integrity: sha512-3+AYXp9fmJ/UhHBMYj2/U+RaUouMD90SzoItyDOB2Za7W6dB7fQApFGnZPl2MF6K8/Tv1koh1JyirSx/kju6Pw==}
+
+  esbuild@0.28.1:
+    resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==}
+    engines: {node: '>=18'}
+    hasBin: true
+
+  escalade@3.2.0:
+    resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==}
+    engines: {node: '>=6'}
+
+  escape-html@1.0.3:
+    resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==}
+
+  etag@1.8.1:
+    resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==}
+    engines: {node: '>= 0.6'}
+
+  eventemitter3@4.0.0:
+    resolution: {integrity: sha512-qerSRB0p+UDEssxTtm6EDKcE7W4OaoisfIMl4CngyEhjpYglocpNg6UEqCvemdGhosAsg4sO2dXJOdyBifPGCg==}
+
+  eventemitter3@4.0.4:
+    resolution: {integrity: sha512-rlaVLnVxtxvoyLsQQFBx53YmXHDxRIzzTLbdfxqi4yocpSjAxXwkU0cScM5JgSKMqEhrZpnvQ2D9gjylR0AimQ==}
+
+  eventemitter3@5.0.4:
+    resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==}
+
+  events@3.3.0:
+    resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==}
+    engines: {node: '>=0.8.x'}
+
+  eventsource-parser@3.1.1:
+    resolution: {integrity: sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==}
+    engines: {node: '>=18.0.0'}
+
+  eventsource@3.0.2:
+    resolution: {integrity: sha512-YolzkJNxsTL3tCJMWFxpxtG2sCjbZ4LQUBUrkdaJK0ub0p6lmJt+2+1SwhKjLc652lpH9L/79Ptez972H9tphw==}
+    engines: {node: '>=18.0.0'}
+
+  exponential-backoff@3.1.3:
+    resolution: {integrity: sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==}
+
+  express-rate-limit@8.7.1:
+    resolution: {integrity: sha512-2FXbqkygil3mZiXzEtM0IjE/UBmBNot0J1GEVrJT4JbrvwOSoEtqtO+wHbiMQELWe1eNvHoYRg2dkjOid+6JkA==}
+    engines: {node: '>= 16'}
+    peerDependencies:
+      express: '>= 4.11'
+
+  express@5.3.0:
+    resolution: {integrity: sha512-qCqy1xSoaugxOD4IVHb1ZWdkvCF2W3aE2okKouWj8VwJ+ztskSiXoBnCSJqfKT+cVPJhuJqe9YLbY425+Zh00g==}
+    engines: {node: '>= 18'}
+
+  extend@3.0.2:
+    resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==}
+
+  fast-deep-equal@3.1.3:
+    resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
+
+  fast-glob@3.3.3:
+    resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==}
+    engines: {node: '>=8.6.0'}
+
+  fast-uri@3.1.9:
+    resolution: {integrity: sha512-hS/0Wt3Ya043oKXs0sIxrMpKQITXhMLIrVAKKXi4gozGC9v7YRWVnXIZzjNPTO02M9BdxLCWqK1yRXgNziVyJQ==}
+
+  fastdom@1.0.12:
+    resolution: {integrity: sha512-LB+xjSTEbjHE1cWsxu+tN2Xqr1kpi+V9aADI7sVM5ZMaXyYGPHULQMzpJMYqOTULK/73pUkWVzzObFRBkPr+hg==}
+
+  fastq@1.20.3:
+    resolution: {integrity: sha512-XKv5nnLs6nLF71NgiKJLIZFLkPyIEuOselLG7ujZnGrRfQK8HpvY+WqKhAJUAdLomwVHErVS4LfxFlPq0/FTAw==}
+
+  fdir@6.5.0:
+    resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
+    engines: {node: '>=12.0.0'}
+    peerDependencies:
+      picomatch: ^3 || ^4
+    peerDependenciesMeta:
+      picomatch:
+        optional: true
+
+  fill-range@7.1.1:
+    resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==}
+    engines: {node: '>=8'}
+
+  finalhandler@1.1.2:
+    resolution: {integrity: sha512-aAWcW57uxVNrQZqFXjITpW3sIUQmHGG3qSb9mUah9MgMC4NeWhNOlNjXEYq3HjRAvL6arUviZGGJsBg6z0zsWA==}
+    engines: {node: '>= 0.8'}
+
+  finalhandler@2.1.1:
+    resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==}
+    engines: {node: '>= 18.0.0'}
+
+  flatted@3.4.4:
+    resolution: {integrity: sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==}
+
+  fmode-ng@0.0.246:
+    resolution: {integrity: sha512-mmO/EFuhs+YAxMiAX2bwbFR4ZyB0eABAfYSMyX9zXppDaTSjOvAi4Iaw31bpn81RfgS0QQWj7lvxv02jPI2aOw==}
+    peerDependencies:
+      '@angular/common': ^17.0.0 || ^18.0.0 || ^19.0.0 || ^20.0.0 || ^21.0.0 || ^22.0.0
+      '@angular/core': ^17.0.0 || ^18.0.0 || ^19.0.0 || ^20.0.0 || ^21.0.0 || ^22.0.0
+    peerDependenciesMeta:
+      '@angular/common':
+        optional: true
+      '@angular/core':
+        optional: true
+
+  follow-redirects@1.16.2:
+    resolution: {integrity: sha512-/kVP2U8z596Ia0gwFrwyIeo74ta5aVmSeU1FfAUMAmcEP5EP7zydmag3SLiN64NaRKef3lxsq7i/g7ltJQDGow==}
+    engines: {node: '>=4.0'}
+    peerDependencies:
+      debug: '*'
+    peerDependenciesMeta:
+      debug:
+        optional: true
+
+  forwarded@0.2.0:
+    resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==}
+    engines: {node: '>= 0.6'}
+
+  fraction.js@5.3.4:
+    resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==}
+
+  fresh@2.0.0:
+    resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==}
+    engines: {node: '>= 0.8'}
+
+  fs-extra@8.1.0:
+    resolution: {integrity: sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==}
+    engines: {node: '>=6 <7 || >=8'}
+
+  fs-minipass@3.0.3:
+    resolution: {integrity: sha512-XUBA9XClHbnJWSfBzjkm6RvPsyg3sryZt06BEQoXcF7EK/xpGaQYJgQKDJSUH5SGZ76Y7pFx1QBnXz09rU5Fbw==}
+    engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0}
+
+  fs.realpath@1.0.0:
+    resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==}
+
+  fsevents@2.3.3:
+    resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
+    engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
+    os: [darwin]
+
+  function-bind@1.1.2:
+    resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==}
+
+  gensync@1.0.0-beta.2:
+    resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==}
+    engines: {node: '>=6.9.0'}
+
+  get-caller-file@2.0.5:
+    resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==}
+    engines: {node: 6.* || 8.* || >= 10.*}
+
+  get-east-asian-width@1.7.0:
+    resolution: {integrity: sha512-XjH1AECxf0giL2V1aU8vKyRR2ppRUb5c0EvT7zuJTokQ74bNo52zOtghqdWIqrhUD79fo3x0WfKZdOqxF6LG1Q==}
+    engines: {node: '>=18'}
+
+  get-intrinsic@1.3.0:
+    resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==}
+    engines: {node: '>= 0.4'}
+
+  get-proto@1.0.1:
+    resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==}
+    engines: {node: '>= 0.4'}
+
+  glob-parent@5.1.2:
+    resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==}
+    engines: {node: '>= 6'}
+
+  glob-parent@6.0.2:
+    resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==}
+    engines: {node: '>=10.13.0'}
+
+  glob-to-regexp@0.4.1:
+    resolution: {integrity: sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==}
+
+  glob@13.0.6:
+    resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==}
+    engines: {node: 18 || 20 || >=22}
+
+  glob@7.2.0:
+    resolution: {integrity: sha512-lmLf6gtyrPq8tTjSmrO94wBeQbFR3HbLHbuyD69wuyQkImp2hWqMGB47OX65FBkPffO641IP9jWa1z4ivqG26Q==}
+    deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
+
+  good-listener@1.2.2:
+    resolution: {integrity: sha512-goW1b+d9q/HIwbVYZzZ6SsTr4IgE+WA44A0GmPIQstuOrgsFcT7VEJ48nmr9GaRtNu0XTKacFLGnBPAM6Afouw==}
+
+  gopd@1.2.0:
+    resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==}
+    engines: {node: '>= 0.4'}
+
+  graceful-fs@4.2.11:
+    resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
+
+  hachure-fill@0.5.2:
+    resolution: {integrity: sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==}
+
+  has-flag@4.0.0:
+    resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
+    engines: {node: '>=8'}
+
+  has-symbols@1.1.0:
+    resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==}
+    engines: {node: '>= 0.4'}
+
+  has-tostringtag@1.0.2:
+    resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==}
+    engines: {node: '>= 0.4'}
+
+  hasown@2.0.4:
+    resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==}
+    engines: {node: '>= 0.4'}
+
+  hono@4.13.14:
+    resolution: {integrity: sha512-yhRLQ21U6qa9Hsu2N7Qcbh8tE+WKEcoErjIo56LeSGiGXGp9sA7UpE86Ui9uexb61KakiGwuHMn2QdV7w6i6kQ==}
+    engines: {node: '>=16.9.0'}
+
+  hosted-git-info@9.0.3:
+    resolution: {integrity: sha512-Hc+ghLoSt6QaYZUv0WBiIvmMDZuZZ7oaDvdH8MbfOO4lOsxdXLEvuC6ePoGs9H1X9oCLyq6+NVN0MKqD+ydxyg==}
+    engines: {node: ^20.17.0 || >=22.9.0}
+
+  html-escaper@2.0.2:
+    resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==}
+
+  htmlparser2@10.1.0:
+    resolution: {integrity: sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==}
+
+  http-cache-semantics@4.3.0:
+    resolution: {integrity: sha512-M5t5LlJpS1UHMjvwRQVdFHvPISGeLAxNcrWuJkeGh0KxsqCHZ1O3NXZU/8x7cD0BDcGW8kapxMKTvwlqrNkHkA==}
+
+  http-errors@2.0.0:
+    resolution: {integrity: sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==}
+    engines: {node: '>= 0.8'}
+
+  http-errors@2.0.1:
+    resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==}
+    engines: {node: '>= 0.8'}
+
+  http-proxy-agent@7.0.0:
+    resolution: {integrity: sha512-+ZT+iBxVUQ1asugqnD6oWoRiS25AkjNfG085dKJGtGxkdwLQrMKU5wJr2bOOFAXzKcTuqq+7fZlTMgG3SRfIYQ==}
+    engines: {node: '>= 14'}
+
+  http-proxy@1.18.1:
+    resolution: {integrity: sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==}
+    engines: {node: '>=8.0.0'}
+
+  https-proxy-agent@7.0.1:
+    resolution: {integrity: sha512-Eun8zV0kcYS1g19r78osiQLEFIRspRUDd9tIfBCTBPBeMieF/EsJNL8VI3xOIdYRDEkjQnqOYPsZ2DsWsVsFwQ==}
+    engines: {node: '>= 14'}
+
+  https-proxy-agent@7.0.6:
+    resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==}
+    engines: {node: '>= 14'}
+
+  iconv-lite@0.4.24:
+    resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==}
+    engines: {node: '>=0.10.0'}
+
+  iconv-lite@0.6.0:
+    resolution: {integrity: sha512-43ZpGYZ9QtuutX5l6WC1DSO8ane9N+Ct5qPLF2OV7vM9abM69gnAbVkh66ibaZd3aOGkoP1ZmringlKhLBkw2Q==}
+    engines: {node: '>=0.10.0'}
+
+  iconv-lite@0.6.3:
+    resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==}
+    engines: {node: '>=0.10.0'}
+
+  iconv-lite@0.7.3:
+    resolution: {integrity: sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==}
+    engines: {node: '>=0.10.0'}
+
+  ignore-walk@8.0.0:
+    resolution: {integrity: sha512-FCeMZT4NiRQGh+YkeKMtWrOmBgWjHjMJ26WQWrRQyoyzqevdaGSakUaJW5xQYmjLlUVk2qUnCjYVBax9EKKg8A==}
+    engines: {node: ^20.17.0 || >=22.9.0}
+
+  immutable@5.1.9:
+    resolution: {integrity: sha512-m8nVez3rwrgmWxtLMt1ZYXB2Lv7OKYn/disyxAlSDYAlKSlFoPPfIAmAM/M5xqL4m4C/wAPw7S2/CNaUii1Hxg==}
+
+  import-meta-resolve@4.2.0:
+    resolution: {integrity: sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==}
+
+  imurmurhash@0.1.4:
+    resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==}
+    engines: {node: '>=0.8.19'}
+
+  inflight@1.0.6:
+    resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==}
+    deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.
+
+  inherits@2.0.4:
+    resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
+
+  ini@5.0.0:
+    resolution: {integrity: sha512-+N0ngpO3e7cRUWOJAS7qw0IZIVc6XPrW4MlFBdD066F2L4k1L6ker3hLqSq7iXxU5tgS4WGkIUElWn5vogAEnw==}
+    engines: {node: ^18.17.0 || >=20.5.0}
+
+  ini@6.0.0:
+    resolution: {integrity: sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ==}
+    engines: {node: ^20.17.0 || >=22.9.0}
+
+  internmap@1.0.1:
+    resolution: {integrity: sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==}
+
+  internmap@2.0.3:
+    resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==}
+    engines: {node: '>=12'}
+
+  ip-address@10.7.5:
+    resolution: {integrity: sha512-t2v0rkILYv9WXmSL7TpAaU3HXgaFYOPUcH3QHgwQ++gpJuH3bqyMy4Fvt/DJCTKwzH3Cf+bAp5W/mFL39KPLzA==}
+    engines: {node: '>= 12'}
+
+  ipaddr.js@1.9.1:
+    resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==}
+    engines: {node: '>= 0.10'}
+
+  is-absolute@0.1.7:
+    resolution: {integrity: sha512-Xi9/ZSn4NFapG8RP98iNPMOeaV3mXPisxKxzKtHVqr3g56j/fBn+yZmnxSVAA8lmZbl2J9b/a4kJvfU3hqQYgA==}
+    engines: {node: '>=0.10.0'}
+
+  is-binary-path@2.1.0:
+    resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==}
+    engines: {node: '>=8'}
+
+  is-core-module@2.17.0:
+    resolution: {integrity: sha512-J/vG0zBCbIKOQFfufSwyXdMrsohyJIUNkrnmo6WZGzoM7tr/lsbfW5b2BvisL6zsyMzK9UxV9L6c7AoFbyXHOA==}
+    engines: {node: '>= 0.4'}
+
+  is-extglob@2.1.1:
+    resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==}
+    engines: {node: '>=0.10.0'}
+
+  is-fullwidth-code-point@3.0.0:
+    resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==}
+    engines: {node: '>=8'}
+
+  is-fullwidth-code-point@4.0.0:
+    resolution: {integrity: sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ==}
+    engines: {node: '>=12'}
+
+  is-fullwidth-code-point@5.1.0:
+    resolution: {integrity: sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==}
+    engines: {node: '>=18'}
+
+  is-glob@4.0.3:
+    resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==}
+    engines: {node: '>=0.10.0'}
+
+  is-interactive@2.0.0:
+    resolution: {integrity: sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==}
+    engines: {node: '>=12'}
+
+  is-network-error@1.3.2:
+    resolution: {integrity: sha512-PhBY86zaxNZUuWP6h13Vu5oFe0XY6/UlKzQnYFELzGVHygP3MxmvTfYSG7GN3aIab/iWudSMgjSnG9Dq+nHrgA==}
+    engines: {node: '>=16'}
+
+  is-number@7.0.0:
+    resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==}
+    engines: {node: '>=0.12.0'}
+
+  is-promise@4.0.0:
+    resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==}
+
+  is-regex@1.2.1:
+    resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==}
+    engines: {node: '>= 0.4'}
+
+  is-relative@0.1.3:
+    resolution: {integrity: sha512-wBOr+rNM4gkAZqoLRJI4myw5WzzIdQosFAAbnvfXP5z1LyzgAI3ivOKehC5KfqlQJZoihVhirgtCBj378Eg8GA==}
+    engines: {node: '>=0.10.0'}
+
+  is-unicode-supported@1.3.0:
+    resolution: {integrity: sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ==}
+    engines: {node: '>=12'}
+
+  is-unicode-supported@2.1.0:
+    resolution: {integrity: sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==}
+    engines: {node: '>=18'}
+
+  isbinaryfile@4.0.10:
+    resolution: {integrity: sha512-iHrqe5shvBUcFbmZq9zOQHBoeOhZJu6RQGrDpBgenUm/Am+F3JM2MgQj+rK3Z601fzrL5gLZWtAPH2OBaSVcyw==}
+    engines: {node: '>= 8.0.0'}
+
+  isexe@2.0.0:
+    resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
+
+  isexe@4.0.0:
+    resolution: {integrity: sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw==}
+    engines: {node: '>=20'}
+
+  istanbul-lib-coverage@3.2.2:
+    resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==}
+    engines: {node: '>=8'}
+
+  istanbul-lib-instrument@5.2.1:
+    resolution: {integrity: sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg==}
+    engines: {node: '>=8'}
+
+  istanbul-lib-instrument@6.0.3:
+    resolution: {integrity: sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==}
+    engines: {node: '>=10'}
+
+  istanbul-lib-report@3.0.1:
+    resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==}
+    engines: {node: '>=10'}
+
+  istanbul-lib-source-maps@4.0.1:
+    resolution: {integrity: sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==}
+    engines: {node: '>=10'}
+
+  istanbul-reports@3.2.0:
+    resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==}
+    engines: {node: '>=8'}
+
+  jasmine-core@4.1.0:
+    resolution: {integrity: sha512-8E8BiffCL8sBwK1zU9cbavLe8xpJAgOduSJ6N8PJVv8VosQ/nxVTuXj2kUeHxTlZBVvh24G19ga7xdiaxlceKg==}
+
+  jasmine-core@5.9.0:
+    resolution: {integrity: sha512-OMUvF1iI6+gSRYOhMrH4QYothVLN9C3EJ6wm4g7zLJlnaTl8zbaPOr0bTw70l7QxkoM7sVFOWo83u9B2Fe2Zng==}
+
+  jiti@1.21.7:
+    resolution: {integrity: sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==}
+    hasBin: true
+
+  jose@6.2.14:
+    resolution: {integrity: sha512-3JasLuki1NRLQvY4wCoC37KZk4qxoBx2k2aFqLxT84IezqW0bkJKnNtxnZj/Fu1xsQivoc/lytxaRHJoGLjJ1A==}
+
+  js-tiktoken@1.0.21:
+    resolution: {integrity: sha512-biOj/6M5qdgx5TKjDnFT1ymSpM5tbd3ylwDtrQvFQSu0Z7bBYko2dF+W/aUkXUPuk6IVpRxk/3Q2sHOzGlS36g==}
+
+  js-tokens@4.0.0:
+    resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
+
+  jsesc@3.1.0:
+    resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==}
+    engines: {node: '>=6'}
+    hasBin: true
+
+  json-parse-even-better-errors@5.0.0:
+    resolution: {integrity: sha512-ZF1nxZ28VhQouRWhUcVlUIN3qwSgPuswK05s/HIaoetAoE/9tngVmCHjSxmSQPav1nd+lPtTL0YZ/2AFdR/iYQ==}
+    engines: {node: ^20.17.0 || >=22.9.0}
+
+  json-schema-traverse@1.0.0:
+    resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==}
+
+  json-schema-typed@8.0.2:
+    resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==}
+
+  json5@2.2.3:
+    resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==}
+    engines: {node: '>=6'}
+    hasBin: true
+
+  jsonc-parser@3.3.1:
+    resolution: {integrity: sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==}
+
+  jsonfile@4.0.0:
+    resolution: {integrity: sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==}
+
+  jsonparse@1.3.1:
+    resolution: {integrity: sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg==}
+    engines: {'0': node >= 0.2.0}
+
+  karma-chrome-launcher@3.2.0:
+    resolution: {integrity: sha512-rE9RkUPI7I9mAxByQWkGJFXfFD6lE4gC5nPuZdobf/QdTEJI6EU4yIay/cfU/xV4ZxlM5JiTv7zWYgA64NpS5Q==}
+
+  karma-coverage@2.2.1:
+    resolution: {integrity: sha512-yj7hbequkQP2qOSb20GuNSIyE//PgJWHwC2IydLE6XRtsnaflv+/OSGNssPjobYUlhVVagy99TQpqUt3vAUG7A==}
+    engines: {node: '>=10.0.0'}
+
+  karma-jasmine-html-reporter@2.1.0:
+    resolution: {integrity: sha512-sPQE1+nlsn6Hwb5t+HHwyy0A1FNCVKuL1192b+XNauMYWThz2kweiBVW1DqloRpVvZIJkIoHVB7XRpK78n1xbQ==}
+    peerDependencies:
+      jasmine-core: ^4.0.0 || ^5.0.0
+      karma: ^6.0.0
+      karma-jasmine: ^5.0.0
+
+  karma-jasmine@5.1.0:
+    resolution: {integrity: sha512-i/zQLFrfEpRyQoJF9fsCdTMOF5c2dK7C7OmsuKg2D0YSsuZSfQDiLuaiktbuio6F2wiCsZSnSnieIQ0ant/uzQ==}
+    engines: {node: '>=12'}
+    peerDependencies:
+      karma: ^6.0.0
+
+  karma@6.4.4:
+    resolution: {integrity: sha512-LrtUxbdvt1gOpo3gxG+VAJlJAEMhbWlM4YrFQgql98FwF7+K8K12LYO4hnDdUkNjeztYrOXEMqgTajSWgmtI/w==}
+    engines: {node: '>= 10'}
+    hasBin: true
+
+  katex@0.16.47:
+    resolution: {integrity: sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg==}
+    hasBin: true
+
+  khroma@2.1.0:
+    resolution: {integrity: sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw==}
+
+  langsmith@0.3.87:
+    resolution: {integrity: sha512-XXR1+9INH8YX96FKWc5tie0QixWz6tOqAsAKfcJyPkE0xPep+NDz0IQLR32q4bn10QK3LqD2HN6T3n6z1YLW7Q==}
+    peerDependencies:
+      '@opentelemetry/api': '*'
+      '@opentelemetry/exporter-trace-otlp-proto': '*'
+      '@opentelemetry/sdk-trace-base': '*'
+      openai: '*'
+    peerDependenciesMeta:
+      '@opentelemetry/api':
+        optional: true
+      '@opentelemetry/exporter-trace-otlp-proto':
+        optional: true
+      '@opentelemetry/sdk-trace-base':
+        optional: true
+      openai:
+        optional: true
+
+  layout-base@1.0.2:
+    resolution: {integrity: sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg==}
+
+  layout-base@2.0.1:
+    resolution: {integrity: sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg==}
+
+  lilconfig@3.1.4:
+    resolution: {integrity: sha512-zJZyBmQhArDRefuWTypZxwDpFDIYmrj8ypSBvwGD5P13Y95s6DjzKgu4DVdGbeQa5PDEsAOCAPNk4X+QOR006w==}
+    engines: {node: '>=14'}
+
+  lines-and-columns@1.2.4:
+    resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==}
+
+  listr2@9.0.1:
+    resolution: {integrity: sha512-SL0JY3DaxylDuo/MecFeiC+7pedM0zia33zl0vcjgwcq1q1FWWF1To9EIauPbl8GbMCU0R2e0uJ8bZunhYKD2g==}
+    engines: {node: '>=20.0.0'}
+
+  lmdb@3.4.2:
+    resolution: {integrity: sha512-nwVGUfTBUwJKXd6lRV8pFNfnrCC1+l49ESJRM19t/tFb/97QfJEixe5DYRvug5JO7DSFKoKaVy7oGMt5rVqZvg==}
+    hasBin: true
+
+  lodash-es@4.18.1:
+    resolution: {integrity: sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==}
+
+  lodash@4.18.1:
+    resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==}
+
+  log-symbols@6.0.0:
+    resolution: {integrity: sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw==}
+    engines: {node: '>=18'}
+
+  log-update@6.1.0:
+    resolution: {integrity: sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==}
+    engines: {node: '>=18'}
+
+  log4js@6.9.1:
+    resolution: {integrity: sha512-1somDdy9sChrr9/f4UlzhdaGfDR2c/SaD2a4T7qEkG4jTS57/B3qmnjLYePwQ8cqWnUHZI0iAKxMBpCZICiZ2g==}
+    engines: {node: '>=8.0'}
+
+  loglevel@1.9.2:
+    resolution: {integrity: sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg==}
+    engines: {node: '>= 0.6.0'}
+
+  lru-cache@11.5.3:
+    resolution: {integrity: sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==}
+    engines: {node: 20 || >=22}
+
+  lru-cache@5.1.1:
+    resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
+
+  magic-string@0.30.17:
+    resolution: {integrity: sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==}
+
+  make-dir@4.0.0:
+    resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==}
+    engines: {node: '>=10'}
+
+  make-fetch-happen@15.0.6:
+    resolution: {integrity: sha512-Je0fLJ0F5atA7F+eIlLzk+Wkcl57JDf4kf+EW8xiP5E31xOQxkIxTbgf1Oi1Lw9tRI9UEMRdI5Vz2xTzoNU1Jw==}
+    engines: {node: ^20.17.0 || >=22.9.0}
+
+  marked@16.3.0:
+    resolution: {integrity: sha512-K3UxuKu6l6bmA5FUwYho8CfJBlsUWAooKtdGgMcERSpF7gcBUrCGsLH7wDaaNOzwq18JzSUDyoEb/YsrqMac3w==}
+    engines: {node: '>= 20'}
+    hasBin: true
+
+  math-intrinsics@1.1.0:
+    resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
+    engines: {node: '>= 0.4'}
+
+  matrix-events-sdk@0.0.1:
+    resolution: {integrity: sha512-1QEOsXO+bhyCroIe2/A5OwaxHvBm7EsSQ46DEDn8RBIfQwN5HWBpFvyWWR4QY0KHPPnnJdI99wgRiAl7Ad5qaA==}
+
+  matrix-js-sdk@42.4.0:
+    resolution: {integrity: sha512-tlTU3kz9WmK+eJVDou+tMVPggVJdvj56XpEYBKgNx56mWd8rF6YfiK82o/aTjtAezuAoV50b+i4q3SGrfPiIGg==}
+    engines: {node: '>=22.0.0'}
+
+  matrix-widget-api@1.20.0:
+    resolution: {integrity: sha512-qwFP37rgns7mPesM5/vvZgknzb+kvyRB5BISv5sCuBxsGMceMr+Kogf7OkjRxBXdMCPBFuf6gIuYRe5S+lpzQw==}
+
+  media-typer@0.3.0:
+    resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==}
+    engines: {node: '>= 0.6'}
+
+  media-typer@1.1.1:
+    resolution: {integrity: sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==}
+    engines: {node: '>= 0.8'}
+
+  merge-descriptors@2.0.0:
+    resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==}
+    engines: {node: '>=18'}
+
+  merge2@1.4.1:
+    resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==}
+    engines: {node: '>= 8'}
+
+  mermaid@11.17.2:
+    resolution: {integrity: sha512-V6K3C8EBdEsPFZXSKMJe6ppQOENxuHARr9GvHX4hh47lAbhMRD9qf4oEK7LoaRQxULMa80/qt5gHO73aCleBBg==}
+
+  micromatch@4.0.8:
+    resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==}
+    engines: {node: '>=8.6'}
+
+  mime-db@1.40.0:
+    resolution: {integrity: sha512-jYdeOMPy9vnxEqFRRo6ZvTZ8d9oPb+k18PKoYNYUe2stVEBPPwsln/qWzdbmaIvnhZ9v2P+CuecK+fpUfsV2mA==}
+    engines: {node: '>= 0.6'}
+
+  mime-db@1.51.0:
+    resolution: {integrity: sha512-5y8A56jg7XVQx2mbv1lu49NR4dokRnhZYTtL+KGfaa27uq4pSTXkwQkFJl4pkRMyNFz/EtYDSkiiEHx3F7UN6g==}
+    engines: {node: '>= 0.6'}
+
+  mime-db@1.54.0:
+    resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==}
+    engines: {node: '>= 0.6'}
+
+  mime-types@2.1.24:
+    resolution: {integrity: sha512-WaFHS3MCl5fapm3oLxU4eYDw77IQM2ACcxQ9RIxfaC3ooc6PFuBMGZZsYpvoXS5D5QTWPieo1jjLdAm3TBP3cQ==}
+    engines: {node: '>= 0.6'}
+
+  mime-types@2.1.34:
+    resolution: {integrity: sha512-6cP692WwGIs9XXdOO4++N+7qjqv0rqxxVvJ3VHPh/Sc9mVZcQP+ZGhkKiTvWMQRr2tbHkJP/Yn7Y0npb3ZBs4A==}
+    engines: {node: '>= 0.6'}
+
+  mime-types@3.0.2:
+    resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==}
+    engines: {node: '>=18'}
+
+  mime@2.5.2:
+    resolution: {integrity: sha512-tqkh47FzKeCPD2PUiPB6pkbMzsCasjxAfC62/Wap5qrUWcb+sFasXUC5I3gYM5iBM8v/Qpn4UK0x+j0iHyFPDg==}
+    engines: {node: '>=4.0.0'}
+    hasBin: true
+
+  mimic-function@5.0.1:
+    resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==}
+    engines: {node: '>=18'}
+
+  minimatch@10.2.6:
+    resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==}
+    engines: {node: 18 || 20 || >=22}
+
+  minimatch@3.1.5:
+    resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==}
+
+  minimist@1.2.8:
+    resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==}
+
+  minipass-collect@2.0.1:
+    resolution: {integrity: sha512-D7V8PO9oaz7PWGLbCACuI1qEOsq7UKfLotx/C0Aet43fCUB/wfQ7DYeq2oR/svFJGYDHPr38SHATeaj/ZoKHKw==}
+    engines: {node: '>=16 || 14 >=14.17'}
+
+  minipass-fetch@4.0.0:
+    resolution: {integrity: sha512-2v6aXUXwLP1Epd/gc32HAMIWoczx+fZwEPRHm/VwtrJzRGwR1qGZXEYV3Zp8ZjjbwaZhMrM6uHV4KVkk+XCc2w==}
+    engines: {node: ^18.17.0 || >=20.5.0}
+
+  minipass-fetch@5.0.2:
+    resolution: {integrity: sha512-2d0q2a8eCi2IRg/IGubCNRJoYbA1+YPXAzQVRFmB45gdGZafyivnZ5YSEfo3JikbjGxOdntGFvBQGqaSMXlAFQ==}
+    engines: {node: ^20.17.0 || >=22.9.0}
+
+  minipass-flush@1.0.7:
+    resolution: {integrity: sha512-TbqTz9cUwWyHS2Dy89P3ocAGUGxKjjLuR9z8w4WUTGAVgEj17/4nhgo2Du56i0Fm3Pm30g4iA8Lcqctc76jCzA==}
+    engines: {node: '>= 8'}
+
+  minipass-pipeline@1.2.4:
+    resolution: {integrity: sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A==}
+    engines: {node: '>=8'}
+
+  minipass-sized@1.0.3:
+    resolution: {integrity: sha512-MbkQQ2CTiBMlA2Dm/5cY+9SWFEN8pzzOXi6rlM5Xxq0Yqbda5ZQy9sU75a673FE9ZK0Zsbr6Y5iP6u9nktfg2g==}
+    engines: {node: '>=8'}
+
+  minipass-sized@2.0.0:
+    resolution: {integrity: sha512-zSsHhto5BcUVM2m1LurnXY6M//cGhVaegT71OfOXoprxT6o780GZd792ea6FfrQkuU4usHZIUczAQMRUE2plzA==}
+    engines: {node: '>=8'}
+
+  minipass@3.0.0:
+    resolution: {integrity: sha512-FKNU4XrAPDX0+ynwns7njVu4RolyG1mUKSlT6n6GwGXLtYSYh2Znc0S83Rl6zEr1zgFfXvAzIBabnmItm+n19g==}
+    engines: {node: '>=8'}
+
+  minipass@7.1.3:
+    resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==}
+    engines: {node: '>=16 || 14 >=14.17'}
+
+  minizlib@3.1.0:
+    resolution: {integrity: sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==}
+    engines: {node: '>= 18'}
+
+  mkdirp@0.5.6:
+    resolution: {integrity: sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==}
+    hasBin: true
+
+  mrmime@2.0.1:
+    resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==}
+    engines: {node: '>=10'}
+
+  ms@2.0.0:
+    resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==}
+
+  ms@2.1.3:
+    resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
+
+  msgpackr-extract@3.0.4:
+    resolution: {integrity: sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw==}
+    hasBin: true
+
+  msgpackr@1.12.1:
+    resolution: {integrity: sha512-4EUH9tQHnMmEgzW/MdAP0KIfa1T9AF+htl0ffe2n5vb2EKn9y2co8ccpgWko6S52Jy1PQZKwRnx5/KkYjtd9MQ==}
+
+  mustache@4.2.0:
+    resolution: {integrity: sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==}
+    hasBin: true
+
+  mute-stream@2.0.0:
+    resolution: {integrity: sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==}
+    engines: {node: ^18.17.0 || >=20.5.0}
+
+  mz@2.7.0:
+    resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==}
+
+  nanoid@3.3.20:
+    resolution: {integrity: sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==}
+    engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
+    hasBin: true
+
+  negotiator@0.6.3:
+    resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==}
+    engines: {node: '>= 0.6'}
+
+  negotiator@1.1.0:
+    resolution: {integrity: sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==}
+    engines: {node: '>=18'}
+
+  ngx-markdown@20.1.0:
+    resolution: {integrity: sha512-BLn6CTMO27cU0zeaJYoC1g5c1hAkrpE5oqVSQFGW0J5gq+gEuvTt4vrtNLc8Z+HYXtuuWmuhUWiXL/bYoiDJ+A==}
+    peerDependencies:
+      '@angular/common': ^20.0.0
+      '@angular/core': ^20.0.0
+      '@angular/platform-browser': ^20.0.0
+      marked: ^15.0.0 || ^16.0.0
+      rxjs: ^6.5.3 || ^7.4.0
+      zone.js: ~0.15.0
+
+  node-addon-api@6.1.0:
+    resolution: {integrity: sha512-+eawOlIgy680F0kBzPUNFhMZGtJ1YmqM6l4+Crf4IkImjYrO/mqPwRMh352g23uIaQKFItcQ64I7KMaJxHgAVA==}
+
+  node-addon-api@7.0.0:
+    resolution: {integrity: sha512-vgbBJTS4m5/KkE16t5Ly0WW9hz46swAstv0hYYwMtbG7AznRhNyfLRe8HZAiWIpcHzoO7HxhLuBQj9rJ/Ho0ZA==}
+
+  node-gyp-build-optional-packages@5.2.2:
+    resolution: {integrity: sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==}
+    hasBin: true
+
+  node-gyp@12.4.0:
+    resolution: {integrity: sha512-OMcPNvqTCFUnNaBlmdgq+lfNqY7gTiSmNRDjY3uAXRyudeKZEZxu3CLtjMQrx4zZxCX2b/mpNqTtwuCJgXhHkw==}
+    engines: {node: ^20.17.0 || >=22.9.0}
+    hasBin: true
+
+  node-localstorage@2.2.1:
+    resolution: {integrity: sha512-vv8fJuOUCCvSPjDjBLlMqYMHob4aGjkmrkaE42/mZr0VT+ZAU10jRF8oTnX9+pgU9/vYJ8P7YT3Vd6ajkmzSCw==}
+    engines: {node: '>=0.12'}
+
+  node-releases@2.0.61:
+    resolution: {integrity: sha512-5NUN6QCxNFrS9oEA9RPS+d0AP+ooGBzzovY2JhIpDRHacPvOhHjXjUsUMX9qp4nLHZ+5jVgXB2jj/P4arGOE/g==}
+    engines: {node: '>=18'}
+
+  nopt@9.0.0:
+    resolution: {integrity: sha512-Zhq3a+yFKrYwSBluL4H9XP3m3y5uvQkB/09CwDruCiRmR/UJYnn9W4R48ry0uGC70aeTPKLynBtscP9efFFcPw==}
+    engines: {node: ^20.17.0 || >=22.9.0}
+    hasBin: true
+
+  normalize-path@3.0.0:
+    resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==}
+    engines: {node: '>=0.10.0'}
+
+  npm-bundled@5.0.0:
+    resolution: {integrity: sha512-JLSpbzh6UUXIEoqPsYBvVNVmyrjVZ1fzEFbqxKkTJQkWBO3xFzFT+KDnSKQWwOQNbuWRwt5LSD6HOTLGIWzfrw==}
+    engines: {node: ^20.17.0 || >=22.9.0}
+
+  npm-install-checks@7.1.0:
+    resolution: {integrity: sha512-bkTildVlofeMX7wiOaWk3PlW7YcBXAuEc7TWpOxwUgalG5ZvgT/ms+6OX9zt7iGLv4+VhKbRZhpOfgQJzk1YAw==}
+    engines: {node: ^18.17.0 || >=20.5.0}
+
+  npm-normalize-package-bin@4.0.0:
+    resolution: {integrity: sha512-TZKxPvItzai9kN9H/TkmCtx/ZN/hvr3vUycjlfmH0ootY9yFBzNOpiXAdIn1Iteqsvk4lQn6B5PTrt+n6h8k/w==}
+    engines: {node: ^18.17.0 || >=20.5.0}
+
+  npm-normalize-package-bin@5.0.0:
+    resolution: {integrity: sha512-CJi3OS4JLsNMmr2u07OJlhcrPxCeOeP/4xq67aWNai6TNWWbTrlNDgl8NcFKVlcBKp18GPj+EzbNIgrBfZhsag==}
+    engines: {node: ^20.17.0 || >=22.9.0}
+
+  npm-package-arg@13.0.0:
+    resolution: {integrity: sha512-+t2etZAGcB7TbbLHfDwooV9ppB2LhhcT6A+L9cahsf9mEUAoQ6CktLEVvEnpD0N5CkX7zJqnPGaFtoQDy9EkHQ==}
+    engines: {node: ^20.17.0 || >=22.9.0}
+
+  npm-packlist@10.0.4:
+    resolution: {integrity: sha512-uMW73iajD8hiH4ZBxEV3HC+eTnppIqwakjOYuvgddnalIw2lJguKviK1pcUJDlIWm1wSJkchpDZDSVVsZEYRng==}
+    engines: {node: ^20.17.0 || >=22.9.0}
+
+  npm-pick-manifest@11.0.1:
+    resolution: {integrity: sha512-HnU7FYSWbo7dTVHtK0G+BXbZ0aIfxz/aUCVLN0979Ec6rGUX5cJ6RbgVx5fqb5G31ufz+BVFA7y1SkRTPVNoVQ==}
+    engines: {node: ^20.17.0 || >=22.9.0}
+
+  npm-registry-fetch@19.0.0:
+    resolution: {integrity: sha512-DFxSAemHUwT/POaXAOY4NJmEWBPB0oKbwD6FFDE9hnt1nORkt/FXvgjD4hQjoKoHw9u0Ezws9SPXwV7xE/Gyww==}
+    engines: {node: ^20.17.0 || >=22.9.0}
+
+  nth-check@2.1.1:
+    resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==}
+
+  object-assign@4.1.1:
+    resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
+    engines: {node: '>=0.10.0'}
+
+  object-hash@3.0.0:
+    resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==}
+    engines: {node: '>= 6'}
+
+  object-inspect@1.13.4:
+    resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==}
+    engines: {node: '>= 0.4'}
+
+  on-finished@2.3.0:
+    resolution: {integrity: sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==}
+    engines: {node: '>= 0.8'}
+
+  on-finished@2.4.2:
+    resolution: {integrity: sha512-ATWUv387i+nLD49EiDoOft5scf32Vsdz3ktrQJG9bi7jRFmaXTssvyvj1EmeKVVgvesbqUtvQQvaS2TpTukAZQ==}
+    engines: {node: '>= 0.8'}
+
+  once@1.4.0:
+    resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
+
+  onetime@7.0.0:
+    resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==}
+    engines: {node: '>=18'}
+
+  ora@8.2.0:
+    resolution: {integrity: sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw==}
+    engines: {node: '>=18'}
+
+  ordered-binary@1.6.3:
+    resolution: {integrity: sha512-Bc8LnA2x4ZHRQ9LjCm/NOdD3+CTQPhGNu8m7/6+ERtiB6Bb/fn7nCySwcmAC+5WCqwelpSAQgo1VX9BGpsC0tQ==}
+
+  p-finally@1.0.0:
+    resolution: {integrity: sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==}
+    engines: {node: '>=4'}
+
+  p-map@7.1.1:
+    resolution: {integrity: sha512-6Lh3jQPLthYujSrlw6ccWXBGZTkoTCz27Hd92g79LH/kjAbgoEu0t442cCCNbegPSJsA0lwDX8z0bcKtkpA32g==}
+    engines: {node: '>=18'}
+
+  p-queue@6.6.2:
+    resolution: {integrity: sha512-RwFpb72c/BhQLEXIZ5K2e+AhgNVmIejGlTgiB9MzZ0e93GRvqZ7uSi0dvRF7/XIXDeNkra2fNHBxTyPDGySpjQ==}
+    engines: {node: '>=8'}
+
+  p-retry@4.0.0:
+    resolution: {integrity: sha512-bMW1FwszSBsMi6DgPnDKLhAVMDhqeySD4viMI0UiKRv7cQzRS/KYhRPWjnoxSQdvunXwZYKgN10Ya6VxF9w+Og==}
+    engines: {node: '>=8'}
+
+  p-retry@8.0.1:
+    resolution: {integrity: sha512-NAigyu8hfvJe7MsS18mnc4is0MZtau3QMdBklaJKK23oBQb6occO3UPM3vHmTckW8KhfyjMZaUOqdTFEYliHgg==}
+    engines: {node: '>=22'}
+
+  p-timeout@3.2.0:
+    resolution: {integrity: sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg==}
+    engines: {node: '>=8'}
+
+  package-manager-detector@1.9.0:
+    resolution: {integrity: sha512-zAMfbta9nPA8AKkA4WdmNAT8R8s6Dk0/xwPsqNnRidMFcLo1YkLuPDTgs2f64XdzS8vbLNnoJ+uUIkgbz7oRHA==}
+
+  pacote@21.5.1:
+    resolution: {integrity: sha512-KvcJ9iy3crysCsgqc4+PknH/w6jkrp8JN36mpZBPwNaDRwTfMZD37YzRazNstiZUOhuF5pno9f78n9mEJBavwg==}
+    engines: {node: ^20.17.0 || >=22.9.0}
+    hasBin: true
+
+  pako@2.1.0:
+    resolution: {integrity: sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==}
+
+  parse5-html-rewriting-stream@8.0.0:
+    resolution: {integrity: sha512-wzh11mj8KKkno1pZEu+l2EVeWsuKDfR5KNWZOTsslfUX8lPDZx77m9T0kIoAVkFtD1nx6YF8oh4BnPHvxMtNMw==}
+
+  parse5-sax-parser@8.0.0:
+    resolution: {integrity: sha512-/dQ8UzHZwnrzs3EvDj6IkKrD/jIZyTlB+8XrHJvcjNgRdmWruNdN9i9RK/JtxakmlUdPwKubKPTCqvbTgzGhrw==}
+
+  parse5@8.0.1:
+    resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==}
+
+  parseurl@1.3.3:
+    resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==}
+    engines: {node: '>= 0.8'}
+
+  path-data-parser@0.1.0:
+    resolution: {integrity: sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w==}
+
+  path-is-absolute@1.0.1:
+    resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==}
+    engines: {node: '>=0.10.0'}
+
+  path-key@3.1.1:
+    resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==}
+    engines: {node: '>=8'}
+
+  path-parse@1.0.7:
+    resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==}
+
+  path-scurry@2.0.2:
+    resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==}
+    engines: {node: 18 || 20 || >=22}
+
+  path-to-regexp@8.4.2:
+    resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==}
+
+  pdfjs-dist@6.4.299:
+    resolution: {integrity: sha512-AVl138zALtfaAPvADulE0PZThbYzCBS79nL4pOSL/6Sm/4AH5A21BD9VHt97OlCuzJuCpmeZtAtkinisF4Vb1g==}
+    engines: {node: '>=22.13.0 || >=24'}
+
+  picocolors@1.1.1:
+    resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
+
+  picomatch@2.3.2:
+    resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==}
+    engines: {node: '>=8.6'}
+
+  picomatch@4.0.4:
+    resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==}
+    engines: {node: '>=12'}
+
+  pirates@4.0.7:
+    resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==}
+    engines: {node: '>= 6'}
+
+  piscina@5.3.2:
+    resolution: {integrity: sha512-vv7l/mM7B9WgrhDaudIqv1x6v4LOBRYYmpWGdWushhpbCmVHp11sUB2v4hj1CAeTGXVxuZFz6xOU1RalAiPKfQ==}
+    engines: {node: '>=20.x'}
+
+  pkce-challenge@5.0.0:
+    resolution: {integrity: sha512-ueGLflrrnvwB3xuo/uGob5pd5FN7l0MsLf0Z87o/UQmRtwjvfylfc9MurIxRAWywCYTgrvpXBcqjV4OfCYGCIQ==}
+    engines: {node: '>=16.20.0'}
+
+  playwright-core@1.64.1:
+    resolution: {integrity: sha512-BC4s8y6SEVA6Q1rSxZTto8hI1KUI3xXlHGZgvGqP4IqPdI/a6FX9H29IDNz7vKo9l21b6LfYosScPL6M3xxyDQ==}
+    engines: {node: '>=20'}
+    hasBin: true
+
+  playwright@1.64.1:
+    resolution: {integrity: sha512-HCrVhHr5QQuwYoswEUGTb7gGp522yflbv/ST8nF4sFWGuLjQCt17Q/EubpGGSNKYn1tsTCPZxqHhHCfVURu7UA==}
+    engines: {node: '>=20'}
+    hasBin: true
+
+  points-on-curve@0.2.0:
+    resolution: {integrity: sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==}
+
+  points-on-path@0.2.1:
+    resolution: {integrity: sha512-25ClnWWuw7JbWZcgqY/gJ4FQWadKxGWk+3kR/7kD0tCaDtPPMj7oHu2ToLaVhfpnHrZzYby2w6tUA0eOIuUg8g==}
+
+  postcss-import@15.1.0:
+    resolution: {integrity: sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==}
+    engines: {node: '>=14.0.0'}
+    peerDependencies:
+      postcss: ^8.0.0
+
+  postcss-js@4.0.1:
+    resolution: {integrity: sha512-dDLF8pEO191hJMtlHFPRa8xsizHaM82MLfNkUHdUtVEV3tgTp5oj+8qbEqYM57SLfc74KSbw//4SeJma2LRVIw==}
+    engines: {node: ^12 || ^14 || >= 16}
+    peerDependencies:
+      postcss: ^8.4.21
+
+  postcss-load-config@6.0.1:
+    resolution: {integrity: sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==}
+    engines: {node: '>= 18'}
+    peerDependencies:
+      jiti: '>=1.21.0'
+      postcss: '>=8.0.9'
+      tsx: ^4.8.1
+      yaml: ^2.4.2
+    peerDependenciesMeta:
+      jiti:
+        optional: true
+      postcss:
+        optional: true
+      tsx:
+        optional: true
+      yaml:
+        optional: true
+
+  postcss-media-query-parser@0.2.3:
+    resolution: {integrity: sha512-3sOlxmbKcSHMjlUXQZKQ06jOswE7oVkXPxmZdoB1r5l0q6gTFTQSHxNxOrCccElbW7dxNytifNEo8qidX2Vsig==}
+
+  postcss-nested@6.2.0:
+    resolution: {integrity: sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==}
+    engines: {node: '>=12.0'}
+    peerDependencies:
+      postcss: ^8.2.14
+
+  postcss-selector-parser@6.1.5:
+    resolution: {integrity: sha512-TEHFzb5KRDKaOgJ4TYE+0jyJtFbjLvkdVOu0fZ2b6MTWzoqZd4Le+61aEppWkvTlYePgOX4ATL1wsAIPMLTw6Q==}
+    engines: {node: '>=4'}
+
+  postcss-value-parser@4.2.0:
+    resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==}
+
+  postcss@8.5.32:
+    resolution: {integrity: sha512-LJlKCCAUFKOuZxipUdYw1NzIITEsPcgyOG9LkvRJvuKj/bRzbuRY0ARXvL0pWGpsZvn8jku+LMep2eU0seHp8g==}
+    engines: {node: ^10 || ^12 || >=14}
+
+  prismjs@1.30.0:
+    resolution: {integrity: sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==}
+    engines: {node: '>=6'}
+
+  proc-log@5.0.0:
+    resolution: {integrity: sha512-Azwzvl90HaF0aCz1JrDdXQykFakSSNPaPoiZ9fm5qJIMHioDZEi7OAdRwSm6rSoPtY3Qutnm3L7ogmg3dc+wbQ==}
+    engines: {node: ^18.17.0 || >=20.5.0}
+
+  proc-log@6.0.0:
+    resolution: {integrity: sha512-KG/XsTDN901PNfPfAMmj6N/Ywg9tM+bHK8pAz+27fS4N4Pcr+4zoYBOcGSBu6ceXYNPxkLpa4ohtfxV1XcLAfA==}
+    engines: {node: ^20.17.0 || >=22.9.0}
+
+  proc-log@6.1.0:
+    resolution: {integrity: sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ==}
+    engines: {node: ^20.17.0 || >=22.9.0}
+
+  promise-polyfill@7.1.2:
+    resolution: {integrity: sha512-FuEc12/eKqqoRYIGBrUptCBRhobL19PS2U31vMNTfyck1FxPyMfgsXyW4Mav85y/ZN1hop3hOwRlUDok23oYfQ==}
+
+  proxy-addr@2.0.8:
+    resolution: {integrity: sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==}
+    engines: {node: '>= 0.10'}
+
+  punycode@1.4.1:
+    resolution: {integrity: sha512-jmYNElW7yvO7TV33CjSmvSiE2yco3bV2czu/OzDKdMNVZQWfxCblURLhf+47syQRBntjfLdd/H0egrzIG+oaFQ==}
+
+  qiniu-js@3.4.4:
+    resolution: {integrity: sha512-S/ooashZjyFQIIbxrte+OfwRxZQz5/MfVv55xWewc9GoxogP/xMmWixCaBEbdqmyjPAmJ2+VtZiWUuP8teB/BA==}
+
+  qjobs@1.2.0:
+    resolution: {integrity: sha512-8YOJEHtxpySA3fFDyCRxA+UUV+fA+rTWnuWvylOK/NCjhY+b4ocCtmu8TtsWb+mYeU+GCHf/S66KZF/AsteKHg==}
+    engines: {node: '>=0.9'}
+
+  qs@6.16.2:
+    resolution: {integrity: sha512-VYdnB5S7/0IeXzBacUtwNAaM6d7Y/PgYnNC7bJCI6meqA/OgnsQbZMtVW8Dt2D1PHgbHGtYmhSoEIgvt7xZ2ZA==}
+    engines: {node: '>=0.6'}
+
+  qs@6.17.1:
+    resolution: {integrity: sha512-jd3COkaKVgGUW+ZHyEHCG9bH7RoaaiWuMgzsIBjUMVjLAaWGWpoUAVwljVvBHxRu/2qEHnadLGZrkutYUBPQJA==}
+    engines: {node: '>=0.6'}
+
+  querystring@0.2.1:
+    resolution: {integrity: sha512-wkvS7mL/JMugcup3/rMitHmd9ecIGd2lhFhK9N3UUQ450h66d1r3Y9nvXzQAW1Lq+wyx61k/1pfKS5KuKiyEbg==}
+    engines: {node: '>=0.4.x'}
+    deprecated: The querystring API is considered Legacy. new code should use the URLSearchParams API instead.
+
+  queue-microtask@1.2.3:
+    resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
+
+  range-parser@1.3.0:
+    resolution: {integrity: sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==}
+    engines: {node: '>= 0.6'}
+
+  raw-body@2.5.3:
+    resolution: {integrity: sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==}
+    engines: {node: '>= 0.8'}
+
+  raw-body@3.0.0:
+    resolution: {integrity: sha512-RmkhL8CAyCRPXCE28MMH0z2PNWQBNk2Q09ZdxM9IOOXwxwZbN+qbWaatPkdkWIKL2ZVDImrN/pK5HTRz2PcS4g==}
+    engines: {node: '>= 0.8'}
+
+  raw-body@3.0.2:
+    resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==}
+    engines: {node: '>= 0.10'}
+
+  read-cache@1.0.2:
+    resolution: {integrity: sha512-/peqiBB/n07gQGLsWaHho3WfvUyRscw0gYTsEFMhrIe/nWLkYaf5SbKYjGYqtRV3aPwykJgF2VEMo1ac4bnsGA==}
+
+  readdirp@3.5.0:
+    resolution: {integrity: sha512-cMhu7c/8rdhkHXWsY+osBhfSy0JikwpHK/5+imo+LpeasTF8ouErHrlYkwT0++njiyuDvc7OFY5T3ukvZ8qmFQ==}
+    engines: {node: '>=8.10.0'}
+
+  readdirp@3.6.0:
+    resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==}
+    engines: {node: '>=8.10.0'}
+
+  readdirp@4.0.1:
+    resolution: {integrity: sha512-GkMg9uOTpIWWKbSsgwb5fA4EavTR+SG/PMPoAY8hkhHfEEY0/vqljY+XHqtDf2cr2IJtoNRDbrrEpZUiZCkYRw==}
+    engines: {node: '>= 14.16.0'}
+
+  recorder-core@1.3.26070800:
+    resolution: {integrity: sha512-92kxhXi9SVjo1mYtGIreXA/d/Mdgvr/iTbjhJ1BRpEzX1Eq/aqQ1eRXFQmnMJ+BmPJaQVXTw1K694yra0bOnEA==}
+
+  reflect-metadata@0.2.2:
+    resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==}
+
+  require-directory@2.1.1:
+    resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==}
+    engines: {node: '>=0.10.0'}
+
+  require-from-string@2.0.2:
+    resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
+    engines: {node: '>=0.10.0'}
+
+  requires-port@1.0.0:
+    resolution: {integrity: sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==}
+
+  resolve@1.22.10:
+    resolution: {integrity: sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==}
+    engines: {node: '>= 0.4'}
+    hasBin: true
+
+  resolve@1.22.12:
+    resolution: {integrity: sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==}
+    engines: {node: '>= 0.4'}
+    hasBin: true
+
+  restore-cursor@5.1.0:
+    resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==}
+    engines: {node: '>=18'}
+
+  retry@0.12.0:
+    resolution: {integrity: sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==}
+    engines: {node: '>= 4'}
+
+  reusify@1.1.0:
+    resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==}
+    engines: {iojs: '>=1.0.0', node: '>=0.10.0'}
+
+  rfdc@1.4.1:
+    resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==}
+
+  rimraf@3.0.2:
+    resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==}
+    deprecated: Rimraf versions prior to v4 are no longer supported
+    hasBin: true
+
+  robust-predicates@3.0.3:
+    resolution: {integrity: sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==}
+
+  rollup@4.59.0:
+    resolution: {integrity: sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==}
+    engines: {node: '>=18.0.0', npm: '>=8.0.0'}
+    hasBin: true
+
+  roughjs@4.6.6:
+    resolution: {integrity: sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ==}
+
+  router@2.2.0:
+    resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==}
+    engines: {node: '>= 18'}
+
+  run-parallel@1.2.0:
+    resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==}
+
+  rw@1.3.3:
+    resolution: {integrity: sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==}
+
+  rxjs@7.8.0:
+    resolution: {integrity: sha512-F2+gxDshqmIub1KdvZkaEfGDwLNpPvk9Fs6LD/MyQxNgMds/WH9OdDDXOmxUZpME+iSK3rQCctkL0DYyytUqMg==}
+
+  rxjs@7.8.2:
+    resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==}
+
+  safe-regex-test@1.1.0:
+    resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==}
+    engines: {node: '>= 0.4'}
+
+  safer-buffer@2.1.2:
+    resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
+
+  sass@1.90.0:
+    resolution: {integrity: sha512-9GUyuksjw70uNpb1MTYWsH9MQHOHY6kwfnkafC24+7aOMZn9+rVMBxRbLvw756mrBFbIsFg6Xw9IkR2Fnn3k+Q==}
+    engines: {node: '>=14.0.0'}
+    hasBin: true
+
+  sdp-transform@3.0.0:
+    resolution: {integrity: sha512-gfYVRGxjHkGF2NPeUWHw5u6T/KGFtS5/drPms73gaSuMaVHKCY3lpLnGDfswVQO0kddeePoti09AwhYP4zA8dQ==}
+    hasBin: true
+
+  select@1.1.2:
+    resolution: {integrity: sha512-OwpTSOfy6xSs1+pwcNrv0RBMOzI39Lp3qQKUTPVVPRjCdNa5JH/oPRiqsesIskK8TVgmRiHwO4KXlV2Li9dANA==}
+
+  semver@6.3.0:
+    resolution: {integrity: sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==}
+    hasBin: true
+
+  semver@6.3.1:
+    resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
+    hasBin: true
+
+  semver@7.7.2:
+    resolution: {integrity: sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==}
+    engines: {node: '>=10'}
+    hasBin: true
+
+  semver@7.8.5:
+    resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==}
+    engines: {node: '>=10'}
+    hasBin: true
+
+  send@1.2.1:
+    resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==}
+    engines: {node: '>= 18'}
+
+  serve-static@2.2.1:
+    resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==}
+    engines: {node: '>= 18'}
+
+  setprototypeof@1.2.0:
+    resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==}
+
+  shebang-command@2.0.0:
+    resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
+    engines: {node: '>=8'}
+
+  shebang-regex@3.0.0:
+    resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==}
+    engines: {node: '>=8'}
+
+  side-channel-list@1.0.1:
+    resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==}
+    engines: {node: '>= 0.4'}
+
+  side-channel-map@1.0.1:
+    resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==}
+    engines: {node: '>= 0.4'}
+
+  side-channel-weakmap@1.0.2:
+    resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==}
+    engines: {node: '>= 0.4'}
+
+  side-channel@1.1.1:
+    resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==}
+    engines: {node: '>= 0.4'}
+
+  signal-exit@4.1.0:
+    resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==}
+    engines: {node: '>=14'}
+
+  sigstore@4.1.1:
+    resolution: {integrity: sha512-endqECJkfhozrXMK5ngu/UAA0xVcVEFdnHJCElGaExypjW+HK5i6zu3NteLoaX/iFbRUbC3+DjttQs0GARr+5w==}
+    engines: {node: ^20.17.0 || >=22.9.0}
+
+  simple-wcswidth@1.1.2:
+    resolution: {integrity: sha512-j7piyCjAeTDSjzTSQ7DokZtMNwNlEAyxqSZeCS+CXH7fJ4jx3FuJ/mTW3mE+6JLs4VJBbcll0Kjn+KXI5t21Iw==}
+
+  slice-ansi@5.0.0:
+    resolution: {integrity: sha512-FC+lgizVPfie0kkhqUScwRu1O/lF6NOgJmlCgK+/LYxDCTk8sGelYaHDhFcDN+Sn3Cv+3VSa4Byeo+IMCzpMgQ==}
+    engines: {node: '>=12'}
+
+  slice-ansi@7.1.0:
+    resolution: {integrity: sha512-bSiSngZ/jWeX93BqeIAbImyTbEihizcwNjFoRUIY/T1wWQsfsm2Vw1agPKylXvQTU7iASGdHhyqRlqQzfz+Htg==}
+    engines: {node: '>=18'}
+
+  slide@1.1.6:
+    resolution: {integrity: sha512-NwrtjCg+lZoqhFU8fOwl4ay2ei8PaqCBOUV3/ektPY9trO1yQ1oXEfmHAhKArUVUr/hOHvy5f6AdP17dCM0zMw==}
+
+  smart-buffer@4.2.0:
+    resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==}
+    engines: {node: '>= 6.0.0', npm: '>= 3.0.0'}
+
+  socket.io-adapter@2.5.8:
+    resolution: {integrity: sha512-6Oy52pbg+kvdCVvjcN+FnY7BvxZ7cIHNScbvztT/It5d0vbwoJoVZmF2gjJmnV0/4WlXRfG15zc45ySk9Ah8bw==}
+
+  socket.io-parser@4.2.7:
+    resolution: {integrity: sha512-IH/iSeO9T6gz1KkFleGDWkG9N3dl4jXVYUtMhIqH10Md0ttMer8nUNWiP1DKuNrybD2xBrixLJdCC9J6ECoYkg==}
+    engines: {node: '>=10.0.0'}
+
+  socket.io@4.8.4:
+    resolution: {integrity: sha512-9ZV9zg8vN8hO3Y25DhR7nUkJ1VDnBhq5O/JrXhFT9s6+F0gqytSASTHVfCFc2mJXaTfARroErplXlzFWwETZqQ==}
+    engines: {node: '>=10.2.0'}
+
+  socks-proxy-agent@8.0.3:
+    resolution: {integrity: sha512-VNegTZKhuGq5vSD6XNKlbqWhyt/40CgoEw8XxD6dhnm8Jq9IEa3nIa4HwnM8XOqU0CdB0BwWVXusqiFXfHB3+A==}
+    engines: {node: '>= 14'}
+
+  socks@2.8.10:
+    resolution: {integrity: sha512-e0VyvkVTwVYViNovRkZ9aodhxVlyoMn7eJhVUPxZ+eK9P/7CBkxvvsBOHqFPEH416726W8tLXXXjKwqgTErrCQ==}
+    engines: {node: '>= 10.0.0', npm: '>= 3.0.0'}
+
+  source-map-js@1.2.2:
+    resolution: {integrity: sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==}
+    engines: {node: '>=0.10.0'}
+
+  source-map-support@0.5.21:
+    resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==}
+
+  source-map@0.6.0:
+    resolution: {integrity: sha512-mTozplhTX4tLKIHYji92OTZzVyZvi+Z1qRZDeBvQFI2XUB89wrRoj/xXad3c9NZ1GPJXXRvB+k41PQCPTMC+aA==}
+    engines: {node: '>=0.10.0'}
+
+  source-map@0.6.1:
+    resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==}
+    engines: {node: '>=0.10.0'}
+
+  source-map@0.7.6:
+    resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==}
+    engines: {node: '>= 12'}
+
+  spark-md5@3.0.2:
+    resolution: {integrity: sha512-wcFzz9cDfbuqe0FZzfi2or1sgyIrsDwmPwfZC4hiNidPdPINjeUwNfv5kldczoEAcjl9Y1L3SM7Uz2PUEQzxQw==}
+
+  spdx-exceptions@2.5.0:
+    resolution: {integrity: sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==}
+
+  spdx-expression-parse@4.0.0:
+    resolution: {integrity: sha512-Clya5JIij/7C6bRR22+tnGXbc4VKlibKSVj2iHvVeX5iMW7s1SIQlqu699JkODJJIhh/pUu8L0/VLh8xflD+LQ==}
+
+  spdx-license-ids@3.0.24:
+    resolution: {integrity: sha512-cLS9TtWkIQFyLkJ3/5aFQAOHOSKTlOs/7WDut/XPSdjom1fJhUdkepeJAKXa9Y05+FabHd0sti+Et559vDtkpQ==}
+
+  ssri@13.0.1:
+    resolution: {integrity: sha512-QUiRf1+u9wPTL/76GTYlKttDEBWV1ga9ZXW8BG6kfdeyyM8LGPix9gROyg9V2+P0xNyF3X2Go526xKFdMZrHSQ==}
+    engines: {node: ^20.17.0 || >=22.9.0}
+
+  statuses@1.5.0:
+    resolution: {integrity: sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==}
+    engines: {node: '>= 0.6'}
+
+  statuses@2.0.1:
+    resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==}
+    engines: {node: '>= 0.8'}
+
+  statuses@2.0.2:
+    resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==}
+    engines: {node: '>= 0.8'}
+
+  stdin-discarder@0.2.2:
+    resolution: {integrity: sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ==}
+    engines: {node: '>=18'}
+
+  streamroller@3.1.5:
+    resolution: {integrity: sha512-KFxaM7XT+irxvdqSP1LGLgNWbYN7ay5owZ3r/8t77p+EtSUAfUgtl7be3xtqtOmGUl9K9YPO2ca8133RlTjvKw==}
+    engines: {node: '>=8.0'}
+
+  strictdom@1.0.1:
+    resolution: {integrity: sha512-cEmp9QeXXRmjj/rVp9oyiqcvyocWab/HaoN4+bwFeZ7QzykJD6L3yD4v12K1x0tHpqRqVpJevN3gW7kyM39Bqg==}
+
+  string-width@4.1.0:
+    resolution: {integrity: sha512-NrX+1dVVh+6Y9dnQ19pR0pP4FiEIlUvdTGn8pw6CKTNq5sgib2nIhmUNT5TAmhWmvKr3WcxBcP3E8nWezuipuQ==}
+    engines: {node: '>=8'}
+
+  string-width@4.2.0:
+    resolution: {integrity: sha512-zUz5JD+tgqtuDjMhwIg5uFVV3dtqZ9yQJlZVfq4I01/K5Paj5UHj7VyrQOJvzawSVlKpObApbfD0Ed6yJc+1eg==}
+    engines: {node: '>=8'}
+
+  string-width@7.0.0:
+    resolution: {integrity: sha512-GPQHj7row82Hjo9hKZieKcHIhaAIKOJvFSIZXuCU9OASVZrMNUaZuz++SPVrBjnLsnk4k+z9f2EIypgxf2vNFw==}
+    engines: {node: '>=18'}
+
+  string-width@7.2.0:
+    resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==}
+    engines: {node: '>=18'}
+
+  string-width@8.3.0:
+    resolution: {integrity: sha512-ZbmZM0JCihQN91dWnxoipT2KOEyHqEyfRXUyjuRhW8b/xnqPDoq4gWEVApTVa9db2wN8mmoikgFBbjh71+cGeQ==}
+    engines: {node: '>=20'}
+
+  strip-ansi@5.2.0:
+    resolution: {integrity: sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==}
+    engines: {node: '>=6'}
+
+  strip-ansi@6.0.0:
+    resolution: {integrity: sha512-AuvKTrTfQNYNIctbR1K/YGTR1756GycPsg7b9bdV9Duqur4gv6aKqHXah67Z8ImS7WEz5QVcOtlfW2rZEugt6w==}
+    engines: {node: '>=8'}
+
+  strip-ansi@7.2.0:
+    resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==}
+    engines: {node: '>=12'}
+
+  stylis@4.4.0:
+    resolution: {integrity: sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA==}
+
+  sucrase@3.35.1:
+    resolution: {integrity: sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==}
+    engines: {node: '>=16 || 14 >=14.17'}
+    hasBin: true
+
+  supports-color@7.1.0:
+    resolution: {integrity: sha512-oRSIpR8pxT1Wr2FquTNnGet79b3BWljqOuoW/h4oBhxJ/HUbX5nX6JSruTkvXDCFMwDPvsaTTbvMLKZWSy0R5g==}
+    engines: {node: '>=8'}
+
+  supports-preserve-symlinks-flag@1.0.0:
+    resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
+    engines: {node: '>= 0.4'}
+
+  tailwindcss@3.4.19:
+    resolution: {integrity: sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==}
+    engines: {node: '>=14.0.0'}
+    hasBin: true
+
+  tar@7.5.22:
+    resolution: {integrity: sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA==}
+    engines: {node: '>=18'}
+
+  thenify-all@1.6.0:
+    resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==}
+    engines: {node: '>=0.8'}
+
+  thenify@3.3.1:
+    resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==}
+
+  tiny-emitter@2.1.0:
+    resolution: {integrity: sha512-NB6Dk1A9xgQPMoGqC5CVXn123gWyte215ONT5Pp5a0yt4nlEoO1ZWeCwpncaekPHXO60i47ihFnZPiRPjRMq4Q==}
+
+  tinyexec@1.3.1:
+    resolution: {integrity: sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==}
+    engines: {node: '>=18'}
+
+  tinyglobby@0.2.14:
+    resolution: {integrity: sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==}
+    engines: {node: '>=12.0.0'}
+
+  tinyglobby@0.2.17:
+    resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==}
+    engines: {node: '>=12.0.0'}
+
+  tmp@0.2.7:
+    resolution: {integrity: sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==}
+    engines: {node: '>=14.14'}
+
+  to-regex-range@5.0.1:
+    resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==}
+    engines: {node: '>=8.0'}
+
+  toidentifier@1.0.1:
+    resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==}
+    engines: {node: '>=0.6'}
+
+  ts-dedent@2.3.0:
+    resolution: {integrity: sha512-JfJeIHke7y2egdGGgRAvpCwYFUsHlM2gPcrVOxFkznt/4uzQ7HFmvE63iFHVLBJNDuyDOQgijDK/tXH/f6Msjg==}
+    engines: {node: '>=6.10'}
+
+  ts-interface-checker@0.1.13:
+    resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==}
+
+  tslib@2.8.1:
+    resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
+
+  tuf-js@4.1.0:
+    resolution: {integrity: sha512-50QV99kCKH5P/Vs4E2Gzp7BopNV+KzTXqWeaxrfu5IQJBOULRsTIS9seSsOVT8ZnGXzCyx55nYWAi4qJzpZKEQ==}
+    engines: {node: ^20.17.0 || >=22.9.0}
+
+  type-fest@0.21.3:
+    resolution: {integrity: sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==}
+    engines: {node: '>=10'}
+
+  type-is@1.6.18:
+    resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==}
+    engines: {node: '>= 0.6'}
+
+  type-is@2.1.0:
+    resolution: {integrity: sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==}
+    engines: {node: '>= 18'}
+
+  typescript@5.9.2:
+    resolution: {integrity: sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==}
+    engines: {node: '>=14.17'}
+    hasBin: true
+
+  ua-parser-js@0.7.30:
+    resolution: {integrity: sha512-uXEtSresNUlXQ1QL4/3dQORcGv7+J2ookOG2ybA/ga9+HYEXueT2o+8dUJQkpedsyTyCJ6jCCirRcKtdtx1kbg==}
+    deprecated: You are using an outdated version of ua-parser-js. Please update to ua-parser-js v0.7.33 / v1.0.33 / v2.0.0 (or later) to avoid ReDoS vulnerability [CVE-2022-25927](https://github.com/advisories/GHSA-fhg7-m89q-25r3)
+
+  undici-types@8.9.0:
+    resolution: {integrity: sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==}
+
+  undici@6.29.1:
+    resolution: {integrity: sha512-EvRlUt/QqAEvXdQeRMYUfwOQsfzpDztykrG2owGEE/rBOBoYv9uF39ZzsNDh4lTFHhTr2XOZjKkYgueD12ZmGA==}
+    engines: {node: '>=18.17'}
+
+  unhomoglyph@1.0.6:
+    resolution: {integrity: sha512-7uvcWI3hWshSADBu4JpnyYbTVc7YlhF5GDW/oPD5AxIxl34k4wXR3WDkPnzLxkN32LiTCTKMQLtKVZiwki3zGg==}
+
+  universalify@0.1.2:
+    resolution: {integrity: sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==}
+    engines: {node: '>= 4.0.0'}
+
+  unpipe@1.0.0:
+    resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==}
+    engines: {node: '>= 0.8'}
+
+  update-browserslist-db@1.3.4:
+    resolution: {integrity: sha512-t8wonIOdQyOjtPd2VktlBu6QjTVTks6v8bSi7kd0r2Vll8TK1JYDZtRRdbmCHOAMphkZgrzK+L+vXK4pyZ8t4A==}
+    hasBin: true
+    peerDependencies:
+      browserslist: '>= 4.21.0'
+
+  util-deprecate@1.0.2:
+    resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
+
+  utils-merge@1.0.1:
+    resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==}
+    engines: {node: '>= 0.4.0'}
+
+  uuid@10.0.0:
+    resolution: {integrity: sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==}
+    hasBin: true
+
+  uuid@14.0.3:
+    resolution: {integrity: sha512-Sc5u1VVln93y1Xnz4R+CunOZc7R9LUcTmhj7Rc7Eg3HVcf7rVn2FOoa812fe0W8p3jD7xeoGSYbfqCGGH2B6xw==}
+    hasBin: true
+
+  validate-npm-package-name@6.0.0:
+    resolution: {integrity: sha512-d7KLgL1LD3U3fgnvWEY1cQXoO/q6EQ1BSz48Sa149V/5zVTAbgmZIpyI8TRi6U9/JNyeYLlTKsEMPtLC27RFUg==}
+    engines: {node: ^18.17.0 || >=20.5.0}
+
+  vary@1.1.2:
+    resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==}
+    engines: {node: '>= 0.8'}
+
+  vite@7.3.7:
+    resolution: {integrity: sha512-AHEzTORMbi7/7aXp7w7KZ5+DCUdSeLsJz0grY8xHpc9DHdYb0vQu6DqJfm2tOw3gDExNgPgnJTXxM6syYFHaCA==}
+    engines: {node: ^20.19.0 || >=22.12.0}
+    hasBin: true
+    peerDependencies:
+      '@types/node': ^20.19.0 || >=22.12.0
+      jiti: '>=1.21.0'
+      less: ^4.0.0
+      lightningcss: ^1.21.0
+      sass: ^1.70.0
+      sass-embedded: ^1.70.0
+      stylus: '>=0.54.8'
+      sugarss: ^5.0.0
+      terser: ^5.16.0
+      tsx: ^4.8.1
+      yaml: ^2.4.2
+    peerDependenciesMeta:
+      '@types/node':
+        optional: true
+      jiti:
+        optional: true
+      less:
+        optional: true
+      lightningcss:
+        optional: true
+      sass:
+        optional: true
+      sass-embedded:
+        optional: true
+      stylus:
+        optional: true
+      sugarss:
+        optional: true
+      terser:
+        optional: true
+      tsx:
+        optional: true
+      yaml:
+        optional: true
+
+  void-elements@2.0.1:
+    resolution: {integrity: sha512-qZKX4RnBzH2ugr8Lxa7x+0V6XD9Sb/ouARtiasEQCHB1EVU4NXtmHsDDrx1dO4ne5fc3J6EW05BP1Dl0z0iung==}
+    engines: {node: '>=0.10.0'}
+
+  watchpack@2.4.4:
+    resolution: {integrity: sha512-c5EGNOiyxxV5qmTtAB7rbiXxi1ooX1pQKMLX/MIabJjRA0SJBQOjKF+KSVfHkr9U1cADPon0mRiVe/riyaiDUA==}
+    engines: {node: '>=10.13.0'}
+
+  weak-lru-cache@1.2.2:
+    resolution: {integrity: sha512-DEAoo25RfSYMuTGc9vPJzZcZullwIqRDSI9LOy+fkCJPi6hykCnfKaXTuPBDuXAUcqHXyOgFtHNp/kB2FjYHbw==}
+
+  which@1.2.1:
+    resolution: {integrity: sha512-yW81tHzyiJtg+nszm26OM2TcMRg7aWVIl/9WUPL+LpKdf6rj7ZOMsb+f356sxivnP6ofbpZXDGqZNIkmBBrPLA==}
+    hasBin: true
+
+  which@2.0.1:
+    resolution: {integrity: sha512-N7GBZOTswtB9lkQBZA4+zAXrjEIWAUOB93AvzUiudRzRxhUdLURQ7D/gAIMY1gatT/LTbmbcv8SiYazy3eYB7w==}
+    engines: {node: '>= 8'}
+    hasBin: true
+
+  which@6.0.1:
+    resolution: {integrity: sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg==}
+    engines: {node: ^20.17.0 || >=22.9.0}
+    hasBin: true
+
+  wrap-ansi@6.2.0:
+    resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==}
+    engines: {node: '>=8'}
+
+  wrap-ansi@7.0.0:
+    resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
+    engines: {node: '>=10'}
+
+  wrap-ansi@9.0.0:
+    resolution: {integrity: sha512-G8ura3S+3Z2G+mkgNRq8dqaFZAuxfsxpBB8OCTGRTCtp+l/v9nbFNmCUP1BZMts3G1142MsZfn6eeUKrr4PD1Q==}
+    engines: {node: '>=18'}
+
+  wrappy@1.0.2:
+    resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
+
+  write-file-atomic@1.1.4:
+    resolution: {integrity: sha512-c5qespPIeoD/YQTLgdOTe9mcjhK0MhK/URjnIlpuF+4Hoec1flfMRcZY+SWrqGHHRC1oGY1VyNC44wiLQgJMiw==}
+
+  ws@8.21.3:
+    resolution: {integrity: sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==}
+    engines: {node: '>=10.0.0'}
+    peerDependencies:
+      bufferutil: ^4.0.1
+      utf-8-validate: '>=5.0.2'
+    peerDependenciesMeta:
+      bufferutil:
+        optional: true
+      utf-8-validate:
+        optional: true
+
+  y18n@5.0.8:
+    resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
+    engines: {node: '>=10'}
+
+  yallist@3.1.1:
+    resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
+
+  yallist@4.0.0:
+    resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==}
+
+  yallist@5.0.0:
+    resolution: {integrity: sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==}
+    engines: {node: '>=18'}
+
+  yargs-parser@20.2.9:
+    resolution: {integrity: sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==}
+    engines: {node: '>=10'}
+
+  yargs-parser@22.0.0:
+    resolution: {integrity: sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==}
+    engines: {node: ^20.19.0 || ^22.12.0 || >=23}
+
+  yargs@16.2.2:
+    resolution: {integrity: sha512-Nt9ZJjXTv5R8MHbqby/wXQ6Gi0Bb3TcYZkR1bzuL4yB2OxWPkXknz513gEF0GoA6tn00UpbPvERW8rzCuWCA6w==}
+    engines: {node: '>=10'}
+
+  yargs@18.0.0:
+    resolution: {integrity: sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==}
+    engines: {node: ^20.19.0 || ^22.12.0 || >=23}
+
+  yargs@18.2.0:
+    resolution: {integrity: sha512-9OpKOLeaoNFecEp7P6iYbzze/5CqWoH7N3SMs/6y1XF4nMvFMspEGZzJ6uFi9MmQwXhyzqYoSEKO3tvA3Z/o2w==}
+    engines: {node: ^20.19.0 || ^22.12.0 || >=23}
+
+  yoctocolors-cjs@2.1.3:
+    resolution: {integrity: sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==}
+    engines: {node: '>=18'}
+
+  zod-to-json-schema@3.25.2:
+    resolution: {integrity: sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==}
+    peerDependencies:
+      zod: ^3.25.28 || ^4
+
+  zod@3.25.32:
+    resolution: {integrity: sha512-OSm2xTIRfW8CV5/QKgngwmQW/8aPfGdaQFlrGoErlgg/Epm7cjb6K6VEyExfe65a3VybUOnu381edLb0dfJl0g==}
+
+  zod@4.6.6:
+    resolution: {integrity: sha512-4UmzQUF5fxvfcYHlbjBmejU+w+hbDnWFx4aS8kBx7nPBHcO2SqPbpAG8snbdg+Xse4pRefR98hgjFgIMCI/UhA==}
+
+  zone.js@0.15.0:
+    resolution: {integrity: sha512-9oxn0IIjbCZkJ67L+LkhYWRyAy7axphb3VgE2MBDlOqnmHMPWGYMxJxBYFueFq/JGY2GMwS0rU+UCLunEmy5UA==}
+
+snapshots:
+
+  '@algolia/abtesting@1.1.0':
+    dependencies:
+      '@algolia/client-common': 5.35.0
+      '@algolia/requester-browser-xhr': 5.35.0
+      '@algolia/requester-fetch': 5.35.0
+      '@algolia/requester-node-http': 5.35.0
+
+  '@algolia/client-abtesting@5.35.0':
+    dependencies:
+      '@algolia/client-common': 5.35.0
+      '@algolia/requester-browser-xhr': 5.35.0
+      '@algolia/requester-fetch': 5.35.0
+      '@algolia/requester-node-http': 5.35.0
+
+  '@algolia/client-analytics@5.35.0':
+    dependencies:
+      '@algolia/client-common': 5.35.0
+      '@algolia/requester-browser-xhr': 5.35.0
+      '@algolia/requester-fetch': 5.35.0
+      '@algolia/requester-node-http': 5.35.0
+
+  '@algolia/client-common@5.35.0': {}
+
+  '@algolia/client-insights@5.35.0':
+    dependencies:
+      '@algolia/client-common': 5.35.0
+      '@algolia/requester-browser-xhr': 5.35.0
+      '@algolia/requester-fetch': 5.35.0
+      '@algolia/requester-node-http': 5.35.0
+
+  '@algolia/client-personalization@5.35.0':
+    dependencies:
+      '@algolia/client-common': 5.35.0
+      '@algolia/requester-browser-xhr': 5.35.0
+      '@algolia/requester-fetch': 5.35.0
+      '@algolia/requester-node-http': 5.35.0
+
+  '@algolia/client-query-suggestions@5.35.0':
+    dependencies:
+      '@algolia/client-common': 5.35.0
+      '@algolia/requester-browser-xhr': 5.35.0
+      '@algolia/requester-fetch': 5.35.0
+      '@algolia/requester-node-http': 5.35.0
+
+  '@algolia/client-search@5.35.0':
+    dependencies:
+      '@algolia/client-common': 5.35.0
+      '@algolia/requester-browser-xhr': 5.35.0
+      '@algolia/requester-fetch': 5.35.0
+      '@algolia/requester-node-http': 5.35.0
+
+  '@algolia/ingestion@1.35.0':
+    dependencies:
+      '@algolia/client-common': 5.35.0
+      '@algolia/requester-browser-xhr': 5.35.0
+      '@algolia/requester-fetch': 5.35.0
+      '@algolia/requester-node-http': 5.35.0
+
+  '@algolia/monitoring@1.35.0':
+    dependencies:
+      '@algolia/client-common': 5.35.0
+      '@algolia/requester-browser-xhr': 5.35.0
+      '@algolia/requester-fetch': 5.35.0
+      '@algolia/requester-node-http': 5.35.0
+
+  '@algolia/recommend@5.35.0':
+    dependencies:
+      '@algolia/client-common': 5.35.0
+      '@algolia/requester-browser-xhr': 5.35.0
+      '@algolia/requester-fetch': 5.35.0
+      '@algolia/requester-node-http': 5.35.0
+
+  '@algolia/requester-browser-xhr@5.35.0':
+    dependencies:
+      '@algolia/client-common': 5.35.0
+
+  '@algolia/requester-fetch@5.35.0':
+    dependencies:
+      '@algolia/client-common': 5.35.0
+
+  '@algolia/requester-node-http@5.35.0':
+    dependencies:
+      '@algolia/client-common': 5.35.0
+
+  '@alloc/quick-lru@5.3.0': {}
+
+  '@ampproject/remapping@2.3.0':
+    dependencies:
+      '@jridgewell/gen-mapping': 0.3.5
+      '@jridgewell/trace-mapping': 0.3.31
+
+  '@angular-devkit/architect@0.2003.42(chokidar@4.0.0)':
+    dependencies:
+      '@angular-devkit/core': 20.3.42(chokidar@4.0.0)
+      rxjs: 7.8.2
+    transitivePeerDependencies:
+      - chokidar
+
+  '@angular-devkit/core@20.3.42(chokidar@4.0.0)':
+    dependencies:
+      ajv: 8.18.0
+      ajv-formats: 3.0.1(ajv@8.18.0)
+      jsonc-parser: 3.3.1
+      picomatch: 4.0.4
+      rxjs: 7.8.2
+      source-map: 0.7.6
+    optionalDependencies:
+      chokidar: 4.0.0
+
+  '@angular-devkit/schematics@20.3.42(chokidar@4.0.0)':
+    dependencies:
+      '@angular-devkit/core': 20.3.42(chokidar@4.0.0)
+      jsonc-parser: 3.3.1
+      magic-string: 0.30.17
+      ora: 8.2.0
+      rxjs: 7.8.2
+    transitivePeerDependencies:
+      - chokidar
+
+  '@angular/build@20.3.42(@angular/compiler-cli@20.3.34(@angular/compiler@20.3.34)(supports-color@7.1.0)(typescript@5.9.2))(@angular/compiler@20.3.34)(@angular/core@20.3.34(@angular/compiler@20.3.34)(rxjs@7.8.0)(zone.js@0.15.0))(@angular/platform-browser@20.3.34(@angular/common@20.3.34(@angular/core@20.3.34(@angular/compiler@20.3.34)(rxjs@7.8.0)(zone.js@0.15.0))(rxjs@7.8.0))(@angular/core@20.3.34(@angular/compiler@20.3.34)(rxjs@7.8.0)(zone.js@0.15.0)))(@types/node@26.6.6)(chokidar@4.0.0)(jiti@1.21.7)(karma@6.4.4(debug@4.4.3(supports-color@7.1.0))(supports-color@7.1.0))(postcss@8.5.32)(supports-color@7.1.0)(tailwindcss@3.4.19)(tslib@2.8.1)(typescript@5.9.2)':
+    dependencies:
+      '@ampproject/remapping': 2.3.0
+      '@angular-devkit/architect': 0.2003.42(chokidar@4.0.0)
+      '@angular/compiler': 20.3.34
+      '@angular/compiler-cli': 20.3.34(@angular/compiler@20.3.34)(supports-color@7.1.0)(typescript@5.9.2)
+      '@babel/core': 7.29.7(supports-color@7.1.0)
+      '@babel/helper-annotate-as-pure': 7.27.3
+      '@babel/helper-split-export-declaration': 7.24.7
+      '@inquirer/confirm': 5.1.14(@types/node@26.6.6)
+      '@vitejs/plugin-basic-ssl': 2.1.0(vite@7.3.7(@types/node@26.6.6)(jiti@1.21.7)(sass@1.90.0))
+      beasties: 0.3.5
+      browserslist: 4.29.5
+      esbuild: 0.28.1
+      https-proxy-agent: 7.0.6(supports-color@7.1.0)
+      istanbul-lib-instrument: 6.0.3(supports-color@7.1.0)
+      jsonc-parser: 3.3.1
+      listr2: 9.0.1
+      magic-string: 0.30.17
+      mrmime: 2.0.1
+      parse5-html-rewriting-stream: 8.0.0
+      picomatch: 4.0.4
+      piscina: 5.3.2
+      rollup: 4.59.0
+      sass: 1.90.0
+      semver: 7.7.2
+      source-map-support: 0.5.21
+      tinyglobby: 0.2.14
+      tslib: 2.8.1
+      typescript: 5.9.2
+      vite: 7.3.7(@types/node@26.6.6)(jiti@1.21.7)(sass@1.90.0)
+      watchpack: 2.4.4
+    optionalDependencies:
+      '@angular/core': 20.3.34(@angular/compiler@20.3.34)(rxjs@7.8.0)(zone.js@0.15.0)
+      '@angular/platform-browser': 20.3.34(@angular/common@20.3.34(@angular/core@20.3.34(@angular/compiler@20.3.34)(rxjs@7.8.0)(zone.js@0.15.0))(rxjs@7.8.0))(@angular/core@20.3.34(@angular/compiler@20.3.34)(rxjs@7.8.0)(zone.js@0.15.0))
+      karma: 6.4.4(debug@4.4.3(supports-color@7.1.0))(supports-color@7.1.0)
+      lmdb: 3.4.2
+      postcss: 8.5.32
+      tailwindcss: 3.4.19
+    transitivePeerDependencies:
+      - '@types/node'
+      - chokidar
+      - jiti
+      - lightningcss
+      - sass-embedded
+      - stylus
+      - sugarss
+      - supports-color
+      - terser
+      - tsx
+      - yaml
+
+  '@angular/cli@20.3.42(@types/node@26.6.6)(chokidar@4.0.0)(supports-color@7.1.0)':
+    dependencies:
+      '@angular-devkit/architect': 0.2003.42(chokidar@4.0.0)
+      '@angular-devkit/core': 20.3.42(chokidar@4.0.0)
+      '@angular-devkit/schematics': 20.3.42(chokidar@4.0.0)
+      '@inquirer/prompts': 7.8.2(@types/node@26.6.6)
+      '@listr2/prompt-adapter-inquirer': 3.0.1(@inquirer/prompts@7.8.2(@types/node@26.6.6))(@types/node@26.6.6)(listr2@9.0.1)
+      '@modelcontextprotocol/sdk': 1.32.1(supports-color@7.1.0)(zod@4.6.6)
+      '@schematics/angular': 20.3.42(chokidar@4.0.0)
+      '@yarnpkg/lockfile': 1.1.0
+      algoliasearch: 5.35.0
+      ini: 5.0.0
+      jsonc-parser: 3.3.1
+      listr2: 9.0.1
+      npm-package-arg: 13.0.0
+      pacote: 21.5.1(supports-color@7.1.0)
+      resolve: 1.22.10
+      semver: 7.7.2
+      yargs: 18.0.0
+      zod: 4.6.6
+    transitivePeerDependencies:
+      - '@cfworker/json-schema'
+      - '@types/node'
+      - chokidar
+      - supports-color
+
+  '@angular/common@20.3.34(@angular/core@20.3.34(@angular/compiler@20.3.34)(rxjs@7.8.0)(zone.js@0.15.0))(rxjs@7.8.0)':
+    dependencies:
+      '@angular/core': 20.3.34(@angular/compiler@20.3.34)(rxjs@7.8.0)(zone.js@0.15.0)
+      rxjs: 7.8.0
+      tslib: 2.8.1
+
+  '@angular/compiler-cli@20.3.34(@angular/compiler@20.3.34)(supports-color@7.1.0)(typescript@5.9.2)':
+    dependencies:
+      '@angular/compiler': 20.3.34
+      '@babel/core': 7.29.7(supports-color@7.1.0)
+      '@jridgewell/sourcemap-codec': 1.6.0
+      chokidar: 4.0.0
+      convert-source-map: 1.9.0
+      reflect-metadata: 0.2.2
+      semver: 7.8.5
+      tslib: 2.8.1
+      yargs: 18.2.0
+    optionalDependencies:
+      typescript: 5.9.2
+    transitivePeerDependencies:
+      - supports-color
+
+  '@angular/compiler@20.3.34':
+    dependencies:
+      tslib: 2.8.1
+
+  '@angular/core@20.3.34(@angular/compiler@20.3.34)(rxjs@7.8.0)(zone.js@0.15.0)':
+    dependencies:
+      rxjs: 7.8.0
+      tslib: 2.8.1
+    optionalDependencies:
+      '@angular/compiler': 20.3.34
+      zone.js: 0.15.0
+
+  '@angular/forms@20.3.34(@angular/common@20.3.34(@angular/core@20.3.34(@angular/compiler@20.3.34)(rxjs@7.8.0)(zone.js@0.15.0))(rxjs@7.8.0))(@angular/core@20.3.34(@angular/compiler@20.3.34)(rxjs@7.8.0)(zone.js@0.15.0))(@angular/platform-browser@20.3.34(@angular/common@20.3.34(@angular/core@20.3.34(@angular/compiler@20.3.34)(rxjs@7.8.0)(zone.js@0.15.0))(rxjs@7.8.0))(@angular/core@20.3.34(@angular/compiler@20.3.34)(rxjs@7.8.0)(zone.js@0.15.0)))(rxjs@7.8.0)':
+    dependencies:
+      '@angular/common': 20.3.34(@angular/core@20.3.34(@angular/compiler@20.3.34)(rxjs@7.8.0)(zone.js@0.15.0))(rxjs@7.8.0)
+      '@angular/core': 20.3.34(@angular/compiler@20.3.34)(rxjs@7.8.0)(zone.js@0.15.0)
+      '@angular/platform-browser': 20.3.34(@angular/common@20.3.34(@angular/core@20.3.34(@angular/compiler@20.3.34)(rxjs@7.8.0)(zone.js@0.15.0))(rxjs@7.8.0))(@angular/core@20.3.34(@angular/compiler@20.3.34)(rxjs@7.8.0)(zone.js@0.15.0))
+      rxjs: 7.8.0
+      tslib: 2.8.1
+
+  '@angular/platform-browser@20.3.34(@angular/common@20.3.34(@angular/core@20.3.34(@angular/compiler@20.3.34)(rxjs@7.8.0)(zone.js@0.15.0))(rxjs@7.8.0))(@angular/core@20.3.34(@angular/compiler@20.3.34)(rxjs@7.8.0)(zone.js@0.15.0))':
+    dependencies:
+      '@angular/common': 20.3.34(@angular/core@20.3.34(@angular/compiler@20.3.34)(rxjs@7.8.0)(zone.js@0.15.0))(rxjs@7.8.0)
+      '@angular/core': 20.3.34(@angular/compiler@20.3.34)(rxjs@7.8.0)(zone.js@0.15.0)
+      tslib: 2.8.1
+
+  '@angular/router@20.3.34(@angular/common@20.3.34(@angular/core@20.3.34(@angular/compiler@20.3.34)(rxjs@7.8.0)(zone.js@0.15.0))(rxjs@7.8.0))(@angular/core@20.3.34(@angular/compiler@20.3.34)(rxjs@7.8.0)(zone.js@0.15.0))(@angular/platform-browser@20.3.34(@angular/common@20.3.34(@angular/core@20.3.34(@angular/compiler@20.3.34)(rxjs@7.8.0)(zone.js@0.15.0))(rxjs@7.8.0))(@angular/core@20.3.34(@angular/compiler@20.3.34)(rxjs@7.8.0)(zone.js@0.15.0)))(rxjs@7.8.0)':
+    dependencies:
+      '@angular/common': 20.3.34(@angular/core@20.3.34(@angular/compiler@20.3.34)(rxjs@7.8.0)(zone.js@0.15.0))(rxjs@7.8.0)
+      '@angular/core': 20.3.34(@angular/compiler@20.3.34)(rxjs@7.8.0)(zone.js@0.15.0)
+      '@angular/platform-browser': 20.3.34(@angular/common@20.3.34(@angular/core@20.3.34(@angular/compiler@20.3.34)(rxjs@7.8.0)(zone.js@0.15.0))(rxjs@7.8.0))(@angular/core@20.3.34(@angular/compiler@20.3.34)(rxjs@7.8.0)(zone.js@0.15.0))
+      rxjs: 7.8.0
+      tslib: 2.8.1
+
+  '@antfu/install-pkg@2.1.0':
+    dependencies:
+      package-manager-detector: 1.9.0
+      tinyexec: 1.3.1
+    optional: true
+
+  '@aws-sdk/checksums@3.1001.7':
+    dependencies:
+      '@aws-sdk/core': 3.978.7
+      '@aws-sdk/types': 3.975.0
+      '@smithy/core': 3.37.0
+      '@smithy/types': 4.19.0
+      tslib: 2.8.1
+
+  '@aws-sdk/client-s3@3.1159.0':
+    dependencies:
+      '@aws-sdk/checksums': 3.1001.7
+      '@aws-sdk/core': 3.978.7
+      '@aws-sdk/credential-provider-node': 3.972.90
+      '@aws-sdk/middleware-sdk-s3': 3.972.83
+      '@aws-sdk/signature-v4-multi-region': 3.996.50
+      '@aws-sdk/types': 3.975.0
+      '@smithy/core': 3.37.0
+      '@smithy/fetch-http-handler': 5.8.0
+      '@smithy/node-http-handler': 4.13.0
+      '@smithy/types': 4.19.0
+      tslib: 2.8.1
+
+  '@aws-sdk/core@3.978.7':
+    dependencies:
+      '@aws-sdk/types': 3.975.0
+      '@aws-sdk/xml-builder': 3.972.42
+      '@aws/lambda-invoke-store': 0.3.0
+      '@smithy/core': 3.37.0
+      '@smithy/signature-v4': 5.7.4
+      '@smithy/types': 4.19.0
+      bowser: 2.15.0
+      tslib: 2.8.1
+
+  '@aws-sdk/credential-provider-env@3.972.78':
+    dependencies:
+      '@aws-sdk/core': 3.978.7
+      '@aws-sdk/types': 3.975.0
+      '@smithy/core': 3.37.0
+      '@smithy/types': 4.19.0
+      tslib: 2.8.1
+
+  '@aws-sdk/credential-provider-http@3.972.80':
+    dependencies:
+      '@aws-sdk/core': 3.978.7
+      '@aws-sdk/types': 3.975.0
+      '@smithy/core': 3.37.0
+      '@smithy/fetch-http-handler': 5.8.0
+      '@smithy/node-http-handler': 4.13.0
+      '@smithy/types': 4.19.0
+      tslib: 2.8.1
+
+  '@aws-sdk/credential-provider-ini@3.973.23':
+    dependencies:
+      '@aws-sdk/core': 3.978.7
+      '@aws-sdk/credential-provider-env': 3.972.78
+      '@aws-sdk/credential-provider-http': 3.972.80
+      '@aws-sdk/credential-provider-login': 3.972.85
+      '@aws-sdk/credential-provider-process': 3.972.78
+      '@aws-sdk/credential-provider-sso': 3.973.22
+      '@aws-sdk/credential-provider-web-identity': 3.972.84
+      '@aws-sdk/nested-clients': 3.997.52
+      '@aws-sdk/types': 3.975.0
+      '@smithy/core': 3.37.0
+      '@smithy/credential-provider-imds': 4.5.2
+      '@smithy/types': 4.19.0
+      tslib: 2.8.1
+
+  '@aws-sdk/credential-provider-login@3.972.85':
+    dependencies:
+      '@aws-sdk/core': 3.978.7
+      '@aws-sdk/nested-clients': 3.997.52
+      '@aws-sdk/types': 3.975.0
+      '@smithy/core': 3.37.0
+      '@smithy/types': 4.19.0
+      tslib: 2.8.1
+
+  '@aws-sdk/credential-provider-node@3.972.90':
+    dependencies:
+      '@aws-sdk/credential-provider-env': 3.972.78
+      '@aws-sdk/credential-provider-http': 3.972.80
+      '@aws-sdk/credential-provider-ini': 3.973.23
+      '@aws-sdk/credential-provider-process': 3.972.78
+      '@aws-sdk/credential-provider-sso': 3.973.22
+      '@aws-sdk/credential-provider-web-identity': 3.972.84
+      '@aws-sdk/types': 3.975.0
+      '@smithy/core': 3.37.0
+      '@smithy/credential-provider-imds': 4.5.2
+      '@smithy/types': 4.19.0
+      tslib: 2.8.1
+
+  '@aws-sdk/credential-provider-process@3.972.78':
+    dependencies:
+      '@aws-sdk/core': 3.978.7
+      '@aws-sdk/types': 3.975.0
+      '@smithy/core': 3.37.0
+      '@smithy/types': 4.19.0
+      tslib: 2.8.1
+
+  '@aws-sdk/credential-provider-sso@3.973.22':
+    dependencies:
+      '@aws-sdk/core': 3.978.7
+      '@aws-sdk/nested-clients': 3.997.52
+      '@aws-sdk/token-providers': 3.1159.0
+      '@aws-sdk/types': 3.975.0
+      '@smithy/core': 3.37.0
+      '@smithy/types': 4.19.0
+      tslib: 2.8.1
+
+  '@aws-sdk/credential-provider-web-identity@3.972.84':
+    dependencies:
+      '@aws-sdk/core': 3.978.7
+      '@aws-sdk/nested-clients': 3.997.52
+      '@aws-sdk/types': 3.975.0
+      '@smithy/core': 3.37.0
+      '@smithy/types': 4.19.0
+      tslib: 2.8.1
+
+  '@aws-sdk/middleware-sdk-s3@3.972.83':
+    dependencies:
+      '@aws-sdk/core': 3.978.7
+      '@aws-sdk/signature-v4-multi-region': 3.996.50
+      '@aws-sdk/types': 3.975.0
+      '@smithy/core': 3.37.0
+      '@smithy/types': 4.19.0
+      tslib: 2.8.1
+
+  '@aws-sdk/nested-clients@3.997.52':
+    dependencies:
+      '@aws-sdk/core': 3.978.7
+      '@aws-sdk/signature-v4-multi-region': 3.996.50
+      '@aws-sdk/types': 3.975.0
+      '@smithy/core': 3.37.0
+      '@smithy/fetch-http-handler': 5.8.0
+      '@smithy/node-http-handler': 4.13.0
+      '@smithy/types': 4.19.0
+      tslib: 2.8.1
+
+  '@aws-sdk/s3-request-presigner@3.1159.0':
+    dependencies:
+      '@aws-sdk/core': 3.978.7
+      '@aws-sdk/signature-v4-multi-region': 3.996.50
+      '@aws-sdk/types': 3.975.0
+      '@smithy/core': 3.37.0
+      '@smithy/types': 4.19.0
+      tslib: 2.8.1
+
+  '@aws-sdk/signature-v4-multi-region@3.996.50':
+    dependencies:
+      '@aws-sdk/types': 3.975.0
+      '@smithy/signature-v4': 5.7.4
+      '@smithy/types': 4.19.0
+      tslib: 2.8.1
+
+  '@aws-sdk/token-providers@3.1159.0':
+    dependencies:
+      '@aws-sdk/core': 3.978.7
+      '@aws-sdk/nested-clients': 3.997.52
+      '@aws-sdk/types': 3.975.0
+      '@smithy/core': 3.37.0
+      '@smithy/types': 4.19.0
+      tslib: 2.8.1
+
+  '@aws-sdk/types@3.975.0':
+    dependencies:
+      '@smithy/types': 4.19.0
+      tslib: 2.8.1
+
+  '@aws-sdk/xml-builder@3.972.42':
+    dependencies:
+      '@smithy/types': 4.19.0
+      tslib: 2.8.1
+
+  '@aws/lambda-invoke-store@0.3.0': {}
+
+  '@babel/code-frame@7.29.7':
+    dependencies:
+      '@babel/helper-validator-identifier': 7.29.7
+      js-tokens: 4.0.0
+      picocolors: 1.1.1
+
+  '@babel/compat-data@7.29.7': {}
+
+  '@babel/core@7.29.7(supports-color@7.1.0)':
+    dependencies:
+      '@babel/code-frame': 7.29.7
+      '@babel/generator': 7.29.8
+      '@babel/helper-compilation-targets': 7.29.7
+      '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7(supports-color@7.1.0))(supports-color@7.1.0)
+      '@babel/helpers': 7.29.10
+      '@babel/parser': 7.29.11
+      '@babel/template': 7.29.7
+      '@babel/traverse': 7.29.10(supports-color@7.1.0)
+      '@babel/types': 7.29.11
+      '@jridgewell/remapping': 2.3.5
+      convert-source-map: 2.0.0
+      debug: 4.4.3(supports-color@7.1.0)
+      gensync: 1.0.0-beta.2
+      json5: 2.2.3
+      semver: 6.3.1
+    transitivePeerDependencies:
+      - supports-color
+
+  '@babel/generator@7.29.8':
+    dependencies:
+      '@babel/parser': 7.29.11
+      '@babel/types': 7.29.11
+      '@jridgewell/gen-mapping': 0.3.12
+      '@jridgewell/trace-mapping': 0.3.31
+      jsesc: 3.1.0
+
+  '@babel/helper-annotate-as-pure@7.27.3':
+    dependencies:
+      '@babel/types': 7.29.11
+
+  '@babel/helper-compilation-targets@7.29.7':
+    dependencies:
+      '@babel/compat-data': 7.29.7
+      '@babel/helper-validator-option': 7.29.7
+      browserslist: 4.29.5
+      lru-cache: 5.1.1
+      semver: 6.3.1
+
+  '@babel/helper-globals@7.29.7': {}
+
+  '@babel/helper-module-imports@7.29.7(supports-color@7.1.0)':
+    dependencies:
+      '@babel/traverse': 7.29.10(supports-color@7.1.0)
+      '@babel/types': 7.29.11
+    transitivePeerDependencies:
+      - supports-color
+
+  '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7(supports-color@7.1.0))(supports-color@7.1.0)':
+    dependencies:
+      '@babel/core': 7.29.7(supports-color@7.1.0)
+      '@babel/helper-module-imports': 7.29.7(supports-color@7.1.0)
+      '@babel/helper-validator-identifier': 7.29.7
+      '@babel/traverse': 7.29.10(supports-color@7.1.0)
+    transitivePeerDependencies:
+      - supports-color
+
+  '@babel/helper-split-export-declaration@7.24.7':
+    dependencies:
+      '@babel/types': 7.29.11
+
+  '@babel/helper-string-parser@7.29.11': {}
+
+  '@babel/helper-validator-identifier@7.29.7': {}
+
+  '@babel/helper-validator-option@7.29.7': {}
+
+  '@babel/helpers@7.29.10':
+    dependencies:
+      '@babel/template': 7.29.7
+      '@babel/types': 7.29.11
+
+  '@babel/parser@7.29.11':
+    dependencies:
+      '@babel/types': 7.29.11
+
+  '@babel/runtime-corejs2@7.29.7':
+    dependencies:
+      core-js: 2.6.12
+
+  '@babel/runtime@8.0.8': {}
+
+  '@babel/template@7.29.7':
+    dependencies:
+      '@babel/code-frame': 7.29.7
+      '@babel/parser': 7.29.11
+      '@babel/types': 7.29.11
+
+  '@babel/traverse@7.29.10(supports-color@7.1.0)':
+    dependencies:
+      '@babel/code-frame': 7.29.7
+      '@babel/generator': 7.29.8
+      '@babel/helper-globals': 7.29.7
+      '@babel/parser': 7.29.11
+      '@babel/template': 7.29.7
+      '@babel/types': 7.29.11
+      debug: 4.4.3(supports-color@7.1.0)
+    transitivePeerDependencies:
+      - supports-color
+
+  '@babel/types@7.29.11':
+    dependencies:
+      '@babel/helper-string-parser': 7.29.11
+      '@babel/helper-validator-identifier': 7.29.7
+
+  '@braintree/sanitize-url@7.1.2':
+    optional: true
+
+  '@cfworker/json-schema@4.0.2': {}
+
+  '@chevrotain/types@11.1.2':
+    optional: true
+
+  '@colors/colors@1.5.0': {}
+
+  '@esbuild/aix-ppc64@0.28.1':
+    optional: true
+
+  '@esbuild/android-arm64@0.28.1':
+    optional: true
+
+  '@esbuild/android-arm@0.28.1':
+    optional: true
+
+  '@esbuild/android-x64@0.28.1':
+    optional: true
+
+  '@esbuild/darwin-arm64@0.28.1':
+    optional: true
+
+  '@esbuild/darwin-x64@0.28.1':
+    optional: true
+
+  '@esbuild/freebsd-arm64@0.28.1':
+    optional: true
+
+  '@esbuild/freebsd-x64@0.28.1':
+    optional: true
+
+  '@esbuild/linux-arm64@0.28.1':
+    optional: true
+
+  '@esbuild/linux-arm@0.28.1':
+    optional: true
+
+  '@esbuild/linux-ia32@0.28.1':
+    optional: true
+
+  '@esbuild/linux-loong64@0.28.1':
+    optional: true
+
+  '@esbuild/linux-mips64el@0.28.1':
+    optional: true
+
+  '@esbuild/linux-ppc64@0.28.1':
+    optional: true
+
+  '@esbuild/linux-riscv64@0.28.1':
+    optional: true
+
+  '@esbuild/linux-s390x@0.28.1':
+    optional: true
+
+  '@esbuild/linux-x64@0.28.1':
+    optional: true
+
+  '@esbuild/netbsd-arm64@0.28.1':
+    optional: true
+
+  '@esbuild/netbsd-x64@0.28.1':
+    optional: true
+
+  '@esbuild/openbsd-arm64@0.28.1':
+    optional: true
+
+  '@esbuild/openbsd-x64@0.28.1':
+    optional: true
+
+  '@esbuild/openharmony-arm64@0.28.1':
+    optional: true
+
+  '@esbuild/sunos-x64@0.28.1':
+    optional: true
+
+  '@esbuild/win32-arm64@0.28.1':
+    optional: true
+
+  '@esbuild/win32-ia32@0.28.1':
+    optional: true
+
+  '@esbuild/win32-x64@0.28.1':
+    optional: true
+
+  '@fortawesome/fontawesome-free@7.3.1': {}
+
+  '@gar/promise-retry@1.0.3': {}
+
+  '@hono/node-server@2.1.5(hono@4.13.14)':
+    dependencies:
+      hono: 4.13.14
+
+  '@iconify/types@2.0.0':
+    optional: true
+
+  '@iconify/utils@3.1.7':
+    dependencies:
+      '@antfu/install-pkg': 2.1.0
+      '@iconify/types': 2.0.0
+      import-meta-resolve: 4.2.0
+    optional: true
+
+  '@inquirer/checkbox@4.2.1(@types/node@26.6.6)':
+    dependencies:
+      '@inquirer/core': 10.1.15(@types/node@26.6.6)
+      '@inquirer/figures': 1.0.13
+      '@inquirer/type': 3.0.8(@types/node@26.6.6)
+      ansi-escapes: 4.3.2
+      yoctocolors-cjs: 2.1.3
+    optionalDependencies:
+      '@types/node': 26.6.6
+
+  '@inquirer/confirm@5.1.14(@types/node@26.6.6)':
+    dependencies:
+      '@inquirer/core': 10.1.15(@types/node@26.6.6)
+      '@inquirer/type': 3.0.8(@types/node@26.6.6)
+    optionalDependencies:
+      '@types/node': 26.6.6
+
+  '@inquirer/core@10.1.15(@types/node@26.6.6)':
+    dependencies:
+      '@inquirer/figures': 1.0.13
+      '@inquirer/type': 3.0.8(@types/node@26.6.6)
+      ansi-escapes: 4.3.2
+      cli-width: 4.1.0
+      mute-stream: 2.0.0
+      signal-exit: 4.1.0
+      wrap-ansi: 6.2.0
+      yoctocolors-cjs: 2.1.3
+    optionalDependencies:
+      '@types/node': 26.6.6
+
+  '@inquirer/editor@4.2.17(@types/node@26.6.6)':
+    dependencies:
+      '@inquirer/core': 10.1.15(@types/node@26.6.6)
+      '@inquirer/external-editor': 1.0.1(@types/node@26.6.6)
+      '@inquirer/type': 3.0.8(@types/node@26.6.6)
+    optionalDependencies:
+      '@types/node': 26.6.6
+
+  '@inquirer/expand@4.0.17(@types/node@26.6.6)':
+    dependencies:
+      '@inquirer/core': 10.1.15(@types/node@26.6.6)
+      '@inquirer/type': 3.0.8(@types/node@26.6.6)
+      yoctocolors-cjs: 2.1.3
+    optionalDependencies:
+      '@types/node': 26.6.6
+
+  '@inquirer/external-editor@1.0.1(@types/node@26.6.6)':
+    dependencies:
+      chardet: 2.2.0
+      iconv-lite: 0.6.3
+    optionalDependencies:
+      '@types/node': 26.6.6
+
+  '@inquirer/figures@1.0.13': {}
+
+  '@inquirer/input@4.2.1(@types/node@26.6.6)':
+    dependencies:
+      '@inquirer/core': 10.1.15(@types/node@26.6.6)
+      '@inquirer/type': 3.0.8(@types/node@26.6.6)
+    optionalDependencies:
+      '@types/node': 26.6.6
+
+  '@inquirer/number@3.0.17(@types/node@26.6.6)':
+    dependencies:
+      '@inquirer/core': 10.1.15(@types/node@26.6.6)
+      '@inquirer/type': 3.0.8(@types/node@26.6.6)
+    optionalDependencies:
+      '@types/node': 26.6.6
+
+  '@inquirer/password@4.0.17(@types/node@26.6.6)':
+    dependencies:
+      '@inquirer/core': 10.1.15(@types/node@26.6.6)
+      '@inquirer/type': 3.0.8(@types/node@26.6.6)
+      ansi-escapes: 4.3.2
+    optionalDependencies:
+      '@types/node': 26.6.6
+
+  '@inquirer/prompts@7.8.2(@types/node@26.6.6)':
+    dependencies:
+      '@inquirer/checkbox': 4.2.1(@types/node@26.6.6)
+      '@inquirer/confirm': 5.1.14(@types/node@26.6.6)
+      '@inquirer/editor': 4.2.17(@types/node@26.6.6)
+      '@inquirer/expand': 4.0.17(@types/node@26.6.6)
+      '@inquirer/input': 4.2.1(@types/node@26.6.6)
+      '@inquirer/number': 3.0.17(@types/node@26.6.6)
+      '@inquirer/password': 4.0.17(@types/node@26.6.6)
+      '@inquirer/rawlist': 4.1.5(@types/node@26.6.6)
+      '@inquirer/search': 3.1.0(@types/node@26.6.6)
+      '@inquirer/select': 4.3.1(@types/node@26.6.6)
+    optionalDependencies:
+      '@types/node': 26.6.6
+
+  '@inquirer/rawlist@4.1.5(@types/node@26.6.6)':
+    dependencies:
+      '@inquirer/core': 10.1.15(@types/node@26.6.6)
+      '@inquirer/type': 3.0.8(@types/node@26.6.6)
+      yoctocolors-cjs: 2.1.3
+    optionalDependencies:
+      '@types/node': 26.6.6
+
+  '@inquirer/search@3.1.0(@types/node@26.6.6)':
+    dependencies:
+      '@inquirer/core': 10.1.15(@types/node@26.6.6)
+      '@inquirer/figures': 1.0.13
+      '@inquirer/type': 3.0.8(@types/node@26.6.6)
+      yoctocolors-cjs: 2.1.3
+    optionalDependencies:
+      '@types/node': 26.6.6
+
+  '@inquirer/select@4.3.1(@types/node@26.6.6)':
+    dependencies:
+      '@inquirer/core': 10.1.15(@types/node@26.6.6)
+      '@inquirer/figures': 1.0.13
+      '@inquirer/type': 3.0.8(@types/node@26.6.6)
+      ansi-escapes: 4.3.2
+      yoctocolors-cjs: 2.1.3
+    optionalDependencies:
+      '@types/node': 26.6.6
+
+  '@inquirer/type@3.0.7(@types/node@26.6.6)':
+    optionalDependencies:
+      '@types/node': 26.6.6
+
+  '@inquirer/type@3.0.8(@types/node@26.6.6)':
+    optionalDependencies:
+      '@types/node': 26.6.6
+
+  '@isaacs/fs-minipass@4.0.1':
+    dependencies:
+      minipass: 7.1.3
+
+  '@istanbuljs/schema@0.1.6': {}
+
+  '@jridgewell/gen-mapping@0.3.12':
+    dependencies:
+      '@jridgewell/sourcemap-codec': 1.6.0
+      '@jridgewell/trace-mapping': 0.3.31
+
+  '@jridgewell/gen-mapping@0.3.2':
+    dependencies:
+      '@jridgewell/set-array': 1.2.1
+      '@jridgewell/sourcemap-codec': 1.6.0
+      '@jridgewell/trace-mapping': 0.3.31
+
+  '@jridgewell/gen-mapping@0.3.5':
+    dependencies:
+      '@jridgewell/set-array': 1.2.1
+      '@jridgewell/sourcemap-codec': 1.6.0
+      '@jridgewell/trace-mapping': 0.3.31
+
+  '@jridgewell/remapping@2.3.5':
+    dependencies:
+      '@jridgewell/gen-mapping': 0.3.5
+      '@jridgewell/trace-mapping': 0.3.31
+
+  '@jridgewell/resolve-uri@3.1.2': {}
+
+  '@jridgewell/set-array@1.2.1': {}
+
+  '@jridgewell/sourcemap-codec@1.6.0': {}
+
+  '@jridgewell/trace-mapping@0.3.31':
+    dependencies:
+      '@jridgewell/resolve-uri': 3.1.2
+      '@jridgewell/sourcemap-codec': 1.6.0
+
+  '@langchain/core@0.3.80':
+    dependencies:
+      '@cfworker/json-schema': 4.0.2
+      ansi-styles: 5.0.0
+      camelcase: 6.0.0
+      decamelize: 1.2.0
+      js-tiktoken: 1.0.21
+      langsmith: 0.3.87
+      mustache: 4.2.0
+      p-queue: 6.6.2
+      p-retry: 4.0.0
+      uuid: 10.0.0
+      zod: 3.25.32
+      zod-to-json-schema: 3.25.2(zod@3.25.32)
+    transitivePeerDependencies:
+      - '@opentelemetry/api'
+      - '@opentelemetry/exporter-trace-otlp-proto'
+      - '@opentelemetry/sdk-trace-base'
+      - openai
+
+  '@listr2/prompt-adapter-inquirer@3.0.1(@inquirer/prompts@7.8.2(@types/node@26.6.6))(@types/node@26.6.6)(listr2@9.0.1)':
+    dependencies:
+      '@inquirer/prompts': 7.8.2(@types/node@26.6.6)
+      '@inquirer/type': 3.0.7(@types/node@26.6.6)
+      listr2: 9.0.1
+    transitivePeerDependencies:
+      - '@types/node'
+
+  '@lmdb/lmdb-darwin-arm64@3.4.2':
+    optional: true
+
+  '@lmdb/lmdb-darwin-x64@3.4.2':
+    optional: true
+
+  '@lmdb/lmdb-linux-arm64@3.4.2':
+    optional: true
+
+  '@lmdb/lmdb-linux-arm@3.4.2':
+    optional: true
+
+  '@lmdb/lmdb-linux-x64@3.4.2':
+    optional: true
+
+  '@lmdb/lmdb-win32-arm64@3.4.2':
+    optional: true
+
+  '@lmdb/lmdb-win32-x64@3.4.2':
+    optional: true
+
+  '@matrix-org/matrix-sdk-crypto-wasm@18.9.0': {}
+
+  '@mermaid-js/parser@1.2.1':
+    dependencies:
+      '@chevrotain/types': 11.1.2
+    optional: true
+
+  '@modelcontextprotocol/sdk@1.32.1(supports-color@7.1.0)(zod@4.6.6)':
+    dependencies:
+      '@hono/node-server': 2.1.5(hono@4.13.14)
+      ajv: 8.20.0
+      ajv-formats: 3.0.1(ajv@8.20.0)
+      content-type: 1.0.5
+      cors: 2.8.6
+      cross-spawn: 7.0.6
+      eventsource: 3.0.2
+      eventsource-parser: 3.1.1
+      express: 5.3.0(supports-color@7.1.0)
+      express-rate-limit: 8.7.1(express@5.3.0(supports-color@7.1.0))(supports-color@7.1.0)
+      hono: 4.13.14
+      jose: 6.2.14
+      json-schema-typed: 8.0.2
+      pkce-challenge: 5.0.0
+      raw-body: 3.0.0
+      zod: 4.6.6
+      zod-to-json-schema: 3.25.2(zod@4.6.6)
+    transitivePeerDependencies:
+      - supports-color
+
+  '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4':
+    optional: true
+
+  '@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.4':
+    optional: true
+
+  '@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.4':
+    optional: true
+
+  '@msgpackr-extract/msgpackr-extract-linux-arm@3.0.4':
+    optional: true
+
+  '@msgpackr-extract/msgpackr-extract-linux-x64@3.0.4':
+    optional: true
+
+  '@msgpackr-extract/msgpackr-extract-win32-x64@3.0.4':
+    optional: true
+
+  '@napi-rs/canvas-android-arm64@1.0.13':
+    optional: true
+
+  '@napi-rs/canvas-darwin-arm64@1.0.13':
+    optional: true
+
+  '@napi-rs/canvas-darwin-x64@1.0.13':
+    optional: true
+
+  '@napi-rs/canvas-linux-arm-gnueabihf@1.0.13':
+    optional: true
+
+  '@napi-rs/canvas-linux-arm64-gnu@1.0.13':
+    optional: true
+
+  '@napi-rs/canvas-linux-arm64-musl@1.0.13':
+    optional: true
+
+  '@napi-rs/canvas-linux-riscv64-gnu@1.0.13':
+    optional: true
+
+  '@napi-rs/canvas-linux-x64-gnu@1.0.13':
+    optional: true
+
+  '@napi-rs/canvas-linux-x64-musl@1.0.13':
+    optional: true
+
+  '@napi-rs/canvas-win32-arm64-msvc@1.0.13':
+    optional: true
+
+  '@napi-rs/canvas-win32-x64-msvc@1.0.13':
+    optional: true
+
+  '@napi-rs/canvas@1.0.13':
+    optionalDependencies:
+      '@napi-rs/canvas-android-arm64': 1.0.13
+      '@napi-rs/canvas-darwin-arm64': 1.0.13
+      '@napi-rs/canvas-darwin-x64': 1.0.13
+      '@napi-rs/canvas-linux-arm-gnueabihf': 1.0.13
+      '@napi-rs/canvas-linux-arm64-gnu': 1.0.13
+      '@napi-rs/canvas-linux-arm64-musl': 1.0.13
+      '@napi-rs/canvas-linux-riscv64-gnu': 1.0.13
+      '@napi-rs/canvas-linux-x64-gnu': 1.0.13
+      '@napi-rs/canvas-linux-x64-musl': 1.0.13
+      '@napi-rs/canvas-win32-arm64-msvc': 1.0.13
+      '@napi-rs/canvas-win32-x64-msvc': 1.0.13
+    optional: true
+
+  '@napi-rs/nice-android-arm-eabi@1.1.1':
+    optional: true
+
+  '@napi-rs/nice-android-arm64@1.1.1':
+    optional: true
+
+  '@napi-rs/nice-darwin-arm64@1.1.1':
+    optional: true
+
+  '@napi-rs/nice-darwin-x64@1.1.1':
+    optional: true
+
+  '@napi-rs/nice-freebsd-x64@1.1.1':
+    optional: true
+
+  '@napi-rs/nice-linux-arm-gnueabihf@1.1.1':
+    optional: true
+
+  '@napi-rs/nice-linux-arm64-gnu@1.1.1':
+    optional: true
+
+  '@napi-rs/nice-linux-arm64-musl@1.1.1':
+    optional: true
+
+  '@napi-rs/nice-linux-ppc64-gnu@1.1.1':
+    optional: true
+
+  '@napi-rs/nice-linux-riscv64-gnu@1.1.1':
+    optional: true
+
+  '@napi-rs/nice-linux-s390x-gnu@1.1.1':
+    optional: true
+
+  '@napi-rs/nice-linux-x64-gnu@1.1.1':
+    optional: true
+
+  '@napi-rs/nice-linux-x64-musl@1.1.1':
+    optional: true
+
+  '@napi-rs/nice-openharmony-arm64@1.1.1':
+    optional: true
+
+  '@napi-rs/nice-win32-arm64-msvc@1.1.1':
+    optional: true
+
+  '@napi-rs/nice-win32-ia32-msvc@1.1.1':
+    optional: true
+
+  '@napi-rs/nice-win32-x64-msvc@1.1.1':
+    optional: true
+
+  '@napi-rs/nice@1.1.1':
+    optionalDependencies:
+      '@napi-rs/nice-android-arm-eabi': 1.1.1
+      '@napi-rs/nice-android-arm64': 1.1.1
+      '@napi-rs/nice-darwin-arm64': 1.1.1
+      '@napi-rs/nice-darwin-x64': 1.1.1
+      '@napi-rs/nice-freebsd-x64': 1.1.1
+      '@napi-rs/nice-linux-arm-gnueabihf': 1.1.1
+      '@napi-rs/nice-linux-arm64-gnu': 1.1.1
+      '@napi-rs/nice-linux-arm64-musl': 1.1.1
+      '@napi-rs/nice-linux-ppc64-gnu': 1.1.1
+      '@napi-rs/nice-linux-riscv64-gnu': 1.1.1
+      '@napi-rs/nice-linux-s390x-gnu': 1.1.1
+      '@napi-rs/nice-linux-x64-gnu': 1.1.1
+      '@napi-rs/nice-linux-x64-musl': 1.1.1
+      '@napi-rs/nice-openharmony-arm64': 1.1.1
+      '@napi-rs/nice-win32-arm64-msvc': 1.1.1
+      '@napi-rs/nice-win32-ia32-msvc': 1.1.1
+      '@napi-rs/nice-win32-x64-msvc': 1.1.1
+    optional: true
+
+  '@nodelib/fs.scandir@2.1.5':
+    dependencies:
+      '@nodelib/fs.stat': 2.0.5
+      run-parallel: 1.2.0
+
+  '@nodelib/fs.stat@2.0.5': {}
+
+  '@nodelib/fs.walk@1.2.8':
+    dependencies:
+      '@nodelib/fs.scandir': 2.1.5
+      fastq: 1.20.3
+
+  '@npmcli/agent@4.0.2(supports-color@7.1.0)':
+    dependencies:
+      agent-base: 7.1.0(supports-color@7.1.0)
+      http-proxy-agent: 7.0.0(supports-color@7.1.0)
+      https-proxy-agent: 7.0.1(supports-color@7.1.0)
+      lru-cache: 11.5.3
+      socks-proxy-agent: 8.0.3(supports-color@7.1.0)
+    transitivePeerDependencies:
+      - supports-color
+
+  '@npmcli/fs@5.0.0':
+    dependencies:
+      semver: 7.8.5
+
+  '@npmcli/git@7.0.2':
+    dependencies:
+      '@gar/promise-retry': 1.0.3
+      '@npmcli/promise-spawn': 9.0.2
+      ini: 6.0.0
+      lru-cache: 11.5.3
+      npm-pick-manifest: 11.0.1
+      proc-log: 6.0.0
+      semver: 7.8.5
+      which: 6.0.1
+
+  '@npmcli/installed-package-contents@4.0.0':
+    dependencies:
+      npm-bundled: 5.0.0
+      npm-normalize-package-bin: 5.0.0
+
+  '@npmcli/node-gyp@5.0.0': {}
+
+  '@npmcli/package-json@7.0.5':
+    dependencies:
+      '@npmcli/git': 7.0.2
+      glob: 13.0.6
+      hosted-git-info: 9.0.3
+      json-parse-even-better-errors: 5.0.0
+      proc-log: 6.0.0
+      semver: 7.8.5
+      spdx-expression-parse: 4.0.0
+
+  '@npmcli/promise-spawn@9.0.2':
+    dependencies:
+      which: 6.0.1
+
+  '@npmcli/redact@3.0.0': {}
+
+  '@npmcli/redact@4.0.0': {}
+
+  '@npmcli/run-script@10.0.4':
+    dependencies:
+      '@npmcli/node-gyp': 5.0.0
+      '@npmcli/package-json': 7.0.5
+      '@npmcli/promise-spawn': 9.0.2
+      node-gyp: 12.4.0
+      proc-log: 6.0.0
+
+  '@parcel/watcher-android-arm64@2.6.0':
+    optional: true
+
+  '@parcel/watcher-darwin-arm64@2.6.0':
+    optional: true
+
+  '@parcel/watcher-darwin-x64@2.6.0':
+    optional: true
+
+  '@parcel/watcher-freebsd-x64@2.6.0':
+    optional: true
+
+  '@parcel/watcher-linux-arm-glibc@2.6.0':
+    optional: true
+
+  '@parcel/watcher-linux-arm-musl@2.6.0':
+    optional: true
+
+  '@parcel/watcher-linux-arm64-glibc@2.6.0':
+    optional: true
+
+  '@parcel/watcher-linux-arm64-musl@2.6.0':
+    optional: true
+
+  '@parcel/watcher-linux-x64-glibc@2.6.0':
+    optional: true
+
+  '@parcel/watcher-linux-x64-musl@2.6.0':
+    optional: true
+
+  '@parcel/watcher-win32-arm64@2.6.0':
+    optional: true
+
+  '@parcel/watcher-win32-x64@2.6.0':
+    optional: true
+
+  '@parcel/watcher@2.6.0':
+    dependencies:
+      detect-libc: 2.1.2
+      is-glob: 4.0.3
+      node-addon-api: 7.0.0
+      picomatch: 4.0.4
+    optionalDependencies:
+      '@parcel/watcher-android-arm64': 2.6.0
+      '@parcel/watcher-darwin-arm64': 2.6.0
+      '@parcel/watcher-darwin-x64': 2.6.0
+      '@parcel/watcher-freebsd-x64': 2.6.0
+      '@parcel/watcher-linux-arm-glibc': 2.6.0
+      '@parcel/watcher-linux-arm-musl': 2.6.0
+      '@parcel/watcher-linux-arm64-glibc': 2.6.0
+      '@parcel/watcher-linux-arm64-musl': 2.6.0
+      '@parcel/watcher-linux-x64-glibc': 2.6.0
+      '@parcel/watcher-linux-x64-musl': 2.6.0
+      '@parcel/watcher-win32-arm64': 2.6.0
+      '@parcel/watcher-win32-x64': 2.6.0
+    optional: true
+
+  '@rollup/rollup-android-arm-eabi@4.59.0':
+    optional: true
+
+  '@rollup/rollup-android-arm64@4.59.0':
+    optional: true
+
+  '@rollup/rollup-darwin-arm64@4.59.0':
+    optional: true
+
+  '@rollup/rollup-darwin-x64@4.59.0':
+    optional: true
+
+  '@rollup/rollup-freebsd-arm64@4.59.0':
+    optional: true
+
+  '@rollup/rollup-freebsd-x64@4.59.0':
+    optional: true
+
+  '@rollup/rollup-linux-arm-gnueabihf@4.59.0':
+    optional: true
+
+  '@rollup/rollup-linux-arm-musleabihf@4.59.0':
+    optional: true
+
+  '@rollup/rollup-linux-arm64-gnu@4.59.0':
+    optional: true
+
+  '@rollup/rollup-linux-arm64-musl@4.59.0':
+    optional: true
+
+  '@rollup/rollup-linux-loong64-gnu@4.59.0':
+    optional: true
+
+  '@rollup/rollup-linux-loong64-musl@4.59.0':
+    optional: true
+
+  '@rollup/rollup-linux-ppc64-gnu@4.59.0':
+    optional: true
+
+  '@rollup/rollup-linux-ppc64-musl@4.59.0':
+    optional: true
+
+  '@rollup/rollup-linux-riscv64-gnu@4.59.0':
+    optional: true
+
+  '@rollup/rollup-linux-riscv64-musl@4.59.0':
+    optional: true
+
+  '@rollup/rollup-linux-s390x-gnu@4.59.0':
+    optional: true
+
+  '@rollup/rollup-linux-x64-gnu@4.59.0':
+    optional: true
+
+  '@rollup/rollup-linux-x64-musl@4.59.0':
+    optional: true
+
+  '@rollup/rollup-openbsd-x64@4.59.0':
+    optional: true
+
+  '@rollup/rollup-openharmony-arm64@4.59.0':
+    optional: true
+
+  '@rollup/rollup-win32-arm64-msvc@4.59.0':
+    optional: true
+
+  '@rollup/rollup-win32-ia32-msvc@4.59.0':
+    optional: true
+
+  '@rollup/rollup-win32-x64-gnu@4.59.0':
+    optional: true
+
+  '@rollup/rollup-win32-x64-msvc@4.59.0':
+    optional: true
+
+  '@schematics/angular@20.3.42(chokidar@4.0.0)':
+    dependencies:
+      '@angular-devkit/core': 20.3.42(chokidar@4.0.0)
+      '@angular-devkit/schematics': 20.3.42(chokidar@4.0.0)
+      jsonc-parser: 3.3.1
+    transitivePeerDependencies:
+      - chokidar
+
+  '@sigstore/bundle@4.0.0':
+    dependencies:
+      '@sigstore/protobuf-specs': 0.5.2
+
+  '@sigstore/core@3.2.1': {}
+
+  '@sigstore/protobuf-specs@0.5.2': {}
+
+  '@sigstore/sign@4.1.1(supports-color@7.1.0)':
+    dependencies:
+      '@gar/promise-retry': 1.0.3
+      '@sigstore/bundle': 4.0.0
+      '@sigstore/core': 3.2.1
+      '@sigstore/protobuf-specs': 0.5.2
+      make-fetch-happen: 15.0.6(supports-color@7.1.0)
+      proc-log: 6.1.0
+    transitivePeerDependencies:
+      - supports-color
+
+  '@sigstore/tuf@4.0.2(supports-color@7.1.0)':
+    dependencies:
+      '@sigstore/protobuf-specs': 0.5.2
+      tuf-js: 4.1.0(supports-color@7.1.0)
+    transitivePeerDependencies:
+      - supports-color
+
+  '@sigstore/verify@3.1.1':
+    dependencies:
+      '@sigstore/bundle': 4.0.0
+      '@sigstore/core': 3.2.1
+      '@sigstore/protobuf-specs': 0.5.2
+
+  '@silurus/ooxml@0.66.3': {}
+
+  '@smithy/core@3.37.0':
+    dependencies:
+      '@smithy/types': 4.19.0
+      tslib: 2.8.1
+
+  '@smithy/credential-provider-imds@4.5.2':
+    dependencies:
+      '@smithy/core': 3.37.0
+      '@smithy/types': 4.19.0
+      tslib: 2.8.1
+
+  '@smithy/fetch-http-handler@5.8.0':
+    dependencies:
+      '@smithy/core': 3.37.0
+      '@smithy/types': 4.19.0
+      tslib: 2.8.1
+
+  '@smithy/node-http-handler@4.13.0':
+    dependencies:
+      '@smithy/core': 3.37.0
+      '@smithy/types': 4.19.0
+      tslib: 2.8.1
+
+  '@smithy/signature-v4@5.7.4':
+    dependencies:
+      '@smithy/core': 3.37.0
+      '@smithy/types': 4.19.0
+      tslib: 2.8.1
+
+  '@smithy/types@4.19.0':
+    dependencies:
+      tslib: 2.8.1
+
+  '@socket.io/component-emitter@3.1.2': {}
+
+  '@tufjs/canonical-json@2.0.0': {}
+
+  '@tufjs/models@4.1.0':
+    dependencies:
+      '@tufjs/canonical-json': 2.0.0
+      minimatch: 10.2.6
+
+  '@types/cors@2.8.19':
+    dependencies:
+      '@types/node': 26.6.6
+
+  '@types/d3-array@3.2.2':
+    optional: true
+
+  '@types/d3-axis@3.0.6':
+    dependencies:
+      '@types/d3-selection': 3.0.12
+    optional: true
+
+  '@types/d3-brush@3.0.6':
+    dependencies:
+      '@types/d3-selection': 3.0.12
+    optional: true
+
+  '@types/d3-chord@3.0.6':
+    optional: true
+
+  '@types/d3-color@3.1.3':
+    optional: true
+
+  '@types/d3-contour@3.0.6':
+    dependencies:
+      '@types/d3-array': 3.2.2
+      '@types/geojson': 7946.0.16
+    optional: true
+
+  '@types/d3-delaunay@6.0.4':
+    optional: true
+
+  '@types/d3-dispatch@3.0.7':
+    optional: true
+
+  '@types/d3-drag@3.0.7':
+    dependencies:
+      '@types/d3-selection': 3.0.12
+    optional: true
+
+  '@types/d3-dsv@3.0.7':
+    optional: true
+
+  '@types/d3-ease@3.0.2':
+    optional: true
+
+  '@types/d3-fetch@3.0.7':
+    dependencies:
+      '@types/d3-dsv': 3.0.7
+    optional: true
+
+  '@types/d3-force@3.0.10':
+    optional: true
+
+  '@types/d3-format@3.0.4':
+    optional: true
+
+  '@types/d3-geo@3.1.1':
+    dependencies:
+      '@types/geojson': 7946.0.16
+    optional: true
+
+  '@types/d3-hierarchy@3.1.7':
+    optional: true
+
+  '@types/d3-interpolate@3.0.4':
+    dependencies:
+      '@types/d3-color': 3.1.3
+    optional: true
+
+  '@types/d3-path@3.1.1':
+    optional: true
+
+  '@types/d3-polygon@3.0.2':
+    optional: true
+
+  '@types/d3-quadtree@3.0.6':
+    optional: true
+
+  '@types/d3-random@3.0.4':
+    optional: true
+
+  '@types/d3-scale-chromatic@3.1.0':
+    optional: true
+
+  '@types/d3-scale@4.0.9':
+    dependencies:
+      '@types/d3-time': 3.0.4
+    optional: true
+
+  '@types/d3-selection@3.0.12':
+    optional: true
+
+  '@types/d3-shape@3.2.0':
+    dependencies:
+      '@types/d3-path': 3.1.1
+    optional: true
+
+  '@types/d3-time-format@4.0.3':
+    optional: true
+
+  '@types/d3-time@3.0.4':
+    optional: true
+
+  '@types/d3-timer@3.0.2':
+    optional: true
+
+  '@types/d3-transition@3.0.9':
+    dependencies:
+      '@types/d3-selection': 3.0.12
+    optional: true
+
+  '@types/d3-zoom@3.0.9':
+    dependencies:
+      '@types/d3-interpolate': 3.0.4
+      '@types/d3-selection': 3.0.12
+    optional: true
+
+  '@types/d3@7.4.3':
+    dependencies:
+      '@types/d3-array': 3.2.2
+      '@types/d3-axis': 3.0.6
+      '@types/d3-brush': 3.0.6
+      '@types/d3-chord': 3.0.6
+      '@types/d3-color': 3.1.3
+      '@types/d3-contour': 3.0.6
+      '@types/d3-delaunay': 6.0.4
+      '@types/d3-dispatch': 3.0.7
+      '@types/d3-drag': 3.0.7
+      '@types/d3-dsv': 3.0.7
+      '@types/d3-ease': 3.0.2
+      '@types/d3-fetch': 3.0.7
+      '@types/d3-force': 3.0.10
+      '@types/d3-format': 3.0.4
+      '@types/d3-geo': 3.1.1
+      '@types/d3-hierarchy': 3.1.7
+      '@types/d3-interpolate': 3.0.4
+      '@types/d3-path': 3.1.1
+      '@types/d3-polygon': 3.0.2
+      '@types/d3-quadtree': 3.0.6
+      '@types/d3-random': 3.0.4
+      '@types/d3-scale': 4.0.9
+      '@types/d3-scale-chromatic': 3.1.0
+      '@types/d3-selection': 3.0.12
+      '@types/d3-shape': 3.2.0
+      '@types/d3-time': 3.0.4
+      '@types/d3-time-format': 4.0.3
+      '@types/d3-timer': 3.0.2
+      '@types/d3-transition': 3.0.9
+      '@types/d3-zoom': 3.0.9
+    optional: true
+
+  '@types/estree@1.0.8': {}
+
+  '@types/events@3.0.3': {}
+
+  '@types/geojson@7946.0.16':
+    optional: true
+
+  '@types/jasmine@5.1.15': {}
+
+  '@types/node@26.6.6':
+    dependencies:
+      undici-types: 8.9.0
+
+  '@types/retry@0.12.5': {}
+
+  '@types/trusted-types@2.0.7':
+    optional: true
+
+  '@types/uuid@10.0.0': {}
+
+  '@types/ws@8.18.2':
+    dependencies:
+      '@types/node': 26.6.6
+
+  '@upsetjs/venn.js@2.0.0':
+    optionalDependencies:
+      d3-selection: 3.0.0
+      d3-transition: 3.0.1(d3-selection@3.0.0)
+    optional: true
+
+  '@vitejs/plugin-basic-ssl@2.1.0(vite@7.3.7(@types/node@26.6.6)(jiti@1.21.7)(sass@1.90.0))':
+    dependencies:
+      vite: 7.3.7(@types/node@26.6.6)(jiti@1.21.7)(sass@1.90.0)
+
+  '@wecom/jssdk@2.4.4': {}
+
+  '@yarnpkg/lockfile@1.1.0': {}
+
+  abbrev@4.0.0: {}
+
+  accepts@1.3.8:
+    dependencies:
+      mime-types: 2.1.34
+      negotiator: 0.6.3
+
+  accepts@2.0.0:
+    dependencies:
+      mime-types: 3.0.2
+      negotiator: 1.1.0
+
+  agent-base@7.1.0(supports-color@7.1.0):
+    dependencies:
+      debug: 4.4.3(supports-color@7.1.0)
+    transitivePeerDependencies:
+      - supports-color
+
+  agent-base@7.1.1(supports-color@7.1.0):
+    dependencies:
+      debug: 4.4.3(supports-color@7.1.0)
+    transitivePeerDependencies:
+      - supports-color
+
+  agent-base@7.1.2(supports-color@7.1.0):
+    dependencies:
+      debug: 4.4.3(supports-color@7.1.0)
+    transitivePeerDependencies:
+      - supports-color
+
+  ajv-formats@3.0.1(ajv@8.18.0):
+    optionalDependencies:
+      ajv: 8.18.0
+
+  ajv-formats@3.0.1(ajv@8.20.0):
+    optionalDependencies:
+      ajv: 8.20.0
+
+  ajv@8.18.0:
+    dependencies:
+      fast-deep-equal: 3.1.3
+      fast-uri: 3.1.9
+      json-schema-traverse: 1.0.0
+      require-from-string: 2.0.2
+
+  ajv@8.20.0:
+    dependencies:
+      fast-deep-equal: 3.1.3
+      fast-uri: 3.1.9
+      json-schema-traverse: 1.0.0
+      require-from-string: 2.0.2
+
+  algoliasearch@5.35.0:
+    dependencies:
+      '@algolia/abtesting': 1.1.0
+      '@algolia/client-abtesting': 5.35.0
+      '@algolia/client-analytics': 5.35.0
+      '@algolia/client-common': 5.35.0
+      '@algolia/client-insights': 5.35.0
+      '@algolia/client-personalization': 5.35.0
+      '@algolia/client-query-suggestions': 5.35.0
+      '@algolia/client-search': 5.35.0
+      '@algolia/ingestion': 1.35.0
+      '@algolia/monitoring': 1.35.0
+      '@algolia/recommend': 5.35.0
+      '@algolia/requester-browser-xhr': 5.35.0
+      '@algolia/requester-fetch': 5.35.0
+      '@algolia/requester-node-http': 5.35.0
+
+  another-json@0.2.0: {}
+
+  ansi-escapes@4.3.2:
+    dependencies:
+      type-fest: 0.21.3
+
+  ansi-escapes@7.3.0:
+    dependencies:
+      environment: 1.1.0
+
+  ansi-regex@4.1.0: {}
+
+  ansi-regex@5.0.0: {}
+
+  ansi-regex@6.4.0: {}
+
+  ansi-styles@4.0.0:
+    dependencies:
+      color-convert: 2.0.1
+
+  ansi-styles@4.1.0:
+    dependencies:
+      color-convert: 2.0.1
+
+  ansi-styles@5.0.0: {}
+
+  ansi-styles@6.0.0: {}
+
+  ansi-styles@6.2.1: {}
+
+  any-promise@1.3.0: {}
+
+  anymatch@3.1.3:
+    dependencies:
+      normalize-path: 3.0.0
+      picomatch: 2.3.2
+
+  arg@5.0.2: {}
+
+  autoprefixer@10.6.2(postcss@8.5.32):
+    dependencies:
+      browserslist: 4.29.5
+      caniuse-lite: 1.0.30001820
+      fraction.js: 5.3.4
+      picocolors: 1.1.1
+      postcss: 8.5.32
+      postcss-value-parser: 4.2.0
+
+  balanced-match@1.0.0: {}
+
+  balanced-match@4.0.4: {}
+
+  base-x@5.0.1: {}
+
+  base64-js@1.5.1: {}
+
+  baseline-browser-mapping@2.11.34: {}
+
+  beasties@0.3.5:
+    dependencies:
+      css-select: 6.0.0
+      css-what: 7.0.0
+      dom-serializer: 2.0.0
+      domhandler: 5.0.3
+      htmlparser2: 10.1.0
+      picocolors: 1.1.1
+      postcss: 8.5.32
+      postcss-media-query-parser: 0.2.3
+
+  binary-extensions@2.0.0: {}
+
+  body-parser@1.20.9(supports-color@7.1.0):
+    dependencies:
+      bytes: 3.1.2
+      content-type: 1.0.5
+      debug: 2.6.9(supports-color@7.1.0)
+      depd: 2.0.0
+      destroy: 1.2.0
+      http-errors: 2.0.1
+      iconv-lite: 0.4.24
+      on-finished: 2.4.2
+      qs: 6.16.2
+      raw-body: 2.5.3
+      type-is: 1.6.18
+      unpipe: 1.0.0
+    transitivePeerDependencies:
+      - supports-color
+
+  body-parser@2.4.0(supports-color@7.1.0):
+    dependencies:
+      bytes: 3.1.2
+      content-type: 2.1.0
+      debug: 4.4.3(supports-color@7.1.0)
+      http-errors: 2.0.1
+      iconv-lite: 0.7.3
+      on-finished: 2.4.2
+      qs: 6.17.1
+      raw-body: 3.0.2
+      type-is: 2.1.0
+    transitivePeerDependencies:
+      - supports-color
+
+  boolbase@1.0.0: {}
+
+  bowser@2.15.0: {}
+
+  brace-expansion@1.1.21:
+    dependencies:
+      balanced-match: 1.0.0
+      concat-map: 0.0.1
+
+  brace-expansion@5.0.12:
+    dependencies:
+      balanced-match: 4.0.4
+
+  braces@3.0.3:
+    dependencies:
+      fill-range: 7.1.1
+
+  browserslist@4.29.5:
+    dependencies:
+      baseline-browser-mapping: 2.11.34
+      caniuse-lite: 1.0.30001820
+      electron-to-chromium: 1.5.467
+      node-releases: 2.0.61
+      update-browserslist-db: 1.3.4(browserslist@4.29.5)
+
+  bs58@6.0.0:
+    dependencies:
+      base-x: 5.0.1
+
+  buffer-from@1.1.2: {}
+
+  bytes@3.1.2: {}
+
+  cacache@20.0.4:
+    dependencies:
+      '@npmcli/fs': 5.0.0
+      fs-minipass: 3.0.3
+      glob: 13.0.6
+      lru-cache: 11.5.3
+      minipass: 7.1.3
+      minipass-collect: 2.0.1
+      minipass-flush: 1.0.7
+      minipass-pipeline: 1.2.4
+      p-map: 7.1.1
+      ssri: 13.0.1
+
+  call-bind-apply-helpers@1.0.2:
+    dependencies:
+      es-errors: 1.3.0
+      function-bind: 1.1.2
+
+  call-bound@1.0.4:
+    dependencies:
+      call-bind-apply-helpers: 1.0.2
+      get-intrinsic: 1.3.0
+
+  camelcase-css@2.0.1: {}
+
+  camelcase@6.0.0: {}
+
+  caniuse-lite@1.0.30001820: {}
+
+  chalk@4.1.2:
+    dependencies:
+      ansi-styles: 4.1.0
+      supports-color: 7.1.0
+
+  chalk@5.3.0: {}
+
+  chardet@2.2.0: {}
+
+  chokidar@3.5.1:
+    dependencies:
+      anymatch: 3.1.3
+      braces: 3.0.3
+      glob-parent: 5.1.2
+      is-binary-path: 2.1.0
+      is-glob: 4.0.3
+      normalize-path: 3.0.0
+      readdirp: 3.5.0
+    optionalDependencies:
+      fsevents: 2.3.3
+
+  chokidar@3.6.0:
+    dependencies:
+      anymatch: 3.1.3
+      braces: 3.0.3
+      glob-parent: 5.1.2
+      is-binary-path: 2.1.0
+      is-glob: 4.0.3
+      normalize-path: 3.0.0
+      readdirp: 3.6.0
+    optionalDependencies:
+      fsevents: 2.3.3
+
+  chokidar@4.0.0:
+    dependencies:
+      readdirp: 4.0.1
+
+  chownr@3.0.0: {}
+
+  cli-cursor@5.0.0:
+    dependencies:
+      restore-cursor: 5.1.0
+
+  cli-spinners@2.9.2: {}
+
+  cli-truncate@4.0.0:
+    dependencies:
+      slice-ansi: 5.0.0
+      string-width: 7.0.0
+
+  cli-width@4.1.0: {}
+
+  clipboard@2.0.11:
+    dependencies:
+      good-listener: 1.2.2
+      select: 1.1.2
+      tiny-emitter: 2.1.0
+    optional: true
+
+  cliui@7.0.4:
+    dependencies:
+      string-width: 4.2.0
+      strip-ansi: 6.0.0
+      wrap-ansi: 7.0.0
+
+  cliui@9.0.1:
+    dependencies:
+      string-width: 7.2.0
+      strip-ansi: 7.2.0
+      wrap-ansi: 9.0.0
+
+  color-convert@2.0.1:
+    dependencies:
+      color-name: 1.1.4
+
+  color-name@1.1.4: {}
+
+  colorette@2.0.20: {}
+
+  commander@4.0.0: {}
+
+  commander@7.0.0:
+    optional: true
+
+  commander@8.3.0:
+    optional: true
+
+  concat-map@0.0.1: {}
+
+  connect@3.7.0(supports-color@7.1.0):
+    dependencies:
+      debug: 2.6.9(supports-color@7.1.0)
+      finalhandler: 1.1.2(supports-color@7.1.0)
+      parseurl: 1.3.3
+      utils-merge: 1.0.1
+    transitivePeerDependencies:
+      - supports-color
+
+  console-table-printer@2.16.1:
+    dependencies:
+      simple-wcswidth: 1.1.2
+
+  content-disposition@2.0.1: {}
+
+  content-type@1.0.5: {}
+
+  content-type@2.1.0: {}
+
+  content-type@3.1.1: {}
+
+  convert-source-map@1.9.0: {}
+
+  convert-source-map@2.0.0: {}
+
+  cookie-signature@1.2.2: {}
+
+  cookie@0.7.1: {}
+
+  cookie@0.7.2: {}
+
+  core-js@2.6.12: {}
+
+  cors@2.8.6:
+    dependencies:
+      object-assign: 4.1.1
+      vary: 1.1.2
+
+  cose-base@1.0.3:
+    dependencies:
+      layout-base: 1.0.2
+    optional: true
+
+  cose-base@2.2.0:
+    dependencies:
+      layout-base: 2.0.1
+    optional: true
+
+  cross-spawn@7.0.6:
+    dependencies:
+      path-key: 3.1.1
+      shebang-command: 2.0.0
+      which: 2.0.1
+
+  crypto-js@4.2.0: {}
+
+  css-select@6.0.0:
+    dependencies:
+      boolbase: 1.0.0
+      css-what: 7.0.0
+      domhandler: 5.0.3
+      domutils: 3.2.2
+      nth-check: 2.1.1
+
+  css-what@7.0.0: {}
+
+  cssesc@3.0.0: {}
+
+  custom-event@1.0.1: {}
+
+  cytoscape-cose-bilkent@4.1.0(cytoscape@3.34.4):
+    dependencies:
+      cose-base: 1.0.3
+      cytoscape: 3.34.4
+    optional: true
+
+  cytoscape-fcose@2.2.0(cytoscape@3.34.4):
+    dependencies:
+      cose-base: 2.2.0
+      cytoscape: 3.34.4
+    optional: true
+
+  cytoscape@3.34.4:
+    optional: true
+
+  d3-array@2.12.1:
+    dependencies:
+      internmap: 1.0.1
+    optional: true
+
+  d3-array@3.2.4:
+    dependencies:
+      internmap: 2.0.3
+    optional: true
+
+  d3-axis@3.0.0:
+    optional: true
+
+  d3-brush@3.0.0:
+    dependencies:
+      d3-dispatch: 3.0.1
+      d3-drag: 3.0.0
+      d3-interpolate: 3.0.1
+      d3-selection: 3.0.0
+      d3-transition: 3.0.1(d3-selection@3.0.0)
+    optional: true
+
+  d3-chord@3.0.1:
+    dependencies:
+      d3-path: 3.1.0
+    optional: true
+
+  d3-color@3.1.0:
+    optional: true
+
+  d3-contour@4.0.2:
+    dependencies:
+      d3-array: 3.2.4
+    optional: true
+
+  d3-delaunay@6.0.4:
+    dependencies:
+      delaunator: 5.1.0
+    optional: true
+
+  d3-dispatch@3.0.1:
+    optional: true
+
+  d3-drag@3.0.0:
+    dependencies:
+      d3-dispatch: 3.0.1
+      d3-selection: 3.0.0
+    optional: true
+
+  d3-dsv@3.0.1:
+    dependencies:
+      commander: 7.0.0
+      iconv-lite: 0.6.0
+      rw: 1.3.3
+    optional: true
+
+  d3-ease@3.0.1:
+    optional: true
+
+  d3-fetch@3.0.1:
+    dependencies:
+      d3-dsv: 3.0.1
+    optional: true
+
+  d3-force@3.0.0:
+    dependencies:
+      d3-dispatch: 3.0.1
+      d3-quadtree: 3.0.1
+      d3-timer: 3.0.1
+    optional: true
+
+  d3-format@3.1.2:
+    optional: true
+
+  d3-geo@3.1.1:
+    dependencies:
+      d3-array: 3.2.4
+    optional: true
+
+  d3-hierarchy@3.1.2:
+    optional: true
+
+  d3-interpolate@3.0.1:
+    dependencies:
+      d3-color: 3.1.0
+    optional: true
+
+  d3-path@1.0.9:
+    optional: true
+
+  d3-path@3.1.0:
+    optional: true
+
+  d3-polygon@3.0.1:
+    optional: true
+
+  d3-quadtree@3.0.1:
+    optional: true
+
+  d3-random@3.0.1:
+    optional: true
+
+  d3-sankey@0.12.3:
+    dependencies:
+      d3-array: 2.12.1
+      d3-shape: 1.3.7
+    optional: true
+
+  d3-scale-chromatic@3.1.0:
+    dependencies:
+      d3-color: 3.1.0
+      d3-interpolate: 3.0.1
+    optional: true
+
+  d3-scale@4.0.2:
+    dependencies:
+      d3-array: 3.2.4
+      d3-format: 3.1.2
+      d3-interpolate: 3.0.1
+      d3-time: 3.1.0
+      d3-time-format: 4.1.0
+    optional: true
+
+  d3-selection@3.0.0:
+    optional: true
+
+  d3-shape@1.3.7:
+    dependencies:
+      d3-path: 1.0.9
+    optional: true
+
+  d3-shape@3.2.0:
+    dependencies:
+      d3-path: 3.1.0
+    optional: true
+
+  d3-time-format@4.1.0:
+    dependencies:
+      d3-time: 3.1.0
+    optional: true
+
+  d3-time@3.1.0:
+    dependencies:
+      d3-array: 3.2.4
+    optional: true
+
+  d3-timer@3.0.1:
+    optional: true
+
+  d3-transition@3.0.1(d3-selection@3.0.0):
+    dependencies:
+      d3-color: 3.1.0
+      d3-dispatch: 3.0.1
+      d3-ease: 3.0.1
+      d3-interpolate: 3.0.1
+      d3-selection: 3.0.0
+      d3-timer: 3.0.1
+    optional: true
+
+  d3-zoom@3.0.0:
+    dependencies:
+      d3-dispatch: 3.0.1
+      d3-drag: 3.0.0
+      d3-interpolate: 3.0.1
+      d3-selection: 3.0.0
+      d3-transition: 3.0.1(d3-selection@3.0.0)
+    optional: true
+
+  d3@7.9.0:
+    dependencies:
+      d3-array: 3.2.4
+      d3-axis: 3.0.0
+      d3-brush: 3.0.0
+      d3-chord: 3.0.1
+      d3-color: 3.1.0
+      d3-contour: 4.0.2
+      d3-delaunay: 6.0.4
+      d3-dispatch: 3.0.1
+      d3-drag: 3.0.0
+      d3-dsv: 3.0.1
+      d3-ease: 3.0.1
+      d3-fetch: 3.0.1
+      d3-force: 3.0.0
+      d3-format: 3.1.2
+      d3-geo: 3.1.1
+      d3-hierarchy: 3.1.2
+      d3-interpolate: 3.0.1
+      d3-path: 3.1.0
+      d3-polygon: 3.0.1
+      d3-quadtree: 3.0.1
+      d3-random: 3.0.1
+      d3-scale: 4.0.2
+      d3-scale-chromatic: 3.1.0
+      d3-selection: 3.0.0
+      d3-shape: 3.2.0
+      d3-time: 3.1.0
+      d3-time-format: 4.1.0
+      d3-timer: 3.0.1
+      d3-transition: 3.0.1(d3-selection@3.0.0)
+      d3-zoom: 3.0.0
+    optional: true
+
+  dagre-d3-es@7.0.14:
+    dependencies:
+      d3: 7.9.0
+      lodash-es: 4.18.1
+    optional: true
+
+  date-format@4.0.14: {}
+
+  dayjs@1.11.23:
+    optional: true
+
+  debug@2.6.9(supports-color@7.1.0):
+    dependencies:
+      ms: 2.0.0
+    optionalDependencies:
+      supports-color: 7.1.0
+
+  debug@4.4.3(supports-color@7.1.0):
+    dependencies:
+      ms: 2.1.3
+    optionalDependencies:
+      supports-color: 7.1.0
+
+  decamelize@1.2.0: {}
+
+  delaunator@5.1.0:
+    dependencies:
+      robust-predicates: 3.0.3
+    optional: true
+
+  delegate@3.2.0:
+    optional: true
+
+  depd@2.0.0: {}
+
+  destroy@1.2.0: {}
+
+  detect-libc@2.1.2:
+    optional: true
+
+  di@0.0.1: {}
+
+  didyoumean@1.2.2: {}
+
+  dingtalk-jsapi@3.2.9:
+    dependencies:
+      promise-polyfill: 7.1.2
+
+  dlv@1.1.3: {}
+
+  dom-serialize@2.2.1:
+    dependencies:
+      custom-event: 1.0.1
+      ent: 2.2.2
+      extend: 3.0.2
+      void-elements: 2.0.1
+
+  dom-serializer@2.0.0:
+    dependencies:
+      domelementtype: 2.3.0
+      domhandler: 5.0.3
+      entities: 4.2.0
+
+  domelementtype@2.3.0: {}
+
+  domhandler@5.0.3:
+    dependencies:
+      domelementtype: 2.3.0
+
+  dompurify@3.4.17:
+    optionalDependencies:
+      '@types/trusted-types': 2.0.7
+    optional: true
+
+  domutils@3.2.2:
+    dependencies:
+      dom-serializer: 2.0.0
+      domelementtype: 2.3.0
+      domhandler: 5.0.3
+
+  dunder-proto@1.0.1:
+    dependencies:
+      call-bind-apply-helpers: 1.0.2
+      es-errors: 1.3.0
+      gopd: 1.2.0
+
+  ee-first@1.1.1: {}
+
+  electron-to-chromium@1.5.467: {}
+
+  emoji-regex@10.3.0: {}
+
+  emoji-regex@8.0.0: {}
+
+  emoji-toolkit@9.0.1:
+    optional: true
+
+  encodeurl@1.0.2: {}
+
+  encodeurl@2.0.1: {}
+
+  encoding@0.1.14:
+    dependencies:
+      iconv-lite: 0.7.3
+    optional: true
+
+  engine.io-parser@5.2.3: {}
+
+  engine.io@6.6.11(supports-color@7.1.0):
+    dependencies:
+      '@types/cors': 2.8.19
+      '@types/node': 26.6.6
+      '@types/ws': 8.18.2
+      accepts: 1.3.8
+      cookie: 0.7.2
+      cors: 2.8.6
+      debug: 4.4.3(supports-color@7.1.0)
+      engine.io-parser: 5.2.3
+      ws: 8.21.3
+    transitivePeerDependencies:
+      - bufferutil
+      - supports-color
+      - utf-8-validate
+
+  ent@2.2.2:
+    dependencies:
+      call-bound: 1.0.4
+      es-errors: 1.3.0
+      punycode: 1.4.1
+      safe-regex-test: 1.1.0
+
+  entities@4.2.0: {}
+
+  entities@6.0.0: {}
+
+  entities@7.0.1: {}
+
+  entities@8.1.0: {}
+
+  env-paths@2.2.0: {}
+
+  environment@1.1.0: {}
+
+  es-define-property@1.0.1: {}
+
+  es-errors@1.3.0: {}
+
+  es-object-atoms@1.1.2:
+    dependencies:
+      es-errors: 1.3.0
+
+  es-toolkit@1.53.0:
+    optional: true
+
+  esbuild@0.28.1:
+    optionalDependencies:
+      '@esbuild/aix-ppc64': 0.28.1
+      '@esbuild/android-arm': 0.28.1
+      '@esbuild/android-arm64': 0.28.1
+      '@esbuild/android-x64': 0.28.1
+      '@esbuild/darwin-arm64': 0.28.1
+      '@esbuild/darwin-x64': 0.28.1
+      '@esbuild/freebsd-arm64': 0.28.1
+      '@esbuild/freebsd-x64': 0.28.1
+      '@esbuild/linux-arm': 0.28.1
+      '@esbuild/linux-arm64': 0.28.1
+      '@esbuild/linux-ia32': 0.28.1
+      '@esbuild/linux-loong64': 0.28.1
+      '@esbuild/linux-mips64el': 0.28.1
+      '@esbuild/linux-ppc64': 0.28.1
+      '@esbuild/linux-riscv64': 0.28.1
+      '@esbuild/linux-s390x': 0.28.1
+      '@esbuild/linux-x64': 0.28.1
+      '@esbuild/netbsd-arm64': 0.28.1
+      '@esbuild/netbsd-x64': 0.28.1
+      '@esbuild/openbsd-arm64': 0.28.1
+      '@esbuild/openbsd-x64': 0.28.1
+      '@esbuild/openharmony-arm64': 0.28.1
+      '@esbuild/sunos-x64': 0.28.1
+      '@esbuild/win32-arm64': 0.28.1
+      '@esbuild/win32-ia32': 0.28.1
+      '@esbuild/win32-x64': 0.28.1
+
+  escalade@3.2.0: {}
+
+  escape-html@1.0.3: {}
+
+  etag@1.8.1: {}
+
+  eventemitter3@4.0.0: {}
+
+  eventemitter3@4.0.4: {}
+
+  eventemitter3@5.0.4: {}
+
+  events@3.3.0: {}
+
+  eventsource-parser@3.1.1: {}
+
+  eventsource@3.0.2:
+    dependencies:
+      eventsource-parser: 3.1.1
+
+  exponential-backoff@3.1.3: {}
+
+  express-rate-limit@8.7.1(express@5.3.0(supports-color@7.1.0))(supports-color@7.1.0):
+    dependencies:
+      debug: 4.4.3(supports-color@7.1.0)
+      express: 5.3.0(supports-color@7.1.0)
+      ip-address: 10.7.5
+    transitivePeerDependencies:
+      - supports-color
+
+  express@5.3.0(supports-color@7.1.0):
+    dependencies:
+      accepts: 2.0.0
+      body-parser: 2.4.0(supports-color@7.1.0)
+      content-disposition: 2.0.1
+      content-type: 2.1.0
+      cookie: 0.7.1
+      cookie-signature: 1.2.2
+      debug: 4.4.3(supports-color@7.1.0)
+      depd: 2.0.0
+      encodeurl: 2.0.1
+      escape-html: 1.0.3
+      etag: 1.8.1
+      finalhandler: 2.1.1(supports-color@7.1.0)
+      fresh: 2.0.0
+      http-errors: 2.0.1
+      merge-descriptors: 2.0.0
+      mime-types: 3.0.2
+      on-finished: 2.4.2
+      once: 1.4.0
+      parseurl: 1.3.3
+      proxy-addr: 2.0.8
+      qs: 6.17.1
+      range-parser: 1.3.0
+      router: 2.2.0(supports-color@7.1.0)
+      send: 1.2.1(supports-color@7.1.0)
+      serve-static: 2.2.1(supports-color@7.1.0)
+      statuses: 2.0.2
+      type-is: 2.1.0
+      vary: 1.1.2
+    transitivePeerDependencies:
+      - supports-color
+
+  extend@3.0.2: {}
+
+  fast-deep-equal@3.1.3: {}
+
+  fast-glob@3.3.3:
+    dependencies:
+      '@nodelib/fs.stat': 2.0.5
+      '@nodelib/fs.walk': 1.2.8
+      glob-parent: 5.1.2
+      merge2: 1.4.1
+      micromatch: 4.0.8
+
+  fast-uri@3.1.9: {}
+
+  fastdom@1.0.12:
+    dependencies:
+      strictdom: 1.0.1
+    optional: true
+
+  fastq@1.20.3:
+    dependencies:
+      reusify: 1.1.0
+
+  fdir@6.5.0(picomatch@4.0.4):
+    optionalDependencies:
+      picomatch: 4.0.4
+
+  fill-range@7.1.1:
+    dependencies:
+      to-regex-range: 5.0.1
+
+  finalhandler@1.1.2(supports-color@7.1.0):
+    dependencies:
+      debug: 2.6.9(supports-color@7.1.0)
+      encodeurl: 1.0.2
+      escape-html: 1.0.3
+      on-finished: 2.3.0
+      parseurl: 1.3.3
+      statuses: 1.5.0
+      unpipe: 1.0.0
+    transitivePeerDependencies:
+      - supports-color
+
+  finalhandler@2.1.1(supports-color@7.1.0):
+    dependencies:
+      debug: 4.4.3(supports-color@7.1.0)
+      encodeurl: 2.0.1
+      escape-html: 1.0.3
+      on-finished: 2.4.2
+      parseurl: 1.3.3
+      statuses: 2.0.2
+    transitivePeerDependencies:
+      - supports-color
+
+  flatted@3.4.4: {}
+
+  fmode-ng@0.0.246(@angular/common@20.3.34(@angular/core@20.3.34(@angular/compiler@20.3.34)(rxjs@7.8.0)(zone.js@0.15.0))(rxjs@7.8.0))(@angular/core@20.3.34(@angular/compiler@20.3.34)(rxjs@7.8.0)(zone.js@0.15.0)):
+    dependencies:
+      '@aws-sdk/client-s3': 3.1159.0
+      '@aws-sdk/s3-request-presigner': 3.1159.0
+      '@langchain/core': 0.3.80
+      '@wecom/jssdk': 2.4.4
+      crypto-js: 4.2.0
+      dingtalk-jsapi: 3.2.9
+      node-localstorage: 2.2.1
+      pako: 2.1.0
+      qiniu-js: 3.4.4
+      recorder-core: 1.3.26070800
+      spark-md5: 3.0.2
+      tslib: 2.8.1
+    optionalDependencies:
+      '@angular/common': 20.3.34(@angular/core@20.3.34(@angular/compiler@20.3.34)(rxjs@7.8.0)(zone.js@0.15.0))(rxjs@7.8.0)
+      '@angular/core': 20.3.34(@angular/compiler@20.3.34)(rxjs@7.8.0)(zone.js@0.15.0)
+    transitivePeerDependencies:
+      - '@opentelemetry/api'
+      - '@opentelemetry/exporter-trace-otlp-proto'
+      - '@opentelemetry/sdk-trace-base'
+      - openai
+
+  follow-redirects@1.16.2(debug@4.4.3(supports-color@7.1.0)):
+    optionalDependencies:
+      debug: 4.4.3(supports-color@7.1.0)
+
+  forwarded@0.2.0: {}
+
+  fraction.js@5.3.4: {}
+
+  fresh@2.0.0: {}
+
+  fs-extra@8.1.0:
+    dependencies:
+      graceful-fs: 4.2.11
+      jsonfile: 4.0.0
+      universalify: 0.1.2
+
+  fs-minipass@3.0.3:
+    dependencies:
+      minipass: 7.1.3
+
+  fs.realpath@1.0.0: {}
+
+  fsevents@2.3.3:
+    optional: true
+
+  function-bind@1.1.2: {}
+
+  gensync@1.0.0-beta.2: {}
+
+  get-caller-file@2.0.5: {}
+
+  get-east-asian-width@1.7.0: {}
+
+  get-intrinsic@1.3.0:
+    dependencies:
+      call-bind-apply-helpers: 1.0.2
+      es-define-property: 1.0.1
+      es-errors: 1.3.0
+      es-object-atoms: 1.1.2
+      function-bind: 1.1.2
+      get-proto: 1.0.1
+      gopd: 1.2.0
+      has-symbols: 1.1.0
+      hasown: 2.0.4
+      math-intrinsics: 1.1.0
+
+  get-proto@1.0.1:
+    dependencies:
+      dunder-proto: 1.0.1
+      es-object-atoms: 1.1.2
+
+  glob-parent@5.1.2:
+    dependencies:
+      is-glob: 4.0.3
+
+  glob-parent@6.0.2:
+    dependencies:
+      is-glob: 4.0.3
+
+  glob-to-regexp@0.4.1: {}
+
+  glob@13.0.6:
+    dependencies:
+      minimatch: 10.2.6
+      minipass: 7.1.3
+      path-scurry: 2.0.2
+
+  glob@7.2.0:
+    dependencies:
+      fs.realpath: 1.0.0
+      inflight: 1.0.6
+      inherits: 2.0.4
+      minimatch: 3.1.5
+      once: 1.4.0
+      path-is-absolute: 1.0.1
+
+  good-listener@1.2.2:
+    dependencies:
+      delegate: 3.2.0
+    optional: true
+
+  gopd@1.2.0: {}
+
+  graceful-fs@4.2.11: {}
+
+  hachure-fill@0.5.2:
+    optional: true
+
+  has-flag@4.0.0: {}
+
+  has-symbols@1.1.0: {}
+
+  has-tostringtag@1.0.2:
+    dependencies:
+      has-symbols: 1.1.0
+
+  hasown@2.0.4:
+    dependencies:
+      function-bind: 1.1.2
+
+  hono@4.13.14: {}
+
+  hosted-git-info@9.0.3:
+    dependencies:
+      lru-cache: 11.5.3
+
+  html-escaper@2.0.2: {}
+
+  htmlparser2@10.1.0:
+    dependencies:
+      domelementtype: 2.3.0
+      domhandler: 5.0.3
+      domutils: 3.2.2
+      entities: 7.0.1
+
+  http-cache-semantics@4.3.0: {}
+
+  http-errors@2.0.0:
+    dependencies:
+      depd: 2.0.0
+      inherits: 2.0.4
+      setprototypeof: 1.2.0
+      statuses: 2.0.1
+      toidentifier: 1.0.1
+
+  http-errors@2.0.1:
+    dependencies:
+      depd: 2.0.0
+      inherits: 2.0.4
+      setprototypeof: 1.2.0
+      statuses: 2.0.2
+      toidentifier: 1.0.1
+
+  http-proxy-agent@7.0.0(supports-color@7.1.0):
+    dependencies:
+      agent-base: 7.1.0(supports-color@7.1.0)
+      debug: 4.4.3(supports-color@7.1.0)
+    transitivePeerDependencies:
+      - supports-color
+
+  http-proxy@1.18.1(debug@4.4.3(supports-color@7.1.0)):
+    dependencies:
+      eventemitter3: 4.0.0
+      follow-redirects: 1.16.2(debug@4.4.3(supports-color@7.1.0))
+      requires-port: 1.0.0
+    transitivePeerDependencies:
+      - debug
+
+  https-proxy-agent@7.0.1(supports-color@7.1.0):
+    dependencies:
+      agent-base: 7.1.0(supports-color@7.1.0)
+      debug: 4.4.3(supports-color@7.1.0)
+    transitivePeerDependencies:
+      - supports-color
+
+  https-proxy-agent@7.0.6(supports-color@7.1.0):
+    dependencies:
+      agent-base: 7.1.2(supports-color@7.1.0)
+      debug: 4.4.3(supports-color@7.1.0)
+    transitivePeerDependencies:
+      - supports-color
+
+  iconv-lite@0.4.24:
+    dependencies:
+      safer-buffer: 2.1.2
+
+  iconv-lite@0.6.0:
+    dependencies:
+      safer-buffer: 2.1.2
+    optional: true
+
+  iconv-lite@0.6.3:
+    dependencies:
+      safer-buffer: 2.1.2
+
+  iconv-lite@0.7.3:
+    dependencies:
+      safer-buffer: 2.1.2
+
+  ignore-walk@8.0.0:
+    dependencies:
+      minimatch: 10.2.6
+
+  immutable@5.1.9: {}
+
+  import-meta-resolve@4.2.0:
+    optional: true
+
+  imurmurhash@0.1.4: {}
+
+  inflight@1.0.6:
+    dependencies:
+      once: 1.4.0
+      wrappy: 1.0.2
+
+  inherits@2.0.4: {}
+
+  ini@5.0.0: {}
+
+  ini@6.0.0: {}
+
+  internmap@1.0.1:
+    optional: true
+
+  internmap@2.0.3:
+    optional: true
+
+  ip-address@10.7.5: {}
+
+  ipaddr.js@1.9.1: {}
+
+  is-absolute@0.1.7:
+    dependencies:
+      is-relative: 0.1.3
+
+  is-binary-path@2.1.0:
+    dependencies:
+      binary-extensions: 2.0.0
+
+  is-core-module@2.17.0:
+    dependencies:
+      hasown: 2.0.4
+
+  is-extglob@2.1.1: {}
+
+  is-fullwidth-code-point@3.0.0: {}
+
+  is-fullwidth-code-point@4.0.0: {}
+
+  is-fullwidth-code-point@5.1.0:
+    dependencies:
+      get-east-asian-width: 1.7.0
+
+  is-glob@4.0.3:
+    dependencies:
+      is-extglob: 2.1.1
+
+  is-interactive@2.0.0: {}
+
+  is-network-error@1.3.2: {}
+
+  is-number@7.0.0: {}
+
+  is-promise@4.0.0: {}
+
+  is-regex@1.2.1:
+    dependencies:
+      call-bound: 1.0.4
+      gopd: 1.2.0
+      has-tostringtag: 1.0.2
+      hasown: 2.0.4
+
+  is-relative@0.1.3: {}
+
+  is-unicode-supported@1.3.0: {}
+
+  is-unicode-supported@2.1.0: {}
+
+  isbinaryfile@4.0.10: {}
+
+  isexe@2.0.0: {}
+
+  isexe@4.0.0: {}
+
+  istanbul-lib-coverage@3.2.2: {}
+
+  istanbul-lib-instrument@5.2.1(supports-color@7.1.0):
+    dependencies:
+      '@babel/core': 7.29.7(supports-color@7.1.0)
+      '@babel/parser': 7.29.11
+      '@istanbuljs/schema': 0.1.6
+      istanbul-lib-coverage: 3.2.2
+      semver: 6.3.0
+    transitivePeerDependencies:
+      - supports-color
+
+  istanbul-lib-instrument@6.0.3(supports-color@7.1.0):
+    dependencies:
+      '@babel/core': 7.29.7(supports-color@7.1.0)
+      '@babel/parser': 7.29.11
+      '@istanbuljs/schema': 0.1.6
+      istanbul-lib-coverage: 3.2.2
+      semver: 7.8.5
+    transitivePeerDependencies:
+      - supports-color
+
+  istanbul-lib-report@3.0.1:
+    dependencies:
+      istanbul-lib-coverage: 3.2.2
+      make-dir: 4.0.0
+      supports-color: 7.1.0
+
+  istanbul-lib-source-maps@4.0.1(supports-color@7.1.0):
+    dependencies:
+      debug: 4.4.3(supports-color@7.1.0)
+      istanbul-lib-coverage: 3.2.2
+      source-map: 0.6.1
+    transitivePeerDependencies:
+      - supports-color
+
+  istanbul-reports@3.2.0:
+    dependencies:
+      html-escaper: 2.0.2
+      istanbul-lib-report: 3.0.1
+
+  jasmine-core@4.1.0: {}
+
+  jasmine-core@5.9.0: {}
+
+  jiti@1.21.7: {}
+
+  jose@6.2.14: {}
+
+  js-tiktoken@1.0.21:
+    dependencies:
+      base64-js: 1.5.1
+
+  js-tokens@4.0.0: {}
+
+  jsesc@3.1.0: {}
+
+  json-parse-even-better-errors@5.0.0: {}
+
+  json-schema-traverse@1.0.0: {}
+
+  json-schema-typed@8.0.2: {}
+
+  json5@2.2.3: {}
+
+  jsonc-parser@3.3.1: {}
+
+  jsonfile@4.0.0:
+    optionalDependencies:
+      graceful-fs: 4.2.11
+
+  jsonparse@1.3.1: {}
+
+  karma-chrome-launcher@3.2.0:
+    dependencies:
+      which: 1.2.1
+
+  karma-coverage@2.2.1(supports-color@7.1.0):
+    dependencies:
+      istanbul-lib-coverage: 3.2.2
+      istanbul-lib-instrument: 5.2.1(supports-color@7.1.0)
+      istanbul-lib-report: 3.0.1
+      istanbul-lib-source-maps: 4.0.1(supports-color@7.1.0)
+      istanbul-reports: 3.2.0
+      minimatch: 3.1.5
+    transitivePeerDependencies:
+      - supports-color
+
+  karma-jasmine-html-reporter@2.1.0(jasmine-core@5.9.0)(karma-jasmine@5.1.0(karma@6.4.4(debug@4.4.3(supports-color@7.1.0))(supports-color@7.1.0)))(karma@6.4.4(debug@4.4.3(supports-color@7.1.0))(supports-color@7.1.0)):
+    dependencies:
+      jasmine-core: 5.9.0
+      karma: 6.4.4(debug@4.4.3(supports-color@7.1.0))(supports-color@7.1.0)
+      karma-jasmine: 5.1.0(karma@6.4.4(debug@4.4.3(supports-color@7.1.0))(supports-color@7.1.0))
+
+  karma-jasmine@5.1.0(karma@6.4.4(debug@4.4.3(supports-color@7.1.0))(supports-color@7.1.0)):
+    dependencies:
+      jasmine-core: 4.1.0
+      karma: 6.4.4(debug@4.4.3(supports-color@7.1.0))(supports-color@7.1.0)
+
+  karma@6.4.4(debug@4.4.3(supports-color@7.1.0))(supports-color@7.1.0):
+    dependencies:
+      '@colors/colors': 1.5.0
+      body-parser: 1.20.9(supports-color@7.1.0)
+      braces: 3.0.3
+      chokidar: 3.5.1
+      connect: 3.7.0(supports-color@7.1.0)
+      di: 0.0.1
+      dom-serialize: 2.2.1
+      glob: 7.2.0
+      graceful-fs: 4.2.11
+      http-proxy: 1.18.1(debug@4.4.3(supports-color@7.1.0))
+      isbinaryfile: 4.0.10
+      lodash: 4.18.1
+      log4js: 6.9.1(supports-color@7.1.0)
+      mime: 2.5.2
+      minimatch: 3.1.5
+      mkdirp: 0.5.6
+      qjobs: 1.2.0
+      range-parser: 1.3.0
+      rimraf: 3.0.2
+      socket.io: 4.8.4(supports-color@7.1.0)
+      source-map: 0.6.1
+      tmp: 0.2.7
+      ua-parser-js: 0.7.30
+      yargs: 16.2.2
+    transitivePeerDependencies:
+      - bufferutil
+      - debug
+      - supports-color
+      - utf-8-validate
+
+  katex@0.16.47:
+    dependencies:
+      commander: 8.3.0
+    optional: true
+
+  khroma@2.1.0:
+    optional: true
+
+  langsmith@0.3.87:
+    dependencies:
+      '@types/uuid': 10.0.0
+      chalk: 4.1.2
+      console-table-printer: 2.16.1
+      p-queue: 6.6.2
+      semver: 7.8.5
+      uuid: 10.0.0
+
+  layout-base@1.0.2:
+    optional: true
+
+  layout-base@2.0.1:
+    optional: true
+
+  lilconfig@3.1.4: {}
+
+  lines-and-columns@1.2.4: {}
+
+  listr2@9.0.1:
+    dependencies:
+      cli-truncate: 4.0.0
+      colorette: 2.0.20
+      eventemitter3: 5.0.4
+      log-update: 6.1.0
+      rfdc: 1.4.1
+      wrap-ansi: 9.0.0
+
+  lmdb@3.4.2:
+    dependencies:
+      msgpackr: 1.12.1
+      node-addon-api: 6.1.0
+      node-gyp-build-optional-packages: 5.2.2
+      ordered-binary: 1.6.3
+      weak-lru-cache: 1.2.2
+    optionalDependencies:
+      '@lmdb/lmdb-darwin-arm64': 3.4.2
+      '@lmdb/lmdb-darwin-x64': 3.4.2
+      '@lmdb/lmdb-linux-arm': 3.4.2
+      '@lmdb/lmdb-linux-arm64': 3.4.2
+      '@lmdb/lmdb-linux-x64': 3.4.2
+      '@lmdb/lmdb-win32-arm64': 3.4.2
+      '@lmdb/lmdb-win32-x64': 3.4.2
+    optional: true
+
+  lodash-es@4.18.1:
+    optional: true
+
+  lodash@4.18.1: {}
+
+  log-symbols@6.0.0:
+    dependencies:
+      chalk: 5.3.0
+      is-unicode-supported: 1.3.0
+
+  log-update@6.1.0:
+    dependencies:
+      ansi-escapes: 7.3.0
+      cli-cursor: 5.0.0
+      slice-ansi: 7.1.0
+      strip-ansi: 7.2.0
+      wrap-ansi: 9.0.0
+
+  log4js@6.9.1(supports-color@7.1.0):
+    dependencies:
+      date-format: 4.0.14
+      debug: 4.4.3(supports-color@7.1.0)
+      flatted: 3.4.4
+      rfdc: 1.4.1
+      streamroller: 3.1.5(supports-color@7.1.0)
+    transitivePeerDependencies:
+      - supports-color
+
+  loglevel@1.9.2: {}
+
+  lru-cache@11.5.3: {}
+
+  lru-cache@5.1.1:
+    dependencies:
+      yallist: 3.1.1
+
+  magic-string@0.30.17:
+    dependencies:
+      '@jridgewell/sourcemap-codec': 1.6.0
+
+  make-dir@4.0.0:
+    dependencies:
+      semver: 7.8.5
+
+  make-fetch-happen@15.0.6(supports-color@7.1.0):
+    dependencies:
+      '@gar/promise-retry': 1.0.3
+      '@npmcli/agent': 4.0.2(supports-color@7.1.0)
+      '@npmcli/redact': 4.0.0
+      cacache: 20.0.4
+      http-cache-semantics: 4.3.0
+      minipass: 7.1.3
+      minipass-fetch: 5.0.2
+      minipass-flush: 1.0.7
+      minipass-pipeline: 1.2.4
+      negotiator: 1.1.0
+      proc-log: 6.0.0
+      ssri: 13.0.1
+    transitivePeerDependencies:
+      - supports-color
+
+  marked@16.3.0: {}
+
+  math-intrinsics@1.1.0: {}
+
+  matrix-events-sdk@0.0.1: {}
+
+  matrix-js-sdk@42.4.0:
+    dependencies:
+      '@babel/runtime': 8.0.8
+      '@matrix-org/matrix-sdk-crypto-wasm': 18.9.0
+      another-json: 0.2.0
+      bs58: 6.0.0
+      content-type: 3.1.1
+      loglevel: 1.9.2
+      matrix-events-sdk: 0.0.1
+      matrix-widget-api: 1.20.0
+      p-retry: 8.0.1
+      sdp-transform: 3.0.0
+      unhomoglyph: 1.0.6
+
+  matrix-widget-api@1.20.0:
+    dependencies:
+      '@types/events': 3.0.3
+      events: 3.3.0
+
+  media-typer@0.3.0: {}
+
+  media-typer@1.1.1: {}
+
+  merge-descriptors@2.0.0: {}
+
+  merge2@1.4.1: {}
+
+  mermaid@11.17.2:
+    dependencies:
+      '@braintree/sanitize-url': 7.1.2
+      '@iconify/utils': 3.1.7
+      '@mermaid-js/parser': 1.2.1
+      '@types/d3': 7.4.3
+      '@upsetjs/venn.js': 2.0.0
+      cytoscape: 3.34.4
+      cytoscape-cose-bilkent: 4.1.0(cytoscape@3.34.4)
+      cytoscape-fcose: 2.2.0(cytoscape@3.34.4)
+      d3: 7.9.0
+      d3-sankey: 0.12.3
+      dagre-d3-es: 7.0.14
+      dayjs: 1.11.23
+      dompurify: 3.4.17
+      es-toolkit: 1.53.0
+      fastdom: 1.0.12
+      katex: 0.16.47
+      khroma: 2.1.0
+      marked: 16.3.0
+      roughjs: 4.6.6
+      stylis: 4.4.0
+      ts-dedent: 2.3.0
+      uuid: 14.0.3
+    optional: true
+
+  micromatch@4.0.8:
+    dependencies:
+      braces: 3.0.3
+      picomatch: 2.3.2
+
+  mime-db@1.40.0: {}
+
+  mime-db@1.51.0: {}
+
+  mime-db@1.54.0: {}
+
+  mime-types@2.1.24:
+    dependencies:
+      mime-db: 1.40.0
+
+  mime-types@2.1.34:
+    dependencies:
+      mime-db: 1.51.0
+
+  mime-types@3.0.2:
+    dependencies:
+      mime-db: 1.54.0
+
+  mime@2.5.2: {}
+
+  mimic-function@5.0.1: {}
+
+  minimatch@10.2.6:
+    dependencies:
+      brace-expansion: 5.0.12
+
+  minimatch@3.1.5:
+    dependencies:
+      brace-expansion: 1.1.21
+
+  minimist@1.2.8: {}
+
+  minipass-collect@2.0.1:
+    dependencies:
+      minipass: 7.1.3
+
+  minipass-fetch@4.0.0:
+    dependencies:
+      minipass: 7.1.3
+      minipass-sized: 1.0.3
+      minizlib: 3.1.0
+    optionalDependencies:
+      encoding: 0.1.14
+
+  minipass-fetch@5.0.2:
+    dependencies:
+      minipass: 7.1.3
+      minipass-sized: 2.0.0
+      minizlib: 3.1.0
+    optionalDependencies:
+      iconv-lite: 0.7.3
+
+  minipass-flush@1.0.7:
+    dependencies:
+      minipass: 3.0.0
+
+  minipass-pipeline@1.2.4:
+    dependencies:
+      minipass: 3.0.0
+
+  minipass-sized@1.0.3:
+    dependencies:
+      minipass: 3.0.0
+
+  minipass-sized@2.0.0:
+    dependencies:
+      minipass: 7.1.3
+
+  minipass@3.0.0:
+    dependencies:
+      yallist: 4.0.0
+
+  minipass@7.1.3: {}
+
+  minizlib@3.1.0:
+    dependencies:
+      minipass: 7.1.3
+
+  mkdirp@0.5.6:
+    dependencies:
+      minimist: 1.2.8
+
+  mrmime@2.0.1: {}
+
+  ms@2.0.0: {}
+
+  ms@2.1.3: {}
+
+  msgpackr-extract@3.0.4:
+    dependencies:
+      node-gyp-build-optional-packages: 5.2.2
+    optionalDependencies:
+      '@msgpackr-extract/msgpackr-extract-darwin-arm64': 3.0.4
+      '@msgpackr-extract/msgpackr-extract-darwin-x64': 3.0.4
+      '@msgpackr-extract/msgpackr-extract-linux-arm': 3.0.4
+      '@msgpackr-extract/msgpackr-extract-linux-arm64': 3.0.4
+      '@msgpackr-extract/msgpackr-extract-linux-x64': 3.0.4
+      '@msgpackr-extract/msgpackr-extract-win32-x64': 3.0.4
+    optional: true
+
+  msgpackr@1.12.1:
+    optionalDependencies:
+      msgpackr-extract: 3.0.4
+    optional: true
+
+  mustache@4.2.0: {}
+
+  mute-stream@2.0.0: {}
+
+  mz@2.7.0:
+    dependencies:
+      any-promise: 1.3.0
+      object-assign: 4.1.1
+      thenify-all: 1.6.0
+
+  nanoid@3.3.20: {}
+
+  negotiator@0.6.3: {}
+
+  negotiator@1.1.0:
+    dependencies:
+      content-type: 2.1.0
+
+  ngx-markdown@20.1.0(@angular/common@20.3.34(@angular/core@20.3.34(@angular/compiler@20.3.34)(rxjs@7.8.0)(zone.js@0.15.0))(rxjs@7.8.0))(@angular/core@20.3.34(@angular/compiler@20.3.34)(rxjs@7.8.0)(zone.js@0.15.0))(@angular/platform-browser@20.3.34(@angular/common@20.3.34(@angular/core@20.3.34(@angular/compiler@20.3.34)(rxjs@7.8.0)(zone.js@0.15.0))(rxjs@7.8.0))(@angular/core@20.3.34(@angular/compiler@20.3.34)(rxjs@7.8.0)(zone.js@0.15.0)))(marked@16.3.0)(rxjs@7.8.0)(zone.js@0.15.0):
+    dependencies:
+      '@angular/common': 20.3.34(@angular/core@20.3.34(@angular/compiler@20.3.34)(rxjs@7.8.0)(zone.js@0.15.0))(rxjs@7.8.0)
+      '@angular/core': 20.3.34(@angular/compiler@20.3.34)(rxjs@7.8.0)(zone.js@0.15.0)
+      '@angular/platform-browser': 20.3.34(@angular/common@20.3.34(@angular/core@20.3.34(@angular/compiler@20.3.34)(rxjs@7.8.0)(zone.js@0.15.0))(rxjs@7.8.0))(@angular/core@20.3.34(@angular/compiler@20.3.34)(rxjs@7.8.0)(zone.js@0.15.0))
+      marked: 16.3.0
+      rxjs: 7.8.0
+      tslib: 2.8.1
+      zone.js: 0.15.0
+    optionalDependencies:
+      clipboard: 2.0.11
+      emoji-toolkit: 9.0.1
+      katex: 0.16.47
+      mermaid: 11.17.2
+      prismjs: 1.30.0
+
+  node-addon-api@6.1.0:
+    optional: true
+
+  node-addon-api@7.0.0:
+    optional: true
+
+  node-gyp-build-optional-packages@5.2.2:
+    dependencies:
+      detect-libc: 2.1.2
+    optional: true
+
+  node-gyp@12.4.0:
+    dependencies:
+      env-paths: 2.2.0
+      exponential-backoff: 3.1.3
+      graceful-fs: 4.2.11
+      nopt: 9.0.0
+      proc-log: 6.0.0
+      semver: 7.8.5
+      tar: 7.5.22
+      tinyglobby: 0.2.17
+      undici: 6.29.1
+      which: 6.0.1
+
+  node-localstorage@2.2.1:
+    dependencies:
+      write-file-atomic: 1.1.4
+
+  node-releases@2.0.61: {}
+
+  nopt@9.0.0:
+    dependencies:
+      abbrev: 4.0.0
+
+  normalize-path@3.0.0: {}
+
+  npm-bundled@5.0.0:
+    dependencies:
+      npm-normalize-package-bin: 5.0.0
+
+  npm-install-checks@7.1.0:
+    dependencies:
+      semver: 7.8.5
+
+  npm-normalize-package-bin@4.0.0: {}
+
+  npm-normalize-package-bin@5.0.0: {}
+
+  npm-package-arg@13.0.0:
+    dependencies:
+      hosted-git-info: 9.0.3
+      proc-log: 5.0.0
+      semver: 7.8.5
+      validate-npm-package-name: 6.0.0
+
+  npm-packlist@10.0.4:
+    dependencies:
+      ignore-walk: 8.0.0
+      proc-log: 6.0.0
+
+  npm-pick-manifest@11.0.1:
+    dependencies:
+      npm-install-checks: 7.1.0
+      npm-normalize-package-bin: 4.0.0
+      npm-package-arg: 13.0.0
+      semver: 7.8.5
+
+  npm-registry-fetch@19.0.0(supports-color@7.1.0):
+    dependencies:
+      '@npmcli/redact': 3.0.0
+      jsonparse: 1.3.1
+      make-fetch-happen: 15.0.6(supports-color@7.1.0)
+      minipass: 7.1.3
+      minipass-fetch: 4.0.0
+      minizlib: 3.1.0
+      npm-package-arg: 13.0.0
+      proc-log: 5.0.0
+    transitivePeerDependencies:
+      - supports-color
+
+  nth-check@2.1.1:
+    dependencies:
+      boolbase: 1.0.0
+
+  object-assign@4.1.1: {}
+
+  object-hash@3.0.0: {}
+
+  object-inspect@1.13.4: {}
+
+  on-finished@2.3.0:
+    dependencies:
+      ee-first: 1.1.1
+
+  on-finished@2.4.2:
+    dependencies:
+      ee-first: 1.1.1
+
+  once@1.4.0:
+    dependencies:
+      wrappy: 1.0.2
+
+  onetime@7.0.0:
+    dependencies:
+      mimic-function: 5.0.1
+
+  ora@8.2.0:
+    dependencies:
+      chalk: 5.3.0
+      cli-cursor: 5.0.0
+      cli-spinners: 2.9.2
+      is-interactive: 2.0.0
+      is-unicode-supported: 2.1.0
+      log-symbols: 6.0.0
+      stdin-discarder: 0.2.2
+      string-width: 7.2.0
+      strip-ansi: 7.2.0
+
+  ordered-binary@1.6.3:
+    optional: true
+
+  p-finally@1.0.0: {}
+
+  p-map@7.1.1: {}
+
+  p-queue@6.6.2:
+    dependencies:
+      eventemitter3: 4.0.4
+      p-timeout: 3.2.0
+
+  p-retry@4.0.0:
+    dependencies:
+      '@types/retry': 0.12.5
+      retry: 0.12.0
+
+  p-retry@8.0.1:
+    dependencies:
+      is-network-error: 1.3.2
+
+  p-timeout@3.2.0:
+    dependencies:
+      p-finally: 1.0.0
+
+  package-manager-detector@1.9.0:
+    optional: true
+
+  pacote@21.5.1(supports-color@7.1.0):
+    dependencies:
+      '@gar/promise-retry': 1.0.3
+      '@npmcli/git': 7.0.2
+      '@npmcli/installed-package-contents': 4.0.0
+      '@npmcli/package-json': 7.0.5
+      '@npmcli/promise-spawn': 9.0.2
+      '@npmcli/run-script': 10.0.4
+      cacache: 20.0.4
+      fs-minipass: 3.0.3
+      minipass: 7.1.3
+      npm-package-arg: 13.0.0
+      npm-packlist: 10.0.4
+      npm-pick-manifest: 11.0.1
+      npm-registry-fetch: 19.0.0(supports-color@7.1.0)
+      proc-log: 6.0.0
+      sigstore: 4.1.1(supports-color@7.1.0)
+      ssri: 13.0.1
+      tar: 7.5.22
+    transitivePeerDependencies:
+      - supports-color
+
+  pako@2.1.0: {}
+
+  parse5-html-rewriting-stream@8.0.0:
+    dependencies:
+      entities: 6.0.0
+      parse5: 8.0.1
+      parse5-sax-parser: 8.0.0
+
+  parse5-sax-parser@8.0.0:
+    dependencies:
+      parse5: 8.0.1
+
+  parse5@8.0.1:
+    dependencies:
+      entities: 8.1.0
+
+  parseurl@1.3.3: {}
+
+  path-data-parser@0.1.0:
+    optional: true
+
+  path-is-absolute@1.0.1: {}
+
+  path-key@3.1.1: {}
+
+  path-parse@1.0.7: {}
+
+  path-scurry@2.0.2:
+    dependencies:
+      lru-cache: 11.5.3
+      minipass: 7.1.3
+
+  path-to-regexp@8.4.2: {}
+
+  pdfjs-dist@6.4.299:
+    optionalDependencies:
+      '@napi-rs/canvas': 1.0.13
+
+  picocolors@1.1.1: {}
+
+  picomatch@2.3.2: {}
+
+  picomatch@4.0.4: {}
+
+  pirates@4.0.7: {}
+
+  piscina@5.3.2:
+    optionalDependencies:
+      '@napi-rs/nice': 1.1.1
+
+  pkce-challenge@5.0.0: {}
+
+  playwright-core@1.64.1: {}
+
+  playwright@1.64.1:
+    dependencies:
+      playwright-core: 1.64.1
+
+  points-on-curve@0.2.0:
+    optional: true
+
+  points-on-path@0.2.1:
+    dependencies:
+      path-data-parser: 0.1.0
+      points-on-curve: 0.2.0
+    optional: true
+
+  postcss-import@15.1.0(postcss@8.5.32):
+    dependencies:
+      postcss: 8.5.32
+      postcss-value-parser: 4.2.0
+      read-cache: 1.0.2
+      resolve: 1.22.12
+
+  postcss-js@4.0.1(postcss@8.5.32):
+    dependencies:
+      camelcase-css: 2.0.1
+      postcss: 8.5.32
+
+  postcss-load-config@6.0.1(jiti@1.21.7)(postcss@8.5.32):
+    dependencies:
+      lilconfig: 3.1.4
+    optionalDependencies:
+      jiti: 1.21.7
+      postcss: 8.5.32
+
+  postcss-media-query-parser@0.2.3: {}
+
+  postcss-nested@6.2.0(postcss@8.5.32):
+    dependencies:
+      postcss: 8.5.32
+      postcss-selector-parser: 6.1.5
+
+  postcss-selector-parser@6.1.5:
+    dependencies:
+      cssesc: 3.0.0
+      util-deprecate: 1.0.2
+
+  postcss-value-parser@4.2.0: {}
+
+  postcss@8.5.32:
+    dependencies:
+      nanoid: 3.3.20
+      picocolors: 1.1.1
+      source-map-js: 1.2.2
+
+  prismjs@1.30.0: {}
+
+  proc-log@5.0.0: {}
+
+  proc-log@6.0.0: {}
+
+  proc-log@6.1.0: {}
+
+  promise-polyfill@7.1.2: {}
+
+  proxy-addr@2.0.8:
+    dependencies:
+      forwarded: 0.2.0
+      ipaddr.js: 1.9.1
+
+  punycode@1.4.1: {}
+
+  qiniu-js@3.4.4:
+    dependencies:
+      '@babel/runtime-corejs2': 7.29.7
+      querystring: 0.2.1
+      spark-md5: 3.0.2
+
+  qjobs@1.2.0: {}
+
+  qs@6.16.2:
+    dependencies:
+      es-define-property: 1.0.1
+      side-channel: 1.1.1
+
+  qs@6.17.1:
+    dependencies:
+      es-define-property: 1.0.1
+      side-channel: 1.1.1
+
+  querystring@0.2.1: {}
+
+  queue-microtask@1.2.3: {}
+
+  range-parser@1.3.0: {}
+
+  raw-body@2.5.3:
+    dependencies:
+      bytes: 3.1.2
+      http-errors: 2.0.1
+      iconv-lite: 0.4.24
+      unpipe: 1.0.0
+
+  raw-body@3.0.0:
+    dependencies:
+      bytes: 3.1.2
+      http-errors: 2.0.0
+      iconv-lite: 0.6.3
+      unpipe: 1.0.0
+
+  raw-body@3.0.2:
+    dependencies:
+      bytes: 3.1.2
+      http-errors: 2.0.1
+      iconv-lite: 0.7.3
+      unpipe: 1.0.0
+
+  read-cache@1.0.2: {}
+
+  readdirp@3.5.0:
+    dependencies:
+      picomatch: 2.3.2
+
+  readdirp@3.6.0:
+    dependencies:
+      picomatch: 2.3.2
+
+  readdirp@4.0.1: {}
+
+  recorder-core@1.3.26070800: {}
+
+  reflect-metadata@0.2.2: {}
+
+  require-directory@2.1.1: {}
+
+  require-from-string@2.0.2: {}
+
+  requires-port@1.0.0: {}
+
+  resolve@1.22.10:
+    dependencies:
+      is-core-module: 2.17.0
+      path-parse: 1.0.7
+      supports-preserve-symlinks-flag: 1.0.0
+
+  resolve@1.22.12:
+    dependencies:
+      es-errors: 1.3.0
+      is-core-module: 2.17.0
+      path-parse: 1.0.7
+      supports-preserve-symlinks-flag: 1.0.0
+
+  restore-cursor@5.1.0:
+    dependencies:
+      onetime: 7.0.0
+      signal-exit: 4.1.0
+
+  retry@0.12.0: {}
+
+  reusify@1.1.0: {}
+
+  rfdc@1.4.1: {}
+
+  rimraf@3.0.2:
+    dependencies:
+      glob: 7.2.0
+
+  robust-predicates@3.0.3:
+    optional: true
+
+  rollup@4.59.0:
+    dependencies:
+      '@types/estree': 1.0.8
+    optionalDependencies:
+      '@rollup/rollup-android-arm-eabi': 4.59.0
+      '@rollup/rollup-android-arm64': 4.59.0
+      '@rollup/rollup-darwin-arm64': 4.59.0
+      '@rollup/rollup-darwin-x64': 4.59.0
+      '@rollup/rollup-freebsd-arm64': 4.59.0
+      '@rollup/rollup-freebsd-x64': 4.59.0
+      '@rollup/rollup-linux-arm-gnueabihf': 4.59.0
+      '@rollup/rollup-linux-arm-musleabihf': 4.59.0
+      '@rollup/rollup-linux-arm64-gnu': 4.59.0
+      '@rollup/rollup-linux-arm64-musl': 4.59.0
+      '@rollup/rollup-linux-loong64-gnu': 4.59.0
+      '@rollup/rollup-linux-loong64-musl': 4.59.0
+      '@rollup/rollup-linux-ppc64-gnu': 4.59.0
+      '@rollup/rollup-linux-ppc64-musl': 4.59.0
+      '@rollup/rollup-linux-riscv64-gnu': 4.59.0
+      '@rollup/rollup-linux-riscv64-musl': 4.59.0
+      '@rollup/rollup-linux-s390x-gnu': 4.59.0
+      '@rollup/rollup-linux-x64-gnu': 4.59.0
+      '@rollup/rollup-linux-x64-musl': 4.59.0
+      '@rollup/rollup-openbsd-x64': 4.59.0
+      '@rollup/rollup-openharmony-arm64': 4.59.0
+      '@rollup/rollup-win32-arm64-msvc': 4.59.0
+      '@rollup/rollup-win32-ia32-msvc': 4.59.0
+      '@rollup/rollup-win32-x64-gnu': 4.59.0
+      '@rollup/rollup-win32-x64-msvc': 4.59.0
+      fsevents: 2.3.3
+
+  roughjs@4.6.6:
+    dependencies:
+      hachure-fill: 0.5.2
+      path-data-parser: 0.1.0
+      points-on-curve: 0.2.0
+      points-on-path: 0.2.1
+    optional: true
+
+  router@2.2.0(supports-color@7.1.0):
+    dependencies:
+      debug: 4.4.3(supports-color@7.1.0)
+      depd: 2.0.0
+      is-promise: 4.0.0
+      parseurl: 1.3.3
+      path-to-regexp: 8.4.2
+    transitivePeerDependencies:
+      - supports-color
+
+  run-parallel@1.2.0:
+    dependencies:
+      queue-microtask: 1.2.3
+
+  rw@1.3.3:
+    optional: true
+
+  rxjs@7.8.0:
+    dependencies:
+      tslib: 2.8.1
+
+  rxjs@7.8.2:
+    dependencies:
+      tslib: 2.8.1
+
+  safe-regex-test@1.1.0:
+    dependencies:
+      call-bound: 1.0.4
+      es-errors: 1.3.0
+      is-regex: 1.2.1
+
+  safer-buffer@2.1.2: {}
+
+  sass@1.90.0:
+    dependencies:
+      chokidar: 4.0.0
+      immutable: 5.1.9
+      source-map-js: 1.2.2
+    optionalDependencies:
+      '@parcel/watcher': 2.6.0
+
+  sdp-transform@3.0.0: {}
+
+  select@1.1.2:
+    optional: true
+
+  semver@6.3.0: {}
+
+  semver@6.3.1: {}
+
+  semver@7.7.2: {}
+
+  semver@7.8.5: {}
+
+  send@1.2.1(supports-color@7.1.0):
+    dependencies:
+      debug: 4.4.3(supports-color@7.1.0)
+      encodeurl: 2.0.1
+      escape-html: 1.0.3
+      etag: 1.8.1
+      fresh: 2.0.0
+      http-errors: 2.0.1
+      mime-types: 3.0.2
+      ms: 2.1.3
+      on-finished: 2.4.2
+      range-parser: 1.3.0
+      statuses: 2.0.2
+    transitivePeerDependencies:
+      - supports-color
+
+  serve-static@2.2.1(supports-color@7.1.0):
+    dependencies:
+      encodeurl: 2.0.1
+      escape-html: 1.0.3
+      parseurl: 1.3.3
+      send: 1.2.1(supports-color@7.1.0)
+    transitivePeerDependencies:
+      - supports-color
+
+  setprototypeof@1.2.0: {}
+
+  shebang-command@2.0.0:
+    dependencies:
+      shebang-regex: 3.0.0
+
+  shebang-regex@3.0.0: {}
+
+  side-channel-list@1.0.1:
+    dependencies:
+      es-errors: 1.3.0
+      object-inspect: 1.13.4
+
+  side-channel-map@1.0.1:
+    dependencies:
+      call-bound: 1.0.4
+      es-errors: 1.3.0
+      get-intrinsic: 1.3.0
+      object-inspect: 1.13.4
+
+  side-channel-weakmap@1.0.2:
+    dependencies:
+      call-bound: 1.0.4
+      es-errors: 1.3.0
+      get-intrinsic: 1.3.0
+      object-inspect: 1.13.4
+      side-channel-map: 1.0.1
+
+  side-channel@1.1.1:
+    dependencies:
+      es-errors: 1.3.0
+      object-inspect: 1.13.4
+      side-channel-list: 1.0.1
+      side-channel-map: 1.0.1
+      side-channel-weakmap: 1.0.2
+
+  signal-exit@4.1.0: {}
+
+  sigstore@4.1.1(supports-color@7.1.0):
+    dependencies:
+      '@sigstore/bundle': 4.0.0
+      '@sigstore/core': 3.2.1
+      '@sigstore/protobuf-specs': 0.5.2
+      '@sigstore/sign': 4.1.1(supports-color@7.1.0)
+      '@sigstore/tuf': 4.0.2(supports-color@7.1.0)
+      '@sigstore/verify': 3.1.1
+    transitivePeerDependencies:
+      - supports-color
+
+  simple-wcswidth@1.1.2: {}
+
+  slice-ansi@5.0.0:
+    dependencies:
+      ansi-styles: 6.0.0
+      is-fullwidth-code-point: 4.0.0
+
+  slice-ansi@7.1.0:
+    dependencies:
+      ansi-styles: 6.2.1
+      is-fullwidth-code-point: 5.1.0
+
+  slide@1.1.6: {}
+
+  smart-buffer@4.2.0: {}
+
+  socket.io-adapter@2.5.8(supports-color@7.1.0):
+    dependencies:
+      debug: 4.4.3(supports-color@7.1.0)
+      ws: 8.21.3
+    transitivePeerDependencies:
+      - bufferutil
+      - supports-color
+      - utf-8-validate
+
+  socket.io-parser@4.2.7(supports-color@7.1.0):
+    dependencies:
+      '@socket.io/component-emitter': 3.1.2
+      debug: 4.4.3(supports-color@7.1.0)
+    transitivePeerDependencies:
+      - supports-color
+
+  socket.io@4.8.4(supports-color@7.1.0):
+    dependencies:
+      accepts: 1.3.8
+      cors: 2.8.6
+      debug: 4.4.3(supports-color@7.1.0)
+      engine.io: 6.6.11(supports-color@7.1.0)
+      socket.io-adapter: 2.5.8(supports-color@7.1.0)
+      socket.io-parser: 4.2.7(supports-color@7.1.0)
+    transitivePeerDependencies:
+      - bufferutil
+      - supports-color
+      - utf-8-validate
+
+  socks-proxy-agent@8.0.3(supports-color@7.1.0):
+    dependencies:
+      agent-base: 7.1.1(supports-color@7.1.0)
+      debug: 4.4.3(supports-color@7.1.0)
+      socks: 2.8.10
+    transitivePeerDependencies:
+      - supports-color
+
+  socks@2.8.10:
+    dependencies:
+      ip-address: 10.7.5
+      smart-buffer: 4.2.0
+
+  source-map-js@1.2.2: {}
+
+  source-map-support@0.5.21:
+    dependencies:
+      buffer-from: 1.1.2
+      source-map: 0.6.0
+
+  source-map@0.6.0: {}
+
+  source-map@0.6.1: {}
+
+  source-map@0.7.6: {}
+
+  spark-md5@3.0.2: {}
+
+  spdx-exceptions@2.5.0: {}
+
+  spdx-expression-parse@4.0.0:
+    dependencies:
+      spdx-exceptions: 2.5.0
+      spdx-license-ids: 3.0.24
+
+  spdx-license-ids@3.0.24: {}
+
+  ssri@13.0.1:
+    dependencies:
+      minipass: 7.1.3
+
+  statuses@1.5.0: {}
+
+  statuses@2.0.1: {}
+
+  statuses@2.0.2: {}
+
+  stdin-discarder@0.2.2: {}
+
+  streamroller@3.1.5(supports-color@7.1.0):
+    dependencies:
+      date-format: 4.0.14
+      debug: 4.4.3(supports-color@7.1.0)
+      fs-extra: 8.1.0
+    transitivePeerDependencies:
+      - supports-color
+
+  strictdom@1.0.1:
+    optional: true
+
+  string-width@4.1.0:
+    dependencies:
+      emoji-regex: 8.0.0
+      is-fullwidth-code-point: 3.0.0
+      strip-ansi: 5.2.0
+
+  string-width@4.2.0:
+    dependencies:
+      emoji-regex: 8.0.0
+      is-fullwidth-code-point: 3.0.0
+      strip-ansi: 6.0.0
+
+  string-width@7.0.0:
+    dependencies:
+      emoji-regex: 10.3.0
+      get-east-asian-width: 1.7.0
+      strip-ansi: 7.2.0
+
+  string-width@7.2.0:
+    dependencies:
+      emoji-regex: 10.3.0
+      get-east-asian-width: 1.7.0
+      strip-ansi: 7.2.0
+
+  string-width@8.3.0:
+    dependencies:
+      get-east-asian-width: 1.7.0
+      strip-ansi: 7.2.0
+
+  strip-ansi@5.2.0:
+    dependencies:
+      ansi-regex: 4.1.0
+
+  strip-ansi@6.0.0:
+    dependencies:
+      ansi-regex: 5.0.0
+
+  strip-ansi@7.2.0:
+    dependencies:
+      ansi-regex: 6.4.0
+
+  stylis@4.4.0:
+    optional: true
+
+  sucrase@3.35.1:
+    dependencies:
+      '@jridgewell/gen-mapping': 0.3.2
+      commander: 4.0.0
+      lines-and-columns: 1.2.4
+      mz: 2.7.0
+      pirates: 4.0.7
+      tinyglobby: 0.2.17
+      ts-interface-checker: 0.1.13
+
+  supports-color@7.1.0:
+    dependencies:
+      has-flag: 4.0.0
+
+  supports-preserve-symlinks-flag@1.0.0: {}
+
+  tailwindcss@3.4.19:
+    dependencies:
+      '@alloc/quick-lru': 5.3.0
+      arg: 5.0.2
+      chokidar: 3.6.0
+      didyoumean: 1.2.2
+      dlv: 1.1.3
+      fast-glob: 3.3.3
+      glob-parent: 6.0.2
+      is-glob: 4.0.3
+      jiti: 1.21.7
+      lilconfig: 3.1.4
+      micromatch: 4.0.8
+      normalize-path: 3.0.0
+      object-hash: 3.0.0
+      picocolors: 1.1.1
+      postcss: 8.5.32
+      postcss-import: 15.1.0(postcss@8.5.32)
+      postcss-js: 4.0.1(postcss@8.5.32)
+      postcss-load-config: 6.0.1(jiti@1.21.7)(postcss@8.5.32)
+      postcss-nested: 6.2.0(postcss@8.5.32)
+      postcss-selector-parser: 6.1.5
+      resolve: 1.22.12
+      sucrase: 3.35.1
+    transitivePeerDependencies:
+      - tsx
+      - yaml
+
+  tar@7.5.22:
+    dependencies:
+      '@isaacs/fs-minipass': 4.0.1
+      chownr: 3.0.0
+      minipass: 7.1.3
+      minizlib: 3.1.0
+      yallist: 5.0.0
+
+  thenify-all@1.6.0:
+    dependencies:
+      thenify: 3.3.1
+
+  thenify@3.3.1:
+    dependencies:
+      any-promise: 1.3.0
+
+  tiny-emitter@2.1.0:
+    optional: true
+
+  tinyexec@1.3.1:
+    optional: true
+
+  tinyglobby@0.2.14:
+    dependencies:
+      fdir: 6.5.0(picomatch@4.0.4)
+      picomatch: 4.0.4
+
+  tinyglobby@0.2.17:
+    dependencies:
+      fdir: 6.5.0(picomatch@4.0.4)
+      picomatch: 4.0.4
+
+  tmp@0.2.7: {}
+
+  to-regex-range@5.0.1:
+    dependencies:
+      is-number: 7.0.0
+
+  toidentifier@1.0.1: {}
+
+  ts-dedent@2.3.0:
+    optional: true
+
+  ts-interface-checker@0.1.13: {}
+
+  tslib@2.8.1: {}
+
+  tuf-js@4.1.0(supports-color@7.1.0):
+    dependencies:
+      '@tufjs/models': 4.1.0
+      debug: 4.4.3(supports-color@7.1.0)
+      make-fetch-happen: 15.0.6(supports-color@7.1.0)
+    transitivePeerDependencies:
+      - supports-color
+
+  type-fest@0.21.3: {}
+
+  type-is@1.6.18:
+    dependencies:
+      media-typer: 0.3.0
+      mime-types: 2.1.24
+
+  type-is@2.1.0:
+    dependencies:
+      content-type: 2.1.0
+      media-typer: 1.1.1
+      mime-types: 3.0.2
+
+  typescript@5.9.2: {}
+
+  ua-parser-js@0.7.30: {}
+
+  undici-types@8.9.0: {}
+
+  undici@6.29.1: {}
+
+  unhomoglyph@1.0.6: {}
+
+  universalify@0.1.2: {}
+
+  unpipe@1.0.0: {}
+
+  update-browserslist-db@1.3.4(browserslist@4.29.5):
+    dependencies:
+      browserslist: 4.29.5
+      escalade: 3.2.0
+      picocolors: 1.1.1
+
+  util-deprecate@1.0.2: {}
+
+  utils-merge@1.0.1: {}
+
+  uuid@10.0.0: {}
+
+  uuid@14.0.3:
+    optional: true
+
+  validate-npm-package-name@6.0.0: {}
+
+  vary@1.1.2: {}
+
+  vite@7.3.7(@types/node@26.6.6)(jiti@1.21.7)(sass@1.90.0):
+    dependencies:
+      esbuild: 0.28.1
+      fdir: 6.5.0(picomatch@4.0.4)
+      picomatch: 4.0.4
+      postcss: 8.5.32
+      rollup: 4.59.0
+      tinyglobby: 0.2.17
+    optionalDependencies:
+      '@types/node': 26.6.6
+      fsevents: 2.3.3
+      jiti: 1.21.7
+      sass: 1.90.0
+
+  void-elements@2.0.1: {}
+
+  watchpack@2.4.4:
+    dependencies:
+      glob-to-regexp: 0.4.1
+      graceful-fs: 4.2.11
+
+  weak-lru-cache@1.2.2:
+    optional: true
+
+  which@1.2.1:
+    dependencies:
+      is-absolute: 0.1.7
+
+  which@2.0.1:
+    dependencies:
+      isexe: 2.0.0
+
+  which@6.0.1:
+    dependencies:
+      isexe: 4.0.0
+
+  wrap-ansi@6.2.0:
+    dependencies:
+      ansi-styles: 4.0.0
+      string-width: 4.1.0
+      strip-ansi: 6.0.0
+
+  wrap-ansi@7.0.0:
+    dependencies:
+      ansi-styles: 4.0.0
+      string-width: 4.2.0
+      strip-ansi: 6.0.0
+
+  wrap-ansi@9.0.0:
+    dependencies:
+      ansi-styles: 6.2.1
+      string-width: 7.0.0
+      strip-ansi: 7.2.0
+
+  wrappy@1.0.2: {}
+
+  write-file-atomic@1.1.4:
+    dependencies:
+      graceful-fs: 4.2.11
+      imurmurhash: 0.1.4
+      slide: 1.1.6
+
+  ws@8.21.3: {}
+
+  y18n@5.0.8: {}
+
+  yallist@3.1.1: {}
+
+  yallist@4.0.0: {}
+
+  yallist@5.0.0: {}
+
+  yargs-parser@20.2.9: {}
+
+  yargs-parser@22.0.0: {}
+
+  yargs@16.2.2:
+    dependencies:
+      cliui: 7.0.4
+      escalade: 3.2.0
+      get-caller-file: 2.0.5
+      require-directory: 2.1.1
+      string-width: 4.2.0
+      y18n: 5.0.8
+      yargs-parser: 20.2.9
+
+  yargs@18.0.0:
+    dependencies:
+      cliui: 9.0.1
+      escalade: 3.2.0
+      get-caller-file: 2.0.5
+      string-width: 7.2.0
+      y18n: 5.0.8
+      yargs-parser: 22.0.0
+
+  yargs@18.2.0:
+    dependencies:
+      cliui: 9.0.1
+      escalade: 3.2.0
+      get-caller-file: 2.0.5
+      string-width: 8.3.0
+      y18n: 5.0.8
+      yargs-parser: 22.0.0
+
+  yoctocolors-cjs@2.1.3: {}
+
+  zod-to-json-schema@3.25.2(zod@3.25.32):
+    dependencies:
+      zod: 3.25.32
+
+  zod-to-json-schema@3.25.2(zod@4.6.6):
+    dependencies:
+      zod: 4.6.6
+
+  zod@3.25.32: {}
+
+  zod@4.6.6: {}
+
+  zone.js@0.15.0: {}

+ 6 - 0
pnpm-workspace.yaml

@@ -0,0 +1,6 @@
+allowBuilds:
+  esbuild: true
+  '@parcel/watcher': true
+  lmdb: true
+  msgpackr-extract: true
+  core-js: true

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

@@ -1,5 +1,6 @@
 import {
 import {
   ApplicationConfig,
   ApplicationConfig,
+  ErrorHandler,
   provideBrowserGlobalErrorListeners,
   provideBrowserGlobalErrorListeners,
   provideZoneChangeDetection,
   provideZoneChangeDetection,
 } from '@angular/core';
 } from '@angular/core';
@@ -8,11 +9,17 @@ import { provideRouter, withInMemoryScrolling } from '@angular/router';
 import { provideMarkdown } from 'ngx-markdown';
 import { provideMarkdown } from 'ngx-markdown';
 
 
 import { provideFmodeApi } from './core/api/api.provider';
 import { provideFmodeApi } from './core/api/api.provider';
+import { AppErrorHandler } from './core/app-error-handler';
 import { routes } from './app.routes';
 import { routes } from './app.routes';
 
 
 export const appConfig: ApplicationConfig = {
 export const appConfig: ApplicationConfig = {
   providers: [
   providers: [
     provideBrowserGlobalErrorListeners(),
     provideBrowserGlobalErrorListeners(),
+    /*
+     * 全局错误处理:只静音「预期内的请求取消」(Matrix SDK 的 AbortError),
+     * 其余错误照常打印。见 `core/app-error-handler.ts`。
+     */
+    { provide: ErrorHandler, useClass: AppErrorHandler },
     provideZoneChangeDetection({ eventCoalescing: true }),
     provideZoneChangeDetection({ eventCoalescing: true }),
     provideRouter(
     provideRouter(
       routes,
       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();
     expect(fixture.nativeElement.querySelector('ui-avatar')).toBeNull();
   });
   });
 
 
-  it('文本消息有复制按钮;己方消息不出现「重新生成」', () => {
+  it('文本消息只有复制按钮(「重新生成」已移除,2026-10-31)', () => {
     render(msg());
     render(msg());
     const acts = fixture.nativeElement.querySelector('.acts');
     const acts = fixture.nativeElement.querySelector('.acts');
     expect(acts).not.toBeNull();
     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);
     render(msg(), true);
     expect(fixture.nativeElement.querySelectorAll('.acts .act').length).toBe(1);
     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');
     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">
           <span class="bubble-meta">
             <time [attr.datetime]="message().ts">{{ rel(message().ts) }}</time>
             <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>
                   <i class="fa-solid" [class]="copied() ? 'fa-check' : 'fa-copy'" aria-hidden="true"></i>
                 </button>
                 </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>
             }
             }
           </span>
           </span>
@@ -230,17 +218,17 @@ import { ToolActivityComponent } from '../tool-activity/tool-activity';
         justify-content: flex-end;
         justify-content: flex-end;
       }
       }
       /*
       /*
-       * 操作条默认收起、悬停/聚焦时淡入。
-       * 🔴 顺序重要:基础态必须写在 :hover / :focus-within **之前** ——
-       *    同优先级下后写的规则胜出,反过来写的话 hover 永远不生效(实测踩过)。
+       * 操作条常驻(2026-10-31 用户要求):时间与复制按钮**始终可见**。
+       * 原先按钮默认 opacity:0、悬停才淡入 —— 用户不知道可以复制。
+       * 常驻后保持低对比(默认 .55),悬停/聚焦时恢复全亮,避免抢正文视觉。
        */
        */
       .acts {
       .acts {
         display: inline-flex;
         display: inline-flex;
         align-items: center;
         align-items: center;
         gap: 2px;
         gap: 2px;
         margin-left: var(--sp-1);
         margin-left: var(--sp-1);
-        opacity: 0;
-        pointer-events: none;
+        opacity: 0.55;
+        pointer-events: auto;
         transition: opacity var(--motion-fast) var(--ease-standard);
         transition: opacity var(--motion-fast) var(--ease-standard);
       }
       }
       .bubble-row:hover .acts,
       .bubble-row:hover .acts,
@@ -384,15 +372,17 @@ import { ToolActivityComponent } from '../tool-activity/tool-activity';
         min-height: 0;
         min-height: 0;
         font-size: var(--fs-11);
         font-size: var(--fs-11);
         color: var(--muted);
         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;
         opacity: 1;
       }
       }
+      /* 发送中 / 失败 需要更醒目:提高对比度而不是靠悬停 */
+      .bubble-meta:has(.state) {
+        color: var(--ink-2);
+      }
       .bubble-row.own .bubble-meta {
       .bubble-row.own .bubble-meta {
         justify-content: flex-end;
         justify-content: flex-end;
       }
       }
@@ -624,8 +614,6 @@ export class MessageBubbleComponent {
   );
   );
 
 
   readonly retry = output<string>();
   readonly retry = output<string>();
-  /** 重新生成:请求以这条为上下文让容器重答(由页面决定怎么发) */
-  readonly regenerate = output<string>();
 
 
   /** 复制成功反馈(1.4s 后自动复位) */
   /** 复制成功反馈(1.4s 后自动复位) */
   protected readonly copied = signal(false);
   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 { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core';
 import { Router } from '@angular/router';
 import { Router } from '@angular/router';
 import { UiAvatar, UiButton, UiModal } from '../../../ui';
 import { UiAvatar, UiButton, UiModal } from '../../../ui';
-import { ChatSessionService } from '../../../core/chat-session.service';
 import { DeviceService } from '../../../core/device.service';
 import { DeviceService } from '../../../core/device.service';
 import { LiveService } from '../../../core/live.service';
 import { LiveService } from '../../../core/live.service';
 import { NewSessionModalService } from './new-session-modal.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>
             <i class="fa-solid fa-chevron-right pick-arrow" aria-hidden="true"></i>
           </button>
           </button>
         } @empty {
         } @empty {
-          <p class="pick-empty">没有可用的容器(需要容器已接入 Matrix)。</p>
+          <p class="pick-empty">没有可用的容器(需先在平台完成接入)。</p>
         }
         }
       </div>
       </div>
 
 
@@ -122,7 +121,6 @@ import { WorkspaceService } from '../../../core/workspace.service';
 export class NewSessionModalComponent {
 export class NewSessionModalComponent {
   protected readonly service = inject(NewSessionModalService);
   protected readonly service = inject(NewSessionModalService);
   private readonly live = inject(LiveService);
   private readonly live = inject(LiveService);
-  private readonly sessions = inject(ChatSessionService);
   private readonly router = inject(Router);
   private readonly router = inject(Router);
   private readonly device = inject(DeviceService);
   private readonly device = inject(DeviceService);
   private readonly workspaces = inject(WorkspaceService);
   private readonly workspaces = inject(WorkspaceService);
@@ -144,36 +142,18 @@ export class NewSessionModalComponent {
 
 
   protected createIn(agentId: string): void {
   protected createIn(agentId: string): void {
     const target = this.service.workspaceId();
     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 {
   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 { ChangeDetectionStrategy, Component, computed, inject, input, output, signal } from '@angular/core';
 import { NavigationEnd, Router, RouterLink } from '@angular/router';
 import { NavigationEnd, Router, RouterLink } from '@angular/router';
 import { effect } from '@angular/core';
 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 { navItemActive } from '../../../desktop/shell/dk-top-nav/dk-top-nav';
 import { ChatSession } from '../../../core/chat-session';
 import { ChatSession } from '../../../core/chat-session';
 import { ChatSessionService } from '../../../core/chat-session.service';
 import { ChatSessionService } from '../../../core/chat-session.service';
@@ -54,6 +54,7 @@ export interface NewSessionRequest {
     UiButton,
     UiButton,
     UiEmpty,
     UiEmpty,
     UiInput,
     UiInput,
+    UiModal,
     UiSkeleton,
     UiSkeleton,
     UiSwitch,
     UiSwitch,
     UiTooltip,
     UiTooltip,
@@ -330,6 +331,20 @@ export interface NewSessionRequest {
                     <ui-skeleton variant="rect" height="32px" />
                     <ui-skeleton variant="rect" height="32px" />
                   }
                   }
                 </div>
                 </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) {
               } @else if (daily().length === 0) {
                 <p class="sect-hint">日常新建的对话会出现在这里。</p>
                 <p class="sect-hint">日常新建的对话会出现在这里。</p>
               }
               }
@@ -474,6 +489,43 @@ export interface NewSessionRequest {
       }
       }
     </nav>
     </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 表示日常) -->
     <!-- 会话行模板(工作区与日常共用;owner=null 表示日常) -->
     <ng-template #row let-s let-owner="owner">
     <ng-template #row let-s let-owner="owner">
       <div
       <div
@@ -495,7 +547,7 @@ export interface NewSessionRequest {
         <span class="sess-title-wrap">
         <span class="sess-title-wrap">
           <span class="sess-title" appHoverScroll [title]="s.title">{{ s.title }}</span>
           <span class="sess-title" appHoverScroll [title]="s.title">{{ s.title }}</span>
         </span>
         </span>
-        <span class="sess-time">{{ sessionTime(s.updatedAt) }}</span>
+        <!-- 左侧列表不显示时间(2026-10-31 澄清:时间指聊天区消息):操作图标常驻 -->
         <span class="sess-acts">
         <span class="sess-acts">
           <!--
           <!--
             移动到工作区(2026-10-30):会话一旦建在日常对话里,
             移动到工作区(2026-10-30):会话一旦建在日常对话里,
@@ -523,7 +575,22 @@ export interface NewSessionRequest {
               (click)="toggleArchive(s, $event)"
               (click)="toggleArchive(s, $event)"
             />
             />
           </ui-tooltip>
           </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>
       </div>
     </ng-template>
     </ng-template>
   `,
   `,
@@ -741,16 +808,27 @@ export interface NewSessionRequest {
         display: flex;
         display: flex;
         align-items: center;
         align-items: center;
         gap: var(--sp-3);
         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);
         color: var(--ink);
-        font-size: var(--fs-13);
+        font: inherit;
+        font-size: var(--fs-14);
+        text-align: left;
         text-decoration: none;
         text-decoration: none;
+        cursor: pointer;
       }
       }
       .more-item:hover {
       .more-item:hover {
         background: var(--surface-hover);
         background: var(--surface-hover);
       }
       }
+      .more-item:focus-visible {
+        outline: 2px solid var(--primary);
+        outline-offset: -2px;
+      }
       .more-item.active {
       .more-item.active {
         background: var(--surface-active);
         background: var(--surface-active);
         font-weight: 600;
         font-weight: 600;
@@ -969,6 +1047,61 @@ export interface NewSessionRequest {
       .sect-hint.inset {
       .sect-hint.inset {
         padding-left: var(--sp-6);
         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 {
       .skel {
         display: grid;
         display: grid;
         gap: var(--sp-1);
         gap: var(--sp-1);
@@ -1095,26 +1228,24 @@ export interface NewSessionRequest {
         flex: 1;
         flex: 1;
         min-width: 0;
         min-width: 0;
       }
       }
+      /*
+       * 会话行(2026-10-31):
+       * · 操作图标常驻,默认低透明度,悬停/聚焦/选中恢复全亮
+       * · 不显示时间 —— 用户澄清「时间」指聊天区每条消息,不是左侧列表
+       */
       .sess-time {
       .sess-time {
-        font-size: var(--fs-11);
-        color: var(--faint);
-        flex-shrink: 0;
-      }
-      /* 未悬停时时间不显示:一排右对齐的灰时间戳把标题挤得只剩半截 */
-      .sess:not(:hover):not(:focus-within) .sess-time {
         display: none;
         display: none;
       }
       }
       .sess-acts {
       .sess-acts {
-        display: none;
+        display: flex;
         flex-shrink: 0;
         flex-shrink: 0;
+        opacity: 0.55;
+        transition: opacity var(--motion-fast) var(--ease-standard);
       }
       }
       .sess:hover .sess-acts,
       .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 {
       .arch-toggle {
@@ -1559,6 +1690,26 @@ export class SessionTreeComponent {
     this.navMenu.load();
     this.navMenu.load();
     // 会话字号在首帧前落位,避免进入会话页时字号"跳一下"
     // 会话字号在首帧前落位,避免进入会话页时字号"跳一下"
     this.chatFont.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(() => {
     effect(() => {
       this.workspaces();
       this.workspaces();
@@ -1575,6 +1726,9 @@ export class SessionTreeComponent {
     this._url.set(this.router.url);
     this._url.set(this.router.url);
   }
   }
 
 
+  /** 已经预热过会话目录的容器清单签名(避免 LiveService 轮询触发重复拉取) */
+  private loadedAgentsSig = '';
+
   protected readonly meName = computed(() => this.session.me()?.name ?? '未登录');
   protected readonly meName = computed(() => this.session.me()?.name ?? '未登录');
   /** 账号辅助行:不是重复主题名,而是「你在这个平台上的身份」 */
   /** 账号辅助行:不是重复主题名,而是「你在这个平台上的身份」 */
   protected readonly userRole = computed(() => (this.session.me() ? '个人账号' : '未登录'));
   protected readonly userRole = computed(() => (this.session.me() ? '个人账号' : '未登录'));
@@ -1587,6 +1741,28 @@ export class SessionTreeComponent {
    */
    */
   protected readonly daily = computed(() => this.filter(this.activeList(null)));
   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 readonly dailyArchived = computed(() => this.filter(this.archivedList(null)));
 
 
   protected sessionsIn(wsId: string): ChatSession[] {
   protected sessionsIn(wsId: string): ChatSession[] {
@@ -1609,8 +1785,36 @@ export class SessionTreeComponent {
     return sameDay ? `${hh}:${mm}` : `${d.getMonth() + 1}-${d.getDate()}`;
     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 {
   protected beginNewWorkspace(): void {
     this.prompt.open({
     this.prompt.open({
@@ -1716,13 +1920,29 @@ export class SessionTreeComponent {
 
 
   protected toggleArchive(s: ChatSession, e: Event): void {
   protected toggleArchive(s: ChatSession, e: Event): void {
     e.stopPropagation();
     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 {
   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);
         border-color: var(--nav-muted);
         background: var(--nav-active);
         background: var(--nav-active);
       }
       }
+      /*
+       * 表格(2026-10-31 修正):
+       * 文件名这类长 token 曾把列挤成一行一个字(report.htm / l 断开)。
+       * 改为:长串在单元格内任意位置换行 + 首列(通常是名称)给足宽度。
+       */
       :host ::ng-deep .md-host table {
       :host ::ng-deep .md-host table {
         width: 100%;
         width: 100%;
+        max-width: 100%;
         margin: var(--sp-2) 0;
         margin: var(--sp-2) 0;
         border-collapse: collapse;
         border-collapse: collapse;
         font-size: var(--fs-13);
         font-size: var(--fs-13);
@@ -201,6 +207,15 @@ import { MarkdownComponent } from 'ngx-markdown';
         padding: 6px 10px;
         padding: 6px 10px;
         border: 1px solid var(--edge);
         border: 1px solid var(--edge);
         text-align: left;
         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 {
       :host ::ng-deep .md-host th {
         background: var(--surface-2);
         background: var(--surface-2);

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

@@ -1,4 +1,5 @@
 import {
 import {
+  ViewEncapsulation,
   ChangeDetectionStrategy,
   ChangeDetectionStrategy,
   Component,
   Component,
   ElementRef,
   ElementRef,
@@ -7,7 +8,7 @@ import {
   signal,
   signal,
   viewChild,
   viewChild,
 } from '@angular/core';
 } from '@angular/core';
-import { SheetTable, toSheetTable } from '../../../core/spreadsheet';
+import { SheetTable, parseCsvTable, toSheetTable } from '../../../core/spreadsheet';
 
 
 /**
 /**
  * Office 预览(docx / xlsx)—— 本地解析,分页 / 表格渲染。
  * Office 预览(docx / xlsx)—— 本地解析,分页 / 表格渲染。
@@ -39,6 +40,13 @@ import { SheetTable, toSheetTable } from '../../../core/spreadsheet';
 @Component({
 @Component({
   selector: 'app-office-preview',
   selector: 'app-office-preview',
   changeDetection: ChangeDetectionStrategy.OnPush,
   changeDetection: ChangeDetectionStrategy.OnPush,
+  /*
+   * 关闭样式封装(2026-10-31):
+   * 表格由 renderSheetHtml 手动拼 DOM,元素拿不到 _ngcontent 标记,
+   * 组件内联样式(全部带作用域属性)对它们一律不生效 —— 网格线/斑马纹/选中态全丢(实测)。
+   * 样式全部以 .of-* / .sheet* / .pdf-* 前缀限定在宿主内,不污染全局。
+   */
+  encapsulation: ViewEncapsulation.None,
   template: `
   template: `
     <div class="of-wrap">
     <div class="of-wrap">
       @if (state() === 'loading') {
       @if (state() === 'loading') {
@@ -202,57 +210,93 @@ import { SheetTable, toSheetTable } from '../../../core/spreadsheet';
         color: var(--ink);
         color: var(--ink);
         font-weight: 600;
         font-weight: 600;
       }
       }
-      /* 表格区:原生滚动,铺满宽度 */
+      /*
+       * 表格区:Excel 观感(2026-10-31 重做)
+       * - 紧凑网格:1px 细线、无外框、单元格内边距贴近 Excel
+       * - 冻结:列字母行 + 行号列(sticky),滚动时始终可见
+       * - 数字右对齐 + 等宽数字;斑马纹;悬停高亮整行
+       * - 单元格选中高亮(点击),与 Excel 的选中格一致
+       */
       .sheet-scroll {
       .sheet-scroll {
         flex: 1;
         flex: 1;
         min-height: 0;
         min-height: 0;
         overflow: auto;
         overflow: auto;
         background: var(--surface);
         background: var(--surface);
+        font-variant-numeric: tabular-nums;
       }
       }
+      /* 按内容定宽(Excel 习惯),列多时横向滚动;不再强行铺满整行 */
       .sheet {
       .sheet {
-        width: 100%;
-        border-collapse: collapse;
+        width: max-content;
+        min-width: 0;
+        border-collapse: separate;
+        border-spacing: 0;
         font-size: var(--fs-12);
         font-size: var(--fs-12);
         color: var(--ink);
         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;
         white-space: nowrap;
-        /* 超长单元格截断,鼠标悬停看全文 */
         max-width: 320px;
         max-width: 320px;
+        min-width: 96px;
         overflow: hidden;
         overflow: hidden;
         text-overflow: ellipsis;
         text-overflow: ellipsis;
+        background: var(--surface);
       }
       }
-      .sheet thead th {
+      .sheet > thead > tr > th {
         position: sticky;
         position: sticky;
         top: 0;
         top: 0;
-        z-index: 1;
+        z-index: 3;
+        height: 24px;
         background: var(--surface-2);
         background: var(--surface-2);
         color: var(--muted);
         color: var(--muted);
-        font-weight: 600;
+        font-weight: 500;
+        font-size: var(--fs-11);
         text-align: center;
         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;
         position: sticky;
         left: 0;
         left: 0;
-        z-index: 1;
+        z-index: 2;
+        width: 48px;
+        min-width: 48px;
         background: var(--surface-2);
         background: var(--surface-2);
         color: var(--muted);
         color: var(--muted);
         text-align: center;
         text-align: center;
-        min-width: 44px;
         font-weight: 400;
         font-weight: 400;
+        font-size: var(--fs-11);
+        border-right: 1px solid var(--edge-strong, var(--edge));
       }
       }
       .sheet thead .row-no {
       .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 {
       .sheet tbody tr:hover td {
         background: var(--surface-hover);
         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 {
       .sheet .al-right {
         text-align: right;
         text-align: right;
         font-variant-numeric: tabular-nums;
         font-variant-numeric: tabular-nums;
@@ -313,6 +357,8 @@ export class OfficePreviewComponent {
     const table = toSheetTable(raw.name ?? 'Sheet1', (raw.rows ?? []) as never);
     const table = toSheetTable(raw.name ?? 'Sheet1', (raw.rows ?? []) as never);
     this.sheet.set(table);
     this.sheet.set(table);
     this.sheetIndex.set(index);
     this.sheetIndex.set(index);
+    await this.waitStage(token);
+    if (token !== this.loadToken) return;
     this.renderSheetHtml(table);
     this.renderSheetHtml(table);
   }
   }
 
 
@@ -325,8 +371,18 @@ export class OfficePreviewComponent {
    *    直接拼 DOM 最可靠,且文字可选中、可复制、可 Ctrl+F。
    *    直接拼 DOM 最可靠,且文字可选中、可复制、可 Ctrl+F。
    */
    */
   private renderSheetHtml(t: SheetTable): void {
   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;
     if (!host) return;
+    this.drawSheet(host, t);
+  }
+
+  private drawSheet(host: HTMLElement, t: SheetTable): void {
     this.sheetError.set('');
     this.sheetError.set('');
     host.innerHTML = '';
     host.innerHTML = '';
 
 
@@ -397,10 +453,32 @@ export class OfficePreviewComponent {
       tbody.appendChild(tr);
       tbody.appendChild(tr);
     }
     }
     table.appendChild(tbody);
     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);
     scroll.appendChild(table);
     host.appendChild(scroll);
     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> {
   async selectSheet(i: number): Promise<void> {
     if (!this.workbook) return;
     if (!this.workbook) return;
@@ -450,7 +528,22 @@ export class OfficePreviewComponent {
     await this.teardown();
     await this.teardown();
 
 
     try {
     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;
         const host = this.stageEl()?.nativeElement;
         if (!host) throw new Error('预览容器未就绪');
         if (!host) throw new Error('预览容器未就绪');
         host.innerHTML = '';
         host.innerHTML = '';

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

@@ -1,16 +1,20 @@
 import {
 import {
+  AfterViewInit,
   ChangeDetectionStrategy,
   ChangeDetectionStrategy,
   Component,
   Component,
   ElementRef,
   ElementRef,
+  OnDestroy,
   effect,
   effect,
+  inject,
   input,
   input,
+  model,
   output,
   output,
   signal,
   signal,
   viewChild,
   viewChild,
 } from '@angular/core';
 } from '@angular/core';
 
 
 /**
 /**
- * PDF 预览 —— 逐页 canvas 渲染。
+ * PDF 预览 —— 逐页 canvas 渲染(**懒渲染**)。
  *
  *
  * ## 借鉴(E:\work\lami\borin-academy-beta-version 的 pdf-preview 组件)
  * ## 借鉴(E:\work\lami\borin-academy-beta-version 的 pdf-preview 组件)
  *
  *
@@ -22,6 +26,12 @@ import {
  * 2. **worker 地址带缓存版本号** —— 企微等内置浏览器会长期复用旧 worker。
  * 2. **worker 地址带缓存版本号** —— 企微等内置浏览器会长期复用旧 worker。
  * 3. **加载失败要能重试**,且错误文案要具体(不要只写"加载失败")。
  * 3. **加载失败要能重试**,且错误文案要具体(不要只写"加载失败")。
  *
  *
+ * ## 2026-10-30 重构(《预览面板重构-设计-20261030》)
+ * - **懒渲染**:首屏页立即画,其余页用 IntersectionObserver 等进入视口附近才画;
+ *   未渲染的页面显示同尺寸灰色骨架,滚动位置不跳。
+ * - **缩放上收**:缩放比例由宿主(app-file-preview)通过 `zoom` 模型驱动,
+ *   本组件不再自带工具栏;页码与总页数通过 `page` / `pageCount` 模型回传宿主状态栏。
+ *
  * 未照搬的部分:双版本 legacy 降级(我们主用 pdf.js 6.x,现代浏览器足够;
  * 未照搬的部分:双版本 legacy 降级(我们主用 pdf.js 6.x,现代浏览器足够;
  * 真遇到老内核再加,避免一开始就把复杂度堆上来)。
  * 真遇到老内核再加,避免一开始就把复杂度堆上来)。
  */
  */
@@ -42,13 +52,6 @@ import {
           <button type="button" class="retry" (click)="reload()">重试</button>
           <button type="button" class="retry" (click)="reload()">重试</button>
         </div>
         </div>
       } @else {
       } @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-scroll" #scroll>
           <div class="pdf-pages" #pages></div>
           <div class="pdf-pages" #pages></div>
         </div>
         </div>
@@ -89,37 +92,14 @@ import {
         color: var(--primary);
         color: var(--primary);
         cursor: pointer;
         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 {
       .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;
         min-height: 0;
         overflow: auto;
         overflow: auto;
         background: var(--surface-2);
         background: var(--surface-2);
@@ -129,6 +109,7 @@ import {
       .pdf-pages {
       .pdf-pages {
         display: grid;
         display: grid;
         justify-items: center;
         justify-items: center;
+        align-content: start;
         gap: var(--sp-3);
         gap: var(--sp-3);
       }
       }
       .pdf-pages canvas {
       .pdf-pages canvas {
@@ -137,23 +118,48 @@ import {
         box-shadow: var(--shadow-card);
         box-shadow: var(--shadow-card);
         background: var(--surface);
         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 url = input.required<string>();
   readonly close = output<void>();
   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 state = signal<'loading' | 'ready' | 'error'>('loading');
   readonly error = signal('');
   readonly error = signal('');
-  readonly scale = signal(1);
-  readonly pageCount = signal(0);
 
 
   private readonly pagesEl = viewChild<ElementRef<HTMLElement>>('pages');
   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() {
   constructor() {
     effect(() => {
     effect(() => {
@@ -166,118 +172,208 @@ export class PdfPreviewComponent {
     void this.load(this.url());
     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> {
   private async load(url: string): Promise<void> {
     this.state.set('loading');
     this.state.set('loading');
     this.error.set('');
     this.error.set('');
-    this.rendered = [];
+    this.teardown();
     try {
     try {
       installPdfjsCompatPolyfills();
       installPdfjsCompatPolyfills();
       // 懒加载:pdf.js 体积大,不进首屏
       // 懒加载:pdf.js 体积大,不进首屏
       const pdfjs = await import('pdfjs-dist');
       const pdfjs = await import('pdfjs-dist');
       pdfjs.GlobalWorkerOptions.workerSrc = workerSrc();
       pdfjs.GlobalWorkerOptions.workerSrc = workerSrc();
-      this.doc?.destroy();
       const task = pdfjs.getDocument({
       const task = pdfjs.getDocument({
         url,
         url,
         verbosity: pdfjs.VerbosityLevel?.ERRORS ?? 0,
         verbosity: pdfjs.VerbosityLevel?.ERRORS ?? 0,
       } as never);
       } 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.doc = doc;
-      this.pageCount.set(doc.numPages);
       this.state.set('ready');
       this.state.set('ready');
       /*
       /*
        * 等一帧:`@else` 分支(含 .pdf-pages 容器)在本 tick 之后才插入 DOM,
        * 等一帧:`@else` 分支(含 .pdf-pages 容器)在本 tick 之后才插入 DOM,
        * 立刻查会是 null。两帧更稳(首帧布局、次帧含样式)。
        * 立刻查会是 null。两帧更稳(首帧布局、次帧含样式)。
        */
        */
       await nextFrames(2);
       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) {
     } catch (e) {
       this.error.set(pdfErrorText(e));
       this.error.set(pdfErrorText(e));
       this.state.set('error');
       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;
     if (!host) return;
+    const token = ++this.renderToken;
     host.innerHTML = '';
     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++) {
     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 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');
     const canvas = document.createElement('canvas');
     canvas.width = Math.floor(viewport.width);
     canvas.width = Math.floor(viewport.width);
     canvas.height = Math.floor(viewport.height);
     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');
     const ctx = canvas.getContext('2d');
     if (!ctx) return;
     if (!ctx) return;
     await page.render({ canvasContext: ctx, viewport }).promise;
     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) */
 /** 等 n 帧(让 Angular 把新分支渲染进 DOM) */

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

@@ -58,15 +58,21 @@ describe('acl 判级', () => {
       expect(scopeOf(r)).toBe('company');
       expect(scopeOf(r)).toBe('company');
     });
     });
 
 
-    it('有公司但无 _Admin 角色 → company-member', () => {
+    it('有公司但无 _Admin 角色 → company-member,但只请求 mine', () => {
       const r = resolveScope(me({ roles: [], companyId: 'C1' }));
       const r = resolveScope(me({ roles: [], companyId: 'C1' }));
       expect(r.level).toBe('company-member');
       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(不是管理员)', () => {
     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` | 全部容器 |
  * | superadmin | `all` | 全部容器 |
  * | company-admin | `company` | 本公司 + 自己的(兜底)|
  * | company-admin | `company` | 本公司 + 自己的(兜底)|
- * | company-member | `company` | 本公司 + 自己的(兜底)**← 当前与管理员相同** |
+ * | company-member | `mine` | **仅自己**(2026-10-31 用户裁决:普通成员不应看到同事的容器)|
  * | guest / personal | `mine` | 仅自己 |
  * | 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 {
 export function scopeOf(scope: AclScope): Scope {
   switch (scope.level) {
   switch (scope.level) {
     case 'superadmin':
     case 'superadmin':
       return 'all';
       return 'all';
     case 'company-admin':
     case 'company-admin':
-    case 'company-member':
       return 'company';
       return 'company';
+    case 'company-member':
+      /*
+       * 普通成员只看自己的(2026-10-31 用户裁决)。
+       * 🔴 不要改回 company:那会把整个公司的容器暴露给每个成员。
+       */
+      return 'mine';
     default:
     default:
       return 'mine';
       return 'mine';
   }
   }

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

@@ -2,6 +2,8 @@ import {
   Agent,
   Agent,
   ArtifactJob,
   ArtifactJob,
   AuthSession,
   AuthSession,
+  ChatThreadRecord,
+  ChatThreadUpsert,
   Deliverable,
   Deliverable,
   DeliverableGroup,
   DeliverableGroup,
   MatrixProvision,
   MatrixProvision,
@@ -56,6 +58,30 @@ export abstract class FmodeApi {
   /** 校验/刷新凭据(`validate: 1`) */
   /** 校验/刷新凭据(`validate: 1`) */
   abstract validateMatrix(agentId: string): Promise<MatrixProvision>;
   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'`) */
   /** 列表(云函数 lfYlgU7SkK,`action:'list'`) */

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

@@ -11,6 +11,8 @@ import {
   Agent,
   Agent,
   ArtifactJob,
   ArtifactJob,
   AuthSession,
   AuthSession,
+  ChatThreadRecord,
+  ChatThreadUpsert,
   Deliverable,
   Deliverable,
   DeliverableGroup,
   DeliverableGroup,
   MatrixProvision,
   MatrixProvision,
@@ -131,6 +133,63 @@ export class MockApi extends FmodeApi {
     return groups;
     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)----------
   // ---------- 任务看板(FR-07b)----------
 
 
   async fetchTasks(agentId: string): Promise<Task[]> {
   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';
       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 {
 import {
   Agent,
   Agent,
   AgentStatus,
   AgentStatus,
+  ChatThreadRecord,
+  ChatThreadUpsert,
   Artifact,
   Artifact,
   ArtifactJob,
   ArtifactJob,
   AuthSession,
   AuthSession,
@@ -56,6 +58,8 @@ const FN = {
   tokenSync: 'zl4yU8PAEB',
   tokenSync: 'zl4yU8PAEB',
   uploadUrl: 'AlP56LCKFm',
   uploadUrl: 'AlP56LCKFm',
   workgroup: 'V1EBgm05y2',
   workgroup: 'V1EBgm05y2',
+  /** 会话目录(2026-10-31 新建,Function.name = fmodeagent-chatthreads) */
+  chatThreads: 'oR5O67i1Cm',
 } as const;
 } as const;
 
 
 /** 兼容「已是对象」与「是 JSON 字符串」两种形态 */
 /** 兼容「已是对象」与「是 JSON 字符串」两种形态 */
@@ -241,6 +245,53 @@ export class ParseApi extends FmodeApi {
     return d;
     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[]> {
   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 { TestBed } from '@angular/core/testing';
 import { ChatSessionService } from './chat-session.service';
 import { ChatSessionService } from './chat-session.service';
+import { FmodeApi } from './api/fmode-api';
 import { SessionService } from './session.service';
 import { SessionService } from './session.service';
 import { legacySessionId } from './chat-session';
 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', () => {
 describe('ChatSessionService', () => {
   let svc: ChatSessionService;
   let svc: ChatSessionService;
 
 
@@ -38,18 +60,11 @@ describe('ChatSessionService', () => {
     expect(s.agentId).toBe(AGENT);
     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 的不同容器 —— 会话互不可见', () => {
   it('为什么服务端认为是同一个 agent 的不同容器 —— 会话互不可见', () => {
@@ -97,25 +112,6 @@ describe('ChatSessionService', () => {
     expect(svc.byId(s.id)!.title).toBe('做个登录页');
     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(用户主动移除)', () => {
   describe('remove(用户主动移除)', () => {
     it('移除后从列表消失', () => {
     it('移除后从列表消失', () => {
@@ -124,18 +120,25 @@ describe('ChatSessionService', () => {
       expect(svc.byId(s.id)).toBeUndefined();
       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 能查到移除记录', () => {
     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 旧消息)', () => {
   describe('历史对话(承接无 thread 旧消息)', () => {
     it('首次创建返回 created=true', () => {
     it('首次创建返回 created=true', () => {
       const r = svc.adoptLegacy(ROOM, AGENT, '旧消息预览');
       const r = svc.adoptLegacy(ROOM, AGENT, '旧消息预览');
@@ -209,21 +174,15 @@ describe('ChatSessionService', () => {
       expect(svc.listFor(AGENT).length).toBe(1);
       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 不同', () => {
     it('不同房间的历史会话 id 不同', () => {
       expect(legacySessionId('!a:x')).not.toBe(legacySessionId('!b:x'));
       expect(legacySessionId('!a:x')).not.toBe(legacySessionId('!b:x'));
     });
     });
   });
   });
 
 
   describe('持久化', () => {
   describe('持久化', () => {
-    it('新建后写入 localStorage', () => {
+    it('新建后写入 localStorage(v2 缓存键,按用户隔离)', () => {
       const s = svc.create(ROOM, AGENT);
       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(raw).withContext('未按用户隔离存储').toBeTruthy();
       expect(JSON.parse(raw!)[0].id).toBe(s.id);
       expect(JSON.parse(raw!)[0].id).toBe(s.id);
     });
     });
@@ -261,7 +220,7 @@ describe('ChatSessionService', () => {
 
 
     it('缺字段的脏数据被过滤', () => {
     it('缺字段的脏数据被过滤', () => {
       localStorage.setItem(
       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 }]),
         JSON.stringify([{ id: '$x' }, { id: '$ok', roomId: '!r:x', agentId: 'a', title: 't', createdAt: 1, updatedAt: 1 }]),
       );
       );
       svc.reset();
       svc.reset();
@@ -282,4 +241,170 @@ describe('ChatSessionService', () => {
       expect(ids.size).toBe(2);
       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 { computed, inject, Injectable, signal } from '@angular/core';
 import { SessionService } from './session.service';
 import { SessionService } from './session.service';
+import { FmodeApi } from './api/fmode-api';
+import { ChatThreadRecord, ChatThreadUpsert } from './api/models';
 import {
 import {
   ChatSession,
   ChatSession,
   createSession,
   createSession,
@@ -8,34 +10,44 @@ import {
   sortSessions,
   sortSessions,
   touchSession,
   touchSession,
 } from './chat-session';
 } 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 纯函数层(可单测)
  * @see chat-session.ts 纯函数层(可单测)
  */
  */
 @Injectable({ providedIn: 'root' })
 @Injectable({ providedIn: 'root' })
 export class ChatSessionService {
 export class ChatSessionService {
   private readonly session = inject(SessionService);
   private readonly session = inject(SessionService);
+  /**
+   * 数据访问唯一闸门(云端会话目录读写;失败静默,不影响本地)。
+   *
+   * 🔴 用 `inject(..., { optional: true })`:本服务在单测/纯本地场景下不注入
+   *    FmodeApi 也应可用(云端同步是可选的增强,不是必需依赖)。
+   */
+  private readonly api = inject(FmodeApi, { optional: true });
 
 
   private readonly _all = signal<ChatSession[]>([]);
   private readonly _all = signal<ChatSession[]>([]);
   readonly all = this._all.asReadonly();
   readonly all = this._all.asReadonly();
@@ -63,6 +75,30 @@ export class ChatSessionService {
 
 
   private loadedFor: string | null = null;
   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。
    * 用户主动移除过的会话 id。
    *
    *
@@ -71,6 +107,14 @@ export class ChatSessionService {
    */
    */
   private readonly _removed = signal<Set<string>>(new Set());
   private readonly _removed = signal<Set<string>>(new Set());
 
 
+  /**
+   * 云端已软删的线程根(跨设备有效)。
+   * 与本地 `_removed` 一起构成「哪些线程不允许重新出现」的黑名单。
+   * 注意:目录已不再从 Matrix 扫描重建,它现在的用途是兜住
+   * 「云端列表与墓碑不一致」的历史数据。
+   */
+  private cloudRemoved = new Set<string>();
+
   /**
   /**
    * 从 localStorage 载入(Shell 启动时调用一次;重复调用幂等)。
    * 从 localStorage 载入(Shell 启动时调用一次;重复调用幂等)。
    *
    *
@@ -115,10 +159,187 @@ export class ChatSessionService {
     }
     }
   }
   }
 
 
-  /** 清空内存态(登出时调用) */
+  /**
+   * 清空内存态(登出时调用)。
+   *
+   * 🔴 `_ready` 必须一起复位:否则登出再登录后,列表会凭「上一次登录的成功状态」
+   *    直接被视为可信,在新一轮云端拉取完成前就渲染出(可能属于别人的)内容。
+   */
   reset(): void {
   reset(): void {
     this.loadedFor = null;
     this.loadedFor = null;
     this._all.set([]);
     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 下的会话(默认不含归档) */
   /** 某 agent 下的会话(默认不含归档) */
@@ -147,29 +368,10 @@ export class ChatSessionService {
     const s = createSession({ roomId, agentId, title });
     const s = createSession({ roomId, agentId, title });
     this._all.update((list) => sortSessions([s, ...list]));
     this._all.update((list) => sortSessions([s, ...list]));
     this.persist();
     this.persist();
+    this.pushOne(s);
     return 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();
     const t = title.replace(/\s+/g, ' ').trim();
     if (!t) return;
     if (!t) return;
     this.update(id, { title: t, titleLocked: true });
     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 {
   archive(id: string, archived = true): void {
     this.update(id, { archived });
     this.update(id, { archived });
+    void this.trySync(() => this.api!.patchChatThread({ threadRoot: id, archived }));
   }
   }
 
 
   /**
   /**
@@ -309,77 +465,36 @@ export class ChatSessionService {
   /**
   /**
    * 从列表移除一条会话(**只移除索引,Matrix 里的消息不动**)。
    * 从列表移除一条会话(**只移除索引,Matrix 里的消息不动**)。
    *
    *
-   * 与 `discard` 的区别:`discard` 是内部清理(只清空壳,有护栏);
-   * 这里是**用户主动移除**,允许移除有消息的会话。
-   *
    * 安全性:会话列表只是「有哪些对话」的索引,消息本体在 Matrix 房间里。
    * 安全性:会话列表只是「有哪些对话」的索引,消息本体在 Matrix 房间里。
    * 移除索引后对话从列表消失,但消息**没有被删除** ——
    * 移除索引后对话从列表消失,但消息**没有被删除** ——
    * 文案必须说清楚这一点,否则用户会以为内容丢了。
    * 文案必须说清楚这一点,否则用户会以为内容丢了。
    */
    */
   remove(id: string): void {
   remove(id: string): void {
     /*
     /*
-     * 🔴 必须记进「已移除」名单 —— 否则**扫描会把它重建回来**。
+     * 🔴 必须记进「已移除」名单 —— 否则下一次从云端拉取时它会重新出现。
      *
      *
-     * 会话目录的真相源是 Matrix 线程根(`mergeScanned` 会从房间时间线还原)。
-     * 只删本地索引的话,下次打开容器一扫描,这条又回来了(实测:删了没效果)。
+     * 会话目录的真相源是云端表。只删本地索引的话,下次 `hydrateFromCloud`
+     * 拉到的还是同一批线程,这条又回来了(实测:删了没效果)。
+     * 云端删除失败时,本地黑名单是最后一道兜底。
      */
      */
+    const cur = this.byId(id); // 删除前取,删除后取不到 roomId/agentId
     this._removed.update((set) => new Set(set).add(id));
     this._removed.update((set) => new Set(set).add(id));
     this._all.update((list) => list.filter((x) => x.id !== id));
     this._all.update((list) => list.filter((x) => x.id !== id));
     this.persist();
     this.persist();
+    /*
+     * 云端删除索引:物理删除记录 + 留一条墓碑阻止扫描重建。
+     * Matrix 房间里的消息**不受影响**(会话正文仍在服务器)。
+     */
+    void this.trySync(() =>
+      this.api!.removeChatThread({ threadRoot: id, roomId: cur?.roomId, agentId: cur?.agentId }),
+    );
   }
   }
 
 
-  /** 该线程是否被用户移除过(扫描重建时跳过) */
+  /** 该线程是否被用户移除过(拉取云端时据此跳过) */
   isRemoved(id: string): boolean {
   isRemoved(id: string): boolean {
     return this._removed().has(id);
     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,调用方直接跳过读写 ——
    * 🔴 未登录时返回 null,调用方直接跳过读写 ——
    *    绝不能回落到一个共享的 `anon` 键:那样两个未登录会话、
    *    绝不能回落到一个共享的 `anon` 键:那样两个未登录会话、
    *    或「登出后再登录另一个账号」的间隙,会把别人的会话显示出来。
    *    或「登出后再登录另一个账号」的间隙,会把别人的会话显示出来。
+   *
+   * 🔴 `_v2` 后缀(2026-11-01):缓存结构语义变了(云端失败时不再展示旧数据、
+   *    空会话不再落云端),旧键里的数据按新规则会被当成脏数据,
+   *    直接换键即等于**一次性失效**,无需写迁移代码。
    */
    */
   private key(): string | null {
   private key(): string | null {
     const uid = this.session.me()?.id;
     const uid = this.session.me()?.id;
-    return uid ? `fmode_chat_sessions_${uid}` : null;
+    return uid ? `fmode_chat_sessions_v2_${uid}` : null;
   }
   }
 
 
   private persist(): void {
   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 {
 function isSession(x: unknown): x is ChatSession {
   if (!x || typeof x !== 'object') return false;
   if (!x || typeof x !== 'object') return false;

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

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

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

@@ -28,8 +28,29 @@ describe('ChatService', () => {
 
 
 
 
   /** 桩:FmodeApi */
   /** 桩: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 = {
   const apiStub = {
     provisionMatrix: jasmine.createSpy('provisionMatrix'),
     provisionMatrix: jasmine.createSpy('provisionMatrix'),
+    fetchChatThreads: jasmine.createSpy('fetchChatThreads'),
+    upsertChatThreads: jasmine.createSpy('upsertChatThreads'),
+    patchChatThread: jasmine.createSpy('patchChatThread'),
+    removeChatThread: jasmine.createSpy('removeChatThread'),
     validateMatrix: jasmine.createSpy('validateMatrix'),
     validateMatrix: jasmine.createSpy('validateMatrix'),
     fetchAgents: jasmine.createSpy('fetchAgents'),
     fetchAgents: jasmine.createSpy('fetchAgents'),
     uploadFile: jasmine.createSpy('uploadFile'),
     uploadFile: jasmine.createSpy('uploadFile'),
@@ -109,6 +130,11 @@ describe('ChatService', () => {
     // 每个用例都重置附件发送策略 —— 否则上一用例的 reject 会漏到下一个
     // 每个用例都重置附件发送策略 —— 否则上一用例的 reject 会漏到下一个
     mxStub.sendFile.and.returnValue(Promise.resolve({ event_id: '$f' }));
     mxStub.sendFile.and.returnValue(Promise.resolve({ event_id: '$f' }));
     mxStub.paginateBack.and.returnValue(Promise.resolve(true));
     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);
     liveStub.agentById.and.returnValue(AGENT);
     apiStub.provisionMatrix.and.returnValue(Promise.resolve(PROV));
     apiStub.provisionMatrix.and.returnValue(Promise.resolve(PROV));
@@ -180,6 +206,31 @@ describe('ChatService', () => {
       expect(svc.state()).toBe('empty');
       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 () => {
     it('有消息 → state=ready 且按 sender 区分 me/agent', async () => {
       realMode();
       realMode();
       // 无 thread 的消息属于「历史对话」语义 —— 给它们当前会话的 threadId 才属于本会话
       // 无 thread 的消息属于「历史对话」语义 —— 给它们当前会话的 threadId 才属于本会话
@@ -580,9 +631,16 @@ describe('ChatService', () => {
 
 
     beforeEach(() => realMode());
     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');
       await svc.open('agent-node-lj-test');
-      expect(svc.sessionId()).toBeTruthy();
+      expect(svc.sessionId()).toBe(CLOUD_THREAD.threadRoot);
       expect(svc.currentSession()).toBeDefined();
       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 { UnreadService } from './unread.service';
 import { MxMessage, MatrixClientService } from './matrix-client.service';
 import { MxMessage, MatrixClientService } from './matrix-client.service';
 import { ChatSession, deriveTitle, isAppSessionId } from './chat-session';
 import { ChatSession, deriveTitle, isAppSessionId } from './chat-session';
-import { scanSessions } from './session-scan';
 import { ChatSessionService } from './chat-session.service';
 import { ChatSessionService } from './chat-session.service';
 import { RelayService } from './relay.service';
 import { RelayService } from './relay.service';
 import { TaskPulseService } from './task-pulse.service';
 import { TaskPulseService } from './task-pulse.service';
@@ -137,6 +136,11 @@ export class ChatService {
 
 
   /** 序号防串扰(存量 seq 模式):切会话时丢弃过期回包 */
   /** 序号防串扰(存量 seq 模式):切会话时丢弃过期回包 */
   private seq = 0;
   private seq = 0;
+  /**
+   * 「新建对话」待建态指定的工作区(会话建出来后才落地)。
+   * `null` = 日常对话。只在 `startBlank()` 到首次发消息之间有效。
+   */
+  private _blankWorkspace: string | null = null;
   private provision: MatrixProvision | null = null;
   private provision: MatrixProvision | null = null;
   /** 真实态下的当前房间 id */
   /** 真实态下的当前房间 id */
   private roomId: string | null = null;
   private roomId: string | null = null;
@@ -215,15 +219,14 @@ export class ChatService {
       this.dropLegacySessions(agentId);
       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();
       const sid = this._sessionId();
       if (!sid) return;
       if (!sid) return;
@@ -244,18 +247,34 @@ export class ChatService {
     });
     });
   }
   }
 
 
+  /**
+   * 打开会话(路由进入时调用)。
+   *
   /**
   /**
    * 打开会话(路由进入时调用)。
    * 打开会话(路由进入时调用)。
    *
    *
    * @param agentId  数字生命
    * @param agentId  数字生命
    * @param sessionId 指定会话(线程根);不传则打开该 agent 最近一个会话
    * @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 (
     if (
+      !force &&
       this._agentId() === agentId &&
       this._agentId() === agentId &&
       (!sessionId || this._sessionId() === sessionId) &&
       (!sessionId || this._sessionId() === sessionId) &&
-      this._state() === 'ready'
+      loaded
     ) {
     ) {
       return;
       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);
     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 用户裁决)。
      * 🔴 「历史对话」承接机制已**整体下线**(2026-10-29 用户裁决)。
      *
      *
@@ -417,34 +496,40 @@ export class ChatService {
      */
      */
     this.dropLegacySessions(agentId);
     this.dropLegacySessions(agentId);
 
 
-    /*
-     * 🔴 **扫描重建会话目录**(Matrix 兜底)。
-     *
-     * 会话目录的正规来源是容器上报,但容器不一定上报(实测 lj-test 的上报器
-     * 根本没有会话列表字段)。而 Matrix 消息自带线程根 ——
-     * 从时间线就能还原「这个房间有哪些对话」。
-     *
-     * 只补不覆盖:容器上报过的标题更准,扫描只填缺的。
-     * 在确定当前会话**之前**做,这样用户一进来就能在左栏看到全部对话。
-     */
-    this.sessions.mergeScanned(rid, agentId, scanSessions(rawTimeline, myUserId));
-
     let sid = sessionId;
     let sid = sessionId;
     if (!sid) {
     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)) {
     } else if (!this.sessions.byId(sid)) {
       this.sessions.adopt({ id: sid, roomId: rid, agentId });
       this.sessions.adopt({ id: sid, roomId: rid, agentId });
     }
     }
     if (my !== this.seq) return;
     if (my !== this.seq) return;
     this._sessionId.set(sid);
     this._sessionId.set(sid);
-    /*
-     * 收敛列表:清掉以前反复进出留下的空壳会话(保留当前这条)。
-     *
-     * 🔴 必须在 `_sessionId` 落定**之后**做,且**只清非当前**的空会话 ——
-     *    当前这条可能也是刚新建、还没发过消息的,清了就等于把用户刚点的
-     *    「新建对话」直接抹掉(实测踩坑)。
-     */
-    this.sessions.pruneEmpty(agentId, sid);
 
 
     const all = this.mapTimeline(
     const all = this.mapTimeline(
       rawTimeline.filter((m) => this.belongsToSession(m, sid)),
       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]);
     if (body) this._messages.update((m) => [...m, msg]);
     this._draft.set('');
     this._draft.set('');
     this._sending.set(true);
     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> {
   private async sendReal(text: string): Promise<void> {
     if (!this.roomId) throw new Error('会话未就绪,请稍后重试');
     if (!this.roomId) throw new Error('会话未就绪,请稍后重试');
-    const sid = this._sessionId();
+    let sid = this._sessionId();
     if (!sid) {
     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;
     const lastId = this._messages().at(-1)?.id;
     // 自造的会话 id 还没有任何消息时,不填 m.in_reply_to(那是降级展示用的引用)
     // 自造的会话 id 还没有任何消息时,不填 m.in_reply_to(那是降级展示用的引用)
@@ -897,6 +1019,33 @@ export class ChatService {
     await this.mx.sendText(this.roomId, text, sid, last);
     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,
       accessToken,
       userId,
       userId,
       ...(deviceId ? { deviceId } : {}),
       ...(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:断线可自动重连)
     // sync 状态 → 连接状态(FR-04 AC-04.9:断线可自动重连)
     this.client.on(sdk.ClientEvent.Sync, (state: string) => {
     this.client.on(sdk.ClientEvent.Sync, (state: string) => {
       if (this.stopped) return;
       if (this.stopped) return;
@@ -664,12 +689,35 @@ export class MatrixClientService implements OnDestroy {
    *
    *
    * 🔴 Matrix 的附件地址是内部协议(`mxc://server/mediaId`),
    * 🔴 Matrix 的附件地址是内部协议(`mxc://server/mediaId`),
    *    浏览器直接打开不了,必须经 SDK 转成 `/_matrix/media/...` 的 HTTP 地址。
    *    浏览器直接打开不了,必须经 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 {
   mxcUrlToHttp(mxc: string): string | null {
     const c = this.client;
     const c = this.client;
     if (!c) return null;
     if (!c) return null;
     try {
     try {
-      return c.mxcUrlToHttp(mxc, undefined, undefined, undefined, true) ?? null;
+      return c.mxcUrlToHttp(mxc, undefined, undefined, undefined, false) ?? null;
     } catch {
     } catch {
       return null;
       return null;
     }
     }

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

@@ -44,6 +44,8 @@ export class MatrixSessionService {
   private readonly chat = inject(ChatService);
   private readonly chat = inject(ChatService);
 
 
   private booted = false;
   private booted = false;
+  /** 上一次生效的用户 id(用于检测换账号) */
+  private lastUid = '';
 
 
   /** 在 Shell 构造时调用(与 `LiveService.start()` 同级) */
   /** 在 Shell 构造时调用(与 `LiveService.start()` 同级) */
   bootstrap(): void {
   bootstrap(): void {
@@ -53,6 +55,21 @@ export class MatrixSessionService {
     effect(() => {
     effect(() => {
       const token = this.session.token();
       const token = this.session.token();
       if (token && !environment.useMockData) {
       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 映射与未读计数(未读归属需要)
         // 载入按用户隔离的 roomId 映射与未读计数(未读归属需要)
         this.chat.loadRoomMap();
         this.chat.loadRoomMap();
         this.unread.reload();
         this.unread.reload();
@@ -62,6 +79,14 @@ export class MatrixSessionService {
       } else if (!token) {
       } else if (!token) {
         this.mx.disconnect();
         this.mx.disconnect();
         this.unread.reset();
         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 表格转换。
  * Excel → HTML 表格转换。
@@ -111,4 +111,27 @@ describe('spreadsheet', () => {
       expect(toSheetTable('销售明细', []).name).toBe('销售明细');
       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;
   if (!t) return false;
   return /^[-+]?[\d,]*\.?\d+%?$/.test(t);
   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"
                     type="text"
                     [value]="newGrantee()"
                     [value]="newGrantee()"
                     (input)="newGrantee.set($any($event.target).value)"
                     (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) {
   infoOf(a: Agent) {
     return [
     return [
       { label: '当前任务', value: a.currentTask || '暂无进行中的任务' },
       { 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.heartbeatAt ? this.dt(a.heartbeatAt) : '—' },
       { label: '最近更新', value: a.updatedAt ? this.dt(a.updatedAt) : '—' },
       { 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);
     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 { TypingIndicatorComponent } from '../../../components/domain/typing-indicator/typing-indicator';
 import { ToolActivityComponent } from '../../../components/domain/tool-activity/tool-activity';
 import { ToolActivityComponent } from '../../../components/domain/tool-activity/tool-activity';
 import { FilePreviewComponent } from '../../../components/preview/file-preview/file-preview';
 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 { PresenceBadgeComponent } from '../../../components/domain/presence-badge/presence-badge';
 import { AgentPresence, agentPresence } from '../../../core/api/models';
 import { AgentPresence, agentPresence } from '../../../core/api/models';
 import { StatusBadgeComponent } from '../../../components/domain/status-badge/status-badge';
 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 { TasksService } from '../../../core/tasks.service';
 import { ChatSession } from '../../../core/chat-session';
 import { ChatSession } from '../../../core/chat-session';
 import { ChatSessionService } from '../../../core/chat-session.service';
 import { ChatSessionService } from '../../../core/chat-session.service';
+import { MatrixSessionService } from '../../../core/matrix-session.service';
 import { WorkspaceService } from '../../../core/workspace.service';
 import { WorkspaceService } from '../../../core/workspace.service';
 import { AgentModalService } from '../../../components/domain/agent-modal/agent-modal.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)));
   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 {
 interface ConversationRow {
   id: string;
   id: string;
@@ -117,11 +131,12 @@ interface ConversationRow {
     TypingIndicatorComponent,
     TypingIndicatorComponent,
     ToolActivityComponent,
     ToolActivityComponent,
     FilePreviewComponent,
     FilePreviewComponent,
+    ImageLightboxComponent,
     PresenceBadgeComponent,
     PresenceBadgeComponent,
     StatusBadgeComponent,
     StatusBadgeComponent,
   ],
   ],
   template: `
   template: `
-    <div class="conversation-layout" [style.--rail-w.px]="railWidth()">
+    <div class="conversation-layout" [style.--rail-w.px]="effectiveRailWidth()">
       <!--
       <!--
         🔴 左栏(会话树)已提升到 DesktopShell 外壳组件(2026-10-30)——
         🔴 左栏(会话树)已提升到 DesktopShell 外壳组件(2026-10-30)——
         一级路由点击时左栏必须保持不变,所以它不能挂在某个页面里。
         一级路由点击时左栏必须保持不变,所以它不能挂在某个页面里。
@@ -141,14 +156,12 @@ interface ConversationRow {
               <div class="chat-title">
               <div class="chat-title">
                 <strong>{{ a.name }}</strong>
                 <strong>{{ a.name }}</strong>
                 <!--
                 <!--
-                  当前对话名。多会话后「这个容器」不再等于「这一条对话」,
-                  必须让用户一眼看到正在聊哪一条;点标题可直接改名。
+                  当前任务名(只读展示,2026-10-31 用户要求取消命名与改名):
+                  原来这里是可点击重命名的「对话名」。现在对话由容器命名,
+                  用户不需要也不应该改 —— 去掉点击改名入口,只保留只读文案。
                 -->
                 -->
                 @if (chat.currentSession(); as s) {
                 @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>
                     <i class="fa-solid fa-comment-dots" aria-hidden="true"></i>
                     {{ s.title }}
                     {{ s.title }}
                     @if (s.archived) {
                     @if (s.archived) {
@@ -249,7 +262,7 @@ interface ConversationRow {
             <div class="banner-hold">
             <div class="banner-hold">
               @if (currentContainerState() === 'unreported' && chat.connection() === 'connected') {
               @if (currentContainerState() === 'unreported' && chat.connection() === 'connected') {
                 <ui-alert type="warning" title="容器状态未知,可能无法应答">
                 <ui-alert type="warning" title="容器状态未知,可能无法应答">
-                  已连接矩阵服务,但该容器的接入通道未上报在线状态,消息可能无人应答。
+                  该容器还没有上报在线状态,发给它的消息可能无人处理。
                 </ui-alert>
                 </ui-alert>
               } @else {
               } @else {
                 <ui-alert
                 <ui-alert
@@ -300,6 +313,20 @@ interface ConversationRow {
                 </div>
                 </div>
               }
               }
               @case ('empty') {
               @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 落地页):
                   空会话欢迎区(2026-10-29,参考 ChatGPT 落地页):
                   给「该做什么」的具体引导,而不是一句泛泛的「还没有消息」。
                   给「该做什么」的具体引导,而不是一句泛泛的「还没有消息」。
@@ -331,6 +358,7 @@ interface ConversationRow {
                     </div>
                     </div>
                   </div>
                   </div>
                 </div>
                 </div>
+                }
               }
               }
               @default {
               @default {
                 <!-- AC-04.4:向上翻历史(滚动到顶部自动加载) -->
                 <!-- AC-04.4:向上翻历史(滚动到顶部自动加载) -->
@@ -358,7 +386,6 @@ interface ConversationRow {
                       [message]="row.message!"
                       [message]="row.message!"
                       [own]="row.message!.sender === 'me'"
                       [own]="row.message!.sender === 'me'"
                       (retry)="chat.retry($event)"
                       (retry)="chat.retry($event)"
-                      (regenerate)="regenerate($event)"
                       (openTask)="openTaskRail()"
                       (openTask)="openTaskRail()"
                       (preview)="openFile($event)"
                       (preview)="openFile($event)"
                     />
                     />
@@ -389,6 +416,14 @@ interface ConversationRow {
             </div>
             </div>
           }
           }
 
 
+          <!--
+            🔴 没有选中会话时不显示输入区(2026-11-01 用户要求)。
+               空态下仍摆一个可以打字的输入框,用户会以为「直接输入就能开始」,
+               但此时没有线程根,发出去会走隐式建会话的旁路,容易产生语义混乱。
+               空态只保留说明 + 「新建对话」按钮,入口唯一。
+               例外:state 是 'ready'/'empty' 但有 currentSession(正常会话)→ 正常显示。
+          -->
+          @if (chat.currentSession()) {
           <div class="composer-hold">
           <div class="composer-hold">
           <!--
           <!--
             暂存附件条:选文件后**先停在这里**,点发送才真正上传+发送。
             暂存附件条:选文件后**先停在这里**,点发送才真正上传+发送。
@@ -398,23 +433,45 @@ interface ConversationRow {
           @if (chat.pendingFiles().length > 0) {
           @if (chat.pendingFiles().length > 0) {
             <div class="pending-holds" role="list" aria-label="待发送附件">
             <div class="pending-holds" role="list" aria-label="待发送附件">
               @for (pf of chat.pendingFiles(); track pf.id) {
               @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>
                     <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>
             </div>
           }
           }
@@ -430,6 +487,7 @@ interface ConversationRow {
             (attach)="chat.stageFiles($event)"
             (attach)="chat.stageFiles($event)"
           />
           />
           </div>
           </div>
+          }
         }
         }
       </section>
       </section>
 
 
@@ -499,23 +557,12 @@ interface ConversationRow {
           </div>
           </div>
           <div class="rail-body" [class.has-preview]="!!previewFile()">
           <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) {
             @if (previewFile(); as pf) {
               <div class="rail-preview">
               <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
                 <app-file-preview
                   [open]="true"
                   [open]="true"
                   [inline]="true"
                   [inline]="true"
@@ -625,7 +672,13 @@ interface ConversationRow {
                   <p class="rail-empty">该数字生命暂无交付成果。</p>
                   <p class="rail-empty">该数字生命暂无交付成果。</p>
                 } @else {
                 } @else {
                   @for (d of agentDeliverables(); track d.id) {
                   @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">
                       <span class="file-icon" aria-hidden="true">
                         <i class="fa-solid" [class]="fileIcon(d.fileType)"></i>
                         <i class="fa-solid" [class]="fileIcon(d.fileType)"></i>
                       </span>
                       </span>
@@ -654,27 +707,8 @@ interface ConversationRow {
                           </small>
                           </small>
                         }
                         }
                       </span>
                       </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>
               </section>
@@ -685,24 +719,45 @@ interface ConversationRow {
       }
       }
     </div>
     </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()"
       [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),不再使用全屏遮罩弹窗。
       消息附件预览已改为**右栏内联**(2026-10-30),不再使用全屏遮罩弹窗。
       见右栏 .rail-preview 区块。
       见右栏 .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)——
       新建对话模态框已移到外壳(app-new-session-modal)——
@@ -728,22 +783,7 @@ interface ConversationRow {
       </div>
       </div>
     </ui-modal>
     </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: [
   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 {
       .rail-preview {
         display: flex;
         display: flex;
         flex-direction: column;
         flex-direction: column;
@@ -862,25 +916,22 @@ interface ConversationRow {
         min-height: 0;
         min-height: 0;
         overflow: hidden;
         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 {
       .rail-preview app-file-preview {
-        flex: 1;
+        /* flex 链而非 display:block:块级宿主会按内容塌缩(实测面板高度随缩放变化) */
+        flex: 1 1 0;
         min-height: 0;
         min-height: 0;
-        display: block;
+        overflow: hidden;
       }
       }
       /* 预览期间隐藏标签条 —— 已经在看具体文件了,切标签会让预览莫名消失 */
       /* 预览期间隐藏标签条 —— 已经在看具体文件了,切标签会让预览莫名消失 */
       .rail-col:has(.rail-preview) .rail-tabs {
       .rail-col:has(.rail-preview) .rail-tabs {
@@ -1176,7 +1227,29 @@ interface ConversationRow {
         margin-top: var(--sp-3);
         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 {
       .pending-holds {
@@ -1219,6 +1292,56 @@ interface ConversationRow {
         flex: none;
         flex: none;
         background: var(--surface-2);
         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 {
       .pending-name {
         min-width: 0;
         min-width: 0;
@@ -1282,53 +1405,21 @@ interface ConversationRow {
         color: var(--faint);
         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 {
       .session-name {
         display: inline-flex;
         display: inline-flex;
         align-items: center;
         align-items: center;
         gap: var(--sp-1);
         gap: var(--sp-1);
-        cursor: pointer;
-        border-radius: var(--r-sm);
-        padding: 0 var(--sp-1);
+        cursor: default;
       }
       }
       .session-name:hover {
       .session-name:hover {
         color: var(--ink-2);
         color: var(--ink-2);
-        background: var(--surface-hover);
-      }
-      .session-name:focus-visible {
-        outline: 2px solid var(--primary);
-        outline-offset: 1px;
       }
       }
       .archived-tag {
       .archived-tag {
         margin-left: var(--sp-1);
         margin-left: var(--sp-1);
@@ -1817,24 +1908,6 @@ interface ConversationRow {
       .rail-item:hover {
       .rail-item:hover {
         background: var(--surface-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 {
       .file-icon {
         display: grid;
         display: grid;
         place-items: center;
         place-items: center;
@@ -1877,6 +1950,8 @@ interface ConversationRow {
 export class DkChatPage {
 export class DkChatPage {
   readonly chat = inject(ChatService);
   readonly chat = inject(ChatService);
   readonly live = inject(LiveService);
   readonly live = inject(LiveService);
+  /** 重连时重建常驻 Matrix 连接(登出/断线后需要) */
+  private readonly matrixSession = inject(MatrixSessionService);
   private readonly unread = inject(UnreadService);
   private readonly unread = inject(UnreadService);
   private readonly deliverables = inject(DeliverablesService);
   private readonly deliverables = inject(DeliverablesService);
   private readonly tasks = inject(TasksService);
   private readonly tasks = inject(TasksService);
@@ -1897,6 +1972,14 @@ export class DkChatPage {
    * `undefined` = 未打开;`null` = 日常对话;`string` = 工作区 id。
    * `undefined` = 未打开;`null` = 日常对话;`string` = 工作区 id。
    */
    */
   readonly newSessionWorkspace = signal<string | null | undefined>(undefined);
   readonly newSessionWorkspace = signal<string | null | undefined>(undefined);
+
+  /** 「新建对话」模态框(左栏按钮与空态引导共用) */
+  private readonly newSessionModal = inject(NewSessionModalService);
+
+  /** 空态里点「新建对话」→ 打开容器选择框 */
+  requestNewSession(): void {
+    this.newSessionModal.open(null);
+  }
   readonly railOpen = signal(true);
   readonly railOpen = signal(true);
   /** 右栏内联预览的成果 */
   /** 右栏内联预览的成果 */
   readonly previewing = signal<Deliverable | null>(null);
   readonly previewing = signal<Deliverable | null>(null);
@@ -1938,7 +2021,9 @@ export class DkChatPage {
   private resizeStartX = 0;
   private resizeStartX = 0;
   private readonly onResizeMove = (e: PointerEvent) => {
   private readonly onResizeMove = (e: PointerEvent) => {
     /* 向左拖 = 变宽:宽度增长量是「起点 X - 当前 X」 */
     /* 向左拖 = 变宽:宽度增长量是「起点 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);
     this._railWidth.set(next);
   };
   };
   private readonly onResizeEnd = () => {
   private readonly onResizeEnd = () => {
@@ -1984,8 +2069,6 @@ export class DkChatPage {
   readonly expandedAgents = signal<Set<string>>(new Set());
   readonly expandedAgents = signal<Set<string>>(new Set());
   /** 左栏:哪些容器的「已归档」目录是展开的 */
   /** 左栏:哪些容器的「已归档」目录是展开的 */
   readonly archivedOpen = signal<Set<string>>(new Set());
   readonly archivedOpen = signal<Set<string>>(new Set());
-  /** 正在重命名的会话(null = 无) */
-  readonly renaming = signal<{ id: string; title: string } | null>(null);
   /** 待确认移除的会话(null = 无) */
   /** 待确认移除的会话(null = 无) */
   readonly removing = signal<ChatSession | null>(null);
   readonly removing = signal<ChatSession | null>(null);
   /** 当前数字生命的任务(右栏任务面板用) */
   /** 当前数字生命的任务(右栏任务面板用) */
@@ -2134,11 +2217,53 @@ export class DkChatPage {
    * 🔴 必须同时**打开右栏**:预览已经改成右栏内联,
    * 🔴 必须同时**打开右栏**:预览已经改成右栏内联,
    *    若用户之前收起了右栏,只设 previewFile 是看不到任何反应的(实测 bug)。
    *    若用户之前收起了右栏,只设 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.previewFile.set(f);
     this.railOpen.set(true);
     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 的占位值) */
   /** 真实未读数(UnreadService 唯一来源;不用 Agent.unread 的占位值) */
   unreadOf(agentId: string): number {
   unreadOf(agentId: string): number {
     return this.unread.countOf(agentId);
     return this.unread.countOf(agentId);
@@ -2263,15 +2388,17 @@ export class DkChatPage {
       this.tasks.ensureAllLoaded(),
       this.tasks.ensureAllLoaded(),
     ]);
     ]);
     if (id) {
     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();
       void this.fillViewportIfNeeded();
     } else {
     } else {
@@ -2368,27 +2495,6 @@ export class DkChatPage {
     this.agentModal.open(agentId);
     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 条起点建议。
    * 空会话欢迎区:按小时问候 + 4 条起点建议。
    *
    *
@@ -2455,34 +2561,16 @@ export class DkChatPage {
   newSession(agentId: string, event?: Event, workspaceId: string | null = null): void {
   newSession(agentId: string, event?: Event, workspaceId: string | null = null): void {
     event?.stopPropagation();
     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.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 {
   sessionTime(ts: number): string {
     if (!ts) return '';
     if (!ts) return '';
@@ -2568,14 +2642,23 @@ export class DkChatPage {
     await this.live.refresh();
     await this.live.refresh();
   }
   }
 
 
+  /**
+   * 重连 / 重新加载。
+   *
+   * 🔴 必须走 `force`:`chat.open()` 在「同一 agent + 同一会话 + state=ready」时
+   *    会短路返回,而断线时 state 仍是 ready —— 不 force 的话按了完全没有反应
+   *    (用户实测:「点击这个重新连接没有任何效果」)。
+   */
   reconnect(): void {
   reconnect(): void {
     const id = this.chat.agentId();
     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 {
   retryOpen(): void {
     const id = this.chat.agentId();
     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]);
     if (id) void this.router.navigate(['/', this.device.mode(), 'tasks', id]);
   }
   }
 
 
-  /** 右栏内联预览 */
+  /**
+   * 成果预览(2026-10-31 用户要求):改为**右栏内联**,与消息附件共用同一个预览面板。
+   *
+   * 原来走全屏遮罩弹窗,和右侧预览是两套观感;现在统一走 `previewFile` 这条链路。
+   * 产物还没有 url(仍在容器里)时如实提示,不给空白预览。
+   */
   preview(d: Deliverable): void {
   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 {
   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 { UiAlert, UiAvatar, UiButton, UiEmpty, UiSkeleton } from '../../../ui';
 import { MessageBubbleComponent } from '../../../components/domain/message-bubble/message-bubble';
 import { MessageBubbleComponent } from '../../../components/domain/message-bubble/message-bubble';
 import { FilePreviewComponent } from '../../../components/preview/file-preview/file-preview';
 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 { Agent, Message } from '../../../core/api/models';
 import { ChatService } from '../../../core/chat.service';
 import { ChatService } from '../../../core/chat.service';
 import { crossOwnerWarning, detectCrossOwner } from '../../../core/cross-owner';
 import { crossOwnerWarning, detectCrossOwner } from '../../../core/cross-owner';
@@ -58,7 +60,7 @@ import { FmodeApi } from '../../../core/api/fmode-api';
 @Component({
 @Component({
   selector: 'app-dk-group-page',
   selector: 'app-dk-group-page',
   changeDetection: ChangeDetectionStrategy.OnPush,
   changeDetection: ChangeDetectionStrategy.OnPush,
-  imports: [UiAlert, UiAvatar, UiButton, UiEmpty, UiSkeleton, MessageBubbleComponent, FilePreviewComponent],
+  imports: [UiAlert, UiAvatar, UiButton, UiEmpty, UiSkeleton, MessageBubbleComponent, FilePreviewComponent, ImageLightboxComponent],
   template: `
   template: `
     <div class="group-layout">
     <div class="group-layout">
       <!-- 群列表 -->
       <!-- 群列表 -->
@@ -566,6 +568,15 @@ import { FmodeApi } from '../../../core/api/fmode-api';
         </aside>
         </aside>
       }
       }
 
 
+      <!-- 图片灯箱(2026-10-30):图片不进预览弹窗 -->
+      <app-image-lightbox
+        [open]="!!lightboxFile()"
+        [url]="lightboxFile()?.url ?? ''"
+        [name]="lightboxFile()?.name ?? ''"
+        [size]="lightboxFile()?.size ?? 0"
+        (closed)="lightboxFile.set(null)"
+      />
+
       <!-- 附件预览:与 DM 共用同一个预览组件 -->
       <!-- 附件预览:与 DM 共用同一个预览组件 -->
       <app-file-preview
       <app-file-preview
         [open]="!!previewFile()"
         [open]="!!previewFile()"
@@ -1220,9 +1231,10 @@ export class DkGroupPage {
    *   你问「做个 Excel 表格」→ 它反问一堆 → 你说「随机即可」→ 它答「不知道你在说什么」。
    *   你问「做个 Excel 表格」→ 它反问一堆 → 你说「随机即可」→ 它答「不知道你在说什么」。
    *
    *
    * 组长制并不能替代上下文:组长也是容器,同样没有跨消息记忆。
    * 组长制并不能替代上下文:组长也是容器,同样没有跨消息记忆。
-   * 所以默认带上最近对话,用户可在设置里关掉(省 token)。
+   * 所以**始终带上最近对话**(2026-11-01 用户要求删掉开关):
+   * 关掉后的表现是「答非所问」,等于坏掉,没有保留成选项的意义。
    */
    */
-  readonly attachContext = signal(this.readContextPref());
+  readonly attachContext = true;
   readonly mentionTargets = signal<Set<string>>(new Set());
   readonly mentionTargets = signal<Set<string>>(new Set());
   /** 上滚加载历史中(防重入) */
   /** 上滚加载历史中(防重入) */
   readonly paginating = signal(false);
   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);
   readonly editing = signal<Agent | null>(null);
@@ -1942,8 +1942,15 @@ export class DkGroupPage {
 
 
   /** 消息附件预览(与 DM 共用同一个预览器) */
   /** 消息附件预览(与 DM 共用同一个预览器) */
   readonly previewFile = signal<{ url: string; name: string; mimeType: string } | null>(null);
   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);
     this.previewFile.set(f);
   }
   }
 
 
@@ -2032,7 +2039,7 @@ export class DkGroupPage {
         ? targets.length === 1 && targets[0].id === leaderNow.id
         ? targets.length === 1 && targets[0].id === leaderNow.id
         : targets.length <= 1;
         : targets.length <= 1;
       const ctx = toLeader
       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,
   DEFAULT_CHAT_FONT,
 } from '../../../core/chat-font.service';
 } from '../../../core/chat-font.service';
 import { SessionService } from '../../../core/session.service';
 import { SessionService } from '../../../core/session.service';
-import { environment } from '../../../../environments/environment';
 
 
 /**
 /**
  * 桌面·我的(SPEC FR-10)
  * 桌面·我的(SPEC FR-10)
@@ -29,8 +28,8 @@ import { environment } from '../../../../environments/environment';
   template: `
   template: `
     <app-page [wide]="true">
     <app-page [wide]="true">
       <div class="settings-layout">
       <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="card-kicker"><i class="fa-solid fa-shield-halved" aria-hidden="true"></i>账户中心</div>
           <div class="identity">
           <div class="identity">
             <ui-avatar [name]="me()?.name ?? '?'" size="lg" />
             <ui-avatar [name]="me()?.name ?? '?'" size="lg" />
@@ -141,43 +140,42 @@ import { environment } from '../../../../environments/environment';
             </div>
             </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>
 
 
-          <ui-card class="settings-card" title="账号与凭证">
-            <div class="setting-row">
+          <ui-card class="settings-card" title="登录凭证">
+            <div class="setting-block">
               <div class="setting-copy">
               <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>
               </div>
-              <!--
-                「修复 Token」= 跳回登录页重新获取凭证。
-                原名含糊(用户实测问「这是什么意思」),故改名 + 补说明。
-              -->
-              <ui-button variant="outline" icon="fa-solid fa-rotate" (click)="fixToken()">
-                重新登录
-              </ui-button>
             </div>
             </div>
           </ui-card>
           </ui-card>
 
 
           <footer class="about">
           <footer class="about">
-            <span>Fmode <span class="mono">{{ version }}</span></span>
+            <span>Fmode</span>
           </footer>
           </footer>
         </div>
         </div>
       </div>
       </div>
@@ -248,23 +246,29 @@ import { environment } from '../../../../environments/environment';
         gap: var(--sp-4);
         gap: var(--sp-4);
         align-items: start;
         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);
           grid-template-columns: minmax(0, 1fr);
         }
         }
+        /* 单列时不需要粘住:会把右侧内容压到屏幕外 */
+        .settings-aside {
+          position: static;
+        }
       }
       }
 
 
       .identity {
       .identity {
@@ -350,6 +354,32 @@ import { environment } from '../../../../environments/environment';
         color: var(--muted);
         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 {
       .palettes {
         display: grid;
         display: grid;
@@ -450,10 +480,6 @@ import { environment } from '../../../../environments/environment';
         font-size: var(--fs-11);
         font-size: var(--fs-11);
         color: var(--muted);
         color: var(--muted);
       }
       }
-      .mono {
-        font-family: var(--mono, monospace);
-        color: var(--ink-2);
-      }
 
 
       .dialog-text {
       .dialog-text {
         margin: 0 0 var(--sp-4);
         margin: 0 0 var(--sp-4);
@@ -523,32 +549,44 @@ export class DkMinePage {
 
 
   readonly palettes = PALETTES;
   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 = [
   readonly themeOptions = [
     { label: '浅色', value: 'light' },
     { label: '浅色', value: 'light' },
     { label: '深色', value: 'dark' },
     { label: '深色', value: 'dark' },
@@ -594,10 +632,12 @@ export class DkMinePage {
     if (meta && canvas) meta.setAttribute('content', canvas);
     if (meta && canvas) meta.setAttribute('content', canvas);
   }
   }
 
 
-  fixToken(): void {
-    void this.router.navigate(['/login'], { queryParams: { redirect: this.router.url } });
-  }
-
+  /**
+   * 「重新登录」按钮已移除(2026-11-01 用户裁决:没有实际价值)。
+   *
+   * 它原本只是「清态 + 回登录页」,与左侧的「退出登录」功能重叠 ——
+   * 用户要换账号或换凭据,直接退出登录即可,多一个入口只会让人困惑该点哪个。
+   */
   doLogout(): void {
   doLogout(): void {
     this.session.logout();
     this.session.logout();
     this.confirming.set(false);
     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);
         gap: var(--sp-4);
         margin-bottom: var(--sp-4);
         margin-bottom: var(--sp-4);
         flex-wrap: wrap;
         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 {
       .total {
         font-size: var(--fs-13);
         font-size: var(--fs-13);
@@ -215,24 +224,49 @@ import { Deliverable } from '../../../core/api/models';
         grid-template-columns: minmax(240px, 26%) minmax(0, 1fr);
         grid-template-columns: minmax(240px, 26%) minmax(0, 1fr);
         gap: var(--sp-4);
         gap: var(--sp-4);
         align-items: start;
         align-items: start;
+        /*
+         * 两栏各自滚动的高度上限(2026-11-01 用户要求)。
+         *
+         * 用「视口高度 − 该页滚动区已占用的部分」而不是固定值:
+         * 顶栏(约 68) + 页头标题(约 96) + 工具栏(约 56) + 页面内边距(约 48)。
+         * 这个和一旦与实际布局不符,两栏底部就会被截断或留白。
+         */
+        --pane-offset: 268px;
+        height: calc(100dvh - var(--pane-offset));
       }
       }
       @include bp.dk-below-3col {
       @include bp.dk-below-3col {
         .split {
         .split {
           grid-template-columns: minmax(0, 1fr);
           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 {
       .list-pane {
         display: grid;
         display: grid;
         gap: var(--sp-2);
         gap: var(--sp-2);
         align-content: start;
         align-content: start;
-        max-height: calc(100dvh - 260px);
+        height: 100%;
         overflow-y: auto;
         overflow-y: auto;
         padding-right: var(--sp-1);
         padding-right: var(--sp-1);
+        scroll-padding-top: var(--sp-2);
       }
       }
       .detail-pane {
       .detail-pane {
         min-width: 0;
         min-width: 0;
+        height: 100%;
+        overflow-y: auto;
+        padding-right: var(--sp-1);
       }
       }
 
 
       .project-row {
       .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"
     <app-page [fixedHeader]="true"
       title="任务看板"
       title="任务看板"
       subtitle="关注真实任务的状态、运行过程与阻塞原因。左侧选择任务查看详情。"
       subtitle="关注真实任务的状态、运行过程与阻塞原因。左侧选择任务查看详情。"
-      [showBack]="true" backText="返回" (back)="goBack()"
     >
     >
       <ng-container pageActions>
       <ng-container pageActions>
         <ui-tag label="只读视图" icon="fa-solid fa-lock" size="sm" />
         <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 {
   openChat(agentId: string): void {
     void this.router.navigate(['/', this.device.mode(), 'chat', agentId]);
     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>
         </div>
         <dl class="kv">
         <dl class="kv">
           <div><dt>连接状态</dt><dd>{{ ready(a) ? '已接入' : '未接入' }}</dd></div>
           <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>
           <div><dt>最近心跳</dt><dd>{{ a.heartbeatAt || '—' }}</dd></div>
         </dl>
         </dl>
         <p class="drawer-note">此处仅展示连接信息,不展示任何凭证。</p>
         <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 { ComposerComponent } from '../../../components/domain/composer/composer';
 import { MessageBubbleComponent } from '../../../components/domain/message-bubble/message-bubble';
 import { MessageBubbleComponent } from '../../../components/domain/message-bubble/message-bubble';
 import { FilePreviewComponent } from '../../../components/preview/file-preview/file-preview';
 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 { TypingIndicatorComponent } from '../../../components/domain/typing-indicator/typing-indicator';
 import { UiMButton, UiMNoticeBar, UiMPopup, UiMSkeleton } from '../../../ui';
 import { UiMButton, UiMNoticeBar, UiMPopup, UiMSkeleton } from '../../../ui';
 import { Deliverable } from '../../../core/api/models';
 import { Deliverable } from '../../../core/api/models';
@@ -34,7 +36,7 @@ import { ChatSessionService } from '../../../core/chat-session.service';
 @Component({
 @Component({
   selector: 'app-mb-chat-page',
   selector: 'app-mb-chat-page',
   changeDetection: ChangeDetectionStrategy.OnPush,
   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: `
   template: `
     <div class="full-chat m-page--fixed">
     <div class="full-chat m-page--fixed">
       <header class="chat-head">
       <header class="chat-head">
@@ -262,6 +264,14 @@ import { ChatSessionService } from '../../../core/chat-session.service';
       [mimeType]="filePreview()?.mimeType ?? ''"
       [mimeType]="filePreview()?.mimeType ?? ''"
       (closed)="filePreview.set(null)"
       (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: [
   styles: [
     `
     `
@@ -718,6 +728,8 @@ export class MbChatPage implements OnInit {
   readonly previewing = signal<Deliverable | null>(null);
   readonly previewing = signal<Deliverable | null>(null);
   /** 消息附件预览对象(与桌面端同一套 app-file-preview) */
   /** 消息附件预览对象(与桌面端同一套 app-file-preview) */
   readonly filePreview = signal<{ url: string; name: string; mimeType: string } | null>(null);
   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');
   private readonly messagesEl = viewChild<ElementRef<HTMLElement>>('messages');
@@ -749,9 +761,11 @@ export class MbChatPage implements OnInit {
     this.route.paramMap.subscribe((params) => {
     this.route.paramMap.subscribe((params) => {
       const id = params.get('id');
       const id = params.get('id');
       if (!id) return;
       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);
     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) -->
       <!-- 底部说明(原型 .list-foot) -->
       <footer class="list-foot">
       <footer class="list-foot">
         <i class="fa-solid fa-lock" aria-hidden="true"></i>
         <i class="fa-solid fa-lock" aria-hidden="true"></i>
-        <span>消息经 Matrix 通道加密传输</span>
+        <span>消息安全传输</span>
       </footer>
       </footer>
     </div>
     </div>
   `,
   `,
@@ -455,12 +455,15 @@ export class MbMessagesPage implements OnInit {
     return this.expanded().has(agentId);
     return this.expanded().has(agentId);
   }
   }
 
 
-  /** 新建对话并直接进入 */
+  /**
+   * 新建对话并直接进入(**不创建会话**,2026-11-01)。
+   *
+   * 只是跳到「待建」地址,用户发出第一条消息时才真正建会话 ——
+   * 避免「点了又反悔」在列表里留下 0 条消息的空壳。
+   */
   newSession(agentId: string, event?: Event): void {
   newSession(agentId: string, event?: Event): void {
     event?.stopPropagation();
     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 { PaletteService, PALETTES } from '../../../core/palette.service';
 import { SessionService } from '../../../core/session.service';
 import { SessionService } from '../../../core/session.service';
 import { DeviceService } from '../../../core/device.service';
 import { DeviceService } from '../../../core/device.service';
-import { environment } from '../../../../environments/environment';
 
 
 /**
 /**
  * 移动·我的(SPEC FR-10 移动形态)
  * 移动·我的(SPEC FR-10 移动形态)
@@ -83,16 +82,20 @@ import { environment } from '../../../../environments/environment';
         </div>
         </div>
       </section>
       </section>
 
 
-      <!-- 凭证 -->
+      <!-- 凭证(2026-11-01:可查看登录 Token;重新登录改为真正清态重登) -->
       <section class="card">
       <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>
           </div>
-          <!-- 「修复 Token」= 跳登录页重新取证;原名含糊,已改为「重新登录」 -->
-          <ui-m-button variant="secondary" (click)="fixToken()">重新登录</ui-m-button>
         </div>
         </div>
       </section>
       </section>
 
 
@@ -102,7 +105,7 @@ import { environment } from '../../../../environments/environment';
       </button>
       </button>
 
 
       <footer class="about">
       <footer class="about">
-        <span>Fmode <span class="mono">{{ version }}</span></span>
+        <span>Fmode</span>
       </footer>
       </footer>
     </div>
     </div>
 
 
@@ -414,15 +417,6 @@ import { environment } from '../../../../environments/environment';
         font-size: var(--fs-11);
         font-size: var(--fs-11);
         color: var(--muted);
         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 {
       .logout-backdrop {
         position: fixed;
         position: fixed;
         inset: 0;
         inset: 0;
@@ -496,7 +490,6 @@ export class MbMinePage {
   onThemeIndex(i: number): void {
   onThemeIndex(i: number): void {
     this.setTheme(i === 0 ? 'light' : 'dark');
     this.setTheme(i === 0 ? 'light' : 'dark');
   }
   }
-  readonly version = environment.version;
   readonly confirming = signal(false);
   readonly confirming = signal(false);
 
 
   readonly me = computed(() => this.session.me());
   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> {
   async doLogout(): Promise<void> {
     this.session.logout();
     this.session.logout();
     this.confirming.set(false);
     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>
         </div>
         <h3 class="sheet-section-title">连接信息</h3>
         <h3 class="sheet-section-title">连接信息</h3>
         <dl class="kv">
         <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>
           <div><dt>数字生命</dt><dd>{{ a.name }}</dd></div>
         </dl>
         </dl>
         <p class="sheet-note">此处仅展示连接信息,不展示任何凭证。</p>
         <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);
     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=` 自动登录并清空地址栏 */
   /** AC-02.2:支持 `#token=` / `?token=` 自动登录并清空地址栏 */
   private async initFromUrl(): Promise<void> {
   private async initFromUrl(): Promise<void> {
+    if (this.redirectIfLoggedIn()) return;
     const hash = typeof location !== 'undefined' ? location.hash : '';
     const hash = typeof location !== 'undefined' ? location.hash : '';
     const query = this.route.snapshot.queryParamMap;
     const query = this.route.snapshot.queryParamMap;
     const urlToken = query.get('token') ?? (/^#token=(.+)$/.exec(hash)?.[1] ?? '');
     const urlToken = query.get('token') ?? (/^#token=(.+)$/.exec(hash)?.[1] ?? '');

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