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)

This commit is contained in:
mysoul12138
2026-04-23 07:35:05 +08:00
committed by GitHub
parent 5537383bdb
commit 696d19298e
2 changed files with 128 additions and 13 deletions
+90 -13
View File
@@ -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<T>(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<T>(key: string, legacyKey?: string | null): T | null {
const value = loadJson<T>(key)
if (value != null) return value
if (!legacyKey) return null
return loadJson<T>(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<InFlightRun>(inFlightKey(sid))
const rec = loadJsonWithFallback<InFlightRun>(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<Session[]>(sessionsCacheKey())
const cachedSessions = loadJsonWithFallback<Session[]>(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<Message[]>(msgsCacheKey(savedId))
const cachedMsgs = loadJsonWithFallback<Message[]>(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<Message[]>(msgsCacheKey(sessionId))
const cachedMsgs = loadJsonWithFallback<Message[]>(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) {
+38
View File
@@ -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()