|
@@ -208,10 +208,24 @@ export class ChatService {
|
|
|
*
|
|
*
|
|
|
* 🔴 不区分的话,一个历史取不到的旧会话会显示成「下午好,我是 xxx」,
|
|
* 🔴 不区分的话,一个历史取不到的旧会话会显示成「下午好,我是 xxx」,
|
|
|
* 用户以为会话还在、内容丢了,实际是被归属规则挡住了(实测)。
|
|
* 用户以为会话还在、内容丢了,实际是被归属规则挡住了(实测)。
|
|
|
|
|
+ *
|
|
|
|
|
+ * `'blank'`(2026-11-12):**已翻到房间最早**、目录里有这条、但 Matrix 里
|
|
|
|
|
+ * 一条消息都没有 —— 这是「空壳会话」,不是「暂时取不到」。
|
|
|
|
|
+ * 说成「暂时」会让用户一直等、以为数据丢了(实测「测试9680」)。
|
|
|
*/
|
|
*/
|
|
|
- private readonly _emptyReason = signal<'fresh' | 'unloaded' | 'removed' | null>(null);
|
|
|
|
|
|
|
+ private readonly _emptyReason = signal<'fresh' | 'unloaded' | 'blank' | 'removed' | null>(null);
|
|
|
readonly emptyReason = this._emptyReason.asReadonly();
|
|
readonly emptyReason = this._emptyReason.asReadonly();
|
|
|
|
|
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 本次打开会话时,是否已经把**整个房间**的历史翻到底(2026-11-12)。
|
|
|
|
|
+ *
|
|
|
|
|
+ * 🔴 不能直接看 `paginationToken(rid) == null` 来判断:
|
|
|
|
|
+ * 回溯有轮数上限(50 轮 × 100 条),大房间(lj-test 已超 5000 事件)
|
|
|
|
|
+ * 翻满上限时 token 仍然存在,但**我们并没有真的翻完**。
|
|
|
|
|
+ * 只有这个字段为 true,才能断定「房间翻遍了、确实没有这条会话的消息」。
|
|
|
|
|
+ */
|
|
|
|
|
+ private _reachedRoomStart = false;
|
|
|
|
|
+
|
|
|
readonly agentId = this._agentId.asReadonly();
|
|
readonly agentId = this._agentId.asReadonly();
|
|
|
/** 当前会话 id(线程根) */
|
|
/** 当前会话 id(线程根) */
|
|
|
readonly sessionId = this._sessionId.asReadonly();
|
|
readonly sessionId = this._sessionId.asReadonly();
|
|
@@ -279,6 +293,9 @@ export class ChatService {
|
|
|
this._sessionId(),
|
|
this._sessionId(),
|
|
|
this.roomId,
|
|
this.roomId,
|
|
|
this.myMxId(),
|
|
this.myMxId(),
|
|
|
|
|
+ Date.now(),
|
|
|
|
|
+ /* 刚发出的会话,上一轮留下的 idle 不算数(2026-11-12,见 sessionRunning 注释) */
|
|
|
|
|
+ this._sessionId() ? this.pulse.sessionLocalStartAt(this._sessionId()!) : undefined,
|
|
|
);
|
|
);
|
|
|
return running === undefined ? undefined : !running;
|
|
return running === undefined ? undefined : !running;
|
|
|
});
|
|
});
|
|
@@ -404,6 +421,8 @@ export class ChatService {
|
|
|
private unbindTyping: (() => void) | null = null;
|
|
private unbindTyping: (() => void) | null = null;
|
|
|
/** 容器 ✅/❌ reaction 订阅解绑(2026-11-11,本轮结束信号) */
|
|
/** 容器 ✅/❌ reaction 订阅解绑(2026-11-11,本轮结束信号) */
|
|
|
private unbindTurnReaction: (() => void) | null = null;
|
|
private unbindTurnReaction: (() => void) | null = null;
|
|
|
|
|
+ /** 容器推送的回合状态事件(2026-11-12,方案 2) */
|
|
|
|
|
+ private unbindTurnStatus: (() => void) | null = null;
|
|
|
/** 全局未读监听解绑(仅登出/销毁时调用) */
|
|
/** 全局未读监听解绑(仅登出/销毁时调用) */
|
|
|
private unbindUnread: (() => void) | null = null;
|
|
private unbindUnread: (() => void) | null = null;
|
|
|
/**
|
|
/**
|
|
@@ -733,6 +752,75 @@ export class ChatService {
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 新建会话并**等专属房间就绪**后再返回(2026-11-12 用户要求)。
|
|
|
|
|
+ *
|
|
|
|
|
+ * ## 为什么不复用 `startBlank`
|
|
|
|
|
+ *
|
|
|
|
|
+ * `startBlank` 里建房是 fire-and-forget:用户「点新建 → 立刻打字发送」时,
|
|
|
|
|
+ * 消息会在房间建好**之前**发出 → 落进共享房间 → 一房间一会话失效。
|
|
|
|
|
+ * 实测后果:5 条测试会话全部还在 `!miyB1Dx…` 共享房间里,
|
|
|
|
|
+ * 继而导致左侧状态互相干扰(转圈 120 秒不停)。
|
|
|
|
|
+ *
|
|
|
|
|
+ * ## 做法
|
|
|
|
|
+ *
|
|
|
|
|
+ * 先建会话(保证即时可用),再**同步等**建房 + 容器进房
|
|
|
|
|
+ * (实测 598~717ms),完成后才返回给调用方去跳转页面。
|
|
|
|
|
+ *
|
|
|
|
|
+ * 建房失败**不阻塞**:降级为共享房间(老行为),用户仍可正常对话。
|
|
|
|
|
+ *
|
|
|
|
|
+ * @returns 会话对象;容器未就绪时返回 null
|
|
|
|
|
+ */
|
|
|
|
|
+ async startBlankWithRoom(
|
|
|
|
|
+ agentId: string,
|
|
|
|
|
+ workspaceId: string | null = null,
|
|
|
|
|
+ ): Promise<ChatSession | null> {
|
|
|
|
|
+ const s = await this.startBlank(agentId, workspaceId);
|
|
|
|
|
+ if (!s) return null;
|
|
|
|
|
+ /* 同步等建房完成(内部已 try/catch,失败静默降级) */
|
|
|
|
|
+ await this.provisionSessionRoom(s.id, agentId);
|
|
|
|
|
+ return s;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 为新建的会话开一个**独立房间**(一房间一会话,2026-11-12)。
|
|
|
|
|
+ *
|
|
|
|
|
+ * 见 `doStartBlank` 中的调用处注释。这里只做「建房 + 等容器进房 + 回写 roomId」,
|
|
|
|
|
+ * **任何一步失败都静默降级**为绑定房间的老行为 —— 新建对话绝不能因此失败。
|
|
|
|
|
+ *
|
|
|
|
|
+ * @param sessionId 新建的会话 id(同时作为该会话的 thread 根)
|
|
|
|
|
+ * @param agentId 所属容器
|
|
|
|
|
+ */
|
|
|
|
|
+ private async provisionSessionRoom(sessionId: string, agentId: string): Promise<void> {
|
|
|
|
|
+ const agent = this.live.agentById(agentId);
|
|
|
|
|
+ const agentMxId = this.provision?.agentMxId ?? MatrixClientService.agentMxId(agent?.id ?? '');
|
|
|
|
|
+ if (!agentMxId) return;
|
|
|
|
|
+ try {
|
|
|
|
|
+ const roomId = await this.mx.createSessionRoom(agentMxId, agent?.name);
|
|
|
|
|
+ /* 等容器进房:不等就发消息,容器可能还没加入、收不到(实测进房约 3 秒) */
|
|
|
|
|
+ const joined = await this.mx.waitAgentJoined(roomId, agentMxId, 30_000);
|
|
|
|
|
+ if (!joined) {
|
|
|
|
|
+ /*
|
|
|
|
|
+ * 容器没进房 → **放弃这个房间**,退回绑定房间。
|
|
|
|
|
+ * 不删房间(Matrix 无删除语义),只是不用它;
|
|
|
|
|
+ * 它没有消息,也不会出现在会话目录里(目录由发消息触发落库)。
|
|
|
|
|
+ */
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ /* 回写 roomId:之后该会话的消息、状态、回溯全部基于这个房间 */
|
|
|
|
|
+ this.sessions.setRoomId(sessionId, roomId);
|
|
|
|
|
+ /*
|
|
|
|
|
+ * 若用户此时正停在这条会话上,把当前房间指针也切过去,
|
|
|
|
|
+ * 让「发送」直接落到新房间(否则会发进老房间,状态又混在一起)。
|
|
|
|
|
+ */
|
|
|
|
|
+ if (this._sessionId() === sessionId) {
|
|
|
|
|
+ this.roomId = roomId;
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch {
|
|
|
|
|
+ /* 离线 / 限流 / 服务端拒绝:保持绑定房间(老行为),不影响用户 */
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
private async doStartBlank(
|
|
private async doStartBlank(
|
|
|
agentId: string,
|
|
agentId: string,
|
|
|
workspaceId: string | null = null,
|
|
workspaceId: string | null = null,
|
|
@@ -780,6 +868,36 @@ export class ChatService {
|
|
|
// 清掉此前留下的其它空会话,保证列表里只有这一条「新建对话」
|
|
// 清掉此前留下的其它空会话,保证列表里只有这一条「新建对话」
|
|
|
this.sessions.pruneEmpty(s.id);
|
|
this.sessions.pruneEmpty(s.id);
|
|
|
|
|
|
|
|
|
|
+ /*
|
|
|
|
|
+ * 🔴 一房间一会话(2026-11-12 架构升级)。
|
|
|
|
|
+ *
|
|
|
|
|
+ * 老架构:所有会话共用一个 DM 房间,容器只能上报 `!roomId`,
|
|
|
|
|
+ * App 无法区分是哪条会话 → 「一条在跑,全房间都转」(用户实测)。
|
|
|
|
|
+ *
|
|
|
|
|
+ * 新架构:**每条新会话开一个独立房间**,房间号即会话标识,容器上报无歧义。
|
|
|
|
|
+ *
|
|
|
|
|
+ * ## 失败降级(重要)
|
|
|
|
|
+ *
|
|
|
|
|
+ * 建房是网络操作,可能失败(离线、homeserver 限流)。
|
|
|
|
|
+ * 失败时**退回绑定房间**(老行为),会话仍然可用,只是状态隔离降级 ——
|
|
|
|
|
+ * 绝不能因为建房失败就让用户「新建对话」失败。
|
|
|
|
|
+ *
|
|
|
|
|
+ * ## 不阻塞首屏
|
|
|
|
|
+ *
|
|
|
|
|
+ * 建房 + 等容器进房最多几秒。**不能 fire-and-forget**(2026-11-12 实测踩坑):
|
|
|
|
|
+ * 原先写 `void this.provisionSessionRoom(...)`,用户紧接着发消息时
|
|
|
|
|
+ * `this.roomId` 还是共享房间 → 首条消息进了老房间,一房间一会话彻底失效。
|
|
|
|
|
+ *
|
|
|
|
|
+ * 🔴 建房**不在这里做**(2026-11-12 修正)。
|
|
|
|
|
+ *
|
|
|
|
|
+ * 原先写 `void this.provisionSessionRoom(...)` 或挂 `_sessionRoomInFlight`,
|
|
|
|
|
+ * 本质都是「先返回、后台建房」—— 用户秒发消息时房间还没好,
|
|
|
|
|
+ * 消息落进共享房间(实测 5 条会话全在 `!miyB1Dx…`)。
|
|
|
|
|
+ *
|
|
|
|
|
+ * 现在由调用方 `startBlankWithRoom()` 在返回前**同步等建房完成**;
|
|
|
|
|
+ * `startBlank()` 仍保留「不建房」的轻量语义(给不需要房间的场景用)。
|
|
|
|
|
+ */
|
|
|
|
|
+
|
|
|
// 切到这条新会话(左侧随之高亮,见 session-tree 的 activeId)
|
|
// 切到这条新会话(左侧随之高亮,见 session-tree 的 activeId)
|
|
|
this._sessionId.set(s.id);
|
|
this._sessionId.set(s.id);
|
|
|
|
|
|
|
@@ -892,7 +1010,7 @@ export class ChatService {
|
|
|
* 由 Hermes 内建行为发出,不依赖插件改造。
|
|
* 由 Hermes 内建行为发出,不依赖插件改造。
|
|
|
*/
|
|
*/
|
|
|
this.unbindTurnReaction =
|
|
this.unbindTurnReaction =
|
|
|
- this.mx.onTurnReaction?.(rid, (_ok, sender) => {
|
|
|
|
|
|
|
+ this.mx.onTurnReaction?.(rid, (_ok, sender, targetId) => {
|
|
|
/* 同 `bindRoomSubscriptions` 的说明:这里不校验全局 `seq` */
|
|
/* 同 `bindRoomSubscriptions` 的说明:这里不校验全局 `seq` */
|
|
|
/*
|
|
/*
|
|
|
* 必须校验 sender:群聊里 @ 了多个容器时,每个容器各打一个 ✅、
|
|
* 必须校验 sender:群聊里 @ 了多个容器时,每个容器各打一个 ✅、
|
|
@@ -901,6 +1019,19 @@ export class ChatService {
|
|
|
*/
|
|
*/
|
|
|
const agentMxId = this._agentMxId();
|
|
const agentMxId = this._agentMxId();
|
|
|
if (agentMxId && sender && sender !== agentMxId) return;
|
|
if (agentMxId && sender && sender !== agentMxId) return;
|
|
|
|
|
+ /*
|
|
|
|
|
+ * 🔴 用 ✅ 标注的那条消息反推「哪条会话结束了」(2026-11-12,用户实测)。
|
|
|
|
|
+ *
|
|
|
|
|
+ * 现象:**没打开**的会话左侧一直转圈,点开才停。
|
|
|
|
|
+ * 根因:`SessionTree.sessionRunningOf()` 只在「当前打开的会话」上才用
|
|
|
|
|
+ * reaction 收尾,其余会话只能等容器 `currentSessions` 上报过期,
|
|
|
|
|
+ * 而本地「刚发出」标记的 TTL 更长。
|
|
|
|
|
+ *
|
|
|
|
|
+ * ✅ 的 `m.relates_to.event_id` 指向容器本轮的最后一条消息,
|
|
|
|
|
+ * 它的 `threadId`(或回落到 roomId)就是结束的那条会话。
|
|
|
|
|
+ * 这里不依赖「当前打开的是哪条」,所以后台会话同样能收尾。
|
|
|
|
|
+ */
|
|
|
|
|
+ this.pulse.clearSessionLocalStartFor(this._sessionsOfMessage(targetId, rid));
|
|
|
/*
|
|
/*
|
|
|
* 稍等一拍再取「末条 agent 消息 id」:reaction 与它标注的消息是
|
|
* 稍等一拍再取「末条 agent 消息 id」:reaction 与它标注的消息是
|
|
|
* 两个独立事件,到达顺序不保证。让出一拍能避免取到上一条。
|
|
* 两个独立事件,到达顺序不保证。让出一拍能避免取到上一条。
|
|
@@ -912,6 +1043,59 @@ export class ChatService {
|
|
|
void this.live.load(true);
|
|
void this.live.load(true);
|
|
|
}, 300);
|
|
}, 300);
|
|
|
}) ?? null;
|
|
}) ?? null;
|
|
|
|
|
+
|
|
|
|
|
+ /*
|
|
|
|
|
+ * 容器主动推送的「本轮结束」事件(2026-11-12,方案 2)。
|
|
|
|
|
+ *
|
|
|
|
|
+ * 容器 turn 结束时立即上报云函数(实测 1 秒内),但 App 拉云函数是 15 秒轮询
|
|
|
|
|
+ * → 左侧最多要转 30 秒。这个订阅让容器同时往房间推一个 Matrix 事件,
|
|
|
|
|
+ * App 收到即刷新,把「停止转圈」的延迟从 30 秒压到 1~2 秒。
|
|
|
|
|
+ *
|
|
|
|
|
+ * 🔴 与 ✅ reaction 无关:✅ = 单条消息发完;本事件 = 整轮结束。
|
|
|
|
|
+ *
|
|
|
|
|
+ * 🔴 **全局订阅**(2026-11-12 修正):一房间一会话后,左侧每条会话都在自己的
|
|
|
|
|
+ * 房间里。只监听当前房间会漏掉后台会话的结束事件(用户实测「回复完了还转很久」)。
|
|
|
|
|
+ */
|
|
|
|
|
+ this.unbindTurnStatus =
|
|
|
|
|
+ this.mx.onTurnStatus?.((sessionKey, status, sender, evRoomId) => {
|
|
|
|
|
+ /* 只认本容器发的(群聊里别的容器也会推) */
|
|
|
|
|
+ const agentMxId = this._agentMxId();
|
|
|
|
|
+ if (agentMxId && sender && sender !== agentMxId) return;
|
|
|
|
|
+ /*
|
|
|
|
|
+ * 用 sessionKey 或事件所在房间定位会话 —— 容器两种形态都可能上报。
|
|
|
|
|
+ */
|
|
|
|
|
+ const sid = this._sessionIdOfKey(sessionKey) || this._sessionIdOfKey(evRoomId);
|
|
|
|
|
+ if (status !== 'idle') {
|
|
|
|
|
+ /* running:立刻刷新,让转圈尽快出现 */
|
|
|
|
|
+ void this.live.load(true);
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ /*
|
|
|
|
|
+ * 🔴 idle:三重保险立刻停止左侧转圈(不等 15 秒轮询)
|
|
|
|
|
+ * ① 清掉该会话的本地「刚发出」标记
|
|
|
|
|
+ * ② 在本地打一个「已结束」时间戳,`sessionRunningOf()` 据此直接判不转
|
|
|
|
|
+ * ③ 刷新容器状态(拉一次云函数,让其它界面也同步)
|
|
|
|
|
+ */
|
|
|
|
|
+ if (sid) {
|
|
|
|
|
+ this.pulse.clearSessionLocalStartFor([sid]);
|
|
|
|
|
+ this.pulse.markSessionEnded(sid);
|
|
|
|
|
+ }
|
|
|
|
|
+ void this.live.load(true);
|
|
|
|
|
+ }) ?? null;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 把容器上报的 sessionKey(`$approot_xxx` 或 `!roomId`)换算成本地会话 id。
|
|
|
|
|
+ *
|
|
|
|
|
+ * 一房间一会话下,房间号即会话 id,两种形态都能定位到会话。
|
|
|
|
|
+ */
|
|
|
|
|
+ private _sessionIdOfKey(sessionKey: string): string {
|
|
|
|
|
+ if (!sessionKey) return '';
|
|
|
|
|
+ const direct = this.sessions.byId(sessionKey);
|
|
|
|
|
+ if (direct) return direct.id;
|
|
|
|
|
+ /* roomId 形态:找 roomId 等于它的那条会话 */
|
|
|
|
|
+ const byRoom = this.sessions.sessionsInRoom(sessionKey)[0];
|
|
|
|
|
+ return byRoom?.id ?? '';
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
/**
|
|
@@ -942,6 +1126,11 @@ export class ChatService {
|
|
|
this.provision = prov;
|
|
this.provision = prov;
|
|
|
|
|
|
|
|
// ② 会话级连接(幂等;同身份重复进入不打断 sync —— 未读计数依赖它常驻)
|
|
// ② 会话级连接(幂等;同身份重复进入不打断 sync —— 未读计数依赖它常驻)
|
|
|
|
|
+ /*
|
|
|
|
|
+ * 告诉 MatrixClientService「当前连的是哪个容器」——
|
|
|
|
|
+ * token 失效时它要用这个 agentId 调 `validateMatrix()` 重签凭据(2026-11-12)。
|
|
|
|
|
+ */
|
|
|
|
|
+ this.mx.setRecoveryAgentId(agentId);
|
|
|
try {
|
|
try {
|
|
|
await this.mx.connectSession(prov.accessToken, prov.userMxId ?? '', prov.deviceId);
|
|
await this.mx.connectSession(prov.accessToken, prov.userMxId ?? '', prov.deviceId);
|
|
|
} catch (e) {
|
|
} catch (e) {
|
|
@@ -955,9 +1144,20 @@ export class ChatService {
|
|
|
if (!agentMxId) throw new Error('房间创建失败:服务端未返回 Agent 身份');
|
|
if (!agentMxId) throw new Error('房间创建失败:服务端未返回 Agent 身份');
|
|
|
|
|
|
|
|
let rid: string;
|
|
let rid: string;
|
|
|
|
|
+ /*
|
|
|
|
|
+ * 🔴 会话已有**专属房间**时直接用它(一房间一会话,2026-11-12)。
|
|
|
|
|
+ *
|
|
|
|
|
+ * 新建对话会为它开独立房间并回写到 `s.roomId`;再打开这条会话时必须进入
|
|
|
|
|
+ * **那个**房间,否则消息会发进绑定房间、状态又混到一起。
|
|
|
|
|
+ *
|
|
|
|
|
+ * 判据:显式指定了 sessionId,且本地目录里这条会话的 roomId 与绑定房间不同。
|
|
|
|
|
+ * 老会话(仍在绑定房间)不受影响,自动走原路径。
|
|
|
|
|
+ */
|
|
|
|
|
+ const explicitSession = sessionId ? this.sessions.byId(sessionId) : undefined;
|
|
|
|
|
+ const ownRoom = explicitSession?.roomId;
|
|
|
try {
|
|
try {
|
|
|
rid = await this.mx.ensureRoom(agentMxId, {
|
|
rid = await this.mx.ensureRoom(agentMxId, {
|
|
|
- roomId: prov.roomId,
|
|
|
|
|
|
|
+ roomId: ownRoom && ownRoom !== prov.roomId ? ownRoom : prov.roomId,
|
|
|
agentName: this.live.agentById(agentId)?.name,
|
|
agentName: this.live.agentById(agentId)?.name,
|
|
|
});
|
|
});
|
|
|
} catch (e) {
|
|
} catch (e) {
|
|
@@ -996,7 +1196,12 @@ export class ChatService {
|
|
|
* 同一个房间里会有多条会话的消息混在 timeline 里,
|
|
* 同一个房间里会有多条会话的消息混在 timeline 里,
|
|
|
* 不按 `threadId` 过滤就会把别的会话的内容显示在当前会话里。
|
|
* 不按 `threadId` 过滤就会把别的会话的内容显示在当前会话里。
|
|
|
*/
|
|
*/
|
|
|
- const rawTimeline = this.mx.timelineToMessages(rid);
|
|
|
|
|
|
|
+ /*
|
|
|
|
|
+ * 🔴 首次读取放在这里只是为了让 `lastMessageAtOf()` 能用;
|
|
|
|
|
+ * 回溯之后**必须重新读一次**(见下方 ⑤-0 之后的 `rawTimeline` 赋值)——
|
|
|
|
|
+ * 否则补回来的历史不被采用,用户依然看到空白。
|
|
|
|
|
+ */
|
|
|
|
|
+ let rawTimeline = this.mx.timelineToMessages(rid);
|
|
|
|
|
|
|
|
/*
|
|
/*
|
|
|
* 某会话在 timeline 里的**真实最后消息时间**(2026-11-07)。
|
|
* 某会话在 timeline 里的**真实最后消息时间**(2026-11-07)。
|
|
@@ -1144,6 +1349,23 @@ export class ChatService {
|
|
|
}
|
|
}
|
|
|
if (my !== this.seq) return;
|
|
if (my !== this.seq) return;
|
|
|
this._sessionId.set(sid);
|
|
this._sessionId.set(sid);
|
|
|
|
|
+
|
|
|
|
|
+ /*
|
|
|
|
|
+ * 🔴 ⑤-0 按会话回溯本地 timeline(2026-11-12 用户实测:「历史记录都无法查看」)。
|
|
|
|
|
+ *
|
|
|
|
|
+ * 问题:`startClient({ initialSyncLimit: 50 })` 只把房间**最近 50 条**
|
|
|
|
|
+ * 放进本地 timeline,而 `timelineToMessages()` 只读本地、**从不主动向前翻**。
|
|
|
|
|
+ * 实测房间有 1252 条消息,老会话的最新一条排在第 52 / 68 位 —— 压根不在本地,
|
|
|
|
|
+ * 打开就是空白,还被渲染成「全新会话」欢迎页(`emptyReason` 判成了 `fresh`)。
|
|
|
|
|
+ *
|
|
|
|
|
+ * 必须在 `sid` 确定之后做(要按会话过滤判断「命中没有」),
|
|
|
|
|
+ * 且必须在下面读 `rawTimeline` **之前**做。
|
|
|
|
|
+ */
|
|
|
|
|
+ await this.backfillSessionTimeline(rid, sid, my);
|
|
|
|
|
+ /* 回溯期间用户可能已切走 —— 过期就不继续(沿用全局 `seq` 版本号约定) */
|
|
|
|
|
+ if (my !== this.seq) return;
|
|
|
|
|
+ /* 🔴 回溯后**重新读取**:不重读就等于没回溯(历史仍取不到) */
|
|
|
|
|
+ rawTimeline = this.mx.timelineToMessages(rid);
|
|
|
/*
|
|
/*
|
|
|
* 🔴 会话目录被整表替换后,会话可能连着被删两次(2026-11-06)。
|
|
* 🔴 会话目录被整表替换后,会话可能连着被删两次(2026-11-06)。
|
|
|
* 循环里 `byId` 只在那一刻成立:清理在别处跑过一轮,它就不在了。
|
|
* 循环里 `byId` 只在那一刻成立:清理在别处跑过一轮,它就不在了。
|
|
@@ -1233,12 +1455,51 @@ export class ChatService {
|
|
|
* ① 真正新建的会话 —— 显示「下午好,我是 xxx」引导页
|
|
* ① 真正新建的会话 —— 显示「下午好,我是 xxx」引导页
|
|
|
* ② 有历史但一条也取不到 —— 只是空着,不能伪装成新建
|
|
* ② 有历史但一条也取不到 —— 只是空着,不能伪装成新建
|
|
|
*
|
|
*
|
|
|
- * 判据:rawTimeline 里有没有**任何**属于这个房间的消息。
|
|
|
|
|
- * 有消息但过滤后为空 = 我们的归属规则把它们挡掉了,属于 ②。
|
|
|
|
|
- * 注意不能用 `messageCount`(实测虚高:云端记 21 条、Matrix 实际 2 条)。
|
|
|
|
|
|
|
+ * 判据(2026-11-12 修正):**这条会话在本地 timeline 里到底有没有消息**。
|
|
|
|
|
+ *
|
|
|
|
|
+ * 🔴 原判据是 `rawTimeline.length > 0`(房间里有没有任何事件)——
|
|
|
|
|
+ * 它恒为真(最近 50 条任何会话的都算),等于没有判断,
|
|
|
|
|
+ * 结果「有历史但取不到」也被渲染成「全新会话欢迎页」,
|
|
|
|
|
+ * 用户以为记录丢了(实测:51 条会话里大部分都这样)。
|
|
|
|
|
+ *
|
|
|
|
|
+ * 现在:
|
|
|
|
|
+ * · 有消息 → 正常渲染
|
|
|
|
|
+ * · 无消息,但房间里**确实有本会话的事件** → `unloaded`(诚实说「取不到」)
|
|
|
|
|
+ * · 无消息,但**会话目录说它该有内容** → `unloaded`(诚实说「取不到」)
|
|
|
|
|
+ * · 无消息,且目录里也没有它的记录 → `fresh`(真的是新会话)
|
|
|
|
|
+ *
|
|
|
|
|
+ * 注意不能用 `messageCount`(实测虚高:云端记 21 条、Matrix 实际 2 条),
|
|
|
|
|
+ * 所以只看「目录里有没有这条记录」这个二值事实,不比对条数。
|
|
|
*/
|
|
*/
|
|
|
- const hadAnyEvent = rawTimeline.length > 0;
|
|
|
|
|
- this._emptyReason.set(this._messages().length > 0 ? null : hadAnyEvent ? 'unloaded' : 'fresh');
|
|
|
|
|
|
|
+ const hasSessionEvent = rawTimeline.some((m) => this.belongsToSession(m, sid, true));
|
|
|
|
|
+ /*
|
|
|
|
|
+ * 「是不是真新会话」看**会话目录**:本地目录里有这条记录 = 它出现过。
|
|
|
|
|
+ * (新会话在落库前只有 `_blankSessionId` 标记,不会出现在目录里。)
|
|
|
|
|
+ *
|
|
|
|
|
+ * 🔴 三种空态要分清(2026-11-12 实测,用户会直接看这句话判断记录丢没丢):
|
|
|
|
|
+ * · 回溯后**找到了**本会话事件但被归属规则挡掉 → `unloaded`(取不到)
|
|
|
|
|
+ * · 已翻到房间最早、目录里有这条、仍无任何它的消息 → `fresh`(它本来就没有消息)
|
|
|
|
|
+ * · 没翻到最早(可能还有更早的历史没找) → `unloaded`(诚实说还没找到)
|
|
|
|
|
+ *
|
|
|
|
|
+ * 「已翻到房间最早」用 `paginationToken == null` 判定 —— 这时
|
|
|
|
|
+ * **整个房间都找过了**,没有就是真的没有,不该再吓唬用户说「取不到」。
|
|
|
|
|
+ */
|
|
|
|
|
+ const reachedRoomStart = this._reachedRoomStart;
|
|
|
|
|
+ const knownInCatalog = !!this.sessions.byId(sid);
|
|
|
|
|
+ this._emptyReason.set(
|
|
|
|
|
+ this._messages().length > 0
|
|
|
|
|
+ ? null
|
|
|
|
|
+ : hasSessionEvent
|
|
|
|
|
+ ? 'unloaded'
|
|
|
|
|
+ : reachedRoomStart && knownInCatalog
|
|
|
|
|
+ ? /*
|
|
|
|
|
+ * 已翻到房间最早 + 目录里有这条 + 一条消息都没有 = **空壳会话**。
|
|
|
|
|
+ * 不能说「暂时取不到」(用户会一直等,以为数据丢了),
|
|
|
|
|
+ * 也不能说成「新会话」(会诱导用户在这里重新开始)。
|
|
|
|
|
+ */
|
|
|
|
|
+ 'blank'
|
|
|
|
|
+ : 'unloaded',
|
|
|
|
|
+ );
|
|
|
this._state.set(this._messages().length === 0 ? 'empty' : 'ready');
|
|
this._state.set(this._messages().length === 0 ? 'empty' : 'ready');
|
|
|
// timeline pagination token 是否存在 = 更早可能有历史
|
|
// timeline pagination token 是否存在 = 更早可能有历史
|
|
|
this._hasMoreHistory.set(this.mx.paginationToken(rid) != null);
|
|
this._hasMoreHistory.set(this.mx.paginationToken(rid) != null);
|
|
@@ -1459,6 +1720,7 @@ export class ChatService {
|
|
|
* 重建拿到一堆空房间,把群历史覆盖成空(评审 2026-11-07 发现)。
|
|
* 重建拿到一堆空房间,把群历史覆盖成空(评审 2026-11-07 发现)。
|
|
|
*/
|
|
*/
|
|
|
if (!opts.skipConnect || !this.mx.connected) {
|
|
if (!opts.skipConnect || !this.mx.connected) {
|
|
|
|
|
+ this.mx.setRecoveryAgentId(agentId);
|
|
|
await this.mx.connectSession(prov.accessToken, prov.userMxId ?? '', prov.deviceId);
|
|
await this.mx.connectSession(prov.accessToken, prov.userMxId ?? '', prov.deviceId);
|
|
|
}
|
|
}
|
|
|
this.roomAgentMap.set(agentId, prov.roomId ?? '');
|
|
this.roomAgentMap.set(agentId, prov.roomId ?? '');
|
|
@@ -2127,6 +2389,110 @@ export class ChatService {
|
|
|
return { ok, fail };
|
|
return { ok, fail };
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 打开会话时,若本地 timeline 里**找不到这条会话的消息**,沿房间历史向前回溯。
|
|
|
|
|
+ *
|
|
|
|
|
+ * ## 为什么需要(2026-11-12 用户实测)
|
|
|
|
|
+ *
|
|
|
|
|
+ * 「我的这些历史记录都无法查看了,大部分都没有正常显示。」
|
|
|
|
|
+ *
|
|
|
|
|
+ * - `startClient({ initialSyncLimit: 50 })` 只把房间**最近 50 条**事件放进本地;
|
|
|
|
|
+ * - `timelineToMessages()` 只读本地 timeline,**从不主动向前翻**;
|
|
|
|
|
+ * - 实测 lj-test 房间有 1252 条消息、51 条会话,老会话的最新一条排在第 52 / 68 位
|
|
|
|
|
+ * → 本地压根没有 → 打开是空白,且被渲染成「全新会话」欢迎页。
|
|
|
|
|
+ *
|
|
|
|
|
+ * ## 策略
|
|
|
|
|
+ *
|
|
|
|
|
+ * · **命中即停**:多数情况 1~2 轮就够,不把整个房间翻完(那是几十次往返);
|
|
|
|
|
+ * · 有上限(`MAX_ROUNDS` × `PAGE`),防止对超大房间无限回溯;
|
|
|
|
|
+ * · 翻到 token 耗尽(房间到顶)也停;
|
|
|
|
|
+ * · 复用 `belongsToSession()` 作为归属唯一真源,**不另写一套匹配规则**。
|
|
|
|
|
+ *
|
|
|
|
|
+ * 回溯失败(网络错误等)只记日志不抛错 —— 打开会话不该因为翻历史失败而整个失败。
|
|
|
|
|
+ *
|
|
|
|
|
+ * @param rid 房间 id
|
|
|
|
|
+ * @param sid 会话 id(线程根)
|
|
|
|
|
+ * @param my `open()` 的版本号,切走即中止
|
|
|
|
|
+ * @returns 是否在本地找到了这条会话的消息
|
|
|
|
|
+ */
|
|
|
|
|
+ private async backfillSessionTimeline(rid: string, sid: string, my: number): Promise<boolean> {
|
|
|
|
|
+ /* 每次打开会话都要重新判定(沿用上一条会话的结论会误判成「翻到底了」) */
|
|
|
|
|
+ this._reachedRoomStart = false;
|
|
|
|
|
+ if (!sid || environment.useMockData) return true;
|
|
|
|
|
+ const hit = (): boolean =>
|
|
|
|
|
+ this.mx.timelineToMessages(rid).some((m) => this.belongsToSession(m, sid));
|
|
|
|
|
+ if (hit()) return true;
|
|
|
|
|
+ /*
|
|
|
|
|
+ * 🔴 新建会话本来就没有历史 —— 不能回溯,否则会把旧内容认领进来
|
|
|
|
|
+ * (这正是 2026-11-08「新建会话带出历史」的入口之一)。
|
|
|
|
|
+ */
|
|
|
|
|
+ if (this._blankSessionId === sid) return false;
|
|
|
|
|
+
|
|
|
|
|
+ /*
|
|
|
|
|
+ * 🔴 **必须等初次 sync 落地再回溯**(2026-11-12 实测)。
|
|
|
|
|
+ *
|
|
|
|
|
+ * `paginationToken()` 在房间还没进本地 store(`client.getRoom()` 为 null)
|
|
|
|
|
+ * 时返回 `null`,与「真的翻到顶了」无法区分。原写法把它当成「已到最早」
|
|
|
|
|
+ * 直接 break → 一次请求都不发 → 历史照样看不到(实测 requests=0)。
|
|
|
|
|
+ *
|
|
|
|
|
+ * `synced()` 会等首个 SYNCING(超时即返回 false,不无限等待)。
|
|
|
|
|
+ */
|
|
|
|
|
+ await this.mx.synced(15_000);
|
|
|
|
|
+ if (my !== this.seq) return false;
|
|
|
|
|
+
|
|
|
|
|
+ const PAGE = 100;
|
|
|
|
|
+ /*
|
|
|
|
|
+ * 🔴 上限要够大(2026-11-12 实测)。
|
|
|
|
|
+ *
|
|
|
|
|
+ * lj-test 的房间已超过 **2100 个事件**(还在增长),老的 10 轮 × 100 = 1000 条
|
|
|
|
|
+ * 根本翻不到底,「测试9680」这类会话的最新一条在第 207 位之外仍取不到。
|
|
|
|
|
+ * 这里抬到 50 轮(约 5000 条),同时靠「命中即停」保证大多数情况只翻 1~2 轮,
|
|
|
|
|
+ * 以及「到顶即停」保证不会白跑。
|
|
|
|
|
+ */
|
|
|
|
|
+ const MAX_ROUNDS = 50;
|
|
|
|
|
+ /*
|
|
|
|
|
+ * 🔴 与 `paginate()` 共用同一把锁(`_paginating`)。
|
|
|
|
|
+ *
|
|
|
|
|
+ * 两者翻的是**同一条房间 timeline**:并发拉取会让 SDK 的 `end` token
|
|
|
|
|
+ * 互相覆盖,出现「翻了一轮等于没翻」甚至重复事件。
|
|
|
|
|
+ * 已有分页在跑时让路 —— 它翻完同样会让 `hit()` 成立。
|
|
|
|
|
+ */
|
|
|
|
|
+ if (this._paginating()) return hit();
|
|
|
|
|
+ this._paginating.set(true);
|
|
|
|
|
+ try {
|
|
|
|
|
+ for (let i = 0; i < MAX_ROUNDS; i++) {
|
|
|
|
|
+ if (my !== this.seq) return false;
|
|
|
|
|
+ if (this.mx.paginationToken(rid) == null) {
|
|
|
|
|
+ /* 已经翻到房间最早:本次判断「有没有这条会话的消息」是**定论** */
|
|
|
|
|
+ this._reachedRoomStart = true;
|
|
|
|
|
+ break;
|
|
|
|
|
+ }
|
|
|
|
|
+ let ok = false;
|
|
|
|
|
+ try {
|
|
|
|
|
+ ok = await this.mx.paginateBack(rid, PAGE);
|
|
|
|
|
+ } catch {
|
|
|
|
|
+ /* 网络抖动:不阻断打开会话(**不能**据此认为翻到底了) */
|
|
|
|
|
+ break;
|
|
|
|
|
+ }
|
|
|
|
|
+ /*
|
|
|
|
|
+ * 🔴 SDK 语义:`paginateEventTimeline` 返回 **false = 已到 timeline 尽头**
|
|
|
|
|
+ * (`client.ts:5054`「false if there are no events and we reached either end」),
|
|
|
|
|
+ * 不是出错。早先把它当错误处理,导致提前 break 且误判「翻到底」。
|
|
|
|
|
+ */
|
|
|
|
|
+ if (!ok) {
|
|
|
|
|
+ this._reachedRoomStart = true;
|
|
|
|
|
+ break;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (hit()) return true;
|
|
|
|
|
+ }
|
|
|
|
|
+ /* 翻满上限还没到顶 —— 后面判定要当「可能还有没找到的历史」处理 */
|
|
|
|
|
+ if (this.mx.paginationToken(rid) == null) this._reachedRoomStart = true;
|
|
|
|
|
+ return hit();
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ if (my === this.seq) this._paginating.set(false);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
/** 向上翻历史(AC-04.4);返回是否还有更早的消息 */
|
|
/** 向上翻历史(AC-04.4);返回是否还有更早的消息 */
|
|
|
async paginate(): Promise<boolean> {
|
|
async paginate(): Promise<boolean> {
|
|
|
if (environment.useMockData || !this.roomId) return false;
|
|
if (environment.useMockData || !this.roomId) return false;
|
|
@@ -2231,12 +2597,24 @@ export class ChatService {
|
|
|
const lastId = this._messages().at(-1)?.id;
|
|
const lastId = this._messages().at(-1)?.id;
|
|
|
// 自造的会话 id 还没有任何消息时,不填 m.in_reply_to(那是降级展示用的引用)
|
|
// 自造的会话 id 还没有任何消息时,不填 m.in_reply_to(那是降级展示用的引用)
|
|
|
const last = lastId && !lastId.startsWith('local-') ? lastId : undefined;
|
|
const last = lastId && !lastId.startsWith('local-') ? lastId : undefined;
|
|
|
|
|
+ /*
|
|
|
|
|
+ * 🔴 等该会话的专属房间建好再发(2026-11-12)。
|
|
|
|
|
+ * 否则消息会落进共享房间,导致「一房间一会话」失效。
|
|
|
|
|
+ * 等不到(建房失败)就发进当前房间 —— 降级为老行为,不影响用户。
|
|
|
|
|
+ */
|
|
|
|
|
+ /*
|
|
|
|
|
+ * 🔴 发到**该会话自己的房间**(一房间一会话,2026-11-12)。
|
|
|
|
|
+ *
|
|
|
|
|
+ * 建房已在 `startBlankWithRoom()` 里同步完成(用户到达页面时房间已就绪),
|
|
|
|
|
+ * 所以这里只需按会话取 roomId;取不到才回落到当前房间(老会话 / 建房失败降级)。
|
|
|
|
|
+ */
|
|
|
|
|
+ const targetRoom = this.sessions.byId(sid)?.roomId ?? this.roomId;
|
|
|
/*
|
|
/*
|
|
|
* `localId` 随事件写进 content:服务端 echo 会原样带回(Matrix 允许任意额外字段),
|
|
* `localId` 随事件写进 content:服务端 echo 会原样带回(Matrix 允许任意额外字段),
|
|
|
* 回读时据此认领本地那条乐观消息,避免同一句话出现两遍。
|
|
* 回读时据此认领本地那条乐观消息,避免同一句话出现两遍。
|
|
|
*/
|
|
*/
|
|
|
await this.mx.sendText(
|
|
await this.mx.sendText(
|
|
|
- this.roomId,
|
|
|
|
|
|
|
+ targetRoom,
|
|
|
text,
|
|
text,
|
|
|
sid,
|
|
sid,
|
|
|
last,
|
|
last,
|
|
@@ -2364,6 +2742,27 @@ export class ChatService {
|
|
|
/** 当前派活锚点(供群聊页在冷启动时补种) */
|
|
/** 当前派活锚点(供群聊页在冷启动时补种) */
|
|
|
readonly dispatchAnchors = this._dispatchAnchor.asReadonly();
|
|
readonly dispatchAnchors = this._dispatchAnchor.asReadonly();
|
|
|
|
|
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 用 ✅ 标注的消息 eventId,反推「哪条会话刚结束」(2026-11-12)。
|
|
|
|
|
+ *
|
|
|
|
|
+ * ✅ 的 `m.relates_to.event_id` 指向容器本轮的最后一条消息。从房间 timeline
|
|
|
|
|
+ * 里找出这条消息,它的归属规则与 `belongsToSession()` 完全一致:
|
|
|
|
|
+ * · 有 `m.thread` → 线程根就是会话 id(容器自开的子线程按规则归当前会话)
|
|
|
|
|
+ * · 无 thread ──── DM 里容器的正常回复**本来就不挂 thread**,
|
|
|
|
|
+ * 此时拿不到精确会话,返回 `null` 让调用方清全部本地标记
|
|
|
|
|
+ * (容器已经收工,继续转圈比少转一会儿更糟)
|
|
|
|
|
+ *
|
|
|
|
|
+ * @returns 命中的会话 id 列表;`null` = 无法精确定位
|
|
|
|
|
+ */
|
|
|
|
|
+ private _sessionsOfMessage(eventId: string, roomId: string): string[] | null {
|
|
|
|
|
+ if (!eventId) return null;
|
|
|
|
|
+ const hit = this.mx.timelineToMessages(roomId).find((m) => m.eventId === eventId);
|
|
|
|
|
+ if (!hit) return null;
|
|
|
|
|
+ if (hit.threadId) return [hit.threadId];
|
|
|
|
|
+ if (hit.sessionId) return [hit.sessionId];
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
private belongsToSession(
|
|
private belongsToSession(
|
|
|
m: MxMessage,
|
|
m: MxMessage,
|
|
|
sessionId: string,
|
|
sessionId: string,
|
|
@@ -2913,6 +3312,19 @@ export class ChatService {
|
|
|
fromAgent: mapped.sender === 'agent',
|
|
fromAgent: mapped.sender === 'agent',
|
|
|
ts: Date.parse(mapped.ts) || Date.now(),
|
|
ts: Date.parse(mapped.ts) || Date.now(),
|
|
|
});
|
|
});
|
|
|
|
|
+ /*
|
|
|
|
|
+ * 会话级「最后一条 agent 消息」:左侧列表据此判断「在输出就转,没回复就停」。
|
|
|
|
|
+ *
|
|
|
|
|
+ * 🔴 必须过滤**历史回放**(2026-11-12 用户实测:点开历史会话就开始转)。
|
|
|
|
|
+ * `appendIncoming` 会被 `/sync` 的历史事件触发,此时 `m.live` 为 false、
|
|
|
|
|
+ * 或消息时间戳很旧。不滤的话,打开任何老会话都会被判成「正在输出」→ 转圈。
|
|
|
|
|
+ * 判据与 `noteMessage` 同口径:实时到达 + 时间戳新鲜。
|
|
|
|
|
+ */
|
|
|
|
|
+ const ts = Date.parse(mapped.ts) || 0;
|
|
|
|
|
+ const fresh = ts > 0 && Date.now() - ts < 60_000;
|
|
|
|
|
+ if (mapped.sender === 'agent' && sid && m.live === true && fresh) {
|
|
|
|
|
+ this.pulse.noteSessionAgentMessage(sid);
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -2920,9 +3332,11 @@ export class ChatService {
|
|
|
this.unbindMessage?.();
|
|
this.unbindMessage?.();
|
|
|
this.unbindTyping?.();
|
|
this.unbindTyping?.();
|
|
|
this.unbindTurnReaction?.();
|
|
this.unbindTurnReaction?.();
|
|
|
|
|
+ this.unbindTurnStatus?.();
|
|
|
this.unbindMessage = null;
|
|
this.unbindMessage = null;
|
|
|
this.unbindTyping = null;
|
|
this.unbindTyping = null;
|
|
|
this.unbindTurnReaction = null;
|
|
this.unbindTurnReaction = null;
|
|
|
|
|
+ this.unbindTurnStatus = null;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
private markSent(id: string): void {
|
|
private markSent(id: string): void {
|