|
|
@@ -544,15 +544,20 @@ export class ChatSessionService {
|
|
|
const cur = this.byId(id);
|
|
|
if (!cur) return;
|
|
|
/*
|
|
|
- * 🔴 用 **upsert 而不是 patch**(2026-11-01 修复)。
|
|
|
+ * 🔴 **未落库的会话只改本地,不发请求**(2026-11-01 用户裁决)。
|
|
|
*
|
|
|
- * `patch` 要求云端已有这条记录;而「新建对话 → 立刻移动工作区」时
|
|
|
- * 云端记录可能还没落地(create 是异步推的),patch 返回 404 被当成成功,
|
|
|
- * 归属**压根没写进云端**。下次拉取时云端 workspaceId 为空 →
|
|
|
- * 会话掉回「日常对话」(用户实测:工作区里新建的对话跑到日常去了)。
|
|
|
+ * 持久化的唯一前提是「该会话里发过消息」。新建对话刚建出来(还没发消息)时
|
|
|
+ * 就被 `startBlank()` 调 `moveTo` 挂到工作区下 —— 若这里直接 upsert,
|
|
|
+ * 等于**点一次新建就在云端留一条记录**,连点几次就堆一堆空会话
|
|
|
+ * (用户实测:多次新建后,前几次的都持久化了)。
|
|
|
*
|
|
|
- * upsert 是幂等的:有就更新、没有就创建,两种时序都正确。
|
|
|
+ * 归属先记在本地,等首次发消息触发 `commit()` 时随会话一起落库。
|
|
|
+ *
|
|
|
+ * 已落库的会话走 upsert(不是 patch):patch 要求云端已有记录,
|
|
|
+ * 「新建 → 立刻移动工作区」时可能还没落地,patch 会 404 被静默吞掉,
|
|
|
+ * 归属压根没写进云端 → 下次拉取掉回「日常对话」。
|
|
|
*/
|
|
|
+ if (this._pendingPush.has(id)) return;
|
|
|
void this.trySync(() => this.api!.upsertChatThreads([toUpsert(cur)]));
|
|
|
}
|
|
|
|
|
|
@@ -595,49 +600,42 @@ export class ChatSessionService {
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
- * 清理**空壳会话**(`messageCount === 0`),保留 `keepId`。
|
|
|
+ * 收敛「新建对话」空壳:**整个列表最多只留一条**。
|
|
|
*
|
|
|
- * 2026-11-01 重新引入:新建对话改为「点击即建」(左侧要立刻显示并选中),
|
|
|
- * 代价是用户点了不发消息就切走会留下空壳。这里在**打开容器**和
|
|
|
- * **切换会话**时收敛,只保留当前正在看的那条。
|
|
|
+ * 2026-11-01 用户裁决:
|
|
|
+ * 新建对话是「点击即建」(左侧立刻可见并选中),但持久化的唯一前提是
|
|
|
+ * **在该会话里发过消息**。所以:
|
|
|
+ * · 「新建对话」在列表里只能有一条 —— 不论在哪个工作区、哪个容器新建,
|
|
|
+ * 一旦新建,**之前所有没发过消息的空会话一律清掉**;
|
|
|
+ * · 不需要等 60 秒,也不需要定时器 —— 新建/切换的那一刻就收敛。
|
|
|
*
|
|
|
- * 同时清掉云端对应记录(软删),否则下次 `hydrateFromCloud` 会把它们拉回来。
|
|
|
+ * 实现上区分两类空壳:
|
|
|
+ * · **从未落库**(`_pendingPush`)→ 删本地即可,不发云端请求(云端本来就没有);
|
|
|
+ * · **已落库**(发过消息后又被清空)→ 删本地 + 云端软删,否则下次拉取会回来。
|
|
|
*
|
|
|
- * @param keepId 当前正在看的会话,必须保留(它也是 0 条,但用户正要发消息)
|
|
|
+ * @param keepId 要保留的那条(通常是刚新建/正在看的会话)
|
|
|
*/
|
|
|
pruneEmpty(keepId?: string): void {
|
|
|
- const now = Date.now();
|
|
|
const victims = this._all().filter(
|
|
|
- (s) =>
|
|
|
- !s.legacy &&
|
|
|
- s.id !== keepId &&
|
|
|
- (s.messageCount ?? 0) === 0 &&
|
|
|
- /*
|
|
|
- * 🔴 刚创建的空会话**不参与清理**(2026-11-01)。
|
|
|
- *
|
|
|
- * 新建对话是「点击即建」,建出来就是一个 0 条消息的会话;
|
|
|
- * 紧接着换 URL 会触发第二轮 `onRouteChange` → `open()` →
|
|
|
- * 又一次 `pruneEmpty`。那一轮的 `keepId` 若因为路由时序还没指向它,
|
|
|
- * 这条刚建好的会话就被**当场当作空壳删掉**
|
|
|
- * (用户实测:聊天区是新的,左侧列表里却没有)。
|
|
|
- *
|
|
|
- * 给新会话 60 秒保护期:足够熬过 URL 切换与首次同步,
|
|
|
- * 又不会让空壳长期堆积(超过 60 秒还没发消息的仍会被清掉)。
|
|
|
- */
|
|
|
- now - s.createdAt > 60_000,
|
|
|
+ (s) => !s.legacy && s.id !== keepId && (s.messageCount ?? 0) === 0,
|
|
|
);
|
|
|
if (victims.length === 0) return;
|
|
|
+
|
|
|
const dead = new Set(victims.map((s) => s.id));
|
|
|
this._all.update((list) => list.filter((x) => !dead.has(x.id)));
|
|
|
this.persist();
|
|
|
- // 云端同步删除,避免下次拉取又出现
|
|
|
+
|
|
|
for (const v of victims) {
|
|
|
+ if (this._pendingPush.has(v.id)) {
|
|
|
+ // 从没写过云端 → 撤销标记即可,不发请求
|
|
|
+ this._pendingPush.delete(v.id);
|
|
|
+ continue;
|
|
|
+ }
|
|
|
void this.trySync(() =>
|
|
|
this.api!.removeChatThread({ threadRoot: v.id, roomId: v.roomId, agentId: v.agentId }),
|
|
|
);
|
|
|
}
|
|
|
}
|
|
|
-
|
|
|
/**
|
|
|
* 直接设置消息计数与最后活动时间。
|
|
|
*
|