perf(chat): add local session list and preview cache hydration

This commit is contained in:
tisonhuang
2026-03-01 18:55:39 +08:00
parent a87d419868
commit ac61ee1833

View File

@@ -142,6 +142,35 @@ function cleanMessageContent(content: string): string {
return content.trim() return content.trim()
} }
const CHAT_SESSION_LIST_CACHE_TTL_MS = 24 * 60 * 60 * 1000
const CHAT_SESSION_PREVIEW_CACHE_TTL_MS = 24 * 60 * 60 * 1000
const CHAT_SESSION_PREVIEW_LIMIT_PER_SESSION = 30
const CHAT_SESSION_PREVIEW_MAX_SESSIONS = 18
function buildChatSessionListCacheKey(scope: string): string {
return `weflow.chat.sessions.v1::${scope || 'default'}`
}
function buildChatSessionPreviewCacheKey(scope: string): string {
return `weflow.chat.preview.v1::${scope || 'default'}`
}
function normalizeChatCacheScope(dbPath: unknown, wxid: unknown): string {
const db = String(dbPath || '').trim()
const id = String(wxid || '').trim()
if (!db && !id) return 'default'
return `${db}::${id}`
}
function safeParseJson<T>(raw: string | null): T | null {
if (!raw) return null
try {
return JSON.parse(raw) as T
} catch {
return null
}
}
function formatYmdDateFromSeconds(timestamp?: number): string { function formatYmdDateFromSeconds(timestamp?: number): string {
if (!timestamp || !Number.isFinite(timestamp)) return '—' if (!timestamp || !Number.isFinite(timestamp)) return '—'
const d = new Date(timestamp * 1000) const d = new Date(timestamp * 1000)
@@ -178,6 +207,21 @@ interface SessionDetail {
messageTables: { dbName: string; tableName: string; count: number }[] messageTables: { dbName: string; tableName: string; count: number }[]
} }
interface SessionListCachePayload {
updatedAt: number
sessions: ChatSession[]
}
interface SessionPreviewCacheEntry {
updatedAt: number
messages: Message[]
}
interface SessionPreviewCachePayload {
updatedAt: number
entries: Record<string, SessionPreviewCacheEntry>
}
// 全局头像加载队列管理器已移至 src/utils/AvatarLoadQueue.ts // 全局头像加载队列管理器已移至 src/utils/AvatarLoadQueue.ts
// 全局头像加载队列管理器已移至 src/utils/AvatarLoadQueue.ts // 全局头像加载队列管理器已移至 src/utils/AvatarLoadQueue.ts
import { avatarLoadQueue } from '../utils/AvatarLoadQueue' import { avatarLoadQueue } from '../utils/AvatarLoadQueue'
@@ -406,6 +450,10 @@ function ChatPage(_props: ChatPageProps) {
const preloadImageKeysRef = useRef<Set<string>>(new Set()) const preloadImageKeysRef = useRef<Set<string>>(new Set())
const lastPreloadSessionRef = useRef<string | null>(null) const lastPreloadSessionRef = useRef<string | null>(null)
const detailRequestSeqRef = useRef(0) const detailRequestSeqRef = useRef(0)
const chatCacheScopeRef = useRef('default')
const previewCacheRef = useRef<Record<string, SessionPreviewCacheEntry>>({})
const previewPersistTimerRef = useRef<number | null>(null)
const sessionListPersistTimerRef = useRef<number | null>(null)
// 加载当前用户头像 // 加载当前用户头像
const loadMyAvatar = useCallback(async () => { const loadMyAvatar = useCallback(async () => {
@@ -419,6 +467,150 @@ function ChatPage(_props: ChatPageProps) {
} }
}, []) }, [])
const resolveChatCacheScope = useCallback(async (): Promise<string> => {
try {
const [dbPath, myWxid] = await Promise.all([
window.electronAPI.config.get('dbPath'),
window.electronAPI.config.get('myWxid')
])
const scope = normalizeChatCacheScope(dbPath, myWxid)
chatCacheScopeRef.current = scope
return scope
} catch {
chatCacheScopeRef.current = 'default'
return 'default'
}
}, [])
const loadPreviewCacheFromStorage = useCallback((scope: string): Record<string, SessionPreviewCacheEntry> => {
try {
const cacheKey = buildChatSessionPreviewCacheKey(scope)
const payload = safeParseJson<SessionPreviewCachePayload>(window.localStorage.getItem(cacheKey))
if (!payload || typeof payload.updatedAt !== 'number' || !payload.entries) {
return {}
}
if (Date.now() - payload.updatedAt > CHAT_SESSION_PREVIEW_CACHE_TTL_MS) {
return {}
}
return payload.entries
} catch {
return {}
}
}, [])
const persistPreviewCacheToStorage = useCallback((scope: string, entries: Record<string, SessionPreviewCacheEntry>) => {
try {
const cacheKey = buildChatSessionPreviewCacheKey(scope)
const payload: SessionPreviewCachePayload = {
updatedAt: Date.now(),
entries
}
window.localStorage.setItem(cacheKey, JSON.stringify(payload))
} catch {
// ignore cache write failures
}
}, [])
const persistSessionPreviewCache = useCallback((sessionId: string, previewMessages: Message[]) => {
const id = String(sessionId || '').trim()
if (!id || !Array.isArray(previewMessages) || previewMessages.length === 0) return
const trimmed = previewMessages.slice(-CHAT_SESSION_PREVIEW_LIMIT_PER_SESSION)
const currentEntries = { ...previewCacheRef.current }
currentEntries[id] = {
updatedAt: Date.now(),
messages: trimmed
}
const sortedIds = Object.entries(currentEntries)
.sort((a, b) => (b[1]?.updatedAt || 0) - (a[1]?.updatedAt || 0))
.map(([entryId]) => entryId)
const keptIds = new Set(sortedIds.slice(0, CHAT_SESSION_PREVIEW_MAX_SESSIONS))
const compactEntries: Record<string, SessionPreviewCacheEntry> = {}
for (const [entryId, entry] of Object.entries(currentEntries)) {
if (keptIds.has(entryId)) {
compactEntries[entryId] = entry
}
}
previewCacheRef.current = compactEntries
if (previewPersistTimerRef.current !== null) {
window.clearTimeout(previewPersistTimerRef.current)
}
previewPersistTimerRef.current = window.setTimeout(() => {
persistPreviewCacheToStorage(chatCacheScopeRef.current, previewCacheRef.current)
previewPersistTimerRef.current = null
}, 220)
}, [persistPreviewCacheToStorage])
const hydrateSessionPreview = useCallback(async (sessionId: string) => {
const id = String(sessionId || '').trim()
if (!id) return
const localEntry = previewCacheRef.current[id]
if (
localEntry &&
Array.isArray(localEntry.messages) &&
localEntry.messages.length > 0 &&
Date.now() - localEntry.updatedAt <= CHAT_SESSION_PREVIEW_CACHE_TTL_MS
) {
setMessages(localEntry.messages.slice())
setHasInitialMessages(true)
return
}
try {
const result = await window.electronAPI.chat.getCachedMessages(id)
if (!result.success || !Array.isArray(result.messages) || result.messages.length === 0) {
return
}
if (currentSessionRef.current !== id && pendingSessionLoadRef.current !== id) return
setMessages(result.messages)
setHasInitialMessages(true)
persistSessionPreviewCache(id, result.messages)
} catch {
// ignore preview cache errors
}
}, [persistSessionPreviewCache, setMessages])
const hydrateSessionListCache = useCallback((scope: string): boolean => {
try {
const cacheKey = buildChatSessionListCacheKey(scope)
const payload = safeParseJson<SessionListCachePayload>(window.localStorage.getItem(cacheKey))
if (!payload || typeof payload.updatedAt !== 'number' || !Array.isArray(payload.sessions)) {
previewCacheRef.current = loadPreviewCacheFromStorage(scope)
return false
}
previewCacheRef.current = loadPreviewCacheFromStorage(scope)
if (Date.now() - payload.updatedAt > CHAT_SESSION_LIST_CACHE_TTL_MS) {
return false
}
if (!Array.isArray(sessionsRef.current) || sessionsRef.current.length === 0) {
setSessions(payload.sessions)
sessionsRef.current = payload.sessions
return payload.sessions.length > 0
}
return false
} catch {
previewCacheRef.current = loadPreviewCacheFromStorage(scope)
return false
}
}, [loadPreviewCacheFromStorage, setSessions])
const persistSessionListCache = useCallback((scope: string, nextSessions: ChatSession[]) => {
try {
const cacheKey = buildChatSessionListCacheKey(scope)
const payload: SessionListCachePayload = {
updatedAt: Date.now(),
sessions: nextSessions
}
window.localStorage.setItem(cacheKey, JSON.stringify(payload))
} catch {
// ignore cache write failures
}
}, [])
// 加载会话详情 // 加载会话详情
const loadSessionDetail = useCallback(async (sessionId: string) => { const loadSessionDetail = useCallback(async (sessionId: string) => {
const normalizedSessionId = String(sessionId || '').trim() const normalizedSessionId = String(sessionId || '').trim()
@@ -580,11 +772,12 @@ function ChatPage(_props: ChatPageProps) {
setConnecting(true) setConnecting(true)
setConnectionError(null) setConnectionError(null)
try { try {
const scopePromise = resolveChatCacheScope()
const result = await window.electronAPI.chat.connect() const result = await window.electronAPI.chat.connect()
if (result.success) { if (result.success) {
setConnected(true) setConnected(true)
const wxidPromise = window.electronAPI.config.get('myWxid') const wxidPromise = window.electronAPI.config.get('myWxid')
await Promise.all([loadSessions(), loadMyAvatar()]) await Promise.all([scopePromise, loadSessions(), loadMyAvatar()])
// 获取 myWxid 用于匹配个人头像 // 获取 myWxid 用于匹配个人头像
const wxid = await wxidPromise const wxid = await wxidPromise
if (wxid) setMyWxid(wxid as string) if (wxid) setMyWxid(wxid as string)
@@ -596,7 +789,7 @@ function ChatPage(_props: ChatPageProps) {
} finally { } finally {
setConnecting(false) setConnecting(false)
} }
}, [loadMyAvatar]) }, [loadMyAvatar, resolveChatCacheScope])
const handleAccountChanged = useCallback(async () => { const handleAccountChanged = useCallback(async () => {
senderAvatarCache.clear() senderAvatarCache.clear()
@@ -616,9 +809,13 @@ function ChatPage(_props: ChatPageProps) {
setConnecting(false) setConnecting(false)
setHasMoreMessages(true) setHasMoreMessages(true)
setHasMoreLater(false) setHasMoreLater(false)
const scope = await resolveChatCacheScope()
hydrateSessionListCache(scope)
await connect() await connect()
}, [ }, [
connect, connect,
resolveChatCacheScope,
hydrateSessionListCache,
setConnected, setConnected,
setConnecting, setConnecting,
setConnectionError, setConnectionError,
@@ -632,6 +829,19 @@ function ChatPage(_props: ChatPageProps) {
setSessions setSessions
]) ])
useEffect(() => {
let cancelled = false
void (async () => {
const scope = await resolveChatCacheScope()
if (cancelled) return
hydrateSessionListCache(scope)
})()
return () => {
cancelled = true
}
}, [resolveChatCacheScope, hydrateSessionListCache])
// 同步 currentSessionId 到 ref // 同步 currentSessionId 到 ref
useEffect(() => { useEffect(() => {
currentSessionRef.current = currentSessionId currentSessionRef.current = currentSessionId
@@ -684,6 +894,7 @@ function ChatPage(_props: ChatPageProps) {
setLoadingSessions(true) setLoadingSessions(true)
} }
try { try {
const scope = await resolveChatCacheScope()
const result = await window.electronAPI.chat.getSessions() const result = await window.electronAPI.chat.getSessions()
if (result.success && result.sessions) { if (result.success && result.sessions) {
// 确保 sessions 是数组 // 确保 sessions 是数组
@@ -695,12 +906,15 @@ function ChatPage(_props: ChatPageProps) {
setSessions(nextSessions) setSessions(nextSessions)
sessionsRef.current = nextSessions sessionsRef.current = nextSessions
persistSessionListCache(scope, nextSessions)
void hydrateSessionStatuses(nextSessions) void hydrateSessionStatuses(nextSessions)
// 立即启动联系人信息加载,不再延迟 500ms // 立即启动联系人信息加载,不再延迟 500ms
void enrichSessionsContactInfo(nextSessions) void enrichSessionsContactInfo(nextSessions)
} else { } else {
console.error('mergeSessions returned non-array:', nextSessions) console.error('mergeSessions returned non-array:', nextSessions)
setSessions(sessionsArray) setSessions(sessionsArray)
sessionsRef.current = sessionsArray
persistSessionListCache(scope, sessionsArray)
void hydrateSessionStatuses(sessionsArray) void hydrateSessionStatuses(sessionsArray)
void enrichSessionsContactInfo(sessionsArray) void enrichSessionsContactInfo(sessionsArray)
} }
@@ -1085,6 +1299,7 @@ function ChatPage(_props: ChatPageProps) {
if (result.success && result.messages) { if (result.success && result.messages) {
if (offset === 0) { if (offset === 0) {
setMessages(result.messages) setMessages(result.messages)
persistSessionPreviewCache(sessionId, result.messages)
if (result.messages.length === 0) { if (result.messages.length === 0) {
setNoMessageTable(true) setNoMessageTable(true)
setHasMoreMessages(false) setHasMoreMessages(false)
@@ -1233,10 +1448,12 @@ function ChatPage(_props: ChatPageProps) {
if (session.username === currentSessionId) return if (session.username === currentSessionId) return
pendingSessionLoadRef.current = session.username pendingSessionLoadRef.current = session.username
setIsSessionSwitching(true) setIsSessionSwitching(true)
setCurrentSession(session.username, { preserveMessages: true }) setCurrentSession(session.username, { preserveMessages: false })
void hydrateSessionPreview(session.username)
setCurrentOffset(0) setCurrentOffset(0)
setJumpStartTime(0) setJumpStartTime(0)
setJumpEndTime(0) setJumpEndTime(0)
setNoMessageTable(false)
void loadMessages(session.username, 0, 0, 0) void loadMessages(session.username, 0, 0, 0)
// 切换会话后回到正常聊天窗口:收起详情侧栏,详情需手动再次展开 // 切换会话后回到正常聊天窗口:收起详情侧栏,详情需手动再次展开
setShowDetailPanel(false) setShowDetailPanel(false)
@@ -1374,6 +1591,14 @@ function ChatPage(_props: ChatPageProps) {
// 组件卸载时清理 // 组件卸载时清理
return () => { return () => {
avatarLoadQueue.clear() avatarLoadQueue.clear()
if (previewPersistTimerRef.current !== null) {
window.clearTimeout(previewPersistTimerRef.current)
previewPersistTimerRef.current = null
}
if (sessionListPersistTimerRef.current !== null) {
window.clearTimeout(sessionListPersistTimerRef.current)
sessionListPersistTimerRef.current = null
}
if (contactUpdateTimerRef.current) { if (contactUpdateTimerRef.current) {
clearTimeout(contactUpdateTimerRef.current) clearTimeout(contactUpdateTimerRef.current)
} }
@@ -1522,6 +1747,22 @@ function ChatPage(_props: ChatPageProps) {
searchKeywordRef.current = searchKeyword searchKeywordRef.current = searchKeyword
}, [searchKeyword]) }, [searchKeyword])
useEffect(() => {
if (!currentSessionId || !Array.isArray(messages) || messages.length === 0) return
persistSessionPreviewCache(currentSessionId, messages)
}, [currentSessionId, messages, persistSessionPreviewCache])
useEffect(() => {
if (!Array.isArray(sessions) || sessions.length === 0) return
if (sessionListPersistTimerRef.current !== null) {
window.clearTimeout(sessionListPersistTimerRef.current)
}
sessionListPersistTimerRef.current = window.setTimeout(() => {
persistSessionListCache(chatCacheScopeRef.current, sessions)
sessionListPersistTimerRef.current = null
}, 260)
}, [sessions, persistSessionListCache])
// 普通视图:隐藏 isFolded 的群,保留 placeholder_foldgroup 入口 // 普通视图:隐藏 isFolded 的群,保留 placeholder_foldgroup 入口
useEffect(() => { useEffect(() => {
if (!Array.isArray(sessions)) { if (!Array.isArray(sessions)) {