mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-03-24 23:06:51 +00:00
perf(chat): add local session list and preview cache hydration
This commit is contained in:
@@ -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)) {
|
||||||
|
|||||||
Reference in New Issue
Block a user