fmodeagent-chatthreads.js 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485
  1. // FmodeAgent 会话目录云函数(2026-10-31 建,2026-11-06 v0.1.6 补群会话字段)
  2. //
  3. // 目的:会话目录(标题/归档/排序)此前只存在客户端 localStorage,
  4. // 换浏览器或清站点数据就全部丢失。这里把它落到服务端,成为**唯一真相源**。
  5. //
  6. // 调用:POST https://server.fmode.cn/api/functions
  7. // 头:X-Parse-Application-Id / X-Parse-Session-Token
  8. // body:{ id: "<本函数 objectId>", params: {...} }
  9. //
  10. // params.op:
  11. // list { agentId } → { code:200, threads:[...] }
  12. // upsert { items:[{agentId,roomId,threadRoot,title,messageCount,lastMessageAt,lastPreview,revive}] }
  13. // → { code:200, created, updated }
  14. // patch { threadRoot, title?, archived? } → { code:200, thread }
  15. // remove { threadRoot } → { code:200, removed }
  16. //
  17. // 平台约定(与 fmodeagent-matrix-provision 一致,实测):
  18. // · 入口 async function handler(request, response);入参在**全局 params**;
  19. // · 身份:优先 request.user(平台按 sessionToken 注入),否则按 token 查 _Session;
  20. // · 🔴 输出必须 response.status(200).json(...) **显式写出**;直接 return 对象平台不回包(请求挂起)。
  21. //
  22. // 安全约定:
  23. // 1. 归属一律取服务端解析出的 user,忽略客户端传入的任何 user / company 参数;
  24. // 2. 会话只归用户本人(不写 company),不会进公司共享视角;
  25. // 3. 删除是软删(isDeleted=true),与平台「删除用软删」铁律一致;
  26. // 4. 标题一旦被用户手动改过(titleLocked=true),upsert 不再覆盖。
  27. //
  28. // 表:FmodeChatThread(首次写入自动建表)
  29. // user Pointer<_User> / agentId String / roomId String / threadRoot String
  30. // title String / titleLocked Boolean / messageCount Number / lastMessageAt Date
  31. // archived Boolean / isDeleted Boolean / lastPreview String
  32. // workspaceId String(归属工作区;'' = 日常对话)— v0.1.4 新增
  33. // kind String('dm' | 'group';缺省 = dm)— v0.1.6 新增
  34. // memberIds Array<String>(群成员容器 id;单聊空)— v0.1.6 新增
  35. // deliverableUrls Array<String>(本会话的成果链接)— v0.1.9 新增(2026-11-09)
  36. // 唯一语义:(user, threadRoot) —— upsert 前查重保证
  37. var CLASS_NAME = 'FmodeChatThread';
  38. var MAX_ITEMS = 200;
  39. var TITLE_MAX = 60;
  40. var PREVIEW_MAX = 120;
  41. function _ok(data) {
  42. response.status(200).json(Object.assign({ code: 200 }, data || {}));
  43. return true;
  44. }
  45. function _fail(code, mess) {
  46. response.status(200).json({ code: code, mess: mess });
  47. return false;
  48. }
  49. function _str(v, max) {
  50. var s = v == null ? '' : String(v);
  51. return max && s.length > max ? s.slice(0, max) : s;
  52. }
  53. /**
  54. * 字符串数组清洗(v0.1.6)。
  55. *
  56. * 去空、去重、限长;非数组一律返回空数组 —— 调用方据此判断「没有成员」。
  57. * 成员 id 上限 120 与 agentId 字段一致。
  58. */
  59. function _strArr(v) {
  60. if (!Array.isArray(v)) return [];
  61. var out = [];
  62. for (var i = 0; i < v.length && out.length < 50; i++) {
  63. /*
  64. * 🔴 上限 500 而不是 120(2026-11-09):成果链接是完整 URL,
  65. * 典型长度 s3.fmode.cn/deliverables/<agentId>/<YYYYMM>/<id>/<hash>-<name>
  66. * 常常超过 120 字符,按 120 截断会得到打不开的地址。
  67. */
  68. var s = _str(v[i], 500);
  69. if (s && out.indexOf(s) < 0) out.push(s);
  70. }
  71. return out;
  72. }
  73. function _date(v) {
  74. if (!v) return new Date();
  75. var d = v instanceof Date ? v : new Date(v);
  76. return isNaN(d.getTime()) ? new Date() : d;
  77. }
  78. /**
  79. * token → _User(唯一权威解析)。
  80. *
  81. * 🔴 加固(2026-11-01,**不是**重复记录的根因,属正确性改进):
  82. * 上一版先判 `request.user && request.user.id` 就直接 `return request.user`。
  83. * 平台注入的 `request.user` 实测不一定带出 `objectId`,拿它当
  84. * `equalTo('user', ...)` 的条件会匹配不到记录。
  85. * 现在以 token 查 `_Session` 得到的真实 `_User` 为准,
  86. * `request.user` 只在**确实带出 objectId** 时作为兜底。
  87. *
  88. * ⚠️ 重复记录的真根因在 `_findAllByRoot`(查重键含 roomId),见其注释。
  89. */
  90. async function _getUser(t) {
  91. if (!t) return null;
  92. try {
  93. var q = new Parse.Query(Parse.Session);
  94. q.equalTo('sessionToken', t);
  95. q.include('user');
  96. var s = await q.first({ useMasterKey: true });
  97. if (s) {
  98. var u = s.get('user');
  99. if (u) return u;
  100. }
  101. } catch (e) {
  102. /* 落到 request.user 兜底 */
  103. }
  104. // 兜底:平台注入了**完整**用户对象时使用(残缺 Pointer 一律不用)
  105. if (request && request.user && request.user.id) return request.user;
  106. return null;
  107. }
  108. /** 取当前会话 token(与平台其他函数同一约定) */
  109. /**
  110. * 取当前会话 token。
  111. *
  112. * 🔴 实测(2026-10-31,加 __debug 探测得出):
  113. * · 平台把 X-Parse-Session-Token 请求头**原样保留**在 request.headers;
  114. * · request.body 只剩 {id, params},body 里的 token 被平台剥离;
  115. * · request.user **没有**被注入。
  116. * ⇒ 身份的唯一可靠来源是 x-parse-session-token 请求头(兜底 params.sessionToken)。
  117. */
  118. function _sessionToken() {
  119. var h = request && request.headers ? request.headers : {};
  120. if (h['x-parse-session-token']) return h['x-parse-session-token'];
  121. if (request && request.body && request.body.token) return request.body.token;
  122. if (params && params.sessionToken) return params.sessionToken;
  123. return null;
  124. }
  125. /**
  126. * 查某用户**所有房间**下同一线程根的全部记录(2026-11-01)。
  127. *
  128. * 为什么按 `threadRoot` 而不是 `(roomId, threadRoot)` 查:
  129. * · `threadRoot` 是客户端生成的全局唯一 ID,一个会话只可能属于一个线程;
  130. * · 房间可能变(agent 重建、换绑定),同一会话被写进新房间后,
  131. * 旧房间那条会变成孤儿 —— 必须收敛掉,不能各留一条。
  132. */
  133. function _findAllByRoot(userPtr, threadRoot) {
  134. var q = new Parse.Query(CLASS_NAME);
  135. q.equalTo('user', userPtr);
  136. q.equalTo('threadRoot', threadRoot);
  137. q.limit(50);
  138. return q.find({ useMasterKey: true });
  139. }
  140. async function opList(userPtr, p) {
  141. var q = new Parse.Query(CLASS_NAME);
  142. q.equalTo('user', userPtr);
  143. /*
  144. * includeDeleted:1 → 只返回已软删的记录(客户端据此建立「不允许重建」黑名单)。
  145. * 跨设备有效:换浏览器/重新登录后仍能挡住「删了又被扫描建回来」。
  146. */
  147. if (p.includeDeleted) q.equalTo('isDeleted', true);
  148. else q.notEqualTo('isDeleted', true);
  149. if (p.agentId) q.equalTo('agentId', String(p.agentId));
  150. if (p.roomId) q.equalTo('roomId', String(p.roomId));
  151. q.descending('lastMessageAt');
  152. q.limit(500);
  153. var rows = await q.find({ useMasterKey: true });
  154. return _ok({
  155. threads: rows.map(function (r) {
  156. return {
  157. objectId: r.id,
  158. agentId: r.get('agentId') || '',
  159. roomId: r.get('roomId') || '',
  160. threadRoot: r.get('threadRoot') || '',
  161. title: r.get('title') || '',
  162. titleLocked: !!r.get('titleLocked'),
  163. messageCount: r.get('messageCount') || 0,
  164. lastMessageAt: (r.get('lastMessageAt') || r.updatedAt || r.createdAt).getTime(),
  165. archived: !!r.get('archived'),
  166. lastPreview: r.get('lastPreview') || '',
  167. // v0.1.4:工作区归属随列表返回,客户端据此还原层级
  168. workspaceId: r.get('workspaceId') || '',
  169. // v0.1.6:群会话标识随列表返回 —— 缺了它客户端无法把群会话
  170. // 归到「群聊」分组,会以首成员身份混进单聊列表
  171. kind: r.get('kind') === 'group' ? 'group' : 'dm',
  172. memberIds: _strArr(r.get('memberIds')),
  173. /*
  174. * v0.1.7:本会话的成果链接(2026-11-09)。
  175. * 客户端据此只显示「属于这条对话」的成果,不再显示整个容器的。
  176. */
  177. deliverableUrls: _strArr(r.get('deliverableUrls')),
  178. };
  179. }),
  180. });
  181. }
  182. async function opUpsert(userPtr, p) {
  183. var items = Array.isArray(p.items) ? p.items.slice(0, MAX_ITEMS) : [];
  184. if (items.length === 0) return _fail(400, 'items 不能为空');
  185. var ptr = { __type: 'Pointer', className: '_User', objectId: userPtr.id };
  186. var created = 0;
  187. var updated = 0;
  188. var collapsed = 0;
  189. for (var i = 0; i < items.length; i++) {
  190. var it = items[i] || {};
  191. var roomId = _str(it.roomId, 200);
  192. var threadRoot = _str(it.threadRoot, 200);
  193. if (!roomId || !threadRoot) continue;
  194. /*
  195. * 🔴 按 threadRoot 查全部记录(不只是当前房间)。
  196. * 房间可能变化;同时兜住历史脏数据里「同一线程根在多条记录」的情况 ——
  197. * 取最新一条作为主记录,其余物理删除(索引重复没有任何保留价值)。
  198. */
  199. var rows = await _findAllByRoot(ptr, threadRoot);
  200. var rec = null;
  201. for (var j = 0; j < rows.length; j++) {
  202. if (!rec || rows[j].updatedAt >= rec.updatedAt) rec = rows[j];
  203. }
  204. for (var k = 0; k < rows.length; k++) {
  205. if (rec && rows[k].id !== rec.id) {
  206. try {
  207. await rows[k].destroy({ useMasterKey: true });
  208. collapsed++;
  209. } catch (e) {
  210. /* 单条失败不影响其余 */
  211. }
  212. }
  213. }
  214. if (!rec) {
  215. rec = new Parse.Object(CLASS_NAME);
  216. rec.set('user', ptr);
  217. rec.set('roomId', roomId);
  218. rec.set('threadRoot', threadRoot);
  219. rec.set('messageCount', 0);
  220. rec.set('archived', false);
  221. rec.set('isDeleted', false);
  222. rec.set('titleLocked', false);
  223. created++;
  224. } else {
  225. updated++;
  226. }
  227. // 标题:用户手改过就不覆盖;否则仅在为空时写入(避免把容器标题降级成首句)
  228. var incomingTitle = _str(it.title, TITLE_MAX);
  229. if (incomingTitle && !rec.get('titleLocked') && !rec.get('title')) {
  230. rec.set('title', incomingTitle);
  231. }
  232. // 房间以本次上报为准(会话跟着房间走)
  233. rec.set('roomId', roomId);
  234. if (it.agentId) rec.set('agentId', _str(it.agentId, 120));
  235. if (typeof it.messageCount === 'number' && it.messageCount >= 0) {
  236. rec.set('messageCount', it.messageCount);
  237. }
  238. if (it.lastMessageAt) rec.set('lastMessageAt', _date(it.lastMessageAt));
  239. if (it.lastPreview) rec.set('lastPreview', _str(it.lastPreview, PREVIEW_MAX));
  240. /*
  241. * v0.1.4:工作区归属。
  242. * 🔴 用 `!= null` 判定而不是真值判断 —— '' 是合法值(代表「移回日常对话」),
  243. * 用 if (it.workspaceId) 会把「移回日常」当成「没传」,归属永远清不掉。
  244. */
  245. if (it.workspaceId !== undefined && it.workspaceId !== null) {
  246. rec.set('workspaceId', _str(it.workspaceId, 120));
  247. }
  248. /*
  249. * v0.1.6:群会话标识。
  250. * 🔴 只在明确传入时写 —— 单聊的后续 upsert 不带这两个字段,
  251. * 若无条件写空值,会把已有的群会话降级成单聊。
  252. */
  253. if (it.kind === 'dm' || it.kind === 'group') {
  254. rec.set('kind', it.kind);
  255. }
  256. if (Array.isArray(it.memberIds) && it.memberIds.length > 0) {
  257. rec.set('memberIds', _strArr(it.memberIds));
  258. }
  259. /*
  260. * v0.1.7:本会话的成果链接(2026-11-09 用户裁决)。
  261. *
  262. * 🔴 只在明确传入时写 —— 与 kind/memberIds 同理,
  263. * 后续 upsert 不带这个字段时不能把已有链接清空。
  264. * 🔴 用 `[]` 显式传空数组才表示「清空」(用户删光了成果)。
  265. */
  266. if (Array.isArray(it.deliverableUrls)) {
  267. rec.set('deliverableUrls', _strArr(it.deliverableUrls));
  268. }
  269. // 🔴 upsert 表示「这条会话现在存在」→ 永远解除墓碑。
  270. // 上一版只有传 revive:true 才解除,客户端漏传就会出现
  271. // 「本地有、云端是墓碑」→ 下次拉取被当已删除跳过 → 会话消失(实测)。
  272. rec.set('isDeleted', false);
  273. await rec.save(null, { useMasterKey: true });
  274. }
  275. return _ok({ created: created, updated: updated, collapsed: collapsed });
  276. }
  277. async function opPatch(userPtr, p) {
  278. var threadRoot = _str(p.threadRoot, 200);
  279. if (!threadRoot) return _fail(400, 'threadRoot 不能为空');
  280. var q = new Parse.Query(CLASS_NAME);
  281. q.equalTo('user', userPtr);
  282. q.equalTo('threadRoot', threadRoot);
  283. q.notEqualTo('isDeleted', true);
  284. q.descending('updatedAt');
  285. var rec = await q.first({ useMasterKey: true });
  286. if (!rec) return _fail(404, '会话不存在');
  287. if (p.title != null) {
  288. var t = _str(p.title, TITLE_MAX);
  289. if (!t) return _fail(400, '标题不能为空');
  290. rec.set('title', t);
  291. rec.set('titleLocked', true);
  292. }
  293. if (p.archived != null) rec.set('archived', !!p.archived);
  294. /* v0.1.4:移动工作区单独走 patch,'' = 移回日常对话 */
  295. if (p.workspaceId !== undefined && p.workspaceId !== null) {
  296. rec.set('workspaceId', _str(p.workspaceId, 120));
  297. }
  298. await rec.save(null, { useMasterKey: true });
  299. return _ok({ thread: { objectId: rec.id, threadRoot: threadRoot } });
  300. }
  301. /**
  302. * remove:**物理删除**云端索引(2026-11-01 用户裁决)。
  303. *
  304. * 语义:删除后不可恢复 —— 云端记录直接 `destroy()`,列表查询不会再返回。
  305. * Matrix 房间里的消息**不动**(会话正文仍在服务器,只是不再作为会话展示)。
  306. * 同时写入一条墓碑记录(isDeleted=true 的极简行),用于跨设备阻止扫描重建。
  307. */
  308. async function opRemove(userPtr, p) {
  309. var threadRoot = _str(p.threadRoot, 200);
  310. if (!threadRoot) return _fail(400, 'threadRoot 不能为空');
  311. /*
  312. * 🔴 用 objectId 构造标准 Pointer 再查(2026-11-01):
  313. * 平台注入的 userPtr 有时是不可枚举的代理对象,直接 equalTo('user', userPtr)
  314. * 匹配不到已有记录(实测:记录明明存在,find() 返回 0 条)。
  315. * 用 Pointer 显式指定 className + objectId 最稳。
  316. */
  317. var ptr = { __type: 'Pointer', className: '_User', objectId: userPtr.id };
  318. /* ① 查全部同线程记录(含已软删) */
  319. var q = new Parse.Query(CLASS_NAME);
  320. q.equalTo('user', ptr);
  321. q.equalTo('threadRoot', threadRoot);
  322. q.limit(100);
  323. var rows = await q.find({ useMasterKey: true });
  324. console.warn('[REMOVE] found=' + rows.length + ' ids=' + rows.map(function (r) { return r.id; }).join(','));
  325. /* ② 保留/创建一条墓碑(isDeleted=true),其余物理删除 */
  326. var tomb = null;
  327. for (var i = 0; i < rows.length; i++) {
  328. if (rows[i].get('isDeleted') === true && !tomb) {
  329. tomb = rows[i];
  330. continue;
  331. }
  332. try {
  333. await rows[i].destroy({ useMasterKey: true });
  334. } catch (e) {
  335. /* 单条删除失败不影响其余 */
  336. }
  337. }
  338. if (!tomb) {
  339. tomb = new Parse.Object(CLASS_NAME);
  340. tomb.set('user', ptr);
  341. tomb.set('threadRoot', threadRoot);
  342. tomb.set('roomId', _str(p.roomId, 200) || '');
  343. tomb.set('agentId', _str(p.agentId, 120) || '');
  344. tomb.set('title', '');
  345. tomb.set('messageCount', 0);
  346. tomb.set('isDeleted', true);
  347. await tomb.save(null, { useMasterKey: true });
  348. }
  349. return _ok({ removed: rows.length - (tomb ? 1 : 0), tomb: true });
  350. }
  351. /**
  352. * ===== 工作区(2026-11-01 新增)=====
  353. *
  354. * 表:FmodeWorkspace
  355. * user Pointer<_User> / name String / createdAtMs Number / isDeleted Boolean
  356. *
  357. * 为什么独立建表而不塞进 FmodeChatThread:
  358. * 工作区是「文件夹」,一条会话只属于一个文件夹,但**文件夹本身也要有列表**
  359. * (空工作区、改名、排序都得有地方放)。塞进会话表就只能靠某条会话顺带记录,
  360. * 会出现「删掉文件夹里最后一条会话,文件夹也跟着消失」这类问题。
  361. *
  362. * 为什么不用 FmodeWorkGroup:那是**群聊**表(roomId/members/goal),语义完全不同。
  363. */
  364. var WS_CLASS = 'FmodeWorkspace';
  365. async function opWsList(userPtr) {
  366. var ptr = { __type: 'Pointer', className: '_User', objectId: userPtr.id };
  367. var q = new Parse.Query(WS_CLASS);
  368. q.equalTo('user', ptr);
  369. q.notEqualTo('isDeleted', true);
  370. q.ascending('createdAtMs');
  371. q.limit(500);
  372. var rows = await q.find({ useMasterKey: true });
  373. return _ok({
  374. workspaces: rows.map(function (r) {
  375. return {
  376. objectId: r.id,
  377. id: r.get('wsId') || r.id,
  378. name: r.get('name') || '',
  379. createdAt: r.get('createdAtMs') || r.createdAt.getTime(),
  380. };
  381. }),
  382. });
  383. }
  384. async function opWsUpsert(userPtr, p) {
  385. var items = Array.isArray(p.items) ? p.items.slice(0, 200) : [];
  386. if (items.length === 0) return _fail(400, 'items 不能为空');
  387. var ptr = { __type: 'Pointer', className: '_User', objectId: userPtr.id };
  388. var created = 0;
  389. var updated = 0;
  390. for (var i = 0; i < items.length; i++) {
  391. var it = items[i] || {};
  392. var wsId = _str(it.id, 120);
  393. var name = _str(it.name, 120);
  394. if (!wsId || !name) continue;
  395. var q = new Parse.Query(WS_CLASS);
  396. q.equalTo('user', ptr);
  397. q.equalTo('wsId', wsId);
  398. q.limit(1);
  399. var rec = await q.first({ useMasterKey: true });
  400. if (!rec) {
  401. rec = new Parse.Object(WS_CLASS);
  402. rec.set('user', ptr);
  403. rec.set('wsId', wsId);
  404. rec.set('isDeleted', false);
  405. created++;
  406. } else {
  407. updated++;
  408. }
  409. rec.set('name', name);
  410. if (typeof it.createdAt === 'number') rec.set('createdAtMs', it.createdAt);
  411. rec.set('isDeleted', false);
  412. await rec.save(null, { useMasterKey: true });
  413. }
  414. return _ok({ created: created, updated: updated });
  415. }
  416. async function opWsRemove(userPtr, p) {
  417. var wsId = _str(p.id, 120);
  418. if (!wsId) return _fail(400, 'id 不能为空');
  419. var ptr = { __type: 'Pointer', className: '_User', objectId: userPtr.id };
  420. var q = new Parse.Query(WS_CLASS);
  421. q.equalTo('user', ptr);
  422. q.equalTo('wsId', wsId);
  423. q.limit(10);
  424. var rows = await q.find({ useMasterKey: true });
  425. /* 软删:保留记录便于排查,列表查询已过滤 */
  426. for (var i = 0; i < rows.length; i++) {
  427. rows[i].set('isDeleted', true);
  428. await rows[i].save(null, { useMasterKey: true });
  429. }
  430. return _ok({ removed: rows.length });
  431. }
  432. async function handler(request, response) {
  433. try {
  434. /*
  435. * 🔴 body 里的 token 由平台放在 **request.body.token**,不会合并进全局 params
  436. * (实测:只管 params.sessionToken 会一直 401,而 token 本身是有效的)。
  437. * 业务参数在全局 params;用户身份两种来源都兼容。
  438. */
  439. params = (params && typeof params === 'object') ? params : (request && request.body && request.body.params) || {};
  440. var user = await _getUser(_sessionToken());
  441. if (!user) return _fail(401, 'sessionToken 无效或已过期,请重新登录 App');
  442. switch (params.op) {
  443. case 'list':
  444. return await opList(user, params);
  445. case 'upsert':
  446. return await opUpsert(user, params);
  447. case 'patch':
  448. return await opPatch(user, params);
  449. case 'remove':
  450. return await opRemove(user, params);
  451. /* ---- v0.1.5:工作区 ---- */
  452. case 'wsList':
  453. return await opWsList(user);
  454. case 'wsUpsert':
  455. return await opWsUpsert(user, params);
  456. case 'wsRemove':
  457. return await opWsRemove(user, params);
  458. default:
  459. return _fail(400, '未知 op: ' + params.op);
  460. }
  461. } catch (e) {
  462. return _fail(500, '会话目录服务异常: ' + ((e && e.message) || String(e)));
  463. }
  464. }