| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239224022412242224322442245224622472248224922502251225222532254225522562257225822592260226122622263226422652266226722682269227022712272227322742275227622772278227922802281228222832284228522862287228822892290229122922293229422952296229722982299230023012302230323042305230623072308230923102311231223132314231523162317231823192320232123222323232423252326232723282329233023312332233323342335233623372338233923402341234223432344234523462347234823492350235123522353235423552356235723582359236023612362236323642365236623672368236923702371237223732374237523762377237823792380238123822383238423852386238723882389239023912392239323942395239623972398239924002401240224032404240524062407240824092410241124122413241424152416241724182419242024212422242324242425242624272428242924302431243224332434243524362437243824392440244124422443244424452446244724482449245024512452245324542455245624572458245924602461246224632464246524662467246824692470247124722473247424752476247724782479248024812482248324842485248624872488248924902491249224932494249524962497249824992500250125022503250425052506250725082509251025112512251325142515251625172518251925202521252225232524252525262527252825292530253125322533253425352536253725382539254025412542254325442545254625472548254925502551255225532554255525562557255825592560256125622563256425652566256725682569257025712572257325742575257625772578257925802581258225832584258525862587258825892590259125922593259425952596259725982599260026012602260326042605260626072608260926102611261226132614261526162617261826192620262126222623262426252626262726282629263026312632263326342635263626372638263926402641264226432644264526462647264826492650265126522653265426552656265726582659266026612662266326642665266626672668266926702671267226732674267526762677267826792680268126822683268426852686268726882689269026912692269326942695269626972698269927002701270227032704270527062707270827092710271127122713271427152716271727182719272027212722272327242725272627272728272927302731273227332734273527362737273827392740274127422743274427452746274727482749275027512752275327542755275627572758275927602761276227632764276527662767276827692770277127722773277427752776277727782779278027812782278327842785278627872788278927902791279227932794279527962797279827992800280128022803280428052806280728082809281028112812281328142815281628172818281928202821282228232824282528262827282828292830283128322833283428352836283728382839284028412842284328442845284628472848284928502851285228532854285528562857285828592860286128622863286428652866286728682869287028712872287328742875287628772878287928802881288228832884288528862887288828892890289128922893289428952896289728982899290029012902290329042905290629072908290929102911291229132914291529162917291829192920292129222923292429252926292729282929293029312932293329342935293629372938293929402941294229432944294529462947 |
- /** 首屏加载条数:只渲染最近一屏,其余上滚再取(用户要求,避免一次性加载全部) */
- const INITIAL_PAGE = 30;
- import { computed, effect, inject, Injectable, signal, untracked } from '@angular/core';
- import { FmodeApi } from './api/fmode-api';
- import {
- Agent,
- FileCard,
- LoadState,
- Message,
- MessageKind,
- MatrixProvision,
- } from './api/models';
- import { DEMO_MESSAGES } from './api/demo-data';
- import { AlertService } from './alert.service';
- import { extractDeliveredLinks, extractS3Links, isUserUploadUrl, prettifyStorageKey, stripLinks } from './s3-link';
- import { isOversize, notifyOversize, oversizeMessage } from './upload-limits';
- import { batchPhase, FileProgress, overallPct, UploadPhase } from './upload-state';
- import { environment } from '../../environments/environment';
- import { LiveService } from './live.service';
- import { SessionService } from './session.service';
- import { UnreadService } from './unread.service';
- import {
- DISPATCH_KEY,
- LOCAL_ID_KEY,
- MxDispatchMeta,
- MxMessage,
- MatrixClientService,
- SESSION_KEY,
- } from './matrix-client.service';
- /** `sendToAgent` 的结果(2026-11-06 由 boolean 升级为对象) */
- export interface DispatchResult {
- ok: boolean;
- /** 派活消息自身的 eventId(服务端确认后才有) */
- eventId?: string;
- /**
- * 发送**前**该房间最后一条消息的 eventId —— 群聊补读的锚点。
- *
- * 为 null 表示 timeline 尚未就绪,补读退化为宽限时间窗。
- */
- baselineEventId: string | null;
- roomId?: string;
- error?: string;
- }
- import { ChatSession, deriveTitle, isAppSessionId, placeholderSession } from './chat-session';
- import { ChatSessionService } from './chat-session.service';
- import { RelayService } from './relay.service';
- import { TaskPulseService } from './task-pulse.service';
- import { sessionRunning, sessionStateOf } from './agent-state';
- export type ConnectionState = 'connecting' | 'connected' | 'disconnected';
- /**
- * 待发送附件。
- *
- * `previewUrl` 是本地对象地址(仅当前页面有效),
- * **在暂存时生成一次并缓存** —— 模板里每次变更检测重新生成会造成泄漏。
- */
- export interface PendingFile {
- id: string;
- file: File;
- previewUrl?: string;
- /**
- * 上传进度与阶段(2026-11-11)。
- *
- * 由 `uploadPending()` 在三个阶段间推进,UI 据此显示
- * 「上传中 42%」/「登记中…」/「发送中…」。未开始为 `undefined`。
- */
- progress?: FileProgress;
- }
- /**
- * 会话服务 —— 单个会话的状态机
- *
- * 职责:provision → 连接 → 房间 → 收发 → 分页,对应 SPEC FR-04 各条 AC。
- *
- * ## 两条分支(由 `environment.useMockData` 单开关决定)
- *
- * - **演示态**:用 `DEMO_MESSAGES` 填充,发送走 `simulateSend()`(不产生真实请求)
- * - **真实态**:`provisionMatrix` 取服务端签发凭据 → `MatrixClientService.connect()`
- * → `ensureRoom()` → `/sync` 实时收发
- *
- * ⚠️ **本文件不直接 import `matrix-js-sdk`**:SDK 调用收敛在 `MatrixClientService`
- * (懒加载 chunk,不进主包 —— NFR-02)。本服务只做状态编排与消息模型归一。
- *
- * ## 凭据来源(黄金规则 9)
- *
- * 真实态下 accessToken **一律来自 provision 云函数**(服务端签发);
- * 前端不派生任何密码,也不持有任何长期密钥。
- */
- @Injectable({ providedIn: 'root' })
- export class ChatService {
- private readonly api = inject(FmodeApi);
- private readonly live = inject(LiveService);
- private readonly session = inject(SessionService);
- private readonly mx = inject(MatrixClientService);
- private readonly unread = inject(UnreadService);
- private readonly pulse = inject(TaskPulseService);
- /**
- * 全局提示(2026-11-11)。用于「上传超限」这类**用户必须立刻知道**的反馈。
- * `_error` 只在消息区错误空态渲染,正常会话里等于静默,不能作为唯一出口。
- */
- private readonly alerts = inject(AlertService);
- /**
- * 会话列表服务(公开只读入口)。
- *
- * 页面需要它来渲染左侧「容器 → 对话」列表;单测也通过这里取,
- * 以保证读到的是**本服务实际使用的那一个实例**。
- */
- readonly sessions = inject(ChatSessionService);
- /** 容器中转(让 A 去问 B) */
- private readonly relay = inject(RelayService);
- private readonly _agentId = signal<string | null>(null);
- /**
- * roomId → 最近一次群聊派活的锚点 eventId(2026-11-06)。
- *
- * 用途:容器的回复不带派活标记,但一定排在派活消息之后 ——
- * 用它把「派活之后的房间级消息」挡在单聊会话之外。
- * 判定基于 timeline 下标而非时间戳,容器时钟不准也不影响。
- */
- private readonly _dispatchAnchor = signal<Map<string, { eventId: string }>>(new Map());
- /**
- * 当前打开的会话(线程根 event_id)。
- *
- * 🔴 容器 `build_session_key` 按 `room + thread` 划分会话
- * (`/opt/hermes/gateway/session.py:654`)——**不带 thread 的每条消息
- * 都会被切成新会话**(`MATRIX_AUTO_THREAD` 默认 true)。
- * 所以本字段是所有收发的前提:发要带它,收要按它过滤。
- */
- private readonly _sessionId = signal<string | null>(null);
- private readonly _messages = signal<Message[]>([]);
- private readonly _state = signal<LoadState>('idle');
- private readonly _error = signal<string | null>(null);
- private readonly _connection = signal<ConnectionState>('disconnected');
- private readonly _sending = signal(false);
- private readonly _uploading = signal(false);
- /*
- * 待发送的附件(本地暂存)。
- *
- * 🔴 用户要求:**先选文件、点发送时才一起发出去**,而不是选完立刻上传。
- * 所以这里只持有 File 对象,不上传、不发消息 —— 发送时才落地。
- */
- private readonly _pendingFiles = signal<PendingFile[]>([]);
- /**
- * 上传总进度 0-100(2026-11-11 改为**按字节加权实时计算**)。
- *
- * 原来是个手动 `set` 的信号,只在「一个文件全部走完」时跳 `(i+1)/N*100`,
- * 单个大文件期间恒为 0。现在由各文件自己的 `progress` 派生,
- * 传 20MB 文件时能看到 1% → 50% → 99% 连续推进。
- */
- private readonly _uploadPct = signal(0);
- /** 整批当前主导阶段(决定输入框下方那一条显示什么) */
- private readonly _uploadPhase = signal<UploadPhase>('idle');
- private readonly _draft = signal('');
- private readonly _peerTyping = signal(false);
- /**
- * 容器发 `m.typing=false` 的**本地接收时刻**(epoch ms,0 = 本次会话还没收到过)。
- *
- * 2026-11-11:容器侧插件在 `post_llm_call` 发 `m.typing=false` 当作「本轮结束」信号。
- * App 侧据此立刻收尾,不再死等 25/60 秒静默窗口。
- */
- private readonly _turnEndedAt = signal(0);
- readonly turnEndedAt = this._turnEndedAt.asReadonly();
- /**
- * 收到 `typing=false` 那一刻,本会话**最后一条 agent 消息的 id**(2026-11-11)。
- *
- * 🔴 为什么不能只存时刻、靠比较时间戳:
- *
- * 消息的 `ts` 来自 **Matrix 服务端**(`origin_server_ts`),而信号记的是
- * **本地时钟**。两台机器差 1~2 秒是常态 —— 服务端稍快时,
- * 「信号时刻 ≥ 末条消息时刻」永远不成立,结束信号**彻底失效**;
- * 服务端稍慢时,又会把上一轮的信号误认成这一轮的。
- *
- * 所以改成**按消息 id 认领**:信号到达时记下此刻的末条 agent 消息 id,
- * 回合判定时只问一句「本块的末条消息是不是就是这一条」—— 同一份数据内部比较,
- * 不跨时钟,天然免疫偏差。
- */
- private readonly _turnEndedAfter = signal<string | null>(null);
- readonly turnEndedAfter = this._turnEndedAfter.asReadonly();
- /**
- * 「文件名|大小」→ 云盘地址(2026-11-10)。
- *
- * 用户附件发送时 Matrix 与 S3 各存一份:Matrix 那份给容器读,
- * S3 那份给 App 长期查看(换设备、跨端)。但回读的 `m.image` 事件里
- * 只有 `mxc://`,没有云盘地址 —— 用这张表在回读时补回 `cloudUrl`。
- * 仅本进程有效;缺失时附件照常可看(走 Matrix 媒体),不影响发送。
- */
- private readonly _cloudUrls = new Map<string, string>();
- /** 云盘地址映射的键:同名同大小的文件视为同一个(Matrix echo 不带本地 id) */
- private cloudKeyOf(name: string, size: number): string {
- return `${name}|${size}`;
- }
- /** 正在加载更早历史(防重入,AC-04.4) */
- private readonly _paginating = signal(false);
- /** 是否可能还有更早历史 */
- private readonly _hasMoreHistory = signal(false);
- /**
- * 当前会话「为什么是空的」(2026-11-09)。
- *
- * - `'fresh'`:真的没有消息 —— 新建对话,显示引导页
- * - `'unloaded'`:房间里有消息,但一条都归属不到这个会话 —— 显示「没有可加载的消息」
- * - `null`:不空
- *
- * 🔴 不区分的话,一个历史取不到的旧会话会显示成「下午好,我是 xxx」,
- * 用户以为会话还在、内容丢了,实际是被归属规则挡住了(实测)。
- */
- private readonly _emptyReason = signal<'fresh' | 'unloaded' | 'removed' | null>(null);
- readonly emptyReason = this._emptyReason.asReadonly();
- readonly agentId = this._agentId.asReadonly();
- /** 当前会话 id(线程根) */
- readonly sessionId = this._sessionId.asReadonly();
- /** 当前会话元信息(标题/预览/归档状态) */
- readonly currentSession = computed<ChatSession | undefined>(() => {
- const id = this._sessionId();
- if (!id) return undefined;
- const table = this.sessions.byId(id);
- if (table) return table;
- /*
- * 🔴 会话目录里没有这条时,仍然给出一个**占位**(2026-11-06)。
- *
- * `_sessionId` 在发消息时就已落到线程根,而会话目录要等云端目录刷新
- * 才会出现这一条。两者不同步的空档里(刷新后首次发消息、刚建的新会话)
- * 原来返回 undefined —— 聊天页据此隐藏页头、右栏与整个输入区,
- * 用户看到的是「输入框没了、只剩一条告警」。条目到达后自动换成真身。
- */
- return placeholderSession(id, this._agentId() ?? '');
- });
- readonly messages = this._messages.asReadonly();
- readonly state = this._state.asReadonly();
- readonly error = this._error.asReadonly();
- readonly connection = this._connection.asReadonly();
- readonly sending = this._sending.asReadonly();
- readonly uploading = this._uploading.asReadonly();
- /** 待发送附件(界面据此渲染缩略条与移除按钮) */
- readonly pendingFiles = this._pendingFiles.asReadonly();
- readonly uploadPct = this._uploadPct.asReadonly();
- /** 整批上传阶段(输入框下方那条进度条据此切文案,2026-11-11) */
- readonly uploadPhase = this._uploadPhase.asReadonly();
- readonly draft = this._draft.asReadonly();
- /** 对方正在输入(真实态由 typing 事件驱动) */
- readonly peerTyping = this._peerTyping.asReadonly();
- /**
- * 容器**明确上报**自己闲下来了(没有在跑任务)。
- *
- * 供回合卡片判断「还在输出」用(2026-11-08):
- * 容器说空闲 → 立刻收尾;容器在跑或状态未知 → 依静默窗口判定。
- * 只有容器能宣布「干完了」,前端不替它下结论。
- */
- /**
- * 容器**明确**上报的「本会话空闲」(2026-11-11 统一方案)。
- *
- * 🔴 三态语义,不能用布尔把「不知道」和「空闲」混在一起:
- *
- * | 值 | 含义 | 用途 |
- * |---|---|---|
- * | `true` | 容器明确说本会话 idle | **直接收尾**(与 ✅ 同级的事实信号) |
- * | `false` | 容器明确说本会话在跑 | 保持输出中 |
- * | `undefined` | 容器没上报 / 不知道该查哪条 | **不参与判定**,交给其他信号 |
- *
- * 初版把 `undefined` 也当成「空闲」返回 `true`,会让**所有**回合被
- * `settledBySession` 判成已结束 —— 那等于把「不知道」当成了「干完了」。
- *
- * 另外:**不再回落容器全局 `status`**。它是「一个容器一个值」,
- * 会话 A 在跑时会话 B 也被判成不空闲,正是「串台」的根源。
- */
- readonly agentIdle = computed<boolean | undefined>(() => {
- const id = this._agentId();
- if (!id) return undefined;
- const agent = this.live.agentById(id);
- if (!agent) return undefined;
- const running = sessionRunning(
- agent.currentSessions,
- this._sessionId(),
- this.roomId,
- this.myMxId(),
- );
- return running === undefined ? undefined : !running;
- });
- /**
- * 本会话当前在做什么(2026-11-11)。
- *
- * 容器在 `currentSessions[].task` 里上报本轮提问摘要。
- * 没有就返回 undefined —— 顶栏退回通用文案「正在处理」,
- * **绝不拿容器级 `currentTask` 冒充本会话**(那是串台的根源)。
- */
- readonly sessionTask = computed<string | undefined>(() => {
- const id = this._agentId();
- if (!id) return undefined;
- const agent = this.live.agentById(id);
- if (!agent) return undefined;
- const s = sessionStateOf(agent.currentSessions, this._sessionId(), this.roomId, this.myMxId());
- return s?.status === 'running' ? s.task : undefined;
- });
- /**
- * 本会话运行态的**原始信号包**(2026-11-11 统一方案)。
- *
- * 各界面不再各自拼信号,一律把它交给 `resolveSessionPhase()` 裁决。
- * 见该函数的注释:多源冲突是「显示错」的根源。
- */
- readonly sessionSignals = computed(() => {
- const id = this._agentId();
- const agent = id ? this.live.agentById(id) : undefined;
- return {
- turnEnded: this._turnEndedAfter() !== null,
- sessionRunning: agent
- ? sessionRunning(agent.currentSessions, this._sessionId(), this.roomId, this.myMxId())
- : undefined,
- typing: this._peerTyping(),
- };
- });
- /** 正在加载更早历史 */
- readonly paginating = this._paginating.asReadonly();
- /** 是否可能还有更早历史(真实态:上次分页仍有返回) */
- readonly hasMoreHistory = this._hasMoreHistory.asReadonly();
- readonly agent = computed<Agent | undefined>(() => {
- const id = this._agentId();
- return id ? this.live.agentById(id) : undefined;
- });
- /** 序号防串扰(存量 seq 模式):切会话时丢弃过期回包 */
- private seq = 0;
- /**
- * 「新建对话」待建态指定的工作区(会话建出来后才落地)。
- * `null` = 日常对话。只在 `startBlank()` 到首次发消息之间有效。
- */
- private _blankWorkspace: string | null = null;
- /**
- * 「新建态」会话 id(2026-11-08)。
- *
- * 🔴 这是**比 `_state === 'empty'` 更可靠**的新建态判据。
- *
- * 原保护失效的原因:`sendText` 在 `commit()` 异步完成之前就执行了
- * `_state.set('ready')`,等 `storeVersion` effect 触发时状态已是 ready,
- * 于是旧历史被整表回填进新会话(用户实测:新建后上方出现昨天的对话)。
- *
- * 这里单独记一个 id:只要当前会话仍是它,effect 一律不回填;
- * 直到 `sendText` 的 `commit` 成功(云端确实有了这条会话)才清除。
- */
- private _blankSessionId: string | null = null;
- private provision: MatrixProvision | null = null;
- /**
- * 当前登录用户在 Matrix 的 mxid(2026-11-11)。
- *
- * 供会话级状态匹配用:容器可能被多人共享,`currentSessions` 里会有别人的会话,
- * 需要 `userId` 才能区分「谁在跑」。见 `agent-state.ts::sessionStateOf()`。
- */
- readonly myMxId = computed(() => this.provision?.userMxId ?? null);
- /** 真实态下的当前房间 id */
- private roomId: string | null = null;
- /**
- * 当前房间 id 的只读视图(2026-11-11)。
- *
- * 供会话级状态匹配用:容器上报的 `sessionKey` 可能只含 roomId
- * (DM 通道 `thread_id` 常为 NULL,实测 46%),此时需要用房间号对齐。
- * 见 `agent-state.ts::sessionStateOf()` 的三级匹配。
- */
- readonly currentRoomId = (): string | null => this.roomId;
- /** 事件订阅解绑函数(切会话必须调用,防串扰) */
- private unbindMessage: (() => void) | null = null;
- private unbindTyping: (() => void) | null = null;
- /** 容器 ✅/❌ reaction 订阅解绑(2026-11-11,本轮结束信号) */
- private unbindTurnReaction: (() => void) | null = null;
- /** 全局未读监听解绑(仅登出/销毁时调用) */
- private unbindUnread: (() => void) | null = null;
- /**
- * agentId → roomId 映射(未读归属反查)。
- *
- * 🔴 **持久化**(localStorage,按用户隔离):未读计数需要「某房间的新消息属于哪个
- * Agent」,而 roomId 只有在**打开过该会话**后才由 provision 返回。
- * 不持久化的话,每次刷新都会丢失映射 → 只有本次打开过的会话能计未读。
- *
- * ⚠️ **已知限制**:从未打开过的 Agent 会话,其消息不计未读
- * (因为拿不到 roomId ↔ agentId 的对应关系)。属可接受折损,已在文档披露。
- */
- private readonly roomAgentMap = new Map<string, string>();
- get provisionInfo(): MatrixProvision | null {
- return this.provision;
- }
- constructor() {
- /**
- * 🔴 连接状态跟随 SDK 的 sync 状态(FR-04 AC-04.9)。
- *
- * Bug 背景:原实现只在 `openReal()` 末尾**读一次** `mx.syncState()`:
- * `this._connection.set(this.mx.syncState() === 'connected' ? ...)`
- * 但 `/sync` 是**异步**的 —— 那一刻必然还是 `connecting`,之后没有任何代码更新,
- * 于是 UI **永久停在「正在连接」**(实测:SDK 已 SYNCING,页面仍显示连接中)。
- *
- * 修复:用 `effect` 订阅 `mx.syncState()` signal,状态变化时自动同步。
- * 仅在已打开会话时生效(未打开会话不干预 `close()` 后的复位)。
- */
- effect(() => {
- const s = this.mx.syncState();
- if (!this._agentId()) return;
- this._connection.set(
- s === 'connected' ? 'connected' : s === 'connecting' ? 'connecting' : 'disconnected',
- );
- });
- /**
- * 🔴 同步完成后重读 timeline(修复「重开会话内容空白」)。
- *
- * `openReal()` 首读 timeline 时,`/sync` 往往还没把历史事件写入本地 store
- * → 读到空数组。此前只有 realtime 事件能把消息补进来,历史内容永远缺失。
- * 这里订阅 store 版本号:每次 sync 落地后重新映射一次 timeline
- * (已读消息按 eventId 去重,不会重复)。
- */
- effect(() => {
- this.mx.storeVersion();
- const rid = this.roomId;
- const agentId = this._agentId();
- if (!rid || !agentId) return;
- const myUserId = this.provision?.userMxId ?? '';
- const raw = this.mx.timelineToMessages(rid);
- if (raw.length === 0) return;
- /*
- * 🔴 历史到达时**重新确定会话**(多会话)。
- *
- * 首次打开时 sync 未完成,`openReal` 读到的 timeline 是空的 →
- * 那时判不出「有没有无 thread 旧消息」→ 「历史对话」建不起来。
- * 等历史真正到达后必须再判一次,否则旧消息永远无处显示(实测踩坑)。
- *
- * 历史到达后只需补建「历史对话」会话,**不改当前选中的会话**。
- *
- * 🔴 曾在此处「当前是空会话就强行切到历史会话并删除空会话」——
- * 导致用户新建的对话一发出消息就被顶掉(实测:新建后 URL 对了,
- * 列表却只剩「历史对话」)。**用户的显式操作永远优先于启发式重排。**
- */
- // 「历史对话」承接机制已下线(2026-10-29):只清理旧数据留下的条目,不再新建
- this.dropLegacySessions(agentId);
- /*
- * 🔴 这里**不再扫描重建会话目录**(2026-10-31 重构)。
- *
- * 用户裁决:会话列表以云端 `FmodeChatThread` 为唯一来源。原先每次 /sync
- * 落地都扫一遍内存 timeline,会把「用户已删除的会话」重新扫回列表
- * (删除只是本地 + 云端软删,房间消息仍在)。
- *
- * 首次接入的初始化扫描已挪到 `openReal()` 中,仅在「云端该容器为空」时执行一次。
- */
- const sid = this._sessionId();
- if (!sid) return;
- /*
- * 🔴 新建态保护(2026-11-08 第三次修正,最终版)。
- *
- * 前两次都错在「用一个时间点解除保护」:
- * ① 在 `commit()` 时清 —— 本地记账,网络请求还在路上;
- * ② 在 `sendReal()` 返回后清 —— 只是「我这边发出去了」,
- * 服务端还没把这条件纳入房间 timeline,容器也还没回应。
- * 两次都留下同一个空窗:保护一撤,`/sync` 落地就把该房间
- * **所有旧容器线程根**算成本会话的(`rootsOfSession` 认领全部历史)。
- * 实测:解除保护后 60ms 内就灌入 17 条旧消息。
- *
- * 正确的判据不是「发出了」,而是「**本会话确实有消息落在 timeline 里**」:
- * 用户那条消息一旦出现在 timeline(`threadId === sid`),
- * `rootsOfSession` 就只会认领由它派生出的容器子线程,旧历史天然被排除。
- *
- * 所以在保护期内,先看 timeline 里有没有本会话自己的消息:
- * · 没有 → 还没落地,继续挡(旧历史一律不进)
- * · 有 → 解除保护,本次及之后的回填都按正常规则走
- */
- if (this._blankSessionId && sid === this._blankSessionId) {
- /*
- * 🔴 必须是「本会话的**用户消息**」才算落地(2026-11-09 修正)。
- *
- * 原判据是「timeline 里有任意 `threadId === sid` 的事件」。
- * 但容器回复也可以挂在这个线程根下 —— 于是容器刚回的**第一条**就
- * 撤掉了保护,紧接着的回填把整个房间的旧历史灌进来
- * (用户实测:新建会话后直接出现「加载更早」和旧内容)。
- *
- * 用户自己发的那条才说明「这段会话真正开始了」。
- */
- const landed = raw.some((m) => m.threadId === sid && m.sender === myUserId);
- if (!landed) return;
- this._blankSessionId = null;
- }
- if (this._state() === 'empty') return;
- /*
- * 🔴 只认「本会话自己产生过」的容器线程根(2026-11-08)。
- *
- * 这里直接计算,不依赖 `_ownedThreadRoots`(那是 `openReal` 在
- * 打开会话那一刻建的,新建会话时还没建)。判据:该线程根下存在
- * 本会话的用户消息 —— 用户在这个会话里发过东西,容器才会为它开子线程。
- */
- const ownedRoots = this.rootsOfSession(raw, sid, myUserId);
- const next = this.mapTimeline(
- raw.filter((m) => this.belongsToSession(m, sid, false, ownedRoots)),
- myUserId,
- );
- if (next.length === 0) return;
- /*
- * 🔴 合并而不是整表覆盖(2026-11-08)。
- *
- * `_messages.set(next)` 会把本地的乐观消息(刚发出、云端还没回读到的)
- * 一起抹掉,再排成「旧历史 → 新消息」的顺序 —— 用户看到新建对话里
- * 凭空出现昨天的内容,且自己的提问被挤到下面。
- *
- * 现在:历史里有的以历史为准,历史里没有的本地消息**保留**并排在后面。
- *
- * 🔴 读 `_messages()` 必须走 `untracked`:本 effect 正在往里写,
- * 直接读会形成自依赖 → 无限重算(实测会导致页面卡死、按钮点不动)。
- */
- const local = untracked(() => this._messages());
- this._messages.set(this.mergeTimeline(next, local));
- /*
- * 🔴 这里**不能**标记「正在回复」:这是历史重读(每次 sync 都会重新灌一遍),
- * 刷新页面时尾部 id 必然变 → 一打开就显示「正在回复」(用户实测)。
- * 真正的实时信号在 appendIncoming()。
- */
- if (this._state() === 'empty') this._state.set('ready');
- /*
- * 🔴 只在**保护期结束后**才按房间 token 决定能否翻页(2026-11-08)。
- *
- * `paginationToken()` 取的是**整个房间**的 backward token,与当前 thread 无关。
- * 新建会话照样能拿到非 null → 页面出现「加载更早的消息」→ 一点就翻出旧内容。
- * 保护期内恒为 false:这条会话确实没有「更早的消息」。
- */
- this._hasMoreHistory.set(
- this._blankSessionId !== sid && this.mx.paginationToken(rid) != null,
- );
- });
- }
- /**
- * 打开会话(路由进入时调用)。
- *
- /**
- * 打开会话(路由进入时调用)。
- *
- * @param agentId 数字生命
- * @param sessionId 指定会话(线程根);不传则打开该 agent 最近一个会话
- * (一条都没有 → 空态,**不自动新建**,新建走 `startBlank()`)
- * @param force 强制重开(「重新连接」「重新加载」用)。
- * 🔴 不加这个参数时,下面的幂等短路会让重连按钮**完全无效**:
- * 断线后 `_state` 仍是 `'ready'`,`open()` 进第一行就 return,
- * 页面停在「连接已断开」上按多少次都没反应(用户实测)。
- */
- async open(agentId: string, sessionId?: string, force = false): Promise<void> {
- /*
- * 幂等短路:同一容器 + 同一会话 + **已加载完成**(ready 或 empty)。
- *
- * 🔴 `empty` 也要算「已完成」:空房间(没有任何会话)的 state 就是 `empty`,
- * 只判 `ready` 会让每次进入都重跑 provision + 连接 + 云端拉取,
- * 既慢又容易和其它异步流程打架(实测:空态页反复重载)。
- */
- const loaded = this._state() === 'ready' || this._state() === 'empty';
- if (
- !force &&
- this._agentId() === agentId &&
- (!sessionId || this._sessionId() === sessionId) &&
- loaded
- ) {
- return;
- }
- const my = ++this.seq;
- /*
- * 🔴 切走前先清掉上一条空会话(2026-11-01)。
- *
- * 场景:用户在不同工作区连点几次「新建对话」,每次都建出一条 0 条消息的会话,
- * 且都没发消息就切走了。持久化的唯一条件是「发过消息」,
- * 这些没落库的空壳应当**在离开时消失**,而不是留在左侧列表里。
- *
- * 保留 `sessionId`(本次要打开的那条)—— 用户刚点完新建正在看它。
- */
- /*
- * 🔴 待打开的那条必须一起保留(2026-11-06)。
- * 刷新后首次进入时,ed 在本地目录里的 `messageCount` 可能还是 0(消息数以
- * 云端/演示留存量回填得晚),只传 `sessionId` 之外的"保留"是不够的 ——
- * 原实现把「本次要打开的会话」当空壳删掉 → `_sessionId` 悬空 → 输入区消失。
- */
- const openTarget = sessionId ?? this._sessionId();
- this.sessions.pruneEmpty(openTarget ?? undefined);
- this.teardownSubscriptions();
- this._agentId.set(agentId);
- this._state.set('loading');
- this._error.set(null);
- this._messages.set([]);
- this._draft.set('');
- this._peerTyping.set(false);
- this._turnEndedAt.set(0);
- this._turnEndedAfter.set(null);
- try {
- await this.live.ensureLoaded();
- if (my !== this.seq) return;
- const agent = this.live.agentById(agentId);
- if (!agent) {
- /*
- * 🔴 「取不到」有三种原因,不能一律报「不存在」:
- * ① 数据加载失败(网络/token)→ 报加载失败并给重试
- * ② 仍在加载中(并发场景)→ 报加载中,不算错误
- * ③ 确实没这个 agent → 才报不存在
- *
- * 刷新页面时最容易踩:live 还是 loading,agentById 返回 undefined,
- * 原实现直接判「数字生命不存在」(实测复现)。
- */
- const liveState = this.live.state();
- this._state.set('error');
- this._error.set(
- liveState === 'error'
- ? `数据加载失败:${this.live.error() ?? '未知错误'}`
- : liveState === 'loading' || liveState === 'idle'
- ? '正在加载数字生命数据,请稍候…'
- : '数字生命不存在',
- );
- return;
- }
- this._connection.set('connecting');
- if (environment.useMockData) {
- // ---- 演示态 ----
- this.provision = await this.api.provisionMatrix(agentId);
- if (my !== this.seq) return;
- this._connection.set('connected');
- /*
- * 演示态也要有 roomId:`startBlank()` / `sendText()` 都以它为「容器已就绪」的依据。
- * 缺了它,「新建对话」会静默返回 null(实测:按钮点了没反应)。
- */
- this.roomId = `mock-room-${agentId}`;
- this.roomAgentMap.set(agentId, this.roomId);
- /*
- * 演示种子只属于「历史遗留会话」(`_sessionId` 为空)——
- * 它们没有线程根,刷新时落在这条分支里,靠种子填充。
- *
- * 🔴 指定了会话(用户在演示态里自己新建/发送过的线程)时不能塞种子
- * (2026-11-06 渲染测试实测):种子没有线程归属,会串进用户自己的会话;
- * 而且种子只在首次打开时写入,刷新后被清空的要点正是「历史没有留痕」。
- * 演示态下用会话内留存量恢复,保证刷新后仍能看到自己发过的内容。
- */
- /*
- * 🔴 选中哪条会话必须在这里定(2026-11-06)。
- * 真实态由 `openReal()` 末尾统一 `_sessionId.set(sid)`;演示态早退,
- * 原来只填了消息没定会话 —— 刷新后 `_sessionId` 为空,
- * 页面渲染成「还没有对话」,输入区整块消失(实测反复复现)。
- * 地址没指定会话时退回该容器最近一条(与真实态同语义)。
- */
- const demoSid = sessionId || this.sessions.latestFor(agentId)?.id || '';
- this._sessionId.set(demoSid);
- this._messages.set(demoSid ? this.demoRestored(demoSid) : this.seedMessages(agentId));
- this._state.set(this._messages().length === 0 ? 'empty' : 'ready');
- return;
- }
- // ---- 真实态 ----
- await this.openReal(agentId, my, sessionId);
- } catch (e) {
- if (my !== this.seq) return;
- this._connection.set('disconnected');
- this._state.set('error');
- this._error.set(msgOf(e));
- }
- }
- /**
- * 新建对话:**立刻创建一条会话**并在界面选中它。
- *
- * 2026-11-01 用户裁决变更(原为「首次发消息才建」):
- * 点「新建对话」之后,左侧对应区域(工作区文件夹 / 日常对话)必须**立刻**
- * 出现一条「新对话」并且处于选中态 —— 否则用户点了按钮看不到任何反馈,
- * 不知道操作有没有生效。
- *
- * 代价与对策:用户点了却不发消息就切走,会留下 0 条消息的空壳。
- * 由 `sessions.pruneEmpty(runningId)` 在**打开容器**和**切走会话**时清理,
- * 只保留当前正在看的那条。
- *
- * @param agentId 目标容器
- * @param workspaceId 归属工作区(`null` = 日常对话)
- * @returns 创建出的会话;容器未就绪时返回 null
- */
- async startBlank(agentId: string, workspaceId: string | null = null): Promise<ChatSession | null> {
- /*
- * 🔴 先自增 `seq`:`startBlank` 不经过 `openReal()`,不会自带版本号,
- * 而此时可能正好有一次 `openReal()` 在途(切换容器/前一次进入的异步尾段)。
- * 它落地时只校验 `my !== this.seq`,版本号不变就会把它**当成当前**继续写
- * `_sessionId` / 历史回填 —— 典型表现是「新建后瞬间又被旧会话内容占满」。
- * 这里自增一次,让所有在途流程作废,再由下面的 `open()` 重新建立连接。
- */
- this.seq++;
- /*
- * 🔴 不调用 `open()`(2026-11-01 修正)。
- *
- * `open()` 会顺带做「打开该容器最近的会话」这一步,把 `_sessionId` 指向已存在的
- * 会话 —— 紧接着我们又把它改成本次新建的那条,来回横跳;
- * 更糟的是当容器里已有空会话时,`open()` 会先选中它,导致复用判断和
- * 「当前会话」指针不一致(测试实测:连点新建变成两条)。
- *
- * 这里只做「确保容器已连接」这件事,选会话完全由本方法自己决定。
- */
- if (this._agentId() !== agentId || !this.roomId) {
- await this.open(agentId);
- }
- if (this._agentId() !== agentId) return null;
- if (this._state() === 'error') return null;
- const roomId = this.roomId;
- if (!roomId) return null;
- /*
- * 🔴 全局只保留**一条**「新建对话」(2026-11-01 用户裁决)。
- *
- * 用户可以在工作区里新建、也可以在日常对话里新建。规则是:
- * 每次新建 → 之前**所有没发过消息**的空会话一律清掉,只留这一条。
- * 不再按「同容器 + 同归属」复用,也不依赖 60 秒定时清理 ——
- * 那样会出现「工作区一条、日常一条」并存的情况,不符合预期。
- *
- * 新建的会话归属到目标工作区(`workspaceId`),但**不落库**:
- * 持久化的唯一前提是在这条会话里发过消息(见 create/commit 的分工)。
- */
- const s = this.sessions.create(roomId, agentId);
- if (workspaceId) this.sessions.moveTo(s.id, workspaceId);
- // 清掉此前留下的其它空会话,保证列表里只有这一条「新建对话」
- this.sessions.pruneEmpty(s.id);
- // 切到这条新会话(左侧随之高亮,见 session-tree 的 activeId)
- this._sessionId.set(s.id);
- /*
- * 🔴 清空消息前**必须解除旧订阅**(2026-11-07 用户反馈「新建对话带出旧数据」)。
- *
- * 上面的 `open(agentId)` 会订阅该房间的实时事件(`onMessage` → `appendIncoming`)。
- * 只把 `_messages` 置空是不够的:订阅还挂在房间上,
- * 后续任何回填 / 重放事件都会把**上一个会话的消息塞回新会话里**。
- *
- * 解除后由「首次发消息」那一步重新建立订阅(`open()` 在真正打开会话时再订)。
- */
- this.teardownSubscriptions();
- /*
- * 🔴 清空「已认领的容器线程根」(2026-11-08)。
- *
- * 该集合只在 `openReal()` 里重建(`clear()` + 重新计算),而 `startBlank()`
- * **不经过 `openReal`** → 集合里残留着**上一个会话**的根。
- * `paginate()` 恰好走这个集合(`belongsToSession(m, sid)` 无 ownedRoots 参数),
- * 于是一翻页就把旧会话的容器子线程认领进新会话。
- */
- this._ownedThreadRoots.clear();
- this._messages.set([]);
- this._state.set('empty');
- this._error.set(null);
- this._hasMoreHistory.set(false);
- this._peerTyping.set(false);
- this._turnEndedAt.set(0);
- this._turnEndedAfter.set(null);
- this._blankWorkspace = workspaceId;
- // 新建态标记:storeVersion effect 据此拒绝回填(见字段注释)
- this._blankSessionId = s.id;
- return this.sessions.byId(s.id) ?? s;
- }
- /**
- * 真实态开启流程。
- *
- * 失败时**分环节报错**(SPEC FR-04:不得用笼统文案掩盖具体环节):
- * FMODE 开通 / Matrix 登录 / 房间创建。
- */
- private async openReal(agentId: string, my: number, sessionId?: string): Promise<void> {
- // ① provision:拿服务端签发凭据(含绑定表命中的 roomId)
- let prov: MatrixProvision;
- try {
- prov = await this.api.provisionMatrix(agentId);
- } catch (e) {
- if (my !== this.seq) return;
- throw new Error(`FMODE 开通失败:${msgOf(e)}`);
- }
- if (my !== this.seq) return;
- this.provision = prov;
- // ② 会话级连接(幂等;同身份重复进入不打断 sync —— 未读计数依赖它常驻)
- try {
- await this.mx.connectSession(prov.accessToken, prov.userMxId ?? '', prov.deviceId);
- } catch (e) {
- if (my !== this.seq) return;
- throw new Error(`连接失败:${msgOf(e)}`);
- }
- if (my !== this.seq) return;
- // ③ 房间(绑定表 roomId 优先)
- const agentMxId = prov.agentMxId ?? '';
- if (!agentMxId) throw new Error('房间创建失败:服务端未返回 Agent 身份');
- let rid: string;
- try {
- rid = await this.mx.ensureRoom(agentMxId, {
- roomId: prov.roomId,
- agentName: this.live.agentById(agentId)?.name,
- });
- } catch (e) {
- if (my !== this.seq) return;
- throw new Error(`房间创建失败:${msgOf(e)}`);
- }
- if (my !== this.seq) return;
- this.roomId = rid;
- // 记录 agentId ↔ roomId 映射并持久化(未读计数按房间归属到 Agent 时需要)
- this.roomAgentMap.set(agentId, rid);
- this.persistRoomMap();
- /*
- * ③·补 确定当前会话(线程根)。
- *
- * 未指定 → 打开该 agent 最近的会话;一个都没有 → 新建一个(纯本地,不触网)。
- * 显式指定但本地不存在(如分享了 /chat/:agentId/:sessionId 链接)→ 以只读方式
- * 切过去,仍能按 thread 过滤显示消息,只是没有标题。
- *
- * 🔴 「历史对话」承接机制已于 2026-10-29 下线(用户裁决:这个条目不要、不能改名)。
- * 无 thread 的旧消息不再单独成会话,也不再在列表中露出 —— 见 `dropLegacySessions`。
- */
- // ④ 订阅实时事件(先订阅再读 timeline,避免中间漏事件)
- const myUserId = prov.userMxId ?? '';
- this.unbindMessage = this.mx.onMessage(rid, (m) => {
- if (my !== this.seq) return;
- this.appendIncoming(m, myUserId);
- });
- this.unbindTyping = this.mx.onTyping(rid, (typing, who) => {
- if (my !== this.seq) return;
- /*
- * 🔴 必须排除自己:`m.typing` 会广播**房间内所有成员**的输入状态(含自己)。
- * 不过滤 → 用户自己打字时界面显示「对方正在输入」,方向完全相反。
- */
- if (myUserId && who === myUserId) return;
- this._peerTyping.set(typing);
- /*
- * 容器从「正在输入」转成「没在输入」= **本轮结束信号**(2026-11-11)。
- *
- * 容器侧插件在 `post_llm_call` 发 `m.typing=false`,这是它在 Matrix 里
- * 唯一能可靠发出的「我干完了」。此前容器整轮一条 typing 都不发,
- * App 只能等 25/60 秒静默窗口,20 秒的活显示到 50 多秒还在「输出中」。
- *
- * 🔴 记的是「此刻最后一条 agent 消息的 id」,不是时刻 —— 见
- * `_turnEndedAfter` 的注释:跨时钟比较会被服务端/本地时差打死。
- */
- if (!typing) {
- this._turnEndedAt.set(Date.now());
- this._turnEndedAfter.set(this.lastAgentMessageId());
- }
- });
- /*
- * 容器的 ✅ / ❌ reaction = **本轮真的干完了**(2026-11-11)。
- *
- * 这是比 typing 更硬的信号:实测容器每轮处理结束都会把 👀 换成 ✅,
- * 由 Hermes 内建行为发出,不依赖插件改造。
- *
- * 用户痛点:「先显示已完成,过很久正文才出来」——容器分条发送时,
- * 第一条消息到达时静默窗口倒计时就开始了,而真结果还在后面。
- * 用 reaction 认领后,只有容器真正收尾才判结束。
- */
- this.unbindTurnReaction = this.mx.onTurnReaction?.(rid, (_ok, sender) => {
- if (my !== this.seq) return;
- /*
- * 🔴 必须校验 sender(2026-11-11,容器侧代码取证指出)。
- *
- * 群聊里 @ 了多个容器时,**每个容器各打一个 ✅、打在同一个 event_id 上**。
- * 若不看 sender,A 容器收工会被当成 B 容器也收工 —— 界面提前收尾、
- * 用户以为 B 挂了。
- *
- * 单聊只有本容器一个 agent,这条校验恒为真、不影响原有行为。
- */
- if (agentMxId && sender && sender !== agentMxId) return;
- /*
- * 🔴 稍等一拍再取「末条 agent 消息 id」(2026-11-11)。
- *
- * reaction 与它标注的消息是两个独立事件,到达顺序**不保证**:
- * 实测 +15.3s 出正文、+15.4s 出 ✅,但也有网络乱序时 ✅ 先到的情形。
- * 若在那一刻就取 id,可能取到上一条,导致本轮永远等不到结束信号。
- *
- * 让出一拍(`setTimeout 0` 不够,消息要过一遍 signal 更新)再取,
- * 代价是收尾晚几十毫秒,用户完全无感。
- */
- const stamp = () => {
- if (my !== this.seq) return;
- this._turnEndedAt.set(Date.now());
- this._turnEndedAfter.set(this.lastAgentMessageId());
- };
- setTimeout(stamp, 300);
- }) ?? null;
- /*
- * ⑤ 读当前 timeline —— 先确定会话,再按会话(thread)过滤,**只取尾部一屏**。
- *
- * 🔴 会话必须在读 timeline **之后**确定:
- * `/sync` 是异步的,首次打开时 timeline 往往还没回填完(实测为空)。
- * 若在读取前就判「有没有无 thread 旧消息」→ 永远判成没有 →
- * 「历史对话」不会被建立,旧消息在任何会话里都看不到(实测踩坑)。
- *
- * 🔴 按 thread 过滤是**多会话隔离的关键**:
- * 同一个房间里会有多条会话的消息混在 timeline 里,
- * 不按 `threadId` 过滤就会把别的会话的内容显示在当前会话里。
- */
- const rawTimeline = this.mx.timelineToMessages(rid);
- /*
- * 某会话在 timeline 里的**真实最后消息时间**(2026-11-07)。
- *
- * `adopt()` 收养会话时用它作 `updatedAt`,而不是 `Date.now()` ——
- * 否则每打开一次就把会话推到左栏第一位(用户反馈的排序问题:
- * 「只是点开看看,它就跳到最上头了」)。
- * 取不到时返回 undefined,由 adopt 自行兜底。
- */
- const lastMessageAtOf = (sessionId: string): number | undefined => {
- let max = 0;
- for (const m of rawTimeline) {
- if (!this.belongsToSession(m, sessionId)) continue;
- // `MxMessage.ts` 是毫秒数(不是字符串),无需解析
- if (Number.isFinite(m.ts) && m.ts > max) max = m.ts;
- }
- return max > 0 ? max : undefined;
- };
- /*
- * ===== 会话目录:**云端表是唯一来源**(2026-11-01 定稿)=====
- *
- * 用户裁决:
- * ① 云端不可达 → **完全不显示会话**(宁可「加载失败」,也不展示可能过期的缓存);
- * ② **彻底关闭**「从 Matrix 房间扫描重建会话」—— 以后新建的会话只由「发消息」产生。
- *
- * 所以这里只剩一件事:拉一次云端。扫不扫描、要不要补推,这里一概不做。
- * 任何「猜」的逻辑都是历史上会话反复增生的来源(扫描 → upsert → 翻倍)。
- */
- await this.sessions.hydrateFromCloud(agentId, sessionId);
- if (my !== this.seq) return;
- /*
- * 云端不可达 → **不显示任何会话**(用户裁决 2026-11-01)。
- *
- * 这里给的是「会话目录加载失败」而不是空态:空态意味着「你确实还没有对话」,
- * 而实际是「我们不知道你有没有」。两者文案必须区分,否则用户会以为自己
- * 的对话被删了(历史上反复出现),从而去重复新建,进一步制造脏数据。
- */
- if (!this.sessions.ready() && this.sessions.error()) {
- /*
- * 显式指定了会话(URL 里有线程根)时**不清空选中**(2026-11-06):
- * 目录不可信只影响「有哪些会话」,不影响「用户在打开哪一条」。
- * 清空会让页面渲染成「还没有对话」,输入框整块消失。
- */
- const keep = sessionId ?? '';
- this._sessionId.set(keep);
- this._messages.set(keep ? this.demoRestored(keep) : []);
- this._state.set('error');
- this._error.set(`会话目录加载失败:${this.sessions.error()}`);
- this._connection.set(this.mx.syncState() === 'connected' ? 'connected' : 'connecting');
- this._hasMoreHistory.set(false);
- return;
- }
- /*
- * 🔴 「历史对话」承接机制已**整体下线**(2026-10-29 用户裁决)。
- *
- * 原逻辑:房间里有不带 thread 的旧消息时,自动建一个 legacy 会话承接它们。
- * 问题:这个条目在多会话体系里既不能改名、也不能归档,却混在正常对话列表里 ——
- * 用户看到的就是一个叫「历史对话」的怪东西。
- *
- * 现在的处理:**不再创建** legacy 会话;已有 legacy 会话在打开时清理掉。
- * 旧的无线程消息不再在界面上出现(消息本身仍在服务器,未删除)。
- */
- this.dropLegacySessions(agentId);
- let sid = sessionId;
- if (!sid) {
- /*
- * 🔴 进入容器时**不再自动新建空会话**(2026-10-31 用户裁决)。
- *
- * 原实现 `ensureFor()` 在没有会话时会 create() 一条「新对话」——
- * 结果每进一次容器就多一条空的「新对话」,还排在列表最上面。
- * 现在:
- * · 有会话 → 打开最近一条;
- * · 一条都没有 → 不建,交给页面显示「空态 + 开始新对话」,
- * 用户点了「新建对话」才 create(显式操作优先)。
- */
- const latest = this.sessions.latestFor(agentId);
- if (!latest) {
- /*
- * 🔴 不建会话,但**不能提前 return**:连接状态、未读基线等收尾逻辑
- * (⑥⑦)仍要执行,否则界面会停在 connecting,看起来像连不上。
- * `sid` 留空 → 后续按会话过滤得到空列表,state=empty。
- */
- this._sessionId.set('');
- this._messages.set([]);
- this._state.set('empty');
- this._connection.set(this.mx.syncState() === 'connected' ? 'connected' : 'connecting');
- this._hasMoreHistory.set(false);
- this.unread.markRead(agentId, Date.now());
- this.ensureUnreadListener();
- return;
- }
- sid =
- latest.roomId === rid
- ? latest.id
- : this.sessions.adopt({
- id: latest.id,
- roomId: rid,
- agentId,
- lastMessageAt: lastMessageAtOf(latest.id),
- }).id;
- } else if (!this.sessions.byId(sid) || this.sessions.byId(sid)!.messageCount === 0) {
- /*
- * 🔴 已软删的会话**不允许经由 URL 重新打开**(2026-11-09 用户裁决:
- * 「列表项里面隐藏,并且不能通过 URL 直达」)。
- *
- * ⚠️ 判据必须是「**本地目录里根本没有这条**」,不能把「有这条但 0 条消息」
- * 也算进来 —— 新建对话恰恰就是 0 条消息的会话,原来那样写会让
- * 用户点「新建对话」后看到「该会话已删除」(实测反馈 2026-11-09)。
- *
- * 三重条件缺一不可:
- * ① 本地目录里没有这条(byId 取不到)
- * ② 云端墓碑里有它
- * ③ 不在本机「新建中」状态里(`_blankSessionId`)
- */
- const known = this.sessions.byId(sid);
- if (!known && this._blankSessionId !== sid && this.sessions.isCloudRemoved(sid)) {
- if (my !== this.seq) return;
- this._sessionId.set(null);
- this._agentId.set(agentId);
- this._state.set('empty');
- this._emptyReason.set('removed');
- this._messages.set([]);
- return;
- }
- /*
- * 🔴 云端目录里暂时没有这条会话 —— 仍按地址打开它(2026-11-06)。
- *
- * 场景:刷新页面时本地目录已载入,云端拉取把「云端没有的」条目整体替换掉,
- * 随后 `open()` 开头的空壳清理才发现这条被当成空会话删了。
- * 原来这里 adopt 之后又被清理逻辑抹掉 → `_sessionId` 悬空 → 输入区消失。
- * 现在只要地址里明确给了线程根,就承认它:消息仍然按 thread 过滤展示,
- * 云端目录后续到达时自动补齐标题等元信息。
- */
- this.sessions.adopt({
- id: sid,
- roomId: rid,
- agentId,
- messageCount: 1,
- lastMessageAt: lastMessageAtOf(sid),
- });
- }
- if (my !== this.seq) return;
- this._sessionId.set(sid);
- /*
- * 🔴 会话目录被整表替换后,会话可能连着被删两次(2026-11-06)。
- * 循环里 `byId` 只在那一刻成立:清理在别处跑过一轮,它就不在了。
- * 这里兜底一次 —— 选中了就必须有一条可渲染的会话,否则页面渲染成
- * 「还没有对话」,输入区整块消失(刷新场景实测反复出现)。
- */
- if (!this.sessions.byId(sid)) {
- this.sessions.adopt({
- id: sid,
- roomId: rid,
- agentId,
- messageCount: 1,
- lastMessageAt: lastMessageAtOf(sid),
- });
- }
- /*
- * 重建「本会话的容器线程根」集合(2026-11-08)。
- *
- * 🔴 必须在过滤**之前**建好,否则第一轮过滤时集合为空,
- * 容器子线程的内容会被全部挡掉(比「串入旧会话」更糟)。
- *
- * 判据:这条容器线程根下,**存在属于本会话的用户消息**。
- * 也就是「用户在这个会话里发过东西,容器才为它开了子线程」。
- */
- this._ownedThreadRoots.clear();
- {
- // 先按 turnId/thread 关系把「本会话的用户消息」找出来
- const mine = new Set<string>();
- for (const m of rawTimeline) {
- if (m.sender !== myUserId) continue;
- if (m.threadId === sid || m.sessionId === sid) {
- mine.add(m.eventId);
- if (m.threadId) this._ownedThreadRoots.add(m.threadId);
- }
- }
- // 容器线程根:出现在本会话用户消息的 dispatch/turnId 关联里才算归属
- for (const m of rawTimeline) {
- const root = m.threadId;
- if (!root || isAppSessionId(root)) continue;
- // 该根下有任何消息的 turnId/sessionId 指向本会话 → 认领
- if (m.sessionId === sid || mine.has(m.eventId)) this._ownedThreadRoots.add(root);
- }
- }
- const all = this.mapTimeline(
- rawTimeline.filter((m) => this.belongsToSession(m, sid)),
- myUserId,
- );
- /*
- * 🔴 初次进入**不截断**(2026-11-03 修复)。
- *
- * 原实现 `slice(-INITIAL_PAGE)` 只保留最近 30 条 —— 早于这个窗口的消息
- * (比如用户几天前发的图片)在进入会话时**直接消失**,要一路点「加载更早」
- * 才可能翻到,实际表现为「图片完全看不到」。
- *
- * sync 已按 `initialSyncLimit: 50` 限定了房间事件量,这里再截一次纯属重复限制;
- * 更早的历史仍由 `paginate()` 按需向上翻。
- */
- this._messages.set(all);
- /*
- * 🔴 历史消息里的云盘链接也要认(2026-11-09)。
- *
- * `appendIncoming` 只处理**实时到达**的消息。刷新页面或重新打开一条
- * 老会话时,消息走的是这条回读路径 —— 如果不在这里提取,
- * 「打开会话 → 右栏成果空 → 再发一条新消息才出现」,用户会以为成果丢了。
- */
- for (const m of all) {
- if (m.sender !== 'agent') continue;
- /* 与实时路径同口径:优先容器交付段落,否则正则兜底 + 文件判定 */
- const delivered = extractDeliveredLinks(m.text);
- if (delivered.length > 0) {
- this.sessions.addDeliverableUrl(sid, ...delivered);
- } else {
- for (const u of extractS3Links(m.text)) {
- if (!isUserUploadUrl(u) && this.cardOfCloudLink(u)) {
- this.sessions.addDeliverableUrl(sid, u);
- }
- }
- }
- }
- // ⑥ 连接状态由 SDK 同步事件驱动
- this._connection.set(this.mx.syncState() === 'connected' ? 'connected' : 'connecting');
- /*
- * 「空」有两种,界面上必须区分(2026-11-09):
- * ① 真正新建的会话 —— 显示「下午好,我是 xxx」引导页
- * ② 有历史但一条也取不到 —— 只是空着,不能伪装成新建
- *
- * 判据:rawTimeline 里有没有**任何**属于这个房间的消息。
- * 有消息但过滤后为空 = 我们的归属规则把它们挡掉了,属于 ②。
- * 注意不能用 `messageCount`(实测虚高:云端记 21 条、Matrix 实际 2 条)。
- */
- const hadAnyEvent = rawTimeline.length > 0;
- this._emptyReason.set(this._messages().length > 0 ? null : hadAnyEvent ? 'unloaded' : 'fresh');
- this._state.set(this._messages().length === 0 ? 'empty' : 'ready');
- // timeline pagination token 是否存在 = 更早可能有历史
- this._hasMoreHistory.set(this.mx.paginationToken(rid) != null);
- // ⑦ 进入会话 = 已读:基线推进到最新消息时间(清未读计数)
- const latest = this._messages().reduce((max, m) => {
- const t = Date.parse(m.ts);
- return Number.isFinite(t) && t > max ? t : max;
- }, 0);
- this.unread.markRead(agentId, latest || Date.now());
- this.ensureUnreadListener();
- }
- /**
- * 全局未读监听:Agent 消息到达且**当前不在该会话**时 +1。
- *
- * 只注册一次(幂等);由 `MatrixClientService` 的全局消息流多路复用,
- * 故不会随会话切换重复挂监听。
- *
- * 由 `MatrixSessionService.bootstrap()` 在**建连前**调用 —— 保证连接建立后
- * 到用户首次进入会话之间的消息也会被计入未读。
- */
- ensureUnreadListener(): void {
- if (this.unbindUnread) return;
- this.unbindUnread = this.mx.onAnyMessage((m) => {
- const myUserId = this.provision?.userMxId ?? '';
- // 只计 Agent 发来的
- if (myUserId && m.sender === myUserId) return;
- const agentId = this.agentIdByRoom(m.roomId);
- if (!agentId) return;
- /*
- * 🔴 群聊派活产生的消息不计入单聊未读(2026-11-06)。
- * 这条回调不走 `belongsToSession`,不单独拦的话
- * 「群里派了活」会让该容器的单聊会话冒出未读红点。
- */
- if (m.dispatch || this.isAfterLastDispatch(m)) return;
- // 正在看的会话 → 直接推进已读基线,不产生未读
- if (agentId === this._agentId()) {
- this.unread.markRead(agentId, m.ts);
- return;
- }
- // 判定交给 UnreadService:初次同步回填阶段只推基线、不计数
- this.unread.onIncoming(agentId, m.ts, !this.mx.hasInitialSync);
- });
- }
- /** roomId → agentId(用已记录的会话映射反查) */
- private agentIdByRoom(roomId: string): string | undefined {
- for (const [agentId, rid] of this.roomAgentMap) {
- if (rid === roomId) return agentId;
- }
- return undefined;
- }
- // ---------- agentId ↔ roomId 映射持久化 ----------
- private roomMapKey(): string {
- const uid = this.session.me()?.id ?? 'anon';
- return `fmode_room_map_${uid}`;
- }
- private persistRoomMap(): void {
- try {
- localStorage.setItem(this.roomMapKey(), JSON.stringify([...this.roomAgentMap]));
- } catch {
- /* 隐私模式忽略 */
- }
- }
- /** 从 localStorage 载入映射(Shell 启动时调用一次) */
- loadRoomMap(): void {
- try {
- const raw = localStorage.getItem(this.roomMapKey());
- if (!raw) return;
- for (const [agentId, rid] of JSON.parse(raw) as [string, string][]) {
- if (agentId && rid) this.roomAgentMap.set(agentId, rid);
- }
- } catch {
- /* 忽略 */
- }
- }
- /**
- * 换账号时彻底复位(2026-11-01)。
- *
- * 🔴 服务都是单例、Shell 不会因登出重建,所以退出再登录时这里全是上一个账号的残留:
- * 会话指针、消息缓冲、房间映射、(经由 `sessions.reset()`)会话目录。
- * 不复位就会先闪出别人的对话,再被云端结果覆盖 —— 看起来像「会话串号」。
- *
- * ⚠️ 不断开 Matrix 连接:连接的生命周期由 `MatrixSessionService` 管。
- */
- resetForUserSwitch(): void {
- this.close();
- this.roomAgentMap.clear();
- this.sessions.reset();
- }
- /**
- * 关闭会话(离开聊天页)。
- *
- * 🔴 **不断开 Matrix 会话级连接** —— sync 常驻,否则离开后收不到消息,
- * 未读计数永远无法累加。只解绑本房间的事件订阅。
- */
- close(): void {
- this.seq++;
- this.teardownSubscriptions();
- this.provision = null;
- this.roomId = null;
- this._agentId.set(null);
- // 🔴 会话 id 必须一起清:残留会在下次打开时被误当成「已选定」的会话
- this._sessionId.set(null);
- this._messages.set([]);
- this._state.set('idle');
- this._error.set(null);
- this._connection.set('disconnected');
- this._draft.set('');
- this._peerTyping.set(false);
- this._turnEndedAt.set(0);
- this._turnEndedAfter.set(null);
- this._paginating.set(false);
- this._hasMoreHistory.set(false);
- this._blankWorkspace = null;
- /*
- * 🔴 新建态标记必须复位(2026-11-08):只在 `sendText` commit 后清除时,
- * 用户「新建对话 → 不发消息 → 离开页面」会把标记留在内存里,
- * 下次万一撞上同一条会话,实时消息会被无声丢弃。
- */
- this._blankSessionId = null;
- }
- setDraft(text: string): void {
- this._draft.set(text);
- // 真实态下发 typing 通知(失败非致命)
- if (this.roomId && !environment.useMockData) {
- void this.mx.setTyping(this.roomId, text.length > 0);
- }
- }
- /** 发送文本(AC-04.2);失败留痕可重试(AC-04.8) */
- /**
- * 向**指定容器**发送一条消息,**不影响当前会话状态**(2026-11-05)。
- *
- * ## 为什么需要
- *
- * `sendText()` 绑定在 `_agentId` 上 —— 它只能发给「当前打开的那个容器」。
- * 伪群聊需要**同时**给 N 个容器各发一条,且不能来回切换当前会话
- * (那会打断正在看的会话、触发重连、搅乱消息流)。
- *
- * ## 实现
- *
- * 走 provision 直接拿房间,发一条**房间级消息**(不带 thread):
- * · 群聊里每个容器都只处理自己的 DM,不需要线程隔离
- * · 不写入 `_messages`(那不是当前会话的消息)
- *
- * @returns 发送成功返回 true;容器未接入 / provision 失败返回 false
- */
- async sendToAgent(
- agentId: string,
- text: string,
- meta?: MxDispatchMeta,
- ): Promise<DispatchResult> {
- const body = text.trim();
- if (!body) return { ok: false, baselineEventId: null, error: '空消息' };
- try {
- const room = await this.ensureAgentRoomFor(agentId);
- if (!room) return { ok: false, baselineEventId: null, error: '容器未接入' };
- const rid = room;
- /*
- * 🔴 发送**前**捕获锚点(2026-11-06)。
- *
- * 群聊补读要靠它判断「哪些事件是本轮之后的新回复」。
- * 用事件顺序而非时间 —— 容器与浏览器时钟不一致时,时间窗会漏收。
- * timeline 尚未就绪时返回 null,下游退化为宽限时间窗。
- */
- const baselineEventId = this.mx.lastMessageEventId(rid);
- const res = await this.mx.sendText(
- rid,
- body,
- undefined,
- undefined,
- meta ? { [DISPATCH_KEY]: meta } : undefined,
- );
- return { ok: true, eventId: res.event_id, baselineEventId, roomId: rid };
- } catch (e) {
- return {
- ok: false,
- baselineEventId: null,
- error: e instanceof Error ? e.message : '发送失败',
- };
- }
- }
- /**
- * 确保「与该容器的 DM 房间」已就绪(provision → 连接 → 房间)。
- *
- * 2026-11-07 从 `sendToAgent` 抽出:群聊重建历史、群聊发附件都要用同一条
- * 路径。**不要另写一份** —— provision 与 connectSession 的幂等语义
- * (同 token 不重建客户端)是靠 `MatrixClientService` 内部保证的,
- * 多写一份就会各自踩坑。
- *
- * @returns 房间 id;容器未接入或接入失败时 null(调用方决定是否中断)
- */
- async ensureAgentRoomFor(agentId: string, opts: { skipConnect?: boolean } = {}): Promise<string | null> {
- const prov = await this.api.provisionMatrix(agentId);
- const agentMxId = prov.agentMxId ?? '';
- if (!agentMxId) return null;
- /*
- * 🔴 读历史时不要重连(2026-11-07)。
- *
- * `connectSession` 在 token 变化时会 **disconnect 重建客户端**,
- * 而重建会中断正在进行的 `/sync` —— 群重建要遍历 N 个成员,
- * 逐个重连的结果是 sync 永远完不成,读出来的 timeline 全是空的。
- * 读历史只需要客户端已连接(消息收发本来就共用同一个用户身份)。
- */
- /*
- * 🔴 skipConnect 只表示「**已连接时**不要重建」。客户端根本不存在时
- * 仍然必须连 —— 否则 `ensureRoom` 里的 requireClient 抛错,
- * 重建拿到一堆空房间,把群历史覆盖成空(评审 2026-11-07 发现)。
- */
- if (!opts.skipConnect || !this.mx.connected) {
- await this.mx.connectSession(prov.accessToken, prov.userMxId ?? '', prov.deviceId);
- }
- this.roomAgentMap.set(agentId, prov.roomId ?? '');
- return this.mx.ensureRoom(agentMxId, {
- roomId: prov.roomId,
- agentName: this.live.agentById(agentId)?.name,
- });
- }
- /**
- * 群聊发附件:把一个文件发进**某个**成员的 DM 房间(2026-11-07)。
- *
- * 调用方对每个目标成员各调一次(同一份文件会在每个房间各上传一份 ——
- * Matrix 没有跨房间引用,这是平台约束)。
- *
- * 事件里带 `dispatch`(让重建能把它识别成本回合的用户内容)和
- * `sessionId = turnId`(让单聊过滤规则把它挡在单聊之外)。
- */
- async sendFileToAgent(
- agentId: string,
- file: File,
- meta: MxDispatchMeta,
- ): Promise<DispatchResult> {
- try {
- const rid = await this.ensureAgentRoomFor(agentId);
- if (!rid) return { ok: false, baselineEventId: null, error: '容器未接入' };
- const baselineEventId = this.mx.lastMessageEventId(rid);
- const res = await this.mx.sendFile(
- rid,
- file,
- { name: file.name, size: file.size, mimeType: file.type || 'application/octet-stream' },
- undefined,
- undefined,
- { [DISPATCH_KEY]: meta, [SESSION_KEY]: meta.turnId },
- );
- return { ok: true, eventId: res.event_id, baselineEventId, roomId: rid };
- } catch (e) {
- return {
- ok: false,
- baselineEventId: null,
- error: e instanceof Error ? e.message : '附件发送失败',
- };
- }
- }
- async sendText(text: string): Promise<void> {
- const body = text.trim();
- /*
- * 只有附件、没有文字也要能发 —— 用户选完图直接点发送是常见操作。
- * 两者都没有才返回。
- */
- if (!body && this._pendingFiles().length === 0) return;
- const conversationId = this._agentId();
- if (!conversationId) return;
- /*
- * 乐观消息 id(2026-11-09)。
- *
- * 🔴 原来用 `local-${Date.now()}`:连点两次(同一毫秒)会生成**同一个 id**,
- * 两条消息互相顶替;而且这个 id 和它自己的服务端 echo(`$eventId`)对不上,
- * 合并时双双保留 → 一条「你好」显示成 3 条。
- * 现在带上随机串,并把这个 id 随事件写进 content,回读时按它认领。
- */
- const localId = `local-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
- const msg: Message = {
- id: localId,
- conversationId,
- sender: 'me',
- kind: 'text',
- // 纯附件时不插入空气泡(附件会在上传后各自成条)
- text: body || undefined,
- ts: new Date().toISOString(),
- state: 'sending',
- localId,
- };
- if (body) this._messages.update((m) => [...m, msg]);
- this._draft.set('');
- // 演示态:留一份存量,刷新后能恢复(真实态历史以 Matrix 为准)
- this.rememberDemoHistory();
- this._sending.set(true);
- /*
- * 会话在**第一次发消息时**才创建(2026-11-01 用户裁决)。
- *
- * 🔴 取代了「点新建对话就 create() 一条空会话」:
- * 那个做法必然产生空壳(用户点了又反悔、切走、关页面都会留下一条 0 消息的
- * 「新对话」),而空壳又要靠 `pruneEmpty` 事后清理 —— 清理再出 bug 就会
- * 变成「每进一次容器多一条」,这正是会话列表膨胀的来源之一。
- *
- * 改成懒创建后,**没有消息就不存在会话**,从根上不会有空壳。
- *
- * 顺序很关键:先建会话 → 再记账(`noteMessage` 才能把标题写进去)。
- */
- if (!this._sessionId()) {
- const created = this.ensureSessionForSend();
- if (created) {
- this._sessionId.set(created.id);
- /*
- * 🔴 必须同时把状态从 empty 切到 ready(2026-11-01 修复)。
- *
- * `startBlank()` 把 state 置为 `'empty'`,而消息区是按 state 分派的:
- * `@case ('empty')` 里「没有 currentSession → 显示新建引导」。
- * 发完第一条消息后 currentSession 有了,但 state 还是 empty ——
- * 页面继续渲染新建流程区,看起来像「发出去没反应」(用户实测反馈)。
- *
- * 建出会话 = 这已经是正常会话了,状态必须跟着走。
- */
- this._state.set('ready');
- }
- }
- /*
- * 会话元信息(标题/预览/时间)随**用户发出的消息**推进。
- *
- * 🔴 必须在这里记,不能只等容器回话:用户第一条消息就该定下对话名,
- * 否则新建的对话一直显示「新对话」直到容器回复(实测踩坑)。
- * `appendIncoming` 里另记一次是为了容器主动发起的情况。
- */
- const curSid = this._sessionId();
- if (curSid) {
- /*
- * 🔴 首次发消息 = 这条会话**真正成立**(2026-11-01 用户裁决)。
- *
- * 新建对话只在本地可见(左侧立刻出现「新建对话」),此时还不上云。
- * 用户发出第一条消息,才用 `commit()` 把它落库 ——
- * 这样「点了不发消息就切走」不会在云端留下 0 条记录。
- *
- * 必须在 `noteMessage` **之前** commit:否则推上去的还是「新建对话 / 0 条」,
- * 标题要等下一次推送才能更正(用户实测:历史会话名一直不对)。
- */
- this.sessions.noteMessage(curSid, {
- text: body,
- ts: Date.parse(msg.ts) || Date.now(),
- fromMe: true,
- });
- /*
- * 🔴 演示态:消息数必须落进本地目录(2026-11-06 实测)。
- * 宿主页面刷新前只写内存,目录里仍是「0 条消息」;`open()` 开头的
- * 空壳清理会把这条当「没发过消息的新建对话」删掉,恢复出来一片空白。
- */
- if (environment.useMockData) {
- this.sessions.setMessageCount(curSid, this.demoRestored(curSid).length || 1);
- }
- /*
- * 发出消息 = 这已是正常对话。
- * 新建对话建出来时 state 是 empty(还没有消息),用户一发消息就该切到 ready ——
- * 否则消息区继续按空态渲染,发出去的内容要等下一次刷新才看得见。
- */
- if (this._state() === 'empty') this._state.set('ready');
- }
- /*
- * 🔴 用户一发出消息就进入「忙」——**这是事实,不是推测**(容器必定会收到)。
- *
- * 容器的运行态上报是分钟级(Hermes 按 turn 写库,再等下一轮上报),
- * 不这么做的话「发了消息 → 右侧还是空闲」会持续很久(用户实测反馈)。
- *
- * 记在 `TaskPulseService` 里而非改 Agent 数据:服务端仍是最终真值,
- * 只有容器报 `idle` 才能把它翻回去(前端绝不自己说「完成」)。
- */
- this.pulse.noteUserAction(conversationId);
- try {
- /*
- * 🔴 首次发消息 = **先落库,落库成功才发送**(2026-11-09 用户要求)。
- *
- * 用户原话:「第一次发送消息的时候,才会开始持久化存储这个对话;
- * 存储成功后,才会开始发送消息」。
- *
- * 原实现调 `commit()`(发出去就不管):消息照发、落库在后台跑。
- * 一旦落库失败,消息已经进 Matrix,而目录里没有这条记录 ——
- * 换设备就找不到这段对话,用户只在事后看到一条提示。
- *
- * 现在首次落库 `await`,失败直接落到下面的 catch:
- * 消息标记为「发送失败」,**不往容器发**,用户可重试。
- */
- if (curSid && this.sessions.needsCommit(curSid)) {
- await this.sessions.commitNow(curSid);
- }
- if (environment.useMockData) {
- await this.simulateSend();
- } else {
- /*
- * 🔴 「去问另一个容器」的指令由 App 搬运(实测结论,见 RelayService)。
- *
- * 用户说「问 lj-test:数据库什么版本」时:
- * · 原消息**不发给当前容器**(它没有能力去找别人)
- * · 由 App 旁路去问目标容器,把答复回填成一条 agent 消息
- *
- * 判断放在发送前,命中就不走普通发送。
- */
- /*
- * 🔴 先把暂存的附件发出去(用户要求:选文件 → 点发送 → 图文一起走)。
- * 附件失败不阻塞文字发送,但要如实告知。
- */
- if (this._pendingFiles().length > 0) {
- const { fail } = await this.uploadPending(conversationId);
- /*
- * 具体原因由 uploadPending 逐条写入(哪个文件、为什么失败);
- * 这里只在**没有具体原因**时补一句汇总,避免覆盖掉有用信息。
- */
- if (fail > 0 && !this._error()) {
- this._error.set(`${fail} 个附件上传失败,可重试`);
- }
- }
- if (!body) {
- // 纯附件:没有文字要发,到此为止
- return;
- }
- const relayed = await this.tryRelay(body, msg, conversationId);
- if (!relayed) await this.sendReal(body, msg.localId);
- }
- this.markSent(msg.id);
- /*
- * 🔴 这里**不解除**新建态保护(2026-11-08 第三次修正)。
- *
- * `sendReal()` 返回只代表「本机把事件发出去了」,服务端还没把它并入
- * 房间 timeline。此刻解除保护,紧接着落地的 `/sync` 就会把该房间
- * 所有旧容器线程根算成本会话的(实测 60ms 内灌入 17 条旧消息)。
- *
- * 解除点统一在 `storeVersion` effect 里:等 timeline 里真的出现
- * `threadId === 本会话` 的消息,说明本会话已在服务端成立,
- * 那时 `rootsOfSession` 只会认领由这条消息派生的子线程,旧历史天然排除。
- */
- } catch (e) {
- this.markFailed(msg.id, msgOf(e));
- /*
- * 发送失败**也要解除**:事件没能落到服务端,timeline 永远不会出现本会话的
- * 消息,再不解锁这条会话就永远收不到内容(容器仍可能从别的路径回复)。
- * 失败残留的旧历史是可接受的次要问题,远好于「彻底静默」。
- */
- if (curSid && this._blankSessionId === curSid) this._blankSessionId = null;
- } finally {
- this._sending.set(false);
- }
- }
- /**
- * 尝试按「中转指令」处理;返回是否已处理。
- *
- * 未命中(普通消息)返回 false,调用方继续常规发送。
- */
- private async tryRelay(body: string, localMsg: Message, conversationId: string): Promise<boolean> {
- const intent = this.relay.detect(body, this._agentId());
- if (!intent) return false;
- /*
- * 本地先落一条「已转问」的痕迹 —— 让用户知道这条消息**没有**发给当前容器,
- * 而是走了另一条路。原乐观消息保持 sending → 改成 sent 并改写文案。
- */
- const askerName = this.live.agentById(conversationId)?.name ?? '当前助手';
- this._messages.update((list) =>
- list.map((m) =>
- m.id === localMsg.id
- ? { ...m, text: `【转问 ${intent.target.name}】${intent.question}`, state: 'sent' as const }
- : m,
- ),
- );
- const answer = await this.relay.relay(intent, askerName);
- /*
- * 回填成一条 agent 消息。
- *
- * 🔴 必须走 `_messages.update` 而不是真实发送 —— 这条内容是 App 生成的,
- * 发到房间里会污染 Matrix 历史(也会被容器当成新指令)。
- */
- this._messages.update((list) => [
- ...list,
- {
- id: `relay-${Date.now()}`,
- conversationId,
- sender: 'agent' as const,
- senderName: intent.target.name,
- kind: 'notice' as const,
- text: answer,
- ts: new Date().toISOString(),
- state: 'sent' as const,
- },
- ]);
- if (this._state() === 'empty') this._state.set('ready');
- const sid = this._sessionId();
- if (sid) {
- this.sessions.noteMessage(sid, {
- text: answer,
- ts: Date.now(),
- fromMe: false,
- });
- }
- return true;
- }
- // ---------- 附件:先暂存,发送时一起提交 ----------
- /**
- * 把文件存进云盘(公开入口,供群聊等其它发送链路复用)。
- *
- * 单聊的 `uploadPending` 与群聊派活都要「文件进云盘」,但群聊
- * 只需存**一次**(单聊里每个房间的 Matrix 副本是另一回事)。
- * 这里只负责云盘那一半,失败抛错由调用方决定文案。
- */
- async uploadToCloud(file: File): Promise<{ key: string; publicUrl: string }> {
- if (isOversize(file)) throw new Error(oversizeMessage(file));
- return this.api.uploadFile(file);
- }
- /**
- * 把已上传云盘的文件登记进文件台账(2026-11-10)。
- *
- * 单聊与群聊共用:`threadRoot` 传会话 id —— 单聊是对话 id,群聊是群会话 id,
- * 这样右侧栏按会话查时互不串台。
- */
- /**
- * 把已上传云盘的文件登记进文件台账(2026-11-10,2026-11-11 加群归属)。
- *
- * @param opts.groupId 群会话 id —— 群内上传时**必须**传,
- * 否则群右栏刷新后看不到自己发的文件(只按 threadRoot 查不到)。
- * @param opts.roomId 显式指定房间;群聊不能沿用单聊的 `this.roomId`
- * (那是单聊 ChatService 的当前房间,群语境下不可靠)。
- */
- async registerSessionFile(
- threadRoot: string,
- file: File,
- up: { key: string; publicUrl: string },
- agentId?: string,
- opts: { groupId?: string; roomId?: string } = {},
- ): Promise<void> {
- await this.api.registerFile({
- threadRoot,
- agentId,
- roomId: opts.roomId ?? this.roomId ?? undefined,
- groupId: opts.groupId,
- name: file.name,
- url: up.publicUrl,
- key: up.key,
- mimeType: file.type || 'application/octet-stream',
- size: file.size,
- uploadedBy: 'user',
- });
- }
- /**
- * 记一条**派发锚点**(2026-11-11,群聊文件归群的依据)。
- *
- * 伪群聊下容器只在自己的 DM 房间工作,回报文件时不带群信息。
- * App 在派活那一刻知道「这条消息是替群 G 发给容器 A 的」,
- * 于是先落一条零字节的锚点记录:`threadRoot = 群 id`、`groupId = 群 id`、
- * `uploadedBy: 'agent'`(用 agent 侧标记,避免与用户上传混淆)。
- *
- * 之后容器产出文件时,服务端按「同 user + 同 roomId + 同 agentId +
- * 时间晚于锚点且在窗口内」反查归属 —— 见 `fmode-file-hub` 的说明。
- *
- * 失败不阻断派活:锚点只是「提高归属准确率」的辅助信息,
- * 没有它文件仍会登记,只是不归群(宁可漏,不归错)。
- */
- async registerGroupAnchor(
- groupId: string,
- agentId: string,
- roomId: string | null | undefined,
- turnId: string,
- ): Promise<void> {
- const anchorUrl = `https://s3.fmode.cn/__anchor__/${encodeURIComponent(groupId)}/${Date.now()}-${encodeURIComponent(turnId)}`;
- try {
- await this.api.registerFile({
- threadRoot: groupId,
- groupId,
- groupIdSource: 'anchor',
- agentId,
- roomId: roomId ?? undefined,
- name: '__anchor__',
- url: anchorUrl,
- mimeType: 'application/x-fmode-anchor',
- size: 0,
- uploadedBy: 'agent',
- } as never);
- } catch {
- /* 锚点失败不影响派活 */
- }
- }
- /**
- * 选择附件(**只暂存,不上传、不发送**)。
- *
- * 用户要求:先选文件,点发送时才随文字一起发出去。
- * 早期实现是「选完立刻上传并单发一条」—— 用户只想要图文一起发,那个行为是错的。
- */
- stageFiles(files: FileList | File[]): void {
- const all = Array.from(files);
- if (all.length === 0) return;
- /*
- * 大小拦截的第一层(2026-11-10):选文件时就挡掉超限项。
- * 第二层在 uploadPending 里兜底(防暂存期替换/绕过)。
- * 超限项**不进暂存区**,用户立刻看到原因 —— 比发出去再失败好。
- */
- const list = all.filter((f) => !isOversize(f));
- const rejected = all.filter((f) => isOversize(f));
- if (rejected.length > 0) {
- /*
- * 🔴 走**全局提示**(2026-11-11 修复)。
- *
- * 原来只写 `_error`,而 `_error` 仅在「消息区整体 error 空态」渲染 ——
- * 正常会话里用户完全看不到,文件选进来一按发送就消失,毫无解释。
- * 全局提示挂在根节点,任何页面都看得见、可手动关。
- * `_error` 保留写入,供 messages 区兜底展示。
- */
- notifyOversize(this.alerts, rejected);
- this._error.set(rejected.map(oversizeMessage).join(';'));
- }
- if (list.length === 0) return;
- this._pendingFiles.update((cur) => [
- ...cur,
- ...list.map((file) => ({
- id: `pf-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
- file,
- // 🔴 对象地址只生成一次并缓存 ——
- // 在模板里每次变更检测都调 createObjectURL 会不断泄漏
- previewUrl: file.type.startsWith('image/') ? URL.createObjectURL(file) : undefined,
- })),
- ]);
- }
- /** 移除一个待发附件(同时释放预览地址) */
- unstageFile(id: string): void {
- this._pendingFiles.update((cur) => {
- const victim = cur.find((f) => f.id === id);
- if (victim?.previewUrl) URL.revokeObjectURL(victim.previewUrl);
- return cur.filter((f) => f.id !== id);
- });
- }
- /** 清空待发附件(同时释放预览地址) */
- clearPendingFiles(): void {
- for (const f of this._pendingFiles()) {
- if (f.previewUrl) URL.revokeObjectURL(f.previewUrl);
- }
- this._pendingFiles.set([]);
- }
- /**
- * 更新单个附件的阶段/进度,并重算整批的百分比与主导阶段(2026-11-11)。
- *
- * 进度与阶段**都从各文件自己的状态派生**,不再手动 set 一个总数 ——
- * 这样多文件并发/串行、某个文件失败时,总体进度都是自洽的。
- */
- private setFileProgress(id: string, progress: Omit<FileProgress, 'id'>): void {
- const next: FileProgress = { id, ...progress };
- this._pendingFiles.update((cur) =>
- cur.map((f) => (f.id === id ? { ...f, progress: next } : f)),
- );
- const items = this._pendingFiles();
- this._uploadPct.set(overallPct(items.map((f) => ({ progress: f.progress, size: f.file.size }))));
- this._uploadPhase.set(batchPhase(items.map((f) => ({ progress: f.progress }))));
- }
- /**
- * 发送待发附件(内部使用)。
- *
- * @returns 每个附件是否成功
- */
- private async uploadPending(conversationId: string): Promise<{ ok: number; fail: number }> {
- const pending = this._pendingFiles();
- if (pending.length === 0) return { ok: 0, fail: 0 };
- this._uploading.set(true);
- this._uploadPct.set(0);
- this._uploadPhase.set('idle');
- let ok = 0;
- let fail = 0;
- /** 上传失败的暂存项 id(保留在待发区供重试) */
- const failedIds = new Set<string>();
- try {
- for (let i = 0; i < pending.length; i++) {
- const { file } = pending[i];
- /*
- * 大小兜底(2026-11-10):stageFiles 已拦过一层,这里防「暂存后文件被替换」
- * 或调用方绕过 stageFiles。超限项按失败处理,留在待发区让用户移除。
- */
- if (isOversize(file)) {
- notifyOversize(this.alerts, [file]);
- this._error.set(oversizeMessage(file));
- fail++;
- failedIds.add(pending[i].id);
- continue;
- }
- try {
- /*
- * 🔴 直接走 **Matrix 原生附件**,不再经过 OBS 预签名 + 自定义 JSON。
- *
- * 容器只认 `{ msgtype: 'm.file'|'m.image', url: 'mxc://...' }`。
- * 早期实现走 `api.uploadFile()` 拿 OBS 地址、再塞进 `m.text` 的 JSON body ——
- * 容器完全不解析,用户发图后它回「只看到文字,没收到附件」(实测)。
- *
- * 现在:文件内容直接上传 Matrix 媒体库 → 发原生事件。
- * `publicUrl` 仅用于界面本地展示,OBS 那条链路不再参与发送。
- */
- /*
- * 🔴 顺序:**先 S3、再 Matrix**(2026-11-10 调整)。
- *
- * 原先是 Matrix 先发、S3 后传,结果是「消息已经发出去了、云盘却没存上」,
- * 用户看到的是一个只存在于聊天记录里、换设备就打不开的文件。
- *
- * 现在反过来:云盘存不上就**不发 Matrix**,失败项留在待发区重试。
- * 这样「发出去的文件 = 一定能长期访问的文件」,不会出现半成功状态。
- *
- * 为什么要两份:
- * Matrix 那份给容器读(容器只认 `m.file` + `mxc://`,实测直链它收不到);
- * S3 那份给 App 长期查看与成果归档(Matrix 媒体会被平台清理)。
- */
- const mimeType = file.type || 'application/octet-stream';
- if (!this.roomId) throw new Error('会话未就绪');
- const sid = this._sessionId();
- /* ① 云盘:失败即中止本文件(不发送)。传 onProgress 拿**真实字节进度**(2026-11-11) */
- this.setFileProgress(pending[i].id, { phase: 'uploading', pct: 0 });
- const up = await this.api.uploadFile(file, (pct) => {
- this.setFileProgress(pending[i].id, { phase: 'uploading', pct });
- });
- const cloudUrl = up.publicUrl;
- /*
- * `key` 是云盘里的稳定对象标识,`publicUrl` 只是它的投影。
- * 以前只留了 url、把 key 丢了 —— 将来要按用户/会话检索文件时,
- * key 才是能对上号的字段,所以一并记进成果。
- */
- /*
- * ② 登记到文件台账(2026-11-10)。
- *
- * 用户要求:文件与成果入库,右侧栏**直接按会话从数据库加载**,
- * 不再依赖「先加载聊天历史才看得到」。
- *
- * 登记失败不阻断发送(文件已经在云盘上了,只是台账缺一条),
- * 但要如实提示 —— 不能让用户以为文件没上去。
- */
- if (sid) {
- try {
- this.setFileProgress(pending[i].id, { phase: 'registering', pct: 100 });
- await this.api.registerFile({
- threadRoot: sid,
- agentId: this._agentId() ?? undefined,
- roomId: this.roomId,
- name: file.name,
- url: cloudUrl,
- key: up.key,
- mimeType,
- size: file.size,
- uploadedBy: 'user',
- });
- } catch (e) {
- this._error.set(
- `「${file.name}」已存入云盘,但未记入文件列表:${e instanceof Error ? e.message : '登记失败'}`,
- );
- }
- }
- /* ② Matrix:供容器读取 */
- this.setFileProgress(pending[i].id, { phase: 'sending', pct: 100 });
- await this.mx.sendFile(
- this.roomId,
- file,
- { name: file.name, size: file.size, mimeType },
- sid ?? undefined,
- );
- this.setFileProgress(pending[i].id, { phase: 'done', pct: 100 });
- /*
- * 云盘地址记进 `_cloudUrls`:回读的 `m.image` 只有 `mxc://`,
- * 没这张表就补不回 `FileCard.cloudUrl`(2026-11-10 合并修复)。
- *
- * 🔴 文件**不进**会话成果列表(`deliverableUrls`):用户自己发的
- * 东西只属于右栏「文件」页签,「成果」= 容器产出的东西。
- */
- this._cloudUrls.set(this.cloudKeyOf(file.name, file.size), cloudUrl);
- // 本地预览用对象地址;Matrix 的 mxc 要等回读后才能转 http
- const displayUrl = URL.createObjectURL(file);
- const card: FileCard = {
- kind: 'file',
- name: file.name,
- size: file.size,
- mimeType,
- url: displayUrl,
- /* 云盘地址:S3 先行之后,能走到这里的文件一定两边都有 */
- cloudUrl,
- };
- /*
- * 本地先插一条文件气泡,发出去立刻可见(用户裁决:本地先插)。
- *
- * ⚠️ 回读的 `m.image` 还会再落一条 —— 依赖回合聚合的
- * `USER_MERGE_MS` 把两条并成同一回合,若出现「两个用户气泡」,
- * 先查这里而不是直接删本地插入。
- */
- this._messages.update((m) => [
- ...m,
- {
- id: `local-file-${Date.now()}-${file.name}`,
- conversationId,
- sender: 'me',
- kind: 'text',
- file: card,
- ts: new Date().toISOString(),
- state: 'sent',
- },
- ]);
- ok++;
- } catch (e) {
- fail++;
- failedIds.add(pending[i].id);
- /* 如实告知失败原因(S3 先行后,这里通常是「云盘没存上」⇒ 未发送) */
- this._error.set(
- `「${file.name}」发送失败:${e instanceof Error ? e.message : '上传失败'}`,
- );
- /*
- * 失败项标成 `failed`:胶囊上显示「上传失败」并保留 ✕ 让用户移除。
- * 不在这里弹全局提示 —— 失败项本身就在输入框上方可见,
- * 再弹一条只是噪音;真正需要全局提示的是「根本没进暂存区」的超限。
- */
- this.setFileProgress(pending[i].id, {
- phase: 'failed',
- pct: 0,
- error: e instanceof Error ? e.message : '上传失败',
- });
- }
- }
- } finally {
- this._uploading.set(false);
- this._uploadPct.set(0);
- this._uploadPhase.set('idle');
- /*
- * 🔴 按 id 精确保留失败项(2026-11-03 修复)。
- *
- * 原实现 `cur.slice(ok)` 把「成功数」当成「失败前缀」:
- * 若第 1 个失败、第 2 个成功(ok=1),`slice(1)` 会**丢掉失败的**、
- * **留下已成功的** —— 用户看到已发出的附件还挂在输入框上,真正失败的却消失了。
- *
- * 同时要 revoke 被移除项的 previewUrl,避免对象地址泄漏。
- */
- this._pendingFiles.update((cur) => {
- const remain = cur.filter((f) => failedIds.has(f.id));
- for (const f of cur) {
- if (!failedIds.has(f.id) && f.previewUrl) URL.revokeObjectURL(f.previewUrl);
- }
- return remain;
- });
- }
- return { ok, fail };
- }
- /** 向上翻历史(AC-04.4);返回是否还有更早的消息 */
- async paginate(): Promise<boolean> {
- if (environment.useMockData || !this.roomId) return false;
- /*
- * 🔴 新建态**禁止翻页**(2026-11-08,「新建会话仍带出历史」的最后一个入口)。
- *
- * `paginate()` 是三条回填路径里唯一没有新建态保护的:
- * · `storeVersion` effect → 已加 `_blankSessionId` 判定
- * · `appendIncoming` → 已加 `_blankSessionId` 判定
- * · `paginate` → 原先没有,且 `paginateBack()` 翻的是**整个房间**的历史
- * (`room.getLiveTimeline()`),不是当前 thread 的 history。
- *
- * 触发链:新建会话首屏只有 0~1 条消息、不满一屏 →
- * 移动端 `fillViewportIfNeeded()` / 桌面端贴底逻辑 → 自动 `paginate()`
- * → 拉回 30 条房间历史 → 按会话过滤时把旧内容认领进来。
- * 页面上还会出现「加载更早的消息」按钮,因为 `hasMoreHistory` 取自房间级 token。
- *
- * 新建会话本来就没有「更早的消息」,直接返回 false。
- */
- const sidNow = this._sessionId();
- if (!sidNow || this._blankSessionId === sidNow) return false;
- // 防重入:滚动事件高频触发,加载中直接忽略,避免重复分页
- if (this._paginating()) return true;
- this._paginating.set(true);
- const my = this.seq;
- try {
- const before = this._messages().length;
- const ok = await this.mx.paginateBack(this.roomId, 30);
- if (my !== this.seq) return false;
- const myUserId = this.provision?.userMxId ?? '';
- const sid = this._sessionId() ?? '';
- const next = this.mapTimeline(
- this.mx.timelineToMessages(this.roomId).filter((m) => this.belongsToSession(m, sid)),
- myUserId,
- );
- /*
- * 分页后**保留完整已加载区间**(不做上限截断):
- * 用户是在往上翻,超出窗口的内容用户正要看,截断会让人往前翻却看不到。
- * 真正省开销的是首屏只取一屏 + 后续按需加载,而不是丢弃已加载内容。
- *
- * 🔴 但与本地乐观消息**合并**,不整表覆盖(2026-11-09):
- * 翻页时还在发送中的消息不在 timeline 里,覆盖掉就凭空消失了。
- */
- this._messages.set(this.mergeTimeline(next, untracked(() => this._messages())));
- // 条数未增长 = 已到最早,停止继续请求
- this._hasMoreHistory.set(ok && next.length > before);
- return this._hasMoreHistory();
- } finally {
- if (my === this.seq) this._paginating.set(false);
- }
- }
- /** 重试失败消息(AC-04.8) */
- async retry(messageId: string): Promise<void> {
- const msg = this._messages().find((m) => m.id === messageId);
- if (!msg || msg.state !== 'failed') return;
- this._messages.update((list) =>
- list.map((m) => (m.id === messageId ? { ...m, state: 'sending', error: undefined } : m)),
- );
- try {
- if (environment.useMockData) {
- await this.simulateSend();
- } else if (msg.text) {
- await this.sendReal(msg.text, msg.localId);
- }
- this.markSent(messageId);
- } catch (e) {
- this.markFailed(messageId, msgOf(e));
- }
- }
- /** 模拟断线(演示控制台用;真实态由 SDK 同步事件驱动) */
- setConnection(state: ConnectionState): void {
- this._connection.set(state);
- }
- // ---------- 内部 ----------
- /**
- * 发真实消息(真实态由 MatrixClientService 落地到房间)。
- *
- * 🔴 **必须带会话线程根**:容器 `build_session_key` 按 `room + thread` 划分会话,
- * 不带 thread 时每条消息都会被 `MATRIX_AUTO_THREAD` 切成新会话 → 容器失忆
- * (2026-10-28 实测,见 `docs/03-interface/容器会话与线程机制-20261028.md`)。
- *
- * ⚠️ 「历史对话」特例已于 2026-10-29 移除 —— 该会话类型不再存在,
- * 所有发送一律带线程根,避免容器侧把消息切成新会话(容器失忆)。
- */
- private async sendReal(text: string, localId?: string): Promise<void> {
- if (!this.roomId) throw new Error('会话未就绪,请稍后重试');
- let sid = this._sessionId();
- if (!sid) {
- /*
- * 「无会话」是合法状态(进入容器不自动建会话)。
- * 用户在这个状态下直接发消息 = 隐式开始一段新对话 → 此刻才创建会话。
- * 正常路径下 `sendText()` 已经建过,这里是附件等旁路入口的兜底。
- */
- const created = this.ensureSessionForSend();
- if (!created) throw new Error('会话未就绪:缺少会话标识,请重新进入');
- sid = created.id;
- }
- const lastId = this._messages().at(-1)?.id;
- // 自造的会话 id 还没有任何消息时,不填 m.in_reply_to(那是降级展示用的引用)
- const last = lastId && !lastId.startsWith('local-') ? lastId : undefined;
- /*
- * `localId` 随事件写进 content:服务端 echo 会原样带回(Matrix 允许任意额外字段),
- * 回读时据此认领本地那条乐观消息,避免同一句话出现两遍。
- */
- await this.mx.sendText(
- this.roomId,
- text,
- sid,
- last,
- localId ? { [LOCAL_ID_KEY]: localId } : undefined,
- );
- }
- /**
- * 懒创建:**第一次发消息时**才把会话落到本地 + 云端(2026-11-01 用户裁决)。
- *
- * 之前「点新建对话就建一条空会话」会产生大量 0 消息的空壳,
- * 还得靠 `pruneEmpty` 事后清理;清理逻辑一旦出错就是「每进一次容器多一条」。
- * 改成「没有消息就不存在会话」后,空壳从源头消失。
- *
- * @returns 会话(已存在则返回当前会话)
- */
- private ensureSessionForSend(): ChatSession | null {
- const agentId = this._agentId();
- const roomId = this.roomId;
- if (!agentId || !roomId) return null;
- const exist = this._sessionId();
- if (exist) {
- const found = this.sessions.byId(exist);
- /*
- * 🔴 有 id 但目录里查不到 = 这条会话已被 `pruneEmpty` 删掉(2026-11-08)。
- *
- * 原实现直接 `return null`,调用方却继续往下走:消息进 `_messages`、
- * 不进任何会话目录;用户再发一条又建一条新的 → 左栏出现**两条外观完全相同**
- * 的会话(用户实测图35)。
- *
- * 现在:不再悄悄返回 null。把指针清掉并**就地重建一条**,
- * 让这次发送仍然落在一个真实存在的会话上。
- */
- if (!found) {
- const recreated = this.sessions.create(roomId, agentId);
- this._sessionId.set(recreated.id);
- /*
- * 🔴 换了会话标识,内存里属于旧会话的消息必须清掉(2026-11-09)。
- * 否则旧会话的内容挂在新的会话 id 下,观感就是「串线」。
- * 同时重置新建态保护,让这次发送按正常的新会话走。
- */
- this._messages.set([]);
- this._ownedThreadRoots.clear();
- this._blankSessionId = recreated.id;
- if (this._blankWorkspace) {
- this.sessions.moveTo(recreated.id, this._blankWorkspace);
- this._blankWorkspace = null;
- }
- return recreated;
- }
- return found;
- }
- const created = this.sessions.create(roomId, agentId);
- /*
- * 「新建对话」时指定的工作区在**这一刻**落地。
- * 之前没有会话可挂,所以只能在建出来之后补归属。
- */
- if (this._blankWorkspace) {
- this.sessions.moveTo(created.id, this._blankWorkspace);
- this._blankWorkspace = null;
- }
- return created;
- }
- /**
- * 消息是否属于指定会话(线程)。
- *
- * 规则:
- * 1. 消息有 threadId → 只有 `threadId === 会话 id` 才属于
- * 2. 消息无 threadId(**历史遗留**)→ 不归入任何会话(承接机制已下线)
- *
- * ## 为什么要单独处理无 thread 的消息
- *
- * 多会话上线前,所有消息都不带 thread。它们是用户真实的对话历史,
- * **不能凭空消失**。处理方式:
- * - 首次打开某 agent 时,若房间里存在无 thread 的消息,
- * 自动建一个「历史会话」承接它们(id 由本机固定生成,见 `legacySessionId`)
- * - 该会话只显示无 thread 消息,且**发送时也不带 thread**
- * —— 这样容器侧仍是原来的「每条一个会话」行为,不会把旧历史和新会话混起来
- *
- * 其它会话一律**不含**无 thread 消息,避免串线。
- */
- /**
- * 这条房间级消息是否落在「该房间最近一次群聊派活」之后。
- *
- * 容器的回复不带派活标记(容器不知道自己在群里干活),但它在时间线上
- * 必然排在派活消息**之后**。用 eventId 的位置判定,不依赖时钟。
- *
- * 锚点来源:内存 `_dispatchAnchor`(本设备刚发过派活,最准)。
- * 找不到锚点 → false(宁可放行,也不误挡单聊历史)。
- */
- private isAfterLastDispatch(m: MxMessage): boolean {
- if (m.threadId) return false; // 有 thread 的走原有逻辑
- const roomId = m.roomId;
- if (!roomId || !m.eventId) return false;
- const anchor = this._dispatchAnchor().get(roomId);
- if (!anchor) return false;
- /*
- * 锚点事件还在当前 timeline 里 → 按下标判定(最可靠)。
- * 锚点被分页裁掉但消息还在 → 视为更新(消息一定排在锚点之后才可能还在)。
- * 消息自己不在 timeline → false,不误挡。
- */
- const index = this.mx.timelineIndex(roomId);
- const mine = index.get(m.eventId);
- const base = index.get(anchor.eventId);
- if (mine !== undefined && base !== undefined) return mine > base;
- if (mine !== undefined && base === undefined) return true;
- return false;
- }
- /**
- * 记录一次群聊派活的锚点(群聊页在 `sendToAgent` 成功后调用)。
- *
- * 同一房间反复派活时取**最新**的一条 —— 「本轮之后的消息」天然包含上一轮。
- */
- noteDispatchAnchor(roomId: string, eventId: string | undefined): void {
- if (!roomId || !eventId) return;
- const next = new Map(this._dispatchAnchor());
- next.set(roomId, { eventId });
- this._dispatchAnchor.set(next);
- }
- /** 当前派活锚点(供群聊页在冷启动时补种) */
- readonly dispatchAnchors = this._dispatchAnchor.asReadonly();
- private belongsToSession(
- m: MxMessage,
- sessionId: string,
- live = false,
- ownedRoots?: ReadonlySet<string>,
- ): boolean {
- if (!m.threadId) {
- /*
- * 房间级消息(无 thread)的归属。
- *
- * ## 历史背景
- *
- * 2026-11-09 用户裁决:「房间级的这个消息,可以丢弃掉,没有问题」。
- * 当时的原因:外部脚本往房间发的调试消息没有 thread,
- * 被新建会话全部认领,表现为「新建会话出现一堆历史」。
- *
- * ## 2026-11-10 修正:容器明确不挂 thread
- *
- * 向 `agent-node-lj-test` 核实,容器原话:
- * 「不会。我只回一条普通消息,不主动挂 m.relates_to 的 thread。」
- *
- * 也就是说**容器在 DM 里的正常回复本来就没有 thread**。
- * 一刀切丢弃的结果是:容器回复可能整段消失(用户实测「完全没反应」)。
- *
- * ## 现在的规则:**有条件的认领**
- *
- * 同时满足以下全部条件才认领(缺一不可,确保不误收别处消息):
- * ① `live === true` —— 实时到达,不是历史回填
- * (历史回填时无法判断归属,仍按老规则丢弃)
- * ② 本会话**正有待回复的消息** —— 已发出 agent 尚未回复
- * ③ 消息时间**晚于**那条待回复的用户消息
- *
- * 这样:
- * · 用户发消息 → 容器回的消息一定会被认领(这正是「没反应」的修复)
- * · 会话闲置时,别人往房间发的消息不会被错收
- * · 刷新/回填时仍严格,避免「新建会话冒出一堆历史」
- */
- if (m.dispatch) return false;
- if (m.sessionId) return m.sessionId === sessionId;
- if (!live) return false;
- if (m.sender === (this.provision?.userMxId ?? '')) return false;
- return this.isAwaitingReplyAfter(sessionId, m.ts || 0);
- }
- if (m.threadId === sessionId) return true;
- /*
- * 无 thread 的消息(房间级消息)已在函数开头处理完毕 ——
- * 到这里 `m.threadId` 必有值。
- *
- * 🔴 **容器自己开的子线程,也算当前会话的内容**。
- *
- * 实测:容器在回复时会另开 `m.thread` 分支跑内部任务
- * (调工具、跑浏览器、自我改进)。那些消息也是这次对话的一部分 ——
- * 用户的交付物(dots.html、截图)就在里面。
- *
- * 所以规则是:
- * · 线程根 = 当前会话 → 属于
- * · 线程根是 App 造的(另一个会话) → 不属于(严格隔离)
- * · 线程根是容器造的(非 approot) → **属于当前会话**,按时间混排在消息流里
- *
- * 这样既不会在左栏冒出重复会话,内容也不会丢。
- *
- * 🔴 但要**限定在当前会话**(2026-11-08 修复)。
- *
- * 原实现直接 `return !isAppSessionId(m.threadId)` —— 意味着容器开过的
- * **每一个**线程根都属于**每一个**会话。后果:新建一个空会话后,
- * 该房间里所有旧线程的消息都会被它认领,上方凭空出现几天前的对话
- * (用户实测图31)。
- *
- * 现在:**历史回填**时容器线程根只归「已经认领过它」的会话 ——
- * 新会话从没认领过任何根,自然什么都拿不到,符合「新建即空白」的预期。
- * **实时事件**(`live=true`)仍然接纳:那是容器刚刚发出的内容,
- * 一定是当前这次对话的产出(截图、交付物都在里面)。
- */
- if (live) return !isAppSessionId(m.threadId);
- const roots = ownedRoots ?? this._ownedThreadRoots;
- return roots.has(m.threadId);
- }
- /**
- * 本会话是否「已发出消息、容器还没回」(2026-11-10,B)。
- *
- * 用于判断一条**无 thread** 的容器消息该不该认领:
- * 只有在「确实在等回复」时才收,避免会话闲置时把房间里别处的消息错收进来。
- *
- * @param sessionId 当前会话
- * @param at 候选消息的时间戳
- * @returns true = 存在一条**早于 `at`** 的用户消息,且其后还没有容器输出
- */
- private isAwaitingReplyAfter(sessionId: string, at: number): boolean {
- if (!sessionId || !at) return false;
- const list = this._messages();
- // 最后一条来自用户的消息
- let lastUserAt = 0;
- for (let i = list.length - 1; i >= 0; i--) {
- const msg = list[i];
- if (msg.sender !== 'me') continue;
- lastUserAt = Date.parse(msg.ts) || 0;
- break;
- }
- if (!lastUserAt || at <= lastUserAt) return false;
- // 那条用户消息之后,已经有过 agent 输出吗?有则本轮已回复,不再收无 thread 消息
- const replied = list.some((msg) => {
- if (msg.sender !== 'agent') return false;
- const ts = Date.parse(msg.ts) || 0;
- return ts > lastUserAt;
- });
- return !replied;
- }
- /**
- * 算出某会话「拥有」的容器线程根(2026-11-08)。
- *
- * 判据:该线程根下,存在**属于本会话的用户消息**。
- * 用户在这个会话里发过东西(`threadId === sid` 或 `sessionId === sid`),
- * 容器才会为它开子线程 —— 那些子线程天然属于本会话。
- *
- * 新建的空会话没有任何用户消息 → 返回空集 → 旧线程一个都不认领。
- */
- private rootsOfSession(
- raw: MxMessage[],
- sessionId: string,
- myUserId: string,
- ): Set<string> {
- const roots = new Set<string>();
- // 本会话的用户消息挂在哪些容器线程根下
- const myRoots = new Set<string>();
- for (const m of raw) {
- if (m.sender !== myUserId) continue;
- if (m.threadId === sessionId) {
- myRoots.add(m.threadId);
- roots.add(sessionId);
- }
- if (m.sessionId === sessionId) {
- myRoots.add(m.eventId);
- if (m.threadId) roots.add(m.threadId);
- }
- }
- // 容器开在「本会话用户消息所属根」下的子线程,也算本会话
- for (const m of raw) {
- const root = m.threadId;
- if (!root || isAppSessionId(root)) continue;
- if (myRoots.has(root) || m.sessionId === sessionId) roots.add(root);
- }
- return roots;
- }
- /**
- * 本会话已经认领的「容器线程根」(2026-11-08)。
- *
- * 由 `mapTimeline` 的归属计算过程写入:当前会话自己产生过的容器线程根。
- * 切换会话时清空。没有它就无法区分「本会话的容器子线程」和「别的会话的」。
- */
- private readonly _ownedThreadRoots = new Set<string>();
- /**
- * 清理「历史对话」遗留会话(2026-10-29 用户裁决下线)。
- *
- * 背景:多会话上线前的消息不带 thread 根,曾经专门建一个 legacy 会话承接它们。
- * 但那个条目**既不能改名也不能归档**,混在正常对话列表里非常突兀。
- *
- * 现在的处理:
- * · 不再创建新的 legacy 会话(`adoptLegacyIfNeeded` 已删除)
- * · 用户本地已存在的 legacy 索引条目,在打开会话时清掉
- *
- * ⚠️ 只清**本地索引**,Matrix 房间里的消息不受影响 —— 删除由服务端策略决定,
- * 客户端无权也不应该擅自删他人数据。
- */
- private dropLegacySessions(agentId: string): void {
- const stale = this.sessions.listFor(agentId, { includeArchived: true }).filter((s) => s.legacy);
- for (const s of stale) this.sessions.remove(s.id);
- }
- /**
- * 把「服务端 timeline」和「本地内存里的消息」合并成一份列表。
- *
- * 🔴 为什么需要它(2026-11-09,用户实测「发一条你好,出现三条」):
- * 乐观消息的 id 是 `local-*`,它自己的服务端 echo 是 `$eventId`,
- * **两者 id 天然不同**。原实现按 id 做并集 —— 于是同一条消息
- * 以「本地一条 + 回读一条」并存;再加上实时追加,最多时出现 3 条。
- *
- * 规则:
- * ① echo 带 `localId` 且能对上本地某条 → **认领**它,只保留服务端那条
- * ② 没被认领的本地消息,**只保留仍在发送中 / 已失败**的
- * (失败消息不能丢,可重试 AC-04.8;已 `sent` 却没有 echo 的,
- * 说明它属于服务端时间线之外 —— 留着只会重复)
- * ③ 只保留**属于当前 agent** 的本地消息
- * (🔴 2026-11-09 评审 B3:原实现不分会话,切会话后
- * `storeVersion` 每次 sync 都会把旧会话的乐观消息重新并回来)
- * ④ 按时间稳定排序,echo 落在它本来的位置,不跳到末尾
- */
- private mergeTimeline(next: Message[], local: Message[]): Message[] {
- // 服务端 echo 认领了哪些本地 id
- const claimed = new Set<string>();
- for (const m of next) {
- if (m.localId) claimed.add(m.localId);
- }
- const agentId = this._agentId() ?? '';
- /*
- * 🔴 echo 认领本地消息时**沿用本地的 ts**(2026-11-09 修复秒数跳变)。
- *
- * 本地乐观消息的 ts 是浏览器时钟,服务端 echo 的 ts 是服务端时钟,
- * 两者有几秒偏差。直接换成服务端的,会让「等待了多久」这个锚点跳变
- * (用户实测 95s → 100s)。
- * 这一条是我方发出的消息,用我方记下的发送时刻更准。
- */
- const localTs = new Map<string, string>();
- for (const m of local) {
- if (m.localId) localTs.set(m.localId, m.ts);
- }
- const out: Message[] = next.map((m) =>
- m.localId && localTs.has(m.localId) ? { ...m, ts: localTs.get(m.localId)! } : m,
- );
- for (const m of local) {
- if (claimed.has(m.id) || (m.localId && claimed.has(m.localId))) continue;
- if (m.state === 'sent') continue;
- if (agentId && m.conversationId !== agentId) continue;
- out.push(m);
- }
- /*
- * 稳定排序:时间相同(同一毫秒)时保持原顺序。
- * Array.prototype.sort 在现代 V8 里是稳定的,不必额外带序号。
- */
- return out.sort((a, b) => (Date.parse(a.ts) || 0) - (Date.parse(b.ts) || 0));
- }
- /** 归一 timeline → 领域消息 */
- private mapTimeline(list: MxMessage[], myUserId: string): Message[] {
- const cid = this._agentId() ?? '';
- return list.map((m) => {
- /*
- * 两种附件形态都要认:
- * ① 我们早期自造的 `m.text + JSON body`
- * ② **Matrix 原生 `m.file` / `m.image`**(容器交付成果用的就是这个)
- *
- * 🔴 早期只认 ① —— 容器发来的 dots.html、截图全被当成普通文本,
- * 界面上既看不到文件、右侧「成果」栏也永远是 0(实测)。
- * `MxMessage.media` 已经把原生附件解析好并转成了可访问 URL。
- */
- const card = MatrixClientService.parseFileCard(m.body);
- const file = card ?? (m.media ? m.media : undefined);
- /*
- * 🔴 正文里的云盘链接 → 生成文件卡片(2026-11-09)。
- *
- * 容器交付成果时不发附件,而是把 `https://s3.fmode.cn/...` 写在回复正文里。
- * 用户要求:「应该是显示卡片,而不是链接」。
- *
- * ⚠️ 2026-11-09 第二次修正 —— 两次都出过问题,记下来避免再犯:
- * ① 初版「剥出链接 + 从正文删掉」→ 正文只剩链接时变成**空消息**,
- * 用户什么都看不到;容器一行一个链接时还会生成**重复卡片**。
- * ② 现在:**正文完整保留**(一个字不删),只为第一个链接生成卡片。
- * 链接在正文里继续显示为可点击文字 —— 这是**有意为之**:
- * 卡片给「文件」区块用,正文里的链接给用户直接点。
- *
- * 重复的两个卡片来自**容器一条回复被拆成多条事件**,
- * 属于容器输出格式问题,已通过「链接独占一行、一次只给一个主文件」规范约束。
- */
- /*
- * 文件来源(2026-11-10 取证后重排优先级):
- *
- * ① Matrix 原生附件(`m.file` / `m.media`) —— 最可靠
- * ② **容器交付段落** `**已上云(可直接下载)**` + `- url` 列表
- * ③ 正文里的云盘链接(正则提取) —— 老格式的兜底
- *
- * 🔴 原先的 ② 是 `<!--fmode:files [...]-->` 结构化声明段。
- * 2026-11-10 向容器取证确认:**容器从未实现该协议**,输出侧不写任何
- * 结构化注释,实际写的是 ② 这种「小标题 + 链接列表」。那条分支
- * 永远命中不了,已删除(容器原话见 docs/03-interface/容器取证-*.md Q12)。
- *
- * 交付段落存在时优先用它,因为那里的链接是容器**明确声明**的成果;
- * 正文其它位置的链接可能是举例或引用,不该当文件。
- */
- let linkCard: FileCard | undefined;
- /**
- * 正文文本(2026-11-10 重构)。
- *
- * 三种情况:
- * ① 带 Matrix 附件 → `body` 只是文件名,不当正文
- * ② 有交付段落 → 完整保留(用户要能直接点链接)
- * ③ 只有裸链接 → 剥掉链接交给卡片,避免卡片与正文重复
- */
- let bodyText = m.body;
- if (!file) {
- const delivered = extractDeliveredLinks(m.body);
- if (delivered.length > 0) {
- linkCard = this.cardOfCloudLink(delivered[0]);
- // 交付段落本身保留(用户可能想直接点),只去掉重复的单条链接由卡片承载
- bodyText = m.body;
- } else {
- // 没有交付段落:回退到「从正文正则提取」(老格式)
- const cloudLinks = extractS3Links(m.body);
- linkCard = cloudLinks.length > 0 ? this.cardOfCloudLink(cloudLinks[0]) : undefined;
- bodyText = linkCard ? stripLinks(m.body, cloudLinks) : m.body;
- }
- } else if (isAttachmentEcho(m.body, file.name)) {
- /* Matrix 原生附件的 body 是文件名(协议要求),不是正文 —— 别当纯文字再显示一遍 */
- bodyText = '';
- }
- return {
- id: m.eventId || `${m.ts}-${m.sender}`,
- conversationId: cid,
- sender: m.sender === myUserId ? ('me' as const) : ('agent' as const),
- kind: m.kind as MessageKind,
- /*
- * 🔴 有附件时**也要保留正文**(2026-11-09 用户反馈「正文没文字了」)。
- *
- * 原实现 `text: file ? undefined : m.body` —— 只要消息带附件就把正文丢掉。
- * 但容器常常是「正文说明 + 文件链接」写在同一条消息里,
- * 丢掉正文后用户只看到一张卡片,不知道这个文件是干什么的。
- */
- text: bodyText || undefined,
- file: file
- ? {
- kind: 'file' as const,
- name: file.name,
- size: file.size,
- mimeType: file.mimeType,
- url: file.url,
- /*
- * 我自己刚发的附件:回读只有 `mxc://`,用发送时记下的云盘地址补上,
- * 右栏「文件」才能长期访问(2026-11-10)。
- */
- cloudUrl: this._cloudUrls.get(this.cloudKeyOf(file.name, file.size)),
- }
- : linkCard,
- ts: new Date(m.ts).toISOString(),
- state: 'sent' as const,
- // 透传本地 id:合并时用它认领乐观消息(见 mergeTimeline)
- localId: m.localId,
- // 透传「在回复哪一条」:回合聚合据此把回复归到正确的提问(2026-11-10)
- replyTo: m.replyTo,
- /*
- * 容器声明的类别(2026-11-11,`content["fmode.kind"]`)。
- * 有这个字段就**不用再猜**某条消息是过程还是正文;没有则回退启发式。
- */
- fmodeKind: m.fmodeKind,
- };
- });
- }
- /**
- * 把一条云盘链接包成附件卡片。
- *
- * 云盘对象是 `public-read`,链接本身就能下载/预览,
- * 所以 `url` 直接用它,`cloudUrl` 也指向它(后续「优先走云盘」逻辑由此生效)。
- * 文件名与类型从 URL 推断。
- */
- private cardOfCloudLink(url: string): FileCard | undefined {
- /*
- * 🔴 先判断「这是不是文件」(2026-11-09)。
- *
- * 原实现只要正文里有云盘域名的链接就造卡 —— 但不全是文件:
- * · 目录:`https://s3.fmode.cn/bucket/`
- * · 无扩展名的路径:`https://s3.fmode.cn/user/xxx/report`
- * · 文档里引用的示例链接
- * 这些被当成文件卡片显示,用户看到的是一堆莫名其妙的「文件」。
- */
- const path = url.split('?')[0].split('#')[0];
- // 以 / 结尾 = 目录
- if (path.endsWith('/')) return undefined;
- const seg = decodeURIComponent(path.split('/').filter(Boolean).pop() ?? '');
- /*
- * 🔴 排除「省略号占位」(2026-11-09)。
- * 容器/用户在举例时常写 `https://s3.fmode.cn/...`,
- * 末段是 `...`,`lastIndexOf('.')` 会误判成「有扩展名」。
- */
- if (/^\.+$/.test(seg) || seg.includes('...') || seg.includes('…')) return undefined;
- const dot = seg.lastIndexOf('.');
- // 没有扩展名 = 不是文件
- if (dot <= 0 || dot === seg.length - 1) return undefined;
- const ext = seg.slice(dot + 1).toLowerCase();
- /* 扩展名必须是纯字母且 ≥2 位(`a.1`、`x.` 这类不是文件) */
- if (!/^[a-z]{2,5}$/.test(ext)) return undefined;
- /*
- * 扩展名白名单:只认「确实是文件」的类型。
- * 不在名单里的一律不造卡(链接留在正文里当文字)。
- */
- const mimeByExt: Record<string, string> = {
- md: 'text/markdown',
- markdown: 'text/markdown',
- html: 'text/html',
- htm: 'text/html',
- pdf: 'application/pdf',
- txt: 'text/plain',
- log: 'text/plain',
- csv: 'text/csv',
- tsv: 'text/tab-separated-values',
- json: 'application/json',
- xml: 'application/xml',
- yml: 'text/yaml',
- yaml: 'text/yaml',
- png: 'image/png',
- jpg: 'image/jpeg',
- jpeg: 'image/jpeg',
- gif: 'image/gif',
- webp: 'image/webp',
- svg: 'image/svg+xml',
- xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
- xls: 'application/vnd.ms-excel',
- docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
- doc: 'application/msword',
- pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
- zip: 'application/zip',
- tar: 'application/x-tar',
- gz: 'application/gzip',
- mp4: 'video/mp4',
- mp3: 'audio/mpeg',
- };
- const mime = mimeByExt[ext];
- if (!mime) return undefined;
- return {
- kind: 'file',
- /*
- * 显示名去掉存储键的前缀(2026-11-11)。
- *
- * 云盘键形如 `0-mwbkfsjsbv-team-weekly-template.md`,前面的
- * 「序号-随机串-」是服务端去重用的,用户不该看到 ——
- * 之前直接拿 URL 末段当名字,卡片显示成 `0-mwbkfsjsbv-team-...`。
- * 与成果页同一套处理(`prettifyStorageKey`)。
- */
- name: prettifyStorageKey(seg) || seg,
- /*
- * 云盘链接不带文件大小。用 -1 表示「未知」,界面据此不显示「0 B」
- * (0 B 会让人以为文件是空的,实测用户困惑)。
- */
- size: -1,
- mimeType: mime,
- url,
- cloudUrl: url,
- };
- }
- /**
- * 当前会话**最后一条 agent 消息**的 id(2026-11-11)。
- *
- * 供结束信号(`m.typing=false`)做「这一段到哪为止」的认领用。
- * 没有 agent 消息时返回 null —— 此时信号没有可绑定的段落,不参与结束判定。
- */
- private lastAgentMessageId(): string | null {
- const list = this._messages();
- for (let i = list.length - 1; i >= 0; i--) {
- if (list[i].sender === 'agent') return list[i].id;
- }
- return null;
- }
- /** 追加一条新到消息(按 eventId 去重;不属于当前会话的忽略) */
- private appendIncoming(m: MxMessage, myUserId: string): void {
- if (!this._agentId()) return;
- const sid = this._sessionId();
- // 🔴 多会话隔离:别的会话的消息不进入当前会话的列表
- if (!sid || !this.belongsToSession(m, sid, true)) return;
- /*
- * 🔴 新建态:只放行**本会话自己的消息**(2026-11-08)。
- *
- * `startBlank()` 之后新会话还没有任何 thread 根,
- * `belongsToSession(live=true)` 对旧事件一律放行,会把别处的内容塞进来。
- * 但用户刚发的那条(`threadId === sid`)必须进来 —— 它是本会话成立的凭据,
- * 也是 storeVersion effect 判定「已落地、可解除保护」的依据。
- */
- if (this._blankSessionId && sid === this._blankSessionId && m.threadId !== sid) return;
- /*
- * 没有 eventId 的事件(极少见)用「时间 + 发送者」当退路 id;
- * 退路 id 可能和时间相同的消息撞上,故只用来去重,不参与后续判定。
- */
- const eid = m.eventId || `${m.ts}-${m.sender}`;
- if (this._messages().some((x) => x.id === eid)) return;
- const [mapped] = this.mapTimeline([m], myUserId);
- /*
- * 🔴 自己的 echo 到达时**替换**乐观消息,而不是追加(2026-11-09)。
- * 否则「我发的那条」会以本地 / 实时两条并存。
- */
- const localId = mapped?.localId;
- this._messages.update((list) => {
- if (!localId) return [...list, mapped];
- /*
- * 🔴 把被替换掉的本地消息一并交给 `mergeTimeline`(2026-11-09 修 C7)。
- *
- * 原写法是 `mergeTimeline([...rest, mapped], [])` —— 第二个参数传空数组,
- * `localTs` 映射表因此为空,echo 认领会**静默失效**,ts 直接换成服务端时钟,
- * 等待读秒从浏览器时钟跳到服务端时钟(用户实测 95s → 100s)。
- * 这里把原本地消息传进去,让 localTs 回落真正生效。
- */
- const mine = list.filter((x) => x.id === localId);
- const rest = list.filter((x) => x.id !== localId);
- return this.mergeTimeline([...rest, mapped], mine);
- });
- this.rememberDemoHistory();
- if (this._state() === 'empty') this._state.set('ready');
- // 会话元信息(预览/标题/时间)随消息推进
- this.sessions.noteMessage(sid, {
- text: mapped.text,
- ts: Date.parse(mapped.ts) || Date.now(),
- fromMe: mapped.sender === 'me',
- });
- /*
- * 容器发来的附件如果是云盘直链,记进本会话的成果列表(2026-11-09)。
- * 用户要求「成果按对话归档」:只有出现在这条对话里的文件才算它的成果。
- *
- * 🔴 必须判 `sender === 'agent'`(2026-11-10 修复)。
- * 原实现没判,用户自己发的附件回读(`sender='me'`)也会被记成成果,
- * 右栏「成果」于是出现了用户图片。下方声明段/正文链接的处理本来就按
- * agent 过滤,这里与它口径对齐。
- */
- if (mapped.sender === 'agent') {
- const fileUrl =
- mapped.file?.cloudUrl ||
- (mapped.file?.url?.startsWith('https://s3.') ? mapped.file.url : undefined);
- if (fileUrl && !isUserUploadUrl(fileUrl)) {
- this.sessions.addDeliverableUrl(sid, fileUrl);
- }
- }
- /*
- * 成果链接记入会话(2026-11-10 取证后重排):
- *
- * ① 容器交付段落 `**已上云(可直接下载)**` + `- url` —— 容器**实际会写**的格式
- * ② 正文里的云盘链接 —— 老格式兜底,且必须通过文件判定
- *
- * 🔴 原先的 ① 是 `<!--fmode:files-->` 声明段,取证确认容器从未实现,已删除。
- *
- * ⚠️ ② 必须同时满足:sender === 'agent' + 不是省略号占位
- * + 有合法扩展名。少一条就会把用户提问里的示例链接收成成果(已实测踩坑)。
- */
- if (mapped.sender === 'agent') {
- const delivered = extractDeliveredLinks(m.body);
- if (delivered.length > 0) {
- this.sessions.addDeliverableUrl(sid, ...delivered);
- } else {
- for (const u of extractS3Links(mapped.text)) {
- if (!isUserUploadUrl(u) && this.cardOfCloudLink(u)) {
- this.sessions.addDeliverableUrl(sid, u);
- }
- }
- }
- }
- /*
- * 实时到达 + 对方发的 = 「正在回复」。
- * 「用户发的」不算 —— 我方刚发出消息不能说明对方在回。
- */
- const agentId = this._agentId();
- if (agentId) {
- this.pulse.noteMessage(agentId, mapped.id, {
- live: true,
- fromAgent: mapped.sender === 'agent',
- ts: Date.parse(mapped.ts) || Date.now(),
- });
- }
- }
- private teardownSubscriptions(): void {
- this.unbindMessage?.();
- this.unbindTyping?.();
- this.unbindTurnReaction?.();
- this.unbindMessage = null;
- this.unbindTyping = null;
- this.unbindTurnReaction = null;
- }
- private markSent(id: string): void {
- this._messages.update((list) =>
- list.map((m) => (m.id === id ? { ...m, state: 'sent', error: undefined } : m)),
- );
- this.rememberDemoHistory();
- }
- private markFailed(id: string, error: string): void {
- this._messages.update((list) =>
- list.map((m) => (m.id === id ? { ...m, state: 'failed', error } : m)),
- );
- this.rememberDemoHistory();
- }
- private async simulateSend(): Promise<void> {
- // 仅演示态使用
- if (this._connection() !== 'connected') {
- throw new Error('连接已断开,请稍后重试');
- }
- await new Promise((r) => setTimeout(r, 200));
- }
- /** 演示态初始消息;真实态由 Matrix 历史拉取填充 */
- private seedMessages(agentId: string): Message[] {
- if (!environment.useMockData) return [];
- const seeds = DEMO_MESSAGES[agentId] ?? [];
- return seeds.map((s, i) => ({
- id: `${agentId}-seed-${i}`,
- conversationId: agentId,
- sender: s.sender,
- kind: 'text' as MessageKind,
- text: s.text,
- artifactId: s.artifactId,
- taskId: s.taskId,
- ts: this.seedTs(s.time),
- state: 'sent' as const,
- }));
- }
- /**
- * 演示态:某条会话的存量消息(2026-11-06)。
- *
- * 演示态没有 Matrix 历史可拉,刷新后消息会全部丢掉 —— 用户自己发的
- * 「第一条:先问候」刷新回来就没了,页面看起来像坏了。这里在内存里
- * 按会话留一份,`open()` 命中同一会话时恢复。
- *
- * 🔴 仅演示态使用,真实态历史一律以 Matrix 为准。
- */
- private demoHistory: Map<string, Message[]> | null = null;
- private static readonly DEMO_HISTORY_KEY = 'fmode_demo_history_v1';
- /**
- * 演示态的历史存储。
- *
- * 演示态没有服务端历史可拉,消息全在内存里 —— 刷新一次就没了。
- * 演示态的会话目录本来就落在 localStorage(MockApi 的
- * `mock/chat-threads`),历史同样存本地,两者的生命期才一致。
- * 真实态(`useMockData = false`)恒为 null,任何地方都不会读写。
- */
- private demoStore(): Map<string, Message[]> | null {
- if (!environment.useMockData || typeof localStorage === 'undefined') return null;
- if (this.demoHistory) return this.demoHistory;
- const map = new Map<string, Message[]>();
- try {
- const raw = localStorage.getItem(ChatService.DEMO_HISTORY_KEY);
- const parsed = raw ? (JSON.parse(raw) as unknown) : null;
- if (Array.isArray(parsed)) {
- for (const e of parsed as [string, Message[]][]) {
- if (Array.isArray(e) && typeof e[0] === 'string' && Array.isArray(e[1])) {
- map.set(e[0], e[1]);
- }
- }
- }
- } catch {
- /* 坏 JSON / 隐私模式:降级为内存态 */
- }
- this.demoHistory = map;
- return map;
- }
- private demoRestored(sessionId: string): Message[] {
- return this.demoStore()?.get(sessionId) ?? [];
- }
- /** 演示态消息写入(发送/接收后调用,幂等覆盖) */
- private rememberDemoHistory(): void {
- const store = this.demoStore();
- const sid = this._sessionId();
- if (!store || !sid) return;
- store.set(sid, [...this._messages()]);
- try {
- localStorage.setItem(ChatService.DEMO_HISTORY_KEY, JSON.stringify([...store.entries()]));
- } catch {
- /* 配额/隐私模式:只保留内存态 */
- }
- }
- private seedTs(time: string): string {
- const today = new Date();
- const m = /^(\d{2}):(\d{2})$/.exec(time);
- if (m) {
- today.setHours(Number(m[1]), Number(m[2]), 0, 0);
- return today.toISOString();
- }
- const y = new Date(today);
- y.setDate(today.getDate() - 1);
- const m2 = /昨天\s*(\d{2}):(\d{2})/.exec(time);
- if (m2) y.setHours(Number(m2[1]), Number(m2[2]), 0, 0);
- return y.toISOString();
- }
- }
- /** 统一错误文案提取 */
- function msgOf(e: unknown): string {
- return e instanceof Error && e.message ? e.message : '未知错误';
- }
- /**
- * 判断附件事件的 `body` 只是「文件名的回声」。
- *
- * Matrix 协议规定 `m.file` / `m.image` 的 `body` 字段**就是文件名**,
- * 渲染时若原样当正文输出,界面会出现「文件卡片 + 同名纯文字」两条(实测截图确认)。
- *
- * @param body 事件 body
- * @param fileName 附件名
- * @returns true 表示这段 body 没有信息量,不应作为正文显示
- */
- export function isAttachmentEcho(body: string | undefined, fileName: string): boolean {
- if (!body) return false;
- const b = body.trim();
- if (!b) return false;
- return b === fileName.trim();
- }
|