From 696d19298ea08dfd569524c6000a8ae8fb16dce4 Mon Sep 17 00:00:00 2001 From: mysoul12138 <839465496@qq.com> Date: Thu, 23 Apr 2026 07:35:05 +0800 Subject: [PATCH] fix: avoid localStorage quota errors when switching chats\n\n- keep default profile legacy cache read compatibility\n- stop duplicating bulky session/message/in-flight cache into legacy keys\n- add best-effort quota recovery before persisting active session\n- cover legacy cache migration in chat store tests (#137) --- packages/client/src/stores/hermes/chat.ts | 103 +++++++++++++++++++--- tests/client/chat-store.test.ts | 38 ++++++++ 2 files changed, 128 insertions(+), 13 deletions(-) diff --git a/packages/client/src/stores/hermes/chat.ts b/packages/client/src/stores/hermes/chat.ts index 20283f88..29809411 100644 --- a/packages/client/src/stores/hermes/chat.ts +++ b/packages/client/src/stores/hermes/chat.ts @@ -164,6 +164,8 @@ function mapHermesSession(s: SessionSummary): Session { // every time they open the page (esp. noticeable on mobile). const STORAGE_KEY_PREFIX = 'hermes_active_session_' const SESSIONS_CACHE_KEY_PREFIX = 'hermes_sessions_cache_v1_' +const LEGACY_STORAGE_KEY = 'hermes_active_session' +const LEGACY_SESSIONS_CACHE_KEY = 'hermes_sessions_cache_v1' const IN_FLIGHT_TTL_MS = 15 * 60 * 1000 // Give up after 15 minutes const POLL_INTERVAL_MS = 2000 const POLL_STABLE_EXITS = 3 // 3 × 2s = 6s of no change → assume run finished @@ -183,6 +185,10 @@ function storageKey(): string { return STORAGE_KEY_PREFIX + getProfileName() } function sessionsCacheKey(): string { return SESSIONS_CACHE_KEY_PREFIX + getProfileName() } function msgsCacheKey(sid: string): string { return `hermes_session_msgs_v1_${getProfileName()}_${sid}_` } function inFlightKey(sid: string): string { return `hermes_in_flight_v1_${getProfileName()}_${sid}` } +function legacyStorageKey(): string | null { return getProfileName() === 'default' ? LEGACY_STORAGE_KEY : null } +function legacySessionsCacheKey(): string | null { return getProfileName() === 'default' ? LEGACY_SESSIONS_CACHE_KEY : null } +function legacyMsgsCacheKey(sid: string): string | null { return getProfileName() === 'default' ? `hermes_session_msgs_v1_${sid}` : null } +function legacyInFlightKey(sid: string): string | null { return getProfileName() === 'default' ? `hermes_in_flight_v1_${sid}` : null } interface InFlightRun { runId: string @@ -198,9 +204,60 @@ function loadJson(key: string): T | null { } } +function isQuotaExceededError(error: unknown): boolean { + if (!error || typeof error !== 'object') return false + const e = error as { name?: string, code?: number } + return e.name === 'QuotaExceededError' || e.code === 22 || e.code === 1014 +} + +function recoverStorageQuota() { + try { + const prefixes = [ + sessionsCacheKey(), + `hermes_session_msgs_v1_${getProfileName()}_`, + `hermes_in_flight_v1_${getProfileName()}_`, + ] + const legacySessions = legacySessionsCacheKey() + if (legacySessions) prefixes.push(legacySessions) + if (getProfileName() === 'default') { + prefixes.push('hermes_session_msgs_v1_') + prefixes.push('hermes_in_flight_v1_') + } + const keysToRemove: string[] = [] + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i) + if (!key) continue + if (key === storageKey() || key === LEGACY_STORAGE_KEY) continue + if (prefixes.some(prefix => key.startsWith(prefix))) { + keysToRemove.push(key) + } + } + keysToRemove.forEach(key => removeItem(key)) + } catch { + // ignore + } +} + +function setItemBestEffort(key: string, value: string) { + try { + localStorage.setItem(key, value) + return + } catch (error) { + if (!isQuotaExceededError(error)) return + } + + recoverStorageQuota() + + try { + localStorage.setItem(key, value) + } catch { + // quota exceeded or private mode — ignore, cache is best-effort + } +} + function saveJson(key: string, value: unknown) { try { - localStorage.setItem(key, JSON.stringify(value)) + setItemBestEffort(key, JSON.stringify(value)) } catch { // quota exceeded or private mode — ignore, cache is best-effort } @@ -214,6 +271,23 @@ function removeItem(key: string) { } } +function loadJsonWithFallback(key: string, legacyKey?: string | null): T | null { + const value = loadJson(key) + if (value != null) return value + if (!legacyKey) return null + return loadJson(legacyKey) +} + +function saveJsonWithLegacy(key: string, value: unknown, legacyKey?: string | null) { + saveJson(key, value) + if (legacyKey) removeItem(legacyKey) +} + +function removeItemWithLegacy(key: string, legacyKey?: string | null) { + removeItem(key) + if (legacyKey) removeItem(legacyKey) +} + // Strip the circular `file: File` reference from attachments before caching — // File objects don't serialize and we only need name/type/size/url for display. function sanitizeForCache(msgs: Message[]): Message[] { @@ -255,9 +329,10 @@ export const useChatStore = defineStore('chat', () => { function persistSessionsList() { // Cache lightweight summaries only (messages are cached per-session). - saveJson( + saveJsonWithLegacy( sessionsCacheKey(), sessions.value.map(s => ({ ...s, messages: [] })), + legacySessionsCacheKey(), ) } @@ -265,22 +340,22 @@ export const useChatStore = defineStore('chat', () => { const sid = activeSessionId.value if (!sid) return const s = sessions.value.find(sess => sess.id === sid) - if (s) saveJson(msgsCacheKey(sid), sanitizeForCache(s.messages)) + if (s) saveJsonWithLegacy(msgsCacheKey(sid), sanitizeForCache(s.messages), legacyMsgsCacheKey(sid)) } function markInFlight(sid: string, runId: string) { - saveJson(inFlightKey(sid), { runId, startedAt: Date.now() } as InFlightRun) + saveJsonWithLegacy(inFlightKey(sid), { runId, startedAt: Date.now() } as InFlightRun, legacyInFlightKey(sid)) } function clearInFlight(sid: string) { - removeItem(inFlightKey(sid)) + removeItemWithLegacy(inFlightKey(sid), legacyInFlightKey(sid)) } function readInFlight(sid: string): InFlightRun | null { - const rec = loadJson(inFlightKey(sid)) + const rec = loadJsonWithFallback(inFlightKey(sid), legacyInFlightKey(sid)) if (!rec) return null if (Date.now() - rec.startedAt > IN_FLIGHT_TTL_MS) { - removeItem(inFlightKey(sid)) + removeItemWithLegacy(inFlightKey(sid), legacyInFlightKey(sid)) return null } return rec @@ -379,14 +454,14 @@ export const useChatStore = defineStore('chat', () => { isLoadingSessions.value = true try { // 从 profile 对应的缓存中恢复,实现 instant render - const cachedSessions = loadJson(sessionsCacheKey()) + const cachedSessions = loadJsonWithFallback(sessionsCacheKey(), legacySessionsCacheKey()) if (cachedSessions?.length) { sessions.value = cachedSessions - const savedId = localStorage.getItem(storageKey()) + const savedId = localStorage.getItem(storageKey()) || (legacyStorageKey() ? localStorage.getItem(legacyStorageKey()!) : null) if (savedId) { const cachedActive = cachedSessions.find(s => s.id === savedId) || null if (cachedActive) { - const cachedMsgs = loadJson(msgsCacheKey(savedId)) + const cachedMsgs = loadJsonWithFallback(msgsCacheKey(savedId), legacyMsgsCacheKey(savedId)) if (cachedMsgs) cachedActive.messages = cachedMsgs activeSession.value = cachedActive activeSessionId.value = savedId @@ -470,7 +545,9 @@ export const useChatStore = defineStore('chat', () => { async function switchSession(sessionId: string, focusId?: string | null) { activeSessionId.value = sessionId focusMessageId.value = focusId ?? null - localStorage.setItem(storageKey(), sessionId) + setItemBestEffort(storageKey(), sessionId) + const legacyActiveKey = legacyStorageKey() + if (legacyActiveKey) removeItem(legacyActiveKey) activeSession.value = sessions.value.find(s => s.id === sessionId) || null if (!activeSession.value) return @@ -480,7 +557,7 @@ export const useChatStore = defineStore('chat', () => { // loading state while we fetch. const hasLocalMessages = activeSession.value.messages.length > 0 if (!hasLocalMessages) { - const cachedMsgs = loadJson(msgsCacheKey(sessionId)) + const cachedMsgs = loadJsonWithFallback(msgsCacheKey(sessionId), legacyMsgsCacheKey(sessionId)) if (cachedMsgs?.length) { activeSession.value.messages = cachedMsgs } @@ -581,7 +658,7 @@ export const useChatStore = defineStore('chat', () => { async function deleteSession(sessionId: string) { await deleteSessionApi(sessionId) sessions.value = sessions.value.filter(s => s.id !== sessionId) - removeItem(msgsCacheKey(sessionId)) + removeItemWithLegacy(msgsCacheKey(sessionId), legacyMsgsCacheKey(sessionId)) persistSessionsList() if (activeSessionId.value === sessionId) { if (sessions.value.length > 0) { diff --git a/tests/client/chat-store.test.ts b/tests/client/chat-store.test.ts index 0004e142..a47e8ebb 100644 --- a/tests/client/chat-store.test.ts +++ b/tests/client/chat-store.test.ts @@ -56,8 +56,11 @@ async function flushPromises() { const PROFILE = 'default' const ACTIVE_SESSION_KEY = `hermes_active_session_${PROFILE}` const SESSIONS_CACHE_KEY = `hermes_sessions_cache_v1_${PROFILE}` +const LEGACY_ACTIVE_SESSION_KEY = 'hermes_active_session' +const LEGACY_SESSIONS_CACHE_KEY = 'hermes_sessions_cache_v1' const sessionMessagesKey = (sessionId: string) => `hermes_session_msgs_v1_${PROFILE}_${sessionId}_` const inFlightKey = (sessionId: string) => `hermes_in_flight_v1_${PROFILE}_${sessionId}` +const legacySessionMessagesKey = (sessionId: string) => `hermes_session_msgs_v1_${sessionId}` describe('Chat Store', () => { beforeEach(() => { @@ -131,6 +134,41 @@ describe('Chat Store', () => { ) }) + it('hydrates from default-profile legacy cache and migrates bulky storage to new keys only', async () => { + const cachedSession = { + id: 'legacy-1', + title: 'Legacy Draft', + source: 'api_server', + messages: [], + createdAt: 1, + updatedAt: 1, + } + const cachedMessages = [ + { id: 'm1', role: 'user', content: 'legacy draft', timestamp: 1 }, + ] + + window.localStorage.setItem(LEGACY_ACTIVE_SESSION_KEY, 'legacy-1') + window.localStorage.setItem(LEGACY_SESSIONS_CACHE_KEY, JSON.stringify([cachedSession])) + window.localStorage.setItem(legacySessionMessagesKey('legacy-1'), JSON.stringify(cachedMessages)) + + mockSessionsApi.fetchSessions.mockResolvedValue([makeSummary('legacy-1', 'Legacy Draft')]) + mockSessionsApi.fetchSession.mockResolvedValue(makeDetail('legacy-1', cachedMessages)) + + const store = useChatStore() + await store.loadSessions() + + expect(store.activeSessionId).toBe('legacy-1') + expect(store.messages.map(m => m.content)).toEqual(['legacy draft']) + + expect(window.localStorage.getItem(ACTIVE_SESSION_KEY)).toBe('legacy-1') + expect(window.localStorage.getItem(SESSIONS_CACHE_KEY)).toBeTruthy() + expect(window.localStorage.getItem(sessionMessagesKey('legacy-1'))).toBeTruthy() + + expect(window.localStorage.getItem(LEGACY_ACTIVE_SESSION_KEY)).toBeNull() + expect(window.localStorage.getItem(LEGACY_SESSIONS_CACHE_KEY)).toBeNull() + expect(window.localStorage.getItem(legacySessionMessagesKey('legacy-1'))).toBeNull() + }) + it('silently refreshes from server on SSE error instead of appending a fake error bubble', async () => { vi.useFakeTimers()