chat-session.service.ts 32 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784
  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. readonly all = this._all.asReadonly();
  53. /** 按 agent 分组的会话(未归档,已排序) */
  54. readonly byAgent = computed<Map<string, ChatSession[]>>(() => {
  55. const m = new Map<string, ChatSession[]>();
  56. for (const s of this._all()) {
  57. /*
  58. * 🔴 **不再在这里丢掉已归档的会话**。
  59. *
  60. * 原实现在这一步 `if (s.archived) continue;` —— 归档的会话直接从
  61. * 映射里消失,于是上层无论怎么判断 `showArchived` 都拿不到它们,
  62. * 「显示已归档」开关**永远无效**(实测点击无反应)。
  63. *
  64. * 归档的取舍交给调用方:默认列表过滤掉,「已归档」目录单独展示。
  65. */
  66. const list = m.get(s.agentId) ?? [];
  67. list.push(s);
  68. m.set(s.agentId, list);
  69. }
  70. for (const [k, v] of m) m.set(k, sortSessions(v));
  71. return m;
  72. });
  73. private loadedFor: string | null = null;
  74. /**
  75. * 会话目录是否可信(2026-11-01 用户裁决)。
  76. *
  77. * 🔴 **`false` 时列表一律为空**,宁可显示「加载失败,重试」也不展示本地缓存。
  78. *
  79. * 为什么改掉「离线展示缓存」:
  80. * 缓存与云端没有可靠的全序(谁更新说不清),一边展示旧缓存、一边又把本地
  81. * 回写云端,两端会互相覆盖 —— 这是「删掉的会话又回来 / 云端为空却被灌满」
  82. * 反复出现的根源。现在只有一条规则:**云端成功返回后列表才可信**。
  83. *
  84. * 初始值 `false`:登录后必须先成功拉一次云端,列表才可能出现内容。
  85. */
  86. private readonly _ready = signal(false);
  87. readonly ready = this._ready.asReadonly();
  88. /** 最近一次云端拉取的失败原因(`null` = 正常);界面据此给出重试入口 */
  89. private readonly _error = signal<string | null>(null);
  90. readonly error = this._error.asReadonly();
  91. /** 上次云端拉取失败的原因(`null` = 正常);供调用方判断能否继续 */
  92. isCloudStale(): boolean {
  93. return !this._ready();
  94. }
  95. /**
  96. * 用户主动移除过的会话 id。
  97. *
  98. * 这些线程在 Matrix 里**仍然存在**(消息没删),但用户不想再看到它们。
  99. * 扫描重建时必须跳过,否则删了立刻被扫回来。
  100. */
  101. private readonly _removed = signal<Set<string>>(new Set());
  102. /**
  103. * 云端已软删的线程根(跨设备有效)。
  104. * 与本地 `_removed` 一起构成「哪些线程不允许重新出现」的黑名单。
  105. * 注意:目录已不再从 Matrix 扫描重建,它现在的用途是兜住
  106. * 「云端列表与墓碑不一致」的历史数据。
  107. */
  108. private cloudRemoved = new Set<string>();
  109. /**
  110. * 每个会话上一次把元信息推到云端的时间(节流用,见 `noteMessage`)。
  111. *
  112. * 放在内存里而不是持久化:它只用于「别太频繁地打接口」,
  113. * 刷新后重新计时即可,不影响任何数据的正确性。
  114. */
  115. private readonly lastPushAt = new Map<string, number>();
  116. /**
  117. * 本地已建、但还没确认推到云端的会话 id。
  118. *
  119. * 用途很窄:`hydrateFromCloud` 整体替换列表时,这些会话在云端还不存在,
  120. * 必须保留 —— 否则「新建对话 → 换 URL → 重新拉取云端」会把刚建的会话抹掉。
  121. * 推送成功后立即移除,避免长期存在。
  122. */
  123. private readonly _pendingPush = new Set<string>();
  124. /**
  125. * 从 localStorage 载入(Shell 启动时调用一次;重复调用幂等)。
  126. *
  127. * 🔴 **按用户区分载入状态**:存储键含用户 id,换账号登录后必须重新载入,
  128. * 否则会把上一个账号的会话列表显示给新用户(越权 + 串号)。
  129. * 用户 id 变化时清空内存态再读。
  130. */
  131. load(): void {
  132. /*
  133. * 🔴 未登录(key 为 null)时**不读也不写**:
  134. * 内存里此刻可能是上一个账号的会话,必须清空,
  135. * 否则登录页/切换账号的瞬间会把别人的对话闪出来。
  136. */
  137. const key = this.key();
  138. if (key === null) {
  139. if (this.loadedFor !== null) this._all.set([]);
  140. this.loadedFor = null;
  141. return;
  142. }
  143. if (this.loadedFor === key) return;
  144. const switchingUser = this.loadedFor !== null;
  145. this.loadedFor = key;
  146. if (switchingUser) this._all.set([]);
  147. try {
  148. const raw = localStorage.getItem(key);
  149. if (!raw) return;
  150. const parsed = JSON.parse(raw) as unknown;
  151. if (!Array.isArray(parsed)) return;
  152. // 逐条校验 —— 坏数据不应该让整个列表消失
  153. const list = parsed.filter(isSession).map(normalize);
  154. this._all.set(sortSessions(list));
  155. // 恢复「已移除」黑名单
  156. const removedRaw = localStorage.getItem(`${key}_removed`);
  157. if (removedRaw) {
  158. const arr = JSON.parse(removedRaw) as unknown;
  159. if (Array.isArray(arr)) {
  160. this._removed.set(new Set(arr.filter((x): x is string => typeof x === 'string')));
  161. }
  162. }
  163. } catch {
  164. /* 隐私模式 / 坏 JSON:降级为内存态 */
  165. }
  166. }
  167. /**
  168. * 清空内存态(登出时调用)。
  169. *
  170. * 🔴 `_ready` 必须一起复位:否则登出再登录后,列表会凭「上一次登录的成功状态」
  171. * 直接被视为可信,在新一轮云端拉取完成前就渲染出(可能属于别人的)内容。
  172. */
  173. reset(): void {
  174. this.loadedFor = null;
  175. this._all.set([]);
  176. this._ready.set(false);
  177. this._error.set(null);
  178. this.cloudRemoved = new Set();
  179. this._removed.set(new Set());
  180. }
  181. // ================= 云端同步(2026-11-01 重写:单向 云端 → 本地) =================
  182. /**
  183. * 从云端拉取会话目录(**唯一真相源**,本地只是渲染缓存)。
  184. *
  185. * ## 唯一规则
  186. *
  187. * **云端成功返回 → 本地该容器的会话直接等于云端返回的集合**;
  188. * 云端失败 → 列表清空并标记不可用(`ready = false`),界面显示「加载失败,重试」,
  189. * **绝不展示旧缓存、也绝不把本地数据反推回云端**。
  190. *
  191. * 为什么不再做「合并」:
  192. * 线上反复出现「删掉的会话又回来 / 云端为空却被灌满 / 同一会话七八条」,
  193. * 根因都是两端各写一半,再用「只补不覆盖」之类的规则去猜谁对。
  194. * 只要允许本地影响云端,就必须维护一套合并规则,而合并规则本身会出错。
  195. * 改成单向同步后,冲突面直接归零。
  196. *
  197. * @returns 本次新增的会话条数(调用方一般不关心)
  198. */
  199. async hydrateFromCloud(agentId: string): Promise<number> {
  200. /*
  201. * 🔴 先确保本地缓存已载入:生产由页面在进入路由时调用 load(),
  202. * 但云端恢复不应依赖调用顺序(测试/深链进入都可能没调过)。
  203. * load() 幂等,重复调用无副作用。
  204. */
  205. this.load();
  206. const key = this.key();
  207. const api = this.api;
  208. if (!key || this.loadedFor !== key || !api) return 0;
  209. let live: ChatThreadRecord[];
  210. let deleted: ChatThreadRecord[];
  211. try {
  212. /*
  213. * 同时拉两份:活跃会话 + 已软删会话。
  214. * 软删那份只用于挡住「同一条线程被重新写回来」(墓碑跨设备有效)。
  215. */
  216. [live, deleted] = await Promise.all([
  217. api.fetchChatThreads(agentId),
  218. api.fetchChatThreads(agentId, { includeDeleted: true }).catch(() => [] as ChatThreadRecord[]),
  219. ]);
  220. } catch (e) {
  221. /*
  222. * 云端不可达 → **列表不可信**(用户裁决:宁可不显示,也不显示可能过期的缓存)。
  223. * 内存里留着旧内容没有意义 —— 界面依据 `ready()` 直接渲染「加载失败」。
  224. */
  225. this._ready.set(false);
  226. this._error.set(e instanceof Error && e.message ? e.message : '会话目录加载失败');
  227. return 0;
  228. }
  229. this.cloudRemoved = new Set(deleted.map((d) => d.threadRoot));
  230. const prev = new Map(
  231. this._all()
  232. .filter((s) => s.agentId === agentId)
  233. .map((s) => [s.id, s]),
  234. );
  235. /*
  236. * 云端列表 → 本地会话。逐条以云端为准,本地只提供两个**可丢失**的字段:
  237. * · `lastPreview`:云端为空时用本地兜底(纯展示,不参与任何判定)
  238. * · `titleLocked`:用户改过名的标记(改名会立刻推云端,正常情况下两边一致)
  239. */
  240. const next: ChatSession[] = [];
  241. for (const r of live) {
  242. // 墓碑挡住:用户明确删过这条线程,即使云端还挂着也不显示
  243. if (this._removed().has(r.threadRoot)) continue;
  244. const local = prev.get(r.threadRoot);
  245. next.push({
  246. id: r.threadRoot,
  247. roomId: r.roomId,
  248. agentId: r.agentId || agentId,
  249. title: r.title || NEW_SESSION_TITLE,
  250. titleLocked: r.titleLocked || local?.titleLocked,
  251. createdAt: local?.createdAt ?? (r.lastMessageAt || Date.now()),
  252. updatedAt: Math.max(r.lastMessageAt || 0, local?.updatedAt ?? 0),
  253. archived: r.archived,
  254. messageCount: r.messageCount,
  255. lastPreview: r.lastPreview || local?.lastPreview,
  256. /*
  257. * 工作区归属(2026-11-01 改为云端为准)。
  258. *
  259. * 🔴 此前只从本地缓存捡(`local?.workspaceId`)—— 云端重建列表时
  260. * 本地没这条(换设备/清缓存/刚新建还没落盘)就掉回「日常对话」,
  261. * 用户看到的就是「新建到工作区却跑到日常里」。
  262. * 现在云端有值就用云端;云端为空才回落到本地(兼容旧数据)。
  263. */
  264. workspaceId: r.workspaceId || local?.workspaceId || null,
  265. });
  266. }
  267. /*
  268. * 用「该容器」的云端结果**整体替换**本地该容器的条目,其它容器不动。
  269. * 这里没有任何「本地有、云端没有 → 保留」的分支 —— 那正是脏数据来源。
  270. *
  271. * 🔴 唯一例外:**还没同步到云端的本地新建会话**(2026-11-01)。
  272. *
  273. * `create()` 之后是异步推云端的,而新建对话紧接着就会换 URL → 重新
  274. * `open()` → 又拉一次云端。那一刻云端列表里还没有这条,
  275. * 若按「云端没有就丢弃」处理,刚建出来的会话**当场消失**
  276. * (用户实测:点了新建,聊天区是新的但左侧列表没有内容)。
  277. *
  278. * 只保留「本地有、且明确处于待同步状态」的条目,范围极小,
  279. * 不会重新引入「本地脏数据灌回列表」的问题。
  280. */
  281. const pending = this._all().filter(
  282. (s) => s.agentId === agentId && this._pendingPush.has(s.id) && !next.some((n) => n.id === s.id),
  283. );
  284. const others = this._all().filter((s) => s.agentId !== agentId);
  285. this._all.set(sortSessions([...others, ...next, ...pending]));
  286. this.persist();
  287. this._ready.set(true);
  288. this._error.set(null);
  289. const before = new Set(prev.keys());
  290. let added = 0;
  291. for (const s of next) if (!before.has(s.id)) added++;
  292. return added;
  293. }
  294. /**
  295. * 把本地会话(该 agent 下)补推到云端。
  296. *
  297. * 正常路径下每次变更(新建 / 改名 / 归档 / 计数)都已单独推送,
  298. * 这里只在云端刚恢复可用时兜底补齐「推送失败留下的缺口」。
  299. *
  300. * 🔴 云端不可用(`ready=false`)时不推送 —— 此时列表根本不可信,
  301. * 写上去就等于把本地残留灌进云端(历史上发生过)。
  302. */
  303. async pushAll(agentId: string): Promise<void> {
  304. if (!this.api || !this._ready()) return;
  305. const list = this._all().filter((s) => s.agentId === agentId && !s.legacy);
  306. if (list.length === 0) return;
  307. await this.trySync(() => this.api!.upsertChatThreads(list.map(toUpsert)));
  308. // 整批推成功后,这些会话云端已存在,撤销「待同步」标记
  309. for (const s of list) this._pendingPush.delete(s.id);
  310. }
  311. /**
  312. * 统一的「云端失败不影响本地」包装。
  313. *
  314. * 🔴 只吞异常、不改本地状态:写入失败时本地照常可用,下次 `hydrateFromCloud`
  315. * 会以云端为准把差异抹平。**这里不允许出现「失败就改本地」的补偿逻辑** ——
  316. * 那正是两端互相覆盖的来源。
  317. */
  318. private async trySync(fn: () => Promise<void>): Promise<void> {
  319. try {
  320. await fn();
  321. } catch {
  322. /* 离线 / 未开通:本地继续可用,下次拉取时以云端为准 */
  323. }
  324. }
  325. /** 单条推送到云端(新建 / 变更后调用) */
  326. private pushOne(s: ChatSession | undefined): void {
  327. if (!s || s.legacy) return;
  328. /*
  329. * 「待同步」标记只在**本地新建、云端尚无**时使用。
  330. *
  331. * 🔴 `pushOne` 也用于「已落库会话的内容更新」(noteMessage 节流推送),
  332. * 若在这里无条件 add,那条会话会**永远**处于待同步状态 ——
  333. * noteMessage 的节流判断 `_pendingPush.has(id)` 恒为真,
  334. * 于是每条消息都打一次接口(实测:节流完全失效)。
  335. * 所以只有 `create()` 会主动登记,这里只负责推送与撤销。
  336. */
  337. void (async () => {
  338. try {
  339. await this.api!.upsertChatThreads([toUpsert(s)]);
  340. this._pendingPush.delete(s.id);
  341. } catch {
  342. /* 离线/服务异常:保留标记,等下次 hydrate 或 pushAll 再补 */
  343. }
  344. })();
  345. }
  346. /**
  347. * 拉取**所有容器**的会话目录(左栏在没进过任何容器时也要能列出对话)。
  348. *
  349. * 逐容器并发拉取;任意一个失败就整体视为失败(`ready=false`)——
  350. * 只显示拉成功的部分会造成「有的对话突然不见了」,比整体重试更让人困惑。
  351. *
  352. * 并发度做了限制(每批 5 个),避免容器多时一次打满平台接口。
  353. */
  354. async reloadAll(agentIds: string[]): Promise<void> {
  355. if (!this.api) return;
  356. const list = [...new Set(agentIds)].filter(Boolean);
  357. if (list.length === 0) return;
  358. let failed = 0;
  359. let lastErr: unknown = null;
  360. for (let i = 0; i < list.length; i += 5) {
  361. const batch = list.slice(i, i + 5);
  362. const results = await Promise.allSettled(batch.map((a) => this.hydrateFromCloud(a)));
  363. for (const r of results) {
  364. if (r.status === 'rejected') {
  365. failed++;
  366. lastErr = r.reason;
  367. }
  368. }
  369. }
  370. if (failed > 0) {
  371. this._ready.set(false);
  372. this._error.set(
  373. lastErr instanceof Error && lastErr.message ? lastErr.message : '会话目录加载失败',
  374. );
  375. }
  376. }
  377. /** 某 agent 下的会话(默认不含归档) */
  378. listFor(agentId: string, opts: { includeArchived?: boolean } = {}): ChatSession[] {
  379. return sessionsOf(this._all(), agentId, opts);
  380. }
  381. /** 按 id 取(含归档 —— 归档会话仍可通过链接打开) */
  382. byId(id: string): ChatSession | undefined {
  383. return this._all().find((s) => s.id === id);
  384. }
  385. /** 某 agent 最近的会话(默认会话 = 上次聊的) */
  386. latestFor(agentId: string): ChatSession | undefined {
  387. return this.listFor(agentId)[0];
  388. }
  389. /**
  390. * 新建会话(**不触网**)。
  391. *
  392. * 线程根由本地生成 —— 实测容器接受客户端自造的 event_id
  393. * (`docs/03-interface/容器会话与线程机制-20261028.md` §六),
  394. * 所以新建是纯本地操作,即时可用。
  395. */
  396. /**
  397. * 在**本地**新建一条会话(2026-11-01 用户裁决:本地先建,落库推迟)。
  398. *
  399. * 🔴 不再一创建就写云端。原因:
  400. * · 用户点「新建对话」后,左侧要**立刻**出现「新建对话」并选中;
  401. * · 但此时还没有任何消息 —— 若立刻写云端,就产生了一条 0 消息的云端记录,
  402. * 发不发消息都留在那儿,反复点击会让云端越积越多。
  403. * · 现在:本地先建(界面立即可见),**首次发消息时**才真正 upsert 到云端。
  404. *
  405. * 未落库的会话由 `_pendingPush` 标记,`hydrateFromCloud` 拉云端时不会把它冲掉。
  406. */
  407. create(roomId: string, agentId: string, title?: string): ChatSession {
  408. const s = createSession({ roomId, agentId, title });
  409. this._pendingPush.add(s.id);
  410. this._all.update((list) => sortSessions([s, ...list]));
  411. this.persist();
  412. return s;
  413. }
  414. /**
  415. * 把一条**本地已有**的会话真正落库到云端(首次发消息时调用)。
  416. *
  417. * 与 `create()` 的分工:`create` 只管本地可见性,本方法负责持久化。
  418. */
  419. commit(id: string): void {
  420. const s = this.byId(id);
  421. if (!s || s.legacy) return;
  422. this.pushOne(s);
  423. }
  424. /**
  425. * 让「历史遗留会话」存在于列表中(幂等)。
  426. *
  427. * 只有当房间里**确实存在无 thread 的旧消息**时才调用 —— 没有旧消息就不该
  428. * 凭空多出一个空会话。调用方(`ChatService`)负责这个判断。
  429. *
  430. * @returns `created` 表示本次是否新建;调用方据此决定要不要「首次打开就选中它」
  431. */
  432. adoptLegacy(
  433. roomId: string,
  434. agentId: string,
  435. preview?: string,
  436. ): { session: ChatSession; created: boolean } {
  437. const id = legacySessionId(roomId);
  438. const existing = this.byId(id);
  439. if (existing) return { session: existing, created: false };
  440. const now = Date.now();
  441. const s: ChatSession = {
  442. id,
  443. roomId,
  444. agentId,
  445. title: '历史对话',
  446. titleLocked: true, // 历史会话不改名
  447. createdAt: now,
  448. updatedAt: 0, // 真实时间由首条消息的 noteMessage 推进
  449. archived: false,
  450. legacy: true,
  451. lastPreview: preview,
  452. messageCount: 0,
  453. };
  454. this._all.update((list) => sortSessions([s, ...list]));
  455. this.persist();
  456. return { session: s, created: true };
  457. }
  458. /**
  459. * 接纳一个本地不认识的会话 id(如通过链接直接打开 `/chat/:id/:sessionId`)。
  460. *
  461. * 用于:线程确实存在于 Matrix(历史消息能读到),但本机没存过元信息。
  462. * 标题先给占位,等第一条消息进来会由 `noteMessage` 推导 —— 在此之前**不编造**。
  463. */
  464. adopt(input: { id: string; roomId: string; agentId: string; title?: string }): ChatSession {
  465. const existing = this.byId(input.id);
  466. if (existing) return existing;
  467. const now = Date.now();
  468. const s: ChatSession = {
  469. id: input.id,
  470. roomId: input.roomId,
  471. agentId: input.agentId,
  472. title: input.title ?? NEW_SESSION_TITLE,
  473. createdAt: now,
  474. updatedAt: now,
  475. archived: false,
  476. messageCount: 0,
  477. };
  478. this._all.update((list) => sortSessions([s, ...list]));
  479. this.persist();
  480. return s;
  481. }
  482. /** 重命名(锁定标题,之后不会被自动覆盖) */
  483. rename(id: string, title: string): void {
  484. const t = title.replace(/\s+/g, ' ').trim();
  485. if (!t) return;
  486. this.update(id, { title: t, titleLocked: true });
  487. void this.trySync(() => this.api!.patchChatThread({ threadRoot: id, title: t }));
  488. }
  489. /** 归档 / 取消归档(软删,不物理删除);同步到云端 */
  490. archive(id: string, archived = true): void {
  491. this.update(id, { archived });
  492. void this.trySync(() => this.api!.patchChatThread({ threadRoot: id, archived }));
  493. }
  494. /**
  495. * 把会话移动到某个工作区(`null` = 日常对话)。
  496. *
  497. * 第 3 轮(2026-10-30)接入:`workspaceId` 由 `WorkspaceService` 的工作区 id 提供,
  498. * 归属只存本地 —— 删除工作区时由调用方把会话退回日常,不会留下悬空引用。
  499. */
  500. /**
  501. * 把会话移动到某个工作区(`null` = 日常对话)。
  502. *
  503. * 🔴 2026-11-01 起**同步到云端**:此前只写本地 localStorage,
  504. * 导致换设备/清缓存后归属丢失,且云端重建列表时会掉回日常对话。
  505. * 云端用 `''` 表示日常对话(空字符串是合法值,不能用真值判断)。
  506. */
  507. moveTo(id: string, workspaceId: string | null): void {
  508. this.update(id, { workspaceId });
  509. const cur = this.byId(id);
  510. if (!cur) return;
  511. /*
  512. * 🔴 **未落库的会话只改本地,不发请求**(2026-11-01 用户裁决)。
  513. *
  514. * 持久化的唯一前提是「该会话里发过消息」。新建对话刚建出来(还没发消息)时
  515. * 就被 `startBlank()` 调 `moveTo` 挂到工作区下 —— 若这里直接 upsert,
  516. * 等于**点一次新建就在云端留一条记录**,连点几次就堆一堆空会话
  517. * (用户实测:多次新建后,前几次的都持久化了)。
  518. *
  519. * 归属先记在本地,等首次发消息触发 `commit()` 时随会话一起落库。
  520. *
  521. * 已落库的会话走 upsert(不是 patch):patch 要求云端已有记录,
  522. * 「新建 → 立刻移动工作区」时可能还没落地,patch 会 404 被静默吞掉,
  523. * 归属压根没写进云端 → 下次拉取掉回「日常对话」。
  524. */
  525. if (this._pendingPush.has(id)) return;
  526. void this.trySync(() => this.api!.upsertChatThreads([toUpsert(cur)]));
  527. }
  528. /** 某工作区下的会话数(不含归档) */
  529. countIn(workspaceId: string | null): number {
  530. return this._all().filter((s) => !s.archived && (s.workspaceId ?? null) === workspaceId).length;
  531. }
  532. /**
  533. * 从列表移除一条会话(**只移除索引,Matrix 里的消息不动**)。
  534. *
  535. * 安全性:会话列表只是「有哪些对话」的索引,消息本体在 Matrix 房间里。
  536. * 移除索引后对话从列表消失,但消息**没有被删除** ——
  537. * 文案必须说清楚这一点,否则用户会以为内容丢了。
  538. */
  539. remove(id: string): void {
  540. /*
  541. * 🔴 必须记进「已移除」名单 —— 否则下一次从云端拉取时它会重新出现。
  542. *
  543. * 会话目录的真相源是云端表。只删本地索引的话,下次 `hydrateFromCloud`
  544. * 拉到的还是同一批线程,这条又回来了(实测:删了没效果)。
  545. * 云端删除失败时,本地黑名单是最后一道兜底。
  546. */
  547. const cur = this.byId(id); // 删除前取,删除后取不到 roomId/agentId
  548. this._removed.update((set) => new Set(set).add(id));
  549. this._all.update((list) => list.filter((x) => x.id !== id));
  550. this.persist();
  551. /*
  552. * 云端删除索引:物理删除记录 + 留一条墓碑阻止扫描重建。
  553. * Matrix 房间里的消息**不受影响**(会话正文仍在服务器)。
  554. */
  555. void this.trySync(() =>
  556. this.api!.removeChatThread({ threadRoot: id, roomId: cur?.roomId, agentId: cur?.agentId }),
  557. );
  558. }
  559. /** 该线程是否被用户移除过(拉取云端时据此跳过) */
  560. isRemoved(id: string): boolean {
  561. return this._removed().has(id);
  562. }
  563. /**
  564. * 收敛「新建对话」空壳:**整个列表最多只留一条**。
  565. *
  566. * 2026-11-01 用户裁决:
  567. * 新建对话是「点击即建」(左侧立刻可见并选中),但持久化的唯一前提是
  568. * **在该会话里发过消息**。所以:
  569. * · 「新建对话」在列表里只能有一条 —— 不论在哪个工作区、哪个容器新建,
  570. * 一旦新建,**之前所有没发过消息的空会话一律清掉**;
  571. * · 不需要等 60 秒,也不需要定时器 —— 新建/切换的那一刻就收敛。
  572. *
  573. * 实现上区分两类空壳:
  574. * · **从未落库**(`_pendingPush`)→ 删本地即可,不发云端请求(云端本来就没有);
  575. * · **已落库**(发过消息后又被清空)→ 删本地 + 云端软删,否则下次拉取会回来。
  576. *
  577. * @param keepId 要保留的那条(通常是刚新建/正在看的会话)
  578. */
  579. pruneEmpty(keepId?: string): void {
  580. const victims = this._all().filter(
  581. (s) => !s.legacy && s.id !== keepId && (s.messageCount ?? 0) === 0,
  582. );
  583. if (victims.length === 0) return;
  584. const dead = new Set(victims.map((s) => s.id));
  585. this._all.update((list) => list.filter((x) => !dead.has(x.id)));
  586. this.persist();
  587. for (const v of victims) {
  588. if (this._pendingPush.has(v.id)) {
  589. // 从没写过云端 → 撤销标记即可,不发请求
  590. this._pendingPush.delete(v.id);
  591. continue;
  592. }
  593. void this.trySync(() =>
  594. this.api!.removeChatThread({ threadRoot: v.id, roomId: v.roomId, agentId: v.agentId }),
  595. );
  596. }
  597. }
  598. /**
  599. * 直接设置消息计数与最后活动时间。
  600. *
  601. * 用于「历史对话」——它的正文是无 thread 的旧消息,
  602. * 不参与线程扫描的计数统计,只能由调用方按实际条数写入。
  603. */
  604. setMessageCount(id: string, count: number, updatedAt?: number): void {
  605. const patch: Partial<ChatSession> = { messageCount: count };
  606. if (updatedAt && updatedAt > (this.byId(id)?.updatedAt ?? 0)) patch.updatedAt = updatedAt;
  607. this.update(id, patch);
  608. }
  609. /**
  610. * 写入一条消息(更新预览/时间,必要时推导标题)。
  611. *
  612. * 由 `ChatService` 在消息到达/发出时调用。
  613. */
  614. noteMessage(
  615. id: string,
  616. msg: { text?: string; ts: number; fromMe: boolean },
  617. ): void {
  618. const cur = this.byId(id);
  619. if (!cur) return;
  620. const next = touchSession(cur, msg);
  621. this.update(id, next);
  622. /*
  623. * 🔴 必须把变化推回云端(2026-11-01 修复)。
  624. *
  625. * 此前 `noteMessage` **只写本地** —— 标题、消息数、最后时间全留在浏览器里,
  626. * 云端那条记录一直停在「新对话 / 0 条消息」。
  627. * 于是换设备、清缓存、甚至只是重新拉一次列表,会话名就退回「新对话」
  628. * (用户实测:历史会话名称加载不对)。
  629. *
  630. * 节流:只在三种关键时刻推送,避免每条消息都打一次接口 ——
  631. * ① 该会话**还没落库**(`_pendingPush`)→ 必须立刻写,不节流;
  632. * ② 标题变了(首条消息命名成功);
  633. * ③ 距上次推送超过 10 秒(保证消息数与时间不会长期落后)。
  634. */
  635. const pending = this._pendingPush.has(id);
  636. const titleChanged = next.title !== cur.title;
  637. const due = Date.now() - (this.lastPushAt.get(id) ?? 0) > 10_000;
  638. if (!this._ready() || (!pending && !titleChanged && !due)) return;
  639. this.lastPushAt.set(id, Date.now());
  640. this.pushOne(this.byId(id) ?? next);
  641. }
  642. /** 取某房间下所有已知会话的线程根(消息过滤用) */
  643. threadIdsOf(roomId: string): Set<string> {
  644. return new Set(this._all().filter((s) => s.roomId === roomId).map((s) => s.id));
  645. }
  646. /**
  647. * 按 id 打补丁。
  648. *
  649. * 🔴 **值没变就不写**:本方法可能被 effect 调用,无条件写 signal 会造成
  650. * 「effect → 写 → effect 再跑」的无限循环(冷启动实测页面崩溃)。
  651. */
  652. private update(id: string, patch: Partial<ChatSession>): ChatSession | undefined {
  653. const cur = this.byId(id);
  654. if (!cur) return undefined;
  655. const changed = (Object.keys(patch) as (keyof ChatSession)[]).some(
  656. (k) => cur[k] !== patch[k],
  657. );
  658. if (!changed) return cur;
  659. const result: ChatSession = { ...cur, ...patch };
  660. this._all.update((list) => sortSessions(list.map((s) => (s.id === id ? result : s))));
  661. this.persist();
  662. return result;
  663. }
  664. /**
  665. * 本地存储键(**按用户隔离**)。
  666. *
  667. * 🔴 未登录时返回 null,调用方直接跳过读写 ——
  668. * 绝不能回落到一个共享的 `anon` 键:那样两个未登录会话、
  669. * 或「登出后再登录另一个账号」的间隙,会把别人的会话显示出来。
  670. *
  671. * 🔴 `_v2` 后缀(2026-11-01):缓存结构语义变了(云端失败时不再展示旧数据、
  672. * 空会话不再落云端),旧键里的数据按新规则会被当成脏数据,
  673. * 直接换键即等于**一次性失效**,无需写迁移代码。
  674. */
  675. private key(): string | null {
  676. const uid = this.session.me()?.id;
  677. return uid ? `fmode_chat_sessions_v2_${uid}` : null;
  678. }
  679. private persist(): void {
  680. const key = this.key();
  681. // 未登录不落盘 —— 没有 owner 的数据写下去就是下一个账号的串号源
  682. if (!key) return;
  683. try {
  684. localStorage.setItem(
  685. key,
  686. JSON.stringify(this._all()),
  687. );
  688. // 黑名单单独存 — 它描述的是「用户不想要哪些」,与会话列表同生命周期
  689. localStorage.setItem(`${key}_removed`, JSON.stringify([...this._removed()]));
  690. } catch {
  691. /* 隐私模式:只保留内存态 */
  692. }
  693. }
  694. }
  695. /**
  696. * 会话 → 云端 upsert 载荷(唯一构造点)。
  697. *
  698. * 收敛到一处的原因:此前 `pushAll` / `pushOne` / `mergeScanned` 各手写一份同样的
  699. * 字段表,改一个字段要改三处,漏一处就会出现「某条路径写上去的会话缺字段」。
  700. */
  701. function toUpsert(s: ChatSession): ChatThreadUpsert {
  702. return {
  703. agentId: s.agentId,
  704. roomId: s.roomId,
  705. threadRoot: s.id,
  706. title: s.title,
  707. messageCount: s.messageCount ?? 0,
  708. lastMessageAt: s.updatedAt || s.createdAt,
  709. lastPreview: s.lastPreview,
  710. // 归属随 upsert 一起上云('' = 日常对话),否则换设备后层级丢失
  711. workspaceId: s.workspaceId ?? '',
  712. };
  713. }
  714. /** 结构校验:坏数据不得进入列表 */
  715. function isSession(x: unknown): x is ChatSession {
  716. if (!x || typeof x !== 'object') return false;
  717. const o = x as Record<string, unknown>;
  718. return (
  719. typeof o['id'] === 'string' &&
  720. typeof o['roomId'] === 'string' &&
  721. typeof o['agentId'] === 'string' &&
  722. typeof o['title'] === 'string' &&
  723. typeof o['createdAt'] === 'number' &&
  724. typeof o['updatedAt'] === 'number'
  725. );
  726. }
  727. /** 补齐可选字段,旧版本数据也能读 */
  728. function normalize(s: ChatSession): ChatSession {
  729. return {
  730. ...s,
  731. archived: !!s.archived,
  732. messageCount: s.messageCount ?? 0,
  733. workspaceId: s.workspaceId ?? null,
  734. };
  735. }