fix(web): unify chat window cache keys and merge IndexedDB restore

Pass userId on every saveSessionWindowCache write, LRU-evict full cache
keys, and applyRestoredFileRaw so mount restore cannot drop newer deliverables.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
weichen
2026-09-17 16:42:56 +00:00
co-authored by Cursor
parent 54244ed97a
commit 2620d808c4
7 changed files with 277 additions and 27 deletions
+6 -7
View File
@@ -230,7 +230,7 @@ import { useUnreadMarks } from './hooks/useUnreadMarks';
import { CategoryDock } from './components/chat/CategoryDock';
import { fetchChatCategories, fetchChatDates, mapTaskStatusToMessageStatus } from './api/chatWindow';
import { ChatCategoryStat, ChatDateStat, QuotedMessage, AssistantMessageStatus } from './types/workbench';
import { saveFile, restoreFileRawToDeliverables } from './data/fileStore';
import { saveFile, restoreFileRawToDeliverables, applyRestoredFileRaw } from './data/fileStore';
import { uploadProjectAsset } from './api/projectAssets';
import { subscribeTaskRunEvents, createTaskRun, cancelTaskRun, getTaskRun } from './api/taskRuns';
@@ -333,14 +333,13 @@ export default function App() {
}, [deliverables]);
// ✅ 启动时从 IndexedDB 恢复上传文件的 fileRaw(解决页面刷新后 File 对象丢失问题)
// 仅在组件 mount 时执行一次;恢复成功后对 deliverables 做最小化 patch。
// 仅在组件 mount 时执行一次;恢复成功后对 deliverables 做最小化 patch(函数式合并,不覆盖期间新增)。
useEffect(() => {
let cancelled = false;
restoreFileRawToDeliverables(deliverables).then((restored) => {
if (!cancelled && restored !== deliverables) {
// 只有真正有内容被恢复时才更新 state(避免无意义的 re-render)
setDeliverables(restored);
}
const snapshot = deliverablesRef.current;
restoreFileRawToDeliverables(snapshot).then((restored) => {
if (cancelled || restored === snapshot) return;
setDeliverables((prev) => applyRestoredFileRaw(prev, restored));
});
return () => { cancelled = true; };
// eslint-disable-next-line react-hooks/exhaustive-deps
+95
View File
@@ -0,0 +1,95 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { applyRestoredFileRaw } from './fileStore';
import type { DeliverableItem } from '../types/workbench';
function baseItem(overrides: Partial<DeliverableItem> & { docId: string }): DeliverableItem {
return {
projectId: 1,
taskKey: 'intake',
title: '上传材料',
stage: 'V0.1',
category: 'private',
creatorId: 1,
creatorName: '测试',
isFixedWorkflow: false,
isTemplateOnly: false,
lifecycleState: 'drafting',
isClaimed: true,
author: '测试',
reviewer: '',
approver: '',
updatedAt: '2026-09-18',
sourceType: 'uploaded_file',
...overrides,
};
}
test('applyRestoredFileRaw: 以 prev 为权威,只补丁缺失 fileRaw,不丢新增条目', () => {
const blobA = new Blob(['a'], { type: 'text/plain' });
const blobB = new Blob(['b'], { type: 'text/plain' });
const mountSnapshot: DeliverableItem[] = [
baseItem({
docId: 'doc-a',
fileMeta: {
originalName: 'a.txt',
size: '1B',
mimeType: 'text/plain',
extension: 'txt',
uploadedAt: '2026-09-18',
},
}),
];
const restored = [
{
...mountSnapshot[0],
fileMeta: {
...mountSnapshot[0].fileMeta!,
fileRaw: blobA,
},
},
];
// resolve 前用户又新增了 doc-b,且 doc-a 仍缺 fileRaw
const prev: DeliverableItem[] = [
...mountSnapshot,
baseItem({
docId: 'doc-b',
title: '新上传',
fileMeta: {
originalName: 'b.txt',
size: '1B',
mimeType: 'text/plain',
extension: 'txt',
fileRaw: blobB,
uploadedAt: '2026-09-18',
},
}),
];
const merged = applyRestoredFileRaw(prev, restored);
assert.equal(merged.length, 2, 'must keep newly added deliverable');
assert.equal(merged[0].docId, 'doc-a');
assert.equal(merged[0].fileMeta?.fileRaw, blobA);
assert.equal(merged[1].docId, 'doc-b');
assert.equal(merged[1].fileMeta?.fileRaw, blobB);
});
test('applyRestoredFileRaw: 无可用 fileRaw 时返回同一引用', () => {
const prev = [
baseItem({
docId: 'doc-x',
fileMeta: {
originalName: 'x.txt',
size: '1B',
mimeType: 'text/plain',
extension: 'txt',
uploadedAt: '2026-09-18',
},
}),
];
const out = applyRestoredFileRaw(prev, prev);
assert.equal(out, prev);
});
+34
View File
@@ -181,3 +181,37 @@ export async function restoreFileRawToDeliverables(
};
});
}
/**
* 将 restore 快照中的 fileRaw 合并进当前 deliverables(函数式更新用)。
* 以 prev 为权威列表,仅按 docId 补丁缺失的 fileRaw;不丢弃 restore 期间新增的条目。
*/
export function applyRestoredFileRaw(
prev: DeliverableItem[],
restored: DeliverableItem[]
): DeliverableItem[] {
const restoredRawByDocId = new Map<string, Blob>();
for (const d of restored) {
const raw = d.fileMeta?.fileRaw;
if (raw) {
restoredRawByDocId.set(d.docId, raw);
}
}
if (restoredRawByDocId.size === 0) return prev;
let changed = false;
const next = prev.map((d) => {
if (!d.fileMeta || d.fileMeta.fileRaw) return d;
const blob = restoredRawByDocId.get(d.docId);
if (!blob) return d;
changed = true;
return {
...d,
fileMeta: {
...d.fileMeta,
fileRaw: blob,
},
};
});
return changed ? next : prev;
}
+7 -6
View File
@@ -161,14 +161,14 @@ function getDraftCacheKey(sessionId: string): string {
return `${DRAFT_CACHE_PREFIX}${sessionId}`;
}
function updateWindowLRU(sessionId: string): void {
function updateWindowLRU(cacheKey: string): void {
const current = safeGetItem<string[]>(WINDOW_LRU_KEY, []);
const updated = [sessionId, ...current.filter((id) => id !== sessionId)];
const updated = [cacheKey, ...current.filter((id) => id !== cacheKey)];
if (updated.length > MAX_CACHED_WINDOWS) {
const evicted = updated.slice(MAX_CACHED_WINDOWS);
evicted.forEach((id) => {
evicted.forEach((key) => {
if (typeof window !== 'undefined' && window.localStorage) {
window.localStorage.removeItem(getWindowCacheKey(id));
window.localStorage.removeItem(key);
}
});
}
@@ -180,8 +180,9 @@ export function loadSessionWindowCache(sessionId: string, userId?: number): Chat
}
export function saveSessionWindowCache(sessionId: string, buckets: ChatDayBucket[], userId?: number): void {
safeSetItem(getWindowCacheKey(sessionId, userId), buckets);
updateWindowLRU(sessionId);
const cacheKey = getWindowCacheKey(sessionId, userId);
safeSetItem(cacheKey, buckets);
updateWindowLRU(cacheKey);
}
export function clearSessionWindowCache(sessionId: string, userId?: number): void {
+101
View File
@@ -0,0 +1,101 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
saveSessionWindowCache,
loadSessionWindowCache,
clearSessionWindowCache,
} from './storage';
import type { ChatDayBucket } from '../types/workbench';
function installMemoryLocalStorage(): { store: Map<string, string>; restore: () => void } {
const store = new Map<string, string>();
const originalWindow = (globalThis as { window?: unknown }).window;
const localStorage = {
getItem(key: string) {
return store.has(key) ? store.get(key)! : null;
},
setItem(key: string, value: string) {
store.set(key, value);
},
removeItem(key: string) {
store.delete(key);
},
};
(globalThis as { window: unknown }).window = { localStorage };
return {
store,
restore: () => {
if (originalWindow === undefined) {
delete (globalThis as { window?: unknown }).window;
} else {
(globalThis as { window: unknown }).window = originalWindow;
}
},
};
}
function sampleBucket(dayKey: string): ChatDayBucket {
return {
dayKey,
count: 1,
loadedCount: 1,
truncated: false,
firstMessageId: 1,
lastMessageId: 1,
messages: [
{
id: `msg-${dayKey}`,
sessionId: 's',
role: 'user',
content: 'hi',
timestamp: '12:00',
},
],
};
}
test('window cache LRU: 驱逐带 userId 前缀的完整 cache key,不留孤儿条目', () => {
const { store, restore } = installMemoryLocalStorage();
try {
const userId = 42;
// MAX_CACHED_WINDOWS = 3;写入 4 个不同 session 应驱逐最早的带前缀 key
const sessions = ['sess-a', 'sess-b', 'sess-c', 'sess-d'];
for (const sid of sessions) {
saveSessionWindowCache(sid, [sampleBucket(`2026-09-18-${sid}`)], userId);
}
const prefixed = [...store.keys()].filter((k) => k.startsWith('smdf_chat_window_u'));
assert.equal(prefixed.length, 3, `expected 3 prefixed window keys, got ${prefixed.join(',')}`);
assert.equal(
loadSessionWindowCache('sess-a', userId),
null,
'oldest sess-a with user prefix must be evicted'
);
assert.ok(loadSessionWindowCache('sess-b', userId));
assert.ok(loadSessionWindowCache('sess-c', userId));
assert.ok(loadSessionWindowCache('sess-d', userId));
// 无前缀 key 不应被误认为存活缓存
assert.equal(store.has('smdf_chat_window_sess-a'), false);
assert.equal(store.has('smdf_chat_window_sess-d'), false);
clearSessionWindowCache('sess-d', userId);
assert.equal(loadSessionWindowCache('sess-d', userId), null);
} finally {
restore();
}
});
test('window cache: 读写同一 userId 前缀,跨用户不串读', () => {
const { restore } = installMemoryLocalStorage();
try {
saveSessionWindowCache('sess-shared', [sampleBucket('2026-09-18')], 1);
saveSessionWindowCache('sess-shared', [sampleBucket('2026-09-19')], 2);
const u1 = loadSessionWindowCache('sess-shared', 1);
const u2 = loadSessionWindowCache('sess-shared', 2);
assert.equal(u1?.[0]?.dayKey, '2026-09-18');
assert.equal(u2?.[0]?.dayKey, '2026-09-19');
} finally {
restore();
}
});
@@ -0,0 +1,20 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
test('useChatWindow: 所有 saveSessionWindowCache 调用必须传入 userId', () => {
const here = dirname(fileURLToPath(import.meta.url));
const src = readFileSync(join(here, 'useChatWindow.ts'), 'utf8');
const calls = [...src.matchAll(/saveSessionWindowCache\(([^)]*)\)/g)];
assert.ok(calls.length >= 8, `expected >=8 saveSessionWindowCache calls, got ${calls.length}`);
for (const m of calls) {
const args = m[1];
assert.match(
args,
/\buserId\b/,
`saveSessionWindowCache call must include userId: saveSessionWindowCache(${args})`
);
}
});
+14 -14
View File
@@ -232,7 +232,7 @@ export function useChatWindow({
if (removed) totalMsgs -= removed.loadedCount;
}
saveSessionWindowCache(sessionId, protectedStack);
saveSessionWindowCache(sessionId, protectedStack, userId);
return protectedStack;
});
@@ -247,7 +247,7 @@ export function useChatWindow({
setPhase('ready');
}
},
[hasMoreBefore, phase, oldestLoadedDay, sessionId]
[hasMoreBefore, phase, oldestLoadedDay, sessionId, userId]
);
// 加载当日全量消息
@@ -268,14 +268,14 @@ export function useChatWindow({
}
return b;
});
saveSessionWindowCache(sessionId, updated);
saveSessionWindowCache(sessionId, updated, userId);
return updated;
});
} catch (err) {
console.error('[useChatWindow] loadFullDay failed', err);
}
},
[sessionId]
[sessionId, userId]
);
// 日期跳转: 聚焦指定日期
@@ -312,7 +312,7 @@ export function useChatWindow({
if (next.length > MAX_WINDOW_DAYS_STACK) {
next.splice(0, next.length - MAX_WINDOW_DAYS_STACK);
}
saveSessionWindowCache(sessionId, next);
saveSessionWindowCache(sessionId, next, userId);
return next;
});
@@ -329,7 +329,7 @@ export function useChatWindow({
console.warn(`[useChatWindow] jumpToDate failed for ${dayKey}:`, err);
}
},
[sessionId, dayStack]
[sessionId, userId, dayStack]
);
// 跨日锚点拉取: 根据 messageId 拉取所属日分片并合并进窗口
@@ -362,7 +362,7 @@ export function useChatWindow({
if (next.length > MAX_WINDOW_DAYS_STACK) {
next.splice(0, next.length - MAX_WINDOW_DAYS_STACK);
}
saveSessionWindowCache(sessionId, next);
saveSessionWindowCache(sessionId, next, userId);
return next;
});
return true;
@@ -372,7 +372,7 @@ export function useChatWindow({
}
return false;
},
[sessionId, dayStack]
[sessionId, userId, dayStack]
);
// 追加新消息 (SSE 或用户发送)
@@ -413,11 +413,11 @@ export function useChatWindow({
updated.sort((a, b) => (a.dayKey < b.dayKey ? -1 : 1));
}
saveSessionWindowCache(sessionId, updated);
saveSessionWindowCache(sessionId, updated, userId);
return updated;
});
},
[sessionId]
[sessionId, userId]
);
// 原地更新指定消息
@@ -436,13 +436,13 @@ export function useChatWindow({
return bucket;
});
if (changed) {
saveSessionWindowCache(sessionId, updated);
saveSessionWindowCache(sessionId, updated, userId);
return updated;
}
return prev;
});
},
[sessionId]
[sessionId, userId]
);
// 将流式临时 ID 替换为服务端落库真实 serverId
@@ -468,13 +468,13 @@ export function useChatWindow({
return bucket;
});
if (changed) {
saveSessionWindowCache(sessionId, updated);
saveSessionWindowCache(sessionId, updated, userId);
return updated;
}
return prev;
});
},
[sessionId]
[sessionId, userId]
);
// 将 liveMessages (来自组件层实时状态,如刚发送的 user 消息或 streaming 中的 assistant 气泡)