|
|
@@ -18,6 +18,7 @@ import { SessionService } from './session.service';
|
|
|
import { UnreadService } from './unread.service';
|
|
|
import {
|
|
|
DISPATCH_KEY,
|
|
|
+ LOCAL_ID_KEY,
|
|
|
MxDispatchMeta,
|
|
|
MxMessage,
|
|
|
MatrixClientService,
|
|
|
@@ -42,6 +43,7 @@ import { ChatSession, deriveTitle, isAppSessionId, placeholderSession } from './
|
|
|
import { ChatSessionService } from './chat-session.service';
|
|
|
import { RelayService } from './relay.service';
|
|
|
import { TaskPulseService } from './task-pulse.service';
|
|
|
+import { sessionRunning } from './agent-state';
|
|
|
|
|
|
export type ConnectionState = 'connecting' | 'connected' | 'disconnected';
|
|
|
|
|
|
@@ -133,6 +135,19 @@ export class ChatService {
|
|
|
/** 是否可能还有更早历史 */
|
|
|
private readonly _hasMoreHistory = signal(false);
|
|
|
|
|
|
+ /**
|
|
|
+ * 当前会话「为什么是空的」(2026-11-09)。
|
|
|
+ *
|
|
|
+ * - `'fresh'`:真的没有消息 —— 新建对话,显示引导页
|
|
|
+ * - `'unloaded'`:房间里有消息,但一条都归属不到这个会话 —— 显示「没有可加载的消息」
|
|
|
+ * - `null`:不空
|
|
|
+ *
|
|
|
+ * 🔴 不区分的话,一个历史取不到的旧会话会显示成「下午好,我是 xxx」,
|
|
|
+ * 用户以为会话还在、内容丢了,实际是被归属规则挡住了(实测)。
|
|
|
+ */
|
|
|
+ private readonly _emptyReason = signal<'fresh' | 'unloaded' | null>(null);
|
|
|
+ readonly emptyReason = this._emptyReason.asReadonly();
|
|
|
+
|
|
|
readonly agentId = this._agentId.asReadonly();
|
|
|
/** 当前会话 id(线程根) */
|
|
|
readonly sessionId = this._sessionId.asReadonly();
|
|
|
@@ -174,7 +189,18 @@ export class ChatService {
|
|
|
readonly agentIdle = computed(() => {
|
|
|
const id = this._agentId();
|
|
|
if (!id) return false;
|
|
|
- return this.live.agentById(id)?.status === 'idle';
|
|
|
+ const agent = this.live.agentById(id);
|
|
|
+ if (!agent) return false;
|
|
|
+ /*
|
|
|
+ * 🔴 优先用**本会话**的状态(2026-11-09 方案 A)。
|
|
|
+ *
|
|
|
+ * 容器上报的 `status` 是整个容器一个值:同一容器的会话 A 在跑时,
|
|
|
+ * 会话 B 也会被告知「不空闲」→ 回合永远收不了尾(用户实测)。
|
|
|
+ * 容器按会话上报时以它为准;没上报(旧容器)才回落到全局 status。
|
|
|
+ */
|
|
|
+ const mine = sessionRunning(agent.currentSessions, this._sessionId());
|
|
|
+ if (mine !== undefined) return !mine;
|
|
|
+ return agent.status === 'idle';
|
|
|
});
|
|
|
/** 正在加载更早历史 */
|
|
|
readonly paginating = this._paginating.asReadonly();
|
|
|
@@ -317,7 +343,17 @@ export class ChatService {
|
|
|
* · 有 → 解除保护,本次及之后的回填都按正常规则走
|
|
|
*/
|
|
|
if (this._blankSessionId && sid === this._blankSessionId) {
|
|
|
- const landed = raw.some((m) => m.threadId === sid);
|
|
|
+ /*
|
|
|
+ * 🔴 必须是「本会话的**用户消息**」才算落地(2026-11-09 修正)。
|
|
|
+ *
|
|
|
+ * 原判据是「timeline 里有任意 `threadId === sid` 的事件」。
|
|
|
+ * 但容器回复也可以挂在这个线程根下 —— 于是容器刚回的**第一条**就
|
|
|
+ * 撤掉了保护,紧接着的回填把整个房间的旧历史灌进来
|
|
|
+ * (用户实测:新建会话后直接出现「加载更早」和旧内容)。
|
|
|
+ *
|
|
|
+ * 用户自己发的那条才说明「这段会话真正开始了」。
|
|
|
+ */
|
|
|
+ const landed = raw.some((m) => m.threadId === sid && m.sender === myUserId);
|
|
|
if (!landed) return;
|
|
|
this._blankSessionId = null;
|
|
|
}
|
|
|
@@ -350,11 +386,7 @@ export class ChatService {
|
|
|
* 直接读会形成自依赖 → 无限重算(实测会导致页面卡死、按钮点不动)。
|
|
|
*/
|
|
|
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()]);
|
|
|
+ this._messages.set(this.mergeTimeline(next, local));
|
|
|
/*
|
|
|
* 🔴 这里**不能**标记「正在回复」:这是历史重读(每次 sync 都会重新灌一遍),
|
|
|
* 刷新页面时尾部 id 必然变 → 一打开就显示「正在回复」(用户实测)。
|
|
|
@@ -875,6 +907,17 @@ export class ChatService {
|
|
|
|
|
|
// ⑥ 连接状态由 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);
|
|
|
@@ -1149,8 +1192,17 @@ export class ChatService {
|
|
|
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: `local-${Date.now()}`,
|
|
|
+ id: localId,
|
|
|
conversationId,
|
|
|
sender: 'me',
|
|
|
kind: 'text',
|
|
|
@@ -1158,6 +1210,7 @@ export class ChatService {
|
|
|
text: body || undefined,
|
|
|
ts: new Date().toISOString(),
|
|
|
state: 'sending',
|
|
|
+ localId,
|
|
|
};
|
|
|
if (body) this._messages.update((m) => [...m, msg]);
|
|
|
this._draft.set('');
|
|
|
@@ -1271,7 +1324,7 @@ export class ChatService {
|
|
|
return;
|
|
|
}
|
|
|
const relayed = await this.tryRelay(body, msg, conversationId);
|
|
|
- if (!relayed) await this.sendReal(body);
|
|
|
+ if (!relayed) await this.sendReal(body, msg.localId);
|
|
|
}
|
|
|
this.markSent(msg.id);
|
|
|
/*
|
|
|
@@ -1521,8 +1574,11 @@ export class ChatService {
|
|
|
* 分页后**保留完整已加载区间**(不做上限截断):
|
|
|
* 用户是在往上翻,超出窗口的内容用户正要看,截断会让人往前翻却看不到。
|
|
|
* 真正省开销的是首屏只取一屏 + 后续按需加载,而不是丢弃已加载内容。
|
|
|
+ *
|
|
|
+ * 🔴 但与本地乐观消息**合并**,不整表覆盖(2026-11-09):
|
|
|
+ * 翻页时还在发送中的消息不在 timeline 里,覆盖掉就凭空消失了。
|
|
|
*/
|
|
|
- this._messages.set(next);
|
|
|
+ this._messages.set(this.mergeTimeline(next, untracked(() => this._messages())));
|
|
|
// 条数未增长 = 已到最早,停止继续请求
|
|
|
this._hasMoreHistory.set(ok && next.length > before);
|
|
|
return this._hasMoreHistory();
|
|
|
@@ -1542,7 +1598,7 @@ export class ChatService {
|
|
|
if (environment.useMockData) {
|
|
|
await this.simulateSend();
|
|
|
} else if (msg.text) {
|
|
|
- await this.sendReal(msg.text);
|
|
|
+ await this.sendReal(msg.text, msg.localId);
|
|
|
}
|
|
|
this.markSent(messageId);
|
|
|
} catch (e) {
|
|
|
@@ -1567,7 +1623,7 @@ export class ChatService {
|
|
|
* ⚠️ 「历史对话」特例已于 2026-10-29 移除 —— 该会话类型不再存在,
|
|
|
* 所有发送一律带线程根,避免容器侧把消息切成新会话(容器失忆)。
|
|
|
*/
|
|
|
- private async sendReal(text: string): Promise<void> {
|
|
|
+ private async sendReal(text: string, localId?: string): Promise<void> {
|
|
|
if (!this.roomId) throw new Error('会话未就绪,请稍后重试');
|
|
|
let sid = this._sessionId();
|
|
|
if (!sid) {
|
|
|
@@ -1583,7 +1639,17 @@ export class ChatService {
|
|
|
const lastId = this._messages().at(-1)?.id;
|
|
|
// 自造的会话 id 还没有任何消息时,不填 m.in_reply_to(那是降级展示用的引用)
|
|
|
const last = lastId && !lastId.startsWith('local-') ? lastId : undefined;
|
|
|
- await this.mx.sendText(this.roomId, text, sid, last);
|
|
|
+ /*
|
|
|
+ * `localId` 随事件写进 content:服务端 echo 会原样带回(Matrix 允许任意额外字段),
|
|
|
+ * 回读时据此认领本地那条乐观消息,避免同一句话出现两遍。
|
|
|
+ */
|
|
|
+ await this.mx.sendText(
|
|
|
+ this.roomId,
|
|
|
+ text,
|
|
|
+ sid,
|
|
|
+ last,
|
|
|
+ localId ? { [LOCAL_ID_KEY]: localId } : undefined,
|
|
|
+ );
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
@@ -1615,6 +1681,14 @@ export class ChatService {
|
|
|
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;
|
|
|
@@ -1706,77 +1780,34 @@ export class ChatService {
|
|
|
): boolean {
|
|
|
if (!m.threadId) {
|
|
|
/*
|
|
|
- * 🔴 群聊派活的隔离(2026-11-06 用户裁决:单聊永远看不到)。
|
|
|
+ * 房间级消息(无 thread)的归属(2026-11-09 用户裁决调整)。
|
|
|
*
|
|
|
- * 群聊与单聊共用同一个 DM 房间,`sendToAgent` 发的是**房间级消息**。
|
|
|
- * 不拦的话,派活和容器回复都会被主会话认领 ——
|
|
|
- * 表现为「群里派了活,日常对话里也出现了同样的内容和回复」。
|
|
|
+ * 用户原话:「房间级的这个消息,可以丢弃掉,没有问题」。
|
|
|
*
|
|
|
- * ① 派活消息本身带 `dispatch` 标记(随事件持久化,刷新后仍有效)
|
|
|
- * ② 派活**之后**的房间级回复没有标记(容器不知道),靠 eventId 锚点判定
|
|
|
- */
|
|
|
- if (m.dispatch) return false;
|
|
|
- if (this.isAfterLastDispatch(m)) return false;
|
|
|
- /*
|
|
|
- * 单聊自己发起的房间级消息(图片/附件不带 thread)带 `sessionId` 标记,
|
|
|
- * 优先归属 —— 否则会被上一条规则误挡(派活之后用户从单聊发的图会消失)。
|
|
|
- */
|
|
|
- if (m.sessionId) return m.sessionId === sessionId;
|
|
|
- const list = this.sessions.listFor(this._agentId() ?? '', { includeArchived: true });
|
|
|
- // 新建态会话(messageCount 为 0 / undefined)不认领房间级历史
|
|
|
- const target = list.find((s) => s.id === sessionId);
|
|
|
- if (!target) return false;
|
|
|
- return (target.messageCount ?? 0) > 0;
|
|
|
- }
|
|
|
- if (m.threadId === sessionId) return true;
|
|
|
- /*
|
|
|
- * 无 thread 的消息 = **房间级消息**,归入该房间的「主会话」。
|
|
|
- *
|
|
|
- * ## 为什么需要这条规则(2026-11-03)
|
|
|
- *
|
|
|
- * 原来这里直接 `return false`,导致房间级消息(用户直接发的图片 `m.image`、
|
|
|
- * 容器早期的无 thread 回复)**在任何会话里都看不到**。
|
|
|
- *
|
|
|
- * ## 为什么不能一律归入「当前会话」(2026-11-04 回归修复)
|
|
|
- *
|
|
|
- * 上一版写成 `if (!m.threadId) return true`,结果是:
|
|
|
- * **新建一个空会话后,该房间所有历史消息都灌进来了**(用户实测:
|
|
|
- * 点「新建对话」,界面上立刻出现几天前的「容器上线自检」对话)。
|
|
|
- *
|
|
|
- * 正确口径:只有**已经承载过历史的那条会话**(即该房间的主会话)才认领
|
|
|
- * 房间级消息;刚新建、从没发过消息的空会话不认领任何历史。
|
|
|
- *
|
|
|
- * 判定方式:「主会话」= 该 agent 下最早创建且非新建态的那条。这里用
|
|
|
- * `sessions.listFor(agentId)` 的最后一条(按 updatedAt 正序的最新=最早创建)
|
|
|
- * 会随消息变动,所以改用更稳的判据:**该会话是否已有多条消息**。
|
|
|
- */
|
|
|
- if (!m.threadId) {
|
|
|
- /*
|
|
|
- * 🔴 群聊派活的隔离(2026-11-06 用户裁决:单聊永远看不到)。
|
|
|
+ * 背景:同一个 DM 房间里,App 的正常会话**都带 thread**;
|
|
|
+ * 只有两种情况会产生无 thread 的消息:
|
|
|
+ * ① 群聊派活(`sendToAgent` 发房间级)—— 有 `dispatch` 标记可识别
|
|
|
+ * ② **外部工具 / 脚本**直接往房间发的消息 —— 没有任何归属信息
|
|
|
*
|
|
|
- * 群聊与单聊共用同一个 DM 房间,`sendToAgent` 发的是**房间级消息**。
|
|
|
- * 不拦的话,派活和容器回复都会被主会话认领 ——
|
|
|
- * 表现为「群里派了活,日常对话里也出现了同样的内容和回复」。
|
|
|
+ * ② 是实测踩到的坑:用脚本调试容器发出的消息没有 thread,
|
|
|
+ * 结果被新建会话全部认领,表现为「新建会话出现一堆历史」。
|
|
|
*
|
|
|
- * ① 派活消息本身带 `dispatch` 标记(随事件持久化,刷新后仍有效)
|
|
|
- * ② 派活**之后**的房间级回复没有标记(容器不知道),靠 eventId 锚点判定
|
|
|
+ * 现在的规则:**一律不认领**。唯一例外是单聊自己发的房间级附件 ——
|
|
|
+ * 那种消息带 `sessionId` 标记,归属是明确的。
|
|
|
+ *
|
|
|
+ * 代价:容器历史上无 thread 的旧回复不再显示。这是有意的取舍 ——
|
|
|
+ * 宁可少显示,也不要把别处的消息错认成这个会话的。
|
|
|
*/
|
|
|
if (m.dispatch) return false;
|
|
|
- if (this.isAfterLastDispatch(m)) return false;
|
|
|
- /*
|
|
|
- * 单聊自己发起的房间级消息(图片/附件不带 thread)带 `sessionId` 标记,
|
|
|
- * 优先归属 —— 否则会被上一条规则误挡(派活之后用户从单聊发的图会消失)。
|
|
|
- */
|
|
|
if (m.sessionId) return m.sessionId === sessionId;
|
|
|
- const list = this.sessions.listFor(this._agentId() ?? '', { includeArchived: true });
|
|
|
- // 新建态会话(messageCount 为 0 / undefined)不认领房间级历史
|
|
|
- const target = list.find((s) => s.id === sessionId);
|
|
|
- if (!target) return false;
|
|
|
- return (target.messageCount ?? 0) > 0;
|
|
|
+ return false;
|
|
|
}
|
|
|
if (m.threadId === sessionId) return true;
|
|
|
/*
|
|
|
- * 🔴 **容器自己开的子线程,也算当前会话的内容**。
|
|
|
+ * 无 thread 的消息(房间级消息)已在函数开头处理完毕 ——
|
|
|
+ * 到这里 `m.threadId` 必有值。
|
|
|
+ *
|
|
|
+ * 🔴 **容器自己开的子线程,也算当前会话的内容**。
|
|
|
*
|
|
|
* 实测:容器在回复时会另开 `m.thread` 分支跑内部任务
|
|
|
* (调工具、跑浏览器、自我改进)。那些消息也是这次对话的一部分 ——
|
|
|
@@ -1801,7 +1832,6 @@ export class ChatService {
|
|
|
* **实时事件**(`live=true`)仍然接纳:那是容器刚刚发出的内容,
|
|
|
* 一定是当前这次对话的产出(截图、交付物都在里面)。
|
|
|
*/
|
|
|
- if (!m.threadId) return false;
|
|
|
if (live) return !isAppSessionId(m.threadId);
|
|
|
const roots = ownedRoots ?? this._ownedThreadRoots;
|
|
|
return roots.has(m.threadId);
|
|
|
@@ -1870,6 +1900,59 @@ export class ChatService {
|
|
|
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() ?? '';
|
|
|
@@ -1902,6 +1985,8 @@ export class ChatService {
|
|
|
: undefined,
|
|
|
ts: new Date(m.ts).toISOString(),
|
|
|
state: 'sent' as const,
|
|
|
+ // 透传本地 id:合并时用它认领乐观消息(见 mergeTimeline)
|
|
|
+ localId: m.localId,
|
|
|
};
|
|
|
});
|
|
|
}
|
|
|
@@ -1928,7 +2013,16 @@ export class ChatService {
|
|
|
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]);
|
|
|
+ /*
|
|
|
+ * 🔴 自己的 echo 到达时**替换**乐观消息,而不是追加(2026-11-09)。
|
|
|
+ * 否则「我发的那条」会以本地 / 实时两条并存。
|
|
|
+ */
|
|
|
+ const localId = mapped?.localId;
|
|
|
+ this._messages.update((list) => {
|
|
|
+ if (!localId) return [...list, mapped];
|
|
|
+ const rest = list.filter((x) => x.id !== localId);
|
|
|
+ return this.mergeTimeline([...rest, mapped], []);
|
|
|
+ });
|
|
|
this.rememberDemoHistory();
|
|
|
if (this._state() === 'empty') this._state.set('ready');
|
|
|
// 会话元信息(预览/标题/时间)随消息推进
|