|
|
@@ -790,9 +790,22 @@ export class ChatService {
|
|
|
* 只把 `_messages` 置空是不够的:订阅还挂在房间上,
|
|
|
* 后续任何回填 / 重放事件都会把**上一个会话的消息塞回新会话里**。
|
|
|
*
|
|
|
- * 解除后由「首次发消息」那一步重新建立订阅(`open()` 在真正打开会话时再订)。
|
|
|
+ * 解除后必须**在这里重新订阅**(2026-11-11 修正)。
|
|
|
+ *
|
|
|
+ * 原注释说「由首次发消息那一步重新建立订阅」,但那条路径**从未实现** ——
|
|
|
+ * 新建的会话因此收不到任何实时事件(包括 ✅ reaction),
|
|
|
+ * 表现为「卡片已收尾,右上角和左侧还挂着处理中」。
|
|
|
*/
|
|
|
this.teardownSubscriptions();
|
|
|
+ /*
|
|
|
+ * 新建会话仍属**同一个房间**(一个容器一个 DM 房间),
|
|
|
+ * 所以直接用当前 roomId 重新订阅即可。版本号取自增后的 `this.seq`:
|
|
|
+ * 之后任何 `open()` 都会再自增,本订阅的回调自然作废。
|
|
|
+ */
|
|
|
+ if (roomId) {
|
|
|
+ const myUserId = this.provision?.userMxId ?? '';
|
|
|
+ this.bindRoomSubscriptions(roomId, this.seq, myUserId);
|
|
|
+ }
|
|
|
|
|
|
/*
|
|
|
* 🔴 清空「已认领的容器线程根」(2026-11-08)。
|
|
|
@@ -817,6 +830,99 @@ export class ChatService {
|
|
|
return this.sessions.byId(s.id) ?? s;
|
|
|
}
|
|
|
|
|
|
+ /**
|
|
|
+ * 订阅当前房间的实时事件(消息 / typing / ✅ reaction)。
|
|
|
+ *
|
|
|
+ * 🔴 抽成独立方法(2026-11-11 用户实测「新建会话状态不同步」)。
|
|
|
+ *
|
|
|
+ * 原来这三段订阅**只写在 `openReal()` 里**。而 `startBlank()`(新建对话)
|
|
|
+ * 会先 `teardownSubscriptions()` 解除全部订阅,然后走自己的路 ——
|
|
|
+ * **从不重新订阅**。结果:
|
|
|
+ *
|
|
|
+ * · 新建的会话收不到 ✅ reaction → `turnSettledLocally` 永远是 false
|
|
|
+ * · 也收不到实时消息 → 只能靠 15 秒轮询补齐
|
|
|
+ *
|
|
|
+ * 表现就是「卡片已按静默窗口收尾,右上角和左侧还挂着处理中」。
|
|
|
+ *
|
|
|
+ * @param rid 房间 id
|
|
|
+ * @param my `open()` 的版本号;回调里据此丢弃过期回包
|
|
|
+ * @param myUserId 自己的 mxid(用于排除自己发出的 typing)
|
|
|
+ */
|
|
|
+ private bindRoomSubscriptions(rid: string, my: number, myUserId: string): void {
|
|
|
+ /*
|
|
|
+ * 🔴 回调里**不校验 `seq`**(2026-11-11 用户实测「新建会话状态不同步」)。
|
|
|
+ *
|
|
|
+ * 原来三个回调都以 `if (my !== this.seq) return;` 开头,本意是丢弃过期回包。
|
|
|
+ * 但 `seq` 是**全局版本号**,任何一次 `open()` / `close()` 都会自增 ——
|
|
|
+ * 新建会话后紧接着 URL 跳转触发 `onRouteChange → open()`,
|
|
|
+ * `seq` 一变,**新建时建立的订阅就全部失效**:
|
|
|
+ *
|
|
|
+ * · 收不到 ✅ reaction → `turnSettledLocally` 恒 false(右侧一直「处理中」)
|
|
|
+ * · 收不到实时消息 → 只能靠 15 秒轮询
|
|
|
+ *
|
|
|
+ * 而现在这个订阅**只绑定在一个确定的房间**上(`rid` 由闭包捕获),
|
|
|
+ * 房间不会因为切会话而变(一个容器一个 DM 房间)。
|
|
|
+ * 真正要防的是「房间变了还往里写」—— 那由 `teardownSubscriptions()`
|
|
|
+ * 在每次切换时解绑来处理,不需要再拿全局版本号挡。
|
|
|
+ */
|
|
|
+ this.unbindMessage = this.mx.onMessage(rid, (m) => {
|
|
|
+ this.appendIncoming(m, myUserId);
|
|
|
+ });
|
|
|
+
|
|
|
+ this.unbindTyping = this.mx.onTyping(rid, (typing, who) => {
|
|
|
+ /*
|
|
|
+ * 🔴 必须排除自己:`m.typing` 会广播**房间内所有成员**的输入状态(含自己)。
|
|
|
+ * 不过滤 → 用户自己打字时界面显示「对方正在输入」,方向完全相反。
|
|
|
+ */
|
|
|
+ if (myUserId && who === myUserId) return;
|
|
|
+ this._peerTyping.set(typing);
|
|
|
+ /*
|
|
|
+ * 容器从「正在输入」转成「没在输入」= 本轮结束的辅助信号。
|
|
|
+ */
|
|
|
+ if (!typing) {
|
|
|
+ this._turnEndedAt.set(Date.now());
|
|
|
+ this._turnEndedAfter.set(this.lastAgentMessageId());
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ /*
|
|
|
+ * 容器的 ✅ / ❌ reaction = **本轮真的干完了**。
|
|
|
+ *
|
|
|
+ * 这是比 typing 更硬的信号:实测容器每轮处理结束都会把 👀 换成 ✅,
|
|
|
+ * 由 Hermes 内建行为发出,不依赖插件改造。
|
|
|
+ */
|
|
|
+ this.unbindTurnReaction =
|
|
|
+ this.mx.onTurnReaction?.(rid, (_ok, sender) => {
|
|
|
+ /* 同 `bindRoomSubscriptions` 的说明:这里不校验全局 `seq` */
|
|
|
+ /*
|
|
|
+ * 必须校验 sender:群聊里 @ 了多个容器时,每个容器各打一个 ✅、
|
|
|
+ * 打在同一个 event_id 上。不看 sender 会把 A 的收工当成 B 也收工。
|
|
|
+ * 单聊只有本容器一个 agent,这条恒为真、不影响原有行为。
|
|
|
+ */
|
|
|
+ const agentMxId = this._agentMxId();
|
|
|
+ if (agentMxId && sender && sender !== agentMxId) return;
|
|
|
+ /*
|
|
|
+ * 稍等一拍再取「末条 agent 消息 id」:reaction 与它标注的消息是
|
|
|
+ * 两个独立事件,到达顺序不保证。让出一拍能避免取到上一条。
|
|
|
+ */
|
|
|
+ setTimeout(() => {
|
|
|
+ this._turnEndedAt.set(Date.now());
|
|
|
+ this._turnEndedAfter.set(this.lastAgentMessageId());
|
|
|
+ /* 立刻刷新容器状态,不等 15 秒轮询 */
|
|
|
+ void this.live.load(true);
|
|
|
+ }, 300);
|
|
|
+ }) ?? null;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 当前会话所属容器的 Matrix mxid(用于 ✅ reaction 的 sender 校验)。
|
|
|
+ *
|
|
|
+ * 单聊恒为一个 agent;群聊时需要按块区分,这里取当前容器即可。
|
|
|
+ */
|
|
|
+ private _agentMxId(): string {
|
|
|
+ return this.provision?.agentMxId ?? '';
|
|
|
+ }
|
|
|
+
|
|
|
/**
|
|
|
* 真实态开启流程。
|
|
|
*
|
|
|
@@ -874,86 +980,9 @@ export class ChatService {
|
|
|
* 🔴 「历史对话」承接机制已于 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());
|
|
|
- /*
|
|
|
- * 🔴 立刻拉一次容器状态,不等 15 秒轮询(2026-11-11 用户反馈)。
|
|
|
- *
|
|
|
- * 用户实测:「容器已经回复完了,右上角还显示『正在处理』」。
|
|
|
- * 根因:`LiveService` 每 15 秒轮询一次容器状态,
|
|
|
- * 而 ✅ 到达时容器**刚写完** idle —— 最坏要等 15 秒才同步到界面。
|
|
|
- *
|
|
|
- * ✅ 是「这一轮真的结束了」的确定信号,此刻刷新一次最准。
|
|
|
- * `load(true)` 是静默刷新(不改 loading 态、不变引用则不动 UI),
|
|
|
- * 不会造成闪烁;一轮只多一次请求,开销可忽略。
|
|
|
- */
|
|
|
- void this.live.load(true);
|
|
|
- };
|
|
|
- setTimeout(stamp, 300);
|
|
|
- }) ?? null;
|
|
|
+ /* ④ 订阅实时事件(先订阅再读 timeline,避免中间漏事件) */
|
|
|
+ this.bindRoomSubscriptions(rid, my, myUserId);
|
|
|
|
|
|
/*
|
|
|
* ⑤ 读当前 timeline —— 先确定会话,再按会话(thread)过滤,**只取尾部一屏**。
|