|
|
@@ -1,7 +1,7 @@
|
|
|
/** 首屏加载条数:只渲染最近一屏,其余上滚再取(用户要求,避免一次性加载全部) */
|
|
|
const INITIAL_PAGE = 30;
|
|
|
|
|
|
-import { computed, effect, inject, Injectable, signal } from '@angular/core';
|
|
|
+import { computed, effect, inject, Injectable, signal, untracked } from '@angular/core';
|
|
|
import { FmodeApi } from './api/fmode-api';
|
|
|
import {
|
|
|
Agent,
|
|
|
@@ -164,6 +164,18 @@ export class ChatService {
|
|
|
readonly draft = this._draft.asReadonly();
|
|
|
/** 对方正在输入(真实态由 typing 事件驱动) */
|
|
|
readonly peerTyping = this._peerTyping.asReadonly();
|
|
|
+ /**
|
|
|
+ * 容器**明确上报**自己闲下来了(没有在跑任务)。
|
|
|
+ *
|
|
|
+ * 供回合卡片判断「还在输出」用(2026-11-08):
|
|
|
+ * 容器说空闲 → 立刻收尾;容器在跑或状态未知 → 依静默窗口判定。
|
|
|
+ * 只有容器能宣布「干完了」,前端不替它下结论。
|
|
|
+ */
|
|
|
+ readonly agentIdle = computed(() => {
|
|
|
+ const id = this._agentId();
|
|
|
+ if (!id) return false;
|
|
|
+ return this.live.agentById(id)?.status === 'idle';
|
|
|
+ });
|
|
|
/** 正在加载更早历史 */
|
|
|
readonly paginating = this._paginating.asReadonly();
|
|
|
/** 是否可能还有更早历史(真实态:上次分页仍有返回) */
|
|
|
@@ -181,6 +193,20 @@ export class ChatService {
|
|
|
* `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;
|
|
|
/** 真实态下的当前房间 id */
|
|
|
private roomId: string | null = null;
|
|
|
@@ -272,31 +298,79 @@ export class ChatService {
|
|
|
if (!sid) return;
|
|
|
|
|
|
/*
|
|
|
- * 🔴 新建态(`startBlank()` 刚建、还没发过消息)**一律不回填**(2026-11-07)。
|
|
|
+ * 🔴 新建态保护(2026-11-08 第三次修正,最终版)。
|
|
|
*
|
|
|
- * 上面已经把 `_messages` 清空、订阅也解除了,但 `/sync` 落地时这个 effect
|
|
|
- * 仍会跑:只要新会话的 id 恰好被 `belongsToSession` 认领(比如房间级消息的
|
|
|
- * `messageCount > 0` 兜底分支命中了旧值),历史就会被重新灌进来 ——
|
|
|
- * 表现为「新建对话里冒出几天前的旧内容」。
|
|
|
+ * 前两次都错在「用一个时间点解除保护」:
|
|
|
+ * ① 在 `commit()` 时清 —— 本地记账,网络请求还在路上;
|
|
|
+ * ② 在 `sendReal()` 返回后清 —— 只是「我这边发出去了」,
|
|
|
+ * 服务端还没把这条件纳入房间 timeline,容器也还没回应。
|
|
|
+ * 两次都留下同一个空窗:保护一撤,`/sync` 落地就把该房间
|
|
|
+ * **所有旧容器线程根**算成本会话的(`rootsOfSession` 认领全部历史)。
|
|
|
+ * 实测:解除保护后 60ms 内就灌入 17 条旧消息。
|
|
|
*
|
|
|
- * `_blankWorkspace !== null` 或 `_state === 'empty'` 表示这条会话还没有任何
|
|
|
- * 本会话消息;此时以「空」为准,等用户真正发出第一条消息由 appendIncoming 接管。
|
|
|
+ * 正确的判据不是「发出了」,而是「**本会话确实有消息落在 timeline 里**」:
|
|
|
+ * 用户那条消息一旦出现在 timeline(`threadId === sid`),
|
|
|
+ * `rootsOfSession` 就只会认领由它派生出的容器子线程,旧历史天然被排除。
|
|
|
+ *
|
|
|
+ * 所以在保护期内,先看 timeline 里有没有本会话自己的消息:
|
|
|
+ * · 没有 → 还没落地,继续挡(旧历史一律不进)
|
|
|
+ * · 有 → 解除保护,本次及之后的回填都按正常规则走
|
|
|
*/
|
|
|
+ if (this._blankSessionId && sid === this._blankSessionId) {
|
|
|
+ const landed = raw.some((m) => m.threadId === sid);
|
|
|
+ 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)),
|
|
|
+ raw.filter((m) => this.belongsToSession(m, sid, false, ownedRoots)),
|
|
|
myUserId,
|
|
|
);
|
|
|
if (next.length === 0) return;
|
|
|
- this._messages.set(next);
|
|
|
+
|
|
|
+ /*
|
|
|
+ * 🔴 合并而不是整表覆盖(2026-11-08)。
|
|
|
+ *
|
|
|
+ * `_messages.set(next)` 会把本地的乐观消息(刚发出、云端还没回读到的)
|
|
|
+ * 一起抹掉,再排成「旧历史 → 新消息」的顺序 —— 用户看到新建对话里
|
|
|
+ * 凭空出现昨天的内容,且自己的提问被挤到下面。
|
|
|
+ *
|
|
|
+ * 现在:历史里有的以历史为准,历史里没有的本地消息**保留**并排在后面。
|
|
|
+ *
|
|
|
+ * 🔴 读 `_messages()` 必须走 `untracked`:本 effect 正在往里写,
|
|
|
+ * 直接读会形成自依赖 → 无限重算(实测会导致页面卡死、按钮点不动)。
|
|
|
+ */
|
|
|
+ const local = untracked(() => this._messages());
|
|
|
+ const byId = new Map(next.map((m) => [m.id, m]));
|
|
|
+ for (const m of local) {
|
|
|
+ if (!byId.has(m.id)) byId.set(m.id, m);
|
|
|
+ }
|
|
|
+ this._messages.set([...byId.values()]);
|
|
|
/*
|
|
|
* 🔴 这里**不能**标记「正在回复」:这是历史重读(每次 sync 都会重新灌一遍),
|
|
|
* 刷新页面时尾部 id 必然变 → 一打开就显示「正在回复」(用户实测)。
|
|
|
* 真正的实时信号在 appendIncoming()。
|
|
|
*/
|
|
|
if (this._state() === 'empty') this._state.set('ready');
|
|
|
- this._hasMoreHistory.set(this.mx.paginationToken(rid) != null);
|
|
|
+ /*
|
|
|
+ * 🔴 只在**保护期结束后**才按房间 token 决定能否翻页(2026-11-08)。
|
|
|
+ *
|
|
|
+ * `paginationToken()` 取的是**整个房间**的 backward token,与当前 thread 无关。
|
|
|
+ * 新建会话照样能拿到非 null → 页面出现「加载更早的消息」→ 一点就翻出旧内容。
|
|
|
+ * 保护期内恒为 false:这条会话确实没有「更早的消息」。
|
|
|
+ */
|
|
|
+ this._hasMoreHistory.set(
|
|
|
+ this._blankSessionId !== sid && this.mx.paginationToken(rid) != null,
|
|
|
+ );
|
|
|
});
|
|
|
}
|
|
|
|
|
|
@@ -450,6 +524,14 @@ export class ChatService {
|
|
|
* @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 修正)。
|
|
|
*
|
|
|
@@ -499,12 +581,24 @@ export class ChatService {
|
|
|
*/
|
|
|
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._blankWorkspace = workspaceId;
|
|
|
+ // 新建态标记:storeVersion effect 据此拒绝回填(见字段注释)
|
|
|
+ this._blankSessionId = s.id;
|
|
|
return this.sessions.byId(s.id) ?? s;
|
|
|
}
|
|
|
|
|
|
@@ -734,6 +828,35 @@ export class ChatService {
|
|
|
});
|
|
|
}
|
|
|
|
|
|
+ /*
|
|
|
+ * 重建「本会话的容器线程根」集合(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,
|
|
|
@@ -872,6 +995,12 @@ export class ChatService {
|
|
|
this._paginating.set(false);
|
|
|
this._hasMoreHistory.set(false);
|
|
|
this._blankWorkspace = null;
|
|
|
+ /*
|
|
|
+ * 🔴 新建态标记必须复位(2026-11-08):只在 `sendText` commit 后清除时,
|
|
|
+ * 用户「新建对话 → 不发消息 → 离开页面」会把标记留在内存里,
|
|
|
+ * 下次万一撞上同一条会话,实时消息会被无声丢弃。
|
|
|
+ */
|
|
|
+ this._blankSessionId = null;
|
|
|
}
|
|
|
|
|
|
setDraft(text: string): void {
|
|
|
@@ -1083,7 +1212,7 @@ export class ChatService {
|
|
|
* 必须在 `noteMessage` **之前** commit:否则推上去的还是「新建对话 / 0 条」,
|
|
|
* 标题要等下一次推送才能更正(用户实测:历史会话名一直不对)。
|
|
|
*/
|
|
|
- this.sessions.noteMessage(curSid, {
|
|
|
+ this.sessions.noteMessage(curSid, {
|
|
|
text: body,
|
|
|
ts: Date.parse(msg.ts) || Date.now(),
|
|
|
fromMe: true,
|
|
|
@@ -1145,8 +1274,25 @@ export class ChatService {
|
|
|
if (!relayed) await this.sendReal(body);
|
|
|
}
|
|
|
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);
|
|
|
}
|
|
|
@@ -1339,6 +1485,24 @@ export class ChatService {
|
|
|
/** 向上翻历史(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);
|
|
|
@@ -1436,7 +1600,29 @@ export class ChatService {
|
|
|
const roomId = this.roomId;
|
|
|
if (!agentId || !roomId) return null;
|
|
|
const exist = this._sessionId();
|
|
|
- if (exist) return this.sessions.byId(exist) ?? null;
|
|
|
+ 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);
|
|
|
+ if (this._blankWorkspace) {
|
|
|
+ this.sessions.moveTo(recreated.id, this._blankWorkspace);
|
|
|
+ this._blankWorkspace = null;
|
|
|
+ }
|
|
|
+ return recreated;
|
|
|
+ }
|
|
|
+ return found;
|
|
|
+ }
|
|
|
const created = this.sessions.create(roomId, agentId);
|
|
|
/*
|
|
|
* 「新建对话」时指定的工作区在**这一刻**落地。
|
|
|
@@ -1512,7 +1698,12 @@ export class ChatService {
|
|
|
/** 当前派活锚点(供群聊页在冷启动时补种) */
|
|
|
readonly dispatchAnchors = this._dispatchAnchor.asReadonly();
|
|
|
|
|
|
- private belongsToSession(m: MxMessage, sessionId: string): boolean {
|
|
|
+ private belongsToSession(
|
|
|
+ m: MxMessage,
|
|
|
+ sessionId: string,
|
|
|
+ live = false,
|
|
|
+ ownedRoots?: ReadonlySet<string>,
|
|
|
+ ): boolean {
|
|
|
if (!m.threadId) {
|
|
|
/*
|
|
|
* 🔴 群聊派活的隔离(2026-11-06 用户裁决:单聊永远看不到)。
|
|
|
@@ -1597,10 +1788,70 @@ export class ChatService {
|
|
|
* · 线程根是容器造的(非 approot) → **属于当前会话**,按时间混排在消息流里
|
|
|
*
|
|
|
* 这样既不会在左栏冒出重复会话,内容也不会丢。
|
|
|
+ *
|
|
|
+ * 🔴 但要**限定在当前会话**(2026-11-08 修复)。
|
|
|
+ *
|
|
|
+ * 原实现直接 `return !isAppSessionId(m.threadId)` —— 意味着容器开过的
|
|
|
+ * **每一个**线程根都属于**每一个**会话。后果:新建一个空会话后,
|
|
|
+ * 该房间里所有旧线程的消息都会被它认领,上方凭空出现几天前的对话
|
|
|
+ * (用户实测图31)。
|
|
|
+ *
|
|
|
+ * 现在:**历史回填**时容器线程根只归「已经认领过它」的会话 ——
|
|
|
+ * 新会话从没认领过任何根,自然什么都拿不到,符合「新建即空白」的预期。
|
|
|
+ * **实时事件**(`live=true`)仍然接纳:那是容器刚刚发出的内容,
|
|
|
+ * 一定是当前这次对话的产出(截图、交付物都在里面)。
|
|
|
*/
|
|
|
- return !isAppSessionId(m.threadId);
|
|
|
+ if (!m.threadId) return false;
|
|
|
+ if (live) return !isAppSessionId(m.threadId);
|
|
|
+ const roots = ownedRoots ?? this._ownedThreadRoots;
|
|
|
+ return roots.has(m.threadId);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 算出某会话「拥有」的容器线程根(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 用户裁决下线)。
|
|
|
*
|
|
|
@@ -1660,8 +1911,22 @@ export class ChatService {
|
|
|
if (!this._agentId()) return;
|
|
|
const sid = this._sessionId();
|
|
|
// 🔴 多会话隔离:别的会话的消息不进入当前会话的列表
|
|
|
- if (!sid || !this.belongsToSession(m, sid)) return;
|
|
|
- if (m.eventId && this._messages().some((x) => x.id === m.eventId)) return;
|
|
|
+ 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);
|
|
|
this._messages.update((list) => [...list, mapped]);
|
|
|
this.rememberDemoHistory();
|