mirror of
https://github.com/EKKOLearnAI/hermes-web-ui.git
synced 2026-05-27 14:30:18 +00:00
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:
@@ -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) {
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user