From 2620d808c4bf0c9a1698c01eb21fe23bdd45f809 Mon Sep 17 00:00:00 2001 From: weichen Date: Thu, 17 Sep 2026 16:42:56 +0000 Subject: [PATCH] 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 --- web/src/App.tsx | 13 ++- web/src/data/fileStore.test.ts | 95 +++++++++++++++++ web/src/data/fileStore.ts | 34 +++++++ web/src/data/storage.ts | 13 +-- web/src/data/storage.windowCache.test.ts | 101 +++++++++++++++++++ web/src/hooks/useChatWindow.cacheKey.test.ts | 20 ++++ web/src/hooks/useChatWindow.ts | 28 ++--- 7 files changed, 277 insertions(+), 27 deletions(-) create mode 100644 web/src/data/fileStore.test.ts create mode 100644 web/src/data/storage.windowCache.test.ts create mode 100644 web/src/hooks/useChatWindow.cacheKey.test.ts diff --git a/web/src/App.tsx b/web/src/App.tsx index 34f13329..3df7ab6c 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -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 diff --git a/web/src/data/fileStore.test.ts b/web/src/data/fileStore.test.ts new file mode 100644 index 00000000..c02d092c --- /dev/null +++ b/web/src/data/fileStore.test.ts @@ -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 & { 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); +}); diff --git a/web/src/data/fileStore.ts b/web/src/data/fileStore.ts index c9471b92..9a085874 100644 --- a/web/src/data/fileStore.ts +++ b/web/src/data/fileStore.ts @@ -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(); + 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; +} diff --git a/web/src/data/storage.ts b/web/src/data/storage.ts index 3759020b..85babdf9 100644 --- a/web/src/data/storage.ts +++ b/web/src/data/storage.ts @@ -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(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 { diff --git a/web/src/data/storage.windowCache.test.ts b/web/src/data/storage.windowCache.test.ts new file mode 100644 index 00000000..688eea75 --- /dev/null +++ b/web/src/data/storage.windowCache.test.ts @@ -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; restore: () => void } { + const store = new Map(); + 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(); + } +}); diff --git a/web/src/hooks/useChatWindow.cacheKey.test.ts b/web/src/hooks/useChatWindow.cacheKey.test.ts new file mode 100644 index 00000000..808c91c2 --- /dev/null +++ b/web/src/hooks/useChatWindow.cacheKey.test.ts @@ -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})` + ); + } +}); diff --git a/web/src/hooks/useChatWindow.ts b/web/src/hooks/useChatWindow.ts index 47170dbb..3252a9f8 100644 --- a/web/src/hooks/useChatWindow.ts +++ b/web/src/hooks/useChatWindow.ts @@ -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 气泡)