chat.service.ts 178 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239224022412242224322442245224622472248224922502251225222532254225522562257225822592260226122622263226422652266226722682269227022712272227322742275227622772278227922802281228222832284228522862287228822892290229122922293229422952296229722982299230023012302230323042305230623072308230923102311231223132314231523162317231823192320232123222323232423252326232723282329233023312332233323342335233623372338233923402341234223432344234523462347234823492350235123522353235423552356235723582359236023612362236323642365236623672368236923702371237223732374237523762377237823792380238123822383238423852386238723882389239023912392239323942395239623972398239924002401240224032404240524062407240824092410241124122413241424152416241724182419242024212422242324242425242624272428242924302431243224332434243524362437243824392440244124422443244424452446244724482449245024512452245324542455245624572458245924602461246224632464246524662467246824692470247124722473247424752476247724782479248024812482248324842485248624872488248924902491249224932494249524962497249824992500250125022503250425052506250725082509251025112512251325142515251625172518251925202521252225232524252525262527252825292530253125322533253425352536253725382539254025412542254325442545254625472548254925502551255225532554255525562557255825592560256125622563256425652566256725682569257025712572257325742575257625772578257925802581258225832584258525862587258825892590259125922593259425952596259725982599260026012602260326042605260626072608260926102611261226132614261526162617261826192620262126222623262426252626262726282629263026312632263326342635263626372638263926402641264226432644264526462647264826492650265126522653265426552656265726582659266026612662266326642665266626672668266926702671267226732674267526762677267826792680268126822683268426852686268726882689269026912692269326942695269626972698269927002701270227032704270527062707270827092710271127122713271427152716271727182719272027212722272327242725272627272728272927302731273227332734273527362737273827392740274127422743274427452746274727482749275027512752275327542755275627572758275927602761276227632764276527662767276827692770277127722773277427752776277727782779278027812782278327842785278627872788278927902791279227932794279527962797279827992800280128022803280428052806280728082809281028112812281328142815281628172818281928202821282228232824282528262827282828292830283128322833283428352836283728382839284028412842284328442845284628472848284928502851285228532854285528562857285828592860286128622863286428652866286728682869287028712872287328742875287628772878287928802881288228832884288528862887288828892890289128922893289428952896289728982899290029012902290329042905290629072908290929102911291229132914291529162917291829192920292129222923292429252926292729282929293029312932293329342935293629372938293929402941294229432944294529462947294829492950295129522953295429552956295729582959296029612962296329642965296629672968296929702971297229732974297529762977297829792980298129822983298429852986298729882989299029912992299329942995299629972998299930003001300230033004300530063007300830093010301130123013301430153016301730183019302030213022302330243025302630273028302930303031303230333034303530363037303830393040304130423043304430453046304730483049305030513052305330543055305630573058305930603061306230633064306530663067306830693070307130723073307430753076307730783079308030813082308330843085308630873088308930903091309230933094309530963097309830993100310131023103310431053106310731083109311031113112311331143115311631173118311931203121312231233124312531263127312831293130313131323133313431353136313731383139314031413142314331443145314631473148314931503151315231533154315531563157315831593160316131623163316431653166316731683169317031713172317331743175317631773178317931803181318231833184318531863187318831893190319131923193319431953196319731983199320032013202320332043205320632073208320932103211321232133214321532163217321832193220322132223223322432253226322732283229323032313232323332343235323632373238323932403241324232433244324532463247324832493250325132523253325432553256325732583259326032613262326332643265326632673268326932703271327232733274327532763277327832793280328132823283328432853286328732883289329032913292329332943295329632973298329933003301330233033304330533063307330833093310331133123313331433153316331733183319332033213322332333243325332633273328332933303331333233333334333533363337333833393340334133423343334433453346334733483349335033513352335333543355335633573358335933603361336233633364336533663367336833693370337133723373337433753376337733783379338033813382338333843385338633873388338933903391339233933394339533963397339833993400340134023403340434053406340734083409341034113412341334143415341634173418341934203421342234233424342534263427342834293430343134323433343434353436343734383439344034413442344334443445344634473448344934503451345234533454345534563457345834593460346134623463346434653466346734683469347034713472347334743475347634773478347934803481348234833484348534863487348834893490349134923493349434953496349734983499350035013502350335043505350635073508350935103511351235133514351535163517351835193520352135223523352435253526352735283529353035313532353335343535353635373538353935403541354235433544354535463547354835493550355135523553355435553556355735583559356035613562356335643565356635673568356935703571357235733574357535763577357835793580358135823583358435853586358735883589359035913592359335943595359635973598359936003601360236033604360536063607360836093610361136123613361436153616361736183619362036213622362336243625362636273628362936303631363236333634363536363637363836393640364136423643364436453646364736483649365036513652365336543655365636573658365936603661366236633664366536663667366836693670367136723673367436753676367736783679368036813682368336843685368636873688368936903691369236933694369536963697369836993700370137023703370437053706370737083709371037113712371337143715371637173718371937203721372237233724372537263727372837293730373137323733373437353736373737383739374037413742374337443745374637473748374937503751375237533754375537563757375837593760376137623763376437653766376737683769377037713772377337743775377637773778377937803781378237833784378537863787378837893790379137923793379437953796379737983799380038013802380338043805380638073808380938103811381238133814381538163817381838193820382138223823382438253826382738283829383038313832383338343835383638373838383938403841384238433844384538463847384838493850385138523853385438553856385738583859386038613862386338643865386638673868386938703871387238733874387538763877387838793880388138823883388438853886388738883889389038913892389338943895389638973898389939003901390239033904390539063907390839093910391139123913391439153916391739183919392039213922392339243925392639273928392939303931393239333934393539363937393839393940394139423943394439453946394739483949395039513952395339543955395639573958395939603961396239633964396539663967396839693970397139723973397439753976397739783979398039813982398339843985398639873988398939903991399239933994399539963997399839994000400140024003
  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 { AlertService } from './alert.service';
  15. import { extractDeliveredLinks, extractS3Links, isCloudUrl, isUserUploadUrl, prettifyStorageKey, stripLinks } from './s3-link';
  16. import { isOversize, notifyOversize, oversizeMessage } from './upload-limits';
  17. import { batchPhase, FileProgress, overallPct, UploadPhase } from './upload-state';
  18. import { currentSendMode } from './send-mode';
  19. import { environment } from '../../environments/environment';
  20. import { LiveService } from './live.service';
  21. import { SessionService } from './session.service';
  22. import { UnreadService } from './unread.service';
  23. import {
  24. CLOUD_URL_KEY,
  25. DISPATCH_KEY,
  26. LOCAL_ID_KEY,
  27. MxDispatchMeta,
  28. MxMessage,
  29. MatrixClientService,
  30. SESSION_KEY,
  31. } from './matrix-client.service';
  32. /** `sendToAgent` 的结果(2026-11-06 由 boolean 升级为对象) */
  33. export interface DispatchResult {
  34. ok: boolean;
  35. /** 派活消息自身的 eventId(服务端确认后才有) */
  36. eventId?: string;
  37. /**
  38. * 发送**前**该房间最后一条消息的 eventId —— 群聊补读的锚点。
  39. *
  40. * 为 null 表示 timeline 尚未就绪,补读退化为宽限时间窗。
  41. */
  42. baselineEventId: string | null;
  43. roomId?: string;
  44. error?: string;
  45. }
  46. import { ChatSession, deriveTitle, isAppSessionId, placeholderSession } from './chat-session';
  47. import { ChatSessionService } from './chat-session.service';
  48. import { RelayService } from './relay.service';
  49. import { TaskPulseService } from './task-pulse.service';
  50. import { sessionRunning, sessionStateOf } from './agent-state';
  51. import { resolveSessionPhase } from './agent-liveness';
  52. export type ConnectionState = 'connecting' | 'connected' | 'disconnected';
  53. /**
  54. * 待发送附件。
  55. *
  56. * `previewUrl` 是本地对象地址(仅当前页面有效),
  57. * **在暂存时生成一次并缓存** —— 模板里每次变更检测重新生成会造成泄漏。
  58. */
  59. export interface PendingFile {
  60. id: string;
  61. file: File;
  62. previewUrl?: string;
  63. /**
  64. * 上传进度与阶段(2026-11-11)。
  65. *
  66. * 由 `uploadPending()` 在三个阶段间推进,UI 据此显示
  67. * 「上传中 42%」/「登记中…」/「发送中…」。未开始为 `undefined`。
  68. */
  69. progress?: FileProgress;
  70. /**
  71. * 已完成的云盘上传结果(2026-11-13 评审 S3)。
  72. *
  73. * 上传 + 登记成功、但**发事件失败**时保留在暂存项上:重试直接复用,
  74. * 否则会再传一份、再登记一条台账,云盘上留孤儿对象、文件列表里出现重复项。
  75. */
  76. uploaded?: { url: string; key: string; registered: boolean };
  77. }
  78. /**
  79. * 会话服务 —— 单个会话的状态机
  80. *
  81. * 职责:provision → 连接 → 房间 → 收发 → 分页,对应 SPEC FR-04 各条 AC。
  82. *
  83. * ## 两条分支(由 `environment.useMockData` 单开关决定)
  84. *
  85. * - **演示态**:用 `DEMO_MESSAGES` 填充,发送走 `simulateSend()`(不产生真实请求)
  86. * - **真实态**:`provisionMatrix` 取服务端签发凭据 → `MatrixClientService.connect()`
  87. * → `ensureRoom()` → `/sync` 实时收发
  88. *
  89. * ⚠️ **本文件不直接 import `matrix-js-sdk`**:SDK 调用收敛在 `MatrixClientService`
  90. * (懒加载 chunk,不进主包 —— NFR-02)。本服务只做状态编排与消息模型归一。
  91. *
  92. * ## 凭据来源(黄金规则 9)
  93. *
  94. * 真实态下 accessToken **一律来自 provision 云函数**(服务端签发);
  95. * 前端不派生任何密码,也不持有任何长期密钥。
  96. */
  97. @Injectable({ providedIn: 'root' })
  98. export class ChatService {
  99. /**
  100. * 建房重试的退避间隔(毫秒)。
  101. *
  102. * 提成静态字段是为了让单测能置空 —— 否则每个重试用例要真等 11 秒。
  103. * 生产值:失败一次等 3s,再失败等 8s,共 3 次尝试。
  104. */
  105. static ROOM_BACKOFF_MS: readonly number[] = [3_000, 8_000];
  106. /**
  107. * 建房最多尝试几次(含首次)。
  108. *
  109. * 🔴 2026-11-13 从 3 调到 2:配合 12 秒单次等待,
  110. * 最坏 12+3+12 = 27 秒。再往上加会让「新建对话」变成
  111. * 用户无法忍受的长等待(已经踩过 100 秒的坑)。
  112. */
  113. static ROOM_MAX_ATTEMPTS = 2;
  114. private readonly api = inject(FmodeApi);
  115. private readonly live = inject(LiveService);
  116. private readonly session = inject(SessionService);
  117. private readonly mx = inject(MatrixClientService);
  118. private readonly unread = inject(UnreadService);
  119. private readonly pulse = inject(TaskPulseService);
  120. /**
  121. * 全局提示(2026-11-11)。用于「上传超限」这类**用户必须立刻知道**的反馈。
  122. * `_error` 只在消息区错误空态渲染,正常会话里等于静默,不能作为唯一出口。
  123. */
  124. private readonly alerts = inject(AlertService);
  125. /**
  126. * 会话列表服务(公开只读入口)。
  127. *
  128. * 页面需要它来渲染左侧「容器 → 对话」列表;单测也通过这里取,
  129. * 以保证读到的是**本服务实际使用的那一个实例**。
  130. */
  131. readonly sessions = inject(ChatSessionService);
  132. /** 容器中转(让 A 去问 B) */
  133. private readonly relay = inject(RelayService);
  134. private readonly _agentId = signal<string | null>(null);
  135. /**
  136. * roomId → 最近一次群聊派活的锚点 eventId(2026-11-06)。
  137. *
  138. * 用途:容器的回复不带派活标记,但一定排在派活消息之后 ——
  139. * 用它把「派活之后的房间级消息」挡在单聊会话之外。
  140. * 判定基于 timeline 下标而非时间戳,容器时钟不准也不影响。
  141. */
  142. private readonly _dispatchAnchor = signal<Map<string, { eventId: string }>>(new Map());
  143. /**
  144. * 当前打开的会话(线程根 event_id)。
  145. *
  146. * 🔴 容器 `build_session_key` 按 `room + thread` 划分会话
  147. * (`/opt/hermes/gateway/session.py:654`)——**不带 thread 的每条消息
  148. * 都会被切成新会话**(`MATRIX_AUTO_THREAD` 默认 true)。
  149. * 所以本字段是所有收发的前提:发要带它,收要按它过滤。
  150. */
  151. private readonly _sessionId = signal<string | null>(null);
  152. private readonly _messages = signal<Message[]>([]);
  153. private readonly _state = signal<LoadState>('idle');
  154. private readonly _error = signal<string | null>(null);
  155. private readonly _connection = signal<ConnectionState>('disconnected');
  156. private readonly _sending = signal(false);
  157. private readonly _uploading = signal(false);
  158. /*
  159. * 待发送的附件(本地暂存)。
  160. *
  161. * 🔴 用户要求:**先选文件、点发送时才一起发出去**,而不是选完立刻上传。
  162. * 所以这里只持有 File 对象,不上传、不发消息 —— 发送时才落地。
  163. */
  164. private readonly _pendingFiles = signal<PendingFile[]>([]);
  165. /**
  166. * 上传总进度 0-100(2026-11-11 改为**按字节加权实时计算**)。
  167. *
  168. * 原来是个手动 `set` 的信号,只在「一个文件全部走完」时跳 `(i+1)/N*100`,
  169. * 单个大文件期间恒为 0。现在由各文件自己的 `progress` 派生,
  170. * 传 20MB 文件时能看到 1% → 50% → 99% 连续推进。
  171. */
  172. private readonly _uploadPct = signal(0);
  173. /** 整批当前主导阶段(决定输入框下方那一条显示什么) */
  174. private readonly _uploadPhase = signal<UploadPhase>('idle');
  175. private readonly _draft = signal('');
  176. private readonly _peerTyping = signal(false);
  177. /**
  178. * 容器发 `m.typing=false` 的**本地接收时刻**(epoch ms,0 = 本次会话还没收到过)。
  179. *
  180. * 2026-11-11:容器侧插件在 `post_llm_call` 发 `m.typing=false` 当作「本轮结束」信号。
  181. * App 侧据此立刻收尾,不再死等 25/60 秒静默窗口。
  182. */
  183. private readonly _turnEndedAt = signal(0);
  184. readonly turnEndedAt = this._turnEndedAt.asReadonly();
  185. /**
  186. * 收到 `typing=false` 那一刻,本会话**最后一条 agent 消息的 id**(2026-11-11)。
  187. *
  188. * 🔴 为什么不能只存时刻、靠比较时间戳:
  189. *
  190. * 消息的 `ts` 来自 **Matrix 服务端**(`origin_server_ts`),而信号记的是
  191. * **本地时钟**。两台机器差 1~2 秒是常态 —— 服务端稍快时,
  192. * 「信号时刻 ≥ 末条消息时刻」永远不成立,结束信号**彻底失效**;
  193. * 服务端稍慢时,又会把上一轮的信号误认成这一轮的。
  194. *
  195. * 所以改成**按消息 id 认领**:信号到达时记下此刻的末条 agent 消息 id,
  196. * 回合判定时只问一句「本块的末条消息是不是就是这一条」—— 同一份数据内部比较,
  197. * 不跨时钟,天然免疫偏差。
  198. */
  199. private readonly _turnEndedAfter = signal<string | null>(null);
  200. readonly turnEndedAfter = this._turnEndedAfter.asReadonly();
  201. /**
  202. * 用户**已发出、尚未收到任何回复**的时刻(epoch ms,0 = 没有在等)。
  203. *
  204. * ## 为什么需要它(2026-11-14 用户实测修复)
  205. *
  206. * 用户点发送后到容器真正开始跑之间有一段**真空期**,此时所有远端信号都是旧值:
  207. * · `turnEnded` —— 上一轮的 ✅ 已在发消息时清掉 → false
  208. * · `sessionRunning` —— 来自 15 秒轮询,新消息还没被标记 → 仍是上一轮的 false
  209. * · `typing` —— 容器还没开始打字 → false
  210. *
  211. * 于是 `sessionBusy = false`,用户**立刻**再发一条时不会被拦截,
  212. * 直接发出去打断了容器(用户实测:「发完之后立刻发新的,他直接发出去了」)。
  213. *
  214. * 这个信号是**本地事实**,不依赖任何轮询或容器上报:
  215. * · 发出消息时置为当前时刻
  216. * · 收到本轮第一条 agent 消息 / ✅ 时清零
  217. *
  218. * `sessionBusy` 只要看到它非 0 就直接判忙 —— 真空期被填上了。
  219. */
  220. private readonly _awaitingReplySince = signal(0);
  221. readonly awaitingReplySince = this._awaitingReplySince.asReadonly();
  222. /**
  223. * 「文件名|大小」→ 云盘地址(2026-11-10)。
  224. *
  225. * 用户附件发送时 Matrix 与 S3 各存一份:Matrix 那份给容器读,
  226. * S3 那份给 App 长期查看(换设备、跨端)。但回读的 `m.image` 事件里
  227. * 只有 `mxc://`,没有云盘地址 —— 用这张表在回读时补回 `cloudUrl`。
  228. * 仅本进程有效;缺失时附件照常可看(走 Matrix 媒体),不影响发送。
  229. */
  230. private readonly _cloudUrls = new Map<string, string>();
  231. /** 云盘地址映射的键:同名同大小的文件视为同一个(Matrix echo 不带本地 id) */
  232. private cloudKeyOf(name: string, size: number): string {
  233. return `${name}|${size}`;
  234. }
  235. /** 正在加载更早历史(防重入,AC-04.4) */
  236. private readonly _paginating = signal(false);
  237. /** 是否可能还有更早历史 */
  238. private readonly _hasMoreHistory = signal(false);
  239. /**
  240. * 当前会话「为什么是空的」(2026-11-09)。
  241. *
  242. * - `'fresh'`:真的没有消息 —— 新建对话,显示引导页
  243. * - `'unloaded'`:房间里有消息,但一条都归属不到这个会话 —— 显示「没有可加载的消息」
  244. * - `null`:不空
  245. *
  246. * 🔴 不区分的话,一个历史取不到的旧会话会显示成「下午好,我是 xxx」,
  247. * 用户以为会话还在、内容丢了,实际是被归属规则挡住了(实测)。
  248. *
  249. * `'blank'`(2026-11-12):**已翻到房间最早**、目录里有这条、但 Matrix 里
  250. * 一条消息都没有 —— 这是「空壳会话」,不是「暂时取不到」。
  251. * 说成「暂时」会让用户一直等、以为数据丢了(实测「测试9680」)。
  252. */
  253. private readonly _emptyReason = signal<'fresh' | 'unloaded' | 'blank' | 'removed' | null>(null);
  254. readonly emptyReason = this._emptyReason.asReadonly();
  255. /**
  256. * 本次打开会话时,是否已经把**整个房间**的历史翻到底(2026-11-12)。
  257. *
  258. * 🔴 不能直接看 `paginationToken(rid) == null` 来判断:
  259. * 回溯有轮数上限(50 轮 × 100 条),大房间(lj-test 已超 5000 事件)
  260. * 翻满上限时 token 仍然存在,但**我们并没有真的翻完**。
  261. * 只有这个字段为 true,才能断定「房间翻遍了、确实没有这条会话的消息」。
  262. */
  263. private _reachedRoomStart = false;
  264. readonly agentId = this._agentId.asReadonly();
  265. /** 当前会话 id(线程根) */
  266. readonly sessionId = this._sessionId.asReadonly();
  267. /** 当前会话元信息(标题/预览/归档状态) */
  268. readonly currentSession = computed<ChatSession | undefined>(() => {
  269. const id = this._sessionId();
  270. if (!id) return undefined;
  271. const table = this.sessions.byId(id);
  272. if (table) return table;
  273. /*
  274. * 🔴 会话目录里没有这条时,仍然给出一个**占位**(2026-11-06)。
  275. *
  276. * `_sessionId` 在发消息时就已落到线程根,而会话目录要等云端目录刷新
  277. * 才会出现这一条。两者不同步的空档里(刷新后首次发消息、刚建的新会话)
  278. * 原来返回 undefined —— 聊天页据此隐藏页头、右栏与整个输入区,
  279. * 用户看到的是「输入框没了、只剩一条告警」。条目到达后自动换成真身。
  280. */
  281. return placeholderSession(id, this._agentId() ?? '');
  282. });
  283. readonly messages = this._messages.asReadonly();
  284. readonly state = this._state.asReadonly();
  285. readonly error = this._error.asReadonly();
  286. readonly connection = this._connection.asReadonly();
  287. readonly sending = this._sending.asReadonly();
  288. readonly uploading = this._uploading.asReadonly();
  289. /** 待发送附件(界面据此渲染缩略条与移除按钮) */
  290. readonly pendingFiles = this._pendingFiles.asReadonly();
  291. readonly uploadPct = this._uploadPct.asReadonly();
  292. /** 整批上传阶段(输入框下方那条进度条据此切文案,2026-11-11) */
  293. readonly uploadPhase = this._uploadPhase.asReadonly();
  294. readonly draft = this._draft.asReadonly();
  295. /** 对方正在输入(真实态由 typing 事件驱动) */
  296. readonly peerTyping = this._peerTyping.asReadonly();
  297. /**
  298. * 容器**明确上报**自己闲下来了(没有在跑任务)。
  299. *
  300. * 供回合卡片判断「还在输出」用(2026-11-08):
  301. * 容器说空闲 → 立刻收尾;容器在跑或状态未知 → 依静默窗口判定。
  302. * 只有容器能宣布「干完了」,前端不替它下结论。
  303. */
  304. /**
  305. * 容器**明确**上报的「本会话空闲」(2026-11-11 统一方案)。
  306. *
  307. * 🔴 三态语义,不能用布尔把「不知道」和「空闲」混在一起:
  308. *
  309. * | 值 | 含义 | 用途 |
  310. * |---|---|---|
  311. * | `true` | 容器明确说本会话 idle | **直接收尾**(与 ✅ 同级的事实信号) |
  312. * | `false` | 容器明确说本会话在跑 | 保持输出中 |
  313. * | `undefined` | 容器没上报 / 不知道该查哪条 | **不参与判定**,交给其他信号 |
  314. *
  315. * 初版把 `undefined` 也当成「空闲」返回 `true`,会让**所有**回合被
  316. * `settledBySession` 判成已结束 —— 那等于把「不知道」当成了「干完了」。
  317. *
  318. * 另外:**不再回落容器全局 `status`**。它是「一个容器一个值」,
  319. * 会话 A 在跑时会话 B 也被判成不空闲,正是「串台」的根源。
  320. */
  321. readonly agentIdle = computed<boolean | undefined>(() => {
  322. const id = this._agentId();
  323. if (!id) return undefined;
  324. const agent = this.live.agentById(id);
  325. if (!agent) return undefined;
  326. const running = sessionRunning(
  327. agent.currentSessions,
  328. this._sessionId(),
  329. this.roomId,
  330. this.myMxId(),
  331. Date.now(),
  332. /* 刚发出的会话,上一轮留下的 idle 不算数(2026-11-12,见 sessionRunning 注释) */
  333. this._sessionId() ? this.pulse.sessionLocalStartAt(this._sessionId()!) : undefined,
  334. );
  335. return running === undefined ? undefined : !running;
  336. });
  337. /**
  338. * 本会话当前在做什么(2026-11-11)。
  339. *
  340. * 容器在 `currentSessions[].task` 里上报本轮提问摘要。
  341. * 没有就返回 undefined —— 顶栏退回通用文案「正在处理」,
  342. * **绝不拿容器级 `currentTask` 冒充本会话**(那是串台的根源)。
  343. */
  344. readonly sessionTask = computed<string | undefined>(() => {
  345. const id = this._agentId();
  346. if (!id) return undefined;
  347. const agent = this.live.agentById(id);
  348. if (!agent) return undefined;
  349. const s = sessionStateOf(agent.currentSessions, this._sessionId(), this.roomId, this.myMxId());
  350. return s?.status === 'running' ? s.task : undefined;
  351. });
  352. /**
  353. * 本轮是否**已在本地收到结束信号**(✅/❌ reaction,2026-11-11)。
  354. *
  355. * 🔴 这是解决「各处状态不同步」的关键:
  356. *
  357. * | 界面 | 原来的数据源 | 延迟 |
  358. * |---|---|---|
  359. * | 回合卡片 | ✅ reaction(实时) | 0 |
  360. * | 右上角 / 左侧列表 | 容器 `currentSessions`(15 秒轮询) | 最坏 15 秒 |
  361. *
  362. * 用户实测:「正文都输出完了,右上角和左侧还挂着加载动画」。
  363. * 因为卡片已经按 reaction 收尾,另两处还在等轮询。
  364. *
  365. * 现在三处共用这一个信号:**reaction 一到,所有界面立刻收尾**,
  366. * 不必等容器上报。容器上报到达后自然会确认这个结论,不冲突。
  367. */
  368. readonly turnSettledLocally = computed(() => this._turnEndedAfter() !== null);
  369. /**
  370. * 本会话运行态的**原始信号包**(2026-11-11 统一方案)。
  371. *
  372. * 各界面不再各自拼信号,一律把它交给 `resolveSessionPhase()` 裁决。
  373. * 见该函数的注释:多源冲突是「显示错」的根源。
  374. */
  375. readonly sessionSignals = computed(() => {
  376. const id = this._agentId();
  377. const agent = id ? this.live.agentById(id) : undefined;
  378. return {
  379. turnEnded: this._turnEndedAfter() !== null,
  380. sessionRunning: agent
  381. ? sessionRunning(agent.currentSessions, this._sessionId(), this.roomId, this.myMxId())
  382. : undefined,
  383. typing: this._peerTyping(),
  384. };
  385. });
  386. /** 正在加载更早历史 */
  387. readonly paginating = this._paginating.asReadonly();
  388. /** 是否可能还有更早历史(真实态:上次分页仍有返回) */
  389. readonly hasMoreHistory = this._hasMoreHistory.asReadonly();
  390. /**
  391. * 本会话是否**正在执行**(容器在跑这一轮)。
  392. *
  393. * 用于「等待中」拦截:容器 `busy_input_mode=interrupt`,
  394. * 直接发会把正在跑的任务打断,所以要先让用户确认。
  395. */
  396. readonly sessionBusy = computed(() => {
  397. /*
  398. * 🔴 本地「已发出、尚未收到回复」优先判忙(2026-11-14 修复)。
  399. *
  400. * 填补「发出消息 → 容器开始跑」之间的真空期:那段时间所有远端信号
  401. * 都还是上一轮的旧值,`resolveSessionPhase` 会判成空闲,
  402. * 导致用户立刻再发一条时不被拦截(用户实测)。
  403. */
  404. if (this._awaitingReplySince() > 0) return true;
  405. const s = this.sessionSignals();
  406. return resolveSessionPhase({ ...s, turnLive: false }).busy;
  407. });
  408. /**
  409. * 待发消息(2026-11-13 用户设计)。
  410. *
  411. * ## 为什么有它
  412. *
  413. * 容器配置 `busy_input_mode=interrupt` —— 新消息到达会**打断当前 turn**。
  414. * 直接发出去的话,用户在长任务中途随手补一句,前面的活全白做。
  415. *
  416. * 但反过来用 `queue` 又不行:用户「改主意」时得干等几分钟。
  417. *
  418. * ## 折中方案(用户裁决)
  419. *
  420. * 本会话正在执行时,新消息**不立即发出**,而是先存为待发:
  421. * · 输入框上方显示「上一条还在执行 · [撤回] [打断发送]」
  422. * · 点「打断发送」→ 立刻发出(容器 interrupt 生效)
  423. * · 点「撤回」→ 丢弃
  424. * · **都不点** → 当前任务结束后自动发出
  425. */
  426. private readonly _heldMessage = signal<{ text: string; at: number } | null>(null);
  427. /** 待发消息(null = 没有)。UI 据此渲染提示条 */
  428. readonly heldMessage = this._heldMessage.asReadonly();
  429. /**
  430. * 撤回:把待发内容**还回输入框**,用户可继续编辑(2026-11-13 用户裁决)。
  431. *
  432. * 与「删除」的区别:撤回是「先不发了,我再改改」,删除是「这条不要了」。
  433. */
  434. withdrawHeld(): void {
  435. const held = this._heldMessage();
  436. if (!held) return;
  437. this._heldMessage.set(null);
  438. /* 还回草稿,用户能接着改;若他已有新草稿则不覆盖(避免吞掉刚打的字) */
  439. if (!this._draft().trim()) this._draft.set(held.text);
  440. }
  441. /** 删除:直接丢弃这条待发内容,不回填输入框(2026-11-13 用户裁决) */
  442. discardHeld(): void {
  443. this._heldMessage.set(null);
  444. }
  445. /** 确认发送:立即发出并打断当前任务(2026-11-13 用户裁决) */
  446. async sendHeldNow(): Promise<void> {
  447. const held = this._heldMessage();
  448. if (!held) return;
  449. this._heldMessage.set(null);
  450. await this.forceSendText(held.text);
  451. }
  452. readonly agent = computed<Agent | undefined>(() => {
  453. const id = this._agentId();
  454. return id ? this.live.agentById(id) : undefined;
  455. });
  456. /** 序号防串扰(存量 seq 模式):切会话时丢弃过期回包 */
  457. private seq = 0;
  458. /**
  459. * 「新建对话」待建态指定的工作区(会话建出来后才落地)。
  460. * `null` = 日常对话。只在 `startBlank()` 到首次发消息之间有效。
  461. */
  462. private _blankWorkspace: string | null = null;
  463. /**
  464. * 「新建态」会话 id(2026-11-08)。
  465. *
  466. * 🔴 这是**比 `_state === 'empty'` 更可靠**的新建态判据。
  467. *
  468. * 原保护失效的原因:`sendText` 在 `commit()` 异步完成之前就执行了
  469. * `_state.set('ready')`,等 `storeVersion` effect 触发时状态已是 ready,
  470. * 于是旧历史被整表回填进新会话(用户实测:新建后上方出现昨天的对话)。
  471. *
  472. * 这里单独记一个 id:只要当前会话仍是它,effect 一律不回填;
  473. * 直到 `sendText` 的 `commit` 成功(云端确实有了这条会话)才清除。
  474. */
  475. private _blankSessionId: string | null = null;
  476. /**
  477. * 「新建会话」进行中的 Promise(2026-11-11)。
  478. *
  479. * 🔴 解决「新建后立刻发首条消息,消息不显示」(用户实测)。
  480. *
  481. * 时间线:
  482. * 1. 用户点「新建对话」→ `startBlank()` 开始跑
  483. * 2. 里面 `await this.open(agentId)`(provision + Matrix 连接,可能要等几秒)
  484. * 3. 用户等不及,直接输入并按回车 → `sendText()`
  485. * 4. 此时 `_agentId()` **还没设好** → `sendText` 在入口处 `return`
  486. * → 文字不清空、消息不出现,用户以为「按了没反应」
  487. *
  488. * 现在:`sendText` 检测到新建在途时**等它跑完再继续**,
  489. * 而不是静默丢弃。用户最多多等那一两秒,但消息一定会发出去。
  490. */
  491. private _blankInFlight: Promise<unknown> | null = null;
  492. private provision: MatrixProvision | null = null;
  493. /**
  494. * 当前登录用户在 Matrix 的 mxid(2026-11-11)。
  495. *
  496. * 供会话级状态匹配用:容器可能被多人共享,`currentSessions` 里会有别人的会话,
  497. * 需要 `userId` 才能区分「谁在跑」。见 `agent-state.ts::sessionStateOf()`。
  498. */
  499. readonly myMxId = computed(() => this.provision?.userMxId ?? null);
  500. /** 真实态下的当前房间 id */
  501. private roomId: string | null = null;
  502. /**
  503. * 当前房间 id 的只读视图(2026-11-11)。
  504. *
  505. * 供会话级状态匹配用:容器上报的 `sessionKey` 可能只含 roomId
  506. * (DM 通道 `thread_id` 常为 NULL,实测 46%),此时需要用房间号对齐。
  507. * 见 `agent-state.ts::sessionStateOf()` 的三级匹配。
  508. */
  509. readonly currentRoomId = (): string | null => this.roomId;
  510. /** 事件订阅解绑函数(切会话必须调用,防串扰) */
  511. private unbindMessage: (() => void) | null = null;
  512. private unbindTyping: (() => void) | null = null;
  513. /** 容器 ✅/❌ reaction 订阅解绑(2026-11-11,本轮结束信号) */
  514. private unbindTurnReaction: (() => void) | null = null;
  515. /** 容器推送的回合状态事件(2026-11-12,方案 2) */
  516. private unbindTurnStatus: (() => void) | null = null;
  517. /** 全局未读监听解绑(仅登出/销毁时调用) */
  518. private unbindUnread: (() => void) | null = null;
  519. /**
  520. * agentId → roomId 映射(未读归属反查)。
  521. *
  522. * 🔴 **持久化**(localStorage,按用户隔离):未读计数需要「某房间的新消息属于哪个
  523. * Agent」,而 roomId 只有在**打开过该会话**后才由 provision 返回。
  524. * 不持久化的话,每次刷新都会丢失映射 → 只有本次打开过的会话能计未读。
  525. *
  526. * ⚠️ **已知限制**:从未打开过的 Agent 会话,其消息不计未读
  527. * (因为拿不到 roomId ↔ agentId 的对应关系)。属可接受折损,已在文档披露。
  528. */
  529. private readonly roomAgentMap = new Map<string, string>();
  530. get provisionInfo(): MatrixProvision | null {
  531. return this.provision;
  532. }
  533. constructor() {
  534. /*
  535. * 🔴 已移除 `armHeldAutoSend()`(2026-11-13 用户裁决)。
  536. *
  537. * 原行为:忙碌时挂起的消息,等任务结束**自动发出**。
  538. * 用户要求改成:**不点就不发** —— 由用户显式选择
  539. * 【确认发送】/【撤回】/【删除】三选一。
  540. *
  541. * 理由:自动发会让用户失去对「是否打断」的控制权,
  542. * 而且提示条上的「打断发送」按钮在自动发模式下语义混乱。
  543. */
  544. /*
  545. * 🔴 会话转为空闲 → 自动清除持有条(2026-11-14 用户实测修复)。
  546. *
  547. * ## 问题
  548. *
  549. * 截图实证:会话早已结束、成果都出来了,输入框上方仍挂着
  550. * 「任务执行中 · 确认发送将中断当前任务」的持有条。
  551. *
  552. * ## 根因
  553. *
  554. * 「不点不发」改动移除了 `armHeldAutoSend()`(空闲后自动发),
  555. * **但没加替代的清理机制** —— `_heldMessage` 一旦写入就再没人清,
  556. * 用户不点任何按钮它就永远挂着。
  557. *
  558. * ## 现在的行为
  559. *
  560. * 会话空闲时把持有条**自动撤回**(内容回到草稿),而不是自动发出。
  561. * 这与用户「不点就不发」的裁决一致:不发,但要有个收尾。
  562. *
  563. * 若草稿已有内容(用户又打了别的),不覆盖它 —— 旧的持有内容直接丢弃,
  564. * 因为用户显然已经在写新的了。
  565. */
  566. effect(() => {
  567. const busy = this.sessionBusy();
  568. const held = untracked(() => this._heldMessage());
  569. if (busy || !held) return;
  570. this._heldMessage.set(null);
  571. if (!untracked(() => this._draft()).trim()) this._draft.set(held.text);
  572. });
  573. /**
  574. * 🔴 连接状态跟随 SDK 的 sync 状态(FR-04 AC-04.9)。
  575. *
  576. * Bug 背景:原实现只在 `openReal()` 末尾**读一次** `mx.syncState()`:
  577. * `this._connection.set(this.mx.syncState() === 'connected' ? ...)`
  578. * 但 `/sync` 是**异步**的 —— 那一刻必然还是 `connecting`,之后没有任何代码更新,
  579. * 于是 UI **永久停在「正在连接」**(实测:SDK 已 SYNCING,页面仍显示连接中)。
  580. *
  581. * 修复:用 `effect` 订阅 `mx.syncState()` signal,状态变化时自动同步。
  582. * 仅在已打开会话时生效(未打开会话不干预 `close()` 后的复位)。
  583. */
  584. effect(() => {
  585. const s = this.mx.syncState();
  586. if (!this._agentId()) return;
  587. this._connection.set(
  588. s === 'connected' ? 'connected' : s === 'connecting' ? 'connecting' : 'disconnected',
  589. );
  590. });
  591. /**
  592. * 🔴 同步完成后重读 timeline(修复「重开会话内容空白」)。
  593. *
  594. * `openReal()` 首读 timeline 时,`/sync` 往往还没把历史事件写入本地 store
  595. * → 读到空数组。此前只有 realtime 事件能把消息补进来,历史内容永远缺失。
  596. * 这里订阅 store 版本号:每次 sync 落地后重新映射一次 timeline
  597. * (已读消息按 eventId 去重,不会重复)。
  598. */
  599. effect(() => {
  600. this.mx.storeVersion();
  601. const rid = this.roomId;
  602. const agentId = this._agentId();
  603. if (!rid || !agentId) return;
  604. const myUserId = this.provision?.userMxId ?? '';
  605. const raw = this.mx.timelineToMessages(rid);
  606. if (raw.length === 0) return;
  607. /*
  608. * 🔴 历史到达时**重新确定会话**(多会话)。
  609. *
  610. * 首次打开时 sync 未完成,`openReal` 读到的 timeline 是空的 →
  611. * 那时判不出「有没有无 thread 旧消息」→ 「历史对话」建不起来。
  612. * 等历史真正到达后必须再判一次,否则旧消息永远无处显示(实测踩坑)。
  613. *
  614. * 历史到达后只需补建「历史对话」会话,**不改当前选中的会话**。
  615. *
  616. * 🔴 曾在此处「当前是空会话就强行切到历史会话并删除空会话」——
  617. * 导致用户新建的对话一发出消息就被顶掉(实测:新建后 URL 对了,
  618. * 列表却只剩「历史对话」)。**用户的显式操作永远优先于启发式重排。**
  619. */
  620. // 「历史对话」承接机制已下线(2026-10-29):只清理旧数据留下的条目,不再新建
  621. this.dropLegacySessions(agentId);
  622. /*
  623. * 🔴 这里**不再扫描重建会话目录**(2026-10-31 重构)。
  624. *
  625. * 用户裁决:会话列表以云端 `FmodeChatThread` 为唯一来源。原先每次 /sync
  626. * 落地都扫一遍内存 timeline,会把「用户已删除的会话」重新扫回列表
  627. * (删除只是本地 + 云端软删,房间消息仍在)。
  628. *
  629. * 首次接入的初始化扫描已挪到 `openReal()` 中,仅在「云端该容器为空」时执行一次。
  630. */
  631. const sid = this._sessionId();
  632. if (!sid) return;
  633. /*
  634. * 🔴 新建态保护(2026-11-08 第三次修正,最终版)。
  635. *
  636. * 前两次都错在「用一个时间点解除保护」:
  637. * ① 在 `commit()` 时清 —— 本地记账,网络请求还在路上;
  638. * ② 在 `sendReal()` 返回后清 —— 只是「我这边发出去了」,
  639. * 服务端还没把这条件纳入房间 timeline,容器也还没回应。
  640. * 两次都留下同一个空窗:保护一撤,`/sync` 落地就把该房间
  641. * **所有旧容器线程根**算成本会话的(`rootsOfSession` 认领全部历史)。
  642. * 实测:解除保护后 60ms 内就灌入 17 条旧消息。
  643. *
  644. * 正确的判据不是「发出了」,而是「**本会话确实有消息落在 timeline 里**」:
  645. * 用户那条消息一旦出现在 timeline(`threadId === sid`),
  646. * `rootsOfSession` 就只会认领由它派生出的容器子线程,旧历史天然被排除。
  647. *
  648. * 所以在保护期内,先看 timeline 里有没有本会话自己的消息:
  649. * · 没有 → 还没落地,继续挡(旧历史一律不进)
  650. * · 有 → 解除保护,本次及之后的回填都按正常规则走
  651. */
  652. if (this._blankSessionId && sid === this._blankSessionId) {
  653. /*
  654. * 🔴 必须是「本会话的**用户消息**」才算落地(2026-11-09 修正)。
  655. *
  656. * 原判据是「timeline 里有任意 `threadId === sid` 的事件」。
  657. * 但容器回复也可以挂在这个线程根下 —— 于是容器刚回的**第一条**就
  658. * 撤掉了保护,紧接着的回填把整个房间的旧历史灌进来
  659. * (用户实测:新建会话后直接出现「加载更早」和旧内容)。
  660. *
  661. * 用户自己发的那条才说明「这段会话真正开始了」。
  662. */
  663. const landed = raw.some((m) => m.threadId === sid && m.sender === myUserId);
  664. if (!landed) return;
  665. this._blankSessionId = null;
  666. }
  667. if (this._state() === 'empty') return;
  668. /*
  669. * 🔴 只认「本会话自己产生过」的容器线程根(2026-11-08)。
  670. *
  671. * 这里直接计算,不依赖 `_ownedThreadRoots`(那是 `openReal` 在
  672. * 打开会话那一刻建的,新建会话时还没建)。判据:该线程根下存在
  673. * 本会话的用户消息 —— 用户在这个会话里发过东西,容器才会为它开子线程。
  674. */
  675. const ownedRoots = this.rootsOfSession(raw, sid, myUserId);
  676. const next = this.mapTimeline(
  677. raw.filter((m) => this.belongsToSession(m, sid, false, ownedRoots)),
  678. myUserId,
  679. );
  680. if (next.length === 0) return;
  681. /*
  682. * 🔴 合并而不是整表覆盖(2026-11-08)。
  683. *
  684. * `_messages.set(next)` 会把本地的乐观消息(刚发出、云端还没回读到的)
  685. * 一起抹掉,再排成「旧历史 → 新消息」的顺序 —— 用户看到新建对话里
  686. * 凭空出现昨天的内容,且自己的提问被挤到下面。
  687. *
  688. * 现在:历史里有的以历史为准,历史里没有的本地消息**保留**并排在后面。
  689. *
  690. * 🔴 读 `_messages()` 必须走 `untracked`:本 effect 正在往里写,
  691. * 直接读会形成自依赖 → 无限重算(实测会导致页面卡死、按钮点不动)。
  692. */
  693. const local = untracked(() => this._messages());
  694. this._messages.set(this.mergeTimeline(next, local));
  695. /*
  696. * 🔴 这里**不能**标记「正在回复」:这是历史重读(每次 sync 都会重新灌一遍),
  697. * 刷新页面时尾部 id 必然变 → 一打开就显示「正在回复」(用户实测)。
  698. * 真正的实时信号在 appendIncoming()。
  699. */
  700. if (this._state() === 'empty') this._state.set('ready');
  701. /*
  702. * 🔴 只在**保护期结束后**才按房间 token 决定能否翻页(2026-11-08)。
  703. *
  704. * `paginationToken()` 取的是**整个房间**的 backward token,与当前 thread 无关。
  705. * 新建会话照样能拿到非 null → 页面出现「加载更早的消息」→ 一点就翻出旧内容。
  706. * 保护期内恒为 false:这条会话确实没有「更早的消息」。
  707. */
  708. this._hasMoreHistory.set(
  709. this._blankSessionId !== sid && this.mx.paginationToken(rid) != null,
  710. );
  711. });
  712. }
  713. /**
  714. * 打开会话(路由进入时调用)。
  715. *
  716. * @param agentId 数字生命
  717. * @param sessionId 指定会话(线程根);不传则打开该 agent 最近一个会话
  718. * (一条都没有 → 空态,**不自动新建**,新建走 `startBlank()`)
  719. * @param force 强制重开(「重新连接」「重新加载」用)。
  720. * 🔴 不加这个参数时,下面的幂等短路会让重连按钮**完全无效**:
  721. * 断线后 `_state` 仍是 `'ready'`,`open()` 进第一行就 return,
  722. * 页面停在「连接已断开」上按多少次都没反应(用户实测)。
  723. */
  724. async open(agentId: string, sessionId?: string, force = false): Promise<void> {
  725. /*
  726. * 幂等短路:同一容器 + 同一会话 + **已加载完成**(ready 或 empty)。
  727. *
  728. * 🔴 `empty` 也要算「已完成」:空房间(没有任何会话)的 state 就是 `empty`,
  729. * 只判 `ready` 会让每次进入都重跑 provision + 连接 + 云端拉取,
  730. * 既慢又容易和其它异步流程打架(实测:空态页反复重载)。
  731. */
  732. const loaded = this._state() === 'ready' || this._state() === 'empty';
  733. if (
  734. !force &&
  735. this._agentId() === agentId &&
  736. (!sessionId || this._sessionId() === sessionId) &&
  737. loaded
  738. ) {
  739. return;
  740. }
  741. const my = ++this.seq;
  742. /*
  743. * 🔴 切走前先清掉上一条空会话(2026-11-01)。
  744. *
  745. * 场景:用户在不同工作区连点几次「新建对话」,每次都建出一条 0 条消息的会话,
  746. * 且都没发消息就切走了。持久化的唯一条件是「发过消息」,
  747. * 这些没落库的空壳应当**在离开时消失**,而不是留在左侧列表里。
  748. *
  749. * 保留 `sessionId`(本次要打开的那条)—— 用户刚点完新建正在看它。
  750. */
  751. /*
  752. * 🔴 待打开的那条必须一起保留(2026-11-06)。
  753. * 刷新后首次进入时,ed 在本地目录里的 `messageCount` 可能还是 0(消息数以
  754. * 云端/演示留存量回填得晚),只传 `sessionId` 之外的"保留"是不够的 ——
  755. * 原实现把「本次要打开的会话」当空壳删掉 → `_sessionId` 悬空 → 输入区消失。
  756. */
  757. const openTarget = sessionId ?? this._sessionId();
  758. this.sessions.pruneEmpty(openTarget ?? undefined);
  759. this.teardownSubscriptions();
  760. this._agentId.set(agentId);
  761. this._state.set('loading');
  762. this._error.set(null);
  763. this._messages.set([]);
  764. this._draft.set('');
  765. this._peerTyping.set(false);
  766. this._turnEndedAt.set(0);
  767. this._turnEndedAfter.set(null);
  768. this._awaitingReplySince.set(0);
  769. try {
  770. await this.live.ensureLoaded();
  771. if (my !== this.seq) return;
  772. const agent = this.live.agentById(agentId);
  773. if (!agent) {
  774. /*
  775. * 🔴 「取不到」有三种原因,不能一律报「不存在」:
  776. * ① 数据加载失败(网络/token)→ 报加载失败并给重试
  777. * ② 仍在加载中(并发场景)→ 报加载中,不算错误
  778. * ③ 确实没这个 agent → 才报不存在
  779. *
  780. * 刷新页面时最容易踩:live 还是 loading,agentById 返回 undefined,
  781. * 原实现直接判「数字生命不存在」(实测复现)。
  782. */
  783. const liveState = this.live.state();
  784. this._state.set('error');
  785. this._error.set(
  786. liveState === 'error'
  787. ? `数据加载失败:${this.live.error() ?? '未知错误'}`
  788. : liveState === 'loading' || liveState === 'idle'
  789. ? '正在加载数字生命数据,请稍候…'
  790. : '数字生命不存在',
  791. );
  792. return;
  793. }
  794. this._connection.set('connecting');
  795. if (environment.useMockData) {
  796. // ---- 演示态 ----
  797. this.provision = await this.api.provisionMatrix(agentId);
  798. if (my !== this.seq) return;
  799. this._connection.set('connected');
  800. /*
  801. * 演示态也要有 roomId:`startBlank()` / `sendText()` 都以它为「容器已就绪」的依据。
  802. * 缺了它,「新建对话」会静默返回 null(实测:按钮点了没反应)。
  803. */
  804. this.roomId = `mock-room-${agentId}`;
  805. this.roomAgentMap.set(agentId, this.roomId);
  806. /*
  807. * 演示种子只属于「历史遗留会话」(`_sessionId` 为空)——
  808. * 它们没有线程根,刷新时落在这条分支里,靠种子填充。
  809. *
  810. * 🔴 指定了会话(用户在演示态里自己新建/发送过的线程)时不能塞种子
  811. * (2026-11-06 渲染测试实测):种子没有线程归属,会串进用户自己的会话;
  812. * 而且种子只在首次打开时写入,刷新后被清空的要点正是「历史没有留痕」。
  813. * 演示态下用会话内留存量恢复,保证刷新后仍能看到自己发过的内容。
  814. */
  815. /*
  816. * 🔴 选中哪条会话必须在这里定(2026-11-06)。
  817. * 真实态由 `openReal()` 末尾统一 `_sessionId.set(sid)`;演示态早退,
  818. * 原来只填了消息没定会话 —— 刷新后 `_sessionId` 为空,
  819. * 页面渲染成「还没有对话」,输入区整块消失(实测反复复现)。
  820. * 地址没指定会话时退回该容器最近一条(与真实态同语义)。
  821. */
  822. const demoSid = sessionId || this.sessions.latestFor(agentId)?.id || '';
  823. this._sessionId.set(demoSid);
  824. this._messages.set(demoSid ? this.demoRestored(demoSid) : this.seedMessages(agentId));
  825. this._state.set(this._messages().length === 0 ? 'empty' : 'ready');
  826. return;
  827. }
  828. // ---- 真实态 ----
  829. await this.openReal(agentId, my, sessionId);
  830. } catch (e) {
  831. if (my !== this.seq) return;
  832. this._connection.set('disconnected');
  833. this._state.set('error');
  834. this._error.set(msgOf(e));
  835. }
  836. }
  837. /**
  838. * 新建对话:**立刻创建一条会话**并在界面选中它。
  839. *
  840. * 2026-11-01 用户裁决变更(原为「首次发消息才建」):
  841. * 点「新建对话」之后,左侧对应区域(工作区文件夹 / 日常对话)必须**立刻**
  842. * 出现一条「新对话」并且处于选中态 —— 否则用户点了按钮看不到任何反馈,
  843. * 不知道操作有没有生效。
  844. *
  845. * 代价与对策:用户点了却不发消息就切走,会留下 0 条消息的空壳。
  846. * 由 `sessions.pruneEmpty(runningId)` 在**打开容器**和**切走会话**时清理,
  847. * 只保留当前正在看的那条。
  848. *
  849. * @param agentId 目标容器
  850. * @param workspaceId 归属工作区(`null` = 日常对话)
  851. * @returns 创建出的会话;容器未就绪时返回 null
  852. */
  853. async startBlank(agentId: string, workspaceId: string | null = null): Promise<ChatSession | null> {
  854. /*
  855. * 登记「新建在途」的 Promise,让 `sendText` 能等它(2026-11-11)。
  856. * 用户在新建立即按回车时,`_agentId` 可能还没设好 —— 见 `_blankInFlight` 注释。
  857. */
  858. const p = this.doStartBlank(agentId, workspaceId);
  859. this._blankInFlight = p;
  860. try {
  861. return await p;
  862. } finally {
  863. if (this._blankInFlight === p) this._blankInFlight = null;
  864. }
  865. }
  866. /**
  867. * 新建会话并**等专属房间就绪**后再返回(2026-11-12 用户要求)。
  868. *
  869. * ## 为什么不复用 `startBlank`
  870. *
  871. * `startBlank` 里建房是 fire-and-forget:用户「点新建 → 立刻打字发送」时,
  872. * 消息会在房间建好**之前**发出 → 落进共享房间 → 一房间一会话失效。
  873. * 实测后果:5 条测试会话全部还在 `!miyB1Dx…` 共享房间里,
  874. * 继而导致左侧状态互相干扰(转圈 120 秒不停)。
  875. *
  876. * ## 做法
  877. *
  878. * 先建会话(保证即时可用),再**同步等**建房 + 容器进房
  879. * (实测 598~717ms),完成后才返回给调用方去跳转页面。
  880. *
  881. * 建房失败**不阻塞**:降级为共享房间(老行为),用户仍可正常对话。
  882. *
  883. * @returns 会话对象;容器未就绪时返回 null
  884. */
  885. async startBlankWithRoom(
  886. agentId: string,
  887. workspaceId: string | null = null,
  888. ): Promise<ChatSession | null> {
  889. /*
  890. * 分段计时(2026-11-13 用户反馈「新建很慢」)。
  891. * 只打 info 级、带 `[perf]` 前缀,便于在控制台/日志里 grep 出各段耗时。
  892. * 线上排查新建卡顿只看这一行即可,不用再猜。
  893. */
  894. const t0 = Date.now();
  895. const s = await this.startBlank(agentId, workspaceId);
  896. const t1 = Date.now();
  897. if (!s) return null;
  898. /*
  899. * 同步等建房完成。**不再抛错阻断新建**(2026-11-13 调整):
  900. * 会话本身已经建好,失败信息记在 `roomErrors` 里由 UI 展示;
  901. * 直接抛错会让用户觉得"新建失败",而实际上会话是存在的、只差房间。
  902. */
  903. await this.provisionSessionRoom(s.id, agentId);
  904. const t2 = Date.now();
  905. console.info(
  906. `[perf] 新建会话:连接/建会话 ${t1 - t0}ms,建房+等进房 ${t2 - t1}ms,合计 ${t2 - t0}ms`,
  907. );
  908. return s;
  909. }
  910. /**
  911. * 为新建的会话开一个**独立房间**(一房间一会话,2026-11-12)。
  912. *
  913. * ## 为什么建房失败不能再静默降级(2026-11-13 重构)
  914. *
  915. * 原实现「任何一步失败都静默降级为绑定房间」,理由是不让新建对话失败。
  916. * 但 2026-11-13 起**成果归属主键改成 roomId**:
  917. *
  918. * 一房间一会话 ⇒ roomId 唯一 ⇒ 台账按 roomId 查才查得准
  919. *
  920. * 一旦降级到共用房间,同一房间下会挂着多个会话,它们的成果会**混在一起**。
  921. * 那种"看起来建成功了、实际数据全串了"比直接报错更糟 —— 用户至少能看见错误。
  922. *
  923. * 现在的策略:
  924. * · 建房 + 等进房失败 → 重试 2 次(退避 3s / 8s)
  925. * · 仍失败 → 标记 `roomError`,由 UI 明确告知,**禁止**在共用房间静默发消息
  926. *
  927. * 不删失败房间:Matrix 无删除语义;它没有消息,也不进会话目录(目录由发消息触发)。
  928. *
  929. * @param sessionId 新建的会话 id(同时作为该会话的 thread 根)
  930. * @param agentId 所属容器
  931. * @throws 建房重试仍失败时抛出,由调用方转为用户可见错误
  932. */
  933. private async provisionSessionRoom(sessionId: string, agentId: string): Promise<void> {
  934. const agent = this.live.agentById(agentId);
  935. /*
  936. * 🔴 `this.provision` 只在 `openReal()` 成功后才非空(2026-11-13)。
  937. *
  938. * 原先完全依赖它,于是「点新建 → 建房」这条路径在 provision 尚未就绪时
  939. * (比如异常恢复、或刚切容器)会直接走「未接入」分支报错。
  940. * 这里改为:provision 缺失时**就地补拉一次**,拉不到才认定未接入。
  941. */
  942. let agentMxId = this.provision?.agentMxId ?? '';
  943. if (!agentMxId) {
  944. try {
  945. const prov = await this.api.provisionMatrix(agentId);
  946. this.provision = prov;
  947. agentMxId = prov.agentMxId ?? '';
  948. } catch {
  949. /* 开通失败:下面按「未接入」处理并给出可见原因 */
  950. }
  951. }
  952. agentMxId = agentMxId || MatrixClientService.agentMxId(agent?.id ?? '');
  953. if (!agentMxId) {
  954. /* 容器还没接入 Matrix:这是终态而非瞬时故障,同样必须显式报出 */
  955. this.markRoomError(sessionId, '该数字生命尚未接入,暂时无法建立独立会话');
  956. return;
  957. }
  958. /**
  959. * 重试退避:第 1 次失败等 3s,第 2 次等 8s。
  960. *
  961. * 放在类字段上而不是内联常量 —— 单测要把它压成 0 才能快速覆盖重试路径,
  962. * 否则一个用例要真等 11 秒(3 个用例就是 33 秒)。
  963. */
  964. /*
  965. * 🔴 尝试次数与退避数组**解耦**(2026-11-13 修正)。
  966. *
  967. * 最初写 `for (attempt <= BACKOFF_MS.length)`:单测把退避压成 `[]` 后
  968. * 循环一次就退出,重试形同虚设 —— 测出来的「重试」是假的。
  969. * 现在次数由常量决定,退避只负责"每次失败等多久"。
  970. */
  971. const MAX_ATTEMPTS = ChatService.ROOM_MAX_ATTEMPTS;
  972. const BACKOFF_MS = ChatService.ROOM_BACKOFF_MS;
  973. let lastErr = '';
  974. for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
  975. if (attempt > 0) {
  976. const wait = BACKOFF_MS[attempt - 1] ?? BACKOFF_MS.at(-1) ?? 0;
  977. if (wait > 0) await new Promise((r) => setTimeout(r, wait));
  978. }
  979. try {
  980. const roomId = await this.mx.createSessionRoom(agentMxId, agent?.name);
  981. /*
  982. * 等容器进房(2026-11-13 修正超时值)。
  983. *
  984. * 🔴 原来传 30 秒是错的:`waitAgentJoined` 自身默认 8 秒,
  985. * 实测正常进房只要 3 秒左右。30 秒 × 3 次重试 + 退避
  986. * ≈ 100 秒 —— 用户点「新建对话」后要干等一分多钟,
  987. * 体感就是「点了没反应、根本用不了」。
  988. *
  989. * 现在:单次仍给 12 秒(比实测 3 秒留 4 倍余量,容忍一次平台抖动),
  990. * 但只重试 2 次,最坏 ~27 秒;且第一次成功后立即返回,绝大多数情况
  991. * 仍是 3 秒内完成,行为与引入重试之前一致。
  992. */
  993. const joined = await this.mx.waitAgentJoined(roomId, agentMxId, 12_000);
  994. if (!joined) {
  995. lastErr = '容器未进入会话房间(超时)';
  996. continue;
  997. }
  998. /* 回写 roomId:之后该会话的消息、状态、回溯全部基于这个房间 */
  999. this.sessions.setRoomId(sessionId, roomId);
  1000. /*
  1001. * 若用户此时正停在这条会话上,把当前房间指针也切过去,
  1002. * 让「发送」直接落到新房间(否则会发进老房间,状态又混在一起)。
  1003. */
  1004. if (this._sessionId() === sessionId) {
  1005. this.roomId = roomId;
  1006. }
  1007. this.clearRoomError(sessionId);
  1008. return;
  1009. } catch (e) {
  1010. /* 离线 / 限流 / 服务端拒绝:属于瞬时故障,值得重试 */
  1011. lastErr = e instanceof Error ? e.message : '建立会话房间失败';
  1012. }
  1013. }
  1014. this.markRoomError(sessionId, lastErr || '建立会话房间失败');
  1015. }
  1016. /** 建房失败的会话 id → 原因(供会话页展示与「重试」按钮使用) */
  1017. private readonly _roomErrors = signal<ReadonlyMap<string, string>>(new Map());
  1018. /** 建房失败的会话及原因(只读) */
  1019. readonly roomErrors = this._roomErrors.asReadonly();
  1020. private markRoomError(sessionId: string, msg: string): void {
  1021. this._roomErrors.update((m) => {
  1022. const next = new Map(m);
  1023. next.set(sessionId, msg);
  1024. return next;
  1025. });
  1026. }
  1027. private clearRoomError(sessionId: string): void {
  1028. this._roomErrors.update((m) => {
  1029. if (!m.has(sessionId)) return m;
  1030. const next = new Map(m);
  1031. next.delete(sessionId);
  1032. return next;
  1033. });
  1034. }
  1035. /**
  1036. * 用户点「重试」时重新建房(2026-11-13)。
  1037. *
  1038. * 只允许对**当前会话**重试:其它会话没有活跃的容器连接上下文。
  1039. */
  1040. async retrySessionRoom(): Promise<boolean> {
  1041. const sid = this._sessionId();
  1042. const agentId = this._agentId();
  1043. if (!sid || !agentId) return false;
  1044. this.clearRoomError(sid);
  1045. await this.provisionSessionRoom(sid, agentId);
  1046. return !this._roomErrors().has(sid);
  1047. }
  1048. private async doStartBlank(
  1049. agentId: string,
  1050. workspaceId: string | null = null,
  1051. ): Promise<ChatSession | null> {
  1052. /*
  1053. * 🔴 先自增 `seq`:`startBlank` 不经过 `openReal()`,不会自带版本号,
  1054. * 而此时可能正好有一次 `openReal()` 在途(切换容器/前一次进入的异步尾段)。
  1055. * 它落地时只校验 `my !== this.seq`,版本号不变就会把它**当成当前**继续写
  1056. * `_sessionId` / 历史回填 —— 典型表现是「新建后瞬间又被旧会话内容占满」。
  1057. * 这里自增一次,让所有在途流程作废,再由下面的 `open()` 重新建立连接。
  1058. */
  1059. this.seq++;
  1060. /*
  1061. * 🔴 不调用 `open()`(2026-11-01 修正)。
  1062. *
  1063. * `open()` 会顺带做「打开该容器最近的会话」这一步,把 `_sessionId` 指向已存在的
  1064. * 会话 —— 紧接着我们又把它改成本次新建的那条,来回横跳;
  1065. * 更糟的是当容器里已有空会话时,`open()` 会先选中它,导致复用判断和
  1066. * 「当前会话」指针不一致(测试实测:连点新建变成两条)。
  1067. *
  1068. * 这里只做「确保容器已连接」这件事,选会话完全由本方法自己决定。
  1069. */
  1070. if (this._agentId() !== agentId || !this.roomId) {
  1071. await this.open(agentId);
  1072. }
  1073. if (this._agentId() !== agentId) return null;
  1074. if (this._state() === 'error') return null;
  1075. const roomId = this.roomId;
  1076. if (!roomId) return null;
  1077. /*
  1078. * 🔴 全局只保留**一条**「新建对话」(2026-11-01 用户裁决)。
  1079. *
  1080. * 用户可以在工作区里新建、也可以在日常对话里新建。规则是:
  1081. * 每次新建 → 之前**所有没发过消息**的空会话一律清掉,只留这一条。
  1082. * 不再按「同容器 + 同归属」复用,也不依赖 60 秒定时清理 ——
  1083. * 那样会出现「工作区一条、日常一条」并存的情况,不符合预期。
  1084. *
  1085. * 新建的会话归属到目标工作区(`workspaceId`),但**不落库**:
  1086. * 持久化的唯一前提是在这条会话里发过消息(见 create/commit 的分工)。
  1087. */
  1088. const s = this.sessions.create(roomId, agentId);
  1089. if (workspaceId) this.sessions.moveTo(s.id, workspaceId);
  1090. // 清掉此前留下的其它空会话,保证列表里只有这一条「新建对话」
  1091. this.sessions.pruneEmpty(s.id);
  1092. /*
  1093. * 🔴 一房间一会话(2026-11-12 架构升级)。
  1094. *
  1095. * 老架构:所有会话共用一个 DM 房间,容器只能上报 `!roomId`,
  1096. * App 无法区分是哪条会话 → 「一条在跑,全房间都转」(用户实测)。
  1097. *
  1098. * 新架构:**每条新会话开一个独立房间**,房间号即会话标识,容器上报无歧义。
  1099. *
  1100. * ## 失败降级(重要)
  1101. *
  1102. * 建房是网络操作,可能失败(离线、homeserver 限流)。
  1103. * 失败时**退回绑定房间**(老行为),会话仍然可用,只是状态隔离降级 ——
  1104. * 绝不能因为建房失败就让用户「新建对话」失败。
  1105. *
  1106. * ## 不阻塞首屏
  1107. *
  1108. * 建房 + 等容器进房最多几秒。**不能 fire-and-forget**(2026-11-12 实测踩坑):
  1109. * 原先写 `void this.provisionSessionRoom(...)`,用户紧接着发消息时
  1110. * `this.roomId` 还是共享房间 → 首条消息进了老房间,一房间一会话彻底失效。
  1111. *
  1112. * 🔴 建房**不在这里做**(2026-11-12 修正)。
  1113. *
  1114. * 原先写 `void this.provisionSessionRoom(...)` 或挂 `_sessionRoomInFlight`,
  1115. * 本质都是「先返回、后台建房」—— 用户秒发消息时房间还没好,
  1116. * 消息落进共享房间(实测 5 条会话全在 `!miyB1Dx…`)。
  1117. *
  1118. * 现在由调用方 `startBlankWithRoom()` 在返回前**同步等建房完成**;
  1119. * `startBlank()` 仍保留「不建房」的轻量语义(给不需要房间的场景用)。
  1120. */
  1121. // 切到这条新会话(左侧随之高亮,见 session-tree 的 activeId)
  1122. this._sessionId.set(s.id);
  1123. /*
  1124. * 🔴 清空消息前**必须解除旧订阅**(2026-11-07 用户反馈「新建对话带出旧数据」)。
  1125. *
  1126. * 上面的 `open(agentId)` 会订阅该房间的实时事件(`onMessage` → `appendIncoming`)。
  1127. * 只把 `_messages` 置空是不够的:订阅还挂在房间上,
  1128. * 后续任何回填 / 重放事件都会把**上一个会话的消息塞回新会话里**。
  1129. *
  1130. * 解除后必须**在这里重新订阅**(2026-11-11 修正)。
  1131. *
  1132. * 原注释说「由首次发消息那一步重新建立订阅」,但那条路径**从未实现** ——
  1133. * 新建的会话因此收不到任何实时事件(包括 ✅ reaction),
  1134. * 表现为「卡片已收尾,右上角和左侧还挂着处理中」。
  1135. */
  1136. this.teardownSubscriptions();
  1137. /*
  1138. * 新建会话仍属**同一个房间**(一个容器一个 DM 房间),
  1139. * 所以直接用当前 roomId 重新订阅即可。版本号取自增后的 `this.seq`:
  1140. * 之后任何 `open()` 都会再自增,本订阅的回调自然作废。
  1141. */
  1142. if (roomId) {
  1143. const myUserId = this.provision?.userMxId ?? '';
  1144. this.bindRoomSubscriptions(roomId, this.seq, myUserId);
  1145. }
  1146. /*
  1147. * 🔴 清空「已认领的容器线程根」(2026-11-08)。
  1148. *
  1149. * 该集合只在 `openReal()` 里重建(`clear()` + 重新计算),而 `startBlank()`
  1150. * **不经过 `openReal`** → 集合里残留着**上一个会话**的根。
  1151. * `paginate()` 恰好走这个集合(`belongsToSession(m, sid)` 无 ownedRoots 参数),
  1152. * 于是一翻页就把旧会话的容器子线程认领进新会话。
  1153. */
  1154. this._ownedThreadRoots.clear();
  1155. this._messages.set([]);
  1156. this._state.set('empty');
  1157. /*
  1158. * 🔴 必须重置空态判定(2026-11-13 用户实测)。
  1159. *
  1160. * 上面 `open(agentId)` 那一分支会走 `openReal()`,它按「已有会话」算出一份
  1161. * `emptyReason`。新建会话随后清空了 `_messages`,但**没清这个标记** ——
  1162. * 上一份结论原样残留,界面直接显示「这个会话的消息暂时没加载出来 / 网络波动」。
  1163. * 新建的会话完全没有历史,正确语义是 `fresh`(走欢迎页)。
  1164. */
  1165. this._emptyReason.set('fresh');
  1166. this._error.set(null);
  1167. this._hasMoreHistory.set(false);
  1168. this._peerTyping.set(false);
  1169. this._turnEndedAt.set(0);
  1170. this._awaitingReplySince.set(0);
  1171. this._turnEndedAfter.set(null);
  1172. this._blankWorkspace = workspaceId;
  1173. // 新建态标记:storeVersion effect 据此拒绝回填(见字段注释)
  1174. this._blankSessionId = s.id;
  1175. return this.sessions.byId(s.id) ?? s;
  1176. }
  1177. /**
  1178. * 订阅当前房间的实时事件(消息 / typing / ✅ reaction)。
  1179. *
  1180. * 🔴 抽成独立方法(2026-11-11 用户实测「新建会话状态不同步」)。
  1181. *
  1182. * 原来这三段订阅**只写在 `openReal()` 里**。而 `startBlank()`(新建对话)
  1183. * 会先 `teardownSubscriptions()` 解除全部订阅,然后走自己的路 ——
  1184. * **从不重新订阅**。结果:
  1185. *
  1186. * · 新建的会话收不到 ✅ reaction → `turnSettledLocally` 永远是 false
  1187. * · 也收不到实时消息 → 只能靠 15 秒轮询补齐
  1188. *
  1189. * 表现就是「卡片已按静默窗口收尾,右上角和左侧还挂着处理中」。
  1190. *
  1191. * @param rid 房间 id
  1192. * @param my `open()` 的版本号;回调里据此丢弃过期回包
  1193. * @param myUserId 自己的 mxid(用于排除自己发出的 typing)
  1194. */
  1195. private bindRoomSubscriptions(rid: string, my: number, myUserId: string): void {
  1196. /*
  1197. * 🔴 回调里**不校验 `seq`**(2026-11-11 用户实测「新建会话状态不同步」)。
  1198. *
  1199. * 原来三个回调都以 `if (my !== this.seq) return;` 开头,本意是丢弃过期回包。
  1200. * 但 `seq` 是**全局版本号**,任何一次 `open()` / `close()` 都会自增 ——
  1201. * 新建会话后紧接着 URL 跳转触发 `onRouteChange → open()`,
  1202. * `seq` 一变,**新建时建立的订阅就全部失效**:
  1203. *
  1204. * · 收不到 ✅ reaction → `turnSettledLocally` 恒 false(右侧一直「处理中」)
  1205. * · 收不到实时消息 → 只能靠 15 秒轮询
  1206. *
  1207. * 而现在这个订阅**只绑定在一个确定的房间**上(`rid` 由闭包捕获),
  1208. * 房间不会因为切会话而变(一个容器一个 DM 房间)。
  1209. * 真正要防的是「房间变了还往里写」—— 那由 `teardownSubscriptions()`
  1210. * 在每次切换时解绑来处理,不需要再拿全局版本号挡。
  1211. */
  1212. this.unbindMessage = this.mx.onMessage(rid, (m) => {
  1213. this.appendIncoming(m, myUserId);
  1214. });
  1215. this.unbindTyping = this.mx.onTyping(rid, (typing, who) => {
  1216. /*
  1217. * 🔴 必须排除自己:`m.typing` 会广播**房间内所有成员**的输入状态(含自己)。
  1218. * 不过滤 → 用户自己打字时界面显示「对方正在输入」,方向完全相反。
  1219. */
  1220. if (myUserId && who === myUserId) return;
  1221. this._peerTyping.set(typing);
  1222. /*
  1223. * 容器从「正在输入」转成「没在输入」= 本轮结束的辅助信号。
  1224. */
  1225. if (!typing) {
  1226. this._turnEndedAt.set(Date.now());
  1227. this._turnEndedAfter.set(this.lastAgentMessageId());
  1228. /* 一轮真的结束了 → 真空期标记也清掉(2026-11-14) */
  1229. this._awaitingReplySince.set(0);
  1230. }
  1231. });
  1232. /*
  1233. * 容器的 ✅ / ❌ reaction = **本轮真的干完了**。
  1234. *
  1235. * 这是比 typing 更硬的信号:实测容器每轮处理结束都会把 👀 换成 ✅,
  1236. * 由 Hermes 内建行为发出,不依赖插件改造。
  1237. */
  1238. this.unbindTurnReaction =
  1239. this.mx.onTurnReaction?.(rid, (_ok, sender, targetId) => {
  1240. /* 同 `bindRoomSubscriptions` 的说明:这里不校验全局 `seq` */
  1241. /*
  1242. * 必须校验 sender:群聊里 @ 了多个容器时,每个容器各打一个 ✅、
  1243. * 打在同一个 event_id 上。不看 sender 会把 A 的收工当成 B 也收工。
  1244. * 单聊只有本容器一个 agent,这条恒为真、不影响原有行为。
  1245. */
  1246. const agentMxId = this._agentMxId();
  1247. if (agentMxId && sender && sender !== agentMxId) return;
  1248. /*
  1249. * 🔴 用 ✅ 标注的那条消息反推「哪条会话结束了」(2026-11-12,用户实测)。
  1250. *
  1251. * 现象:**没打开**的会话左侧一直转圈,点开才停。
  1252. * 根因:`SessionTree.sessionRunningOf()` 只在「当前打开的会话」上才用
  1253. * reaction 收尾,其余会话只能等容器 `currentSessions` 上报过期,
  1254. * 而本地「刚发出」标记的 TTL 更长。
  1255. *
  1256. * ✅ 的 `m.relates_to.event_id` 指向容器本轮的最后一条消息,
  1257. * 它的 `threadId`(或回落到 roomId)就是结束的那条会话。
  1258. * 这里不依赖「当前打开的是哪条」,所以后台会话同样能收尾。
  1259. */
  1260. this.pulse.clearSessionLocalStartFor(this._sessionsOfMessage(targetId, rid));
  1261. /*
  1262. * 稍等一拍再取「末条 agent 消息 id」:reaction 与它标注的消息是
  1263. * 两个独立事件,到达顺序不保证。让出一拍能避免取到上一条。
  1264. */
  1265. setTimeout(() => {
  1266. this._turnEndedAt.set(Date.now());
  1267. this._turnEndedAfter.set(this.lastAgentMessageId());
  1268. /* ✅ = 本轮真的结束 → 真空期标记清掉(2026-11-14) */
  1269. this._awaitingReplySince.set(0);
  1270. /* 立刻刷新容器状态,不等 15 秒轮询 */
  1271. void this.live.load(true);
  1272. }, 300);
  1273. }) ?? null;
  1274. /*
  1275. * 容器主动推送的「本轮结束」事件(2026-11-12,方案 2)。
  1276. *
  1277. * 容器 turn 结束时立即上报云函数(实测 1 秒内),但 App 拉云函数是 15 秒轮询
  1278. * → 左侧最多要转 30 秒。这个订阅让容器同时往房间推一个 Matrix 事件,
  1279. * App 收到即刷新,把「停止转圈」的延迟从 30 秒压到 1~2 秒。
  1280. *
  1281. * 🔴 与 ✅ reaction 无关:✅ = 单条消息发完;本事件 = 整轮结束。
  1282. *
  1283. * 🔴 **全局订阅**(2026-11-12 修正):一房间一会话后,左侧每条会话都在自己的
  1284. * 房间里。只监听当前房间会漏掉后台会话的结束事件(用户实测「回复完了还转很久」)。
  1285. */
  1286. this.unbindTurnStatus =
  1287. this.mx.onTurnStatus?.((sessionKey, status, sender, evRoomId) => {
  1288. /* 只认本容器发的(群聊里别的容器也会推) */
  1289. const agentMxId = this._agentMxId();
  1290. if (agentMxId && sender && sender !== agentMxId) return;
  1291. /*
  1292. * 用 sessionKey 或事件所在房间定位会话 —— 容器两种形态都可能上报。
  1293. */
  1294. const sid = this._sessionIdOfKey(sessionKey) || this._sessionIdOfKey(evRoomId);
  1295. if (status !== 'idle') {
  1296. /* running:立刻刷新,让转圈尽快出现 */
  1297. void this.live.load(true);
  1298. return;
  1299. }
  1300. /*
  1301. * 🔴 idle:三重保险立刻停止左侧转圈(不等 15 秒轮询)
  1302. * ① 清掉该会话的本地「刚发出」标记
  1303. * ② 在本地打一个「已结束」时间戳,`sessionRunningOf()` 据此直接判不转
  1304. * ③ 刷新容器状态(拉一次云函数,让其它界面也同步)
  1305. */
  1306. if (sid) {
  1307. this.pulse.clearSessionLocalStartFor([sid]);
  1308. this.pulse.markSessionEnded(sid);
  1309. }
  1310. void this.live.load(true);
  1311. }) ?? null;
  1312. }
  1313. /**
  1314. * 把容器上报的 sessionKey(`$approot_xxx` 或 `!roomId`)换算成本地会话 id。
  1315. *
  1316. * 一房间一会话下,房间号即会话 id,两种形态都能定位到会话。
  1317. */
  1318. private _sessionIdOfKey(sessionKey: string): string {
  1319. if (!sessionKey) return '';
  1320. const direct = this.sessions.byId(sessionKey);
  1321. if (direct) return direct.id;
  1322. /* roomId 形态:找 roomId 等于它的那条会话 */
  1323. const byRoom = this.sessions.sessionsInRoom(sessionKey)[0];
  1324. return byRoom?.id ?? '';
  1325. }
  1326. /**
  1327. * 当前会话所属容器的 Matrix mxid(用于 ✅ reaction 的 sender 校验)。
  1328. *
  1329. * 单聊恒为一个 agent;群聊时需要按块区分,这里取当前容器即可。
  1330. */
  1331. private _agentMxId(): string {
  1332. return this.provision?.agentMxId ?? '';
  1333. }
  1334. /**
  1335. * 真实态开启流程。
  1336. *
  1337. * 失败时**分环节报错**(SPEC FR-04:不得用笼统文案掩盖具体环节):
  1338. * FMODE 开通 / Matrix 登录 / 房间创建。
  1339. */
  1340. private async openReal(agentId: string, my: number, sessionId?: string): Promise<void> {
  1341. // ① provision:拿服务端签发凭据(含绑定表命中的 roomId)
  1342. let prov: MatrixProvision;
  1343. try {
  1344. prov = await this.api.provisionMatrix(agentId);
  1345. } catch (e) {
  1346. if (my !== this.seq) return;
  1347. throw new Error(`FMODE 开通失败:${msgOf(e)}`);
  1348. }
  1349. if (my !== this.seq) return;
  1350. this.provision = prov;
  1351. // ② 会话级连接(幂等;同身份重复进入不打断 sync —— 未读计数依赖它常驻)
  1352. /*
  1353. * 告诉 MatrixClientService「当前连的是哪个容器」——
  1354. * token 失效时它要用这个 agentId 调 `validateMatrix()` 重签凭据(2026-11-12)。
  1355. */
  1356. this.mx.setRecoveryAgentId(agentId);
  1357. try {
  1358. await this.mx.connectSession(prov.accessToken, prov.userMxId ?? '', prov.deviceId);
  1359. } catch (e) {
  1360. if (my !== this.seq) return;
  1361. throw new Error(`连接失败:${msgOf(e)}`);
  1362. }
  1363. if (my !== this.seq) return;
  1364. // ③ 房间(绑定表 roomId 优先)
  1365. const agentMxId = prov.agentMxId ?? '';
  1366. if (!agentMxId) throw new Error('房间创建失败:服务端未返回 Agent 身份');
  1367. let rid: string;
  1368. /*
  1369. * 🔴 会话已有**专属房间**时直接用它(一房间一会话,2026-11-12)。
  1370. *
  1371. * 新建对话会为它开独立房间并回写到 `s.roomId`;再打开这条会话时必须进入
  1372. * **那个**房间,否则消息会发进绑定房间、状态又混到一起。
  1373. *
  1374. * 判据:显式指定了 sessionId,且本地目录里这条会话的 roomId 与绑定房间不同。
  1375. * 老会话(仍在绑定房间)不受影响,自动走原路径。
  1376. */
  1377. const explicitSession = sessionId ? this.sessions.byId(sessionId) : undefined;
  1378. const ownRoom = explicitSession?.roomId;
  1379. try {
  1380. rid = await this.mx.ensureRoom(agentMxId, {
  1381. roomId: ownRoom && ownRoom !== prov.roomId ? ownRoom : prov.roomId,
  1382. agentName: this.live.agentById(agentId)?.name,
  1383. });
  1384. } catch (e) {
  1385. if (my !== this.seq) return;
  1386. throw new Error(`房间创建失败:${msgOf(e)}`);
  1387. }
  1388. if (my !== this.seq) return;
  1389. this.roomId = rid;
  1390. // 记录 agentId ↔ roomId 映射并持久化(未读计数按房间归属到 Agent 时需要)
  1391. this.roomAgentMap.set(agentId, rid);
  1392. this.persistRoomMap();
  1393. /*
  1394. * ③·补 确定当前会话(线程根)。
  1395. *
  1396. * 未指定 → 打开该 agent 最近的会话;一个都没有 → 新建一个(纯本地,不触网)。
  1397. * 显式指定但本地不存在(如分享了 /chat/:agentId/:sessionId 链接)→ 以只读方式
  1398. * 切过去,仍能按 thread 过滤显示消息,只是没有标题。
  1399. *
  1400. * 🔴 「历史对话」承接机制已于 2026-10-29 下线(用户裁决:这个条目不要、不能改名)。
  1401. * 无 thread 的旧消息不再单独成会话,也不再在列表中露出 —— 见 `dropLegacySessions`。
  1402. */
  1403. const myUserId = prov.userMxId ?? '';
  1404. /* ④ 订阅实时事件(先订阅再读 timeline,避免中间漏事件) */
  1405. this.bindRoomSubscriptions(rid, my, myUserId);
  1406. /*
  1407. * ⑤ 读当前 timeline —— 先确定会话,再按会话(thread)过滤,**只取尾部一屏**。
  1408. *
  1409. * 🔴 会话必须在读 timeline **之后**确定:
  1410. * `/sync` 是异步的,首次打开时 timeline 往往还没回填完(实测为空)。
  1411. * 若在读取前就判「有没有无 thread 旧消息」→ 永远判成没有 →
  1412. * 「历史对话」不会被建立,旧消息在任何会话里都看不到(实测踩坑)。
  1413. *
  1414. * 🔴 按 thread 过滤是**多会话隔离的关键**:
  1415. * 同一个房间里会有多条会话的消息混在 timeline 里,
  1416. * 不按 `threadId` 过滤就会把别的会话的内容显示在当前会话里。
  1417. */
  1418. /*
  1419. * 🔴 首次读取放在这里只是为了让 `lastMessageAtOf()` 能用;
  1420. * 回溯之后**必须重新读一次**(见下方 ⑤-0 之后的 `rawTimeline` 赋值)——
  1421. * 否则补回来的历史不被采用,用户依然看到空白。
  1422. */
  1423. let rawTimeline = this.mx.timelineToMessages(rid);
  1424. /*
  1425. * 某会话在 timeline 里的**真实最后消息时间**(2026-11-07)。
  1426. *
  1427. * `adopt()` 收养会话时用它作 `updatedAt`,而不是 `Date.now()` ——
  1428. * 否则每打开一次就把会话推到左栏第一位(用户反馈的排序问题:
  1429. * 「只是点开看看,它就跳到最上头了」)。
  1430. * 取不到时返回 undefined,由 adopt 自行兜底。
  1431. */
  1432. const lastMessageAtOf = (sessionId: string): number | undefined => {
  1433. let max = 0;
  1434. for (const m of rawTimeline) {
  1435. if (!this.belongsToSession(m, sessionId)) continue;
  1436. // `MxMessage.ts` 是毫秒数(不是字符串),无需解析
  1437. if (Number.isFinite(m.ts) && m.ts > max) max = m.ts;
  1438. }
  1439. return max > 0 ? max : undefined;
  1440. };
  1441. /*
  1442. * ===== 会话目录:**云端表是唯一来源**(2026-11-01 定稿)=====
  1443. *
  1444. * 用户裁决:
  1445. * ① 云端不可达 → **完全不显示会话**(宁可「加载失败」,也不展示可能过期的缓存);
  1446. * ② **彻底关闭**「从 Matrix 房间扫描重建会话」—— 以后新建的会话只由「发消息」产生。
  1447. *
  1448. * 所以这里只剩一件事:拉一次云端。扫不扫描、要不要补推,这里一概不做。
  1449. * 任何「猜」的逻辑都是历史上会话反复增生的来源(扫描 → upsert → 翻倍)。
  1450. */
  1451. await this.sessions.hydrateFromCloud(agentId, sessionId);
  1452. if (my !== this.seq) return;
  1453. /*
  1454. * 云端不可达 → **不显示任何会话**(用户裁决 2026-11-01)。
  1455. *
  1456. * 这里给的是「会话目录加载失败」而不是空态:空态意味着「你确实还没有对话」,
  1457. * 而实际是「我们不知道你有没有」。两者文案必须区分,否则用户会以为自己
  1458. * 的对话被删了(历史上反复出现),从而去重复新建,进一步制造脏数据。
  1459. */
  1460. if (!this.sessions.ready() && this.sessions.error()) {
  1461. /*
  1462. * 显式指定了会话(URL 里有线程根)时**不清空选中**(2026-11-06):
  1463. * 目录不可信只影响「有哪些会话」,不影响「用户在打开哪一条」。
  1464. * 清空会让页面渲染成「还没有对话」,输入框整块消失。
  1465. */
  1466. const keep = sessionId ?? '';
  1467. this._sessionId.set(keep);
  1468. this._messages.set(keep ? this.demoRestored(keep) : []);
  1469. this._state.set('error');
  1470. this._error.set(`会话目录加载失败:${this.sessions.error()}`);
  1471. this._connection.set(this.mx.syncState() === 'connected' ? 'connected' : 'connecting');
  1472. this._hasMoreHistory.set(false);
  1473. return;
  1474. }
  1475. /*
  1476. * 🔴 「历史对话」承接机制已**整体下线**(2026-10-29 用户裁决)。
  1477. *
  1478. * 原逻辑:房间里有不带 thread 的旧消息时,自动建一个 legacy 会话承接它们。
  1479. * 问题:这个条目在多会话体系里既不能改名、也不能归档,却混在正常对话列表里 ——
  1480. * 用户看到的就是一个叫「历史对话」的怪东西。
  1481. *
  1482. * 现在的处理:**不再创建** legacy 会话;已有 legacy 会话在打开时清理掉。
  1483. * 旧的无线程消息不再在界面上出现(消息本身仍在服务器,未删除)。
  1484. */
  1485. this.dropLegacySessions(agentId);
  1486. let sid = sessionId;
  1487. if (!sid) {
  1488. /*
  1489. * 🔴 进入容器时**不再自动新建空会话**(2026-10-31 用户裁决)。
  1490. *
  1491. * 原实现 `ensureFor()` 在没有会话时会 create() 一条「新对话」——
  1492. * 结果每进一次容器就多一条空的「新对话」,还排在列表最上面。
  1493. * 现在:
  1494. * · 有会话 → 打开最近一条;
  1495. * · 一条都没有 → 不建,交给页面显示「空态 + 开始新对话」,
  1496. * 用户点了「新建对话」才 create(显式操作优先)。
  1497. */
  1498. const latest = this.sessions.latestFor(agentId);
  1499. if (!latest) {
  1500. /*
  1501. * 🔴 不建会话,但**不能提前 return**:连接状态、未读基线等收尾逻辑
  1502. * (⑥⑦)仍要执行,否则界面会停在 connecting,看起来像连不上。
  1503. * `sid` 留空 → 后续按会话过滤得到空列表,state=empty。
  1504. */
  1505. this._sessionId.set('');
  1506. this._messages.set([]);
  1507. this._state.set('empty');
  1508. this._connection.set(this.mx.syncState() === 'connected' ? 'connected' : 'connecting');
  1509. this._hasMoreHistory.set(false);
  1510. this.unread.markRead(agentId, Date.now());
  1511. this.ensureUnreadListener();
  1512. return;
  1513. }
  1514. sid =
  1515. latest.roomId === rid
  1516. ? latest.id
  1517. : this.sessions.adopt({
  1518. id: latest.id,
  1519. roomId: rid,
  1520. agentId,
  1521. lastMessageAt: lastMessageAtOf(latest.id),
  1522. }).id;
  1523. } else if (!this.sessions.byId(sid) || this.sessions.byId(sid)!.messageCount === 0) {
  1524. /*
  1525. * 🔴 已软删的会话**不允许经由 URL 重新打开**(2026-11-09 用户裁决:
  1526. * 「列表项里面隐藏,并且不能通过 URL 直达」)。
  1527. *
  1528. * ⚠️ 判据必须是「**本地目录里根本没有这条**」,不能把「有这条但 0 条消息」
  1529. * 也算进来 —— 新建对话恰恰就是 0 条消息的会话,原来那样写会让
  1530. * 用户点「新建对话」后看到「该会话已删除」(实测反馈 2026-11-09)。
  1531. *
  1532. * 三重条件缺一不可:
  1533. * ① 本地目录里没有这条(byId 取不到)
  1534. * ② 云端墓碑里有它
  1535. * ③ 不在本机「新建中」状态里(`_blankSessionId`)
  1536. */
  1537. const known = this.sessions.byId(sid);
  1538. if (!known && this._blankSessionId !== sid && this.sessions.isCloudRemoved(sid)) {
  1539. if (my !== this.seq) return;
  1540. this._sessionId.set(null);
  1541. this._agentId.set(agentId);
  1542. this._state.set('empty');
  1543. this._emptyReason.set('removed');
  1544. this._messages.set([]);
  1545. return;
  1546. }
  1547. /*
  1548. * 🔴 云端目录里暂时没有这条会话 —— 仍按地址打开它(2026-11-06)。
  1549. *
  1550. * 场景:刷新页面时本地目录已载入,云端拉取把「云端没有的」条目整体替换掉,
  1551. * 随后 `open()` 开头的空壳清理才发现这条被当成空会话删了。
  1552. * 原来这里 adopt 之后又被清理逻辑抹掉 → `_sessionId` 悬空 → 输入区消失。
  1553. * 现在只要地址里明确给了线程根,就承认它:消息仍然按 thread 过滤展示,
  1554. * 云端目录后续到达时自动补齐标题等元信息。
  1555. */
  1556. this.sessions.adopt({
  1557. id: sid,
  1558. roomId: rid,
  1559. agentId,
  1560. messageCount: 1,
  1561. lastMessageAt: lastMessageAtOf(sid),
  1562. });
  1563. }
  1564. if (my !== this.seq) return;
  1565. this._sessionId.set(sid);
  1566. /*
  1567. * 🔴 ⑤-0 按会话回溯本地 timeline(2026-11-12 用户实测:「历史记录都无法查看」)。
  1568. *
  1569. * 问题:`startClient({ initialSyncLimit: 50 })` 只把房间**最近 50 条**
  1570. * 放进本地 timeline,而 `timelineToMessages()` 只读本地、**从不主动向前翻**。
  1571. * 实测房间有 1252 条消息,老会话的最新一条排在第 52 / 68 位 —— 压根不在本地,
  1572. * 打开就是空白,还被渲染成「全新会话」欢迎页(`emptyReason` 判成了 `fresh`)。
  1573. *
  1574. * 必须在 `sid` 确定之后做(要按会话过滤判断「命中没有」),
  1575. * 且必须在下面读 `rawTimeline` **之前**做。
  1576. */
  1577. await this.backfillSessionTimeline(rid, sid, my);
  1578. /* 回溯期间用户可能已切走 —— 过期就不继续(沿用全局 `seq` 版本号约定) */
  1579. if (my !== this.seq) return;
  1580. /* 🔴 回溯后**重新读取**:不重读就等于没回溯(历史仍取不到) */
  1581. rawTimeline = this.mx.timelineToMessages(rid);
  1582. /*
  1583. * 🔴 会话目录被整表替换后,会话可能连着被删两次(2026-11-06)。
  1584. * 循环里 `byId` 只在那一刻成立:清理在别处跑过一轮,它就不在了。
  1585. * 这里兜底一次 —— 选中了就必须有一条可渲染的会话,否则页面渲染成
  1586. * 「还没有对话」,输入区整块消失(刷新场景实测反复出现)。
  1587. */
  1588. if (!this.sessions.byId(sid)) {
  1589. this.sessions.adopt({
  1590. id: sid,
  1591. roomId: rid,
  1592. agentId,
  1593. messageCount: 1,
  1594. lastMessageAt: lastMessageAtOf(sid),
  1595. });
  1596. }
  1597. /*
  1598. * 重建「本会话的容器线程根」集合(2026-11-08)。
  1599. *
  1600. * 🔴 必须在过滤**之前**建好,否则第一轮过滤时集合为空,
  1601. * 容器子线程的内容会被全部挡掉(比「串入旧会话」更糟)。
  1602. *
  1603. * 判据:这条容器线程根下,**存在属于本会话的用户消息**。
  1604. * 也就是「用户在这个会话里发过东西,容器才为它开了子线程」。
  1605. */
  1606. this._ownedThreadRoots.clear();
  1607. {
  1608. // 先按 turnId/thread 关系把「本会话的用户消息」找出来
  1609. const mine = new Set<string>();
  1610. for (const m of rawTimeline) {
  1611. if (m.sender !== myUserId) continue;
  1612. if (m.threadId === sid || m.sessionId === sid) {
  1613. mine.add(m.eventId);
  1614. if (m.threadId) this._ownedThreadRoots.add(m.threadId);
  1615. }
  1616. }
  1617. // 容器线程根:出现在本会话用户消息的 dispatch/turnId 关联里才算归属
  1618. for (const m of rawTimeline) {
  1619. const root = m.threadId;
  1620. if (!root || isAppSessionId(root)) continue;
  1621. // 该根下有任何消息的 turnId/sessionId 指向本会话 → 认领
  1622. if (m.sessionId === sid || mine.has(m.eventId)) this._ownedThreadRoots.add(root);
  1623. }
  1624. }
  1625. const all = this.mapTimeline(
  1626. rawTimeline.filter((m) => this.belongsToSession(m, sid)),
  1627. myUserId,
  1628. );
  1629. /*
  1630. * 🔴 初次进入**不截断**(2026-11-03 修复)。
  1631. *
  1632. * 原实现 `slice(-INITIAL_PAGE)` 只保留最近 30 条 —— 早于这个窗口的消息
  1633. * (比如用户几天前发的图片)在进入会话时**直接消失**,要一路点「加载更早」
  1634. * 才可能翻到,实际表现为「图片完全看不到」。
  1635. *
  1636. * sync 已按 `initialSyncLimit: 50` 限定了房间事件量,这里再截一次纯属重复限制;
  1637. * 更早的历史仍由 `paginate()` 按需向上翻。
  1638. */
  1639. this._messages.set(all);
  1640. /*
  1641. * 🔴 历史消息里的云盘链接也要认(2026-11-09)。
  1642. *
  1643. * `appendIncoming` 只处理**实时到达**的消息。刷新页面或重新打开一条
  1644. * 老会话时,消息走的是这条回读路径 —— 如果不在这里提取,
  1645. * 「打开会话 → 右栏成果空 → 再发一条新消息才出现」,用户会以为成果丢了。
  1646. */
  1647. for (const m of all) {
  1648. if (m.sender !== 'agent') continue;
  1649. /* 与实时路径同口径:优先容器交付段落,否则正则兜底 + 文件判定 */
  1650. const delivered = extractDeliveredLinks(m.text);
  1651. if (delivered.length > 0) {
  1652. this.sessions.addDeliverableUrl(sid, ...delivered);
  1653. } else {
  1654. for (const u of extractS3Links(m.text)) {
  1655. if (!isUserUploadUrl(u) && this.cardOfCloudLink(u)) {
  1656. this.sessions.addDeliverableUrl(sid, u);
  1657. }
  1658. }
  1659. }
  1660. }
  1661. // ⑥ 连接状态由 SDK 同步事件驱动
  1662. this._connection.set(this.mx.syncState() === 'connected' ? 'connected' : 'connecting');
  1663. /*
  1664. * 「空」有两种,界面上必须区分(2026-11-09):
  1665. * ① 真正新建的会话 —— 显示「下午好,我是 xxx」引导页
  1666. * ② 有历史但一条也取不到 —— 只是空着,不能伪装成新建
  1667. *
  1668. * 判据(2026-11-12 修正):**这条会话在本地 timeline 里到底有没有消息**。
  1669. *
  1670. * 🔴 原判据是 `rawTimeline.length > 0`(房间里有没有任何事件)——
  1671. * 它恒为真(最近 50 条任何会话的都算),等于没有判断,
  1672. * 结果「有历史但取不到」也被渲染成「全新会话欢迎页」,
  1673. * 用户以为记录丢了(实测:51 条会话里大部分都这样)。
  1674. *
  1675. * 现在:
  1676. * · 有消息 → 正常渲染
  1677. * · 无消息,但房间里**确实有本会话的事件** → `unloaded`(诚实说「取不到」)
  1678. * · 无消息,但**会话目录说它该有内容** → `unloaded`(诚实说「取不到」)
  1679. * · 无消息,且目录里也没有它的记录 → `fresh`(真的是新会话)
  1680. *
  1681. * 注意不能用 `messageCount`(实测虚高:云端记 21 条、Matrix 实际 2 条),
  1682. * 所以只看「目录里有没有这条记录」这个二值事实,不比对条数。
  1683. */
  1684. const hasSessionEvent = rawTimeline.some((m) => this.belongsToSession(m, sid, true));
  1685. /*
  1686. * 「是不是真新会话」看**会话目录**:本地目录里有这条记录 = 它出现过。
  1687. * (新会话在落库前只有 `_blankSessionId` 标记,不会出现在目录里。)
  1688. *
  1689. * 🔴 三种空态要分清(2026-11-12 实测,用户会直接看这句话判断记录丢没丢):
  1690. * · 回溯后**找到了**本会话事件但被归属规则挡掉 → `unloaded`(取不到)
  1691. * · 已翻到房间最早、目录里有这条、仍无任何它的消息 → `fresh`(它本来就没有消息)
  1692. * · 没翻到最早(可能还有更早的历史没找) → `unloaded`(诚实说还没找到)
  1693. *
  1694. * 「已翻到房间最早」用 `paginationToken == null` 判定 —— 这时
  1695. * **整个房间都找过了**,没有就是真的没有,不该再吓唬用户说「取不到」。
  1696. */
  1697. const reachedRoomStart = this._reachedRoomStart;
  1698. const knownInCatalog = !!this.sessions.byId(sid);
  1699. /*
  1700. * 🔴 新建中的会话不能判成「取不到」(2026-11-13 修复)。
  1701. *
  1702. * `backfillSessionTimeline()` 对 `_blankSessionId` 会**直接 return**(本该如此,
  1703. * 否则会认领旧内容),但它同时让 `_reachedRoomStart` 停在 `false` ——
  1704. * 于是刚点「新建对话」就必然落进下面的 `unloaded` 分支,界面直接显示
  1705. * 「这个消息暂时没加载出来 / 可能是网络波动」。
  1706. * 用户看到的是「新建会话报错了」,而且这个错误态一直挂着,
  1707. * 直到用户真的发出第一条消息才会变成正常布局。
  1708. *
  1709. * 新建中的会话**本来就没有历史**,正确语义是 `fresh`(走欢迎页)。
  1710. */
  1711. const isBlank = this._blankSessionId === sid;
  1712. this._emptyReason.set(
  1713. this._messages().length > 0
  1714. ? null
  1715. : isBlank
  1716. ? 'fresh'
  1717. : hasSessionEvent
  1718. ? 'unloaded'
  1719. : reachedRoomStart && knownInCatalog
  1720. ? /*
  1721. * 已翻到房间最早 + 目录里有这条 + 一条消息都没有 = **空壳会话**。
  1722. * 不能说「暂时取不到」(用户会一直等,以为数据丢了),
  1723. * 也不能说成「新会话」(会诱导用户在这里重新开始)。
  1724. */
  1725. 'blank'
  1726. : 'unloaded',
  1727. );
  1728. this._state.set(this._messages().length === 0 ? 'empty' : 'ready');
  1729. // timeline pagination token 是否存在 = 更早可能有历史
  1730. this._hasMoreHistory.set(this.mx.paginationToken(rid) != null);
  1731. // ⑦ 进入会话 = 已读:基线推进到最新消息时间(清未读计数)
  1732. const latest = this._messages().reduce((max, m) => {
  1733. const t = Date.parse(m.ts);
  1734. return Number.isFinite(t) && t > max ? t : max;
  1735. }, 0);
  1736. this.unread.markRead(agentId, latest || Date.now());
  1737. this.ensureUnreadListener();
  1738. }
  1739. /**
  1740. * 全局未读监听:Agent 消息到达且**当前不在该会话**时 +1。
  1741. *
  1742. * 只注册一次(幂等);由 `MatrixClientService` 的全局消息流多路复用,
  1743. * 故不会随会话切换重复挂监听。
  1744. *
  1745. * 由 `MatrixSessionService.bootstrap()` 在**建连前**调用 —— 保证连接建立后
  1746. * 到用户首次进入会话之间的消息也会被计入未读。
  1747. */
  1748. ensureUnreadListener(): void {
  1749. if (this.unbindUnread) return;
  1750. this.unbindUnread = this.mx.onAnyMessage((m) => {
  1751. const myUserId = this.provision?.userMxId ?? '';
  1752. // 只计 Agent 发来的
  1753. if (myUserId && m.sender === myUserId) return;
  1754. const agentId = this.agentIdByRoom(m.roomId);
  1755. if (!agentId) return;
  1756. /*
  1757. * 🔴 群聊派活产生的消息不计入单聊未读(2026-11-06)。
  1758. * 这条回调不走 `belongsToSession`,不单独拦的话
  1759. * 「群里派了活」会让该容器的单聊会话冒出未读红点。
  1760. */
  1761. if (m.dispatch || this.isAfterLastDispatch(m)) return;
  1762. // 正在看的会话 → 直接推进已读基线,不产生未读
  1763. if (agentId === this._agentId()) {
  1764. this.unread.markRead(agentId, m.ts);
  1765. return;
  1766. }
  1767. // 判定交给 UnreadService:初次同步回填阶段只推基线、不计数
  1768. this.unread.onIncoming(agentId, m.ts, !this.mx.hasInitialSync);
  1769. });
  1770. }
  1771. /** roomId → agentId(用已记录的会话映射反查) */
  1772. private agentIdByRoom(roomId: string): string | undefined {
  1773. for (const [agentId, rid] of this.roomAgentMap) {
  1774. if (rid === roomId) return agentId;
  1775. }
  1776. return undefined;
  1777. }
  1778. // ---------- agentId ↔ roomId 映射持久化 ----------
  1779. private roomMapKey(): string {
  1780. const uid = this.session.me()?.id ?? 'anon';
  1781. return `fmode_room_map_${uid}`;
  1782. }
  1783. private persistRoomMap(): void {
  1784. try {
  1785. localStorage.setItem(this.roomMapKey(), JSON.stringify([...this.roomAgentMap]));
  1786. } catch {
  1787. /* 隐私模式忽略 */
  1788. }
  1789. }
  1790. /** 从 localStorage 载入映射(Shell 启动时调用一次) */
  1791. loadRoomMap(): void {
  1792. try {
  1793. const raw = localStorage.getItem(this.roomMapKey());
  1794. if (!raw) return;
  1795. for (const [agentId, rid] of JSON.parse(raw) as [string, string][]) {
  1796. if (agentId && rid) this.roomAgentMap.set(agentId, rid);
  1797. }
  1798. } catch {
  1799. /* 忽略 */
  1800. }
  1801. }
  1802. /**
  1803. * 换账号时彻底复位(2026-11-01)。
  1804. *
  1805. * 🔴 服务都是单例、Shell 不会因登出重建,所以退出再登录时这里全是上一个账号的残留:
  1806. * 会话指针、消息缓冲、房间映射、(经由 `sessions.reset()`)会话目录。
  1807. * 不复位就会先闪出别人的对话,再被云端结果覆盖 —— 看起来像「会话串号」。
  1808. *
  1809. * ⚠️ 不断开 Matrix 连接:连接的生命周期由 `MatrixSessionService` 管。
  1810. */
  1811. resetForUserSwitch(): void {
  1812. this.close();
  1813. this.roomAgentMap.clear();
  1814. this.sessions.reset();
  1815. }
  1816. /**
  1817. * 关闭会话(离开聊天页)。
  1818. *
  1819. * 🔴 **不断开 Matrix 会话级连接** —— sync 常驻,否则离开后收不到消息,
  1820. * 未读计数永远无法累加。只解绑本房间的事件订阅。
  1821. */
  1822. close(): void {
  1823. this.seq++;
  1824. this.teardownSubscriptions();
  1825. this.provision = null;
  1826. this.roomId = null;
  1827. this._agentId.set(null);
  1828. // 🔴 会话 id 必须一起清:残留会在下次打开时被误当成「已选定」的会话
  1829. this._sessionId.set(null);
  1830. this._messages.set([]);
  1831. this._state.set('idle');
  1832. this._error.set(null);
  1833. this._connection.set('disconnected');
  1834. this._draft.set('');
  1835. this._peerTyping.set(false);
  1836. this._awaitingReplySince.set(0);
  1837. this._turnEndedAt.set(0);
  1838. this._turnEndedAfter.set(null);
  1839. this._paginating.set(false);
  1840. this._hasMoreHistory.set(false);
  1841. this._blankWorkspace = null;
  1842. /*
  1843. * 🔴 新建态标记必须复位(2026-11-08):只在 `sendText` commit 后清除时,
  1844. * 用户「新建对话 → 不发消息 → 离开页面」会把标记留在内存里,
  1845. * 下次万一撞上同一条会话,实时消息会被无声丢弃。
  1846. */
  1847. this._blankSessionId = null;
  1848. }
  1849. setDraft(text: string): void {
  1850. this._draft.set(text);
  1851. // 真实态下发 typing 通知(失败非致命)
  1852. if (this.roomId && !environment.useMockData) {
  1853. void this.mx.setTyping(this.roomId, text.length > 0);
  1854. }
  1855. }
  1856. /** 发送文本(AC-04.2);失败留痕可重试(AC-04.8) */
  1857. /**
  1858. * 向**指定容器**发送一条消息,**不影响当前会话状态**(2026-11-05)。
  1859. *
  1860. * ## 为什么需要
  1861. *
  1862. * `sendText()` 绑定在 `_agentId` 上 —— 它只能发给「当前打开的那个容器」。
  1863. * 伪群聊需要**同时**给 N 个容器各发一条,且不能来回切换当前会话
  1864. * (那会打断正在看的会话、触发重连、搅乱消息流)。
  1865. *
  1866. * ## 实现
  1867. *
  1868. * 走 provision 直接拿房间,发一条**房间级消息**(不带 thread):
  1869. * · 群聊里每个容器都只处理自己的 DM,不需要线程隔离
  1870. * · 不写入 `_messages`(那不是当前会话的消息)
  1871. *
  1872. * @returns 发送成功返回 true;容器未接入 / provision 失败返回 false
  1873. */
  1874. async sendToAgent(
  1875. agentId: string,
  1876. text: string,
  1877. meta?: MxDispatchMeta,
  1878. ): Promise<DispatchResult> {
  1879. const body = text.trim();
  1880. if (!body) return { ok: false, baselineEventId: null, error: '空消息' };
  1881. try {
  1882. const room = await this.ensureAgentRoomFor(agentId);
  1883. if (!room) return { ok: false, baselineEventId: null, error: '容器未接入' };
  1884. const rid = room;
  1885. /*
  1886. * 🔴 发送**前**捕获锚点(2026-11-06)。
  1887. *
  1888. * 群聊补读要靠它判断「哪些事件是本轮之后的新回复」。
  1889. * 用事件顺序而非时间 —— 容器与浏览器时钟不一致时,时间窗会漏收。
  1890. * timeline 尚未就绪时返回 null,下游退化为宽限时间窗。
  1891. */
  1892. const baselineEventId = this.mx.lastMessageEventId(rid);
  1893. const res = await this.mx.sendText(
  1894. rid,
  1895. body,
  1896. undefined,
  1897. undefined,
  1898. meta ? { [DISPATCH_KEY]: meta } : undefined,
  1899. );
  1900. return { ok: true, eventId: res.event_id, baselineEventId, roomId: rid };
  1901. } catch (e) {
  1902. return {
  1903. ok: false,
  1904. baselineEventId: null,
  1905. error: e instanceof Error ? e.message : '发送失败',
  1906. };
  1907. }
  1908. }
  1909. /**
  1910. * 确保「与该容器的 DM 房间」已就绪(provision → 连接 → 房间)。
  1911. *
  1912. * 2026-11-07 从 `sendToAgent` 抽出:群聊重建历史、群聊发附件都要用同一条
  1913. * 路径。**不要另写一份** —— provision 与 connectSession 的幂等语义
  1914. * (同 token 不重建客户端)是靠 `MatrixClientService` 内部保证的,
  1915. * 多写一份就会各自踩坑。
  1916. *
  1917. * @returns 房间 id;容器未接入或接入失败时 null(调用方决定是否中断)
  1918. */
  1919. async ensureAgentRoomFor(agentId: string, opts: { skipConnect?: boolean } = {}): Promise<string | null> {
  1920. const prov = await this.api.provisionMatrix(agentId);
  1921. const agentMxId = prov.agentMxId ?? '';
  1922. if (!agentMxId) return null;
  1923. /*
  1924. * 🔴 读历史时不要重连(2026-11-07)。
  1925. *
  1926. * `connectSession` 在 token 变化时会 **disconnect 重建客户端**,
  1927. * 而重建会中断正在进行的 `/sync` —— 群重建要遍历 N 个成员,
  1928. * 逐个重连的结果是 sync 永远完不成,读出来的 timeline 全是空的。
  1929. * 读历史只需要客户端已连接(消息收发本来就共用同一个用户身份)。
  1930. */
  1931. /*
  1932. * 🔴 skipConnect 只表示「**已连接时**不要重建」。客户端根本不存在时
  1933. * 仍然必须连 —— 否则 `ensureRoom` 里的 requireClient 抛错,
  1934. * 重建拿到一堆空房间,把群历史覆盖成空(评审 2026-11-07 发现)。
  1935. */
  1936. if (!opts.skipConnect || !this.mx.connected) {
  1937. this.mx.setRecoveryAgentId(agentId);
  1938. await this.mx.connectSession(prov.accessToken, prov.userMxId ?? '', prov.deviceId);
  1939. }
  1940. this.roomAgentMap.set(agentId, prov.roomId ?? '');
  1941. return this.mx.ensureRoom(agentMxId, {
  1942. roomId: prov.roomId,
  1943. agentName: this.live.agentById(agentId)?.name,
  1944. });
  1945. }
  1946. /**
  1947. * 群聊发附件:把一个文件发进**某个**成员的 DM 房间(2026-11-07)。
  1948. *
  1949. * 调用方对每个目标成员各调一次(同一份文件会在每个房间各上传一份 ——
  1950. * Matrix 没有跨房间引用,这是平台约束)。
  1951. *
  1952. * 事件里带 `dispatch`(让重建能把它识别成本回合的用户内容)和
  1953. * `sessionId = turnId`(让单聊过滤规则把它挡在单聊之外)。
  1954. */
  1955. /**
  1956. * 群聊派活:把一个文件发进**某个**成员的 DM 房间。
  1957. *
  1958. * @param cloudUrl 已上传到个人存储的地址。**`'url'` 模式必填**(缺失即失败,
  1959. * 不做静默降级);`'mxc'` 回退模式忽略它,由 `sendFile()` 自行上传。
  1960. */
  1961. async sendFileToAgent(
  1962. agentId: string,
  1963. file: File,
  1964. meta: MxDispatchMeta,
  1965. cloudUrl: string,
  1966. ): Promise<DispatchResult> {
  1967. try {
  1968. const rid = await this.ensureAgentRoomFor(agentId);
  1969. if (!rid) return { ok: false, baselineEventId: null, error: '容器未接入' };
  1970. const baselineEventId = this.mx.lastMessageEventId(rid);
  1971. const res = await this.sendCloudFile(
  1972. rid,
  1973. file,
  1974. cloudUrl,
  1975. { name: file.name, size: file.size, mimeType: file.type || 'application/octet-stream' },
  1976. undefined,
  1977. { [DISPATCH_KEY]: meta, [SESSION_KEY]: meta.turnId },
  1978. );
  1979. return { ok: true, eventId: res.event_id, baselineEventId, roomId: rid };
  1980. } catch (e) {
  1981. return {
  1982. ok: false,
  1983. baselineEventId: null,
  1984. error: e instanceof Error ? e.message : '附件发送失败',
  1985. };
  1986. }
  1987. }
  1988. async sendText(text: string): Promise<void> {
  1989. await this.sendTextInternal(text, false);
  1990. }
  1991. /**
  1992. * 强制发送(跳过「等待中」拦截)。
  1993. *
  1994. * 供 `sendHeldNow()`(用户点「打断发送」)与 `armHeldAutoSend()`(自动发出)使用。
  1995. */
  1996. private async forceSendText(text: string): Promise<void> {
  1997. await this.sendTextInternal(text, true);
  1998. }
  1999. /**
  2000. * @param force 跳过「本会话正在执行 → 先拦住」的检查
  2001. */
  2002. private async sendTextInternal(text: string, force: boolean): Promise<void> {
  2003. const body = text.trim();
  2004. /*
  2005. * 只有附件、没有文字也要能发 —— 用户选完图直接点发送是常见操作。
  2006. * 两者都没有才返回。
  2007. */
  2008. if (!body && this._pendingFiles().length === 0) return;
  2009. /*
  2010. * 🔴 新建会话正在途中 → **等它完成**再继续(2026-11-11 用户实测)。
  2011. *
  2012. * 用户点「新建对话」后立刻输入按回车时,`startBlank()` 内部的
  2013. * `await open()`(provision + Matrix 连接)还没跑完,`_agentId` 还是空。
  2014. * 原实现在下面直接 `return` —— 文字不清空、消息不显示,
  2015. * 用户以为「按了没反应」(实测:文字一直留在输入框里)。
  2016. *
  2017. * 现在最多多等那一两秒,但消息一定会发出去。
  2018. */
  2019. if (this._blankInFlight) {
  2020. try {
  2021. await this._blankInFlight;
  2022. } catch {
  2023. /* 新建失败由 startBlank 自己报错,这里继续走正常流程看能否兜住 */
  2024. }
  2025. }
  2026. const conversationId = this._agentId();
  2027. if (!conversationId) return;
  2028. /*
  2029. * 🔴 本会话正在执行 → **先拦住**,不直接发(2026-11-13 用户设计)。
  2030. *
  2031. * 容器 `busy_input_mode=interrupt`:直接发会打断正在跑的任务。
  2032. * 这里改成「存为待发 + 提示条让用户选」:
  2033. * · 打断发送 → sendHeldNow()
  2034. * · 撤回 → withdrawHeld()
  2035. * · 都不点 → 会话空闲后由 armHeldAutoSend() 自动发出
  2036. *
  2037. * ⚠️ 附件(`_pendingFiles`)不走这条路径:附件已上传到待发区,
  2038. * 单纯文字才拦截 —— 混合发送保持原行为,避免附件重复上传。
  2039. */
  2040. if (!force && body && this._pendingFiles().length === 0 && this.sessionBusy()) {
  2041. this._heldMessage.set({ text: body, at: Date.now() });
  2042. return;
  2043. }
  2044. /*
  2045. * 乐观消息 id(2026-11-09)。
  2046. *
  2047. * 🔴 原来用 `local-${Date.now()}`:连点两次(同一毫秒)会生成**同一个 id**,
  2048. * 两条消息互相顶替;而且这个 id 和它自己的服务端 echo(`$eventId`)对不上,
  2049. * 合并时双双保留 → 一条「你好」显示成 3 条。
  2050. * 现在带上随机串,并把这个 id 随事件写进 content,回读时按它认领。
  2051. */
  2052. const localId = `local-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
  2053. const msg: Message = {
  2054. id: localId,
  2055. conversationId,
  2056. sender: 'me',
  2057. kind: 'text',
  2058. // 纯附件时不插入空气泡(附件会在上传后各自成条)
  2059. text: body || undefined,
  2060. ts: new Date().toISOString(),
  2061. state: 'sending',
  2062. localId,
  2063. };
  2064. if (body) this._messages.update((m) => [...m, msg]);
  2065. /*
  2066. * 🔴 发出新消息 → **立刻清掉上一轮的结束信号**(2026-11-13 时机修复)。
  2067. *
  2068. * ## 问题
  2069. *
  2070. * 用户实测:「发了一个消息之后**立刻发新的消息**不会拦住」,
  2071. * 而任务运行中发消息是能拦住的。
  2072. *
  2073. * ## 根因
  2074. *
  2075. * `_turnEndedAfter`(上一轮的 ✅ 结束信号)只在「打开/切换/新建会话」
  2076. * 时重置,**发消息时不重置**。于是:
  2077. * ① 上一轮收到 ✅ → `turnEnded = true`
  2078. * ② 用户立刻再发 → 容器还没来得及把新消息标成 running
  2079. * ③ `resolveSessionPhase` 里 `turnEnded` **一票否决** → `busy = false`
  2080. * ④ 新消息不被拦截,直接发出去打断了容器
  2081. *
  2082. * 清掉它之后:`turnEnded` 不再否决,`busy` 回落到
  2083. * `sessionRunning` / `typing` / `turnLive` 的真实状态。
  2084. */
  2085. this._turnEndedAt.set(0);
  2086. this._turnEndedAfter.set(null);
  2087. /*
  2088. * 标记「已发出、尚未收到回复」(2026-11-14)。
  2089. * 填补「发出 → 容器开始跑」的真空期,让立刻再发的那条能被拦住。
  2090. */
  2091. this._awaitingReplySince.set(Date.now());
  2092. this._draft.set('');
  2093. // 演示态:留一份存量,刷新后能恢复(真实态历史以 Matrix 为准)
  2094. this.rememberDemoHistory();
  2095. this._sending.set(true);
  2096. /*
  2097. * 会话在**第一次发消息时**才创建(2026-11-01 用户裁决)。
  2098. *
  2099. * 🔴 取代了「点新建对话就 create() 一条空会话」:
  2100. * 那个做法必然产生空壳(用户点了又反悔、切走、关页面都会留下一条 0 消息的
  2101. * 「新对话」),而空壳又要靠 `pruneEmpty` 事后清理 —— 清理再出 bug 就会
  2102. * 变成「每进一次容器多一条」,这正是会话列表膨胀的来源之一。
  2103. *
  2104. * 改成懒创建后,**没有消息就不存在会话**,从根上不会有空壳。
  2105. *
  2106. * 顺序很关键:先建会话 → 再记账(`noteMessage` 才能把标题写进去)。
  2107. */
  2108. if (!this._sessionId()) {
  2109. const created = this.ensureSessionForSend();
  2110. if (created) {
  2111. this._sessionId.set(created.id);
  2112. /*
  2113. * 🔴 必须同时把状态从 empty 切到 ready(2026-11-01 修复)。
  2114. *
  2115. * `startBlank()` 把 state 置为 `'empty'`,而消息区是按 state 分派的:
  2116. * `@case ('empty')` 里「没有 currentSession → 显示新建引导」。
  2117. * 发完第一条消息后 currentSession 有了,但 state 还是 empty ——
  2118. * 页面继续渲染新建流程区,看起来像「发出去没反应」(用户实测反馈)。
  2119. *
  2120. * 建出会话 = 这已经是正常会话了,状态必须跟着走。
  2121. */
  2122. this._state.set('ready');
  2123. }
  2124. }
  2125. /*
  2126. * 会话元信息(标题/预览/时间)随**用户发出的消息**推进。
  2127. *
  2128. * 🔴 必须在这里记,不能只等容器回话:用户第一条消息就该定下对话名,
  2129. * 否则新建的对话一直显示「新对话」直到容器回复(实测踩坑)。
  2130. * `appendIncoming` 里另记一次是为了容器主动发起的情况。
  2131. */
  2132. const curSid = this._sessionId();
  2133. if (curSid) {
  2134. /*
  2135. * 🔴 首次发消息 = 这条会话**真正成立**(2026-11-01 用户裁决)。
  2136. *
  2137. * 新建对话只在本地可见(左侧立刻出现「新建对话」),此时还不上云。
  2138. * 用户发出第一条消息,才用 `commit()` 把它落库 ——
  2139. * 这样「点了不发消息就切走」不会在云端留下 0 条记录。
  2140. *
  2141. * 必须在 `noteMessage` **之前** commit:否则推上去的还是「新建对话 / 0 条」,
  2142. * 标题要等下一次推送才能更正(用户实测:历史会话名一直不对)。
  2143. */
  2144. this.sessions.noteMessage(curSid, {
  2145. text: body,
  2146. ts: Date.parse(msg.ts) || Date.now(),
  2147. fromMe: true,
  2148. });
  2149. /*
  2150. * 🔴 演示态:消息数必须落进本地目录(2026-11-06 实测)。
  2151. * 宿主页面刷新前只写内存,目录里仍是「0 条消息」;`open()` 开头的
  2152. * 空壳清理会把这条当「没发过消息的新建对话」删掉,恢复出来一片空白。
  2153. */
  2154. if (environment.useMockData) {
  2155. this.sessions.setMessageCount(curSid, this.demoRestored(curSid).length || 1);
  2156. }
  2157. /*
  2158. * 发出消息 = 这已是正常对话。
  2159. * 新建对话建出来时 state 是 empty(还没有消息),用户一发消息就该切到 ready ——
  2160. * 否则消息区继续按空态渲染,发出去的内容要等下一次刷新才看得见。
  2161. */
  2162. if (this._state() === 'empty') this._state.set('ready');
  2163. }
  2164. /*
  2165. * 🔴 用户一发出消息就进入「忙」——**这是事实,不是推测**(容器必定会收到)。
  2166. *
  2167. * 容器的运行态上报是分钟级(Hermes 按 turn 写库,再等下一轮上报),
  2168. * 不这么做的话「发了消息 → 右侧还是空闲」会持续很久(用户实测反馈)。
  2169. *
  2170. * 记在 `TaskPulseService` 里而非改 Agent 数据:服务端仍是最终真值,
  2171. * 只有容器报 `idle` 才能把它翻回去(前端绝不自己说「完成」)。
  2172. */
  2173. this.pulse.noteUserAction(conversationId);
  2174. /*
  2175. * 会话级标记(2026-11-11):左侧会话树的旋转图标也靠它立刻转起来。
  2176. * 容器级标记会让同行所有会话一起转 —— 那正是要避免的串台。
  2177. */
  2178. const currentSessionId = this._sessionId();
  2179. if (currentSessionId) this.pulse.noteSessionUserAction(currentSessionId);
  2180. try {
  2181. /*
  2182. * 🔴 首次发消息 = **先落库,落库成功才发送**(2026-11-09 用户要求)。
  2183. *
  2184. * 用户原话:「第一次发送消息的时候,才会开始持久化存储这个对话;
  2185. * 存储成功后,才会开始发送消息」。
  2186. *
  2187. * 原实现调 `commit()`(发出去就不管):消息照发、落库在后台跑。
  2188. * 一旦落库失败,消息已经进 Matrix,而目录里没有这条记录 ——
  2189. * 换设备就找不到这段对话,用户只在事后看到一条提示。
  2190. *
  2191. * 现在首次落库 `await`,失败直接落到下面的 catch:
  2192. * 消息标记为「发送失败」,**不往容器发**,用户可重试。
  2193. */
  2194. if (curSid && this.sessions.needsCommit(curSid)) {
  2195. await this.sessions.commitNow(curSid);
  2196. }
  2197. if (environment.useMockData) {
  2198. await this.simulateSend();
  2199. } else {
  2200. /*
  2201. * 🔴 「去问另一个容器」的指令由 App 搬运(实测结论,见 RelayService)。
  2202. *
  2203. * 用户说「问 lj-test:数据库什么版本」时:
  2204. * · 原消息**不发给当前容器**(它没有能力去找别人)
  2205. * · 由 App 旁路去问目标容器,把答复回填成一条 agent 消息
  2206. *
  2207. * 判断放在发送前,命中就不走普通发送。
  2208. */
  2209. /*
  2210. * 🔴 先把暂存的附件发出去(用户要求:选文件 → 点发送 → 图文一起走)。
  2211. * 附件失败不阻塞文字发送,但要如实告知。
  2212. */
  2213. if (this._pendingFiles().length > 0) {
  2214. const { fail } = await this.uploadPending(conversationId);
  2215. /*
  2216. * 具体原因由 uploadPending 逐条写入(哪个文件、为什么失败);
  2217. * 这里只在**没有具体原因**时补一句汇总,避免覆盖掉有用信息。
  2218. */
  2219. if (fail > 0 && !this._error()) {
  2220. this._error.set(`${fail} 个附件上传失败,可重试`);
  2221. }
  2222. }
  2223. if (!body) {
  2224. // 纯附件:没有文字要发,到此为止
  2225. return;
  2226. }
  2227. const relayed = await this.tryRelay(body, msg, conversationId);
  2228. if (!relayed) await this.sendReal(body, msg.localId);
  2229. }
  2230. this.markSent(msg.id);
  2231. /*
  2232. * 🔴 这里**不解除**新建态保护(2026-11-08 第三次修正)。
  2233. *
  2234. * `sendReal()` 返回只代表「本机把事件发出去了」,服务端还没把它并入
  2235. * 房间 timeline。此刻解除保护,紧接着落地的 `/sync` 就会把该房间
  2236. * 所有旧容器线程根算成本会话的(实测 60ms 内灌入 17 条旧消息)。
  2237. *
  2238. * 解除点统一在 `storeVersion` effect 里:等 timeline 里真的出现
  2239. * `threadId === 本会话` 的消息,说明本会话已在服务端成立,
  2240. * 那时 `rootsOfSession` 只会认领由这条消息派生的子线程,旧历史天然排除。
  2241. */
  2242. } catch (e) {
  2243. this.markFailed(msg.id, msgOf(e));
  2244. /*
  2245. * 发送失败**也要解除**:事件没能落到服务端,timeline 永远不会出现本会话的
  2246. * 消息,再不解锁这条会话就永远收不到内容(容器仍可能从别的路径回复)。
  2247. * 失败残留的旧历史是可接受的次要问题,远好于「彻底静默」。
  2248. */
  2249. if (curSid && this._blankSessionId === curSid) this._blankSessionId = null;
  2250. } finally {
  2251. this._sending.set(false);
  2252. }
  2253. }
  2254. /**
  2255. * 尝试按「中转指令」处理;返回是否已处理。
  2256. *
  2257. * 未命中(普通消息)返回 false,调用方继续常规发送。
  2258. */
  2259. private async tryRelay(body: string, localMsg: Message, conversationId: string): Promise<boolean> {
  2260. const intent = this.relay.detect(body, this._agentId());
  2261. if (!intent) return false;
  2262. /*
  2263. * 本地先落一条「已转问」的痕迹 —— 让用户知道这条消息**没有**发给当前容器,
  2264. * 而是走了另一条路。原乐观消息保持 sending → 改成 sent 并改写文案。
  2265. */
  2266. const askerName = this.live.agentById(conversationId)?.name ?? '当前助手';
  2267. this._messages.update((list) =>
  2268. list.map((m) =>
  2269. m.id === localMsg.id
  2270. ? { ...m, text: `【转问 ${intent.target.name}】${intent.question}`, state: 'sent' as const }
  2271. : m,
  2272. ),
  2273. );
  2274. const answer = await this.relay.relay(intent, askerName);
  2275. /*
  2276. * 回填成一条 agent 消息。
  2277. *
  2278. * 🔴 必须走 `_messages.update` 而不是真实发送 —— 这条内容是 App 生成的,
  2279. * 发到房间里会污染 Matrix 历史(也会被容器当成新指令)。
  2280. */
  2281. this._messages.update((list) => [
  2282. ...list,
  2283. {
  2284. id: `relay-${Date.now()}`,
  2285. conversationId,
  2286. sender: 'agent' as const,
  2287. senderName: intent.target.name,
  2288. kind: 'notice' as const,
  2289. text: answer,
  2290. ts: new Date().toISOString(),
  2291. state: 'sent' as const,
  2292. },
  2293. ]);
  2294. if (this._state() === 'empty') this._state.set('ready');
  2295. const sid = this._sessionId();
  2296. if (sid) {
  2297. this.sessions.noteMessage(sid, {
  2298. text: answer,
  2299. ts: Date.now(),
  2300. fromMe: false,
  2301. });
  2302. }
  2303. return true;
  2304. }
  2305. // ---------- 附件:先暂存,发送时一起提交 ----------
  2306. /**
  2307. * 把文件存进云盘(公开入口,供群聊等其它发送链路复用)。
  2308. *
  2309. * 单聊的 `uploadPending` 与群聊派活都要「文件进云盘」,但群聊
  2310. * 只需存**一次**(单聊里每个房间的 Matrix 副本是另一回事)。
  2311. * 这里只负责云盘那一半,失败抛错由调用方决定文案。
  2312. */
  2313. async uploadToCloud(file: File): Promise<{ key: string; publicUrl: string }> {
  2314. if (isOversize(file)) throw new Error(oversizeMessage(file));
  2315. return this.api.uploadFile(file);
  2316. }
  2317. /**
  2318. * 发一条「文件已在个人存储」的附件事件(2026-11-13,单聊/群聊共用)。
  2319. *
  2320. * ## 为什么要按模式分发
  2321. *
  2322. * - `'url'`(默认):文件只在个人 storage 里存一份,事件 `content.url` 就是云盘地址,
  2323. * 本体**不进 Matrix**(用户裁决:只允许收发 cdn/obs 地址)。
  2324. * - `'mxc'`(回退):容器补丁未落地时,把 `file` 整份传进 Matrix 媒体库。
  2325. *
  2326. * 调用方**必须先成功拿到 `cloudUrl`** 才能调本方法 —— 保证「发出去的文件 = 能长期访问的文件」。
  2327. */
  2328. async sendCloudFile(
  2329. roomId: string,
  2330. file: File,
  2331. cloudUrl: string,
  2332. card: { name: string; size: number; mimeType: string },
  2333. threadRootId?: string,
  2334. extra?: Record<string, unknown>,
  2335. ): Promise<{ event_id?: string }> {
  2336. if (currentSendMode() === 'mxc') {
  2337. // 回退模式:文件整份进 Matrix,cloudUrl 只作 `com.fmode.cloud_url` 附带
  2338. return this.mx.sendFile(roomId, file, card, threadRootId, undefined, {
  2339. ...(cloudUrl ? { [CLOUD_URL_KEY]: cloudUrl } : {}),
  2340. ...(extra ?? {}),
  2341. });
  2342. }
  2343. if (!cloudUrl) throw new Error('「' + card.name + '」尚未上传到个人存储,未发送');
  2344. return this.mx.sendFileByUrl(roomId, cloudUrl, card, threadRootId, undefined, {
  2345. [CLOUD_URL_KEY]: cloudUrl,
  2346. ...(extra ?? {}),
  2347. });
  2348. }
  2349. /**
  2350. * 把已上传云盘的文件登记进文件台账(2026-11-10)。
  2351. *
  2352. * 单聊与群聊共用:`threadRoot` 传会话 id —— 单聊是对话 id,群聊是群会话 id,
  2353. * 这样右侧栏按会话查时互不串台。
  2354. */
  2355. /**
  2356. * 把已上传云盘的文件登记进文件台账(2026-11-10,2026-11-11 加群归属)。
  2357. *
  2358. * @param opts.groupId 群会话 id —— 群内上传时**必须**传,
  2359. * 否则群右栏刷新后看不到自己发的文件(只按 threadRoot 查不到)。
  2360. * @param opts.roomId 显式指定房间;群聊不能沿用单聊的 `this.roomId`
  2361. * (那是单聊 ChatService 的当前房间,群语境下不可靠)。
  2362. */
  2363. async registerSessionFile(
  2364. threadRoot: string,
  2365. file: File,
  2366. up: { key: string; publicUrl: string },
  2367. agentId?: string,
  2368. opts: { groupId?: string; roomId?: string } = {},
  2369. ): Promise<void> {
  2370. await this.api.registerFile({
  2371. threadRoot,
  2372. agentId,
  2373. roomId: opts.roomId ?? this.roomId ?? undefined,
  2374. groupId: opts.groupId,
  2375. name: file.name,
  2376. url: up.publicUrl,
  2377. key: up.key,
  2378. mimeType: file.type || 'application/octet-stream',
  2379. size: file.size,
  2380. uploadedBy: 'user',
  2381. });
  2382. }
  2383. /**
  2384. * 记一条**派发锚点**(2026-11-11,群聊文件归群的依据)。
  2385. *
  2386. * 伪群聊下容器只在自己的 DM 房间工作,回报文件时不带群信息。
  2387. * App 在派活那一刻知道「这条消息是替群 G 发给容器 A 的」,
  2388. * 于是先落一条零字节的锚点记录:`threadRoot = 群 id`、`groupId = 群 id`、
  2389. * `uploadedBy: 'agent'`(用 agent 侧标记,避免与用户上传混淆)。
  2390. *
  2391. * 之后容器产出文件时,服务端按「同 user + 同 roomId + 同 agentId +
  2392. * 时间晚于锚点且在窗口内」反查归属 —— 见 `fmode-file-hub` 的说明。
  2393. *
  2394. * 失败不阻断派活:锚点只是「提高归属准确率」的辅助信息,
  2395. * 没有它文件仍会登记,只是不归群(宁可漏,不归错)。
  2396. */
  2397. async registerGroupAnchor(
  2398. groupId: string,
  2399. agentId: string,
  2400. roomId: string | null | undefined,
  2401. turnId: string,
  2402. ): Promise<void> {
  2403. const anchorUrl = `https://s3.fmode.cn/__anchor__/${encodeURIComponent(groupId)}/${Date.now()}-${encodeURIComponent(turnId)}`;
  2404. try {
  2405. await this.api.registerFile({
  2406. threadRoot: groupId,
  2407. groupId,
  2408. groupIdSource: 'anchor',
  2409. agentId,
  2410. roomId: roomId ?? undefined,
  2411. name: '__anchor__',
  2412. url: anchorUrl,
  2413. mimeType: 'application/x-fmode-anchor',
  2414. size: 0,
  2415. uploadedBy: 'agent',
  2416. } as never);
  2417. } catch {
  2418. /* 锚点失败不影响派活 */
  2419. }
  2420. }
  2421. /**
  2422. * 选择附件(**只暂存,不上传、不发送**)。
  2423. *
  2424. * 用户要求:先选文件,点发送时才随文字一起发出去。
  2425. * 早期实现是「选完立刻上传并单发一条」—— 用户只想要图文一起发,那个行为是错的。
  2426. */
  2427. stageFiles(files: FileList | File[]): void {
  2428. const all = Array.from(files);
  2429. if (all.length === 0) return;
  2430. /*
  2431. * 大小拦截的第一层(2026-11-10):选文件时就挡掉超限项。
  2432. * 第二层在 uploadPending 里兜底(防暂存期替换/绕过)。
  2433. * 超限项**不进暂存区**,用户立刻看到原因 —— 比发出去再失败好。
  2434. */
  2435. const list = all.filter((f) => !isOversize(f));
  2436. const rejected = all.filter((f) => isOversize(f));
  2437. if (rejected.length > 0) {
  2438. /*
  2439. * 🔴 走**全局提示**(2026-11-11 修复)。
  2440. *
  2441. * 原来只写 `_error`,而 `_error` 仅在「消息区整体 error 空态」渲染 ——
  2442. * 正常会话里用户完全看不到,文件选进来一按发送就消失,毫无解释。
  2443. * 全局提示挂在根节点,任何页面都看得见、可手动关。
  2444. * `_error` 保留写入,供 messages 区兜底展示。
  2445. */
  2446. notifyOversize(this.alerts, rejected);
  2447. this._error.set(rejected.map(oversizeMessage).join(';'));
  2448. }
  2449. if (list.length === 0) return;
  2450. this._pendingFiles.update((cur) => [
  2451. ...cur,
  2452. ...list.map((file) => ({
  2453. id: `pf-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
  2454. file,
  2455. // 🔴 对象地址只生成一次并缓存 ——
  2456. // 在模板里每次变更检测都调 createObjectURL 会不断泄漏
  2457. previewUrl: file.type.startsWith('image/') ? URL.createObjectURL(file) : undefined,
  2458. })),
  2459. ]);
  2460. }
  2461. /** 移除一个待发附件(同时释放预览地址) */
  2462. unstageFile(id: string): void {
  2463. this._pendingFiles.update((cur) => {
  2464. const victim = cur.find((f) => f.id === id);
  2465. if (victim?.previewUrl) URL.revokeObjectURL(victim.previewUrl);
  2466. return cur.filter((f) => f.id !== id);
  2467. });
  2468. }
  2469. /** 清空待发附件(同时释放预览地址) */
  2470. clearPendingFiles(): void {
  2471. for (const f of this._pendingFiles()) {
  2472. if (f.previewUrl) URL.revokeObjectURL(f.previewUrl);
  2473. }
  2474. this._pendingFiles.set([]);
  2475. }
  2476. /**
  2477. * 更新单个附件的阶段/进度,并重算整批的百分比与主导阶段(2026-11-11)。
  2478. *
  2479. * 进度与阶段**都从各文件自己的状态派生**,不再手动 set 一个总数 ——
  2480. * 这样多文件并发/串行、某个文件失败时,总体进度都是自洽的。
  2481. */
  2482. private setFileProgress(id: string, progress: Omit<FileProgress, 'id'>): void {
  2483. const next: FileProgress = { id, ...progress };
  2484. this._pendingFiles.update((cur) =>
  2485. cur.map((f) => (f.id === id ? { ...f, progress: next } : f)),
  2486. );
  2487. const items = this._pendingFiles();
  2488. this._uploadPct.set(overallPct(items.map((f) => ({ progress: f.progress, size: f.file.size }))));
  2489. this._uploadPhase.set(batchPhase(items.map((f) => ({ progress: f.progress }))));
  2490. }
  2491. /**
  2492. * 发送待发附件(内部使用)。
  2493. *
  2494. * @returns 每个附件是否成功
  2495. */
  2496. private async uploadPending(conversationId: string): Promise<{ ok: number; fail: number }> {
  2497. const pending = this._pendingFiles();
  2498. if (pending.length === 0) return { ok: 0, fail: 0 };
  2499. this._uploading.set(true);
  2500. this._uploadPct.set(0);
  2501. this._uploadPhase.set('idle');
  2502. let ok = 0;
  2503. let fail = 0;
  2504. /** 上传失败的暂存项 id(保留在待发区供重试) */
  2505. const failedIds = new Set<string>();
  2506. try {
  2507. for (let i = 0; i < pending.length; i++) {
  2508. const { file } = pending[i];
  2509. /*
  2510. * 大小兜底(2026-11-10):stageFiles 已拦过一层,这里防「暂存后文件被替换」
  2511. * 或调用方绕过 stageFiles。超限项按失败处理,留在待发区让用户移除。
  2512. */
  2513. if (isOversize(file)) {
  2514. notifyOversize(this.alerts, [file]);
  2515. this._error.set(oversizeMessage(file));
  2516. fail++;
  2517. failedIds.add(pending[i].id);
  2518. continue;
  2519. }
  2520. try {
  2521. /*
  2522. * 🔴 顺序:**先上个人存储、后发消息**(2026-11-13 用户裁决,沿用 11-10 顺序)。
  2523. *
  2524. * 云盘存不上就**不发**,失败项留在待发区重试。
  2525. * 这样「发出去的文件 = 一定能长期访问的文件」,不会出现半成功状态。
  2526. *
  2527. * 🔴 不再双写(2026-11-13):
  2528. * 以前 Matrix 还存一份 `mxc://`,因为容器只认 `m.file` + `mxc://`。
  2529. * 用户裁决「只允许收发 cdn/obs 地址,禁止经应用服务器/Matrix 传附件」,
  2530. * 现在改发 `content.url = 云盘 https 地址`(**前置条件**:容器侧
  2531. * adapter.py 补丁已落地,见 `send-mode.ts` 的说明)。
  2532. * 回退:`localStorage.fmode.sendMode='mxc'` 即恢复旧双写。
  2533. */
  2534. const mimeType = file.type || 'application/octet-stream';
  2535. if (!this.roomId) throw new Error('会话未就绪');
  2536. const sid = this._sessionId();
  2537. /*
  2538. * ① 云盘:失败即中止本文件(不发送)。传 onProgress 拿**真实字节进度**(2026-11-11)
  2539. *
  2540. * 🔴 重试复用(2026-11-13 评审 S3):上次已经传上去、只是**发事件失败**的,
  2541. * 直接拿缓存,不再上传一次 —— 否则云盘多一份孤儿、台账多一条重复。
  2542. */
  2543. let cloudUrl: string;
  2544. let cloudKey: string;
  2545. let registered: boolean;
  2546. const cached = this._pendingFiles().find((p) => p.id === pending[i].id)?.uploaded;
  2547. if (cached) {
  2548. cloudUrl = cached.url;
  2549. cloudKey = cached.key;
  2550. registered = cached.registered;
  2551. this.setFileProgress(pending[i].id, { phase: 'uploading', pct: 100 });
  2552. } else {
  2553. this.setFileProgress(pending[i].id, { phase: 'uploading', pct: 0 });
  2554. const up = await this.api.uploadFile(file, (pct) => {
  2555. this.setFileProgress(pending[i].id, { phase: 'uploading', pct });
  2556. });
  2557. cloudUrl = up.publicUrl;
  2558. cloudKey = up.key;
  2559. registered = false;
  2560. }
  2561. /*
  2562. * `key` 是云盘里的稳定对象标识,`publicUrl` 只是它的投影。
  2563. * 以前只留了 url、把 key 丢了 —— 将来要按用户/会话检索文件时,
  2564. * key 才是能对上号的字段,所以一并记进成果。
  2565. */
  2566. /*
  2567. * ② 登记到文件台账(2026-11-10)。
  2568. *
  2569. * 用户要求:文件与成果入库,右侧栏**直接按会话从数据库加载**,
  2570. * 不再依赖「先加载聊天历史才看得到」。
  2571. *
  2572. * 登记失败不阻断发送(文件已经在云盘上了,只是台账缺一条),
  2573. * 但要如实提示 —— 不能让用户以为文件没上去。
  2574. */
  2575. /*
  2576. * 🔴 `sid` 为空时**不能跳过登记**(2026-11-13 修复)。
  2577. *
  2578. * 原写法 `if (sid && !registered)`:进入容器但还没建会话时发附件,
  2579. * 上传与发送都正常、气泡也显示,但台账里没有这条 ——
  2580. * 右栏「文件」页签查不到(`listBySession` 只按 threadRoot 查),
  2581. * 刷新后尤其明显,用户会以为文件丢了。
  2582. *
  2583. * 修法:先确保会话存在(与 `sendReal` 同一兜底),再登记。
  2584. */
  2585. const ledgerRoot = sid ?? this.ensureSessionForSend()?.id;
  2586. if (ledgerRoot && !registered) {
  2587. try {
  2588. this.setFileProgress(pending[i].id, { phase: 'registering', pct: 100 });
  2589. await this.api.registerFile({
  2590. threadRoot: ledgerRoot,
  2591. agentId: this._agentId() ?? undefined,
  2592. roomId: this.roomId,
  2593. name: file.name,
  2594. url: cloudUrl,
  2595. key: cloudKey,
  2596. mimeType,
  2597. size: file.size,
  2598. uploadedBy: 'user',
  2599. });
  2600. registered = true;
  2601. } catch (e) {
  2602. this._error.set(
  2603. `「${file.name}」已存入云盘,但未记入文件列表:${e instanceof Error ? e.message : '登记失败'}`,
  2604. );
  2605. }
  2606. }
  2607. /*
  2608. * 先把「已上云」记到暂存项上,再发事件 —— 若下一步失败,
  2609. * 下一步重试就只重发事件(评审 S3)。
  2610. */
  2611. this._pendingFiles.update((cur) =>
  2612. cur.map((p) => (p.id === pending[i].id ? { ...p, uploaded: { url: cloudUrl, key: cloudKey, registered } } : p)),
  2613. );
  2614. /* ② Matrix:把云盘地址发给容器(2026-11-13 起不再传文件本体) */
  2615. this.setFileProgress(pending[i].id, { phase: 'sending', pct: 100 });
  2616. await this.sendCloudFile(
  2617. this.roomId,
  2618. file,
  2619. cloudUrl,
  2620. { name: file.name, size: file.size, mimeType },
  2621. /* 🔴 与台账登记用**同一个根**,否则容器回的归属和台账对不上(2026-11-13) */
  2622. ledgerRoot ?? undefined,
  2623. );
  2624. this.setFileProgress(pending[i].id, { phase: 'done', pct: 100 });
  2625. /*
  2626. * 云盘地址记进 `_cloudUrls`:**只服务于 `'mxc'` 回退模式**下的老消息
  2627. * (事件里没带 `com.fmode.cloud_url` 的那种)。`'url'` 模式下事件自带地址,
  2628. * 这张内存表是冗余兜底(2026-11-10 引入,2026-11-13 收窄语义)。
  2629. *
  2630. * 🔴 文件**不进**会话成果列表(`deliverableUrls`):用户自己发的
  2631. * 东西只属于右栏「文件」页签,「成果」= 容器产出的东西。
  2632. */
  2633. this._cloudUrls.set(this.cloudKeyOf(file.name, file.size), cloudUrl);
  2634. // 本地预览用对象地址;Matrix 的 mxc 要等回读后才能转 http
  2635. const displayUrl = URL.createObjectURL(file);
  2636. const card: FileCard = {
  2637. kind: 'file',
  2638. name: file.name,
  2639. size: file.size,
  2640. mimeType,
  2641. url: displayUrl,
  2642. /* 云盘地址:S3 先行之后,能走到这里的文件一定两边都有 */
  2643. cloudUrl,
  2644. };
  2645. /*
  2646. * 本地先插一条文件气泡,发出去立刻可见(用户裁决:本地先插)。
  2647. *
  2648. * ⚠️ 回读的 `m.image` 还会再落一条 —— 依赖回合聚合的
  2649. * `USER_MERGE_MS` 把两条并成同一回合,若出现「两个用户气泡」,
  2650. * 先查这里而不是直接删本地插入。
  2651. */
  2652. this._messages.update((m) => [
  2653. ...m,
  2654. {
  2655. id: `local-file-${Date.now()}-${file.name}`,
  2656. conversationId,
  2657. sender: 'me',
  2658. kind: 'text',
  2659. file: card,
  2660. ts: new Date().toISOString(),
  2661. state: 'sent',
  2662. },
  2663. ]);
  2664. ok++;
  2665. } catch (e) {
  2666. fail++;
  2667. failedIds.add(pending[i].id);
  2668. /* 如实告知失败原因(S3 先行后,这里通常是「云盘没存上」⇒ 未发送) */
  2669. this._error.set(
  2670. `「${file.name}」发送失败:${e instanceof Error ? e.message : '上传失败'}`,
  2671. );
  2672. /*
  2673. * 失败项标成 `failed`:胶囊上显示「上传失败」并保留 ✕ 让用户移除。
  2674. * 不在这里弹全局提示 —— 失败项本身就在输入框上方可见,
  2675. * 再弹一条只是噪音;真正需要全局提示的是「根本没进暂存区」的超限。
  2676. */
  2677. this.setFileProgress(pending[i].id, {
  2678. phase: 'failed',
  2679. pct: 0,
  2680. error: e instanceof Error ? e.message : '上传失败',
  2681. });
  2682. }
  2683. }
  2684. } finally {
  2685. this._uploading.set(false);
  2686. this._uploadPct.set(0);
  2687. this._uploadPhase.set('idle');
  2688. /*
  2689. * 🔴 按 id 精确保留失败项(2026-11-03 修复)。
  2690. *
  2691. * 原实现 `cur.slice(ok)` 把「成功数」当成「失败前缀」:
  2692. * 若第 1 个失败、第 2 个成功(ok=1),`slice(1)` 会**丢掉失败的**、
  2693. * **留下已成功的** —— 用户看到已发出的附件还挂在输入框上,真正失败的却消失了。
  2694. *
  2695. * 同时要 revoke 被移除项的 previewUrl,避免对象地址泄漏。
  2696. */
  2697. this._pendingFiles.update((cur) => {
  2698. const remain = cur.filter((f) => failedIds.has(f.id));
  2699. for (const f of cur) {
  2700. if (!failedIds.has(f.id) && f.previewUrl) URL.revokeObjectURL(f.previewUrl);
  2701. }
  2702. return remain;
  2703. });
  2704. }
  2705. return { ok, fail };
  2706. }
  2707. /**
  2708. * 打开会话时,若本地 timeline 里**找不到这条会话的消息**,沿房间历史向前回溯。
  2709. *
  2710. * ## 为什么需要(2026-11-12 用户实测)
  2711. *
  2712. * 「我的这些历史记录都无法查看了,大部分都没有正常显示。」
  2713. *
  2714. * - `startClient({ initialSyncLimit: 50 })` 只把房间**最近 50 条**事件放进本地;
  2715. * - `timelineToMessages()` 只读本地 timeline,**从不主动向前翻**;
  2716. * - 实测 lj-test 房间有 1252 条消息、51 条会话,老会话的最新一条排在第 52 / 68 位
  2717. * → 本地压根没有 → 打开是空白,且被渲染成「全新会话」欢迎页。
  2718. *
  2719. * ## 策略
  2720. *
  2721. * · **命中即停**:多数情况 1~2 轮就够,不把整个房间翻完(那是几十次往返);
  2722. * · 有上限(`MAX_ROUNDS` × `PAGE`),防止对超大房间无限回溯;
  2723. * · 翻到 token 耗尽(房间到顶)也停;
  2724. * · 复用 `belongsToSession()` 作为归属唯一真源,**不另写一套匹配规则**。
  2725. *
  2726. * 回溯失败(网络错误等)只记日志不抛错 —— 打开会话不该因为翻历史失败而整个失败。
  2727. *
  2728. * @param rid 房间 id
  2729. * @param sid 会话 id(线程根)
  2730. * @param my `open()` 的版本号,切走即中止
  2731. * @returns 是否在本地找到了这条会话的消息
  2732. */
  2733. private async backfillSessionTimeline(rid: string, sid: string, my: number): Promise<boolean> {
  2734. /* 每次打开会话都要重新判定(沿用上一条会话的结论会误判成「翻到底了」) */
  2735. this._reachedRoomStart = false;
  2736. if (!sid || environment.useMockData) return true;
  2737. const hit = (): boolean =>
  2738. this.mx.timelineToMessages(rid).some((m) => this.belongsToSession(m, sid));
  2739. if (hit()) return true;
  2740. /*
  2741. * 🔴 新建会话本来就没有历史 —— 不能回溯,否则会把旧内容认领进来
  2742. * (这正是 2026-11-08「新建会话带出历史」的入口之一)。
  2743. */
  2744. if (this._blankSessionId === sid) return false;
  2745. /*
  2746. * 🔴 **必须等初次 sync 落地再回溯**(2026-11-12 实测)。
  2747. *
  2748. * `paginationToken()` 在房间还没进本地 store(`client.getRoom()` 为 null)
  2749. * 时返回 `null`,与「真的翻到顶了」无法区分。原写法把它当成「已到最早」
  2750. * 直接 break → 一次请求都不发 → 历史照样看不到(实测 requests=0)。
  2751. *
  2752. * `synced()` 会等首个 SYNCING(超时即返回 false,不无限等待)。
  2753. */
  2754. await this.mx.synced(15_000);
  2755. if (my !== this.seq) return false;
  2756. const PAGE = 100;
  2757. /*
  2758. * 🔴 上限要够大(2026-11-12 实测)。
  2759. *
  2760. * lj-test 的房间已超过 **2100 个事件**(还在增长),老的 10 轮 × 100 = 1000 条
  2761. * 根本翻不到底,「测试9680」这类会话的最新一条在第 207 位之外仍取不到。
  2762. * 这里抬到 50 轮(约 5000 条),同时靠「命中即停」保证大多数情况只翻 1~2 轮,
  2763. * 以及「到顶即停」保证不会白跑。
  2764. */
  2765. const MAX_ROUNDS = 50;
  2766. /*
  2767. * 🔴 与 `paginate()` 共用同一把锁(`_paginating`)。
  2768. *
  2769. * 两者翻的是**同一条房间 timeline**:并发拉取会让 SDK 的 `end` token
  2770. * 互相覆盖,出现「翻了一轮等于没翻」甚至重复事件。
  2771. * 已有分页在跑时让路 —— 它翻完同样会让 `hit()` 成立。
  2772. */
  2773. if (this._paginating()) return hit();
  2774. this._paginating.set(true);
  2775. try {
  2776. for (let i = 0; i < MAX_ROUNDS; i++) {
  2777. if (my !== this.seq) return false;
  2778. if (this.mx.paginationToken(rid) == null) {
  2779. /* 已经翻到房间最早:本次判断「有没有这条会话的消息」是**定论** */
  2780. this._reachedRoomStart = true;
  2781. break;
  2782. }
  2783. let ok = false;
  2784. try {
  2785. ok = await this.mx.paginateBack(rid, PAGE);
  2786. } catch {
  2787. /*
  2788. * 🔴 网络抖动**不能直接放弃**(2026-11-13 真机实测)。
  2789. *
  2790. * 原实现 catch 后直接 break → `hit()` 为 false →
  2791. * 界面显示「这个会话没有可加载的消息」。
  2792. * 移动网络下一次请求失败,整条历史就"消失"了 —— 用户看到的就是
  2793. * 「预览完返回 / 刷新后历史不见了」。
  2794. *
  2795. * 现在:先重试一次(1 秒后);仍失败才退出循环,
  2796. * 并且**不置 `_reachedRoomStart`**(不能把网络失败当成"翻到底了")。
  2797. */
  2798. await new Promise((r) => setTimeout(r, 1000));
  2799. try {
  2800. ok = await this.mx.paginateBack(rid, PAGE);
  2801. } catch {
  2802. break; // 两次都失败:退出,保持「未翻到底」语义
  2803. }
  2804. }
  2805. /*
  2806. * 🔴 SDK 语义:`paginateEventTimeline` 返回 **false = 已到 timeline 尽头**
  2807. * (`client.ts:5054`「false if there are no events and we reached either end」),
  2808. * 不是出错。早先把它当错误处理,导致提前 break 且误判「翻到底」。
  2809. */
  2810. if (!ok) {
  2811. this._reachedRoomStart = true;
  2812. break;
  2813. }
  2814. if (hit()) return true;
  2815. }
  2816. /* 翻满上限还没到顶 —— 后面判定要当「可能还有没找到的历史」处理 */
  2817. if (this.mx.paginationToken(rid) == null) this._reachedRoomStart = true;
  2818. return hit();
  2819. } finally {
  2820. if (my === this.seq) this._paginating.set(false);
  2821. }
  2822. }
  2823. /** 向上翻历史(AC-04.4);返回是否还有更早的消息 */
  2824. async paginate(): Promise<boolean> {
  2825. if (environment.useMockData || !this.roomId) return false;
  2826. /*
  2827. * 🔴 新建态**禁止翻页**(2026-11-08,「新建会话仍带出历史」的最后一个入口)。
  2828. *
  2829. * `paginate()` 是三条回填路径里唯一没有新建态保护的:
  2830. * · `storeVersion` effect → 已加 `_blankSessionId` 判定
  2831. * · `appendIncoming` → 已加 `_blankSessionId` 判定
  2832. * · `paginate` → 原先没有,且 `paginateBack()` 翻的是**整个房间**的历史
  2833. * (`room.getLiveTimeline()`),不是当前 thread 的 history。
  2834. *
  2835. * 触发链:新建会话首屏只有 0~1 条消息、不满一屏 →
  2836. * 移动端 `fillViewportIfNeeded()` / 桌面端贴底逻辑 → 自动 `paginate()`
  2837. * → 拉回 30 条房间历史 → 按会话过滤时把旧内容认领进来。
  2838. * 页面上还会出现「加载更早的消息」按钮,因为 `hasMoreHistory` 取自房间级 token。
  2839. *
  2840. * 新建会话本来就没有「更早的消息」,直接返回 false。
  2841. */
  2842. const sidNow = this._sessionId();
  2843. if (!sidNow || this._blankSessionId === sidNow) return false;
  2844. // 防重入:滚动事件高频触发,加载中直接忽略,避免重复分页
  2845. if (this._paginating()) return true;
  2846. this._paginating.set(true);
  2847. const my = this.seq;
  2848. try {
  2849. const before = this._messages().length;
  2850. const ok = await this.mx.paginateBack(this.roomId, 30);
  2851. if (my !== this.seq) return false;
  2852. const myUserId = this.provision?.userMxId ?? '';
  2853. const sid = this._sessionId() ?? '';
  2854. const next = this.mapTimeline(
  2855. this.mx.timelineToMessages(this.roomId).filter((m) => this.belongsToSession(m, sid)),
  2856. myUserId,
  2857. );
  2858. /*
  2859. * 分页后**保留完整已加载区间**(不做上限截断):
  2860. * 用户是在往上翻,超出窗口的内容用户正要看,截断会让人往前翻却看不到。
  2861. * 真正省开销的是首屏只取一屏 + 后续按需加载,而不是丢弃已加载内容。
  2862. *
  2863. * 🔴 但与本地乐观消息**合并**,不整表覆盖(2026-11-09):
  2864. * 翻页时还在发送中的消息不在 timeline 里,覆盖掉就凭空消失了。
  2865. */
  2866. this._messages.set(this.mergeTimeline(next, untracked(() => this._messages())));
  2867. // 条数未增长 = 已到最早,停止继续请求
  2868. this._hasMoreHistory.set(ok && next.length > before);
  2869. return this._hasMoreHistory();
  2870. } finally {
  2871. if (my === this.seq) this._paginating.set(false);
  2872. }
  2873. }
  2874. /** 重试失败消息(AC-04.8) */
  2875. async retry(messageId: string): Promise<void> {
  2876. const msg = this._messages().find((m) => m.id === messageId);
  2877. if (!msg || msg.state !== 'failed') return;
  2878. this._messages.update((list) =>
  2879. list.map((m) => (m.id === messageId ? { ...m, state: 'sending', error: undefined } : m)),
  2880. );
  2881. try {
  2882. if (environment.useMockData) {
  2883. await this.simulateSend();
  2884. } else if (msg.text) {
  2885. await this.sendReal(msg.text, msg.localId);
  2886. }
  2887. this.markSent(messageId);
  2888. } catch (e) {
  2889. this.markFailed(messageId, msgOf(e));
  2890. }
  2891. }
  2892. /** 模拟断线(演示控制台用;真实态由 SDK 同步事件驱动) */
  2893. setConnection(state: ConnectionState): void {
  2894. this._connection.set(state);
  2895. }
  2896. // ---------- 内部 ----------
  2897. /**
  2898. * 发真实消息(真实态由 MatrixClientService 落地到房间)。
  2899. *
  2900. * 🔴 **必须带会话线程根**:容器 `build_session_key` 按 `room + thread` 划分会话,
  2901. * 不带 thread 时每条消息都会被 `MATRIX_AUTO_THREAD` 切成新会话 → 容器失忆
  2902. * (2026-10-28 实测,见 `docs/03-interface/容器会话与线程机制-20261028.md`)。
  2903. *
  2904. * ⚠️ 「历史对话」特例已于 2026-10-29 移除 —— 该会话类型不再存在,
  2905. * 所有发送一律带线程根,避免容器侧把消息切成新会话(容器失忆)。
  2906. */
  2907. private async sendReal(text: string, localId?: string): Promise<void> {
  2908. if (!this.roomId) throw new Error('会话未就绪,请稍后重试');
  2909. let sid = this._sessionId();
  2910. if (!sid) {
  2911. /*
  2912. * 「无会话」是合法状态(进入容器不自动建会话)。
  2913. * 用户在这个状态下直接发消息 = 隐式开始一段新对话 → 此刻才创建会话。
  2914. * 正常路径下 `sendText()` 已经建过,这里是附件等旁路入口的兜底。
  2915. */
  2916. const created = this.ensureSessionForSend();
  2917. if (!created) throw new Error('会话未就绪:缺少会话标识,请重新进入');
  2918. sid = created.id;
  2919. }
  2920. const lastId = this._messages().at(-1)?.id;
  2921. // 自造的会话 id 还没有任何消息时,不填 m.in_reply_to(那是降级展示用的引用)
  2922. const last = lastId && !lastId.startsWith('local-') ? lastId : undefined;
  2923. /*
  2924. * 🔴 等该会话的专属房间建好再发(2026-11-12)。
  2925. * 否则消息会落进共享房间,导致「一房间一会话」失效。
  2926. * 等不到(建房失败)就发进当前房间 —— 降级为老行为,不影响用户。
  2927. */
  2928. /*
  2929. * 🔴 发到**该会话自己的房间**(一房间一会话,2026-11-12)。
  2930. *
  2931. * 建房已在 `startBlankWithRoom()` 里同步完成(用户到达页面时房间已就绪),
  2932. * 所以这里只需按会话取 roomId;取不到才回落到当前房间(老会话 / 建房失败降级)。
  2933. */
  2934. const targetRoom = this.sessions.byId(sid)?.roomId ?? this.roomId;
  2935. /*
  2936. * `localId` 随事件写进 content:服务端 echo 会原样带回(Matrix 允许任意额外字段),
  2937. * 回读时据此认领本地那条乐观消息,避免同一句话出现两遍。
  2938. */
  2939. await this.mx.sendText(
  2940. targetRoom,
  2941. text,
  2942. sid,
  2943. last,
  2944. localId ? { [LOCAL_ID_KEY]: localId } : undefined,
  2945. );
  2946. }
  2947. /**
  2948. * 懒创建:**第一次发消息时**才把会话落到本地 + 云端(2026-11-01 用户裁决)。
  2949. *
  2950. * 之前「点新建对话就建一条空会话」会产生大量 0 消息的空壳,
  2951. * 还得靠 `pruneEmpty` 事后清理;清理逻辑一旦出错就是「每进一次容器多一条」。
  2952. * 改成「没有消息就不存在会话」后,空壳从源头消失。
  2953. *
  2954. * @returns 会话(已存在则返回当前会话)
  2955. */
  2956. private ensureSessionForSend(): ChatSession | null {
  2957. const agentId = this._agentId();
  2958. const roomId = this.roomId;
  2959. if (!agentId || !roomId) return null;
  2960. const exist = this._sessionId();
  2961. if (exist) {
  2962. const found = this.sessions.byId(exist);
  2963. /*
  2964. * 🔴 有 id 但目录里查不到 = 这条会话已被 `pruneEmpty` 删掉(2026-11-08)。
  2965. *
  2966. * 原实现直接 `return null`,调用方却继续往下走:消息进 `_messages`、
  2967. * 不进任何会话目录;用户再发一条又建一条新的 → 左栏出现**两条外观完全相同**
  2968. * 的会话(用户实测图35)。
  2969. *
  2970. * 现在:不再悄悄返回 null。把指针清掉并**就地重建一条**,
  2971. * 让这次发送仍然落在一个真实存在的会话上。
  2972. */
  2973. if (!found) {
  2974. const recreated = this.sessions.create(roomId, agentId);
  2975. this._sessionId.set(recreated.id);
  2976. /*
  2977. * 🔴 换了会话标识,内存里属于旧会话的消息必须清掉(2026-11-09)。
  2978. * 否则旧会话的内容挂在新的会话 id 下,观感就是「串线」。
  2979. * 同时重置新建态保护,让这次发送按正常的新会话走。
  2980. */
  2981. this._messages.set([]);
  2982. this._ownedThreadRoots.clear();
  2983. this._blankSessionId = recreated.id;
  2984. if (this._blankWorkspace) {
  2985. this.sessions.moveTo(recreated.id, this._blankWorkspace);
  2986. this._blankWorkspace = null;
  2987. }
  2988. return recreated;
  2989. }
  2990. return found;
  2991. }
  2992. const created = this.sessions.create(roomId, agentId);
  2993. /*
  2994. * 「新建对话」时指定的工作区在**这一刻**落地。
  2995. * 之前没有会话可挂,所以只能在建出来之后补归属。
  2996. */
  2997. if (this._blankWorkspace) {
  2998. this.sessions.moveTo(created.id, this._blankWorkspace);
  2999. this._blankWorkspace = null;
  3000. }
  3001. return created;
  3002. }
  3003. /**
  3004. * 消息是否属于指定会话(线程)。
  3005. *
  3006. * 规则:
  3007. * 1. 消息有 threadId → 只有 `threadId === 会话 id` 才属于
  3008. * 2. 消息无 threadId(**历史遗留**)→ 不归入任何会话(承接机制已下线)
  3009. *
  3010. * ## 为什么要单独处理无 thread 的消息
  3011. *
  3012. * 多会话上线前,所有消息都不带 thread。它们是用户真实的对话历史,
  3013. * **不能凭空消失**。处理方式:
  3014. * - 首次打开某 agent 时,若房间里存在无 thread 的消息,
  3015. * 自动建一个「历史会话」承接它们(id 由本机固定生成,见 `legacySessionId`)
  3016. * - 该会话只显示无 thread 消息,且**发送时也不带 thread**
  3017. * —— 这样容器侧仍是原来的「每条一个会话」行为,不会把旧历史和新会话混起来
  3018. *
  3019. * 其它会话一律**不含**无 thread 消息,避免串线。
  3020. */
  3021. /**
  3022. * 这条房间级消息是否落在「该房间最近一次群聊派活」之后。
  3023. *
  3024. * 容器的回复不带派活标记(容器不知道自己在群里干活),但它在时间线上
  3025. * 必然排在派活消息**之后**。用 eventId 的位置判定,不依赖时钟。
  3026. *
  3027. * 锚点来源:内存 `_dispatchAnchor`(本设备刚发过派活,最准)。
  3028. * 找不到锚点 → false(宁可放行,也不误挡单聊历史)。
  3029. */
  3030. private isAfterLastDispatch(m: MxMessage): boolean {
  3031. if (m.threadId) return false; // 有 thread 的走原有逻辑
  3032. const roomId = m.roomId;
  3033. if (!roomId || !m.eventId) return false;
  3034. const anchor = this._dispatchAnchor().get(roomId);
  3035. if (!anchor) return false;
  3036. /*
  3037. * 锚点事件还在当前 timeline 里 → 按下标判定(最可靠)。
  3038. * 锚点被分页裁掉但消息还在 → 视为更新(消息一定排在锚点之后才可能还在)。
  3039. * 消息自己不在 timeline → false,不误挡。
  3040. */
  3041. const index = this.mx.timelineIndex(roomId);
  3042. const mine = index.get(m.eventId);
  3043. const base = index.get(anchor.eventId);
  3044. if (mine !== undefined && base !== undefined) return mine > base;
  3045. if (mine !== undefined && base === undefined) return true;
  3046. return false;
  3047. }
  3048. /**
  3049. * 记录一次群聊派活的锚点(群聊页在 `sendToAgent` 成功后调用)。
  3050. *
  3051. * 同一房间反复派活时取**最新**的一条 —— 「本轮之后的消息」天然包含上一轮。
  3052. */
  3053. noteDispatchAnchor(roomId: string, eventId: string | undefined): void {
  3054. if (!roomId || !eventId) return;
  3055. const next = new Map(this._dispatchAnchor());
  3056. next.set(roomId, { eventId });
  3057. this._dispatchAnchor.set(next);
  3058. }
  3059. /** 当前派活锚点(供群聊页在冷启动时补种) */
  3060. readonly dispatchAnchors = this._dispatchAnchor.asReadonly();
  3061. /**
  3062. * 用 ✅ 标注的消息 eventId,反推「哪条会话刚结束」(2026-11-12)。
  3063. *
  3064. * ✅ 的 `m.relates_to.event_id` 指向容器本轮的最后一条消息。从房间 timeline
  3065. * 里找出这条消息,它的归属规则与 `belongsToSession()` 完全一致:
  3066. * · 有 `m.thread` → 线程根就是会话 id(容器自开的子线程按规则归当前会话)
  3067. * · 无 thread ──── DM 里容器的正常回复**本来就不挂 thread**,
  3068. * 此时拿不到精确会话,返回 `null` 让调用方清全部本地标记
  3069. * (容器已经收工,继续转圈比少转一会儿更糟)
  3070. *
  3071. * @returns 命中的会话 id 列表;`null` = 无法精确定位
  3072. */
  3073. private _sessionsOfMessage(eventId: string, roomId: string): string[] | null {
  3074. if (!eventId) return null;
  3075. const hit = this.mx.timelineToMessages(roomId).find((m) => m.eventId === eventId);
  3076. if (!hit) return null;
  3077. if (hit.threadId) return [hit.threadId];
  3078. if (hit.sessionId) return [hit.sessionId];
  3079. return null;
  3080. }
  3081. private belongsToSession(
  3082. m: MxMessage,
  3083. sessionId: string,
  3084. live = false,
  3085. ownedRoots?: ReadonlySet<string>,
  3086. ): boolean {
  3087. if (!m.threadId) {
  3088. /*
  3089. * 房间级消息(无 thread)的归属。
  3090. *
  3091. * ## 历史背景
  3092. *
  3093. * 2026-11-09 用户裁决:「房间级的这个消息,可以丢弃掉,没有问题」。
  3094. * 当时的原因:外部脚本往房间发的调试消息没有 thread,
  3095. * 被新建会话全部认领,表现为「新建会话出现一堆历史」。
  3096. *
  3097. * ## 2026-11-10 修正:容器明确不挂 thread
  3098. *
  3099. * 向 `agent-node-lj-test` 核实,容器原话:
  3100. * 「不会。我只回一条普通消息,不主动挂 m.relates_to 的 thread。」
  3101. *
  3102. * 也就是说**容器在 DM 里的正常回复本来就没有 thread**。
  3103. * 一刀切丢弃的结果是:容器回复可能整段消失(用户实测「完全没反应」)。
  3104. *
  3105. * ## 现在的规则:**有条件的认领**
  3106. *
  3107. * 同时满足以下全部条件才认领(缺一不可,确保不误收别处消息):
  3108. * ① `live === true` —— 实时到达,不是历史回填
  3109. * (历史回填时无法判断归属,仍按老规则丢弃)
  3110. * ② 本会话**正有待回复的消息** —— 已发出 agent 尚未回复
  3111. * ③ 消息时间**晚于**那条待回复的用户消息
  3112. *
  3113. * 这样:
  3114. * · 用户发消息 → 容器回的消息一定会被认领(这正是「没反应」的修复)
  3115. * · 会话闲置时,别人往房间发的消息不会被错收
  3116. * · 刷新/回填时仍严格,避免「新建会话冒出一堆历史」
  3117. */
  3118. if (m.dispatch) return false;
  3119. if (m.sessionId) return m.sessionId === sessionId;
  3120. if (!live) return false;
  3121. if (m.sender === (this.provision?.userMxId ?? '')) return false;
  3122. return this.isAwaitingReplyAfter(sessionId, m.ts || 0);
  3123. }
  3124. if (m.threadId === sessionId) return true;
  3125. /*
  3126. * 无 thread 的消息(房间级消息)已在函数开头处理完毕 ——
  3127. * 到这里 `m.threadId` 必有值。
  3128. *
  3129. * 🔴 **容器自己开的子线程,也算当前会话的内容**。
  3130. *
  3131. * 实测:容器在回复时会另开 `m.thread` 分支跑内部任务
  3132. * (调工具、跑浏览器、自我改进)。那些消息也是这次对话的一部分 ——
  3133. * 用户的交付物(dots.html、截图)就在里面。
  3134. *
  3135. * 所以规则是:
  3136. * · 线程根 = 当前会话 → 属于
  3137. * · 线程根是 App 造的(另一个会话) → 不属于(严格隔离)
  3138. * · 线程根是容器造的(非 approot) → **属于当前会话**,按时间混排在消息流里
  3139. *
  3140. * 这样既不会在左栏冒出重复会话,内容也不会丢。
  3141. *
  3142. * 🔴 但要**限定在当前会话**(2026-11-08 修复)。
  3143. *
  3144. * 原实现直接 `return !isAppSessionId(m.threadId)` —— 意味着容器开过的
  3145. * **每一个**线程根都属于**每一个**会话。后果:新建一个空会话后,
  3146. * 该房间里所有旧线程的消息都会被它认领,上方凭空出现几天前的对话
  3147. * (用户实测图31)。
  3148. *
  3149. * 现在:**历史回填**时容器线程根只归「已经认领过它」的会话 ——
  3150. * 新会话从没认领过任何根,自然什么都拿不到,符合「新建即空白」的预期。
  3151. * **实时事件**(`live=true`)仍然接纳:那是容器刚刚发出的内容,
  3152. * 一定是当前这次对话的产出(截图、交付物都在里面)。
  3153. */
  3154. if (live) return !isAppSessionId(m.threadId);
  3155. const roots = ownedRoots ?? this._ownedThreadRoots;
  3156. return roots.has(m.threadId);
  3157. }
  3158. /**
  3159. * 本会话是否「已发出消息、容器还没回」(2026-11-10,B)。
  3160. *
  3161. * 用于判断一条**无 thread** 的容器消息该不该认领:
  3162. * 只有在「确实在等回复」时才收,避免会话闲置时把房间里别处的消息错收进来。
  3163. *
  3164. * @param sessionId 当前会话
  3165. * @param at 候选消息的时间戳
  3166. * @returns true = 存在一条**早于 `at`** 的用户消息,且其后还没有容器输出
  3167. */
  3168. private isAwaitingReplyAfter(sessionId: string, at: number): boolean {
  3169. if (!sessionId || !at) return false;
  3170. const list = this._messages();
  3171. // 最后一条来自用户的消息
  3172. let lastUserAt = 0;
  3173. for (let i = list.length - 1; i >= 0; i--) {
  3174. const msg = list[i];
  3175. if (msg.sender !== 'me') continue;
  3176. lastUserAt = Date.parse(msg.ts) || 0;
  3177. break;
  3178. }
  3179. if (!lastUserAt || at <= lastUserAt) return false;
  3180. // 那条用户消息之后,已经有过 agent 输出吗?有则本轮已回复,不再收无 thread 消息
  3181. const replied = list.some((msg) => {
  3182. if (msg.sender !== 'agent') return false;
  3183. const ts = Date.parse(msg.ts) || 0;
  3184. return ts > lastUserAt;
  3185. });
  3186. return !replied;
  3187. }
  3188. /**
  3189. * 算出某会话「拥有」的容器线程根(2026-11-08)。
  3190. *
  3191. * 判据:该线程根下,存在**属于本会话的用户消息**。
  3192. * 用户在这个会话里发过东西(`threadId === sid` 或 `sessionId === sid`),
  3193. * 容器才会为它开子线程 —— 那些子线程天然属于本会话。
  3194. *
  3195. * 新建的空会话没有任何用户消息 → 返回空集 → 旧线程一个都不认领。
  3196. */
  3197. private rootsOfSession(
  3198. raw: MxMessage[],
  3199. sessionId: string,
  3200. myUserId: string,
  3201. ): Set<string> {
  3202. const roots = new Set<string>();
  3203. // 本会话的用户消息挂在哪些容器线程根下
  3204. const myRoots = new Set<string>();
  3205. for (const m of raw) {
  3206. if (m.sender !== myUserId) continue;
  3207. if (m.threadId === sessionId) {
  3208. myRoots.add(m.threadId);
  3209. roots.add(sessionId);
  3210. }
  3211. if (m.sessionId === sessionId) {
  3212. myRoots.add(m.eventId);
  3213. if (m.threadId) roots.add(m.threadId);
  3214. }
  3215. }
  3216. // 容器开在「本会话用户消息所属根」下的子线程,也算本会话
  3217. for (const m of raw) {
  3218. const root = m.threadId;
  3219. if (!root || isAppSessionId(root)) continue;
  3220. if (myRoots.has(root) || m.sessionId === sessionId) roots.add(root);
  3221. }
  3222. return roots;
  3223. }
  3224. /**
  3225. * 本会话已经认领的「容器线程根」(2026-11-08)。
  3226. *
  3227. * 由 `mapTimeline` 的归属计算过程写入:当前会话自己产生过的容器线程根。
  3228. * 切换会话时清空。没有它就无法区分「本会话的容器子线程」和「别的会话的」。
  3229. */
  3230. private readonly _ownedThreadRoots = new Set<string>();
  3231. /**
  3232. * 清理「历史对话」遗留会话(2026-10-29 用户裁决下线)。
  3233. *
  3234. * 背景:多会话上线前的消息不带 thread 根,曾经专门建一个 legacy 会话承接它们。
  3235. * 但那个条目**既不能改名也不能归档**,混在正常对话列表里非常突兀。
  3236. *
  3237. * 现在的处理:
  3238. * · 不再创建新的 legacy 会话(`adoptLegacyIfNeeded` 已删除)
  3239. * · 用户本地已存在的 legacy 索引条目,在打开会话时清掉
  3240. *
  3241. * ⚠️ 只清**本地索引**,Matrix 房间里的消息不受影响 —— 删除由服务端策略决定,
  3242. * 客户端无权也不应该擅自删他人数据。
  3243. */
  3244. private dropLegacySessions(agentId: string): void {
  3245. const stale = this.sessions.listFor(agentId, { includeArchived: true }).filter((s) => s.legacy);
  3246. for (const s of stale) this.sessions.remove(s.id);
  3247. }
  3248. /**
  3249. * 把「服务端 timeline」和「本地内存里的消息」合并成一份列表。
  3250. *
  3251. * 🔴 为什么需要它(2026-11-09,用户实测「发一条你好,出现三条」):
  3252. * 乐观消息的 id 是 `local-*`,它自己的服务端 echo 是 `$eventId`,
  3253. * **两者 id 天然不同**。原实现按 id 做并集 —— 于是同一条消息
  3254. * 以「本地一条 + 回读一条」并存;再加上实时追加,最多时出现 3 条。
  3255. *
  3256. * 规则:
  3257. * ① echo 带 `localId` 且能对上本地某条 → **认领**它,只保留服务端那条
  3258. * ② 没被认领的本地消息,**只保留仍在发送中 / 已失败**的
  3259. * (失败消息不能丢,可重试 AC-04.8;已 `sent` 却没有 echo 的,
  3260. * 说明它属于服务端时间线之外 —— 留着只会重复)
  3261. * ③ 只保留**属于当前 agent** 的本地消息
  3262. * (🔴 2026-11-09 评审 B3:原实现不分会话,切会话后
  3263. * `storeVersion` 每次 sync 都会把旧会话的乐观消息重新并回来)
  3264. * ④ 按时间稳定排序,echo 落在它本来的位置,不跳到末尾
  3265. */
  3266. private mergeTimeline(next: Message[], local: Message[]): Message[] {
  3267. // 服务端 echo 认领了哪些本地 id
  3268. const claimed = new Set<string>();
  3269. for (const m of next) {
  3270. if (m.localId) claimed.add(m.localId);
  3271. }
  3272. const agentId = this._agentId() ?? '';
  3273. /*
  3274. * 🔴 echo 认领本地消息时**沿用本地的 ts**(2026-11-09 修复秒数跳变)。
  3275. *
  3276. * 本地乐观消息的 ts 是浏览器时钟,服务端 echo 的 ts 是服务端时钟,
  3277. * 两者有几秒偏差。直接换成服务端的,会让「等待了多久」这个锚点跳变
  3278. * (用户实测 95s → 100s)。
  3279. * 这一条是我方发出的消息,用我方记下的发送时刻更准。
  3280. */
  3281. const localTs = new Map<string, string>();
  3282. for (const m of local) {
  3283. if (m.localId) localTs.set(m.localId, m.ts);
  3284. }
  3285. const out: Message[] = next.map((m) =>
  3286. m.localId && localTs.has(m.localId) ? { ...m, ts: localTs.get(m.localId)! } : m,
  3287. );
  3288. for (const m of local) {
  3289. if (claimed.has(m.id) || (m.localId && claimed.has(m.localId))) continue;
  3290. if (m.state === 'sent') continue;
  3291. if (agentId && m.conversationId !== agentId) continue;
  3292. out.push(m);
  3293. }
  3294. /*
  3295. * 稳定排序:时间相同(同一毫秒)时保持原顺序。
  3296. * Array.prototype.sort 在现代 V8 里是稳定的,不必额外带序号。
  3297. */
  3298. return out.sort((a, b) => (Date.parse(a.ts) || 0) - (Date.parse(b.ts) || 0));
  3299. }
  3300. /** 归一 timeline → 领域消息 */
  3301. private mapTimeline(list: MxMessage[], myUserId: string): Message[] {
  3302. const cid = this._agentId() ?? '';
  3303. /*
  3304. * 先滤掉容器「附件投递失败」的兜底提示(2026-11-13)。
  3305. * 只在实时流过滤会导致「刚发完看不到、刷新后又冒出来」,两处必须一致。
  3306. */
  3307. return list.filter((m) => !isMediaDeliveryFailure(m.body)).map((m) => {
  3308. /*
  3309. * 两种附件形态都要认:
  3310. * ① 我们早期自造的 `m.text + JSON body`
  3311. * ② **Matrix 原生 `m.file` / `m.image`**(容器交付成果用的就是这个)
  3312. *
  3313. * 🔴 早期只认 ① —— 容器发来的 dots.html、截图全被当成普通文本,
  3314. * 界面上既看不到文件、右侧「成果」栏也永远是 0(实测)。
  3315. * `MxMessage.media` 已经把原生附件解析好并转成了可访问 URL。
  3316. */
  3317. const card = MatrixClientService.parseFileCard(m.body);
  3318. const file = card ?? (m.media ? m.media : undefined);
  3319. /*
  3320. * 🔴 正文里的云盘链接 → 生成文件卡片(2026-11-09)。
  3321. *
  3322. * 容器交付成果时不发附件,而是把 `https://s3.fmode.cn/...` 写在回复正文里。
  3323. * 用户要求:「应该是显示卡片,而不是链接」。
  3324. *
  3325. * ⚠️ 2026-11-09 第二次修正 —— 两次都出过问题,记下来避免再犯:
  3326. * ① 初版「剥出链接 + 从正文删掉」→ 正文只剩链接时变成**空消息**,
  3327. * 用户什么都看不到;容器一行一个链接时还会生成**重复卡片**。
  3328. * ② 现在:**正文完整保留**(一个字不删),只为第一个链接生成卡片。
  3329. * 链接在正文里继续显示为可点击文字 —— 这是**有意为之**:
  3330. * 卡片给「文件」区块用,正文里的链接给用户直接点。
  3331. *
  3332. * 重复的两个卡片来自**容器一条回复被拆成多条事件**,
  3333. * 属于容器输出格式问题,已通过「链接独占一行、一次只给一个主文件」规范约束。
  3334. */
  3335. /*
  3336. * 文件来源(2026-11-10 取证后重排优先级):
  3337. *
  3338. * ① Matrix 原生附件(`m.file` / `m.media`) —— 最可靠
  3339. * ② **容器交付段落** `**已上云(可直接下载)**` + `- url` 列表
  3340. * ③ 正文里的云盘链接(正则提取) —— 老格式的兜底
  3341. *
  3342. * 🔴 原先的 ② 是 `<!--fmode:files [...]-->` 结构化声明段。
  3343. * 2026-11-10 向容器取证确认:**容器从未实现该协议**,输出侧不写任何
  3344. * 结构化注释,实际写的是 ② 这种「小标题 + 链接列表」。那条分支
  3345. * 永远命中不了,已删除(容器原话见 docs/03-interface/容器取证-*.md Q12)。
  3346. *
  3347. * 交付段落存在时优先用它,因为那里的链接是容器**明确声明**的成果;
  3348. * 正文其它位置的链接可能是举例或引用,不该当文件。
  3349. */
  3350. let linkCard: FileCard | undefined;
  3351. /**
  3352. * 正文文本(2026-11-10 重构)。
  3353. *
  3354. * 三种情况:
  3355. * ① 带 Matrix 附件 → `body` 只是文件名,不当正文
  3356. * ② 有交付段落 → 完整保留(用户要能直接点链接)
  3357. * ③ 只有裸链接 → 剥掉链接交给卡片,避免卡片与正文重复
  3358. */
  3359. let bodyText = m.body;
  3360. /*
  3361. * 🔴 带 Matrix 附件时,也要找同一条消息里的云盘链接(2026-11-13 用户实测)。
  3362. *
  3363. * 容器产出发文件时,**同时**做两件事:
  3364. * ① 发 `m.file` 原生附件(`mxc://`,供 App 内联显示)
  3365. * ② 在正文写「**已上云(可直接下载)** + https://s3.fmode.cn/...」
  3366. *
  3367. * 原实现 `if (!file)` 一刀切 —— 只要有附件就**完全不看正文的云盘链接**,
  3368. * 于是卡片/新标签/复制链接全走 `/_matrix/media/...`。
  3369. * 那个地址 ① 需要鉴权 ② Safari 兼容差 ③ 平台清理后就失效,
  3370. * 用户要的是**云盘公网链接**(`s3.fmode.cn`)。
  3371. *
  3372. * 现在:附件优先但**可被云盘链接覆盖** —— 同一条消息里有 s3 链接就用它。
  3373. */
  3374. const siblingCloud =
  3375. file != null
  3376. ? (extractDeliveredLinks(m.body)[0] ?? extractS3Links(m.body)[0])
  3377. : undefined;
  3378. if (!file) {
  3379. const delivered = extractDeliveredLinks(m.body);
  3380. if (delivered.length > 0) {
  3381. linkCard = this.cardOfCloudLink(delivered[0]);
  3382. // 交付段落本身保留(用户可能想直接点),只去掉重复的单条链接由卡片承载
  3383. bodyText = m.body;
  3384. } else {
  3385. // 没有交付段落:回退到「从正文正则提取」(老格式)
  3386. const cloudLinks = extractS3Links(m.body);
  3387. linkCard = cloudLinks.length > 0 ? this.cardOfCloudLink(cloudLinks[0]) : undefined;
  3388. bodyText = linkCard ? stripLinks(m.body, cloudLinks) : m.body;
  3389. }
  3390. } else if (isAttachmentEcho(m.body, file.name) || !m.body.trim()) {
  3391. /*
  3392. * 附件的 body 是文件名(协议要求),**或者为空**(用户 `'url'` 模式发出的
  3393. * 附件事件没有正文)—— 都不是正文,别当纯文字再显示一遍。
  3394. *
  3395. * 🔴 2026-11-13 评审 S4:原先只判 `isAttachmentEcho`,空 body 会掉进
  3396. * 「从正文提取链接」分支 —— 结果气泡里**明文显示整条云盘地址**,
  3397. * 正是用户明确要求不能出现的东西。
  3398. */
  3399. bodyText = '';
  3400. }
  3401. return {
  3402. id: m.eventId || `${m.ts}-${m.sender}`,
  3403. conversationId: cid,
  3404. sender: m.sender === myUserId ? ('me' as const) : ('agent' as const),
  3405. kind: m.kind as MessageKind,
  3406. /*
  3407. * 🔴 有附件时**也要保留正文**(2026-11-09 用户反馈「正文没文字了」)。
  3408. *
  3409. * 原实现 `text: file ? undefined : m.body` —— 只要消息带附件就把正文丢掉。
  3410. * 但容器常常是「正文说明 + 文件链接」写在同一条消息里,
  3411. * 丢掉正文后用户只看到一张卡片,不知道这个文件是干什么的。
  3412. */
  3413. text: bodyText || undefined,
  3414. file: file
  3415. ? {
  3416. kind: 'file' as const,
  3417. name: file.name,
  3418. size: file.size,
  3419. mimeType: file.mimeType,
  3420. /*
  3421. * 🔴 同一条消息里若还有云盘链接 → **用它覆盖 Matrix 附件地址**
  3422. * (2026-11-13 用户实测)。矩阵媒体地址需要鉴权、Safari 兼容差,
  3423. * 云盘地址才是用户要的「长期可访问」链接。
  3424. */
  3425. url: siblingCloud ?? file.url,
  3426. /*
  3427. * 云盘链接的确定顺序:
  3428. * ① 同一条消息里的「已上云」链接(容器产出,最准)
  3429. * ② 事件自带的 `com.fmode.cloud_url`(用户自己发的文件,已在 url 里)
  3430. * ③ 内存 Map(老消息兜底,刷新即丢)
  3431. */
  3432. cloudUrl:
  3433. siblingCloud ??
  3434. (isCloudUrl(file.url)
  3435. ? file.url
  3436. : this._cloudUrls.get(this.cloudKeyOf(file.name, file.size))),
  3437. }
  3438. : linkCard,
  3439. ts: new Date(m.ts).toISOString(),
  3440. state: 'sent' as const,
  3441. // 透传本地 id:合并时用它认领乐观消息(见 mergeTimeline)
  3442. localId: m.localId,
  3443. // 透传「在回复哪一条」:回合聚合据此把回复归到正确的提问(2026-11-10)
  3444. replyTo: m.replyTo,
  3445. /*
  3446. * 容器声明的类别(2026-11-11,`content["fmode.kind"]`)。
  3447. * 有这个字段就**不用再猜**某条消息是过程还是正文;没有则回退启发式。
  3448. */
  3449. fmodeKind: m.fmodeKind,
  3450. };
  3451. });
  3452. }
  3453. /**
  3454. * 把一条云盘链接包成附件卡片。
  3455. *
  3456. * 云盘对象是 `public-read`,链接本身就能下载/预览,
  3457. * 所以 `url` 直接用它,`cloudUrl` 也指向它(后续「优先走云盘」逻辑由此生效)。
  3458. * 文件名与类型从 URL 推断。
  3459. */
  3460. private cardOfCloudLink(url: string): FileCard | undefined {
  3461. /*
  3462. * 🔴 先判断「这是不是文件」(2026-11-09)。
  3463. *
  3464. * 原实现只要正文里有云盘域名的链接就造卡 —— 但不全是文件:
  3465. * · 目录:`https://s3.fmode.cn/bucket/`
  3466. * · 无扩展名的路径:`https://s3.fmode.cn/user/xxx/report`
  3467. * · 文档里引用的示例链接
  3468. * 这些被当成文件卡片显示,用户看到的是一堆莫名其妙的「文件」。
  3469. */
  3470. const path = url.split('?')[0].split('#')[0];
  3471. // 以 / 结尾 = 目录
  3472. if (path.endsWith('/')) return undefined;
  3473. const seg = decodeURIComponent(path.split('/').filter(Boolean).pop() ?? '');
  3474. /*
  3475. * 🔴 排除「省略号占位」(2026-11-09)。
  3476. * 容器/用户在举例时常写 `https://s3.fmode.cn/...`,
  3477. * 末段是 `...`,`lastIndexOf('.')` 会误判成「有扩展名」。
  3478. */
  3479. if (/^\.+$/.test(seg) || seg.includes('...') || seg.includes('…')) return undefined;
  3480. const dot = seg.lastIndexOf('.');
  3481. // 没有扩展名 = 不是文件
  3482. if (dot <= 0 || dot === seg.length - 1) return undefined;
  3483. const ext = seg.slice(dot + 1).toLowerCase();
  3484. /* 扩展名必须是纯字母且 ≥2 位(`a.1`、`x.` 这类不是文件) */
  3485. if (!/^[a-z]{2,5}$/.test(ext)) return undefined;
  3486. /*
  3487. * 扩展名白名单:只认「确实是文件」的类型。
  3488. * 不在名单里的一律不造卡(链接留在正文里当文字)。
  3489. */
  3490. const mimeByExt: Record<string, string> = {
  3491. md: 'text/markdown',
  3492. markdown: 'text/markdown',
  3493. html: 'text/html',
  3494. htm: 'text/html',
  3495. pdf: 'application/pdf',
  3496. txt: 'text/plain',
  3497. log: 'text/plain',
  3498. csv: 'text/csv',
  3499. tsv: 'text/tab-separated-values',
  3500. json: 'application/json',
  3501. xml: 'application/xml',
  3502. yml: 'text/yaml',
  3503. yaml: 'text/yaml',
  3504. png: 'image/png',
  3505. jpg: 'image/jpeg',
  3506. jpeg: 'image/jpeg',
  3507. gif: 'image/gif',
  3508. webp: 'image/webp',
  3509. svg: 'image/svg+xml',
  3510. xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
  3511. xls: 'application/vnd.ms-excel',
  3512. docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
  3513. doc: 'application/msword',
  3514. pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
  3515. zip: 'application/zip',
  3516. tar: 'application/x-tar',
  3517. gz: 'application/gzip',
  3518. mp4: 'video/mp4',
  3519. mp3: 'audio/mpeg',
  3520. };
  3521. const mime = mimeByExt[ext];
  3522. if (!mime) return undefined;
  3523. return {
  3524. kind: 'file',
  3525. /*
  3526. * 显示名去掉存储键的前缀(2026-11-11)。
  3527. *
  3528. * 云盘键形如 `0-mwbkfsjsbv-team-weekly-template.md`,前面的
  3529. * 「序号-随机串-」是服务端去重用的,用户不该看到 ——
  3530. * 之前直接拿 URL 末段当名字,卡片显示成 `0-mwbkfsjsbv-team-...`。
  3531. * 与成果页同一套处理(`prettifyStorageKey`)。
  3532. */
  3533. name: prettifyStorageKey(seg) || seg,
  3534. /*
  3535. * 云盘链接不带文件大小。用 -1 表示「未知」,界面据此不显示「0 B」
  3536. * (0 B 会让人以为文件是空的,实测用户困惑)。
  3537. */
  3538. size: -1,
  3539. mimeType: mime,
  3540. url,
  3541. cloudUrl: url,
  3542. };
  3543. }
  3544. /**
  3545. * 当前会话**最后一条 agent 消息**的 id(2026-11-11)。
  3546. *
  3547. * 供结束信号(`m.typing=false`)做「这一段到哪为止」的认领用。
  3548. * 没有 agent 消息时返回 null —— 此时信号没有可绑定的段落,不参与结束判定。
  3549. */
  3550. private lastAgentMessageId(): string | null {
  3551. const list = this._messages();
  3552. for (let i = list.length - 1; i >= 0; i--) {
  3553. if (list[i].sender === 'agent') return list[i].id;
  3554. }
  3555. return null;
  3556. }
  3557. /** 追加一条新到消息(按 eventId 去重;不属于当前会话的忽略) */
  3558. private appendIncoming(m: MxMessage, myUserId: string): void {
  3559. if (!this._agentId()) return;
  3560. /*
  3561. * 丢弃容器「附件投递失败」的兜底提示(2026-11-13)。
  3562. * 它来自已废弃的「Matrix 回发文件本体」链路,对用户没有信息量,见 isMediaDeliveryFailure。
  3563. */
  3564. if (isMediaDeliveryFailure(m.body)) return;
  3565. const sid = this._sessionId();
  3566. // 🔴 多会话隔离:别的会话的消息不进入当前会话的列表
  3567. if (!sid || !this.belongsToSession(m, sid, true)) return;
  3568. /*
  3569. * 🔴 新建态:只放行**本会话自己的消息**(2026-11-08)。
  3570. *
  3571. * `startBlank()` 之后新会话还没有任何 thread 根,
  3572. * `belongsToSession(live=true)` 对旧事件一律放行,会把别处的内容塞进来。
  3573. * 但用户刚发的那条(`threadId === sid`)必须进来 —— 它是本会话成立的凭据,
  3574. * 也是 storeVersion effect 判定「已落地、可解除保护」的依据。
  3575. */
  3576. if (this._blankSessionId && sid === this._blankSessionId && m.threadId !== sid) return;
  3577. /*
  3578. * 没有 eventId 的事件(极少见)用「时间 + 发送者」当退路 id;
  3579. * 退路 id 可能和时间相同的消息撞上,故只用来去重,不参与后续判定。
  3580. */
  3581. const eid = m.eventId || `${m.ts}-${m.sender}`;
  3582. if (this._messages().some((x) => x.id === eid)) return;
  3583. const [mapped] = this.mapTimeline([m], myUserId);
  3584. /*
  3585. * 🔴 自己的 echo 到达时**替换**乐观消息,而不是追加(2026-11-09)。
  3586. * 否则「我发的那条」会以本地 / 实时两条并存。
  3587. */
  3588. const localId = mapped?.localId;
  3589. this._messages.update((list) => {
  3590. if (!localId) return [...list, mapped];
  3591. /*
  3592. * 🔴 把被替换掉的本地消息一并交给 `mergeTimeline`(2026-11-09 修 C7)。
  3593. *
  3594. * 原写法是 `mergeTimeline([...rest, mapped], [])` —— 第二个参数传空数组,
  3595. * `localTs` 映射表因此为空,echo 认领会**静默失效**,ts 直接换成服务端时钟,
  3596. * 等待读秒从浏览器时钟跳到服务端时钟(用户实测 95s → 100s)。
  3597. * 这里把原本地消息传进去,让 localTs 回落真正生效。
  3598. */
  3599. const mine = list.filter((x) => x.id === localId);
  3600. const rest = list.filter((x) => x.id !== localId);
  3601. return this.mergeTimeline([...rest, mapped], mine);
  3602. });
  3603. this.rememberDemoHistory();
  3604. if (this._state() === 'empty') this._state.set('ready');
  3605. /*
  3606. * 收到**容器**的第一条消息 → 真空期结束(2026-11-14)。
  3607. *
  3608. * 之后 `sessionBusy` 交回给真实的远端信号
  3609. * (`sessionRunning` / `typing`)。不清的话会永远判忙,
  3610. * 用户再也发不出下一条。
  3611. */
  3612. if (m.sender !== 'me') this._awaitingReplySince.set(0);
  3613. // 会话元信息(预览/标题/时间)随消息推进
  3614. this.sessions.noteMessage(sid, {
  3615. text: mapped.text,
  3616. ts: Date.parse(mapped.ts) || Date.now(),
  3617. fromMe: mapped.sender === 'me',
  3618. });
  3619. /*
  3620. * 容器发来的附件如果是云盘直链,记进本会话的成果列表(2026-11-09)。
  3621. * 用户要求「成果按对话归档」:只有出现在这条对话里的文件才算它的成果。
  3622. *
  3623. * 🔴 必须判 `sender === 'agent'`(2026-11-10 修复)。
  3624. * 原实现没判,用户自己发的附件回读(`sender='me'`)也会被记成成果,
  3625. * 右栏「成果」于是出现了用户图片。下方声明段/正文链接的处理本来就按
  3626. * agent 过滤,这里与它口径对齐。
  3627. */
  3628. if (mapped.sender === 'agent') {
  3629. const fileUrl =
  3630. mapped.file?.cloudUrl ||
  3631. (mapped.file?.url?.startsWith('https://s3.') ? mapped.file.url : undefined);
  3632. if (fileUrl && !isUserUploadUrl(fileUrl)) {
  3633. this.sessions.addDeliverableUrl(sid, fileUrl);
  3634. }
  3635. }
  3636. /*
  3637. * 成果链接记入会话(2026-11-10 取证后重排):
  3638. *
  3639. * ① 容器交付段落 `**已上云(可直接下载)**` + `- url` —— 容器**实际会写**的格式
  3640. * ② 正文里的云盘链接 —— 老格式兜底,且必须通过文件判定
  3641. *
  3642. * 🔴 原先的 ① 是 `<!--fmode:files-->` 声明段,取证确认容器从未实现,已删除。
  3643. *
  3644. * ⚠️ ② 必须同时满足:sender === 'agent' + 不是省略号占位
  3645. * + 有合法扩展名。少一条就会把用户提问里的示例链接收成成果(已实测踩坑)。
  3646. */
  3647. if (mapped.sender === 'agent') {
  3648. const delivered = extractDeliveredLinks(m.body);
  3649. if (delivered.length > 0) {
  3650. this.sessions.addDeliverableUrl(sid, ...delivered);
  3651. } else {
  3652. for (const u of extractS3Links(mapped.text)) {
  3653. if (!isUserUploadUrl(u) && this.cardOfCloudLink(u)) {
  3654. this.sessions.addDeliverableUrl(sid, u);
  3655. }
  3656. }
  3657. }
  3658. }
  3659. /*
  3660. * 实时到达 + 对方发的 = 「正在回复」。
  3661. * 「用户发的」不算 —— 我方刚发出消息不能说明对方在回。
  3662. */
  3663. const agentId = this._agentId();
  3664. if (agentId) {
  3665. this.pulse.noteMessage(agentId, mapped.id, {
  3666. live: true,
  3667. fromAgent: mapped.sender === 'agent',
  3668. ts: Date.parse(mapped.ts) || Date.now(),
  3669. });
  3670. /*
  3671. * 会话级「最后一条 agent 消息」:左侧列表据此判断「在输出就转,没回复就停」。
  3672. *
  3673. * 🔴 必须过滤**历史回放**(2026-11-12 用户实测:点开历史会话就开始转)。
  3674. * `appendIncoming` 会被 `/sync` 的历史事件触发,此时 `m.live` 为 false、
  3675. * 或消息时间戳很旧。不滤的话,打开任何老会话都会被判成「正在输出」→ 转圈。
  3676. * 判据与 `noteMessage` 同口径:实时到达 + 时间戳新鲜。
  3677. */
  3678. const ts = Date.parse(mapped.ts) || 0;
  3679. const fresh = ts > 0 && Date.now() - ts < 60_000;
  3680. if (mapped.sender === 'agent' && sid && m.live === true && fresh) {
  3681. this.pulse.noteSessionAgentMessage(sid);
  3682. }
  3683. }
  3684. }
  3685. private teardownSubscriptions(): void {
  3686. this.unbindMessage?.();
  3687. this.unbindTyping?.();
  3688. this.unbindTurnReaction?.();
  3689. this.unbindTurnStatus?.();
  3690. this.unbindMessage = null;
  3691. this.unbindTyping = null;
  3692. this.unbindTurnReaction = null;
  3693. this.unbindTurnStatus = null;
  3694. }
  3695. private markSent(id: string): void {
  3696. this._messages.update((list) =>
  3697. list.map((m) => (m.id === id ? { ...m, state: 'sent', error: undefined } : m)),
  3698. );
  3699. this.rememberDemoHistory();
  3700. }
  3701. private markFailed(id: string, error: string): void {
  3702. this._messages.update((list) =>
  3703. list.map((m) => (m.id === id ? { ...m, state: 'failed', error } : m)),
  3704. );
  3705. this.rememberDemoHistory();
  3706. }
  3707. private async simulateSend(): Promise<void> {
  3708. // 仅演示态使用
  3709. if (this._connection() !== 'connected') {
  3710. throw new Error('连接已断开,请稍后重试');
  3711. }
  3712. await new Promise((r) => setTimeout(r, 200));
  3713. }
  3714. /** 演示态初始消息;真实态由 Matrix 历史拉取填充 */
  3715. private seedMessages(agentId: string): Message[] {
  3716. if (!environment.useMockData) return [];
  3717. const seeds = DEMO_MESSAGES[agentId] ?? [];
  3718. return seeds.map((s, i) => ({
  3719. id: `${agentId}-seed-${i}`,
  3720. conversationId: agentId,
  3721. sender: s.sender,
  3722. kind: 'text' as MessageKind,
  3723. text: s.text,
  3724. artifactId: s.artifactId,
  3725. taskId: s.taskId,
  3726. ts: this.seedTs(s.time),
  3727. state: 'sent' as const,
  3728. }));
  3729. }
  3730. /**
  3731. * 演示态:某条会话的存量消息(2026-11-06)。
  3732. *
  3733. * 演示态没有 Matrix 历史可拉,刷新后消息会全部丢掉 —— 用户自己发的
  3734. * 「第一条:先问候」刷新回来就没了,页面看起来像坏了。这里在内存里
  3735. * 按会话留一份,`open()` 命中同一会话时恢复。
  3736. *
  3737. * 🔴 仅演示态使用,真实态历史一律以 Matrix 为准。
  3738. */
  3739. private demoHistory: Map<string, Message[]> | null = null;
  3740. private static readonly DEMO_HISTORY_KEY = 'fmode_demo_history_v1';
  3741. /**
  3742. * 演示态的历史存储。
  3743. *
  3744. * 演示态没有服务端历史可拉,消息全在内存里 —— 刷新一次就没了。
  3745. * 演示态的会话目录本来就落在 localStorage(MockApi 的
  3746. * `mock/chat-threads`),历史同样存本地,两者的生命期才一致。
  3747. * 真实态(`useMockData = false`)恒为 null,任何地方都不会读写。
  3748. */
  3749. private demoStore(): Map<string, Message[]> | null {
  3750. if (!environment.useMockData || typeof localStorage === 'undefined') return null;
  3751. if (this.demoHistory) return this.demoHistory;
  3752. const map = new Map<string, Message[]>();
  3753. try {
  3754. const raw = localStorage.getItem(ChatService.DEMO_HISTORY_KEY);
  3755. const parsed = raw ? (JSON.parse(raw) as unknown) : null;
  3756. if (Array.isArray(parsed)) {
  3757. for (const e of parsed as [string, Message[]][]) {
  3758. if (Array.isArray(e) && typeof e[0] === 'string' && Array.isArray(e[1])) {
  3759. map.set(e[0], e[1]);
  3760. }
  3761. }
  3762. }
  3763. } catch {
  3764. /* 坏 JSON / 隐私模式:降级为内存态 */
  3765. }
  3766. this.demoHistory = map;
  3767. return map;
  3768. }
  3769. private demoRestored(sessionId: string): Message[] {
  3770. return this.demoStore()?.get(sessionId) ?? [];
  3771. }
  3772. /** 演示态消息写入(发送/接收后调用,幂等覆盖) */
  3773. private rememberDemoHistory(): void {
  3774. const store = this.demoStore();
  3775. const sid = this._sessionId();
  3776. if (!store || !sid) return;
  3777. store.set(sid, [...this._messages()]);
  3778. try {
  3779. localStorage.setItem(ChatService.DEMO_HISTORY_KEY, JSON.stringify([...store.entries()]));
  3780. } catch {
  3781. /* 配额/隐私模式:只保留内存态 */
  3782. }
  3783. }
  3784. private seedTs(time: string): string {
  3785. const today = new Date();
  3786. const m = /^(\d{2}):(\d{2})$/.exec(time);
  3787. if (m) {
  3788. today.setHours(Number(m[1]), Number(m[2]), 0, 0);
  3789. return today.toISOString();
  3790. }
  3791. const y = new Date(today);
  3792. y.setDate(today.getDate() - 1);
  3793. const m2 = /昨天\s*(\d{2}):(\d{2})/.exec(time);
  3794. if (m2) y.setHours(Number(m2[1]), Number(m2[2]), 0, 0);
  3795. return y.toISOString();
  3796. }
  3797. }
  3798. /** 统一错误文案提取 */
  3799. function msgOf(e: unknown): string {
  3800. return e instanceof Error && e.message ? e.message : '未知错误';
  3801. }
  3802. /**
  3803. * 判断附件事件的 `body` 只是「文件名的回声」。
  3804. *
  3805. * Matrix 协议规定 `m.file` / `m.image` 的 `body` 字段**就是文件名**,
  3806. * 渲染时若原样当正文输出,界面会出现「文件卡片 + 同名纯文字」两条(实测截图确认)。
  3807. *
  3808. * @param body 事件 body
  3809. * @param fileName 附件名
  3810. * @returns true 表示这段 body 没有信息量,不应作为正文显示
  3811. */
  3812. export function isAttachmentEcho(body: string | undefined, fileName: string): boolean {
  3813. if (!body) return false;
  3814. const b = body.trim();
  3815. if (!b) return false;
  3816. return b === fileName.trim();
  3817. }
  3818. /**
  3819. * 容器「附件投递失败」的系统提示(2026-11-13)。
  3820. *
  3821. * ## 为什么要在 App 侧识别它
  3822. *
  3823. * 容器侧的 Hermes 在**尝试通过 Matrix 把文件本体回发**时,若本地路径对不上,
  3824. * 会发一条纯文本兜底提示(`matrix/adapter.py:1832` 的
  3825. * `"⚠️ Couldn't deliver the attachment."`)。实测对 60MB 视频必现。
  3826. *
  3827. * 对用户来说这是一条**没有信息量、且指向已废弃链路**的噪声:
  3828. * · 文件其实早就传到云盘了,容器也读到了;
  3829. * · 按产品要求,容器**不该**用 Matrix 回发文件本体,只应给云盘链接。
  3830. *
  3831. * 所以这里识别出来、由上层**静默丢弃**(不渲染成消息)。
  3832. * 🔴 只匹配这句固定文案,不做模糊匹配 —— 避免误吞容器正常的报错说明。
  3833. */
  3834. const MEDIA_DELIVERY_FAILURE = /Couldn['’]t deliver\s+(?:the\s+|an?\s+|your\s+)?\w*\s*attachment/i;
  3835. export function isMediaDeliveryFailure(body: string | undefined | null): boolean {
  3836. return !!body && MEDIA_DELIVERY_FAILURE.test(body);
  3837. }