chat-session.service.ts 46 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100
  1. import { computed, inject, Injectable, signal } from '@angular/core';
  2. import { SessionService } from './session.service';
  3. import { FmodeApi } from './api/fmode-api';
  4. import { ChatThreadRecord, ChatThreadUpsert } from './api/models';
  5. import {
  6. ChatSession,
  7. NEW_SESSION_TITLE,
  8. createSession,
  9. legacySessionId,
  10. sessionsOf,
  11. sortSessions,
  12. touchSession,
  13. } from './chat-session';
  14. /**
  15. * 会话(多线程)服务 —— App 侧会话目录的**唯一 owner**
  16. *
  17. * ## 职责
  18. *
  19. * 维护「线程根 event_id → 会话元信息」的映射,并提供新建 / 重命名 / 归档 / 删除。
  20. * 消息内容本身仍由 Matrix 房间承载,**本服务只存元信息**。
  21. *
  22. * ## 存储(2026-11-01 定稿)
  23. *
  24. * | 层 | 角色 |
  25. * |---|---|
  26. * | 云端 `FmodeChatThread` | **唯一真相源** |
  27. * | localStorage(`fmode_chat_sessions_v2_<userId>`) | 渲染缓存 + 删除黑名单;云端不可达时**不展示** |
  28. *
  29. * 同步方向是**单向**的:云端 → 本地(`hydrateFromCloud`),
  30. * 写入操作再单独回写云端。**不存在「本地有、云端没有 → 保留本地」的分支** ——
  31. * 那正是「删掉的会话又回来 / 云端被灌满 / 同一会话多条」的根因。
  32. *
  33. * ## 为什么正文不进平台库
  34. *
  35. * 会话正文(含附件)全部在 Matrix 房间里,平台库只存「哪些线程存在、叫什么名字」。
  36. * 平台库额外存的只有 `lastPreview`(列表用的一行摘要,可丢)。
  37. * 把正文搬进平台库等于重造一遍 Matrix,收发链路、附件、实时性全要另写一套。
  38. *
  39. * @see chat-session.ts 纯函数层(可单测)
  40. */
  41. @Injectable({ providedIn: 'root' })
  42. export class ChatSessionService {
  43. private readonly session = inject(SessionService);
  44. /**
  45. * 数据访问唯一闸门(云端会话目录读写;失败静默,不影响本地)。
  46. *
  47. * 🔴 用 `inject(..., { optional: true })`:本服务在单测/纯本地场景下不注入
  48. * FmodeApi 也应可用(云端同步是可选的增强,不是必需依赖)。
  49. */
  50. private readonly api = inject(FmodeApi, { optional: true });
  51. private readonly _all = signal<ChatSession[]>([]);
  52. /**
  53. * 同一容器的在途 hydrate(并发去重用)。
  54. * 🔴 必须声明在类字段区最前面:它被 `hydrateFromCloud` 使用,
  55. * 若声明在方法之后,类字段初始化顺序会让运行时拿到 undefined。
  56. */
  57. private readonly hydrating = new Map<string, Promise<number>>();
  58. readonly all = this._all.asReadonly();
  59. /** 按 agent 分组的会话(未归档,已排序) */
  60. readonly byAgent = computed<Map<string, ChatSession[]>>(() => {
  61. const m = new Map<string, ChatSession[]>();
  62. for (const s of this._all()) {
  63. /*
  64. * 🔴 **不再在这里丢掉已归档的会话**。
  65. *
  66. * 原实现在这一步 `if (s.archived) continue;` —— 归档的会话直接从
  67. * 映射里消失,于是上层无论怎么判断 `showArchived` 都拿不到它们,
  68. * 「显示已归档」开关**永远无效**(实测点击无反应)。
  69. *
  70. * 归档的取舍交给调用方:默认列表过滤掉,「已归档」目录单独展示。
  71. */
  72. const list = m.get(s.agentId) ?? [];
  73. list.push(s);
  74. m.set(s.agentId, list);
  75. }
  76. for (const [k, v] of m) m.set(k, sortSessions(v));
  77. return m;
  78. });
  79. private loadedFor: string | null = null;
  80. /**
  81. * 会话目录是否可信(2026-11-01 用户裁决)。
  82. *
  83. * 🔴 **`false` 时列表一律为空**,宁可显示「加载失败,重试」也不展示本地缓存。
  84. *
  85. * 为什么改掉「离线展示缓存」:
  86. * 缓存与云端没有可靠的全序(谁更新说不清),一边展示旧缓存、一边又把本地
  87. * 回写云端,两端会互相覆盖 —— 这是「删掉的会话又回来 / 云端为空却被灌满」
  88. * 反复出现的根源。现在只有一条规则:**云端成功返回后列表才可信**。
  89. *
  90. * 初始值 `false`:登录后必须先成功拉一次云端,列表才可能出现内容。
  91. */
  92. private readonly _ready = signal(false);
  93. readonly ready = this._ready.asReadonly();
  94. /** 最近一次云端拉取的失败原因(`null` = 正常);界面据此给出重试入口 */
  95. private readonly _error = signal<string | null>(null);
  96. readonly error = this._error.asReadonly();
  97. /**
  98. * 新建会话**推送上云失败**的原因(2026-11-09 用户要求)。
  99. *
  100. * 🔴 用户原话:「推送失败要报错」。
  101. *
  102. * 原来是静默吞掉的:本地照常显示,但云端没有 ——
  103. * 用户在 A 浏览器建了会话,B 浏览器永远看不到,而且**A 这边也没有任何提示**,
  104. * 用户根本不知道这条会话只存在于这一台机器上。
  105. *
  106. * 现在:失败就记下来,界面提示「这条会话还没同步到云端」并给重试入口。
  107. */
  108. private readonly _pushFailed = signal<string | null>(null);
  109. readonly pushFailed = this._pushFailed.asReadonly();
  110. /** 上次云端拉取失败的原因(`null` = 正常);供调用方判断能否继续 */
  111. isCloudStale(): boolean {
  112. return !this._ready();
  113. }
  114. /**
  115. * 用户主动移除过的会话 id。
  116. *
  117. * 这些线程在 Matrix 里**仍然存在**(消息没删),但用户不想再看到它们。
  118. * 扫描重建时必须跳过,否则删了立刻被扫回来。
  119. */
  120. private readonly _removed = signal<Set<string>>(new Set());
  121. /**
  122. * 云端已软删的线程根(跨设备有效)。
  123. * 与本地 `_removed` 一起构成「哪些线程不允许重新出现」的黑名单。
  124. * 注意:目录已不再从 Matrix 扫描重建,它现在的用途是兜住
  125. * 「云端列表与墓碑不一致」的历史数据。
  126. */
  127. private cloudRemoved = new Set<string>();
  128. /**
  129. * 每个会话上一次把元信息推到云端的时间(节流用,见 `noteMessage`)。
  130. *
  131. * 放在内存里而不是持久化:它只用于「别太频繁地打接口」,
  132. * 刷新后重新计时即可,不影响任何数据的正确性。
  133. */
  134. private readonly lastPushAt = new Map<string, number>();
  135. /**
  136. * 本地已建、但还没确认推到云端的会话 id。
  137. *
  138. * 用途很窄:`hydrateFromCloud` 整体替换列表时,这些会话在云端还不存在,
  139. * 必须保留 —— 否则「新建对话 → 换 URL → 重新拉取云端」会把刚建的会话抹掉。
  140. * 推送成功后立即移除,避免长期存在。
  141. */
  142. private readonly _pendingPush = new Set<string>();
  143. /** 首次落库的进行中 Promise(供 `commitNow` 等待,避免重复请求) */
  144. private readonly _commitWait = new Map<string, Promise<void>>();
  145. /**
  146. * 从 localStorage 载入(Shell 启动时调用一次;重复调用幂等)。
  147. *
  148. * 🔴 **按用户区分载入状态**:存储键含用户 id,换账号登录后必须重新载入,
  149. * 否则会把上一个账号的会话列表显示给新用户(越权 + 串号)。
  150. * 用户 id 变化时清空内存态再读。
  151. */
  152. load(): void {
  153. /*
  154. * 🔴 未登录(key 为 null)时**不读也不写**:
  155. * 内存里此刻可能是上一个账号的会话,必须清空,
  156. * 否则登录页/切换账号的瞬间会把别人的对话闪出来。
  157. */
  158. const key = this.key();
  159. if (key === null) {
  160. if (this.loadedFor !== null) this._all.set([]);
  161. this.loadedFor = null;
  162. return;
  163. }
  164. if (this.loadedFor === key) return;
  165. const switchingUser = this.loadedFor !== null;
  166. this.loadedFor = key;
  167. if (switchingUser) this._all.set([]);
  168. try {
  169. const raw = localStorage.getItem(key);
  170. if (!raw) return;
  171. const parsed = JSON.parse(raw) as unknown;
  172. if (!Array.isArray(parsed)) return;
  173. // 逐条校验 —— 坏数据不应该让整个列表消失
  174. const list = parsed.filter(isSession).map(normalize);
  175. this._all.set(sortSessions(list));
  176. // 恢复「已移除」黑名单
  177. const removedRaw = localStorage.getItem(`${key}_removed`);
  178. if (removedRaw) {
  179. const arr = JSON.parse(removedRaw) as unknown;
  180. if (Array.isArray(arr)) {
  181. this._removed.set(new Set(arr.filter((x): x is string => typeof x === 'string')));
  182. }
  183. }
  184. } catch {
  185. /* 隐私模式 / 坏 JSON:降级为内存态 */
  186. }
  187. }
  188. /**
  189. * 清空内存态(登出时调用)。
  190. *
  191. * 🔴 `_ready` 必须一起复位:否则登出再登录后,列表会凭「上一次登录的成功状态」
  192. * 直接被视为可信,在新一轮云端拉取完成前就渲染出(可能属于别人的)内容。
  193. */
  194. reset(): void {
  195. this.loadedFor = null;
  196. this._all.set([]);
  197. this._ready.set(false);
  198. this._error.set(null);
  199. this.cloudRemoved = new Set();
  200. this._removed.set(new Set());
  201. }
  202. // ================= 云端同步(2026-11-01 重写:单向 云端 → 本地) =================
  203. /**
  204. * 从云端拉取会话目录(**唯一真相源**,本地只是渲染缓存)。
  205. *
  206. * ## 唯一规则
  207. *
  208. * **云端成功返回 → 本地该容器的会话直接等于云端返回的集合**;
  209. * 云端失败 → 列表清空并标记不可用(`ready = false`),界面显示「加载失败,重试」,
  210. * **绝不展示旧缓存、也绝不把本地数据反推回云端**。
  211. *
  212. * 为什么不再做「合并」:
  213. * 线上反复出现「删掉的会话又回来 / 云端为空却被灌满 / 同一会话七八条」,
  214. * 根因都是两端各写一半,再用「只补不覆盖」之类的规则去猜谁对。
  215. * 只要允许本地影响云端,就必须维护一套合并规则,而合并规则本身会出错。
  216. * 改成单向同步后,冲突面直接归零。
  217. *
  218. * @returns 本次新增的会话条数(调用方一般不关心)
  219. */
  220. async hydrateFromCloud(agentId: string, keepId?: string): Promise<number> {
  221. /*
  222. * 🔴 并发去重(2026-11-03):
  223. * 会话列表页、抽屉、会话详情页会**同时**触发预热,
  224. * 同一个容器被拉 2~4 次(实测每次 2 条请求:活跃 + 含已删)。
  225. * 同容器已有在途请求时直接复用,请求量减半以上。
  226. *
  227. * 不用缓存结果 —— 请求完成后立即释放,保证下次进入拿到最新数据。
  228. */
  229. const inflight = this.hydrating.get(agentId);
  230. if (inflight) return inflight;
  231. const task = this.hydrateOnce(agentId, keepId).finally(() => {
  232. this.hydrating.delete(agentId);
  233. });
  234. this.hydrating.set(agentId, task);
  235. return task;
  236. }
  237. private async hydrateOnce(agentId: string, keepId?: string): Promise<number> {
  238. /*
  239. * 🔴 先确保本地缓存已载入:生产由页面在进入路由时调用 load(),
  240. * 但云端恢复不应依赖调用顺序(测试/深链进入都可能没调过)。
  241. * load() 幂等,重复调用无副作用。
  242. */
  243. this.load();
  244. const key = this.key();
  245. const api = this.api;
  246. if (!key || this.loadedFor !== key || !api) return 0;
  247. let live: ChatThreadRecord[];
  248. let deleted: ChatThreadRecord[];
  249. try {
  250. /*
  251. * 两份列表分开拉(2026-11-09 实测确认语义):
  252. * · `fetchChatThreads(agentId)` → **仅活跃**会话
  253. * · `fetchChatThreads(agentId, {includeDeleted})` → **仅已软删**会话
  254. *
  255. * 🔴 参数名叫 includeDeleted,但实测返回的是**只含已删**的记录,
  256. * 而不是「活跃 + 已删」。实测证据(2026-11-09):
  257. * 默认 list = 2 条,includeDeleted = 41 条,两个集合**完全不相交**。
  258. * 原实现把它当「并集」用,导致墓碑里混入了错误数据。
  259. */
  260. [live, deleted] = await Promise.all([
  261. api.fetchChatThreads(agentId),
  262. api.fetchChatThreads(agentId, { includeDeleted: true }).catch(() => [] as ChatThreadRecord[]),
  263. ]);
  264. } catch (e) {
  265. /*
  266. * 云端不可达 → **列表不可信**(用户裁决:宁可不显示,也不显示可能过期的缓存)。
  267. * 内存里留着旧内容没有意义 —— 界面依据 `ready()` 直接渲染「加载失败」。
  268. */
  269. this._ready.set(false);
  270. this._error.set(e instanceof Error && e.message ? e.message : '会话目录加载失败');
  271. return 0;
  272. }
  273. /*
  274. * 🔴 云端墓碑(2026-11-09):只用于「URL 直达已删会话时拒绝打开」。
  275. *
  276. * 原先 `cloudRemoved` 存了但**从没被任何地方读取**。现在它是
  277. * `openReal` 判定「这条会话已被删除」的权威依据。
  278. *
  279. * 🔴 刻意**不**并入 `_removed`:后者会持久化到 localStorage,
  280. * 并入后一旦云端某次返回异常,本地就会永久拉黑一批正常会话。
  281. * 墓碑是「本次会话周期内」的判定依据,重新拉取时自然刷新。
  282. */
  283. this.cloudRemoved = new Set(deleted.map((d) => d.threadRoot));
  284. const prev = new Map(
  285. this._all()
  286. .filter((s) => s.agentId === agentId)
  287. .map((s) => [s.id, s]),
  288. );
  289. /*
  290. * 云端列表 → 本地会话。逐条以云端为准,本地只提供两个**可丢失**的字段:
  291. * · `lastPreview`:云端为空时用本地兜底(纯展示,不参与任何判定)
  292. * · `titleLocked`:用户改过名的标记(改名会立刻推云端,正常情况下两边一致)
  293. */
  294. const next: ChatSession[] = [];
  295. for (const r of live) {
  296. // 墓碑挡住:用户明确删过这条线程,即使云端还挂着也不显示
  297. if (this._removed().has(r.threadRoot)) continue;
  298. const local = prev.get(r.threadRoot);
  299. next.push({
  300. id: r.threadRoot,
  301. roomId: r.roomId,
  302. agentId: r.agentId || agentId,
  303. title: r.title || NEW_SESSION_TITLE,
  304. titleLocked: r.titleLocked || local?.titleLocked,
  305. createdAt: local?.createdAt ?? (r.lastMessageAt || Date.now()),
  306. updatedAt: Math.max(r.lastMessageAt || 0, local?.updatedAt ?? 0),
  307. archived: r.archived,
  308. messageCount: r.messageCount,
  309. lastPreview: r.lastPreview || local?.lastPreview,
  310. /*
  311. * 工作区归属(2026-11-01 改为云端为准)。
  312. *
  313. * 🔴 此前只从本地缓存捡(`local?.workspaceId`)—— 云端重建列表时
  314. * 本地没这条(换设备/清缓存/刚新建还没落盘)就掉回「日常对话」,
  315. * 用户看到的就是「新建到工作区却跑到日常里」。
  316. * 现在云端有值就用云端;云端为空才回落到本地(兼容旧数据)。
  317. */
  318. workspaceId: r.workspaceId || local?.workspaceId || null,
  319. /*
  320. * 群会话形态(2026-11-05):云端为准,缺失回落本地。
  321. * 🔴 缺省 = dm —— 存量数据没有这个字段,不能因此被当成群聊。
  322. */
  323. kind: r.kind || local?.kind || undefined,
  324. memberIds: r.memberIds?.length ? r.memberIds : local?.memberIds,
  325. /*
  326. * 成果链接数组(2026-11-09):**云端为准,空数组也算数**。
  327. *
  328. * 🔴 原来写成 `r.deliverableUrls?.length ? r.deliverableUrls : local?.xxx`,
  329. * 云端清空后(`[]` 长度为 0)会回退到**本地旧值** ——
  330. * 用户清掉脏数据、刷新页面,错误成果还在(实测反馈)。
  331. *
  332. * 现在:云端明确返回了数组(含空数组)就以它为准;
  333. * 只有字段**完全缺失**(老数据)才用本地兜底。
  334. */
  335. deliverableUrls: Array.isArray(r.deliverableUrls)
  336. ? r.deliverableUrls
  337. : local?.deliverableUrls,
  338. });
  339. }
  340. /*
  341. * 用「该容器」的云端结果**整体替换**本地该容器的条目,其它容器不动。
  342. * 这里没有任何「本地有、云端没有 → 保留」的分支 —— 那正是脏数据来源。
  343. *
  344. * 🔴 唯一例外:**还没同步到云端的本地新建会话**(2026-11-01)。
  345. *
  346. * `create()` 之后是异步推云端的,而新建对话紧接着就会换 URL → 重新
  347. * `open()` → 又拉一次云端。那一刻云端列表里还没有这条,
  348. * 若按「云端没有就丢弃」处理,刚建出来的会话**当场消失**
  349. * (用户实测:点了新建,聊天区是新的但左侧列表没有内容)。
  350. *
  351. * 只保留「本地有、且明确处于待同步状态」的条目,范围极小,
  352. * 不会重新引入「本地脏数据灌回列表」的问题。
  353. */
  354. const pending = this._all().filter(
  355. (s) => s.agentId === agentId && this._pendingPush.has(s.id) && !next.some((n) => n.id === s.id),
  356. );
  357. /*
  358. * 🔴 正在打开的那条会话(`keepId`)即使云端列表里没有,也不能被整体替换抹掉
  359. * (2026-11-06 实测)。
  360. *
  361. * 场景:刷新页面 → URL 指定会话 → 本地目录里有它、云端这一轮还没返回它
  362. * (刚发出、推送在途;演示态则根本没有云端)→ 整表替换后它消失 →
  363. * `open()` 里 `_sessionId` 悬空 → 页面渲染成「还没有对话」,输入区整块消失。
  364. * 范围极小:只是「调用方明确正在打开」的那一条,不是「本地有的都保留」。
  365. */
  366. /*
  367. * 🔴 `keep` 必须**排除已经在 `pending` 里的条目**(2026-11-08)。
  368. *
  369. * 刚新建、还没推上云的会话会同时满足两个条件:
  370. * · 在 `_pendingPush` 里 → 被 `pending` 收一次
  371. * · 正是 `keepId`(正在打开)→ 被 `keep` 再收一次
  372. * 两个数组拼在一起 → 同一个 id 在 `_all` 里存**两份**,
  373. * 左栏就出现两条一模一样的会话(用户实测)。
  374. *
  375. * 判据加 `!this._pendingPush.has(keepId)`,让每条只进一次。
  376. */
  377. /*
  378. * 🔴 「正在打开」的空会话**不保留**(2026-11-08,「新建会话带出旧数据」图39)。
  379. *
  380. * 云端列表里没有它时,原逻辑会把它捡回来。但如果它一条消息都没发过
  381. * (`messageCount` 为 0),它只是一个「新建对话」空壳 ——
  382. * 而 `pruneEmpty()` 的职责正是把这类空壳收敛成**最多一条**。
  383. * 捡回来等于把刚清掉的空壳重新塞进列表,用户看到的就是
  384. * 「新建了一次,列表里却多出好几条一模一样的旧对话」。
  385. *
  386. * 非空会话(发过消息、只是在等云端回写)仍然保留,这一点不变。
  387. */
  388. const keep =
  389. keepId &&
  390. !next.some((n) => n.id === keepId) &&
  391. !this._removed().has(keepId) &&
  392. !this._pendingPush.has(keepId) &&
  393. (this.byId(keepId)?.messageCount ?? 0) > 0 && !this._pendingPush.has(keepId)
  394. ? this._all().filter((s) => s.id === keepId && s.agentId === agentId)
  395. : [];
  396. const others = this._all().filter((s) => s.agentId !== agentId);
  397. /*
  398. * 最后再按 id 去重一次(兜底):任何合并路径都不该产生重复 id。
  399. * 两个数组若有交集,后面的会覆盖前面的 —— 保留先出现的即可。
  400. */
  401. const merged = new Map<string, (typeof next)[number]>();
  402. for (const s of [...others, ...next, ...pending, ...keep]) merged.set(s.id, s);
  403. this._all.set(sortSessions([...merged.values()]));
  404. this.persist();
  405. this._ready.set(true);
  406. this._error.set(null);
  407. const before = new Set(prev.keys());
  408. let added = 0;
  409. for (const s of next) if (!before.has(s.id)) added++;
  410. return added;
  411. }
  412. /**
  413. * 把本地会话(该 agent 下)补推到云端。
  414. *
  415. * 正常路径下每次变更(新建 / 改名 / 归档 / 计数)都已单独推送,
  416. * 这里只在云端刚恢复可用时兜底补齐「推送失败留下的缺口」。
  417. *
  418. * 🔴 云端不可用(`ready=false`)时不推送 —— 此时列表根本不可信,
  419. * 写上去就等于把本地残留灌进云端(历史上发生过)。
  420. */
  421. async pushAll(agentId: string): Promise<void> {
  422. if (!this.api || !this._ready()) return;
  423. const list = this._all().filter((s) => s.agentId === agentId && !s.legacy);
  424. if (list.length === 0) return;
  425. await this.trySync(() => this.api!.upsertChatThreads(list.map(toUpsert)));
  426. // 整批推成功后,这些会话云端已存在,撤销「待同步」标记
  427. for (const s of list) this._pendingPush.delete(s.id);
  428. // 补齐成功 → 清掉「未同步」的错误提示(2026-11-09)
  429. if (this._pushFailed()) this._pushFailed.set(null);
  430. }
  431. /**
  432. * 统一的「云端失败不影响本地」包装。
  433. *
  434. * 🔴 只吞异常、不改本地状态:写入失败时本地照常可用,下次 `hydrateFromCloud`
  435. * 会以云端为准把差异抹平。**这里不允许出现「失败就改本地」的补偿逻辑** ——
  436. * 那正是两端互相覆盖的来源。
  437. */
  438. private async trySync(fn: () => Promise<void>): Promise<void> {
  439. try {
  440. await fn();
  441. } catch {
  442. /* 离线 / 未开通:本地继续可用,下次拉取时以云端为准 */
  443. }
  444. }
  445. /** 单条推送到云端(新建 / 变更后调用) */
  446. private pushOne(s: ChatSession | undefined): void {
  447. if (!s || s.legacy) return;
  448. /*
  449. * 「待同步」标记只在**本地新建、云端尚无**时使用。
  450. *
  451. * 🔴 `pushOne` 也用于「已落库会话的内容更新」(noteMessage 节流推送),
  452. * 若在这里无条件 add,那条会话会**永远**处于待同步状态 ——
  453. * noteMessage 的节流判断 `_pendingPush.has(id)` 恒为真,
  454. * 于是每条消息都打一次接口(实测:节流完全失效)。
  455. * 所以只有 `create()` 会主动登记,这里只负责推送与撤销。
  456. */
  457. void (async () => {
  458. try {
  459. await this.api!.upsertChatThreads([toUpsert(s)]);
  460. this._pendingPush.delete(s.id);
  461. // 成功一次就把之前的失败提示清掉
  462. if (this._pushFailed()) this._pushFailed.set(null);
  463. } catch (e) {
  464. /*
  465. * 推送失败 → **必须让用户知道**(2026-11-09 用户要求)。
  466. *
  467. * 原来这里是静默吞掉的。后果:这条会话只存在于当前这台机器上,
  468. * 换浏览器永远看不到,而用户完全不知情,会以为「同步慢」而反复操作。
  469. *
  470. * 保留 `_pendingPush` 标记 —— 下次 hydrate 或 pushAll 会自动补推。
  471. */
  472. const msg = e instanceof Error && e.message ? e.message : '网络异常';
  473. this._pushFailed.set(`会话「${s.title || '新对话'}」尚未同步到云端:${msg}`);
  474. }
  475. })();
  476. }
  477. /**
  478. * 拉取**所有容器**的会话目录(左栏在没进过任何容器时也要能列出对话)。
  479. *
  480. * 逐容器并发拉取;任意一个失败就整体视为失败(`ready=false`)——
  481. * 只显示拉成功的部分会造成「有的对话突然不见了」,比整体重试更让人困惑。
  482. *
  483. * 并发度做了限制(每批 5 个),避免容器多时一次打满平台接口。
  484. */
  485. async reloadAll(agentIds: string[]): Promise<void> {
  486. if (!this.api) return;
  487. const list = [...new Set(agentIds)].filter(Boolean);
  488. if (list.length === 0) return;
  489. let failed = 0;
  490. let lastErr: unknown = null;
  491. for (let i = 0; i < list.length; i += 5) {
  492. const batch = list.slice(i, i + 5);
  493. const results = await Promise.allSettled(batch.map((a) => this.hydrateFromCloud(a)));
  494. for (const r of results) {
  495. if (r.status === 'rejected') {
  496. failed++;
  497. lastErr = r.reason;
  498. }
  499. }
  500. }
  501. if (failed > 0) {
  502. this._ready.set(false);
  503. this._error.set(
  504. lastErr instanceof Error && lastErr.message ? lastErr.message : '会话目录加载失败',
  505. );
  506. }
  507. }
  508. /** 某 agent 下的会话(默认不含归档) */
  509. listFor(agentId: string, opts: { includeArchived?: boolean } = {}): ChatSession[] {
  510. return sessionsOf(this._all(), agentId, opts);
  511. }
  512. /** 按 id 取(含归档 —— 归档会话仍可通过链接打开) */
  513. byId(id: string): ChatSession | undefined {
  514. return this._all().find((s) => s.id === id);
  515. }
  516. /** 某 agent 最近的会话(默认会话 = 上次聊的) */
  517. latestFor(agentId: string): ChatSession | undefined {
  518. return this.listFor(agentId)[0];
  519. }
  520. /**
  521. * 新建会话(**不触网**)。
  522. *
  523. * 线程根由本地生成 —— 实测容器接受客户端自造的 event_id
  524. * (`docs/03-interface/容器会话与线程机制-20261028.md` §六),
  525. * 所以新建是纯本地操作,即时可用。
  526. */
  527. /**
  528. * 在**本地**新建一条会话(2026-11-01 用户裁决:本地先建,落库推迟)。
  529. *
  530. * 🔴 不再一创建就写云端。原因:
  531. * · 用户点「新建对话」后,左侧要**立刻**出现「新建对话」并选中;
  532. * · 但此时还没有任何消息 —— 若立刻写云端,就产生了一条 0 消息的云端记录,
  533. * 发不发消息都留在那儿,反复点击会让云端越积越多。
  534. * · 现在:本地先建(界面立即可见),**首次发消息时**才真正 upsert 到云端。
  535. *
  536. * 未落库的会话由 `_pendingPush` 标记,`hydrateFromCloud` 拉云端时不会把它冲掉。
  537. */
  538. create(roomId: string, agentId: string, title?: string): ChatSession {
  539. const s = createSession({ roomId, agentId, title });
  540. this._pendingPush.add(s.id);
  541. this._all.update((list) => sortSessions([s, ...list]));
  542. this.persist();
  543. return s;
  544. }
  545. /**
  546. * 新建**群会话**(2026-11-05,本地先建,与单聊同规则)。
  547. *
  548. * 群会话不是真实 Matrix 房间 —— 它只是展示容器,真正的收发在各成员的 DM 里。
  549. * `roomId` 沿用首个成员房间,满足存量字段必填与路由形态。
  550. *
  551. * @param memberIds 至少 2 个容器 id(调用方已校验)
  552. */
  553. createGroup(roomId: string, memberIds: string[], title: string, workspaceId: string | null = null): ChatSession {
  554. const s = createSession({ roomId, agentId: memberIds[0], title });
  555. s.kind = 'group';
  556. s.memberIds = [...memberIds];
  557. s.workspaceId = workspaceId;
  558. this._pendingPush.add(s.id);
  559. this._all.update((list) => sortSessions([s, ...list]));
  560. this.persist();
  561. return s;
  562. }
  563. /**
  564. * 把一条**本地已有**的会话真正落库到云端(首次发消息时调用)。
  565. *
  566. * 与 `create()` 的分工:`create` 只管本地可见性,本方法负责持久化。
  567. */
  568. commit(id: string): void {
  569. const s = this.byId(id);
  570. if (!s || s.legacy) return;
  571. this.pushOne(s);
  572. }
  573. /**
  574. * 这条会话是否**还没落过库**(需要走强顺序的 `commitNow`)。
  575. *
  576. * 判据:仍在待推送集合里 = 本地新建、云端尚无记录。
  577. * 已落库的会话后续更新走 `commit()` 的节流推送即可。
  578. */
  579. needsCommit(id: string): boolean {
  580. return this._pendingPush.has(id);
  581. }
  582. /**
  583. * 让「历史遗留会话」存在于列表中(幂等)。
  584. *
  585. * 只有当房间里**确实存在无 thread 的旧消息**时才调用 —— 没有旧消息就不该
  586. * 凭空多出一个空会话。调用方(`ChatService`)负责这个判断。
  587. *
  588. * @returns `created` 表示本次是否新建;调用方据此决定要不要「首次打开就选中它」
  589. */
  590. adoptLegacy(
  591. roomId: string,
  592. agentId: string,
  593. preview?: string,
  594. ): { session: ChatSession; created: boolean } {
  595. const id = legacySessionId(roomId);
  596. const existing = this.byId(id);
  597. if (existing) return { session: existing, created: false };
  598. const now = Date.now();
  599. const s: ChatSession = {
  600. id,
  601. roomId,
  602. agentId,
  603. title: '历史对话',
  604. titleLocked: true, // 历史会话不改名
  605. createdAt: now,
  606. updatedAt: 0, // 真实时间由首条消息的 noteMessage 推进
  607. archived: false,
  608. legacy: true,
  609. lastPreview: preview,
  610. messageCount: 0,
  611. };
  612. this._all.update((list) => sortSessions([s, ...list]));
  613. this.persist();
  614. return { session: s, created: true };
  615. }
  616. /**
  617. * 接纳一个本地不认识的会话 id(如通过链接直接打开 `/chat/:id/:sessionId`)。
  618. *
  619. * 用于:线程确实存在于 Matrix(历史消息能读到),但本机没存过元信息。
  620. * 标题先给占位,等第一条消息进来会由 `noteMessage` 推导 —— 在此之前**不编造**。
  621. */
  622. adopt(input: {
  623. id: string;
  624. roomId: string;
  625. agentId: string;
  626. title?: string;
  627. /** 消息数(缺省 0 = 新建空会话);按地址恢复已有会话时传 ≥1,避免被空壳清理 */
  628. messageCount?: number;
  629. /**
  630. * 最后一条消息的时间(2026-11-07)。
  631. *
  632. * 🔴 **不要缺省用 `now`**:`adopt` 的触发点是「按地址打开一条目录里没有的会话」,
  633. * 几乎每次刷新都会命中。原来写 `updatedAt: now`,等于每打开一次就把该会话
  634. * 推到左栏第一位 —— 用户反馈「只是点开看看,它就跳到最上头了」。
  635. * 调用方应传入该线程真实的消息时间;实在拿不到才由本方法兜底。
  636. */
  637. lastMessageAt?: number;
  638. createdAt?: number;
  639. }): ChatSession {
  640. const existing = this.byId(input.id);
  641. if (existing) {
  642. /*
  643. * 已存在时也要补正时间:如果它此前被按 `now` 收养过,这里用真实时间纠正,
  644. * 否则那个虚高的值会通过 `hydrateFromCloud` 的 `Math.max` 永久保留。
  645. * 只在**有真实时间且更早**时下调,绝不把已确认的时间往后推。
  646. */
  647. if (input.lastMessageAt && input.lastMessageAt < existing.updatedAt) {
  648. const fixed: ChatSession = {
  649. ...existing,
  650. createdAt: Math.min(existing.createdAt, input.createdAt ?? existing.createdAt),
  651. updatedAt: input.lastMessageAt,
  652. };
  653. this._all.update((list) => sortSessions(list.map((s) => (s.id === fixed.id ? fixed : s))));
  654. this.persist();
  655. return fixed;
  656. }
  657. return existing;
  658. }
  659. // 兜底:没有真实时间时用当前时间(新建会话就是这个语义)
  660. const now = Date.now();
  661. const s: ChatSession = {
  662. id: input.id,
  663. roomId: input.roomId,
  664. agentId: input.agentId,
  665. title: input.title ?? NEW_SESSION_TITLE,
  666. createdAt: input.createdAt ?? now,
  667. updatedAt: input.lastMessageAt ?? now,
  668. archived: false,
  669. messageCount: input.messageCount ?? 0,
  670. };
  671. this._all.update((list) => sortSessions([s, ...list]));
  672. this.persist();
  673. return s;
  674. }
  675. /** 重命名(锁定标题,之后不会被自动覆盖) */
  676. rename(id: string, title: string): void {
  677. const t = title.replace(/\s+/g, ' ').trim();
  678. if (!t) return;
  679. this.update(id, { title: t, titleLocked: true });
  680. void this.trySync(() => this.api!.patchChatThread({ threadRoot: id, title: t }));
  681. }
  682. /** 归档 / 取消归档(软删,不物理删除);同步到云端 */
  683. archive(id: string, archived = true): void {
  684. this.update(id, { archived });
  685. void this.trySync(() => this.api!.patchChatThread({ threadRoot: id, archived }));
  686. }
  687. /**
  688. * 把会话移动到某个工作区(`null` = 日常对话)。
  689. *
  690. * 第 3 轮(2026-10-30)接入:`workspaceId` 由 `WorkspaceService` 的工作区 id 提供,
  691. * 归属只存本地 —— 删除工作区时由调用方把会话退回日常,不会留下悬空引用。
  692. */
  693. /**
  694. * 把会话移动到某个工作区(`null` = 日常对话)。
  695. *
  696. * 🔴 2026-11-01 起**同步到云端**:此前只写本地 localStorage,
  697. * 导致换设备/清缓存后归属丢失,且云端重建列表时会掉回日常对话。
  698. * 云端用 `''` 表示日常对话(空字符串是合法值,不能用真值判断)。
  699. */
  700. moveTo(id: string, workspaceId: string | null): void {
  701. this.update(id, { workspaceId });
  702. const cur = this.byId(id);
  703. if (!cur) return;
  704. /*
  705. * 🔴 **未落库的会话只改本地,不发请求**(2026-11-01 用户裁决)。
  706. *
  707. * 持久化的唯一前提是「该会话里发过消息」。新建对话刚建出来(还没发消息)时
  708. * 就被 `startBlank()` 调 `moveTo` 挂到工作区下 —— 若这里直接 upsert,
  709. * 等于**点一次新建就在云端留一条记录**,连点几次就堆一堆空会话
  710. * (用户实测:多次新建后,前几次的都持久化了)。
  711. *
  712. * 归属先记在本地,等首次发消息触发 `commit()` 时随会话一起落库。
  713. *
  714. * 已落库的会话走 upsert(不是 patch):patch 要求云端已有记录,
  715. * 「新建 → 立刻移动工作区」时可能还没落地,patch 会 404 被静默吞掉,
  716. * 归属压根没写进云端 → 下次拉取掉回「日常对话」。
  717. */
  718. if (this._pendingPush.has(id)) return;
  719. void this.trySync(() => this.api!.upsertChatThreads([toUpsert(cur)]));
  720. }
  721. /** 某工作区下的会话数(不含归档) */
  722. countIn(workspaceId: string | null): number {
  723. return this._all().filter((s) => !s.archived && (s.workspaceId ?? null) === workspaceId).length;
  724. }
  725. /**
  726. * 给会话换一个房间(一房间一会话,2026-11-12)。
  727. *
  728. * 场景:新建对话时先在绑定房间建出会话(保证即时可用),
  729. * 后台再为它开专属房间;房间落地后把 `roomId` 改过去。
  730. *
  731. * 与 `moveTo()` 同一纪律:**未落库的会话只改本地**,等首次发消息
  732. * 随 `commit()` 一起写云端,避免「点一次新建就在云端留一条空记录」。
  733. */
  734. setRoomId(id: string, roomId: string): void {
  735. if (!roomId) return;
  736. const cur = this.byId(id);
  737. if (!cur || cur.roomId === roomId) return;
  738. this.update(id, { roomId });
  739. if (this._pendingPush.has(id)) return;
  740. void this.trySync(() => this.api!.upsertChatThreads([toUpsert(this.byId(id)!)]));
  741. }
  742. /** 该房间下已有哪些会话(迁房后用于确认「一房间一会话」没被破坏) */
  743. sessionsInRoom(roomId: string): readonly ChatSession[] {
  744. return this._all().filter((s) => s.roomId === roomId);
  745. }
  746. /**
  747. * 从列表移除一条会话(**只移除索引,Matrix 里的消息不动**)。
  748. *
  749. * 安全性:会话列表只是「有哪些对话」的索引,消息本体在 Matrix 房间里。
  750. * 移除索引后对话从列表消失,但消息**没有被删除** ——
  751. * 文案必须说清楚这一点,否则用户会以为内容丢了。
  752. */
  753. remove(id: string): void {
  754. /*
  755. * 🔴 必须记进「已移除」名单 —— 否则下一次从云端拉取时它会重新出现。
  756. *
  757. * 会话目录的真相源是云端表。只删本地索引的话,下次 `hydrateFromCloud`
  758. * 拉到的还是同一批线程,这条又回来了(实测:删了没效果)。
  759. * 云端删除失败时,本地黑名单是最后一道兜底。
  760. */
  761. const cur = this.byId(id); // 删除前取,删除后取不到 roomId/agentId
  762. this._removed.update((set) => new Set(set).add(id));
  763. this._all.update((list) => list.filter((x) => x.id !== id));
  764. this.persist();
  765. /*
  766. * 云端删除索引:物理删除记录 + 留一条墓碑阻止扫描重建。
  767. * Matrix 房间里的消息**不受影响**(会话正文仍在服务器)。
  768. */
  769. void this.trySync(() =>
  770. this.api!.removeChatThread({ threadRoot: id, roomId: cur?.roomId, agentId: cur?.agentId }),
  771. );
  772. }
  773. /** 该线程是否被用户移除过(拉取云端时据此跳过) */
  774. isRemoved(id: string): boolean {
  775. return this._removed().has(id);
  776. }
  777. /**
  778. * 该线程是否**已被云端标记为删除**(墓碑)。
  779. *
  780. * 与 `isRemoved` 的区别:`isRemoved` 还包含「本机删过但云端还没同步」,
  781. * 而这里只认云端墓碑 —— 用于「URL 直达已删会话」这种需要权威判断的场景。
  782. *
  783. * 注意:`cloudRemoved` 在会话目录**还没加载**时是空的。此时返回 false,
  784. * 让调用方按原有路径走(不能因为「还没拉到」就拒绝打开任何会话)。
  785. */
  786. isCloudRemoved(id: string): boolean {
  787. return this.cloudRemoved.has(id);
  788. }
  789. /**
  790. * 收敛「新建对话」空壳:**整个列表最多只留一条**。
  791. *
  792. * 2026-11-01 用户裁决:
  793. * 新建对话是「点击即建」(左侧立刻可见并选中),但持久化的唯一前提是
  794. * **在该会话里发过消息**。所以:
  795. * · 「新建对话」在列表里只能有一条 —— 不论在哪个工作区、哪个容器新建,
  796. * 一旦新建,**之前所有没发过消息的空会话一律清掉**;
  797. * · 不需要等 60 秒,也不需要定时器 —— 新建/切换的那一刻就收敛。
  798. *
  799. * 实现上区分两类空壳:
  800. * · **从未落库**(`_pendingPush`)→ 删本地即可,不发云端请求(云端本来就没有);
  801. * · **已落库**(发过消息后又被清空)→ 删本地 + 云端软删,否则下次拉取会回来。
  802. *
  803. * @param keepId 要保留的那条(通常是刚新建/正在看的会话)
  804. */
  805. pruneEmpty(keepId?: string): void {
  806. const victims = this._all().filter(
  807. (s) => !s.legacy && s.id !== keepId && (s.messageCount ?? 0) === 0,
  808. );
  809. if (victims.length === 0) return;
  810. const dead = new Set(victims.map((s) => s.id));
  811. this._all.update((list) => list.filter((x) => !dead.has(x.id)));
  812. this.persist();
  813. for (const v of victims) {
  814. if (this._pendingPush.has(v.id)) {
  815. // 从没写过云端 → 撤销标记即可,不发请求
  816. this._pendingPush.delete(v.id);
  817. continue;
  818. }
  819. void this.trySync(() =>
  820. this.api!.removeChatThread({ threadRoot: v.id, roomId: v.roomId, agentId: v.agentId }),
  821. );
  822. }
  823. }
  824. /**
  825. * 直接设置消息计数与最后活动时间。
  826. *
  827. * 用于「历史对话」——它的正文是无 thread 的旧消息,
  828. * 不参与线程扫描的计数统计,只能由调用方按实际条数写入。
  829. */
  830. setMessageCount(id: string, count: number, updatedAt?: number): void {
  831. const patch: Partial<ChatSession> = { messageCount: count };
  832. if (updatedAt && updatedAt > (this.byId(id)?.updatedAt ?? 0)) patch.updatedAt = updatedAt;
  833. this.update(id, patch);
  834. }
  835. /**
  836. * 把一条云盘链接记到会话的成果列表里(2026-11-09 用户裁决)。
  837. *
  838. * 用户要求:成果按会话存放 —— 会话记录里存该会话成果的在线链接数组。
  839. *
  840. * 调用时机:会话里出现带 `cloudUrl` 的附件 / 有新成果上云时。
  841. * 幂等:同一链接不重复加;已有链接不会被覆盖掉。
  842. */
  843. addDeliverableUrl(id: string, ...urls: (string | null | undefined)[]): void {
  844. const add = urls.map((x) => (x ?? '').trim()).filter((u) => u.length > 0);
  845. if (add.length === 0) return;
  846. const cur = this.byId(id);
  847. if (!cur) return;
  848. const list = cur.deliverableUrls ?? [];
  849. const fresh = add.filter((u) => !list.includes(u));
  850. if (fresh.length === 0) return;
  851. // 上限 50,与服务端 `_strArr` 的截断一致
  852. const next = [...list, ...fresh].slice(0, 50);
  853. this.update(id, { deliverableUrls: next });
  854. /*
  855. * 🔴 立刻落库:成果归属是跨设备可见的数据,只留本地没有意义。
  856. * `pushOne` 是 fire-and-forget,失败会进 `_pushFailed` 并等下次补推。
  857. */
  858. this.pushOne(this.byId(id));
  859. }
  860. /**
  861. * 写入一条消息(更新预览/时间,必要时推导标题)。
  862. *
  863. * 由 `ChatService` 在消息到达/发出时调用。
  864. */
  865. noteMessage(
  866. id: string,
  867. msg: { text?: string; ts: number; fromMe: boolean },
  868. ): void {
  869. const cur = this.byId(id);
  870. if (!cur) return;
  871. const next = touchSession(cur, msg);
  872. this.update(id, next);
  873. /*
  874. * 🔴 必须把变化推回云端(2026-11-01 修复)。
  875. *
  876. * 此前 `noteMessage` **只写本地** —— 标题、消息数、最后时间全留在浏览器里,
  877. * 云端那条记录一直停在「新对话 / 0 条消息」。
  878. * 于是换设备、清缓存、甚至只是重新拉一次列表,会话名就退回「新对话」
  879. * (用户实测:历史会话名称加载不对)。
  880. *
  881. * 节流:只在三种关键时刻推送,避免每条消息都打一次接口 ——
  882. * ① 该会话**还没落库**(`_pendingPush`)→ 必须立刻写,不节流;
  883. * ② 标题变了(首条消息命名成功);
  884. * ③ 距上次推送超过 10 秒(保证消息数与时间不会长期落后)。
  885. */
  886. const pending = this._pendingPush.has(id);
  887. const titleChanged = next.title !== cur.title;
  888. const due = Date.now() - (this.lastPushAt.get(id) ?? 0) > 10_000;
  889. if (!this._ready() || (!pending && !titleChanged && !due)) return;
  890. this.lastPushAt.set(id, Date.now());
  891. /*
  892. * 🔴 首次落库(pending)也在这里推,但要能被 `sendText` **等待**(2026-11-09)。
  893. *
  894. * 用户要求:第一次发消息时先持久化,**存储成功后才发送**。
  895. * 做法是把这次推送的 Promise 挂到 `_commitWait`,`commitNow()` 取出来 await;
  896. * 调用方(群聊等)没有 await 也不会丢 —— 推送照常在后台跑完。
  897. */
  898. const session = this.byId(id) ?? next;
  899. const p = this.pushOneAsync(session);
  900. if (pending) this._commitWait.set(id, p);
  901. else void p;
  902. }
  903. /**
  904. * 等待该会话的**首次落库**完成(2026-11-09 用户要求)。
  905. *
  906. * - 若 `noteMessage` 已经发起首次推送 → 直接等那个 Promise,不重复请求
  907. * - 若还没发起(例如调用方直接 sendText 没走 noteMessage)→ 主动推一次
  908. *
  909. * @throws 云端写入失败时抛出,调用方据此把消息标记为失败、不发送
  910. */
  911. async commitNow(id: string): Promise<void> {
  912. const s = this.byId(id);
  913. if (!s || s.legacy) return;
  914. if (!this.api) return; // 演示态:无云端
  915. const inflight = this._commitWait.get(id);
  916. if (inflight) {
  917. this._commitWait.delete(id);
  918. await inflight;
  919. return;
  920. }
  921. await this.pushOneAsync(s);
  922. }
  923. /**
  924. * 推送一条会话并按结果 resolve/reject(`pushOne` 的同步版)。
  925. *
  926. * 🔴 `pushOne` 是 fire-and-forget,失败只设提示不抛;首次落库需要抛给调用方,
  927. * 所以单开一个可 await 的实现,两者共用同一套成功/失败处理。
  928. */
  929. private async pushOneAsync(s: ChatSession): Promise<void> {
  930. if (s.legacy || !this.api) return;
  931. try {
  932. await this.api.upsertChatThreads([toUpsert(s)]);
  933. this._pendingPush.delete(s.id);
  934. if (this._pushFailed()) this._pushFailed.set(null);
  935. } catch (e) {
  936. const msg = e instanceof Error && e.message ? e.message : '网络异常';
  937. // 保留待同步标记,下次 hydrate / pushAll 自动补推
  938. this._pushFailed.set(`会话「${s.title || '新对话'}」尚未同步到云端:${msg}`);
  939. throw new Error(msg);
  940. }
  941. }
  942. /** 取某房间下所有已知会话的线程根(消息过滤用) */
  943. threadIdsOf(roomId: string): Set<string> {
  944. return new Set(this._all().filter((s) => s.roomId === roomId).map((s) => s.id));
  945. }
  946. /**
  947. * 按 id 打补丁。
  948. *
  949. * 🔴 **值没变就不写**:本方法可能被 effect 调用,无条件写 signal 会造成
  950. * 「effect → 写 → effect 再跑」的无限循环(冷启动实测页面崩溃)。
  951. */
  952. private update(id: string, patch: Partial<ChatSession>): ChatSession | undefined {
  953. const cur = this.byId(id);
  954. if (!cur) return undefined;
  955. const changed = (Object.keys(patch) as (keyof ChatSession)[]).some(
  956. (k) => cur[k] !== patch[k],
  957. );
  958. if (!changed) return cur;
  959. const result: ChatSession = { ...cur, ...patch };
  960. this._all.update((list) => sortSessions(list.map((s) => (s.id === id ? result : s))));
  961. this.persist();
  962. return result;
  963. }
  964. /**
  965. * 本地存储键(**按用户隔离**)。
  966. *
  967. * 🔴 未登录时返回 null,调用方直接跳过读写 ——
  968. * 绝不能回落到一个共享的 `anon` 键:那样两个未登录会话、
  969. * 或「登出后再登录另一个账号」的间隙,会把别人的会话显示出来。
  970. *
  971. * 🔴 `_v2` 后缀(2026-11-01):缓存结构语义变了(云端失败时不再展示旧数据、
  972. * 空会话不再落云端),旧键里的数据按新规则会被当成脏数据,
  973. * 直接换键即等于**一次性失效**,无需写迁移代码。
  974. */
  975. private key(): string | null {
  976. const uid = this.session.me()?.id;
  977. return uid ? `fmode_chat_sessions_v2_${uid}` : null;
  978. }
  979. private persist(): void {
  980. const key = this.key();
  981. // 未登录不落盘 —— 没有 owner 的数据写下去就是下一个账号的串号源
  982. if (!key) return;
  983. try {
  984. localStorage.setItem(
  985. key,
  986. JSON.stringify(this._all()),
  987. );
  988. // 黑名单单独存 — 它描述的是「用户不想要哪些」,与会话列表同生命周期
  989. localStorage.setItem(`${key}_removed`, JSON.stringify([...this._removed()]));
  990. } catch {
  991. /* 隐私模式:只保留内存态 */
  992. }
  993. }
  994. }
  995. /**
  996. * 会话 → 云端 upsert 载荷(唯一构造点)。
  997. *
  998. * 收敛到一处的原因:此前 `pushAll` / `pushOne` / `mergeScanned` 各手写一份同样的
  999. * 字段表,改一个字段要改三处,漏一处就会出现「某条路径写上去的会话缺字段」。
  1000. */
  1001. function toUpsert(s: ChatSession): ChatThreadUpsert {
  1002. return {
  1003. agentId: s.agentId,
  1004. roomId: s.roomId,
  1005. threadRoot: s.id,
  1006. title: s.title,
  1007. messageCount: s.messageCount ?? 0,
  1008. lastMessageAt: s.updatedAt || s.createdAt,
  1009. lastPreview: s.lastPreview,
  1010. // 归属随 upsert 一起上云('' = 日常对话),否则换设备后层级丢失
  1011. workspaceId: s.workspaceId ?? '',
  1012. // 群会话形态与成员(2026-11-05):不同步会导致换设备后群聊退化成单聊
  1013. kind: s.kind,
  1014. memberIds: s.memberIds,
  1015. // 本会话的成果链接(2026-11-09):成果按会话存放
  1016. deliverableUrls: s.deliverableUrls,
  1017. };
  1018. }
  1019. /** 结构校验:坏数据不得进入列表 */
  1020. function isSession(x: unknown): x is ChatSession {
  1021. if (!x || typeof x !== 'object') return false;
  1022. const o = x as Record<string, unknown>;
  1023. return (
  1024. typeof o['id'] === 'string' &&
  1025. typeof o['roomId'] === 'string' &&
  1026. typeof o['agentId'] === 'string' &&
  1027. typeof o['title'] === 'string' &&
  1028. typeof o['createdAt'] === 'number' &&
  1029. typeof o['updatedAt'] === 'number'
  1030. );
  1031. }
  1032. /** 补齐可选字段,旧版本数据也能读 */
  1033. function normalize(s: ChatSession): ChatSession {
  1034. return {
  1035. ...s,
  1036. archived: !!s.archived,
  1037. messageCount: s.messageCount ?? 0,
  1038. workspaceId: s.workspaceId ?? null,
  1039. };
  1040. }