|
@@ -123,6 +123,15 @@ export class ChatSessionService {
|
|
|
*/
|
|
*/
|
|
|
private readonly lastPushAt = new Map<string, number>();
|
|
private readonly lastPushAt = new Map<string, number>();
|
|
|
|
|
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 本地已建、但还没确认推到云端的会话 id。
|
|
|
|
|
+ *
|
|
|
|
|
+ * 用途很窄:`hydrateFromCloud` 整体替换列表时,这些会话在云端还不存在,
|
|
|
|
|
+ * 必须保留 —— 否则「新建对话 → 换 URL → 重新拉取云端」会把刚建的会话抹掉。
|
|
|
|
|
+ * 推送成功后立即移除,避免长期存在。
|
|
|
|
|
+ */
|
|
|
|
|
+ private readonly _pendingPush = new Set<string>();
|
|
|
|
|
+
|
|
|
/**
|
|
/**
|
|
|
* 从 localStorage 载入(Shell 启动时调用一次;重复调用幂等)。
|
|
* 从 localStorage 载入(Shell 启动时调用一次;重复调用幂等)。
|
|
|
*
|
|
*
|
|
@@ -276,9 +285,22 @@ export class ChatSessionService {
|
|
|
/*
|
|
/*
|
|
|
* 用「该容器」的云端结果**整体替换**本地该容器的条目,其它容器不动。
|
|
* 用「该容器」的云端结果**整体替换**本地该容器的条目,其它容器不动。
|
|
|
* 这里没有任何「本地有、云端没有 → 保留」的分支 —— 那正是脏数据来源。
|
|
* 这里没有任何「本地有、云端没有 → 保留」的分支 —— 那正是脏数据来源。
|
|
|
|
|
+ *
|
|
|
|
|
+ * 🔴 唯一例外:**还没同步到云端的本地新建会话**(2026-11-01)。
|
|
|
|
|
+ *
|
|
|
|
|
+ * `create()` 之后是异步推云端的,而新建对话紧接着就会换 URL → 重新
|
|
|
|
|
+ * `open()` → 又拉一次云端。那一刻云端列表里还没有这条,
|
|
|
|
|
+ * 若按「云端没有就丢弃」处理,刚建出来的会话**当场消失**
|
|
|
|
|
+ * (用户实测:点了新建,聊天区是新的但左侧列表没有内容)。
|
|
|
|
|
+ *
|
|
|
|
|
+ * 只保留「本地有、且明确处于待同步状态」的条目,范围极小,
|
|
|
|
|
+ * 不会重新引入「本地脏数据灌回列表」的问题。
|
|
|
*/
|
|
*/
|
|
|
|
|
+ const pending = this._all().filter(
|
|
|
|
|
+ (s) => s.agentId === agentId && this._pendingPush.has(s.id) && !next.some((n) => n.id === s.id),
|
|
|
|
|
+ );
|
|
|
const others = this._all().filter((s) => s.agentId !== agentId);
|
|
const others = this._all().filter((s) => s.agentId !== agentId);
|
|
|
- this._all.set(sortSessions([...others, ...next]));
|
|
|
|
|
|
|
+ this._all.set(sortSessions([...others, ...next, ...pending]));
|
|
|
this.persist();
|
|
this.persist();
|
|
|
this._ready.set(true);
|
|
this._ready.set(true);
|
|
|
this._error.set(null);
|
|
this._error.set(null);
|
|
@@ -303,6 +325,8 @@ export class ChatSessionService {
|
|
|
const list = this._all().filter((s) => s.agentId === agentId && !s.legacy);
|
|
const list = this._all().filter((s) => s.agentId === agentId && !s.legacy);
|
|
|
if (list.length === 0) return;
|
|
if (list.length === 0) return;
|
|
|
await this.trySync(() => this.api!.upsertChatThreads(list.map(toUpsert)));
|
|
await this.trySync(() => this.api!.upsertChatThreads(list.map(toUpsert)));
|
|
|
|
|
+ // 整批推成功后,这些会话云端已存在,撤销「待同步」标记
|
|
|
|
|
+ for (const s of list) this._pendingPush.delete(s.id);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
/**
|
|
@@ -323,7 +347,22 @@ export class ChatSessionService {
|
|
|
/** 单条推送到云端(新建 / 变更后调用) */
|
|
/** 单条推送到云端(新建 / 变更后调用) */
|
|
|
private pushOne(s: ChatSession | undefined): void {
|
|
private pushOne(s: ChatSession | undefined): void {
|
|
|
if (!s || s.legacy) return;
|
|
if (!s || s.legacy) return;
|
|
|
- void this.trySync(() => this.api!.upsertChatThreads([toUpsert(s)]));
|
|
|
|
|
|
|
+ /*
|
|
|
|
|
+ * 🔴 先登记「待同步」再推送(2026-11-01)。
|
|
|
|
|
+ *
|
|
|
|
|
+ * 推送是异步的,期间若发生 `hydrateFromCloud`(新建对话会立刻换 URL 触发),
|
|
|
|
|
+ * 云端还没有这条 → 会被当成「云端已删除」抹掉。登记后 hydrate 会保留它。
|
|
|
|
|
+ */
|
|
|
|
|
+ this._pendingPush.add(s.id);
|
|
|
|
|
+ void (async () => {
|
|
|
|
|
+ try {
|
|
|
|
|
+ await this.api!.upsertChatThreads([toUpsert(s)]);
|
|
|
|
|
+ // 成功才撤销标记:失败时云端仍没有这条,hydrate 必须继续保留它
|
|
|
|
|
+ this._pendingPush.delete(s.id);
|
|
|
|
|
+ } catch {
|
|
|
|
|
+ /* 离线/服务异常:保留标记,等下次 hydrate 或 pushAll 再补 */
|
|
|
|
|
+ }
|
|
|
|
|
+ })();
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
/**
|