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:
+6
-7
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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})`
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -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 气泡)
|
||||
|
||||
Reference in New Issue
Block a user