chat.service.ts 109 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150215121522153215421552156215721582159216021612162216321642165216621672168216921702171217221732174217521762177217821792180218121822183218421852186218721882189219021912192219321942195219621972198219922002201220222032204220522062207220822092210221122122213221422152216221722182219222022212222222322242225222622272228222922302231223222332234223522362237223822392240224122422243224422452246224722482249225022512252225322542255225622572258225922602261226222632264226522662267226822692270227122722273227422752276227722782279228022812282228322842285228622872288228922902291229222932294229522962297229822992300230123022303230423052306230723082309231023112312231323142315231623172318231923202321232223232324232523262327232823292330233123322333233423352336233723382339234023412342234323442345234623472348234923502351235223532354235523562357235823592360236123622363236423652366236723682369237023712372237323742375237623772378237923802381238223832384238523862387238823892390239123922393239423952396239723982399240024012402240324042405240624072408240924102411241224132414241524162417241824192420242124222423242424252426242724282429243024312432243324342435243624372438243924402441244224432444244524462447244824492450245124522453245424552456245724582459246024612462246324642465246624672468246924702471247224732474247524762477247824792480248124822483248424852486248724882489249024912492249324942495249624972498249925002501250225032504
  1. /** 首屏加载条数:只渲染最近一屏,其余上滚再取(用户要求,避免一次性加载全部) */
  2. const INITIAL_PAGE = 30;
  3. import { computed, effect, inject, Injectable, signal, untracked } from '@angular/core';
  4. import { FmodeApi } from './api/fmode-api';
  5. import {
  6. Agent,
  7. FileCard,
  8. LoadState,
  9. Message,
  10. MessageKind,
  11. MatrixProvision,
  12. } from './api/models';
  13. import { DEMO_MESSAGES } from './api/demo-data';
  14. import { extractS3Links, isUserUploadUrl, stripLinks } from './s3-link';
  15. import { DeclaredFile, parseDeclaredFiles, stripDeclaredFiles } from './declared-files';
  16. import { environment } from '../../environments/environment';
  17. import { LiveService } from './live.service';
  18. import { SessionService } from './session.service';
  19. import { UnreadService } from './unread.service';
  20. import {
  21. DISPATCH_KEY,
  22. LOCAL_ID_KEY,
  23. MxDispatchMeta,
  24. MxMessage,
  25. MatrixClientService,
  26. SESSION_KEY,
  27. } from './matrix-client.service';
  28. /** `sendToAgent` 的结果(2026-11-06 由 boolean 升级为对象) */
  29. export interface DispatchResult {
  30. ok: boolean;
  31. /** 派活消息自身的 eventId(服务端确认后才有) */
  32. eventId?: string;
  33. /**
  34. * 发送**前**该房间最后一条消息的 eventId —— 群聊补读的锚点。
  35. *
  36. * 为 null 表示 timeline 尚未就绪,补读退化为宽限时间窗。
  37. */
  38. baselineEventId: string | null;
  39. roomId?: string;
  40. error?: string;
  41. }
  42. import { ChatSession, deriveTitle, isAppSessionId, placeholderSession } from './chat-session';
  43. import { ChatSessionService } from './chat-session.service';
  44. import { RelayService } from './relay.service';
  45. import { TaskPulseService } from './task-pulse.service';
  46. import { sessionRunning } from './agent-state';
  47. export type ConnectionState = 'connecting' | 'connected' | 'disconnected';
  48. /**
  49. * 待发送附件。
  50. *
  51. * `previewUrl` 是本地对象地址(仅当前页面有效),
  52. * **在暂存时生成一次并缓存** —— 模板里每次变更检测重新生成会造成泄漏。
  53. */
  54. export interface PendingFile {
  55. id: string;
  56. file: File;
  57. previewUrl?: string;
  58. }
  59. /**
  60. * 会话服务 —— 单个会话的状态机
  61. *
  62. * 职责:provision → 连接 → 房间 → 收发 → 分页,对应 SPEC FR-04 各条 AC。
  63. *
  64. * ## 两条分支(由 `environment.useMockData` 单开关决定)
  65. *
  66. * - **演示态**:用 `DEMO_MESSAGES` 填充,发送走 `simulateSend()`(不产生真实请求)
  67. * - **真实态**:`provisionMatrix` 取服务端签发凭据 → `MatrixClientService.connect()`
  68. * → `ensureRoom()` → `/sync` 实时收发
  69. *
  70. * ⚠️ **本文件不直接 import `matrix-js-sdk`**:SDK 调用收敛在 `MatrixClientService`
  71. * (懒加载 chunk,不进主包 —— NFR-02)。本服务只做状态编排与消息模型归一。
  72. *
  73. * ## 凭据来源(黄金规则 9)
  74. *
  75. * 真实态下 accessToken **一律来自 provision 云函数**(服务端签发);
  76. * 前端不派生任何密码,也不持有任何长期密钥。
  77. */
  78. @Injectable({ providedIn: 'root' })
  79. export class ChatService {
  80. private readonly api = inject(FmodeApi);
  81. private readonly live = inject(LiveService);
  82. private readonly session = inject(SessionService);
  83. private readonly mx = inject(MatrixClientService);
  84. private readonly unread = inject(UnreadService);
  85. private readonly pulse = inject(TaskPulseService);
  86. /**
  87. * 会话列表服务(公开只读入口)。
  88. *
  89. * 页面需要它来渲染左侧「容器 → 对话」列表;单测也通过这里取,
  90. * 以保证读到的是**本服务实际使用的那一个实例**。
  91. */
  92. readonly sessions = inject(ChatSessionService);
  93. /** 容器中转(让 A 去问 B) */
  94. private readonly relay = inject(RelayService);
  95. private readonly _agentId = signal<string | null>(null);
  96. /**
  97. * roomId → 最近一次群聊派活的锚点 eventId(2026-11-06)。
  98. *
  99. * 用途:容器的回复不带派活标记,但一定排在派活消息之后 ——
  100. * 用它把「派活之后的房间级消息」挡在单聊会话之外。
  101. * 判定基于 timeline 下标而非时间戳,容器时钟不准也不影响。
  102. */
  103. private readonly _dispatchAnchor = signal<Map<string, { eventId: string }>>(new Map());
  104. /**
  105. * 当前打开的会话(线程根 event_id)。
  106. *
  107. * 🔴 容器 `build_session_key` 按 `room + thread` 划分会话
  108. * (`/opt/hermes/gateway/session.py:654`)——**不带 thread 的每条消息
  109. * 都会被切成新会话**(`MATRIX_AUTO_THREAD` 默认 true)。
  110. * 所以本字段是所有收发的前提:发要带它,收要按它过滤。
  111. */
  112. private readonly _sessionId = signal<string | null>(null);
  113. private readonly _messages = signal<Message[]>([]);
  114. private readonly _state = signal<LoadState>('idle');
  115. private readonly _error = signal<string | null>(null);
  116. private readonly _connection = signal<ConnectionState>('disconnected');
  117. private readonly _sending = signal(false);
  118. private readonly _uploading = signal(false);
  119. /*
  120. * 待发送的附件(本地暂存)。
  121. *
  122. * 🔴 用户要求:**先选文件、点发送时才一起发出去**,而不是选完立刻上传。
  123. * 所以这里只持有 File 对象,不上传、不发消息 —— 发送时才落地。
  124. */
  125. private readonly _pendingFiles = signal<PendingFile[]>([]);
  126. private readonly _uploadPct = signal(0);
  127. private readonly _draft = signal('');
  128. private readonly _peerTyping = signal(false);
  129. /**
  130. * 「文件名|大小」→ 云盘地址(2026-11-10)。
  131. *
  132. * 用户附件发送时 Matrix 与 S3 各存一份:Matrix 那份给容器读,
  133. * S3 那份给 App 长期查看(换设备、跨端)。但回读的 `m.image` 事件里
  134. * 只有 `mxc://`,没有云盘地址 —— 用这张表在回读时补回 `cloudUrl`。
  135. * 仅本进程有效;缺失时附件照常可看(走 Matrix 媒体),不影响发送。
  136. */
  137. private readonly _cloudUrls = new Map<string, string>();
  138. /** 云盘地址映射的键:同名同大小的文件视为同一个(Matrix echo 不带本地 id) */
  139. private cloudKeyOf(name: string, size: number): string {
  140. return `${name}|${size}`;
  141. }
  142. /** 正在加载更早历史(防重入,AC-04.4) */
  143. private readonly _paginating = signal(false);
  144. /** 是否可能还有更早历史 */
  145. private readonly _hasMoreHistory = signal(false);
  146. /**
  147. * 当前会话「为什么是空的」(2026-11-09)。
  148. *
  149. * - `'fresh'`:真的没有消息 —— 新建对话,显示引导页
  150. * - `'unloaded'`:房间里有消息,但一条都归属不到这个会话 —— 显示「没有可加载的消息」
  151. * - `null`:不空
  152. *
  153. * 🔴 不区分的话,一个历史取不到的旧会话会显示成「下午好,我是 xxx」,
  154. * 用户以为会话还在、内容丢了,实际是被归属规则挡住了(实测)。
  155. */
  156. private readonly _emptyReason = signal<'fresh' | 'unloaded' | 'removed' | null>(null);
  157. readonly emptyReason = this._emptyReason.asReadonly();
  158. readonly agentId = this._agentId.asReadonly();
  159. /** 当前会话 id(线程根) */
  160. readonly sessionId = this._sessionId.asReadonly();
  161. /** 当前会话元信息(标题/预览/归档状态) */
  162. readonly currentSession = computed<ChatSession | undefined>(() => {
  163. const id = this._sessionId();
  164. if (!id) return undefined;
  165. const table = this.sessions.byId(id);
  166. if (table) return table;
  167. /*
  168. * 🔴 会话目录里没有这条时,仍然给出一个**占位**(2026-11-06)。
  169. *
  170. * `_sessionId` 在发消息时就已落到线程根,而会话目录要等云端目录刷新
  171. * 才会出现这一条。两者不同步的空档里(刷新后首次发消息、刚建的新会话)
  172. * 原来返回 undefined —— 聊天页据此隐藏页头、右栏与整个输入区,
  173. * 用户看到的是「输入框没了、只剩一条告警」。条目到达后自动换成真身。
  174. */
  175. return placeholderSession(id, this._agentId() ?? '');
  176. });
  177. readonly messages = this._messages.asReadonly();
  178. readonly state = this._state.asReadonly();
  179. readonly error = this._error.asReadonly();
  180. readonly connection = this._connection.asReadonly();
  181. readonly sending = this._sending.asReadonly();
  182. readonly uploading = this._uploading.asReadonly();
  183. /** 待发送附件(界面据此渲染缩略条与移除按钮) */
  184. readonly pendingFiles = this._pendingFiles.asReadonly();
  185. readonly uploadPct = this._uploadPct.asReadonly();
  186. readonly draft = this._draft.asReadonly();
  187. /** 对方正在输入(真实态由 typing 事件驱动) */
  188. readonly peerTyping = this._peerTyping.asReadonly();
  189. /**
  190. * 容器**明确上报**自己闲下来了(没有在跑任务)。
  191. *
  192. * 供回合卡片判断「还在输出」用(2026-11-08):
  193. * 容器说空闲 → 立刻收尾;容器在跑或状态未知 → 依静默窗口判定。
  194. * 只有容器能宣布「干完了」,前端不替它下结论。
  195. */
  196. readonly agentIdle = computed(() => {
  197. const id = this._agentId();
  198. if (!id) return false;
  199. const agent = this.live.agentById(id);
  200. if (!agent) return false;
  201. /*
  202. * 🔴 优先用**本会话**的状态(2026-11-09 方案 A)。
  203. *
  204. * 容器上报的 `status` 是整个容器一个值:同一容器的会话 A 在跑时,
  205. * 会话 B 也会被告知「不空闲」→ 回合永远收不了尾(用户实测)。
  206. * 容器按会话上报时以它为准;没上报(旧容器)才回落到全局 status。
  207. */
  208. const mine = sessionRunning(agent.currentSessions, this._sessionId());
  209. if (mine !== undefined) return !mine;
  210. return agent.status === 'idle';
  211. });
  212. /** 正在加载更早历史 */
  213. readonly paginating = this._paginating.asReadonly();
  214. /** 是否可能还有更早历史(真实态:上次分页仍有返回) */
  215. readonly hasMoreHistory = this._hasMoreHistory.asReadonly();
  216. readonly agent = computed<Agent | undefined>(() => {
  217. const id = this._agentId();
  218. return id ? this.live.agentById(id) : undefined;
  219. });
  220. /** 序号防串扰(存量 seq 模式):切会话时丢弃过期回包 */
  221. private seq = 0;
  222. /**
  223. * 「新建对话」待建态指定的工作区(会话建出来后才落地)。
  224. * `null` = 日常对话。只在 `startBlank()` 到首次发消息之间有效。
  225. */
  226. private _blankWorkspace: string | null = null;
  227. /**
  228. * 「新建态」会话 id(2026-11-08)。
  229. *
  230. * 🔴 这是**比 `_state === 'empty'` 更可靠**的新建态判据。
  231. *
  232. * 原保护失效的原因:`sendText` 在 `commit()` 异步完成之前就执行了
  233. * `_state.set('ready')`,等 `storeVersion` effect 触发时状态已是 ready,
  234. * 于是旧历史被整表回填进新会话(用户实测:新建后上方出现昨天的对话)。
  235. *
  236. * 这里单独记一个 id:只要当前会话仍是它,effect 一律不回填;
  237. * 直到 `sendText` 的 `commit` 成功(云端确实有了这条会话)才清除。
  238. */
  239. private _blankSessionId: string | null = null;
  240. private provision: MatrixProvision | null = null;
  241. /** 真实态下的当前房间 id */
  242. private roomId: string | null = null;
  243. /** 事件订阅解绑函数(切会话必须调用,防串扰) */
  244. private unbindMessage: (() => void) | null = null;
  245. private unbindTyping: (() => void) | null = null;
  246. /** 全局未读监听解绑(仅登出/销毁时调用) */
  247. private unbindUnread: (() => void) | null = null;
  248. /**
  249. * agentId → roomId 映射(未读归属反查)。
  250. *
  251. * 🔴 **持久化**(localStorage,按用户隔离):未读计数需要「某房间的新消息属于哪个
  252. * Agent」,而 roomId 只有在**打开过该会话**后才由 provision 返回。
  253. * 不持久化的话,每次刷新都会丢失映射 → 只有本次打开过的会话能计未读。
  254. *
  255. * ⚠️ **已知限制**:从未打开过的 Agent 会话,其消息不计未读
  256. * (因为拿不到 roomId ↔ agentId 的对应关系)。属可接受折损,已在文档披露。
  257. */
  258. private readonly roomAgentMap = new Map<string, string>();
  259. get provisionInfo(): MatrixProvision | null {
  260. return this.provision;
  261. }
  262. constructor() {
  263. /**
  264. * 🔴 连接状态跟随 SDK 的 sync 状态(FR-04 AC-04.9)。
  265. *
  266. * Bug 背景:原实现只在 `openReal()` 末尾**读一次** `mx.syncState()`:
  267. * `this._connection.set(this.mx.syncState() === 'connected' ? ...)`
  268. * 但 `/sync` 是**异步**的 —— 那一刻必然还是 `connecting`,之后没有任何代码更新,
  269. * 于是 UI **永久停在「正在连接」**(实测:SDK 已 SYNCING,页面仍显示连接中)。
  270. *
  271. * 修复:用 `effect` 订阅 `mx.syncState()` signal,状态变化时自动同步。
  272. * 仅在已打开会话时生效(未打开会话不干预 `close()` 后的复位)。
  273. */
  274. effect(() => {
  275. const s = this.mx.syncState();
  276. if (!this._agentId()) return;
  277. this._connection.set(
  278. s === 'connected' ? 'connected' : s === 'connecting' ? 'connecting' : 'disconnected',
  279. );
  280. });
  281. /**
  282. * 🔴 同步完成后重读 timeline(修复「重开会话内容空白」)。
  283. *
  284. * `openReal()` 首读 timeline 时,`/sync` 往往还没把历史事件写入本地 store
  285. * → 读到空数组。此前只有 realtime 事件能把消息补进来,历史内容永远缺失。
  286. * 这里订阅 store 版本号:每次 sync 落地后重新映射一次 timeline
  287. * (已读消息按 eventId 去重,不会重复)。
  288. */
  289. effect(() => {
  290. this.mx.storeVersion();
  291. const rid = this.roomId;
  292. const agentId = this._agentId();
  293. if (!rid || !agentId) return;
  294. const myUserId = this.provision?.userMxId ?? '';
  295. const raw = this.mx.timelineToMessages(rid);
  296. if (raw.length === 0) return;
  297. /*
  298. * 🔴 历史到达时**重新确定会话**(多会话)。
  299. *
  300. * 首次打开时 sync 未完成,`openReal` 读到的 timeline 是空的 →
  301. * 那时判不出「有没有无 thread 旧消息」→ 「历史对话」建不起来。
  302. * 等历史真正到达后必须再判一次,否则旧消息永远无处显示(实测踩坑)。
  303. *
  304. * 历史到达后只需补建「历史对话」会话,**不改当前选中的会话**。
  305. *
  306. * 🔴 曾在此处「当前是空会话就强行切到历史会话并删除空会话」——
  307. * 导致用户新建的对话一发出消息就被顶掉(实测:新建后 URL 对了,
  308. * 列表却只剩「历史对话」)。**用户的显式操作永远优先于启发式重排。**
  309. */
  310. // 「历史对话」承接机制已下线(2026-10-29):只清理旧数据留下的条目,不再新建
  311. this.dropLegacySessions(agentId);
  312. /*
  313. * 🔴 这里**不再扫描重建会话目录**(2026-10-31 重构)。
  314. *
  315. * 用户裁决:会话列表以云端 `FmodeChatThread` 为唯一来源。原先每次 /sync
  316. * 落地都扫一遍内存 timeline,会把「用户已删除的会话」重新扫回列表
  317. * (删除只是本地 + 云端软删,房间消息仍在)。
  318. *
  319. * 首次接入的初始化扫描已挪到 `openReal()` 中,仅在「云端该容器为空」时执行一次。
  320. */
  321. const sid = this._sessionId();
  322. if (!sid) return;
  323. /*
  324. * 🔴 新建态保护(2026-11-08 第三次修正,最终版)。
  325. *
  326. * 前两次都错在「用一个时间点解除保护」:
  327. * ① 在 `commit()` 时清 —— 本地记账,网络请求还在路上;
  328. * ② 在 `sendReal()` 返回后清 —— 只是「我这边发出去了」,
  329. * 服务端还没把这条件纳入房间 timeline,容器也还没回应。
  330. * 两次都留下同一个空窗:保护一撤,`/sync` 落地就把该房间
  331. * **所有旧容器线程根**算成本会话的(`rootsOfSession` 认领全部历史)。
  332. * 实测:解除保护后 60ms 内就灌入 17 条旧消息。
  333. *
  334. * 正确的判据不是「发出了」,而是「**本会话确实有消息落在 timeline 里**」:
  335. * 用户那条消息一旦出现在 timeline(`threadId === sid`),
  336. * `rootsOfSession` 就只会认领由它派生出的容器子线程,旧历史天然被排除。
  337. *
  338. * 所以在保护期内,先看 timeline 里有没有本会话自己的消息:
  339. * · 没有 → 还没落地,继续挡(旧历史一律不进)
  340. * · 有 → 解除保护,本次及之后的回填都按正常规则走
  341. */
  342. if (this._blankSessionId && sid === this._blankSessionId) {
  343. /*
  344. * 🔴 必须是「本会话的**用户消息**」才算落地(2026-11-09 修正)。
  345. *
  346. * 原判据是「timeline 里有任意 `threadId === sid` 的事件」。
  347. * 但容器回复也可以挂在这个线程根下 —— 于是容器刚回的**第一条**就
  348. * 撤掉了保护,紧接着的回填把整个房间的旧历史灌进来
  349. * (用户实测:新建会话后直接出现「加载更早」和旧内容)。
  350. *
  351. * 用户自己发的那条才说明「这段会话真正开始了」。
  352. */
  353. const landed = raw.some((m) => m.threadId === sid && m.sender === myUserId);
  354. if (!landed) return;
  355. this._blankSessionId = null;
  356. }
  357. if (this._state() === 'empty') return;
  358. /*
  359. * 🔴 只认「本会话自己产生过」的容器线程根(2026-11-08)。
  360. *
  361. * 这里直接计算,不依赖 `_ownedThreadRoots`(那是 `openReal` 在
  362. * 打开会话那一刻建的,新建会话时还没建)。判据:该线程根下存在
  363. * 本会话的用户消息 —— 用户在这个会话里发过东西,容器才会为它开子线程。
  364. */
  365. const ownedRoots = this.rootsOfSession(raw, sid, myUserId);
  366. const next = this.mapTimeline(
  367. raw.filter((m) => this.belongsToSession(m, sid, false, ownedRoots)),
  368. myUserId,
  369. );
  370. if (next.length === 0) return;
  371. /*
  372. * 🔴 合并而不是整表覆盖(2026-11-08)。
  373. *
  374. * `_messages.set(next)` 会把本地的乐观消息(刚发出、云端还没回读到的)
  375. * 一起抹掉,再排成「旧历史 → 新消息」的顺序 —— 用户看到新建对话里
  376. * 凭空出现昨天的内容,且自己的提问被挤到下面。
  377. *
  378. * 现在:历史里有的以历史为准,历史里没有的本地消息**保留**并排在后面。
  379. *
  380. * 🔴 读 `_messages()` 必须走 `untracked`:本 effect 正在往里写,
  381. * 直接读会形成自依赖 → 无限重算(实测会导致页面卡死、按钮点不动)。
  382. */
  383. const local = untracked(() => this._messages());
  384. this._messages.set(this.mergeTimeline(next, local));
  385. /*
  386. * 🔴 这里**不能**标记「正在回复」:这是历史重读(每次 sync 都会重新灌一遍),
  387. * 刷新页面时尾部 id 必然变 → 一打开就显示「正在回复」(用户实测)。
  388. * 真正的实时信号在 appendIncoming()。
  389. */
  390. if (this._state() === 'empty') this._state.set('ready');
  391. /*
  392. * 🔴 只在**保护期结束后**才按房间 token 决定能否翻页(2026-11-08)。
  393. *
  394. * `paginationToken()` 取的是**整个房间**的 backward token,与当前 thread 无关。
  395. * 新建会话照样能拿到非 null → 页面出现「加载更早的消息」→ 一点就翻出旧内容。
  396. * 保护期内恒为 false:这条会话确实没有「更早的消息」。
  397. */
  398. this._hasMoreHistory.set(
  399. this._blankSessionId !== sid && this.mx.paginationToken(rid) != null,
  400. );
  401. });
  402. }
  403. /**
  404. * 打开会话(路由进入时调用)。
  405. *
  406. /**
  407. * 打开会话(路由进入时调用)。
  408. *
  409. * @param agentId 数字生命
  410. * @param sessionId 指定会话(线程根);不传则打开该 agent 最近一个会话
  411. * (一条都没有 → 空态,**不自动新建**,新建走 `startBlank()`)
  412. * @param force 强制重开(「重新连接」「重新加载」用)。
  413. * 🔴 不加这个参数时,下面的幂等短路会让重连按钮**完全无效**:
  414. * 断线后 `_state` 仍是 `'ready'`,`open()` 进第一行就 return,
  415. * 页面停在「连接已断开」上按多少次都没反应(用户实测)。
  416. */
  417. async open(agentId: string, sessionId?: string, force = false): Promise<void> {
  418. /*
  419. * 幂等短路:同一容器 + 同一会话 + **已加载完成**(ready 或 empty)。
  420. *
  421. * 🔴 `empty` 也要算「已完成」:空房间(没有任何会话)的 state 就是 `empty`,
  422. * 只判 `ready` 会让每次进入都重跑 provision + 连接 + 云端拉取,
  423. * 既慢又容易和其它异步流程打架(实测:空态页反复重载)。
  424. */
  425. const loaded = this._state() === 'ready' || this._state() === 'empty';
  426. if (
  427. !force &&
  428. this._agentId() === agentId &&
  429. (!sessionId || this._sessionId() === sessionId) &&
  430. loaded
  431. ) {
  432. return;
  433. }
  434. const my = ++this.seq;
  435. /*
  436. * 🔴 切走前先清掉上一条空会话(2026-11-01)。
  437. *
  438. * 场景:用户在不同工作区连点几次「新建对话」,每次都建出一条 0 条消息的会话,
  439. * 且都没发消息就切走了。持久化的唯一条件是「发过消息」,
  440. * 这些没落库的空壳应当**在离开时消失**,而不是留在左侧列表里。
  441. *
  442. * 保留 `sessionId`(本次要打开的那条)—— 用户刚点完新建正在看它。
  443. */
  444. /*
  445. * 🔴 待打开的那条必须一起保留(2026-11-06)。
  446. * 刷新后首次进入时,ed 在本地目录里的 `messageCount` 可能还是 0(消息数以
  447. * 云端/演示留存量回填得晚),只传 `sessionId` 之外的"保留"是不够的 ——
  448. * 原实现把「本次要打开的会话」当空壳删掉 → `_sessionId` 悬空 → 输入区消失。
  449. */
  450. const openTarget = sessionId ?? this._sessionId();
  451. this.sessions.pruneEmpty(openTarget ?? undefined);
  452. this.teardownSubscriptions();
  453. this._agentId.set(agentId);
  454. this._state.set('loading');
  455. this._error.set(null);
  456. this._messages.set([]);
  457. this._draft.set('');
  458. this._peerTyping.set(false);
  459. try {
  460. await this.live.ensureLoaded();
  461. if (my !== this.seq) return;
  462. const agent = this.live.agentById(agentId);
  463. if (!agent) {
  464. /*
  465. * 🔴 「取不到」有三种原因,不能一律报「不存在」:
  466. * ① 数据加载失败(网络/token)→ 报加载失败并给重试
  467. * ② 仍在加载中(并发场景)→ 报加载中,不算错误
  468. * ③ 确实没这个 agent → 才报不存在
  469. *
  470. * 刷新页面时最容易踩:live 还是 loading,agentById 返回 undefined,
  471. * 原实现直接判「数字生命不存在」(实测复现)。
  472. */
  473. const liveState = this.live.state();
  474. this._state.set('error');
  475. this._error.set(
  476. liveState === 'error'
  477. ? `数据加载失败:${this.live.error() ?? '未知错误'}`
  478. : liveState === 'loading' || liveState === 'idle'
  479. ? '正在加载数字生命数据,请稍候…'
  480. : '数字生命不存在',
  481. );
  482. return;
  483. }
  484. this._connection.set('connecting');
  485. if (environment.useMockData) {
  486. // ---- 演示态 ----
  487. this.provision = await this.api.provisionMatrix(agentId);
  488. if (my !== this.seq) return;
  489. this._connection.set('connected');
  490. /*
  491. * 演示态也要有 roomId:`startBlank()` / `sendText()` 都以它为「容器已就绪」的依据。
  492. * 缺了它,「新建对话」会静默返回 null(实测:按钮点了没反应)。
  493. */
  494. this.roomId = `mock-room-${agentId}`;
  495. this.roomAgentMap.set(agentId, this.roomId);
  496. /*
  497. * 演示种子只属于「历史遗留会话」(`_sessionId` 为空)——
  498. * 它们没有线程根,刷新时落在这条分支里,靠种子填充。
  499. *
  500. * 🔴 指定了会话(用户在演示态里自己新建/发送过的线程)时不能塞种子
  501. * (2026-11-06 渲染测试实测):种子没有线程归属,会串进用户自己的会话;
  502. * 而且种子只在首次打开时写入,刷新后被清空的要点正是「历史没有留痕」。
  503. * 演示态下用会话内留存量恢复,保证刷新后仍能看到自己发过的内容。
  504. */
  505. /*
  506. * 🔴 选中哪条会话必须在这里定(2026-11-06)。
  507. * 真实态由 `openReal()` 末尾统一 `_sessionId.set(sid)`;演示态早退,
  508. * 原来只填了消息没定会话 —— 刷新后 `_sessionId` 为空,
  509. * 页面渲染成「还没有对话」,输入区整块消失(实测反复复现)。
  510. * 地址没指定会话时退回该容器最近一条(与真实态同语义)。
  511. */
  512. const demoSid = sessionId || this.sessions.latestFor(agentId)?.id || '';
  513. this._sessionId.set(demoSid);
  514. this._messages.set(demoSid ? this.demoRestored(demoSid) : this.seedMessages(agentId));
  515. this._state.set(this._messages().length === 0 ? 'empty' : 'ready');
  516. return;
  517. }
  518. // ---- 真实态 ----
  519. await this.openReal(agentId, my, sessionId);
  520. } catch (e) {
  521. if (my !== this.seq) return;
  522. this._connection.set('disconnected');
  523. this._state.set('error');
  524. this._error.set(msgOf(e));
  525. }
  526. }
  527. /**
  528. * 新建对话:**立刻创建一条会话**并在界面选中它。
  529. *
  530. * 2026-11-01 用户裁决变更(原为「首次发消息才建」):
  531. * 点「新建对话」之后,左侧对应区域(工作区文件夹 / 日常对话)必须**立刻**
  532. * 出现一条「新对话」并且处于选中态 —— 否则用户点了按钮看不到任何反馈,
  533. * 不知道操作有没有生效。
  534. *
  535. * 代价与对策:用户点了却不发消息就切走,会留下 0 条消息的空壳。
  536. * 由 `sessions.pruneEmpty(runningId)` 在**打开容器**和**切走会话**时清理,
  537. * 只保留当前正在看的那条。
  538. *
  539. * @param agentId 目标容器
  540. * @param workspaceId 归属工作区(`null` = 日常对话)
  541. * @returns 创建出的会话;容器未就绪时返回 null
  542. */
  543. async startBlank(agentId: string, workspaceId: string | null = null): Promise<ChatSession | null> {
  544. /*
  545. * 🔴 先自增 `seq`:`startBlank` 不经过 `openReal()`,不会自带版本号,
  546. * 而此时可能正好有一次 `openReal()` 在途(切换容器/前一次进入的异步尾段)。
  547. * 它落地时只校验 `my !== this.seq`,版本号不变就会把它**当成当前**继续写
  548. * `_sessionId` / 历史回填 —— 典型表现是「新建后瞬间又被旧会话内容占满」。
  549. * 这里自增一次,让所有在途流程作废,再由下面的 `open()` 重新建立连接。
  550. */
  551. this.seq++;
  552. /*
  553. * 🔴 不调用 `open()`(2026-11-01 修正)。
  554. *
  555. * `open()` 会顺带做「打开该容器最近的会话」这一步,把 `_sessionId` 指向已存在的
  556. * 会话 —— 紧接着我们又把它改成本次新建的那条,来回横跳;
  557. * 更糟的是当容器里已有空会话时,`open()` 会先选中它,导致复用判断和
  558. * 「当前会话」指针不一致(测试实测:连点新建变成两条)。
  559. *
  560. * 这里只做「确保容器已连接」这件事,选会话完全由本方法自己决定。
  561. */
  562. if (this._agentId() !== agentId || !this.roomId) {
  563. await this.open(agentId);
  564. }
  565. if (this._agentId() !== agentId) return null;
  566. if (this._state() === 'error') return null;
  567. const roomId = this.roomId;
  568. if (!roomId) return null;
  569. /*
  570. * 🔴 全局只保留**一条**「新建对话」(2026-11-01 用户裁决)。
  571. *
  572. * 用户可以在工作区里新建、也可以在日常对话里新建。规则是:
  573. * 每次新建 → 之前**所有没发过消息**的空会话一律清掉,只留这一条。
  574. * 不再按「同容器 + 同归属」复用,也不依赖 60 秒定时清理 ——
  575. * 那样会出现「工作区一条、日常一条」并存的情况,不符合预期。
  576. *
  577. * 新建的会话归属到目标工作区(`workspaceId`),但**不落库**:
  578. * 持久化的唯一前提是在这条会话里发过消息(见 create/commit 的分工)。
  579. */
  580. const s = this.sessions.create(roomId, agentId);
  581. if (workspaceId) this.sessions.moveTo(s.id, workspaceId);
  582. // 清掉此前留下的其它空会话,保证列表里只有这一条「新建对话」
  583. this.sessions.pruneEmpty(s.id);
  584. // 切到这条新会话(左侧随之高亮,见 session-tree 的 activeId)
  585. this._sessionId.set(s.id);
  586. /*
  587. * 🔴 清空消息前**必须解除旧订阅**(2026-11-07 用户反馈「新建对话带出旧数据」)。
  588. *
  589. * 上面的 `open(agentId)` 会订阅该房间的实时事件(`onMessage` → `appendIncoming`)。
  590. * 只把 `_messages` 置空是不够的:订阅还挂在房间上,
  591. * 后续任何回填 / 重放事件都会把**上一个会话的消息塞回新会话里**。
  592. *
  593. * 解除后由「首次发消息」那一步重新建立订阅(`open()` 在真正打开会话时再订)。
  594. */
  595. this.teardownSubscriptions();
  596. /*
  597. * 🔴 清空「已认领的容器线程根」(2026-11-08)。
  598. *
  599. * 该集合只在 `openReal()` 里重建(`clear()` + 重新计算),而 `startBlank()`
  600. * **不经过 `openReal`** → 集合里残留着**上一个会话**的根。
  601. * `paginate()` 恰好走这个集合(`belongsToSession(m, sid)` 无 ownedRoots 参数),
  602. * 于是一翻页就把旧会话的容器子线程认领进新会话。
  603. */
  604. this._ownedThreadRoots.clear();
  605. this._messages.set([]);
  606. this._state.set('empty');
  607. this._error.set(null);
  608. this._hasMoreHistory.set(false);
  609. this._peerTyping.set(false);
  610. this._blankWorkspace = workspaceId;
  611. // 新建态标记:storeVersion effect 据此拒绝回填(见字段注释)
  612. this._blankSessionId = s.id;
  613. return this.sessions.byId(s.id) ?? s;
  614. }
  615. /**
  616. * 真实态开启流程。
  617. *
  618. * 失败时**分环节报错**(SPEC FR-04:不得用笼统文案掩盖具体环节):
  619. * FMODE 开通 / Matrix 登录 / 房间创建。
  620. */
  621. private async openReal(agentId: string, my: number, sessionId?: string): Promise<void> {
  622. // ① provision:拿服务端签发凭据(含绑定表命中的 roomId)
  623. let prov: MatrixProvision;
  624. try {
  625. prov = await this.api.provisionMatrix(agentId);
  626. } catch (e) {
  627. if (my !== this.seq) return;
  628. throw new Error(`FMODE 开通失败:${msgOf(e)}`);
  629. }
  630. if (my !== this.seq) return;
  631. this.provision = prov;
  632. // ② 会话级连接(幂等;同身份重复进入不打断 sync —— 未读计数依赖它常驻)
  633. try {
  634. await this.mx.connectSession(prov.accessToken, prov.userMxId ?? '', prov.deviceId);
  635. } catch (e) {
  636. if (my !== this.seq) return;
  637. throw new Error(`连接失败:${msgOf(e)}`);
  638. }
  639. if (my !== this.seq) return;
  640. // ③ 房间(绑定表 roomId 优先)
  641. const agentMxId = prov.agentMxId ?? '';
  642. if (!agentMxId) throw new Error('房间创建失败:服务端未返回 Agent 身份');
  643. let rid: string;
  644. try {
  645. rid = await this.mx.ensureRoom(agentMxId, {
  646. roomId: prov.roomId,
  647. agentName: this.live.agentById(agentId)?.name,
  648. });
  649. } catch (e) {
  650. if (my !== this.seq) return;
  651. throw new Error(`房间创建失败:${msgOf(e)}`);
  652. }
  653. if (my !== this.seq) return;
  654. this.roomId = rid;
  655. // 记录 agentId ↔ roomId 映射并持久化(未读计数按房间归属到 Agent 时需要)
  656. this.roomAgentMap.set(agentId, rid);
  657. this.persistRoomMap();
  658. /*
  659. * ③·补 确定当前会话(线程根)。
  660. *
  661. * 未指定 → 打开该 agent 最近的会话;一个都没有 → 新建一个(纯本地,不触网)。
  662. * 显式指定但本地不存在(如分享了 /chat/:agentId/:sessionId 链接)→ 以只读方式
  663. * 切过去,仍能按 thread 过滤显示消息,只是没有标题。
  664. *
  665. * 🔴 「历史对话」承接机制已于 2026-10-29 下线(用户裁决:这个条目不要、不能改名)。
  666. * 无 thread 的旧消息不再单独成会话,也不再在列表中露出 —— 见 `dropLegacySessions`。
  667. */
  668. // ④ 订阅实时事件(先订阅再读 timeline,避免中间漏事件)
  669. const myUserId = prov.userMxId ?? '';
  670. this.unbindMessage = this.mx.onMessage(rid, (m) => {
  671. if (my !== this.seq) return;
  672. this.appendIncoming(m, myUserId);
  673. });
  674. this.unbindTyping = this.mx.onTyping(rid, (typing, who) => {
  675. if (my !== this.seq) return;
  676. /*
  677. * 🔴 必须排除自己:`m.typing` 会广播**房间内所有成员**的输入状态(含自己)。
  678. * 不过滤 → 用户自己打字时界面显示「对方正在输入」,方向完全相反。
  679. */
  680. if (myUserId && who === myUserId) return;
  681. this._peerTyping.set(typing);
  682. });
  683. /*
  684. * ⑤ 读当前 timeline —— 先确定会话,再按会话(thread)过滤,**只取尾部一屏**。
  685. *
  686. * 🔴 会话必须在读 timeline **之后**确定:
  687. * `/sync` 是异步的,首次打开时 timeline 往往还没回填完(实测为空)。
  688. * 若在读取前就判「有没有无 thread 旧消息」→ 永远判成没有 →
  689. * 「历史对话」不会被建立,旧消息在任何会话里都看不到(实测踩坑)。
  690. *
  691. * 🔴 按 thread 过滤是**多会话隔离的关键**:
  692. * 同一个房间里会有多条会话的消息混在 timeline 里,
  693. * 不按 `threadId` 过滤就会把别的会话的内容显示在当前会话里。
  694. */
  695. const rawTimeline = this.mx.timelineToMessages(rid);
  696. /*
  697. * 某会话在 timeline 里的**真实最后消息时间**(2026-11-07)。
  698. *
  699. * `adopt()` 收养会话时用它作 `updatedAt`,而不是 `Date.now()` ——
  700. * 否则每打开一次就把会话推到左栏第一位(用户反馈的排序问题:
  701. * 「只是点开看看,它就跳到最上头了」)。
  702. * 取不到时返回 undefined,由 adopt 自行兜底。
  703. */
  704. const lastMessageAtOf = (sessionId: string): number | undefined => {
  705. let max = 0;
  706. for (const m of rawTimeline) {
  707. if (!this.belongsToSession(m, sessionId)) continue;
  708. // `MxMessage.ts` 是毫秒数(不是字符串),无需解析
  709. if (Number.isFinite(m.ts) && m.ts > max) max = m.ts;
  710. }
  711. return max > 0 ? max : undefined;
  712. };
  713. /*
  714. * ===== 会话目录:**云端表是唯一来源**(2026-11-01 定稿)=====
  715. *
  716. * 用户裁决:
  717. * ① 云端不可达 → **完全不显示会话**(宁可「加载失败」,也不展示可能过期的缓存);
  718. * ② **彻底关闭**「从 Matrix 房间扫描重建会话」—— 以后新建的会话只由「发消息」产生。
  719. *
  720. * 所以这里只剩一件事:拉一次云端。扫不扫描、要不要补推,这里一概不做。
  721. * 任何「猜」的逻辑都是历史上会话反复增生的来源(扫描 → upsert → 翻倍)。
  722. */
  723. await this.sessions.hydrateFromCloud(agentId, sessionId);
  724. if (my !== this.seq) return;
  725. /*
  726. * 云端不可达 → **不显示任何会话**(用户裁决 2026-11-01)。
  727. *
  728. * 这里给的是「会话目录加载失败」而不是空态:空态意味着「你确实还没有对话」,
  729. * 而实际是「我们不知道你有没有」。两者文案必须区分,否则用户会以为自己
  730. * 的对话被删了(历史上反复出现),从而去重复新建,进一步制造脏数据。
  731. */
  732. if (!this.sessions.ready() && this.sessions.error()) {
  733. /*
  734. * 显式指定了会话(URL 里有线程根)时**不清空选中**(2026-11-06):
  735. * 目录不可信只影响「有哪些会话」,不影响「用户在打开哪一条」。
  736. * 清空会让页面渲染成「还没有对话」,输入框整块消失。
  737. */
  738. const keep = sessionId ?? '';
  739. this._sessionId.set(keep);
  740. this._messages.set(keep ? this.demoRestored(keep) : []);
  741. this._state.set('error');
  742. this._error.set(`会话目录加载失败:${this.sessions.error()}`);
  743. this._connection.set(this.mx.syncState() === 'connected' ? 'connected' : 'connecting');
  744. this._hasMoreHistory.set(false);
  745. return;
  746. }
  747. /*
  748. * 🔴 「历史对话」承接机制已**整体下线**(2026-10-29 用户裁决)。
  749. *
  750. * 原逻辑:房间里有不带 thread 的旧消息时,自动建一个 legacy 会话承接它们。
  751. * 问题:这个条目在多会话体系里既不能改名、也不能归档,却混在正常对话列表里 ——
  752. * 用户看到的就是一个叫「历史对话」的怪东西。
  753. *
  754. * 现在的处理:**不再创建** legacy 会话;已有 legacy 会话在打开时清理掉。
  755. * 旧的无线程消息不再在界面上出现(消息本身仍在服务器,未删除)。
  756. */
  757. this.dropLegacySessions(agentId);
  758. let sid = sessionId;
  759. if (!sid) {
  760. /*
  761. * 🔴 进入容器时**不再自动新建空会话**(2026-10-31 用户裁决)。
  762. *
  763. * 原实现 `ensureFor()` 在没有会话时会 create() 一条「新对话」——
  764. * 结果每进一次容器就多一条空的「新对话」,还排在列表最上面。
  765. * 现在:
  766. * · 有会话 → 打开最近一条;
  767. * · 一条都没有 → 不建,交给页面显示「空态 + 开始新对话」,
  768. * 用户点了「新建对话」才 create(显式操作优先)。
  769. */
  770. const latest = this.sessions.latestFor(agentId);
  771. if (!latest) {
  772. /*
  773. * 🔴 不建会话,但**不能提前 return**:连接状态、未读基线等收尾逻辑
  774. * (⑥⑦)仍要执行,否则界面会停在 connecting,看起来像连不上。
  775. * `sid` 留空 → 后续按会话过滤得到空列表,state=empty。
  776. */
  777. this._sessionId.set('');
  778. this._messages.set([]);
  779. this._state.set('empty');
  780. this._connection.set(this.mx.syncState() === 'connected' ? 'connected' : 'connecting');
  781. this._hasMoreHistory.set(false);
  782. this.unread.markRead(agentId, Date.now());
  783. this.ensureUnreadListener();
  784. return;
  785. }
  786. sid =
  787. latest.roomId === rid
  788. ? latest.id
  789. : this.sessions.adopt({
  790. id: latest.id,
  791. roomId: rid,
  792. agentId,
  793. lastMessageAt: lastMessageAtOf(latest.id),
  794. }).id;
  795. } else if (!this.sessions.byId(sid) || this.sessions.byId(sid)!.messageCount === 0) {
  796. /*
  797. * 🔴 已软删的会话**不允许经由 URL 重新打开**(2026-11-09 用户裁决:
  798. * 「列表项里面隐藏,并且不能通过 URL 直达」)。
  799. *
  800. * ⚠️ 判据必须是「**本地目录里根本没有这条**」,不能把「有这条但 0 条消息」
  801. * 也算进来 —— 新建对话恰恰就是 0 条消息的会话,原来那样写会让
  802. * 用户点「新建对话」后看到「该会话已删除」(实测反馈 2026-11-09)。
  803. *
  804. * 三重条件缺一不可:
  805. * ① 本地目录里没有这条(byId 取不到)
  806. * ② 云端墓碑里有它
  807. * ③ 不在本机「新建中」状态里(`_blankSessionId`)
  808. */
  809. const known = this.sessions.byId(sid);
  810. if (!known && this._blankSessionId !== sid && this.sessions.isCloudRemoved(sid)) {
  811. if (my !== this.seq) return;
  812. this._sessionId.set(null);
  813. this._agentId.set(agentId);
  814. this._state.set('empty');
  815. this._emptyReason.set('removed');
  816. this._messages.set([]);
  817. return;
  818. }
  819. /*
  820. * 🔴 云端目录里暂时没有这条会话 —— 仍按地址打开它(2026-11-06)。
  821. *
  822. * 场景:刷新页面时本地目录已载入,云端拉取把「云端没有的」条目整体替换掉,
  823. * 随后 `open()` 开头的空壳清理才发现这条被当成空会话删了。
  824. * 原来这里 adopt 之后又被清理逻辑抹掉 → `_sessionId` 悬空 → 输入区消失。
  825. * 现在只要地址里明确给了线程根,就承认它:消息仍然按 thread 过滤展示,
  826. * 云端目录后续到达时自动补齐标题等元信息。
  827. */
  828. this.sessions.adopt({
  829. id: sid,
  830. roomId: rid,
  831. agentId,
  832. messageCount: 1,
  833. lastMessageAt: lastMessageAtOf(sid),
  834. });
  835. }
  836. if (my !== this.seq) return;
  837. this._sessionId.set(sid);
  838. /*
  839. * 🔴 会话目录被整表替换后,会话可能连着被删两次(2026-11-06)。
  840. * 循环里 `byId` 只在那一刻成立:清理在别处跑过一轮,它就不在了。
  841. * 这里兜底一次 —— 选中了就必须有一条可渲染的会话,否则页面渲染成
  842. * 「还没有对话」,输入区整块消失(刷新场景实测反复出现)。
  843. */
  844. if (!this.sessions.byId(sid)) {
  845. this.sessions.adopt({
  846. id: sid,
  847. roomId: rid,
  848. agentId,
  849. messageCount: 1,
  850. lastMessageAt: lastMessageAtOf(sid),
  851. });
  852. }
  853. /*
  854. * 重建「本会话的容器线程根」集合(2026-11-08)。
  855. *
  856. * 🔴 必须在过滤**之前**建好,否则第一轮过滤时集合为空,
  857. * 容器子线程的内容会被全部挡掉(比「串入旧会话」更糟)。
  858. *
  859. * 判据:这条容器线程根下,**存在属于本会话的用户消息**。
  860. * 也就是「用户在这个会话里发过东西,容器才为它开了子线程」。
  861. */
  862. this._ownedThreadRoots.clear();
  863. {
  864. // 先按 turnId/thread 关系把「本会话的用户消息」找出来
  865. const mine = new Set<string>();
  866. for (const m of rawTimeline) {
  867. if (m.sender !== myUserId) continue;
  868. if (m.threadId === sid || m.sessionId === sid) {
  869. mine.add(m.eventId);
  870. if (m.threadId) this._ownedThreadRoots.add(m.threadId);
  871. }
  872. }
  873. // 容器线程根:出现在本会话用户消息的 dispatch/turnId 关联里才算归属
  874. for (const m of rawTimeline) {
  875. const root = m.threadId;
  876. if (!root || isAppSessionId(root)) continue;
  877. // 该根下有任何消息的 turnId/sessionId 指向本会话 → 认领
  878. if (m.sessionId === sid || mine.has(m.eventId)) this._ownedThreadRoots.add(root);
  879. }
  880. }
  881. const all = this.mapTimeline(
  882. rawTimeline.filter((m) => this.belongsToSession(m, sid)),
  883. myUserId,
  884. );
  885. /*
  886. * 🔴 初次进入**不截断**(2026-11-03 修复)。
  887. *
  888. * 原实现 `slice(-INITIAL_PAGE)` 只保留最近 30 条 —— 早于这个窗口的消息
  889. * (比如用户几天前发的图片)在进入会话时**直接消失**,要一路点「加载更早」
  890. * 才可能翻到,实际表现为「图片完全看不到」。
  891. *
  892. * sync 已按 `initialSyncLimit: 50` 限定了房间事件量,这里再截一次纯属重复限制;
  893. * 更早的历史仍由 `paginate()` 按需向上翻。
  894. */
  895. this._messages.set(all);
  896. /*
  897. * 🔴 历史消息里的云盘链接也要认(2026-11-09)。
  898. *
  899. * `appendIncoming` 只处理**实时到达**的消息。刷新页面或重新打开一条
  900. * 老会话时,消息走的是这条回读路径 —— 如果不在这里提取,
  901. * 「打开会话 → 右栏成果空 → 再发一条新消息才出现」,用户会以为成果丢了。
  902. */
  903. for (const m of all) {
  904. if (m.sender !== 'agent') continue;
  905. /* 与实时路径同口径:优先声明段,否则正则兜底 + 文件判定 */
  906. const declared = parseDeclaredFiles(m.text);
  907. if (declared !== null) {
  908. for (const d of declared) {
  909. if (!isUserUploadUrl(d.url)) this.sessions.addDeliverableUrl(sid, d.url);
  910. }
  911. } else {
  912. for (const u of extractS3Links(m.text)) {
  913. if (!isUserUploadUrl(u) && this.cardOfCloudLink(u)) {
  914. this.sessions.addDeliverableUrl(sid, u);
  915. }
  916. }
  917. }
  918. }
  919. // ⑥ 连接状态由 SDK 同步事件驱动
  920. this._connection.set(this.mx.syncState() === 'connected' ? 'connected' : 'connecting');
  921. /*
  922. * 「空」有两种,界面上必须区分(2026-11-09):
  923. * ① 真正新建的会话 —— 显示「下午好,我是 xxx」引导页
  924. * ② 有历史但一条也取不到 —— 只是空着,不能伪装成新建
  925. *
  926. * 判据:rawTimeline 里有没有**任何**属于这个房间的消息。
  927. * 有消息但过滤后为空 = 我们的归属规则把它们挡掉了,属于 ②。
  928. * 注意不能用 `messageCount`(实测虚高:云端记 21 条、Matrix 实际 2 条)。
  929. */
  930. const hadAnyEvent = rawTimeline.length > 0;
  931. this._emptyReason.set(this._messages().length > 0 ? null : hadAnyEvent ? 'unloaded' : 'fresh');
  932. this._state.set(this._messages().length === 0 ? 'empty' : 'ready');
  933. // timeline pagination token 是否存在 = 更早可能有历史
  934. this._hasMoreHistory.set(this.mx.paginationToken(rid) != null);
  935. // ⑦ 进入会话 = 已读:基线推进到最新消息时间(清未读计数)
  936. const latest = this._messages().reduce((max, m) => {
  937. const t = Date.parse(m.ts);
  938. return Number.isFinite(t) && t > max ? t : max;
  939. }, 0);
  940. this.unread.markRead(agentId, latest || Date.now());
  941. this.ensureUnreadListener();
  942. }
  943. /**
  944. * 全局未读监听:Agent 消息到达且**当前不在该会话**时 +1。
  945. *
  946. * 只注册一次(幂等);由 `MatrixClientService` 的全局消息流多路复用,
  947. * 故不会随会话切换重复挂监听。
  948. *
  949. * 由 `MatrixSessionService.bootstrap()` 在**建连前**调用 —— 保证连接建立后
  950. * 到用户首次进入会话之间的消息也会被计入未读。
  951. */
  952. ensureUnreadListener(): void {
  953. if (this.unbindUnread) return;
  954. this.unbindUnread = this.mx.onAnyMessage((m) => {
  955. const myUserId = this.provision?.userMxId ?? '';
  956. // 只计 Agent 发来的
  957. if (myUserId && m.sender === myUserId) return;
  958. const agentId = this.agentIdByRoom(m.roomId);
  959. if (!agentId) return;
  960. /*
  961. * 🔴 群聊派活产生的消息不计入单聊未读(2026-11-06)。
  962. * 这条回调不走 `belongsToSession`,不单独拦的话
  963. * 「群里派了活」会让该容器的单聊会话冒出未读红点。
  964. */
  965. if (m.dispatch || this.isAfterLastDispatch(m)) return;
  966. // 正在看的会话 → 直接推进已读基线,不产生未读
  967. if (agentId === this._agentId()) {
  968. this.unread.markRead(agentId, m.ts);
  969. return;
  970. }
  971. // 判定交给 UnreadService:初次同步回填阶段只推基线、不计数
  972. this.unread.onIncoming(agentId, m.ts, !this.mx.hasInitialSync);
  973. });
  974. }
  975. /** roomId → agentId(用已记录的会话映射反查) */
  976. private agentIdByRoom(roomId: string): string | undefined {
  977. for (const [agentId, rid] of this.roomAgentMap) {
  978. if (rid === roomId) return agentId;
  979. }
  980. return undefined;
  981. }
  982. // ---------- agentId ↔ roomId 映射持久化 ----------
  983. private roomMapKey(): string {
  984. const uid = this.session.me()?.id ?? 'anon';
  985. return `fmode_room_map_${uid}`;
  986. }
  987. private persistRoomMap(): void {
  988. try {
  989. localStorage.setItem(this.roomMapKey(), JSON.stringify([...this.roomAgentMap]));
  990. } catch {
  991. /* 隐私模式忽略 */
  992. }
  993. }
  994. /** 从 localStorage 载入映射(Shell 启动时调用一次) */
  995. loadRoomMap(): void {
  996. try {
  997. const raw = localStorage.getItem(this.roomMapKey());
  998. if (!raw) return;
  999. for (const [agentId, rid] of JSON.parse(raw) as [string, string][]) {
  1000. if (agentId && rid) this.roomAgentMap.set(agentId, rid);
  1001. }
  1002. } catch {
  1003. /* 忽略 */
  1004. }
  1005. }
  1006. /**
  1007. * 换账号时彻底复位(2026-11-01)。
  1008. *
  1009. * 🔴 服务都是单例、Shell 不会因登出重建,所以退出再登录时这里全是上一个账号的残留:
  1010. * 会话指针、消息缓冲、房间映射、(经由 `sessions.reset()`)会话目录。
  1011. * 不复位就会先闪出别人的对话,再被云端结果覆盖 —— 看起来像「会话串号」。
  1012. *
  1013. * ⚠️ 不断开 Matrix 连接:连接的生命周期由 `MatrixSessionService` 管。
  1014. */
  1015. resetForUserSwitch(): void {
  1016. this.close();
  1017. this.roomAgentMap.clear();
  1018. this.sessions.reset();
  1019. }
  1020. /**
  1021. * 关闭会话(离开聊天页)。
  1022. *
  1023. * 🔴 **不断开 Matrix 会话级连接** —— sync 常驻,否则离开后收不到消息,
  1024. * 未读计数永远无法累加。只解绑本房间的事件订阅。
  1025. */
  1026. close(): void {
  1027. this.seq++;
  1028. this.teardownSubscriptions();
  1029. this.provision = null;
  1030. this.roomId = null;
  1031. this._agentId.set(null);
  1032. // 🔴 会话 id 必须一起清:残留会在下次打开时被误当成「已选定」的会话
  1033. this._sessionId.set(null);
  1034. this._messages.set([]);
  1035. this._state.set('idle');
  1036. this._error.set(null);
  1037. this._connection.set('disconnected');
  1038. this._draft.set('');
  1039. this._peerTyping.set(false);
  1040. this._paginating.set(false);
  1041. this._hasMoreHistory.set(false);
  1042. this._blankWorkspace = null;
  1043. /*
  1044. * 🔴 新建态标记必须复位(2026-11-08):只在 `sendText` commit 后清除时,
  1045. * 用户「新建对话 → 不发消息 → 离开页面」会把标记留在内存里,
  1046. * 下次万一撞上同一条会话,实时消息会被无声丢弃。
  1047. */
  1048. this._blankSessionId = null;
  1049. }
  1050. setDraft(text: string): void {
  1051. this._draft.set(text);
  1052. // 真实态下发 typing 通知(失败非致命)
  1053. if (this.roomId && !environment.useMockData) {
  1054. void this.mx.setTyping(this.roomId, text.length > 0);
  1055. }
  1056. }
  1057. /** 发送文本(AC-04.2);失败留痕可重试(AC-04.8) */
  1058. /**
  1059. * 向**指定容器**发送一条消息,**不影响当前会话状态**(2026-11-05)。
  1060. *
  1061. * ## 为什么需要
  1062. *
  1063. * `sendText()` 绑定在 `_agentId` 上 —— 它只能发给「当前打开的那个容器」。
  1064. * 伪群聊需要**同时**给 N 个容器各发一条,且不能来回切换当前会话
  1065. * (那会打断正在看的会话、触发重连、搅乱消息流)。
  1066. *
  1067. * ## 实现
  1068. *
  1069. * 走 provision 直接拿房间,发一条**房间级消息**(不带 thread):
  1070. * · 群聊里每个容器都只处理自己的 DM,不需要线程隔离
  1071. * · 不写入 `_messages`(那不是当前会话的消息)
  1072. *
  1073. * @returns 发送成功返回 true;容器未接入 / provision 失败返回 false
  1074. */
  1075. async sendToAgent(
  1076. agentId: string,
  1077. text: string,
  1078. meta?: MxDispatchMeta,
  1079. ): Promise<DispatchResult> {
  1080. const body = text.trim();
  1081. if (!body) return { ok: false, baselineEventId: null, error: '空消息' };
  1082. try {
  1083. const room = await this.ensureAgentRoomFor(agentId);
  1084. if (!room) return { ok: false, baselineEventId: null, error: '容器未接入' };
  1085. const rid = room;
  1086. /*
  1087. * 🔴 发送**前**捕获锚点(2026-11-06)。
  1088. *
  1089. * 群聊补读要靠它判断「哪些事件是本轮之后的新回复」。
  1090. * 用事件顺序而非时间 —— 容器与浏览器时钟不一致时,时间窗会漏收。
  1091. * timeline 尚未就绪时返回 null,下游退化为宽限时间窗。
  1092. */
  1093. const baselineEventId = this.mx.lastMessageEventId(rid);
  1094. const res = await this.mx.sendText(
  1095. rid,
  1096. body,
  1097. undefined,
  1098. undefined,
  1099. meta ? { [DISPATCH_KEY]: meta } : undefined,
  1100. );
  1101. return { ok: true, eventId: res.event_id, baselineEventId, roomId: rid };
  1102. } catch (e) {
  1103. return {
  1104. ok: false,
  1105. baselineEventId: null,
  1106. error: e instanceof Error ? e.message : '发送失败',
  1107. };
  1108. }
  1109. }
  1110. /**
  1111. * 确保「与该容器的 DM 房间」已就绪(provision → 连接 → 房间)。
  1112. *
  1113. * 2026-11-07 从 `sendToAgent` 抽出:群聊重建历史、群聊发附件都要用同一条
  1114. * 路径。**不要另写一份** —— provision 与 connectSession 的幂等语义
  1115. * (同 token 不重建客户端)是靠 `MatrixClientService` 内部保证的,
  1116. * 多写一份就会各自踩坑。
  1117. *
  1118. * @returns 房间 id;容器未接入或接入失败时 null(调用方决定是否中断)
  1119. */
  1120. async ensureAgentRoomFor(agentId: string, opts: { skipConnect?: boolean } = {}): Promise<string | null> {
  1121. const prov = await this.api.provisionMatrix(agentId);
  1122. const agentMxId = prov.agentMxId ?? '';
  1123. if (!agentMxId) return null;
  1124. /*
  1125. * 🔴 读历史时不要重连(2026-11-07)。
  1126. *
  1127. * `connectSession` 在 token 变化时会 **disconnect 重建客户端**,
  1128. * 而重建会中断正在进行的 `/sync` —— 群重建要遍历 N 个成员,
  1129. * 逐个重连的结果是 sync 永远完不成,读出来的 timeline 全是空的。
  1130. * 读历史只需要客户端已连接(消息收发本来就共用同一个用户身份)。
  1131. */
  1132. /*
  1133. * 🔴 skipConnect 只表示「**已连接时**不要重建」。客户端根本不存在时
  1134. * 仍然必须连 —— 否则 `ensureRoom` 里的 requireClient 抛错,
  1135. * 重建拿到一堆空房间,把群历史覆盖成空(评审 2026-11-07 发现)。
  1136. */
  1137. if (!opts.skipConnect || !this.mx.connected) {
  1138. await this.mx.connectSession(prov.accessToken, prov.userMxId ?? '', prov.deviceId);
  1139. }
  1140. this.roomAgentMap.set(agentId, prov.roomId ?? '');
  1141. return this.mx.ensureRoom(agentMxId, {
  1142. roomId: prov.roomId,
  1143. agentName: this.live.agentById(agentId)?.name,
  1144. });
  1145. }
  1146. /**
  1147. * 群聊发附件:把一个文件发进**某个**成员的 DM 房间(2026-11-07)。
  1148. *
  1149. * 调用方对每个目标成员各调一次(同一份文件会在每个房间各上传一份 ——
  1150. * Matrix 没有跨房间引用,这是平台约束)。
  1151. *
  1152. * 事件里带 `dispatch`(让重建能把它识别成本回合的用户内容)和
  1153. * `sessionId = turnId`(让单聊过滤规则把它挡在单聊之外)。
  1154. */
  1155. async sendFileToAgent(
  1156. agentId: string,
  1157. file: File,
  1158. meta: MxDispatchMeta,
  1159. ): Promise<DispatchResult> {
  1160. try {
  1161. const rid = await this.ensureAgentRoomFor(agentId);
  1162. if (!rid) return { ok: false, baselineEventId: null, error: '容器未接入' };
  1163. const baselineEventId = this.mx.lastMessageEventId(rid);
  1164. const res = await this.mx.sendFile(
  1165. rid,
  1166. file,
  1167. { name: file.name, size: file.size, mimeType: file.type || 'application/octet-stream' },
  1168. undefined,
  1169. undefined,
  1170. { [DISPATCH_KEY]: meta, [SESSION_KEY]: meta.turnId },
  1171. );
  1172. return { ok: true, eventId: res.event_id, baselineEventId, roomId: rid };
  1173. } catch (e) {
  1174. return {
  1175. ok: false,
  1176. baselineEventId: null,
  1177. error: e instanceof Error ? e.message : '附件发送失败',
  1178. };
  1179. }
  1180. }
  1181. async sendText(text: string): Promise<void> {
  1182. const body = text.trim();
  1183. /*
  1184. * 只有附件、没有文字也要能发 —— 用户选完图直接点发送是常见操作。
  1185. * 两者都没有才返回。
  1186. */
  1187. if (!body && this._pendingFiles().length === 0) return;
  1188. const conversationId = this._agentId();
  1189. if (!conversationId) return;
  1190. /*
  1191. * 乐观消息 id(2026-11-09)。
  1192. *
  1193. * 🔴 原来用 `local-${Date.now()}`:连点两次(同一毫秒)会生成**同一个 id**,
  1194. * 两条消息互相顶替;而且这个 id 和它自己的服务端 echo(`$eventId`)对不上,
  1195. * 合并时双双保留 → 一条「你好」显示成 3 条。
  1196. * 现在带上随机串,并把这个 id 随事件写进 content,回读时按它认领。
  1197. */
  1198. const localId = `local-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
  1199. const msg: Message = {
  1200. id: localId,
  1201. conversationId,
  1202. sender: 'me',
  1203. kind: 'text',
  1204. // 纯附件时不插入空气泡(附件会在上传后各自成条)
  1205. text: body || undefined,
  1206. ts: new Date().toISOString(),
  1207. state: 'sending',
  1208. localId,
  1209. };
  1210. if (body) this._messages.update((m) => [...m, msg]);
  1211. this._draft.set('');
  1212. // 演示态:留一份存量,刷新后能恢复(真实态历史以 Matrix 为准)
  1213. this.rememberDemoHistory();
  1214. this._sending.set(true);
  1215. /*
  1216. * 会话在**第一次发消息时**才创建(2026-11-01 用户裁决)。
  1217. *
  1218. * 🔴 取代了「点新建对话就 create() 一条空会话」:
  1219. * 那个做法必然产生空壳(用户点了又反悔、切走、关页面都会留下一条 0 消息的
  1220. * 「新对话」),而空壳又要靠 `pruneEmpty` 事后清理 —— 清理再出 bug 就会
  1221. * 变成「每进一次容器多一条」,这正是会话列表膨胀的来源之一。
  1222. *
  1223. * 改成懒创建后,**没有消息就不存在会话**,从根上不会有空壳。
  1224. *
  1225. * 顺序很关键:先建会话 → 再记账(`noteMessage` 才能把标题写进去)。
  1226. */
  1227. if (!this._sessionId()) {
  1228. const created = this.ensureSessionForSend();
  1229. if (created) {
  1230. this._sessionId.set(created.id);
  1231. /*
  1232. * 🔴 必须同时把状态从 empty 切到 ready(2026-11-01 修复)。
  1233. *
  1234. * `startBlank()` 把 state 置为 `'empty'`,而消息区是按 state 分派的:
  1235. * `@case ('empty')` 里「没有 currentSession → 显示新建引导」。
  1236. * 发完第一条消息后 currentSession 有了,但 state 还是 empty ——
  1237. * 页面继续渲染新建流程区,看起来像「发出去没反应」(用户实测反馈)。
  1238. *
  1239. * 建出会话 = 这已经是正常会话了,状态必须跟着走。
  1240. */
  1241. this._state.set('ready');
  1242. }
  1243. }
  1244. /*
  1245. * 会话元信息(标题/预览/时间)随**用户发出的消息**推进。
  1246. *
  1247. * 🔴 必须在这里记,不能只等容器回话:用户第一条消息就该定下对话名,
  1248. * 否则新建的对话一直显示「新对话」直到容器回复(实测踩坑)。
  1249. * `appendIncoming` 里另记一次是为了容器主动发起的情况。
  1250. */
  1251. const curSid = this._sessionId();
  1252. if (curSid) {
  1253. /*
  1254. * 🔴 首次发消息 = 这条会话**真正成立**(2026-11-01 用户裁决)。
  1255. *
  1256. * 新建对话只在本地可见(左侧立刻出现「新建对话」),此时还不上云。
  1257. * 用户发出第一条消息,才用 `commit()` 把它落库 ——
  1258. * 这样「点了不发消息就切走」不会在云端留下 0 条记录。
  1259. *
  1260. * 必须在 `noteMessage` **之前** commit:否则推上去的还是「新建对话 / 0 条」,
  1261. * 标题要等下一次推送才能更正(用户实测:历史会话名一直不对)。
  1262. */
  1263. this.sessions.noteMessage(curSid, {
  1264. text: body,
  1265. ts: Date.parse(msg.ts) || Date.now(),
  1266. fromMe: true,
  1267. });
  1268. /*
  1269. * 🔴 演示态:消息数必须落进本地目录(2026-11-06 实测)。
  1270. * 宿主页面刷新前只写内存,目录里仍是「0 条消息」;`open()` 开头的
  1271. * 空壳清理会把这条当「没发过消息的新建对话」删掉,恢复出来一片空白。
  1272. */
  1273. if (environment.useMockData) {
  1274. this.sessions.setMessageCount(curSid, this.demoRestored(curSid).length || 1);
  1275. }
  1276. /*
  1277. * 发出消息 = 这已是正常对话。
  1278. * 新建对话建出来时 state 是 empty(还没有消息),用户一发消息就该切到 ready ——
  1279. * 否则消息区继续按空态渲染,发出去的内容要等下一次刷新才看得见。
  1280. */
  1281. if (this._state() === 'empty') this._state.set('ready');
  1282. }
  1283. /*
  1284. * 🔴 用户一发出消息就进入「忙」——**这是事实,不是推测**(容器必定会收到)。
  1285. *
  1286. * 容器的运行态上报是分钟级(Hermes 按 turn 写库,再等下一轮上报),
  1287. * 不这么做的话「发了消息 → 右侧还是空闲」会持续很久(用户实测反馈)。
  1288. *
  1289. * 记在 `TaskPulseService` 里而非改 Agent 数据:服务端仍是最终真值,
  1290. * 只有容器报 `idle` 才能把它翻回去(前端绝不自己说「完成」)。
  1291. */
  1292. this.pulse.noteUserAction(conversationId);
  1293. try {
  1294. /*
  1295. * 🔴 首次发消息 = **先落库,落库成功才发送**(2026-11-09 用户要求)。
  1296. *
  1297. * 用户原话:「第一次发送消息的时候,才会开始持久化存储这个对话;
  1298. * 存储成功后,才会开始发送消息」。
  1299. *
  1300. * 原实现调 `commit()`(发出去就不管):消息照发、落库在后台跑。
  1301. * 一旦落库失败,消息已经进 Matrix,而目录里没有这条记录 ——
  1302. * 换设备就找不到这段对话,用户只在事后看到一条提示。
  1303. *
  1304. * 现在首次落库 `await`,失败直接落到下面的 catch:
  1305. * 消息标记为「发送失败」,**不往容器发**,用户可重试。
  1306. */
  1307. if (curSid && this.sessions.needsCommit(curSid)) {
  1308. await this.sessions.commitNow(curSid);
  1309. }
  1310. if (environment.useMockData) {
  1311. await this.simulateSend();
  1312. } else {
  1313. /*
  1314. * 🔴 「去问另一个容器」的指令由 App 搬运(实测结论,见 RelayService)。
  1315. *
  1316. * 用户说「问 lj-test:数据库什么版本」时:
  1317. * · 原消息**不发给当前容器**(它没有能力去找别人)
  1318. * · 由 App 旁路去问目标容器,把答复回填成一条 agent 消息
  1319. *
  1320. * 判断放在发送前,命中就不走普通发送。
  1321. */
  1322. /*
  1323. * 🔴 先把暂存的附件发出去(用户要求:选文件 → 点发送 → 图文一起走)。
  1324. * 附件失败不阻塞文字发送,但要如实告知。
  1325. */
  1326. if (this._pendingFiles().length > 0) {
  1327. const { fail } = await this.uploadPending(conversationId);
  1328. if (fail > 0) this._error.set(`${fail} 个附件上传失败,可重试`);
  1329. }
  1330. if (!body) {
  1331. // 纯附件:没有文字要发,到此为止
  1332. return;
  1333. }
  1334. const relayed = await this.tryRelay(body, msg, conversationId);
  1335. if (!relayed) await this.sendReal(body, msg.localId);
  1336. }
  1337. this.markSent(msg.id);
  1338. /*
  1339. * 🔴 这里**不解除**新建态保护(2026-11-08 第三次修正)。
  1340. *
  1341. * `sendReal()` 返回只代表「本机把事件发出去了」,服务端还没把它并入
  1342. * 房间 timeline。此刻解除保护,紧接着落地的 `/sync` 就会把该房间
  1343. * 所有旧容器线程根算成本会话的(实测 60ms 内灌入 17 条旧消息)。
  1344. *
  1345. * 解除点统一在 `storeVersion` effect 里:等 timeline 里真的出现
  1346. * `threadId === 本会话` 的消息,说明本会话已在服务端成立,
  1347. * 那时 `rootsOfSession` 只会认领由这条消息派生的子线程,旧历史天然排除。
  1348. */
  1349. } catch (e) {
  1350. this.markFailed(msg.id, msgOf(e));
  1351. /*
  1352. * 发送失败**也要解除**:事件没能落到服务端,timeline 永远不会出现本会话的
  1353. * 消息,再不解锁这条会话就永远收不到内容(容器仍可能从别的路径回复)。
  1354. * 失败残留的旧历史是可接受的次要问题,远好于「彻底静默」。
  1355. */
  1356. if (curSid && this._blankSessionId === curSid) this._blankSessionId = null;
  1357. } finally {
  1358. this._sending.set(false);
  1359. }
  1360. }
  1361. /**
  1362. * 尝试按「中转指令」处理;返回是否已处理。
  1363. *
  1364. * 未命中(普通消息)返回 false,调用方继续常规发送。
  1365. */
  1366. private async tryRelay(body: string, localMsg: Message, conversationId: string): Promise<boolean> {
  1367. const intent = this.relay.detect(body, this._agentId());
  1368. if (!intent) return false;
  1369. /*
  1370. * 本地先落一条「已转问」的痕迹 —— 让用户知道这条消息**没有**发给当前容器,
  1371. * 而是走了另一条路。原乐观消息保持 sending → 改成 sent 并改写文案。
  1372. */
  1373. const askerName = this.live.agentById(conversationId)?.name ?? '当前助手';
  1374. this._messages.update((list) =>
  1375. list.map((m) =>
  1376. m.id === localMsg.id
  1377. ? { ...m, text: `【转问 ${intent.target.name}】${intent.question}`, state: 'sent' as const }
  1378. : m,
  1379. ),
  1380. );
  1381. const answer = await this.relay.relay(intent, askerName);
  1382. /*
  1383. * 回填成一条 agent 消息。
  1384. *
  1385. * 🔴 必须走 `_messages.update` 而不是真实发送 —— 这条内容是 App 生成的,
  1386. * 发到房间里会污染 Matrix 历史(也会被容器当成新指令)。
  1387. */
  1388. this._messages.update((list) => [
  1389. ...list,
  1390. {
  1391. id: `relay-${Date.now()}`,
  1392. conversationId,
  1393. sender: 'agent' as const,
  1394. senderName: intent.target.name,
  1395. kind: 'notice' as const,
  1396. text: answer,
  1397. ts: new Date().toISOString(),
  1398. state: 'sent' as const,
  1399. },
  1400. ]);
  1401. if (this._state() === 'empty') this._state.set('ready');
  1402. const sid = this._sessionId();
  1403. if (sid) {
  1404. this.sessions.noteMessage(sid, {
  1405. text: answer,
  1406. ts: Date.now(),
  1407. fromMe: false,
  1408. });
  1409. }
  1410. return true;
  1411. }
  1412. // ---------- 附件:先暂存,发送时一起提交 ----------
  1413. /**
  1414. * 选择附件(**只暂存,不上传、不发送**)。
  1415. *
  1416. * 用户要求:先选文件,点发送时才随文字一起发出去。
  1417. * 早期实现是「选完立刻上传并单发一条」—— 用户只想要图文一起发,那个行为是错的。
  1418. */
  1419. stageFiles(files: FileList | File[]): void {
  1420. const list = Array.from(files);
  1421. if (list.length === 0) return;
  1422. this._pendingFiles.update((cur) => [
  1423. ...cur,
  1424. ...list.map((file) => ({
  1425. id: `pf-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
  1426. file,
  1427. // 🔴 对象地址只生成一次并缓存 ——
  1428. // 在模板里每次变更检测都调 createObjectURL 会不断泄漏
  1429. previewUrl: file.type.startsWith('image/') ? URL.createObjectURL(file) : undefined,
  1430. })),
  1431. ]);
  1432. }
  1433. /** 移除一个待发附件(同时释放预览地址) */
  1434. unstageFile(id: string): void {
  1435. this._pendingFiles.update((cur) => {
  1436. const victim = cur.find((f) => f.id === id);
  1437. if (victim?.previewUrl) URL.revokeObjectURL(victim.previewUrl);
  1438. return cur.filter((f) => f.id !== id);
  1439. });
  1440. }
  1441. /** 清空待发附件(同时释放预览地址) */
  1442. clearPendingFiles(): void {
  1443. for (const f of this._pendingFiles()) {
  1444. if (f.previewUrl) URL.revokeObjectURL(f.previewUrl);
  1445. }
  1446. this._pendingFiles.set([]);
  1447. }
  1448. /**
  1449. * 发送待发附件(内部使用)。
  1450. *
  1451. * @returns 每个附件是否成功
  1452. */
  1453. private async uploadPending(conversationId: string): Promise<{ ok: number; fail: number }> {
  1454. const pending = this._pendingFiles();
  1455. if (pending.length === 0) return { ok: 0, fail: 0 };
  1456. this._uploading.set(true);
  1457. this._uploadPct.set(0);
  1458. let ok = 0;
  1459. let fail = 0;
  1460. /** 上传失败的暂存项 id(保留在待发区供重试) */
  1461. const failedIds = new Set<string>();
  1462. try {
  1463. for (let i = 0; i < pending.length; i++) {
  1464. const { file } = pending[i];
  1465. try {
  1466. /*
  1467. * 🔴 直接走 **Matrix 原生附件**,不再经过 OBS 预签名 + 自定义 JSON。
  1468. *
  1469. * 容器只认 `{ msgtype: 'm.file'|'m.image', url: 'mxc://...' }`。
  1470. * 早期实现走 `api.uploadFile()` 拿 OBS 地址、再塞进 `m.text` 的 JSON body ——
  1471. * 容器完全不解析,用户发图后它回「只看到文字,没收到附件」(实测)。
  1472. *
  1473. * 现在:文件内容直接上传 Matrix 媒体库 → 发原生事件。
  1474. * `publicUrl` 仅用于界面本地展示,OBS 那条链路不再参与发送。
  1475. */
  1476. const mimeType = file.type || 'application/octet-stream';
  1477. if (!this.roomId) throw new Error('会话未就绪');
  1478. const sid = this._sessionId();
  1479. /*
  1480. * 🔴 「两条都存」(2026-11-09 用户裁决)。
  1481. *
  1482. * 用户要求:文件处理完必须上传云盘,之后 App 里查看**都走云盘**。
  1483. *
  1484. * ⚠️ 但**不能**像早期那样「只走 OBS」—— 容器只认 Matrix 的
  1485. * `m.file` / `m.image` 原生事件,换成 S3 直链它会回「没收到附件」。
  1486. *
  1487. * 所以:Matrix 照常发(供容器读取与消息流回溯),
  1488. * S3 **额外**存一份(供 App 长期查看与成果归档)。
  1489. * S3 失败**不阻断发送** —— 降级为只有 Matrix,并记一条告警。
  1490. */
  1491. await this.mx.sendFile(
  1492. this.roomId,
  1493. file,
  1494. { name: file.name, size: file.size, mimeType },
  1495. sid ?? undefined,
  1496. );
  1497. let cloudUrl: string | null = null;
  1498. try {
  1499. const up = await this.api.uploadFile(file);
  1500. cloudUrl = up.publicUrl;
  1501. /*
  1502. * 🔴 用户上传的文件**不记进会话成果列表**(2026-11-10 修复)。
  1503. *
  1504. * 原实现这里直接 `addDeliverableUrl(sid, cloudUrl)`,
  1505. * 结果自己发的图同时出现在右栏「文件」和「成果」两处
  1506. * (用户实测截图)。「成果」= 容器产出的东西,
  1507. * 用户文件只属于「文件」页签(由消息流 sender='me' 渲染)。
  1508. *
  1509. * 云盘地址本身仍有用:回读的 `m.image` 只有 `mxc://`,
  1510. * 这里记进 `_cloudUrls`,回读时补回 `FileCard.cloudUrl`(2026-11-10)。
  1511. */
  1512. if (cloudUrl) this._cloudUrls.set(this.cloudKeyOf(file.name, file.size), cloudUrl);
  1513. } catch (e) {
  1514. /* 如实记录:这条文件只在 Matrix,没进云盘 */
  1515. this._error.set(
  1516. `「${file.name}」已发送,但未存入云盘:${e instanceof Error ? e.message : '上传失败'}`,
  1517. );
  1518. }
  1519. this._uploadPct.set(Math.round(((i + 1) / pending.length) * 100));
  1520. /*
  1521. * 🔴 不再本地插入「文件消息」(2026-11-10 修复)。
  1522. *
  1523. * 原实现在这里造一条 `local-file-*` 的 me 消息,同时 Matrix 的
  1524. * `m.image` echo 回读后又落一条 —— 配合回合聚合「每条 me 开新回合」,
  1525. * 一次发送配图就出现两个用户气泡(用户实测截图)。
  1526. *
  1527. * 现在:即时反馈交给输入框的上传进度(`uploading()` / `uploadPct()`),
  1528. * 附件气泡一律以回读的 `m.image` 为准,与文字合并成同一回合。
  1529. * 回读一般在秒级内到达,期间用户看到的是上传进度,不是空白。
  1530. */
  1531. ok++;
  1532. } catch {
  1533. fail++;
  1534. failedIds.add(pending[i].id);
  1535. }
  1536. }
  1537. } finally {
  1538. this._uploading.set(false);
  1539. this._uploadPct.set(0);
  1540. /*
  1541. * 🔴 按 id 精确保留失败项(2026-11-03 修复)。
  1542. *
  1543. * 原实现 `cur.slice(ok)` 把「成功数」当成「失败前缀」:
  1544. * 若第 1 个失败、第 2 个成功(ok=1),`slice(1)` 会**丢掉失败的**、
  1545. * **留下已成功的** —— 用户看到已发出的附件还挂在输入框上,真正失败的却消失了。
  1546. *
  1547. * 同时要 revoke 被移除项的 previewUrl,避免对象地址泄漏。
  1548. */
  1549. this._pendingFiles.update((cur) => {
  1550. const remain = cur.filter((f) => failedIds.has(f.id));
  1551. for (const f of cur) {
  1552. if (!failedIds.has(f.id) && f.previewUrl) URL.revokeObjectURL(f.previewUrl);
  1553. }
  1554. return remain;
  1555. });
  1556. }
  1557. return { ok, fail };
  1558. }
  1559. /** 向上翻历史(AC-04.4);返回是否还有更早的消息 */
  1560. async paginate(): Promise<boolean> {
  1561. if (environment.useMockData || !this.roomId) return false;
  1562. /*
  1563. * 🔴 新建态**禁止翻页**(2026-11-08,「新建会话仍带出历史」的最后一个入口)。
  1564. *
  1565. * `paginate()` 是三条回填路径里唯一没有新建态保护的:
  1566. * · `storeVersion` effect → 已加 `_blankSessionId` 判定
  1567. * · `appendIncoming` → 已加 `_blankSessionId` 判定
  1568. * · `paginate` → 原先没有,且 `paginateBack()` 翻的是**整个房间**的历史
  1569. * (`room.getLiveTimeline()`),不是当前 thread 的 history。
  1570. *
  1571. * 触发链:新建会话首屏只有 0~1 条消息、不满一屏 →
  1572. * 移动端 `fillViewportIfNeeded()` / 桌面端贴底逻辑 → 自动 `paginate()`
  1573. * → 拉回 30 条房间历史 → 按会话过滤时把旧内容认领进来。
  1574. * 页面上还会出现「加载更早的消息」按钮,因为 `hasMoreHistory` 取自房间级 token。
  1575. *
  1576. * 新建会话本来就没有「更早的消息」,直接返回 false。
  1577. */
  1578. const sidNow = this._sessionId();
  1579. if (!sidNow || this._blankSessionId === sidNow) return false;
  1580. // 防重入:滚动事件高频触发,加载中直接忽略,避免重复分页
  1581. if (this._paginating()) return true;
  1582. this._paginating.set(true);
  1583. const my = this.seq;
  1584. try {
  1585. const before = this._messages().length;
  1586. const ok = await this.mx.paginateBack(this.roomId, 30);
  1587. if (my !== this.seq) return false;
  1588. const myUserId = this.provision?.userMxId ?? '';
  1589. const sid = this._sessionId() ?? '';
  1590. const next = this.mapTimeline(
  1591. this.mx.timelineToMessages(this.roomId).filter((m) => this.belongsToSession(m, sid)),
  1592. myUserId,
  1593. );
  1594. /*
  1595. * 分页后**保留完整已加载区间**(不做上限截断):
  1596. * 用户是在往上翻,超出窗口的内容用户正要看,截断会让人往前翻却看不到。
  1597. * 真正省开销的是首屏只取一屏 + 后续按需加载,而不是丢弃已加载内容。
  1598. *
  1599. * 🔴 但与本地乐观消息**合并**,不整表覆盖(2026-11-09):
  1600. * 翻页时还在发送中的消息不在 timeline 里,覆盖掉就凭空消失了。
  1601. */
  1602. this._messages.set(this.mergeTimeline(next, untracked(() => this._messages())));
  1603. // 条数未增长 = 已到最早,停止继续请求
  1604. this._hasMoreHistory.set(ok && next.length > before);
  1605. return this._hasMoreHistory();
  1606. } finally {
  1607. if (my === this.seq) this._paginating.set(false);
  1608. }
  1609. }
  1610. /** 重试失败消息(AC-04.8) */
  1611. async retry(messageId: string): Promise<void> {
  1612. const msg = this._messages().find((m) => m.id === messageId);
  1613. if (!msg || msg.state !== 'failed') return;
  1614. this._messages.update((list) =>
  1615. list.map((m) => (m.id === messageId ? { ...m, state: 'sending', error: undefined } : m)),
  1616. );
  1617. try {
  1618. if (environment.useMockData) {
  1619. await this.simulateSend();
  1620. } else if (msg.text) {
  1621. await this.sendReal(msg.text, msg.localId);
  1622. }
  1623. this.markSent(messageId);
  1624. } catch (e) {
  1625. this.markFailed(messageId, msgOf(e));
  1626. }
  1627. }
  1628. /** 模拟断线(演示控制台用;真实态由 SDK 同步事件驱动) */
  1629. setConnection(state: ConnectionState): void {
  1630. this._connection.set(state);
  1631. }
  1632. // ---------- 内部 ----------
  1633. /**
  1634. * 发真实消息(真实态由 MatrixClientService 落地到房间)。
  1635. *
  1636. * 🔴 **必须带会话线程根**:容器 `build_session_key` 按 `room + thread` 划分会话,
  1637. * 不带 thread 时每条消息都会被 `MATRIX_AUTO_THREAD` 切成新会话 → 容器失忆
  1638. * (2026-10-28 实测,见 `docs/03-interface/容器会话与线程机制-20261028.md`)。
  1639. *
  1640. * ⚠️ 「历史对话」特例已于 2026-10-29 移除 —— 该会话类型不再存在,
  1641. * 所有发送一律带线程根,避免容器侧把消息切成新会话(容器失忆)。
  1642. */
  1643. private async sendReal(text: string, localId?: string): Promise<void> {
  1644. if (!this.roomId) throw new Error('会话未就绪,请稍后重试');
  1645. let sid = this._sessionId();
  1646. if (!sid) {
  1647. /*
  1648. * 「无会话」是合法状态(进入容器不自动建会话)。
  1649. * 用户在这个状态下直接发消息 = 隐式开始一段新对话 → 此刻才创建会话。
  1650. * 正常路径下 `sendText()` 已经建过,这里是附件等旁路入口的兜底。
  1651. */
  1652. const created = this.ensureSessionForSend();
  1653. if (!created) throw new Error('会话未就绪:缺少会话标识,请重新进入');
  1654. sid = created.id;
  1655. }
  1656. const lastId = this._messages().at(-1)?.id;
  1657. // 自造的会话 id 还没有任何消息时,不填 m.in_reply_to(那是降级展示用的引用)
  1658. const last = lastId && !lastId.startsWith('local-') ? lastId : undefined;
  1659. /*
  1660. * `localId` 随事件写进 content:服务端 echo 会原样带回(Matrix 允许任意额外字段),
  1661. * 回读时据此认领本地那条乐观消息,避免同一句话出现两遍。
  1662. */
  1663. await this.mx.sendText(
  1664. this.roomId,
  1665. text,
  1666. sid,
  1667. last,
  1668. localId ? { [LOCAL_ID_KEY]: localId } : undefined,
  1669. );
  1670. }
  1671. /**
  1672. * 懒创建:**第一次发消息时**才把会话落到本地 + 云端(2026-11-01 用户裁决)。
  1673. *
  1674. * 之前「点新建对话就建一条空会话」会产生大量 0 消息的空壳,
  1675. * 还得靠 `pruneEmpty` 事后清理;清理逻辑一旦出错就是「每进一次容器多一条」。
  1676. * 改成「没有消息就不存在会话」后,空壳从源头消失。
  1677. *
  1678. * @returns 会话(已存在则返回当前会话)
  1679. */
  1680. private ensureSessionForSend(): ChatSession | null {
  1681. const agentId = this._agentId();
  1682. const roomId = this.roomId;
  1683. if (!agentId || !roomId) return null;
  1684. const exist = this._sessionId();
  1685. if (exist) {
  1686. const found = this.sessions.byId(exist);
  1687. /*
  1688. * 🔴 有 id 但目录里查不到 = 这条会话已被 `pruneEmpty` 删掉(2026-11-08)。
  1689. *
  1690. * 原实现直接 `return null`,调用方却继续往下走:消息进 `_messages`、
  1691. * 不进任何会话目录;用户再发一条又建一条新的 → 左栏出现**两条外观完全相同**
  1692. * 的会话(用户实测图35)。
  1693. *
  1694. * 现在:不再悄悄返回 null。把指针清掉并**就地重建一条**,
  1695. * 让这次发送仍然落在一个真实存在的会话上。
  1696. */
  1697. if (!found) {
  1698. const recreated = this.sessions.create(roomId, agentId);
  1699. this._sessionId.set(recreated.id);
  1700. /*
  1701. * 🔴 换了会话标识,内存里属于旧会话的消息必须清掉(2026-11-09)。
  1702. * 否则旧会话的内容挂在新的会话 id 下,观感就是「串线」。
  1703. * 同时重置新建态保护,让这次发送按正常的新会话走。
  1704. */
  1705. this._messages.set([]);
  1706. this._ownedThreadRoots.clear();
  1707. this._blankSessionId = recreated.id;
  1708. if (this._blankWorkspace) {
  1709. this.sessions.moveTo(recreated.id, this._blankWorkspace);
  1710. this._blankWorkspace = null;
  1711. }
  1712. return recreated;
  1713. }
  1714. return found;
  1715. }
  1716. const created = this.sessions.create(roomId, agentId);
  1717. /*
  1718. * 「新建对话」时指定的工作区在**这一刻**落地。
  1719. * 之前没有会话可挂,所以只能在建出来之后补归属。
  1720. */
  1721. if (this._blankWorkspace) {
  1722. this.sessions.moveTo(created.id, this._blankWorkspace);
  1723. this._blankWorkspace = null;
  1724. }
  1725. return created;
  1726. }
  1727. /**
  1728. * 消息是否属于指定会话(线程)。
  1729. *
  1730. * 规则:
  1731. * 1. 消息有 threadId → 只有 `threadId === 会话 id` 才属于
  1732. * 2. 消息无 threadId(**历史遗留**)→ 不归入任何会话(承接机制已下线)
  1733. *
  1734. * ## 为什么要单独处理无 thread 的消息
  1735. *
  1736. * 多会话上线前,所有消息都不带 thread。它们是用户真实的对话历史,
  1737. * **不能凭空消失**。处理方式:
  1738. * - 首次打开某 agent 时,若房间里存在无 thread 的消息,
  1739. * 自动建一个「历史会话」承接它们(id 由本机固定生成,见 `legacySessionId`)
  1740. * - 该会话只显示无 thread 消息,且**发送时也不带 thread**
  1741. * —— 这样容器侧仍是原来的「每条一个会话」行为,不会把旧历史和新会话混起来
  1742. *
  1743. * 其它会话一律**不含**无 thread 消息,避免串线。
  1744. */
  1745. /**
  1746. * 这条房间级消息是否落在「该房间最近一次群聊派活」之后。
  1747. *
  1748. * 容器的回复不带派活标记(容器不知道自己在群里干活),但它在时间线上
  1749. * 必然排在派活消息**之后**。用 eventId 的位置判定,不依赖时钟。
  1750. *
  1751. * 锚点来源:内存 `_dispatchAnchor`(本设备刚发过派活,最准)。
  1752. * 找不到锚点 → false(宁可放行,也不误挡单聊历史)。
  1753. */
  1754. private isAfterLastDispatch(m: MxMessage): boolean {
  1755. if (m.threadId) return false; // 有 thread 的走原有逻辑
  1756. const roomId = m.roomId;
  1757. if (!roomId || !m.eventId) return false;
  1758. const anchor = this._dispatchAnchor().get(roomId);
  1759. if (!anchor) return false;
  1760. /*
  1761. * 锚点事件还在当前 timeline 里 → 按下标判定(最可靠)。
  1762. * 锚点被分页裁掉但消息还在 → 视为更新(消息一定排在锚点之后才可能还在)。
  1763. * 消息自己不在 timeline → false,不误挡。
  1764. */
  1765. const index = this.mx.timelineIndex(roomId);
  1766. const mine = index.get(m.eventId);
  1767. const base = index.get(anchor.eventId);
  1768. if (mine !== undefined && base !== undefined) return mine > base;
  1769. if (mine !== undefined && base === undefined) return true;
  1770. return false;
  1771. }
  1772. /**
  1773. * 记录一次群聊派活的锚点(群聊页在 `sendToAgent` 成功后调用)。
  1774. *
  1775. * 同一房间反复派活时取**最新**的一条 —— 「本轮之后的消息」天然包含上一轮。
  1776. */
  1777. noteDispatchAnchor(roomId: string, eventId: string | undefined): void {
  1778. if (!roomId || !eventId) return;
  1779. const next = new Map(this._dispatchAnchor());
  1780. next.set(roomId, { eventId });
  1781. this._dispatchAnchor.set(next);
  1782. }
  1783. /** 当前派活锚点(供群聊页在冷启动时补种) */
  1784. readonly dispatchAnchors = this._dispatchAnchor.asReadonly();
  1785. private belongsToSession(
  1786. m: MxMessage,
  1787. sessionId: string,
  1788. live = false,
  1789. ownedRoots?: ReadonlySet<string>,
  1790. ): boolean {
  1791. if (!m.threadId) {
  1792. /*
  1793. * 房间级消息(无 thread)的归属(2026-11-09 用户裁决调整)。
  1794. *
  1795. * 用户原话:「房间级的这个消息,可以丢弃掉,没有问题」。
  1796. *
  1797. * 背景:同一个 DM 房间里,App 的正常会话**都带 thread**;
  1798. * 只有两种情况会产生无 thread 的消息:
  1799. * ① 群聊派活(`sendToAgent` 发房间级)—— 有 `dispatch` 标记可识别
  1800. * ② **外部工具 / 脚本**直接往房间发的消息 —— 没有任何归属信息
  1801. *
  1802. * ② 是实测踩到的坑:用脚本调试容器发出的消息没有 thread,
  1803. * 结果被新建会话全部认领,表现为「新建会话出现一堆历史」。
  1804. *
  1805. * 现在的规则:**一律不认领**。唯一例外是单聊自己发的房间级附件 ——
  1806. * 那种消息带 `sessionId` 标记,归属是明确的。
  1807. *
  1808. * 代价:容器历史上无 thread 的旧回复不再显示。这是有意的取舍 ——
  1809. * 宁可少显示,也不要把别处的消息错认成这个会话的。
  1810. */
  1811. if (m.dispatch) return false;
  1812. if (m.sessionId) return m.sessionId === sessionId;
  1813. return false;
  1814. }
  1815. if (m.threadId === sessionId) return true;
  1816. /*
  1817. * 无 thread 的消息(房间级消息)已在函数开头处理完毕 ——
  1818. * 到这里 `m.threadId` 必有值。
  1819. *
  1820. * 🔴 **容器自己开的子线程,也算当前会话的内容**。
  1821. *
  1822. * 实测:容器在回复时会另开 `m.thread` 分支跑内部任务
  1823. * (调工具、跑浏览器、自我改进)。那些消息也是这次对话的一部分 ——
  1824. * 用户的交付物(dots.html、截图)就在里面。
  1825. *
  1826. * 所以规则是:
  1827. * · 线程根 = 当前会话 → 属于
  1828. * · 线程根是 App 造的(另一个会话) → 不属于(严格隔离)
  1829. * · 线程根是容器造的(非 approot) → **属于当前会话**,按时间混排在消息流里
  1830. *
  1831. * 这样既不会在左栏冒出重复会话,内容也不会丢。
  1832. *
  1833. * 🔴 但要**限定在当前会话**(2026-11-08 修复)。
  1834. *
  1835. * 原实现直接 `return !isAppSessionId(m.threadId)` —— 意味着容器开过的
  1836. * **每一个**线程根都属于**每一个**会话。后果:新建一个空会话后,
  1837. * 该房间里所有旧线程的消息都会被它认领,上方凭空出现几天前的对话
  1838. * (用户实测图31)。
  1839. *
  1840. * 现在:**历史回填**时容器线程根只归「已经认领过它」的会话 ——
  1841. * 新会话从没认领过任何根,自然什么都拿不到,符合「新建即空白」的预期。
  1842. * **实时事件**(`live=true`)仍然接纳:那是容器刚刚发出的内容,
  1843. * 一定是当前这次对话的产出(截图、交付物都在里面)。
  1844. */
  1845. if (live) return !isAppSessionId(m.threadId);
  1846. const roots = ownedRoots ?? this._ownedThreadRoots;
  1847. return roots.has(m.threadId);
  1848. }
  1849. /**
  1850. * 算出某会话「拥有」的容器线程根(2026-11-08)。
  1851. *
  1852. * 判据:该线程根下,存在**属于本会话的用户消息**。
  1853. * 用户在这个会话里发过东西(`threadId === sid` 或 `sessionId === sid`),
  1854. * 容器才会为它开子线程 —— 那些子线程天然属于本会话。
  1855. *
  1856. * 新建的空会话没有任何用户消息 → 返回空集 → 旧线程一个都不认领。
  1857. */
  1858. private rootsOfSession(
  1859. raw: MxMessage[],
  1860. sessionId: string,
  1861. myUserId: string,
  1862. ): Set<string> {
  1863. const roots = new Set<string>();
  1864. // 本会话的用户消息挂在哪些容器线程根下
  1865. const myRoots = new Set<string>();
  1866. for (const m of raw) {
  1867. if (m.sender !== myUserId) continue;
  1868. if (m.threadId === sessionId) {
  1869. myRoots.add(m.threadId);
  1870. roots.add(sessionId);
  1871. }
  1872. if (m.sessionId === sessionId) {
  1873. myRoots.add(m.eventId);
  1874. if (m.threadId) roots.add(m.threadId);
  1875. }
  1876. }
  1877. // 容器开在「本会话用户消息所属根」下的子线程,也算本会话
  1878. for (const m of raw) {
  1879. const root = m.threadId;
  1880. if (!root || isAppSessionId(root)) continue;
  1881. if (myRoots.has(root) || m.sessionId === sessionId) roots.add(root);
  1882. }
  1883. return roots;
  1884. }
  1885. /**
  1886. * 本会话已经认领的「容器线程根」(2026-11-08)。
  1887. *
  1888. * 由 `mapTimeline` 的归属计算过程写入:当前会话自己产生过的容器线程根。
  1889. * 切换会话时清空。没有它就无法区分「本会话的容器子线程」和「别的会话的」。
  1890. */
  1891. private readonly _ownedThreadRoots = new Set<string>();
  1892. /**
  1893. * 清理「历史对话」遗留会话(2026-10-29 用户裁决下线)。
  1894. *
  1895. * 背景:多会话上线前的消息不带 thread 根,曾经专门建一个 legacy 会话承接它们。
  1896. * 但那个条目**既不能改名也不能归档**,混在正常对话列表里非常突兀。
  1897. *
  1898. * 现在的处理:
  1899. * · 不再创建新的 legacy 会话(`adoptLegacyIfNeeded` 已删除)
  1900. * · 用户本地已存在的 legacy 索引条目,在打开会话时清掉
  1901. *
  1902. * ⚠️ 只清**本地索引**,Matrix 房间里的消息不受影响 —— 删除由服务端策略决定,
  1903. * 客户端无权也不应该擅自删他人数据。
  1904. */
  1905. private dropLegacySessions(agentId: string): void {
  1906. const stale = this.sessions.listFor(agentId, { includeArchived: true }).filter((s) => s.legacy);
  1907. for (const s of stale) this.sessions.remove(s.id);
  1908. }
  1909. /**
  1910. * 把「服务端 timeline」和「本地内存里的消息」合并成一份列表。
  1911. *
  1912. * 🔴 为什么需要它(2026-11-09,用户实测「发一条你好,出现三条」):
  1913. * 乐观消息的 id 是 `local-*`,它自己的服务端 echo 是 `$eventId`,
  1914. * **两者 id 天然不同**。原实现按 id 做并集 —— 于是同一条消息
  1915. * 以「本地一条 + 回读一条」并存;再加上实时追加,最多时出现 3 条。
  1916. *
  1917. * 规则:
  1918. * ① echo 带 `localId` 且能对上本地某条 → **认领**它,只保留服务端那条
  1919. * ② 没被认领的本地消息,**只保留仍在发送中 / 已失败**的
  1920. * (失败消息不能丢,可重试 AC-04.8;已 `sent` 却没有 echo 的,
  1921. * 说明它属于服务端时间线之外 —— 留着只会重复)
  1922. * ③ 只保留**属于当前 agent** 的本地消息
  1923. * (🔴 2026-11-09 评审 B3:原实现不分会话,切会话后
  1924. * `storeVersion` 每次 sync 都会把旧会话的乐观消息重新并回来)
  1925. * ④ 按时间稳定排序,echo 落在它本来的位置,不跳到末尾
  1926. */
  1927. private mergeTimeline(next: Message[], local: Message[]): Message[] {
  1928. // 服务端 echo 认领了哪些本地 id
  1929. const claimed = new Set<string>();
  1930. for (const m of next) {
  1931. if (m.localId) claimed.add(m.localId);
  1932. }
  1933. const agentId = this._agentId() ?? '';
  1934. /*
  1935. * 🔴 echo 认领本地消息时**沿用本地的 ts**(2026-11-09 修复秒数跳变)。
  1936. *
  1937. * 本地乐观消息的 ts 是浏览器时钟,服务端 echo 的 ts 是服务端时钟,
  1938. * 两者有几秒偏差。直接换成服务端的,会让「等待了多久」这个锚点跳变
  1939. * (用户实测 95s → 100s)。
  1940. * 这一条是我方发出的消息,用我方记下的发送时刻更准。
  1941. */
  1942. const localTs = new Map<string, string>();
  1943. for (const m of local) {
  1944. if (m.localId) localTs.set(m.localId, m.ts);
  1945. }
  1946. const out: Message[] = next.map((m) =>
  1947. m.localId && localTs.has(m.localId) ? { ...m, ts: localTs.get(m.localId)! } : m,
  1948. );
  1949. for (const m of local) {
  1950. if (claimed.has(m.id) || (m.localId && claimed.has(m.localId))) continue;
  1951. if (m.state === 'sent') continue;
  1952. if (agentId && m.conversationId !== agentId) continue;
  1953. out.push(m);
  1954. }
  1955. /*
  1956. * 稳定排序:时间相同(同一毫秒)时保持原顺序。
  1957. * Array.prototype.sort 在现代 V8 里是稳定的,不必额外带序号。
  1958. */
  1959. return out.sort((a, b) => (Date.parse(a.ts) || 0) - (Date.parse(b.ts) || 0));
  1960. }
  1961. /** 归一 timeline → 领域消息 */
  1962. private mapTimeline(list: MxMessage[], myUserId: string): Message[] {
  1963. const cid = this._agentId() ?? '';
  1964. return list.map((m) => {
  1965. /*
  1966. * 两种附件形态都要认:
  1967. * ① 我们早期自造的 `m.text + JSON body`
  1968. * ② **Matrix 原生 `m.file` / `m.image`**(容器交付成果用的就是这个)
  1969. *
  1970. * 🔴 早期只认 ① —— 容器发来的 dots.html、截图全被当成普通文本,
  1971. * 界面上既看不到文件、右侧「成果」栏也永远是 0(实测)。
  1972. * `MxMessage.media` 已经把原生附件解析好并转成了可访问 URL。
  1973. */
  1974. const card = MatrixClientService.parseFileCard(m.body);
  1975. const file = card ?? (m.media ? m.media : undefined);
  1976. /*
  1977. * 🔴 正文里的云盘链接 → 生成文件卡片(2026-11-09)。
  1978. *
  1979. * 容器交付成果时不发附件,而是把 `https://s3.fmode.cn/...` 写在回复正文里。
  1980. * 用户要求:「应该是显示卡片,而不是链接」。
  1981. *
  1982. * ⚠️ 2026-11-09 第二次修正 —— 两次都出过问题,记下来避免再犯:
  1983. * ① 初版「剥出链接 + 从正文删掉」→ 正文只剩链接时变成**空消息**,
  1984. * 用户什么都看不到;容器一行一个链接时还会生成**重复卡片**。
  1985. * ② 现在:**正文完整保留**(一个字不删),只为第一个链接生成卡片。
  1986. * 链接在正文里继续显示为可点击文字 —— 这是**有意为之**:
  1987. * 卡片给「文件」区块用,正文里的链接给用户直接点。
  1988. *
  1989. * 重复的两个卡片来自**容器一条回复被拆成多条事件**,
  1990. * 属于容器输出格式问题,已通过「链接独占一行、一次只给一个主文件」规范约束。
  1991. */
  1992. /*
  1993. * 文件来源(2026-11-10 起按优先级):
  1994. *
  1995. * ① Matrix 原生附件(`m.file` / `m.media`) —— 最可靠
  1996. * ② **容器声明段** `<!--fmode:files [...] -->` —— 结构化,不需要猜
  1997. * ③ 正文里的云盘链接(正则提取) —— 老格式的兜底
  1998. *
  1999. * 🔴 ② 是新增的:原先只有 ①③,而 ③ 靠正则猜 URL,
  2000. * 已经踩过两次坑(容器举例的 `https://s3.fmode.cn/...` 被当成真文件、
  2001. * 用户提问里的链接被收成成果)。声明段是容器**显式说**有哪些文件。
  2002. *
  2003. * 声明段存在时**不再**走 ③ —— 容器已经明确表态,不需要再猜。
  2004. * 没有声明段时才回退到 ③(老容器仍可用)。
  2005. */
  2006. const declared = file ? null : parseDeclaredFiles(m.body);
  2007. let linkCard: FileCard | undefined;
  2008. let bodyText = m.body;
  2009. if (declared !== null) {
  2010. // 有声明段:正文去掉那段 JSON(用户不该看到),文件从声明里取
  2011. bodyText = stripDeclaredFiles(m.body);
  2012. const first = declared.find((d) => d.url);
  2013. if (first) linkCard = this.cardFromDeclared(first);
  2014. } else if (!file) {
  2015. // 没有声明段:回退到「从正文正则提取」(老格式)
  2016. const cloudLinks = extractS3Links(m.body);
  2017. linkCard = cloudLinks.length > 0 ? this.cardOfCloudLink(cloudLinks[0]) : undefined;
  2018. bodyText = linkCard ? stripLinks(m.body, cloudLinks) : m.body;
  2019. }
  2020. return {
  2021. id: m.eventId || `${m.ts}-${m.sender}`,
  2022. conversationId: cid,
  2023. sender: m.sender === myUserId ? ('me' as const) : ('agent' as const),
  2024. kind: m.kind as MessageKind,
  2025. /*
  2026. * 🔴 有附件时**也要保留正文**(2026-11-09 用户反馈「正文没文字了」)。
  2027. *
  2028. * 原实现 `text: file ? undefined : m.body` —— 只要消息带附件就把正文丢掉。
  2029. * 但容器常常是「正文说明 + 文件链接」写在同一条消息里,
  2030. * 丢掉正文后用户只看到一张卡片,不知道这个文件是干什么的。
  2031. */
  2032. text: bodyText || undefined,
  2033. file: file
  2034. ? {
  2035. kind: 'file' as const,
  2036. name: file.name,
  2037. size: file.size,
  2038. mimeType: file.mimeType,
  2039. url: file.url,
  2040. /*
  2041. * 我自己刚发的附件:回读只有 `mxc://`,用发送时记下的云盘地址补上,
  2042. * 右栏「文件」才能长期访问(2026-11-10)。
  2043. */
  2044. cloudUrl: this._cloudUrls.get(this.cloudKeyOf(file.name, file.size)),
  2045. }
  2046. : linkCard,
  2047. ts: new Date(m.ts).toISOString(),
  2048. state: 'sent' as const,
  2049. // 透传本地 id:合并时用它认领乐观消息(见 mergeTimeline)
  2050. localId: m.localId,
  2051. // 透传「在回复哪一条」:回合聚合据此把回复归到正确的提问(2026-11-10)
  2052. replyTo: m.replyTo,
  2053. };
  2054. });
  2055. }
  2056. /**
  2057. * 把声明段里的一项包成附件卡片(2026-11-10)。
  2058. *
  2059. * 与 `cardOfCloudLink` 的区别:这里的**文件名和大小是容器给的**,不用猜。
  2060. */
  2061. private cardFromDeclared(d: DeclaredFile): FileCard {
  2062. const ext = (d.name.split('.').pop() ?? '').toLowerCase();
  2063. const mimeByExt: Record<string, string> = {
  2064. md: 'text/markdown',
  2065. markdown: 'text/markdown',
  2066. html: 'text/html',
  2067. htm: 'text/html',
  2068. pdf: 'application/pdf',
  2069. txt: 'text/plain',
  2070. csv: 'text/csv',
  2071. json: 'application/json',
  2072. png: 'image/png',
  2073. jpg: 'image/jpeg',
  2074. jpeg: 'image/jpeg',
  2075. gif: 'image/gif',
  2076. webp: 'image/webp',
  2077. svg: 'image/svg+xml',
  2078. xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
  2079. docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
  2080. pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
  2081. zip: 'application/zip',
  2082. mp4: 'video/mp4',
  2083. };
  2084. return {
  2085. kind: 'file',
  2086. name: d.name,
  2087. size: d.size,
  2088. mimeType: mimeByExt[ext] ?? 'application/octet-stream',
  2089. url: d.url,
  2090. cloudUrl: d.url,
  2091. };
  2092. }
  2093. /**
  2094. * 把一条云盘链接包成附件卡片。
  2095. *
  2096. * 云盘对象是 `public-read`,链接本身就能下载/预览,
  2097. * 所以 `url` 直接用它,`cloudUrl` 也指向它(后续「优先走云盘」逻辑由此生效)。
  2098. * 文件名与类型从 URL 推断。
  2099. */
  2100. private cardOfCloudLink(url: string): FileCard | undefined {
  2101. /*
  2102. * 🔴 先判断「这是不是文件」(2026-11-09)。
  2103. *
  2104. * 原实现只要正文里有云盘域名的链接就造卡 —— 但不全是文件:
  2105. * · 目录:`https://s3.fmode.cn/bucket/`
  2106. * · 无扩展名的路径:`https://s3.fmode.cn/user/xxx/report`
  2107. * · 文档里引用的示例链接
  2108. * 这些被当成文件卡片显示,用户看到的是一堆莫名其妙的「文件」。
  2109. */
  2110. const path = url.split('?')[0].split('#')[0];
  2111. // 以 / 结尾 = 目录
  2112. if (path.endsWith('/')) return undefined;
  2113. const seg = decodeURIComponent(path.split('/').filter(Boolean).pop() ?? '');
  2114. /*
  2115. * 🔴 排除「省略号占位」(2026-11-09)。
  2116. * 容器/用户在举例时常写 `https://s3.fmode.cn/...`,
  2117. * 末段是 `...`,`lastIndexOf('.')` 会误判成「有扩展名」。
  2118. */
  2119. if (/^\.+$/.test(seg) || seg.includes('...') || seg.includes('…')) return undefined;
  2120. const dot = seg.lastIndexOf('.');
  2121. // 没有扩展名 = 不是文件
  2122. if (dot <= 0 || dot === seg.length - 1) return undefined;
  2123. const ext = seg.slice(dot + 1).toLowerCase();
  2124. /* 扩展名必须是纯字母且 ≥2 位(`a.1`、`x.` 这类不是文件) */
  2125. if (!/^[a-z]{2,5}$/.test(ext)) return undefined;
  2126. /*
  2127. * 扩展名白名单:只认「确实是文件」的类型。
  2128. * 不在名单里的一律不造卡(链接留在正文里当文字)。
  2129. */
  2130. const mimeByExt: Record<string, string> = {
  2131. md: 'text/markdown',
  2132. markdown: 'text/markdown',
  2133. html: 'text/html',
  2134. htm: 'text/html',
  2135. pdf: 'application/pdf',
  2136. txt: 'text/plain',
  2137. log: 'text/plain',
  2138. csv: 'text/csv',
  2139. tsv: 'text/tab-separated-values',
  2140. json: 'application/json',
  2141. xml: 'application/xml',
  2142. yml: 'text/yaml',
  2143. yaml: 'text/yaml',
  2144. png: 'image/png',
  2145. jpg: 'image/jpeg',
  2146. jpeg: 'image/jpeg',
  2147. gif: 'image/gif',
  2148. webp: 'image/webp',
  2149. svg: 'image/svg+xml',
  2150. xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
  2151. xls: 'application/vnd.ms-excel',
  2152. docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
  2153. doc: 'application/msword',
  2154. pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
  2155. zip: 'application/zip',
  2156. tar: 'application/x-tar',
  2157. gz: 'application/gzip',
  2158. mp4: 'video/mp4',
  2159. mp3: 'audio/mpeg',
  2160. };
  2161. const mime = mimeByExt[ext];
  2162. if (!mime) return undefined;
  2163. return {
  2164. kind: 'file',
  2165. name: seg,
  2166. /*
  2167. * 云盘链接不带文件大小。用 -1 表示「未知」,界面据此不显示「0 B」
  2168. * (0 B 会让人以为文件是空的,实测用户困惑)。
  2169. */
  2170. size: -1,
  2171. mimeType: mime,
  2172. url,
  2173. cloudUrl: url,
  2174. };
  2175. }
  2176. /** 追加一条新到消息(按 eventId 去重;不属于当前会话的忽略) */
  2177. private appendIncoming(m: MxMessage, myUserId: string): void {
  2178. if (!this._agentId()) return;
  2179. const sid = this._sessionId();
  2180. // 🔴 多会话隔离:别的会话的消息不进入当前会话的列表
  2181. if (!sid || !this.belongsToSession(m, sid, true)) return;
  2182. /*
  2183. * 🔴 新建态:只放行**本会话自己的消息**(2026-11-08)。
  2184. *
  2185. * `startBlank()` 之后新会话还没有任何 thread 根,
  2186. * `belongsToSession(live=true)` 对旧事件一律放行,会把别处的内容塞进来。
  2187. * 但用户刚发的那条(`threadId === sid`)必须进来 —— 它是本会话成立的凭据,
  2188. * 也是 storeVersion effect 判定「已落地、可解除保护」的依据。
  2189. */
  2190. if (this._blankSessionId && sid === this._blankSessionId && m.threadId !== sid) return;
  2191. /*
  2192. * 没有 eventId 的事件(极少见)用「时间 + 发送者」当退路 id;
  2193. * 退路 id 可能和时间相同的消息撞上,故只用来去重,不参与后续判定。
  2194. */
  2195. const eid = m.eventId || `${m.ts}-${m.sender}`;
  2196. if (this._messages().some((x) => x.id === eid)) return;
  2197. const [mapped] = this.mapTimeline([m], myUserId);
  2198. /*
  2199. * 🔴 自己的 echo 到达时**替换**乐观消息,而不是追加(2026-11-09)。
  2200. * 否则「我发的那条」会以本地 / 实时两条并存。
  2201. */
  2202. const localId = mapped?.localId;
  2203. this._messages.update((list) => {
  2204. if (!localId) return [...list, mapped];
  2205. /*
  2206. * 🔴 把被替换掉的本地消息一并交给 `mergeTimeline`(2026-11-09 修 C7)。
  2207. *
  2208. * 原写法是 `mergeTimeline([...rest, mapped], [])` —— 第二个参数传空数组,
  2209. * `localTs` 映射表因此为空,echo 认领会**静默失效**,ts 直接换成服务端时钟,
  2210. * 等待读秒从浏览器时钟跳到服务端时钟(用户实测 95s → 100s)。
  2211. * 这里把原本地消息传进去,让 localTs 回落真正生效。
  2212. */
  2213. const mine = list.filter((x) => x.id === localId);
  2214. const rest = list.filter((x) => x.id !== localId);
  2215. return this.mergeTimeline([...rest, mapped], mine);
  2216. });
  2217. this.rememberDemoHistory();
  2218. if (this._state() === 'empty') this._state.set('ready');
  2219. // 会话元信息(预览/标题/时间)随消息推进
  2220. this.sessions.noteMessage(sid, {
  2221. text: mapped.text,
  2222. ts: Date.parse(mapped.ts) || Date.now(),
  2223. fromMe: mapped.sender === 'me',
  2224. });
  2225. /*
  2226. * 容器发来的附件如果是云盘直链,记进本会话的成果列表(2026-11-09)。
  2227. * 用户要求「成果按对话归档」:只有出现在这条对话里的文件才算它的成果。
  2228. *
  2229. * 🔴 必须判 `sender === 'agent'`(2026-11-10 修复)。
  2230. * 原实现没判,用户自己发的附件回读(`sender='me'`)也会被记成成果,
  2231. * 右栏「成果」于是出现了用户图片。下方声明段/正文链接的处理本来就按
  2232. * agent 过滤,这里与它口径对齐。
  2233. */
  2234. if (mapped.sender === 'agent') {
  2235. const fileUrl =
  2236. mapped.file?.cloudUrl ||
  2237. (mapped.file?.url?.startsWith('https://s3.') ? mapped.file.url : undefined);
  2238. if (fileUrl && !isUserUploadUrl(fileUrl)) {
  2239. this.sessions.addDeliverableUrl(sid, fileUrl);
  2240. }
  2241. }
  2242. /*
  2243. * 成果链接记入会话(2026-11-10 起按优先级):
  2244. *
  2245. * ① 容器声明段 `<!--fmode:files-->` —— **显式声明,最可靠**
  2246. * ② 正文里的云盘链接 —— 老格式兜底,且必须通过文件判定
  2247. *
  2248. * ⚠️ 之前只有 ②,必须同时满足:sender === 'agent' + 不是省略号占位
  2249. * + 有合法扩展名。少一条就会把用户提问里的示例链接收成成果(已实测踩坑)。
  2250. */
  2251. if (mapped.sender === 'agent') {
  2252. const declared = parseDeclaredFiles(m.body);
  2253. if (declared !== null) {
  2254. for (const d of declared) {
  2255. if (!isUserUploadUrl(d.url)) this.sessions.addDeliverableUrl(sid, d.url);
  2256. }
  2257. } else {
  2258. for (const u of extractS3Links(mapped.text)) {
  2259. if (!isUserUploadUrl(u) && this.cardOfCloudLink(u)) {
  2260. this.sessions.addDeliverableUrl(sid, u);
  2261. }
  2262. }
  2263. }
  2264. }
  2265. /*
  2266. * 实时到达 + 对方发的 = 「正在回复」。
  2267. * 「用户发的」不算 —— 我方刚发出消息不能说明对方在回。
  2268. */
  2269. const agentId = this._agentId();
  2270. if (agentId) {
  2271. this.pulse.noteMessage(agentId, mapped.id, {
  2272. live: true,
  2273. fromAgent: mapped.sender === 'agent',
  2274. ts: Date.parse(mapped.ts) || Date.now(),
  2275. });
  2276. }
  2277. }
  2278. private teardownSubscriptions(): void {
  2279. this.unbindMessage?.();
  2280. this.unbindTyping?.();
  2281. this.unbindMessage = null;
  2282. this.unbindTyping = null;
  2283. }
  2284. private markSent(id: string): void {
  2285. this._messages.update((list) =>
  2286. list.map((m) => (m.id === id ? { ...m, state: 'sent', error: undefined } : m)),
  2287. );
  2288. this.rememberDemoHistory();
  2289. }
  2290. private markFailed(id: string, error: string): void {
  2291. this._messages.update((list) =>
  2292. list.map((m) => (m.id === id ? { ...m, state: 'failed', error } : m)),
  2293. );
  2294. this.rememberDemoHistory();
  2295. }
  2296. private async simulateSend(): Promise<void> {
  2297. // 仅演示态使用
  2298. if (this._connection() !== 'connected') {
  2299. throw new Error('连接已断开,请稍后重试');
  2300. }
  2301. await new Promise((r) => setTimeout(r, 200));
  2302. }
  2303. /** 演示态初始消息;真实态由 Matrix 历史拉取填充 */
  2304. private seedMessages(agentId: string): Message[] {
  2305. if (!environment.useMockData) return [];
  2306. const seeds = DEMO_MESSAGES[agentId] ?? [];
  2307. return seeds.map((s, i) => ({
  2308. id: `${agentId}-seed-${i}`,
  2309. conversationId: agentId,
  2310. sender: s.sender,
  2311. kind: 'text' as MessageKind,
  2312. text: s.text,
  2313. artifactId: s.artifactId,
  2314. taskId: s.taskId,
  2315. ts: this.seedTs(s.time),
  2316. state: 'sent' as const,
  2317. }));
  2318. }
  2319. /**
  2320. * 演示态:某条会话的存量消息(2026-11-06)。
  2321. *
  2322. * 演示态没有 Matrix 历史可拉,刷新后消息会全部丢掉 —— 用户自己发的
  2323. * 「第一条:先问候」刷新回来就没了,页面看起来像坏了。这里在内存里
  2324. * 按会话留一份,`open()` 命中同一会话时恢复。
  2325. *
  2326. * 🔴 仅演示态使用,真实态历史一律以 Matrix 为准。
  2327. */
  2328. private demoHistory: Map<string, Message[]> | null = null;
  2329. private static readonly DEMO_HISTORY_KEY = 'fmode_demo_history_v1';
  2330. /**
  2331. * 演示态的历史存储。
  2332. *
  2333. * 演示态没有服务端历史可拉,消息全在内存里 —— 刷新一次就没了。
  2334. * 演示态的会话目录本来就落在 localStorage(MockApi 的
  2335. * `mock/chat-threads`),历史同样存本地,两者的生命期才一致。
  2336. * 真实态(`useMockData = false`)恒为 null,任何地方都不会读写。
  2337. */
  2338. private demoStore(): Map<string, Message[]> | null {
  2339. if (!environment.useMockData || typeof localStorage === 'undefined') return null;
  2340. if (this.demoHistory) return this.demoHistory;
  2341. const map = new Map<string, Message[]>();
  2342. try {
  2343. const raw = localStorage.getItem(ChatService.DEMO_HISTORY_KEY);
  2344. const parsed = raw ? (JSON.parse(raw) as unknown) : null;
  2345. if (Array.isArray(parsed)) {
  2346. for (const e of parsed as [string, Message[]][]) {
  2347. if (Array.isArray(e) && typeof e[0] === 'string' && Array.isArray(e[1])) {
  2348. map.set(e[0], e[1]);
  2349. }
  2350. }
  2351. }
  2352. } catch {
  2353. /* 坏 JSON / 隐私模式:降级为内存态 */
  2354. }
  2355. this.demoHistory = map;
  2356. return map;
  2357. }
  2358. private demoRestored(sessionId: string): Message[] {
  2359. return this.demoStore()?.get(sessionId) ?? [];
  2360. }
  2361. /** 演示态消息写入(发送/接收后调用,幂等覆盖) */
  2362. private rememberDemoHistory(): void {
  2363. const store = this.demoStore();
  2364. const sid = this._sessionId();
  2365. if (!store || !sid) return;
  2366. store.set(sid, [...this._messages()]);
  2367. try {
  2368. localStorage.setItem(ChatService.DEMO_HISTORY_KEY, JSON.stringify([...store.entries()]));
  2369. } catch {
  2370. /* 配额/隐私模式:只保留内存态 */
  2371. }
  2372. }
  2373. private seedTs(time: string): string {
  2374. const today = new Date();
  2375. const m = /^(\d{2}):(\d{2})$/.exec(time);
  2376. if (m) {
  2377. today.setHours(Number(m[1]), Number(m[2]), 0, 0);
  2378. return today.toISOString();
  2379. }
  2380. const y = new Date(today);
  2381. y.setDate(today.getDate() - 1);
  2382. const m2 = /昨天\s*(\d{2}):(\d{2})/.exec(time);
  2383. if (m2) y.setHours(Number(m2[1]), Number(m2[2]), 0, 0);
  2384. return y.toISOString();
  2385. }
  2386. }
  2387. /** 统一错误文案提取 */
  2388. function msgOf(e: unknown): string {
  2389. return e instanceof Error && e.message ? e.message : '未知错误';
  2390. }