Explorar o código

feat(workspace): 工作区上云,新建 FmodeWorkspace 表

背景:工作区此前只存 localStorage,换设备后文件夹全丢,
而会话的 workspaceId 还在云端——归属无处安放,只能落进「孤儿」区。

选型核查:逐表检查平台 873 张业务表 + 5 张 Fmode 表,无一张可复用。
- FmodeWorkGroup 是群聊表(roomId/members/goal),语义不同不能混用
- FmodeChatThread 是会话表,硬塞会破坏「一行一会话」,且空工作区/改名无处存放

云端(云函数 v0.1.5 已热更,复用 oR5O67i1Cm 不新开函数)
- 新表 FmodeWorkspace:user / wsId / name / createdAtMs / isDeleted
- 新增 op:wsList / wsUpsert(按 wsId 幂等)/ wsRemove(软删)

客户端
- WorkspaceService 接云端,syncFromCloud() 以云端为准
- 首次迁移:云端为空而本地有数据时整批推上去(老用户不丢)
- create/rename/remove 同步回写
- 离线时仍可用本地(工作区是轻量组织数据,不做强一致)

验证:云函数端到端 5/5;tsc 0 错误;单测 1076/1076;构建通过;verify:ui 12/12

Co-Authored-By: Claude Code <noreply@anthropic.com>
Huccct hai 23 horas
pai
achega
c15494c0f2

+ 339 - 0
cloud/backup/oR5O67i1Cm-fmodeagent-chatthreads-v0.1.4.js

@@ -0,0 +1,339 @@
+// 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
+//   workspaceId String(归属工作区;'' = 日常对话)— v0.1.4 新增
+//   唯一语义:(user, 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') || '',
+        // v0.1.4:工作区归属随列表返回,客户端据此还原层级
+        workspaceId: r.get('workspaceId') || '',
+      };
+    }),
+  });
+}
+
+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));
+    /*
+     * v0.1.4:工作区归属。
+     * 🔴 用 `!= null` 判定而不是真值判断 —— '' 是合法值(代表「移回日常对话」),
+     *    用 if (it.workspaceId) 会把「移回日常」当成「没传」,归属永远清不掉。
+     */
+    if (it.workspaceId !== undefined && it.workspaceId !== null) {
+      rec.set('workspaceId', _str(it.workspaceId, 120));
+    }
+    // 🔴 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);
+  /* v0.1.4:移动工作区单独走 patch,'' = 移回日常对话 */
+  if (p.workspaceId !== undefined && p.workspaceId !== null) {
+    rec.set('workspaceId', _str(p.workspaceId, 120));
+  }
+  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)));
+  }
+}

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

@@ -310,6 +310,91 @@ async function opRemove(userPtr, p) {
   return _ok({ removed: rows.length - (tomb ? 1 : 0), tomb: true });
 }
 
+/**
+ * ===== 工作区(2026-11-01 新增)=====
+ *
+ * 表:FmodeWorkspace
+ *   user Pointer<_User> / name String / createdAtMs Number / isDeleted Boolean
+ *
+ * 为什么独立建表而不塞进 FmodeChatThread:
+ *   工作区是「文件夹」,一条会话只属于一个文件夹,但**文件夹本身也要有列表**
+ *   (空工作区、改名、排序都得有地方放)。塞进会话表就只能靠某条会话顺带记录,
+ *   会出现「删掉文件夹里最后一条会话,文件夹也跟着消失」这类问题。
+ *
+ * 为什么不用 FmodeWorkGroup:那是**群聊**表(roomId/members/goal),语义完全不同。
+ */
+var WS_CLASS = 'FmodeWorkspace';
+
+async function opWsList(userPtr) {
+  var ptr = { __type: 'Pointer', className: '_User', objectId: userPtr.id };
+  var q = new Parse.Query(WS_CLASS);
+  q.equalTo('user', ptr);
+  q.notEqualTo('isDeleted', true);
+  q.ascending('createdAtMs');
+  q.limit(500);
+  var rows = await q.find({ useMasterKey: true });
+  return _ok({
+    workspaces: rows.map(function (r) {
+      return {
+        objectId: r.id,
+        id: r.get('wsId') || r.id,
+        name: r.get('name') || '',
+        createdAt: r.get('createdAtMs') || r.createdAt.getTime(),
+      };
+    }),
+  });
+}
+
+async function opWsUpsert(userPtr, p) {
+  var items = Array.isArray(p.items) ? p.items.slice(0, 200) : [];
+  if (items.length === 0) return _fail(400, 'items 不能为空');
+  var ptr = { __type: 'Pointer', className: '_User', objectId: userPtr.id };
+  var created = 0;
+  var updated = 0;
+  for (var i = 0; i < items.length; i++) {
+    var it = items[i] || {};
+    var wsId = _str(it.id, 120);
+    var name = _str(it.name, 120);
+    if (!wsId || !name) continue;
+    var q = new Parse.Query(WS_CLASS);
+    q.equalTo('user', ptr);
+    q.equalTo('wsId', wsId);
+    q.limit(1);
+    var rec = await q.first({ useMasterKey: true });
+    if (!rec) {
+      rec = new Parse.Object(WS_CLASS);
+      rec.set('user', ptr);
+      rec.set('wsId', wsId);
+      rec.set('isDeleted', false);
+      created++;
+    } else {
+      updated++;
+    }
+    rec.set('name', name);
+    if (typeof it.createdAt === 'number') rec.set('createdAtMs', it.createdAt);
+    rec.set('isDeleted', false);
+    await rec.save(null, { useMasterKey: true });
+  }
+  return _ok({ created: created, updated: updated });
+}
+
+async function opWsRemove(userPtr, p) {
+  var wsId = _str(p.id, 120);
+  if (!wsId) return _fail(400, 'id 不能为空');
+  var ptr = { __type: 'Pointer', className: '_User', objectId: userPtr.id };
+  var q = new Parse.Query(WS_CLASS);
+  q.equalTo('user', ptr);
+  q.equalTo('wsId', wsId);
+  q.limit(10);
+  var rows = await q.find({ useMasterKey: true });
+  /* 软删:保留记录便于排查,列表查询已过滤 */
+  for (var i = 0; i < rows.length; i++) {
+    rows[i].set('isDeleted', true);
+    await rows[i].save(null, { useMasterKey: true });
+  }
+  return _ok({ removed: rows.length });
+}
+
 async function handler(request, response) {
   try {
     /*
@@ -330,6 +415,13 @@ async function handler(request, response) {
         return await opPatch(user, params);
       case 'remove':
         return await opRemove(user, params);
+      /* ---- v0.1.5:工作区 ---- */
+      case 'wsList':
+        return await opWsList(user);
+      case 'wsUpsert':
+        return await opWsUpsert(user, params);
+      case 'wsRemove':
+        return await opWsRemove(user, params);
       default:
         return _fail(400, '未知 op: ' + params.op);
     }

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

@@ -3580,3 +3580,38 @@ messageCount: (s.messageCount ?? 0) + 1,   // 先累加
 - tsc 0 错误;单测 **1076/1076**;构建通过;verify:ui 12/12
 - 已用 Chrome 实际渲染截图核对 8 刻度等分效果(明暗两套主题)
 - 云端核查:活跃会话 1 条,标题正常,count=38
+
+## 2026-11-01 第十三轮:工作区上云
+
+### 决策依据(已核查现有表结构)
+逐表检查了平台 873 张业务表 + 5 张 Fmode 表,**没有一张能复用来存工作区**:
+- `FmodeWorkGroup` 是**群聊**表(roomId/members/goal),语义完全不同,混用会打架
+- `FmodeChatThread` 是会话表,硬塞会破坏「一行一会话」的唯一约束,
+  且工作区列表本身(空工作区/改名/排序)无处存放
+
+故新建表 `FmodeWorkspace`。
+
+### 云端(云函数 v0.1.5,已热更)
+复用现有云函数 `oR5O67i1Cm`(不新开函数 —— 同属「用户自己的对话组织数据」,
+部署与回滚应一起走),新增 3 个 op:
+- `wsList`   → `{ workspaces: [{ id, name, createdAt }] }`
+- `wsUpsert` → 按 `wsId` 幂等,改名不产生重复
+- `wsRemove` → 软删(`isDeleted=true`)
+
+表 `FmodeWorkspace`:`user / wsId / name / createdAtMs / isDeleted`
+
+### 客户端
+- `WorkspaceService` 接入云端:`syncFromCloud()` 以云端为准
+- **首次迁移**:云端为空而本地有数据时,把本地整批推上去(老用户不丢)
+- `create/rename/remove` 同步回写云端
+- 离线策略:工作区是轻量组织数据,**云端失败仍可用本地**
+  (与会话目录的强一致策略不同 —— 那边展示旧数据会导致误删)
+
+### 验证
+- 云函数端到端 **5/5**:新建上云 / 名称正确 / 改名生效 / 不产生重复 / 删除后消失
+- tsc 0 错误;单测 **1076/1076**;构建通过;verify:ui 12/12
+- 备份:`cloud/backup/oR5O67i1Cm-...-v0.1.4.js`
+
+### 关于历史对话(用户裁决:不恢复)
+agent-lj 的 8 个历史会话索引在墓碑中,Matrix 消息完好未删。
+按用户决定保持现状,不重建索引。

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

@@ -11,6 +11,8 @@ import {
   Task,
   UploadTicket,
   WorkGroup,
+  WorkspaceRecord,
+  WorkspaceUpsert,
 } from './models';
 
 /**
@@ -84,6 +86,22 @@ export abstract class FmodeApi {
   /** 删除会话索引(云端物理删除 + 留墓碑阻止重建);Matrix 消息不受影响 */
   abstract removeChatThread(input: { threadRoot: string; roomId?: string; agentId?: string }): Promise<void>;
 
+  // ---------- 工作区(2026-11-01 上云) ----------
+
+  /**
+   * 拉取用户的工作区列表。
+   *
+   * 工作区是用户自己整理对话的「文件夹」,此前只存 localStorage ——
+   * 换设备/清缓存后文件夹全丢,会话归属还在但无处安放。
+   */
+  abstract fetchWorkspaces(): Promise<WorkspaceRecord[]>;
+
+  /** 新建 / 更新工作区(按 id 幂等) */
+  abstract upsertWorkspaces(items: WorkspaceUpsert[]): Promise<void>;
+
+  /** 删除工作区(软删) */
+  abstract removeWorkspace(id: string): Promise<void>;
+
   // ---------- 交付成果 ----------
 
   /** 列表(云函数 lfYlgU7SkK,`action:'list'`) */

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

@@ -20,6 +20,8 @@ import {
   Task,
   UploadTicket,
   WorkGroup,
+  WorkspaceRecord,
+  WorkspaceUpsert,
 } from './models';
 
 /**
@@ -192,6 +194,25 @@ export class MockApi extends FmodeApi {
     }
   }
 
+  // ---------- 工作区(2026-11-01 上云) ----------
+
+  private readonly mockWorkspaces = new Map<string, WorkspaceRecord>();
+
+  async fetchWorkspaces(): Promise<WorkspaceRecord[]> {
+    await this.delay();
+    return [...this.mockWorkspaces.values()].sort((a, b) => a.createdAt - b.createdAt);
+  }
+
+  async upsertWorkspaces(items: WorkspaceUpsert[]): Promise<void> {
+    await this.delay();
+    for (const it of items) this.mockWorkspaces.set(it.id, { ...it });
+  }
+
+  async removeWorkspace(id: string): Promise<void> {
+    await this.delay();
+    this.mockWorkspaces.delete(id);
+  }
+
   // ---------- 任务看板(FR-07b)----------
 
   async fetchTasks(agentId: string): Promise<Task[]> {

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

@@ -580,6 +580,27 @@ export function agentPresenceTone(p: AgentPresence): SemanticTone {
   }
 }
 
+/**
+ * 云端工作区记录(表 `FmodeWorkspace`,云函数 `fmodeagent-chatthreads` 的 wsList)。
+ *
+ * 2026-11-01 上云:工作区此前只存 localStorage,换设备后文件夹全丢,
+ * 而会话的 workspaceId 还在 —— 归属无处安放,只能落进「孤儿」区。
+ */
+export interface WorkspaceRecord {
+  /** 客户端生成的工作区 id(`ws_<ts>_<rand>`),跨设备稳定 */
+  id: string;
+  name: string;
+  /** 毫秒时间戳;用于稳定排序 */
+  createdAt: number;
+}
+
+/** 工作区 upsert 载荷 */
+export interface WorkspaceUpsert {
+  id: string;
+  name: string;
+  createdAt: number;
+}
+
 /**
  * 云端会话目录记录(表 `FmodeChatThread`,云函数 `fmodeagent-chatthreads`)。
  *

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

@@ -6,6 +6,8 @@ import {
   AgentStatus,
   ChatThreadRecord,
   ChatThreadUpsert,
+  WorkspaceRecord,
+  WorkspaceUpsert,
   Artifact,
   ArtifactJob,
   AuthSession,
@@ -286,6 +288,40 @@ export class ParseApi extends FmodeApi {
     if (d.code !== 200 && d.code !== 404) throw new Error(d.mess ?? '会话更新失败');
   }
 
+  // ---------- 工作区(2026-11-01 上云) ----------
+
+  /**
+   * 拉取工作区列表。
+   *
+   * 与工作区相关的 op 都复用同一个云函数 `oR5O67i1Cm`(fmodeagent-chatthreads),
+   * 不另开函数 —— 它们同属「用户自己的对话组织数据」,部署与回滚应该一起走。
+   */
+  async fetchWorkspaces(): Promise<WorkspaceRecord[]> {
+    const d = await this.callFunction<{ code: number; workspaces?: WorkspaceRecord[]; mess?: string }>(
+      FN.chatThreads,
+      { op: 'wsList' },
+    );
+    if (d.code !== 200) throw new Error(d.mess ?? '工作区读取失败');
+    return Array.isArray(d.workspaces) ? d.workspaces : [];
+  }
+
+  async upsertWorkspaces(items: WorkspaceUpsert[]): Promise<void> {
+    if (items.length === 0) return;
+    const d = await this.callFunction<{ code: number; mess?: string }>(FN.chatThreads, {
+      op: 'wsUpsert',
+      items,
+    });
+    if (d.code !== 200) throw new Error(d.mess ?? '工作区同步失败');
+  }
+
+  async removeWorkspace(id: string): Promise<void> {
+    const d = await this.callFunction<{ code: number; mess?: string }>(FN.chatThreads, {
+      op: 'wsRemove',
+      id,
+    });
+    if (d.code !== 200) 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',

+ 68 - 4
src/app/core/workspace.service.ts

@@ -1,11 +1,12 @@
 import { computed, inject, Injectable, signal } from '@angular/core';
+import { FmodeApi } from './api/fmode-api';
 import { SessionService } from './session.service';
 
 /**
  * 工作区(用户手动新建的"文件夹")
  *
  * 与「项目」(ProjectsService,来自平台数据)**是两回事**:
- * 工作区纯粹是用户自己整理对话用的分类,只存在本地。
+ * 工作区纯粹是用户自己整理对话用的分类。
  */
 export interface Workspace {
   id: string;
@@ -23,10 +24,21 @@ function isWorkspace(x: unknown): x is Workspace {
 /**
  * 工作区服务 —— 工作区的**唯一 owner**
  *
- * ## 存储
+ * ## 存储(2026-11-01 上云)
  *
- * localStorage,按用户隔离(`fmode_workspaces_<userId>`),与
- * `ChatSessionService` 同一范式:localStorage 不可用时静默降级为内存态。
+ * | 层 | 角色 |
+ * |---|---|
+ * | 云端 `FmodeWorkspace` | **唯一真相源** |
+ * | localStorage(`fmode_workspaces_<userId>`) | 渲染缓存;云端不可达时**仍可用**(工作区是轻量组织数据,不像会话那样必须强一致) |
+ *
+ * 为什么工作区不做「云端不可达就不显示」:
+ *   它只是文件夹,不是用户资产。断网时能继续整理对话比「强制刷新」更有用;
+ *   而会话目录一旦展示旧数据会导致误删,所以那边才做强一致。
+ *
+ * ## 首次上云的迁移
+ *
+ * 老用户的工作区只在本地。`load()` 发现云端为空而本地有数据时,
+ * 会把本地整批推上去(一次性),此后以云端为准。
  *
  * ## 与「会话归属工作区」的分工
  *
@@ -37,6 +49,8 @@ function isWorkspace(x: unknown): x is Workspace {
 @Injectable({ providedIn: 'root' })
 export class WorkspaceService {
   private readonly session = inject(SessionService);
+  /** 云端读写闸门;单测/演示环境下可为空(纯本地模式) */
+  private readonly api = inject(FmodeApi, { optional: true });
 
   private readonly _all = signal<Workspace[]>([]);
   readonly all = this._all.asReadonly();
@@ -45,6 +59,8 @@ export class WorkspaceService {
   readonly sorted = computed(() => [...this._all()].sort((a, b) => a.createdAt - b.createdAt));
 
   private loadedFor: string | null = null;
+  /** 是否已从云端拉取成功(决定后续变更要不要回写) */
+  private cloudReady = false;
 
   /** 从 localStorage 载入(幂等;按当前用户隔离) */
   load(): void {
@@ -75,6 +91,39 @@ export class WorkspaceService {
       /* 隐私模式 / 坏 JSON:降级为内存态 */
       this._all.set([]);
     }
+    // 载入后异步与云端对齐(不阻塞首屏)
+    void this.syncFromCloud();
+  }
+
+  /**
+   * 与云端对齐(2026-11-01)。
+   *
+   * - 云端有数据 → 以云端为准(本地缓存被覆盖,因为它是权威来源);
+   * - 云端为空、本地有 → **一次性迁移**:把本地工作区推上去(老用户数据不丢);
+   * - 云端失败 → 保持本地可用(工作区是轻量数据,不做强一致)。
+   */
+  async syncFromCloud(): Promise<void> {
+    const api = this.api;
+    if (!api || !this.session.me()) return;
+    let remote: Workspace[];
+    try {
+      remote = await api.fetchWorkspaces();
+    } catch {
+      // 离线:本地继续可用
+      return;
+    }
+    this.cloudReady = true;
+    if (remote.length === 0) {
+      const local = this._all();
+      if (local.length > 0) {
+        await api
+          .upsertWorkspaces(local.map((w) => ({ id: w.id, name: w.name, createdAt: w.createdAt })))
+          .catch(() => undefined);
+      }
+      return;
+    }
+    this._all.set(remote);
+    this.persist();
   }
 
   /** 新建工作区,返回新条目;名字为空时**不创建**并返回 null */
@@ -95,6 +144,7 @@ export class WorkspaceService {
     };
     this._all.update((list) => [...list, w]);
     this.persist();
+    this.pushOne(w);
     return w;
   }
 
@@ -103,11 +153,16 @@ export class WorkspaceService {
     if (!next) return;
     this._all.update((list) => list.map((w) => (w.id === id ? { ...w, name: next } : w)));
     this.persist();
+    const w = this.byId(id);
+    if (w) this.pushOne(w);
   }
 
   remove(id: string): void {
     this._all.update((list) => list.filter((w) => w.id !== id));
     this.persist();
+    if (this.cloudReady && this.api) {
+      void this.api.removeWorkspace(id).catch(() => undefined);
+    }
   }
 
   byId(id: string): Workspace | undefined {
@@ -117,9 +172,18 @@ export class WorkspaceService {
   /** 登出时清空内存态 */
   reset(): void {
     this.loadedFor = null;
+    this.cloudReady = false;
     this._all.set([]);
   }
 
+  /** 单个工作区回写云端(失败静默:下次 syncFromCloud 会以云端为准) */
+  private pushOne(w: Workspace): void {
+    if (!this.cloudReady || !this.api) return;
+    void this.api
+      .upsertWorkspaces([{ id: w.id, name: w.name, createdAt: w.createdAt }])
+      .catch(() => undefined);
+  }
+
   private key(): string | null {
     const uid = this.session.me()?.id;
     return uid ? `fmode_workspaces_${uid}` : null;