diff --git a/src/main/index.ts b/src/main/index.ts index 63fbcd8..ae59395 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -17,7 +17,7 @@ import { extname } from 'path' import { electronApp, optimizer, is } from '@electron-toolkit/utils' import icon from '../../resources/icon.png?asset' import { WechatDb } from './wechat-db' -import { bootstrapWcdbNative, Wcdb4Client } from './wcdb4-client' +import { bootstrapWcdbNativeAsync, Wcdb4Client } from './wcdb4-client' import { VoiceService } from './voice-service' import { StickerService } from './sticker-service' import { parseMessageContent } from './message-parser' @@ -61,12 +61,15 @@ import { import type { SaveImageKeyRequest, TestImageDecryptionRequest } from '../shared/image-decryption' import { loadSettings, saveSettings, getSettingsPath, AppSettings } from './services/settings-store' import { + flushBootstrapCacheWritesSync, getBootstrapCache, + getCachedMessagePage, getCachedMessages, mergeBootstrapAvatars, mergeCachedContactAvatars, saveBootstrapContacts, saveBootstrapSelf, + saveCachedGroupSnapshot, saveCachedMessages } from './services/bootstrap-cache' import { installSafeConsole } from './safe-log' @@ -95,16 +98,23 @@ const keyServiceWin = new KeyServiceWin() let tray: Tray | null = null let recallArchiveMonitor: RecallArchiveMonitor | null = null let recallProtectionGeneration = 0 +let recallJournalTimer: NodeJS.Timeout | null = null +let wcdbBootstrapPromise: Promise | null = null function configureRecallProtection( wcdb4Client: Wcdb4Client, accountRoot: string, - enabled: boolean + enabled: boolean, + installJournal = false ): void { recallProtectionGeneration += 1 const generation = recallProtectionGeneration recallArchiveMonitor?.stop() recallArchiveMonitor = null + if (recallJournalTimer) { + clearTimeout(recallJournalTimer) + recallJournalTimer = null + } configureRecallArchive(enabled ? accountRoot : '') if (!enabled) return @@ -128,8 +138,19 @@ function configureRecallProtection( }) ) recallArchiveMonitor = monitor - monitor.seedAll() - setTimeout(() => { + // Session indexing is not required to open the UI. Defer it so large databases + // do not make db:init wait for every conversation to be enumerated. + setImmediate(() => { + if (generation === recallProtectionGeneration && recallArchiveMonitor === monitor) { + monitor.seedAll() + } + }) + if (!installJournal) return + // Creating recall triggers scans every message table and runs synchronously in + // the main process. Only do this after the user explicitly enables protection; + // existing installations remain active without repeating the scan at startup. + recallJournalTimer = setTimeout(() => { + recallJournalTimer = null if (generation !== recallProtectionGeneration || recallArchiveMonitor !== monitor) return const result = wcdb4Client.installRecallJournal( wcdb4Client.getSessions().map((session) => session.username) @@ -137,7 +158,7 @@ function configureRecallProtection( console.log( `[WCDB4] recall journal ready installed=${result.installed} failed=${result.failed}` ) - }, 0) + }, 30_000) } const packagedIconPath = join(process.resourcesPath, 'resources', 'icon.png') @@ -305,15 +326,11 @@ app.whenReady().then(async () => { }) }) - // WCDB's Windows runtime returns -1006 if wcdb_init is called more than once - // per process. Bootstrap native once here so any later Wcdb4Client instance - // reuses the already-initialized library and skips wcdb_init. - try { - bootstrapWcdbNative() - console.log('[WCDB4] bootstrap complete at whenReady top') - } catch (bootstrapError) { - console.error('[WCDB4] bootstrap failed at whenReady top:', bootstrapError) - } + // Create the renderer before native WCDB bootstrap so startup progress is visible immediately. + createWindow() + wcdbBootstrapPromise = bootstrapWcdbNativeAsync().then(() => { + console.log('[WCDB4] async bootstrap complete') + }) // 设置应用程序用户模型 ID electronApp.setAppUserModelId('com.wechatexplorer.app') @@ -338,6 +355,7 @@ app.whenReady().then(async () => { dbInitInFlight = (async () => { try { + if (wcdbBootstrapPromise) await wcdbBootstrapPromise const trimmedKey = String(key || '').trim() console.log(`db:init build=${BUILD_MARK} keyLength=${trimmedKey.length}`) const settings = loadSettings() @@ -372,6 +390,13 @@ app.whenReady().then(async () => { if (!window.isDestroyed()) window.webContents.send('wcdb-change', { type, json }) } }) + setImmediate(() => { + const recentSession = wcdb4Client.getSessions()[0] + if (!recentSession?.username) return + void wcdb4Client + .getMessagesAsync(recentSession.username, undefined, undefined, { limit: 1 }) + .catch((error) => console.warn('[WCDB4] message cursor warmup failed:', error)) + }) imageDecryptService = null return { success: true, monitoring } } catch (error) { @@ -512,11 +537,26 @@ app.whenReady().then(async () => { return getBootstrapCache(chat.getCurrentAccountRoot()) }) + ipcMain.handle('db:getStartupCache', () => { + const settings = loadSettings() + return settings.dbRoot ? getBootstrapCache(settings.dbRoot) : null + }) + ipcMain.handle( 'db:getCachedMessages', (_, userMd5: string, startTime?: number, endTime?: number) => { - if (!chat.isReady()) return [] - return getCachedMessages(chat.getCurrentAccountRoot(), userMd5, startTime, endTime) + const accountRoot = chat.isReady() ? chat.getCurrentAccountRoot() : loadSettings().dbRoot + return accountRoot ? getCachedMessages(accountRoot, userMd5, startTime, endTime) : [] + } + ) + + ipcMain.handle( + 'db:getCachedMessagePage', + (_, userMd5: string, startTime?: number, endTime?: number) => { + const accountRoot = chat.isReady() ? chat.getCurrentAccountRoot() : loadSettings().dbRoot + return accountRoot + ? getCachedMessagePage(accountRoot, userMd5, startTime, endTime) + : { hit: false, messages: [] } } ) @@ -539,8 +579,8 @@ app.whenReady().then(async () => { ipcMain.handle( 'db:getMessages', - (_, userMd5: string, startTime?: number, endTime?: number, options?: { limit?: number }) => { - const messages = chat.listMessages(userMd5, startTime, endTime, options) + async (_, userMd5: string, startTime?: number, endTime?: number, options?: { limit?: number }) => { + const messages = await chat.listMessagesAsync(userMd5, startTime, endTime, options) if (chat.isReady()) { saveCachedMessages(chat.getCurrentAccountRoot(), userMd5, startTime, endTime, messages) } @@ -548,7 +588,13 @@ app.whenReady().then(async () => { } ) - ipcMain.handle('db:getGroupSnapshot', (_, userMd5: string) => chat.getGroupSnapshot(userMd5)) + ipcMain.handle('db:getGroupSnapshot', async (_, userMd5: string) => { + const snapshot = await chat.getGroupSnapshotAsync(userMd5) + if (snapshot && chat.isReady()) { + saveCachedGroupSnapshot(chat.getCurrentAccountRoot(), userMd5, snapshot) + } + return snapshot + }) ipcMain.handle('db:search', (_, keyword: string) => chat.searchMessages(keyword)) @@ -824,7 +870,8 @@ app.whenReady().then(async () => { configureRecallProtection( currentDb.getWcdb4Client(), chat.getCurrentAccountRoot(), - nextSettings.recallProtectionEnabled + nextSettings.recallProtectionEnabled, + nextSettings.recallProtectionEnabled && !before.recallProtectionEnabled ) } } @@ -938,8 +985,6 @@ app.whenReady().then(async () => { return result.canceled ? { canceled: true } : { canceled: false, path: result.filePaths[0] } }) - createWindow() - // 启动本地 HTTP API(由 settings.apiEnabled 控制) const settings = loadSettings() if (settings.apiEnabled) { @@ -972,6 +1017,7 @@ app.on('window-all-closed', () => { app.on('before-quit', async () => { agentHubService.stop() + flushBootstrapCacheWritesSync() chat.setChatDb(null) await apiServer.stop().catch(() => undefined) if (tray) { diff --git a/src/main/services/bootstrap-cache.ts b/src/main/services/bootstrap-cache.ts index c1f76c7..863cb4a 100644 --- a/src/main/services/bootstrap-cache.ts +++ b/src/main/services/bootstrap-cache.ts @@ -11,6 +11,19 @@ export interface CachedSelfInfo { accountRoot: string } +export interface CachedGroupSnapshot { + roomId: string + memberCount: number + members: { + wxid: string + nickname: string + groupNickname: string + wechatNickname: string + remark: string + avatar: string + }[] +} + interface CachedMessageBucket { updatedAt: number startTime?: number @@ -26,11 +39,12 @@ interface BootstrapCacheFile { self?: CachedSelfInfo contacts?: Contact[] messages?: Record + groupSnapshots?: Record } const CACHE_VERSION = 1 -const MAX_MESSAGE_BUCKETS = 24 -const MAX_MESSAGES_PER_BUCKET = 1200 +const MAX_MESSAGE_BUCKETS = 768 +const MAX_MESSAGES_PER_BUCKET = 120 const WRITE_DEBOUNCE_MS = 300 const memoryCache = new Map() const writeTimers = new Map() @@ -73,7 +87,9 @@ function readCacheFile(accountRoot?: string): BootstrapCacheFile | null { updatedAt: Number(raw.updatedAt) || 0, self: raw.self, contacts: Array.isArray(raw.contacts) ? raw.contacts : [], - messages: raw.messages && typeof raw.messages === 'object' ? raw.messages : {} + messages: raw.messages && typeof raw.messages === 'object' ? raw.messages : {}, + groupSnapshots: + raw.groupSnapshots && typeof raw.groupSnapshots === 'object' ? raw.groupSnapshots : {} } memoryCache.set(file, result) return result @@ -122,7 +138,8 @@ function loadOrCreate(accountRoot?: string): BootstrapCacheFile | null { accountRoot: normalizedRoot, updatedAt: Date.now(), contacts: [], - messages: {} + messages: {}, + groupSnapshots: {} } memoryCache.set(getCacheFile(normalizedRoot), created) return created @@ -132,6 +149,12 @@ function messageBucketKey(userMd5: string, startTime?: number, endTime?: number) return `${userMd5}:${startTime ?? ''}:${endTime ?? ''}` } +function cachedMessageIdentity(message: Message): string { + if (message.localId) return `local:${message.localId}` + if (message.serverId) return `server:${message.serverId}` + return `id:${message.id}` +} + function pruneMessageBuckets(messages: Record): void { const entries = Object.entries(messages) if (entries.length <= MAX_MESSAGE_BUCKETS) return @@ -260,6 +283,71 @@ export function getCachedMessages( return bucket?.items || [] } +export function getCachedMessagePage( + accountRoot: string, + userMd5: string, + startTime?: number, + endTime?: number +): { hit: boolean; messages: Message[]; groupSnapshot?: CachedGroupSnapshot } { + const cache = readCacheFile(accountRoot) + const key = messageBucketKey(userMd5, startTime, endTime) + let bucket = cache?.messages?.[key] + if (!bucket && cache?.messages && startTime === undefined && endTime === undefined) { + const merged = new Map() + for (const [cachedKey, candidate] of Object.entries(cache.messages)) { + if (!cachedKey.startsWith(`${userMd5}:`)) continue + for (const message of candidate.items || []) { + merged.set(cachedMessageIdentity(message), message) + } + } + const migratedMessages = Array.from(merged.values()) + .sort((left, right) => (left.createTime || 0) - (right.createTime || 0)) + .slice(-MAX_MESSAGES_PER_BUCKET) + if (migratedMessages.length > 0) { + bucket = { + updatedAt: Date.now(), + items: migratedMessages + } + cache.messages[key] = bucket + cache.updatedAt = Date.now() + pruneMessageBuckets(cache.messages) + writeCacheFile(cache) + } + } + return { + hit: Boolean(bucket), + messages: bucket?.items || [], + groupSnapshot: cache?.groupSnapshots?.[userMd5]?.snapshot + } +} + +export function saveCachedGroupSnapshot( + accountRoot: string, + userMd5: string, + snapshot: CachedGroupSnapshot +): void { + const cache = loadOrCreate(accountRoot) + if (!cache) return + cache.groupSnapshots ||= {} + cache.groupSnapshots[userMd5] = { updatedAt: Date.now(), snapshot } + cache.updatedAt = Date.now() + writeCacheFile(cache) +} + +export function flushBootstrapCacheWritesSync(): void { + for (const [file, cache] of memoryCache) { + const timer = writeTimers.get(file) + if (timer) clearTimeout(timer) + writeTimers.delete(file) + try { + fs.ensureDirSync(path.dirname(file)) + fs.writeFileSync(file, JSON.stringify(cache), 'utf8') + } catch (error) { + console.warn('[BootstrapCache] flush failed:', error) + } + } +} + export function saveCachedMessages( accountRoot: string, userMd5: string, diff --git a/src/main/services/chat-service.ts b/src/main/services/chat-service.ts index 5215a87..ae1d831 100644 --- a/src/main/services/chat-service.ts +++ b/src/main/services/chat-service.ts @@ -143,25 +143,29 @@ export function listContacts(filter?: string): FormattedContact[] { }) } - const chatTables = dbRef.getAllChatTables() - for (const table of chatTables) { - if (!table.name.startsWith('Chat_')) continue - const md5 = table.name.substring(5) - if (existingMd5s.has(md5)) continue - if (groupContacts[md5]) { - contacts.push({ - m_nsUsrName: `Group_${md5}`, - m_nsNickName: groupContacts[md5], - md5, - type: 'group' - }) - } else { - contacts.push({ - m_nsUsrName: `Unknown_${md5}`, - m_nsNickName: `Chat_${md5}`, - md5, - type: 'user' - }) + // The session list already covers normal conversations. Only scan Chat_* + // tables as a recovery fallback when the session query returned nothing. + if (userList.length === 0) { + const chatTables = dbRef.getAllChatTables() + for (const table of chatTables) { + if (!table.name.startsWith('Chat_')) continue + const md5 = table.name.substring(5) + if (existingMd5s.has(md5)) continue + if (groupContacts[md5]) { + contacts.push({ + m_nsUsrName: `Group_${md5}`, + m_nsNickName: groupContacts[md5], + md5, + type: 'group' + }) + } else { + contacts.push({ + m_nsUsrName: `Unknown_${md5}`, + m_nsNickName: `Chat_${md5}`, + md5, + type: 'user' + }) + } } } return contacts @@ -180,7 +184,8 @@ function listSourceMessages( userMd5: string, startTime?: number, endTime?: number, - options?: { limit?: number } + options?: { limit?: number }, + rawMessagesOverride?: WechatMessage[] ): FormattedMessage[] { if (!dbRef) return [] @@ -191,7 +196,8 @@ function listSourceMessages( console.log( `[ChatService] listMessages begin md5=${userMd5} username=${username || ''} start=${startTime || 0} end=${endTime || 0} limit=${options?.limit || 0}` ) - const rawMessages = dbRef.getUserMessages(userMd5, startTime, endTime, options) + const rawMessages = + rawMessagesOverride ?? dbRef.getUserMessages(userMd5, startTime, endTime, options) console.log( `[ChatService] listMessages native done md5=${userMd5} raw=${rawMessages.length} cost=${Date.now() - startedAt}ms` ) @@ -350,6 +356,26 @@ export function listMessages( return mergeRecallArchiveMessages(userMd5, sourceMessages, startTime, endTime, options?.limit) } +export async function listMessagesAsync( + userMd5: string, + startTime?: number, + endTime?: number, + options?: { limit?: number } +): Promise { + if (!dbRef) return [] + const rawMessages = await dbRef.getUserMessagesAsync(userMd5, startTime, endTime, options) + const sourceMessages = listSourceMessages( + userMd5, + startTime, + endTime, + options, + rawMessages + ) + const username = dbRef.getWcdb4Client().getUsernameByMd5(userMd5) || '' + recordRecallArchiveMessages(userMd5, username, sourceMessages) + return mergeRecallArchiveMessages(userMd5, sourceMessages, startTime, endTime, options?.limit) +} + export function getGroupSnapshot(userMd5: string): GroupSnapshot | null { if (!dbRef) return null const wcdb4Client = dbRef.getWcdb4Client() @@ -371,6 +397,25 @@ export function getGroupSnapshot(userMd5: string): GroupSnapshot | null { return { roomId, memberCount: members.length, members } } +export async function getGroupSnapshotAsync(userMd5: string): Promise { + if (!dbRef) return null + const wcdb4Client = dbRef.getWcdb4Client() + const roomId = wcdb4Client.getUsernameByMd5(userMd5) + if (!roomId || !roomId.endsWith('@chatroom')) return null + + const members = (await wcdb4Client.getGroupMembersAsync(roomId)) + .filter((member) => member?.m_nsUsrName) + .map((member) => ({ + wxid: member.m_nsUsrName, + nickname: member.nickname || '', + groupNickname: member.groupNickname || '', + wechatNickname: member.wechatNickname || '', + remark: member.remark || '', + avatar: member.m_nsHeadImgUrl || '' + })) + return { roomId, memberCount: members.length, members } +} + export function searchMessages(keyword: string): string | null { if (!dbRef) return null return dbRef.searchAllMessages(keyword) diff --git a/src/main/wcdb4-client.ts b/src/main/wcdb4-client.ts index ad75e92..b8b189b 100644 --- a/src/main/wcdb4-client.ts +++ b/src/main/wcdb4-client.ts @@ -68,6 +68,8 @@ type KoffiModule = { // library reference for every Wcdb4Client instance. let wcdbBootstrapLib: KoffiLibrary | null = null +let wcdbBootstrapAsyncPromise: Promise | null = null + export function bootstrapWcdbNative(libPath?: string, libDirOverride?: string): KoffiLibrary { if (wcdbBootstrapLib) return wcdbBootstrapLib @@ -141,8 +143,82 @@ export function bootstrapWcdbNative(libPath?: string, libDirOverride?: string): return lib } +export function bootstrapWcdbNativeAsync( + libPath?: string, + libDirOverride?: string +): Promise { + if (wcdbBootstrapLib) return Promise.resolve(wcdbBootstrapLib) + if (wcdbBootstrapAsyncPromise) return wcdbBootstrapAsyncPromise + + wcdbBootstrapAsyncPromise = (async () => { + const koffi = nodeRequire('koffi') as KoffiModule + const resolvedLibPath = libPath || Wcdb4Client.resolveNativeLibrary() + const libDir = libDirOverride || path.dirname(resolvedLibPath) + for (const name of process.platform === 'win32' + ? ['WCDB.dll', 'SDL2.dll'] + : process.platform === 'darwin' + ? ['libWCDB.dylib'] + : []) { + const preloadPath = path.join(libDir, name) + if (!fs.existsSync(preloadPath)) continue + try { + koffi.load(preloadPath) + } catch { + // The main library may still resolve its dependencies through the loader. + } + } + const lib = koffi.load(resolvedLibPath) + const initProtection = lib.func('int32 InitProtection(const char* resourcePath)') as ( + resourcePath: string + ) => number + const resourceRoots = Array.from( + new Set( + [libDir, path.dirname(libDir), process.env.WCDB_RESOURCES_PATH || '', ...getResourceRoots()].filter( + Boolean + ) + ) + ) + let initOk = false + for (const resourceRoot of resourceRoots) { + try { + if (Number(initProtection(resourceRoot)) === 0) { + initOk = true + break + } + } catch { + // Try the next resource root. + } + } + if (initOk) { + const wcdbInit = lib.func('int32 wcdb_init()') as KoffiAsyncFunction + await new Promise((resolve, reject) => { + wcdbInit.async((error: unknown, result: unknown) => { + if (error) { + reject(error) + return + } + if (Number(result) !== 0) { + console.warn(`[WCDB4] async wcdb_init rc=${Number(result)}`) + } + resolve() + }) + }) + } + wcdbBootstrapLib = lib + return lib + })().catch((error) => { + wcdbBootstrapAsyncPromise = null + throw error + }) + return wcdbBootstrapAsyncPromise +} + type KoffiLibrary = { - func: (signature: string) => (...args: unknown[]) => unknown + func: (signature: string) => KoffiAsyncFunction +} + +type KoffiAsyncFunction = ((...args: unknown[]) => unknown) & { + async: (...args: unknown[]) => void } type WcdbVoidOut = [unknown] @@ -170,6 +246,7 @@ export class Wcdb4Client { private wcdbOpenAccount: | ((sessionDbPath: string, key: string, handleOut: WcdbHandleOut) => number) | null = null + private wcdbOpenAccountAsync: KoffiAsyncFunction | null = null private wcdbSetMyWxid: ((handle: number, wxid: string) => number) | null = null private wcdbFreeString: ((ptr: unknown) => void) | null = null private wcdbGetSessions: ((handle: number, outJson: WcdbVoidOut) => number) | null = null @@ -428,6 +505,39 @@ export class Wcdb4Client { } } + async openAsync(): Promise { + this.loadNativeLibrary() + if (!this.wcdbOpenAccountAsync) { + throw new Error('WCDB 4.0 native open async interface unavailable') + } + + const handleOut: WcdbHandleOut = [0] + const openResult = await new Promise((resolve, reject) => { + this.wcdbOpenAccountAsync!.async( + this.sessionDbPath, + this.key, + handleOut, + (error: unknown, result: unknown) => { + if (error) reject(error) + else resolve(Number(result)) + } + ) + }) + if (openResult !== 0 || handleOut[0] <= 0) { + throw new Error( + `wcdb_open_account failed, code=${openResult}; sessionDb=${this.sessionDbPath}; accountRoot=${this.accountRoot}; wxid=${this.wxid}; keyLength=${this.key.length}` + ) + } + this.handle = handleOut[0] + if (this.wcdbSetMyWxid) { + try { + this.wcdbSetMyWxid(this.handle, this.wxid) + } catch { + // Optional helper. Failure does not block message reads. + } + } + } + close(): void { this.stopMonitor() if (this.handle === null || !this.wcdbShutdown) return @@ -699,6 +809,21 @@ export class Wcdb4Client { return messages } + async getMessagesAsync( + username: string, + startTime?: number, + endTime?: number, + options: Wcdb4MessageQueryOptions = {} + ): Promise { + const startedAt = Date.now() + const maxRows = this.normalizeMessageLimit(options.limit) + const messages = await this.getMessagesByCursorAsync(username, startTime, endTime, maxRows) + console.log( + `[WCDB4] getMessages async username=${username} rows=${messages.length} cost=${Date.now() - startedAt}ms` + ) + return messages + } + private readSessionRows(): Record[] { if (!this.wcdbGetSessions) return [] const rows = this.callJson[]>((handle, outJson) => @@ -1139,6 +1264,140 @@ export class Wcdb4Client { } } + private async getMessagesByCursorAsync( + username: string, + startTime?: number, + endTime?: number, + limit?: number + ): Promise { + if (!this.wcdbOpenMessageCursor || !this.wcdbFetchMessageBatch) return [] + + const handle = this.ensureHandle() + const batchSize = limit ? Math.min(500, limit) : 1000 + const cursorOut: WcdbHandleOut = [0] + const begin = this.normalizeTimestamp(startTime || 0) + const end = this.normalizeTimestamp(endTime || 0) + const ascending = limit ? 0 : 1 + const openResult = await this.callAsyncCode( + this.wcdbOpenMessageCursor as unknown as KoffiAsyncFunction, + handle, + username, + batchSize, + ascending, + begin, + end, + cursorOut + ) + if (openResult !== 0 || cursorOut[0] <= 0) return [] + + const cursor = cursorOut[0] + const allRows: Record[] = [] + try { + while (true) { + const outJson: WcdbVoidOut = [null] + const outHasMore: [number] = [0] + const fetchResult = await this.callAsyncCode( + this.wcdbFetchMessageBatch as unknown as KoffiAsyncFunction, + handle, + cursor, + outJson, + outHasMore + ) + if (fetchResult !== 0 || !outJson[0]) break + try { + const json = this.koffi!.decode(outJson[0], 'char', -1) + const batch = JSON.parse(json) as Record[] + if (Array.isArray(batch)) allRows.push(...batch) + } finally { + this.wcdbFreeString?.(outJson[0]) + } + if (!outHasMore[0] || (limit && allRows.length >= limit)) break + } + } finally { + try { + this.wcdbCloseMessageCursor?.(handle, cursor) + } catch { + // Best-effort cursor cleanup. + } + } + return this.finalizeMessages(username, allRows, startTime, endTime, limit) + } + + async getGroupMembersAsync(chatroomId: string): Promise { + if (!this.wcdbGetGroupMembers || !chatroomId) return [] + + try { + const groupNicknames = await this.getGroupNicknamesAsync(chatroomId) + const rows = await this.callJsonAsync[]>( + this.wcdbGetGroupMembers as unknown as KoffiAsyncFunction, + chatroomId + ) + const members = (Array.isArray(rows) ? rows : []).map((row) => { + const username = this.pickString(row, [ + 'username', + 'userName', + 'user_name', + 'member_username', + 'm_nsUsrName' + ]) + const wechatNickname = this.pickString(row, [ + 'nickname', + 'nickName', + 'wechatNickname', + 'wechat_nickname', + 'm_nsNickName' + ]) + const remark = this.pickString(row, [ + 'remark', + 'remarkName', + 'remark_name', + 'contactRemark', + 'contact_remark' + ]) + const memberNickname = this.pickString(row, ['displayName', 'display_name', 'name']) + const avatar = this.pickString(row, [ + 'avatarUrl', + 'avatar_url', + 'headImgUrl', + 'm_nsHeadImgUrl' + ]) + if (username && avatar) this.avatarCache.set(username, avatar) + return { + m_nsUsrName: username, + nickname: groupNicknames.get(username) || remark || wechatNickname || memberNickname, + groupNickname: groupNicknames.get(username) || '', + wechatNickname: wechatNickname || memberNickname, + remark, + m_nsHeadImgUrl: avatar + } + }) + + const missingNames = members + .filter((member) => !member.nickname) + .map((member) => member.m_nsUsrName) + .filter(Boolean) + const missingAvatars = members + .filter((member) => !member.m_nsHeadImgUrl) + .map((member) => member.m_nsUsrName) + .filter(Boolean) + await Promise.all([ + this.hydrateDisplayNamesAsync(missingNames), + this.hydrateAvatarUrlsAsync(missingAvatars) + ]) + return members.map((member) => ({ + ...member, + nickname: + member.nickname || this.displayNameCache.get(member.m_nsUsrName) || member.m_nsUsrName, + wechatNickname: + member.wechatNickname || this.displayNameCache.get(member.m_nsUsrName) || '', + m_nsHeadImgUrl: member.m_nsHeadImgUrl || this.avatarCache.get(member.m_nsUsrName) || '' + })) + } catch (error) { + console.warn(`[WCDB4] async group members failed chatroom=${chatroomId}:`, error) + return [] + } + } + getGroupNicknames(chatroomId: string): Map { const cached = this.groupNicknameCache.get(chatroomId) if (cached) return cached @@ -1167,6 +1426,28 @@ export class Wcdb4Client { return nicknames } + private async getGroupNicknamesAsync(chatroomId: string): Promise> { + const cached = this.groupNicknameCache.get(chatroomId) + if (cached) return cached + const nicknames = new Map() + if (!this.wcdbGetGroupNicknames || !chatroomId) return nicknames + + const rows = await this.callJsonAsync< + Record | Record[] + >(this.wcdbGetGroupNicknames as unknown as KoffiAsyncFunction, chatroomId) + this.readStringMap(rows, [ + 'nickname', + 'nickName', + 'displayName', + 'display_name', + 'groupNickname', + 'group_nickname', + 'name' + ]).forEach((nickname, username) => nicknames.set(username, nickname)) + this.groupNicknameCache.set(chatroomId, nicknames) + return nicknames + } + async getVoiceData( sessionId: string, createTime: number, @@ -1354,9 +1635,11 @@ export class Wcdb4Client { } this.wcdbShutdown = lib.func('int32 wcdb_shutdown()') as () => number - this.wcdbOpenAccount = lib.func( + const openAccount = lib.func( 'int32 wcdb_open_account(const char* path, const char* key, _Out_ int64* handle)' ) as (sessionDbPath: string, key: string, handleOut: WcdbHandleOut) => number + this.wcdbOpenAccount = openAccount + this.wcdbOpenAccountAsync = openAccount as unknown as KoffiAsyncFunction this.wcdbFreeString = lib.func('void wcdb_free_string(void* ptr)') as (ptr: unknown) => void this.wcdbGetSessions = lib.func( 'int32 wcdb_get_sessions(int64 handle, _Out_ void** outJson)' @@ -1646,6 +1929,41 @@ export class Wcdb4Client { } } + private callJsonAsync(fn: KoffiAsyncFunction, ...args: unknown[]): Promise { + const handle = this.ensureHandle() + const outJson: WcdbVoidOut = [null] + return new Promise((resolve, reject) => { + fn.async(handle, ...args, outJson, (error: unknown, result: unknown) => { + if (error) { + reject(error) + return + } + const code = Number(result) + if (code !== 0 || !outJson[0]) { + reject(new Error(`WCDB async call failed, code: ${code}`)) + return + } + try { + const json = this.koffi!.decode(outJson[0], 'char', -1) + resolve(JSON.parse(json) as T) + } catch (decodeError) { + reject(decodeError) + } finally { + this.wcdbFreeString?.(outJson[0]) + } + }) + }) + } + + private callAsyncCode(fn: KoffiAsyncFunction, ...args: unknown[]): Promise { + return new Promise((resolve, reject) => { + fn.async(...args, (error: unknown, result: unknown) => { + if (error) reject(error) + else resolve(Number(result)) + }) + }) + } + private ensureHandle(): number { if (!this.handle) throw new Error('微信 4.0 数据库未打开') return this.handle @@ -1810,6 +2128,26 @@ export class Wcdb4Client { } } + private async hydrateDisplayNamesAsync(usernames: string[]): Promise { + if (!this.wcdbGetDisplayNames) return + const missing = this.uniq(usernames).filter((username) => !this.displayNameCache.has(username)) + if (missing.length === 0) return + try { + const rows = await this.callJsonAsync< + Record | Record[] + >(this.wcdbGetDisplayNames as unknown as KoffiAsyncFunction, JSON.stringify(missing)) + this.readStringMap(rows, [ + 'nickname', + 'displayName', + 'display_name', + 'remark', + 'name' + ]).forEach((name, username) => this.displayNameCache.set(username, name)) + } catch { + // Names are optional; usernames remain usable. + } + } + private hydrateAvatarUrls(usernames: string[]): void { const missing = this.uniq(usernames).filter((username) => !this.avatarCache.has(username)) if (missing.length === 0) return @@ -1844,6 +2182,27 @@ export class Wcdb4Client { } } + private async hydrateAvatarUrlsAsync(usernames: string[]): Promise { + if (!this.wcdbGetAvatarUrls) return + const missing = this.uniq(usernames).filter((username) => !this.avatarCache.has(username)) + if (missing.length === 0) return + try { + const rows = await this.callJsonAsync< + Record | Record[] + >(this.wcdbGetAvatarUrls as unknown as KoffiAsyncFunction, JSON.stringify(missing)) + this.readStringMap(rows, [ + 'avatarUrl', + 'avatar_url', + 'headImgUrl', + 'm_nsHeadImgUrl', + 'big_head_img_url', + 'small_head_img_url' + ]).forEach((avatar, username) => this.avatarCache.set(username, avatar)) + } catch { + // Avatars are optional. + } + } + private readContactAvatarUrls(usernames: string[]): Map { const result = new Map() if (!this.wcdbExecQuery || usernames.length === 0) return result diff --git a/src/main/wechat-db.ts b/src/main/wechat-db.ts index a546fcb..6f86049 100644 --- a/src/main/wechat-db.ts +++ b/src/main/wechat-db.ts @@ -35,20 +35,12 @@ export interface GroupMemberInfo { export class WechatDb { private wcdb4Client: Wcdb4Client private chatMd5ToUsername = new Map() + private chatTableMappingLoaded = false static async create(rawKey: string, accountRoot?: string): Promise { - // WCDB native init must run on the Electron main process; worker threads - // get -1006 from wcdb_init. Initialize the client synchronously here - // (and keep create() async for callers that already await it). - return new Promise((resolve, reject) => { - try { - const client = new Wcdb4Client(rawKey, accountRoot) - client.open() - resolve(new WechatDb(rawKey, accountRoot, client)) - } catch (error) { - reject(error instanceof Error ? error : new Error(String(error))) - } - }) + const client = new Wcdb4Client(rawKey, accountRoot) + await client.openAsync() + return new WechatDb(rawKey, accountRoot, client) } constructor( @@ -61,11 +53,22 @@ export class WechatDb { const client = clientOverride || new Wcdb4Client(rawKey, accountRoot) if (!clientOverride) client.open() this.wcdb4Client = client - for (const table of initialChatTables || client.getChatTables()) { + for (const table of initialChatTables || []) { if (table.name.startsWith('Chat_')) { this.chatMd5ToUsername.set(table.name.substring(5), table.db_number) } } + this.chatTableMappingLoaded = Boolean(initialChatTables) + } + + private ensureChatTableMapping(): void { + if (this.chatTableMappingLoaded) return + for (const table of this.wcdb4Client.getChatTables()) { + if (table.name.startsWith('Chat_')) { + this.chatMd5ToUsername.set(table.name.substring(5), table.db_number) + } + } + this.chatTableMappingLoaded = true } public getUserList(nicknameFilter?: string): UserContact[] { @@ -112,6 +115,7 @@ export class WechatDb { } public getGroupMembersForChat(userMd5: string): Record { + this.ensureChatTableMapping() const username = this.chatMd5ToUsername.get(userMd5) if (!username || !username.endsWith('@chatroom')) return {} @@ -154,6 +158,7 @@ export class WechatDb { endTime?: number, options?: Wcdb4MessageQueryOptions ): WechatMessage[] { + this.ensureChatTableMapping() const username = this.chatMd5ToUsername.get(userMd5) if (!username) return [] return this.wcdb4Client.getMessages(username, startTime, endTime, options).map((message) => ({ @@ -162,6 +167,24 @@ export class WechatDb { })) } + public async getUserMessagesAsync( + userMd5: string, + startTime?: number, + endTime?: number, + options?: Wcdb4MessageQueryOptions + ): Promise { + this.ensureChatTableMapping() + const username = this.chatMd5ToUsername.get(userMd5) + if (!username) return [] + const messages = await this.wcdb4Client.getMessagesAsync( + username, + startTime, + endTime, + options + ) + return messages.map((message) => ({ ...message, ...message.raw })) + } + public searchAllMessages(keyword: string): string | null { const lowerKeyword = keyword.trim().toLowerCase() if (!lowerKeyword) return null diff --git a/src/preload/index.d.ts b/src/preload/index.d.ts index a3e5f1e..4df09df 100644 --- a/src/preload/index.d.ts +++ b/src/preload/index.d.ts @@ -93,6 +93,11 @@ declare global { contacts: Contact[] updatedAt: number } | null> + getStartupCache: () => Promise<{ + self?: { wxid: string; nickname: string; avatar?: string; accountRoot: string } + contacts: Contact[] + updatedAt: number + } | null> getContacts: (filter?: string) => Promise getContactAvatars: (usernames: string[]) => Promise> getCachedMessages: ( @@ -100,6 +105,26 @@ declare global { startTime?: number, endTime?: number ) => Promise + getCachedMessagePage: ( + userMd5: string, + startTime?: number, + endTime?: number + ) => Promise<{ + hit: boolean + messages: Message[] + groupSnapshot?: { + roomId: string + memberCount: number + members: { + wxid: string + nickname: string + groupNickname: string + wechatNickname: string + remark: string + avatar: string + }[] + } + }> getMessages: ( userMd5: string, startTime?: number, diff --git a/src/preload/index.ts b/src/preload/index.ts index 6ba21ef..781f8bd 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -26,10 +26,13 @@ const api = { revealAppLog: () => ipcRenderer.invoke('app-log:reveal'), initDb: (key: string) => ipcRenderer.invoke('db:init', key), getBootstrapCache: () => ipcRenderer.invoke('db:getBootstrapCache'), + getStartupCache: () => ipcRenderer.invoke('db:getStartupCache'), getContacts: (filter?: string) => ipcRenderer.invoke('db:getContacts', filter), getContactAvatars: (usernames: string[]) => ipcRenderer.invoke('db:getContactAvatars', usernames), getCachedMessages: (userMd5: string, startTime?: number, endTime?: number) => ipcRenderer.invoke('db:getCachedMessages', userMd5, startTime, endTime), + getCachedMessagePage: (userMd5: string, startTime?: number, endTime?: number) => + ipcRenderer.invoke('db:getCachedMessagePage', userMd5, startTime, endTime), getMessages: ( userMd5: string, startTime?: number, diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 6de10b5..4dc381d 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -40,7 +40,9 @@ interface SelfInfo { const MAC_KEY_FAQ_URL = 'https://github.com/hicccc77/WeFlow/blob/main/docs/MAC-KEY-FAQ.md' const MESSAGE_MONITOR_DEBOUNCE_MS = 8000 +const INITIAL_MESSAGE_COUNT = 20 const MESSAGE_PAGE_SIZE = 100 +const MESSAGE_PREFETCH_COUNT = INITIAL_MESSAGE_COUNT + MESSAGE_PAGE_SIZE const EXPORT_PREVIEW_LIMIT = 20 const getMessageIdentity = (message: Message): string => { if (message.localId) return `local:${message.localId}` @@ -48,6 +50,70 @@ const getMessageIdentity = (message: Message): string => { return `${message.createTime || 0}:${message.from}:${message.type}:${message.content}` } +const normalizeQuotedText = (value: string | undefined): string => + String(value || '') + .replace(/\s+/g, ' ') + .trim() + +const isInternalReferenceSender = (value: string | undefined): boolean => { + const sender = String(value || '').trim() + return ( + !sender || + sender.endsWith('@chatroom') || + sender.startsWith('wxid_') || + /^[a-z0-9_@.-]{12,}$/i.test(sender) + ) +} + +const enrichQuotedMessages = (messages: Message[], referenceMessages: Message[]): Message[] => { + const imageDatNameByMd5 = new Map() + const messagesByContent = new Map() + + for (const message of referenceMessages) { + if ( + message.contentData?.type === 'image' && + message.contentData.md5 && + message.contentData.datName + ) { + imageDatNameByMd5.set(message.contentData.md5, message.contentData.datName) + } + const content = normalizeQuotedText(message.content) + if (!content) continue + const candidates = messagesByContent.get(content) || [] + candidates.push(message) + messagesByContent.set(content, candidates) + } + + return messages.map((message) => { + if (message.contentData?.type !== 'quote') return message + const quote = message.contentData + let quotedImageDatName = quote.quotedImageDatName + if (!quotedImageDatName && quote.quotedImageMd5) { + quotedImageDatName = imageDatNameByMd5.get(quote.quotedImageMd5) + } + + let quotedSender = quote.quotedSender + if (isInternalReferenceSender(quotedSender)) { + const candidates = messagesByContent.get(normalizeQuotedText(quote.quotedContent)) || [] + const source = candidates + .filter((candidate) => (candidate.createTime || 0) <= (message.createTime || Infinity)) + .sort((left, right) => (right.createTime || 0) - (left.createTime || 0))[0] + if (source?.name && !isInternalReferenceSender(source.name)) quotedSender = source.name + } + + if ( + quotedSender === quote.quotedSender && + quotedImageDatName === quote.quotedImageDatName + ) { + return message + } + return { + ...message, + contentData: { ...quote, quotedSender, quotedImageDatName } + } + }) +} + const areMessagesEquivalent = (left: Message[], right: Message[]): boolean => { if (left === right) return true if (left.length !== right.length) return false @@ -79,7 +145,7 @@ type StartupProgress = { } const formatGroupMemberName = (member: GroupSnapshot['members'][number]): string => - member.nickname || member.wxid + member.groupNickname || member.nickname || member.remark || member.wechatNickname || member.wxid const buildSyntheticGroupMessages = ( previous: GroupSnapshot | null, @@ -150,7 +216,6 @@ function App(): React.ReactElement { const [messages, setMessages] = useState([]) const [isMessagesLoading, setIsMessagesLoading] = useState(false) const [filteredContacts, setFilteredContacts] = useState([]) - const [dateRange, setDateRange] = useState('today') // 默认今天 const [contentFilter, setContentFilter] = useState('') const [isFetchingDbKey, setIsFetchingDbKey] = useState(false) const [dbKeyStatus, setDbKeyStatus] = useState('') @@ -195,6 +260,10 @@ function App(): React.ReactElement { const currentGroupSnapshotRef = React.useRef(null) const syntheticGroupMessagesRef = React.useRef>({}) const groupMemberMetaRef = React.useRef>>({}) + const messageHistoryRef = React.useRef([]) + const messagesRef = React.useRef([]) + const messagePrefetchRef = React.useRef | null>(null) + messagesRef.current = messages React.useEffect(() => { if (!reportNotice) return const timer = window.setTimeout(() => setReportNotice(''), 3200) @@ -486,7 +555,32 @@ function App(): React.ReactElement { } if (!autoLoginEnabled) return try { - const result = await window.api.initDb(key) + const startupCacheReady = await loadStartupCache() + const initPromise = window.api.initDb(key) + if (startupCacheReady) { + setIsAuthenticated(true) + setIsDatabaseConnected(false) + setBootState('login') + void initPromise.then(async (result) => { + const success = typeof result === 'boolean' ? result : result.success + if (!success) { + const error = typeof result === 'boolean' ? '' : result.error + setDbKeyStatus(`后台连接失败${error ? `: ${error}` : ''}`) + setDbKeyStatusKind('error') + return + } + setIsNativeMonitorActive(typeof result !== 'boolean' && result.monitoring === true) + setIsDatabaseConnected(true) + setDbKeyStatus('已连接数据库') + // Cached contacts/self info are enough for startup. Native refresh is + // intentionally user-triggered so it cannot freeze the first session. + }).catch((error) => { + console.warn('[Startup] background database init failed:', error) + setDbKeyStatusKind('error') + }) + return + } + const result = await initPromise if (!active) return const success = typeof result === 'boolean' ? result : result.success if (success) { @@ -497,9 +591,13 @@ function App(): React.ReactElement { setIsDatabaseConnected(true) setDbKeyStatus('已自动连接') setDbKeyStatusKind('success') - await loadContacts() - await refreshSelfInfo(3) - setIsAuthenticated(true) + const hasBootstrap = await loadBootstrapCache() + if (hasBootstrap) { + setIsAuthenticated(true) + } else { + await Promise.all([loadContacts(), refreshSelfInfo(3)]) + setIsAuthenticated(true) + } } else { const error = typeof result === 'boolean' ? '' : result.error setDbKeyStatus(`自动连接失败,请重新输入${error ? `: ${error}` : ''}`) @@ -569,7 +667,7 @@ function App(): React.ReactElement { detail: '正在读取本地缓存', percent: 25 }) - await loadBootstrapCache() + const hasBootstrap = await loadBootstrapCache() // 持久化手动输入的密钥,供下次启动继续使用 void window.api.saveDbKey(keyToUse).catch(() => undefined) void window.api.getSettings().then((current) => { @@ -585,15 +683,28 @@ function App(): React.ReactElement { }) // 账号识别依赖联系人数据就绪。返回登录后数据已被清空,如果先查账号, // 会出现“数据库已连接,但账号未连接”的分离状态。手动连接与启动自动连接保持同一顺序。 - await loadContacts({ waitForAvatars: false }) - await refreshSelfInfo(3) + if (hasBootstrap) { + // Cached contacts are sufficient for the first paint. Refresh native data in the background. + setIsAuthenticated(true) + void Promise.all([ + loadContacts({ waitForAvatars: false }), + refreshSelfInfo(3) + ]).catch((error) => { + console.warn('[Startup] background refresh failed:', error) + }) + } else { + await Promise.all([ + loadContacts({ waitForAvatars: false }), + refreshSelfInfo(3) + ]) + setIsAuthenticated(true) + } setStartupProgress({ title: '加载完成', subtitle: '正在进入主页面', detail: '联系人和头像已准备好', percent: 100 }) - setIsAuthenticated(true) setIsDatabaseConnected(true) setBootState('login') window.setTimeout(() => { @@ -646,10 +757,14 @@ function App(): React.ReactElement { const applyGroupMemberMeta = React.useCallback( (contact: Contact | null, baseMessages: Message[]): Message[] => { if (!contact || contact.type !== 'group') return baseMessages + const enrichedMessages = enrichQuotedMessages(baseMessages, [ + ...messageHistoryRef.current, + ...baseMessages + ]) const memberMap = groupMemberMetaRef.current[contact.md5] - if (!memberMap || memberMap.size === 0) return baseMessages + if (!memberMap || memberMap.size === 0) return enrichedMessages - return baseMessages.map((message) => { + return enrichedMessages.map((message) => { const senderId = String(message.senderId || message.name || '').trim() if (!senderId) return message const member = memberMap.get(senderId) @@ -672,23 +787,57 @@ function App(): React.ReactElement { [] ) + const storeGroupMemberMeta = React.useCallback( + (contact: Contact, snapshot: GroupSnapshot): void => { + currentGroupSnapshotRef.current = snapshot + groupMemberMetaRef.current[contact.md5] = new Map( + snapshot.members.map((member) => [ + member.wxid, + { nickname: formatGroupMemberName(member), avatar: member.avatar || '' } + ]) + ) + }, + [] + ) + const loadGroupMemberMeta = React.useCallback( async (contact: Contact | null): Promise => { if (!contact || contact.type !== 'group') return null const snapshot = await logGroupSnapshot(contact, 'load-member-meta') if (!snapshot) return null - currentGroupSnapshotRef.current = snapshot - groupMemberMetaRef.current[contact.md5] = new Map( - snapshot.members.map((member) => [ - member.wxid, - { nickname: member.nickname || member.wxid, avatar: member.avatar || '' } - ]) - ) + storeGroupMemberMeta(contact, snapshot) return snapshot }, - [logGroupSnapshot] + [logGroupSnapshot, storeGroupMemberMeta] ) + const handleReloadCurrentAvatars = React.useCallback(async (): Promise => { + const contact = selectedContact + if (!contact) return + + try { + const avatars = await window.api.getContactAvatars([contact.m_nsUsrName]) + const avatar = avatars[contact.m_nsUsrName] + if (avatar) { + const updateContact = (item: Contact): Contact => + item.md5 === contact.md5 ? { ...item, avatar } : item + setContacts((current) => current.map(updateContact)) + setFilteredContacts((current) => current.map(updateContact)) + setSelectedContact((current) => + current?.md5 === contact.md5 ? updateContact(current) : current + ) + } + } catch (error) { + console.warn('[Contacts] current avatar reload failed:', error) + } + + const snapshot = await loadGroupMemberMeta(contact) + if (!snapshot || selectedContactMd5Ref.current !== contact.md5) return + setMessages((current) => + applyGroupMemberMeta(contact, mergeSyntheticMessages(contact, current, snapshot.roomId)) + ) + }, [applyGroupMemberMeta, loadGroupMemberMeta, mergeSyntheticMessages, selectedContact]) + const handleAutoGetDbKey = async (): Promise => { if (isFetchingDbKey) return setIsFetchingDbKey(true) @@ -761,65 +910,57 @@ function App(): React.ReactElement { setStartupProgress(null) } - const getDateRangeParams = ( - range: string - ): { startTime: number | undefined; endTime: number | undefined } => { - const now = new Date() - const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime() / 1000 - - let startTime: number | undefined - let endTime: number | undefined - - switch (range) { - case 'today': - startTime = startOfToday - break - case 'yesterday': - startTime = startOfToday - 86400 - endTime = startOfToday - 1 // 昨天结束 - break - case '7': - startTime = Math.floor(Date.now() / 1000) - 7 * 86400 - break - case '30': - startTime = Math.floor(Date.now() / 1000) - 30 * 86400 - break - case 'all': - startTime = undefined - break - default: - startTime = startOfToday - } - return { startTime, endTime } - } - - const handleSelectContact = async (contact: Contact): Promise => { + const handleSelectContact = async (contact: Contact, forceLive = false): Promise => { setSelectedContact(contact) selectedContactMd5Ref.current = contact.md5 currentGroupSnapshotRef.current = null setIsMessagesLoading(true) - const { startTime, endTime } = getDateRangeParams(dateRange) - const cachedMsgs = await window.api.getCachedMessages(contact.md5, startTime, endTime) + const cachedPage = await window.api.getCachedMessagePage(contact.md5) + const cachedMsgs = cachedPage.messages if (selectedContactMd5Ref.current !== contact.md5) return - if (cachedMsgs.length) { - setMessages( - applyGroupMemberMeta( - contact, - mergeSyntheticMessages(contact, cachedMsgs.slice(-MESSAGE_PAGE_SIZE)) - ) - ) - } else { - setMessages([]) + if (cachedPage.groupSnapshot) { + storeGroupMemberMeta(contact, cachedPage.groupSnapshot) } + messageHistoryRef.current = cachedMsgs + setMessages( + applyGroupMemberMeta( + contact, + mergeSyntheticMessages(contact, cachedMsgs.slice(-INITIAL_MESSAGE_COUNT)) + ) + ) + const needsLivePage = + forceLive || (isDatabaseConnected && cachedMsgs.length < MESSAGE_PREFETCH_COUNT) + if (!needsLivePage) { + setIsMessagesLoading(false) + if (contact.type === 'group' && cachedMsgs.length > 0 && !cachedPage.groupSnapshot) { + window.setTimeout(() => { + void loadGroupMemberMeta(contact).then((snapshot) => { + if (!snapshot || selectedContactMd5Ref.current !== contact.md5) return + setMessages((current) => + applyGroupMemberMeta( + contact, + mergeSyntheticMessages(contact, current, snapshot.roomId) + ) + ) + }) + }, 0) + } + return + } + if (cachedMsgs.length >= INITIAL_MESSAGE_COUNT) setIsMessagesLoading(false) await waitForPaint() - try { - const msgs = await window.api.getMessages(contact.md5, startTime, endTime, { - limit: MESSAGE_PAGE_SIZE + const loadLivePage = async (): Promise => { + const msgs = await window.api.getMessages(contact.md5, undefined, undefined, { + limit: MESSAGE_PREFETCH_COUNT }) if (selectedContactMd5Ref.current !== contact.md5) return - const cachedMessages = applyGroupMemberMeta(contact, mergeSyntheticMessages(contact, msgs)) - setMessages(cachedMessages) - if (contact.type === 'group') { + messageHistoryRef.current = msgs + const visibleMessages = applyGroupMemberMeta( + contact, + mergeSyntheticMessages(contact, msgs.slice(-INITIAL_MESSAGE_COUNT)) + ) + setMessages(visibleMessages) + if (contact.type === 'group' && visibleMessages.length > 0) { window.setTimeout(() => { void loadGroupMemberMeta(contact).then((snapshot) => { if (selectedContactMd5Ref.current !== contact.md5) return @@ -833,28 +974,79 @@ function App(): React.ReactElement { }) }, 120) } + } + const prefetchPromise = loadLivePage() + messagePrefetchRef.current = prefetchPromise + try { + await prefetchPromise } finally { + if (messagePrefetchRef.current === prefetchPromise) messagePrefetchRef.current = null if (selectedContactMd5Ref.current === contact.md5) setIsMessagesLoading(false) } } + React.useEffect(() => { + if (!isDatabaseConnected || !selectedContact || messages.length > 0) return + // The first live page uses async native cursors, so a cache miss can be filled + // without blocking the Electron main thread. + void handleSelectContact(selectedContact) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isDatabaseConnected]) + + const loadStartupCache = async (): Promise => { + try { + const cache = await window.api.getStartupCache() + if (!cache?.contacts.length) return false + setContacts(cache.contacts) + setFilteredContacts(cache.contacts) + if (cache.self) setSelfInfo(cache.self) + return true + } catch (error) { + console.warn('[StartupCache] load failed:', error) + return false + } + } + const handleLoadOlderMessages = async (): Promise => { const contact = selectedContact - if (!contact || isMessagesLoading || messages.length === 0) return - const oldestTime = messages[0]?.createTime + if (!contact || messagesRef.current.length === 0) return + if (messagePrefetchRef.current) await messagePrefetchRef.current + if (selectedContactMd5Ref.current !== contact.md5) return + const currentMessages = messagesRef.current + const historyMessages = messageHistoryRef.current + const firstVisibleIndex = historyMessages.findIndex( + (message) => getMessageIdentity(message) === getMessageIdentity(currentMessages[0]) + ) + if (firstVisibleIndex > 0) { + const inMemoryOlder = historyMessages.slice( + Math.max(0, firstVisibleIndex - MESSAGE_PAGE_SIZE), + firstVisibleIndex + ) + setMessages((current) => + applyGroupMemberMeta( + contact, + mergeSyntheticMessages(contact, mergeMessagePages(inMemoryOlder, current)) + ) + ) + return + } + const oldestTime = currentMessages[0]?.createTime if (!oldestTime) return - const { startTime } = getDateRangeParams(dateRange) - if (startTime && oldestTime <= startTime) return setIsMessagesLoading(true) try { - const olderMessages = await window.api.getMessages( + const cachedPage = await window.api.getCachedMessagePage( contact.md5, - startTime, - oldestTime - 1, - { limit: MESSAGE_PAGE_SIZE } + undefined, + oldestTime - 1 ) + const olderMessages = cachedPage.hit + ? cachedPage.messages + : await window.api.getMessages(contact.md5, undefined, oldestTime - 1, { + limit: MESSAGE_PAGE_SIZE + }) if (selectedContactMd5Ref.current !== contact.md5) return + messageHistoryRef.current = mergeMessagePages(olderMessages, historyMessages) setMessages((current) => applyGroupMemberMeta(contact, mergeSyntheticMessages(contact, mergeMessagePages(olderMessages, current))) ) @@ -878,40 +1070,6 @@ function App(): React.ReactElement { } } - const handleDateRangeChange = (range: string): void => { - setDateRange(range) - if (selectedContact) { - const { startTime, endTime } = getDateRangeParams(range) - window.api - .getCachedMessages(selectedContact.md5, startTime, endTime) - .then((cachedMessages) => { - if (!cachedMessages.length) return - setMessages( - applyGroupMemberMeta( - selectedContact, - mergeSyntheticMessages(selectedContact, cachedMessages.slice(-MESSAGE_PAGE_SIZE)) - ) - ) - }) - setIsMessagesLoading(true) - window.api - .getMessages(selectedContact.md5, startTime, endTime, { limit: MESSAGE_PAGE_SIZE }) - .then((nextMessages) => { - setMessages( - applyGroupMemberMeta( - selectedContact, - mergeSyntheticMessages(selectedContact, nextMessages) - ) - ) - setIsMessagesLoading(false) - }) - .catch((error) => { - console.warn('[Messages] date range load failed:', error) - setIsMessagesLoading(false) - }) - } - } - React.useEffect(() => { if (!isAuthenticated || !selectedContact || !isNativeMonitorActive) return @@ -928,12 +1086,11 @@ function App(): React.ReactElement { } refreshInFlight = true try { - const range = getDateRangeParams(dateRange) const latestMessages = await window.api.getMessages( contactMd5, - range.startTime, - range.endTime, - { limit: MESSAGE_PAGE_SIZE } + undefined, + undefined, + { limit: INITIAL_MESSAGE_COUNT } ) const nextMessages = applyGroupMemberMeta( selectedContact, @@ -975,7 +1132,6 @@ function App(): React.ReactElement { unsubscribe() } }, [ - dateRange, isAuthenticated, isNativeMonitorActive, selectedContact, @@ -1201,8 +1357,6 @@ function App(): React.ReactElement { onSearch={handleSearchContacts} onContentFilter={setContentFilter} width={sidebarWidth} - dateRange={dateRange} - onDateRangeChange={handleDateRangeChange} selfInfo={selfInfo} dbReady={isDatabaseConnected} onOpenSettings={openSettings} @@ -1214,11 +1368,11 @@ function App(): React.ReactElement { messages={messages} isLoadingMessages={isMessagesLoading} contentFilter={contentFilter} - dateRange={dateRange} onContentFilterChange={setContentFilter} - onRefresh={() => selectedContact && handleSelectContact(selectedContact)} + onRefresh={() => selectedContact && handleSelectContact(selectedContact, true)} onRefreshData={loadContacts} - onLoadOlderMessages={() => void handleLoadOlderMessages()} + onReloadAvatars={handleReloadCurrentAvatars} + onLoadOlderMessages={handleLoadOlderMessages} onCreateGroupReport={handleOpenReportWorkspace} isAiLoading={reportGeneration.isGenerating} /> diff --git a/src/renderer/src/assets/main.css b/src/renderer/src/assets/main.css index 25570c7..3f02aa3 100644 --- a/src/renderer/src/assets/main.css +++ b/src/renderer/src/assets/main.css @@ -1803,6 +1803,41 @@ body { accent-color: var(--wxex-brand); } +.chat-avatar-note { + max-width: 320px; + color: var(--wxex-text-muted); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.chat-avatar-reload { + display: inline-flex; + align-items: center; + gap: 4px; + white-space: nowrap; +} + +.chat-avatar-reload svg { + width: 14px; + height: 14px; + fill: none; + stroke: currentColor; + stroke-linecap: round; + stroke-linejoin: round; + stroke-width: 1.8; +} + +.chat-avatar-reload svg.is-spinning { + animation: chat-avatar-spin 0.8s linear infinite; +} + +@keyframes chat-avatar-spin { + to { + transform: rotate(360deg); + } +} + .chat-status-separator { width: 1px; height: 14px; @@ -1827,6 +1862,12 @@ body { cursor: default; } +@media (max-width: 1120px) { + .chat-avatar-note { + display: none; + } +} + .empty-conversation-state { display: flex; flex-direction: column; diff --git a/src/renderer/src/components/ChatWindow.tsx b/src/renderer/src/components/ChatWindow.tsx index e24ae04..30834d2 100644 --- a/src/renderer/src/components/ChatWindow.tsx +++ b/src/renderer/src/components/ChatWindow.tsx @@ -11,62 +11,24 @@ interface ChatWindowProps { messages: Message[] isLoadingMessages?: boolean contentFilter?: string - dateRange?: string onContentFilterChange?: (keyword: string) => void onRefresh?: () => void onRefreshData?: () => void - onLoadOlderMessages?: () => void + onReloadAvatars?: () => Promise + onLoadOlderMessages?: () => Promise onCreateGroupReport?: () => void isAiLoading?: boolean } -const DATE_RANGE_LABELS: Record = { - today: '今天', - yesterday: '昨日', - '7': '7 天', - '30': '30 天', - all: '全部' -} - -const formatClock = (date: Date): string => - `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}` - -const formatRangeDate = (date: Date, now: Date): string => { - const clock = formatClock(date) - if (date.getFullYear() === now.getFullYear()) { - return `${date.getMonth() + 1} 月 ${date.getDate()} 日 ${clock}` - } - return `${date.getFullYear()} 年 ${date.getMonth() + 1} 月 ${date.getDate()} 日 ${clock}` -} - -const getChatHeaderRangeLabel = (range: string): string => { - const now = new Date() - const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()) - const endOfYesterday = new Date(startOfToday.getTime() - 60_000) - - if (range === 'today') return `今天 00:00—现在` - if (range === 'yesterday') return `昨天 00:00—${formatClock(endOfYesterday)}` - if (range === '7') { - const start = new Date(Date.now() - 7 * 86400000) - return `${formatRangeDate(start, now)}—现在` - } - if (range === '30') { - const start = new Date(Date.now() - 30 * 86400000) - return `${formatRangeDate(start, now)}—现在` - } - if (range === 'all') return '全部记录' - return DATE_RANGE_LABELS[range] || '当前范围' -} - const ChatWindow: React.FC = ({ contact, messages, isLoadingMessages, contentFilter, - dateRange = 'today', onContentFilterChange, onRefresh, onRefreshData, + onReloadAvatars, onLoadOlderMessages, onCreateGroupReport, isAiLoading = false @@ -86,6 +48,8 @@ const ChatWindow: React.FC = ({ ) const [showAvatar, setShowAvatar] = useState(true) const [isAtLatest, setIsAtLatest] = useState(true) + const [isReloadingAvatars, setIsReloadingAvatars] = useState(false) + const previousScrollTopRef = useRef(0) const scrollToBottom = useCallback((): void => { messagesEndRef.current?.scrollIntoView({ behavior: 'auto' }) @@ -95,15 +59,34 @@ const ChatWindow: React.FC = ({ const handleMessageListScroll = useCallback((event: React.UIEvent): void => { const target = event.currentTarget const distanceToBottom = target.scrollHeight - target.scrollTop - target.clientHeight - setIsAtLatest(distanceToBottom <= 24) + if (distanceToBottom <= 24) { + setIsAtLatest(true) + } else if (target.scrollTop < previousScrollTopRef.current - 1) { + setIsAtLatest(false) + } + previousScrollTopRef.current = target.scrollTop }, []) + useEffect(() => { + previousScrollTopRef.current = 0 + setIsAtLatest(true) + }, [contact?.md5]) + useEffect(() => { if (!isAtLatest) return const frame = window.requestAnimationFrame(() => scrollToBottom()) return () => window.cancelAnimationFrame(frame) }, [isAtLatest, messages, scrollToBottom]) + useEffect(() => { + if (!isAtLatest) return + const content = messageListRef.current?.querySelector('.virtual-message-list') + if (!content) return + const observer = new ResizeObserver(() => scrollToBottom()) + observer.observe(content) + return () => observer.disconnect() + }, [contact?.md5, isAtLatest, scrollToBottom]) + const openImagePreview = (imageUrl: string): void => { setPreviewImage(imageUrl) setImageScale(1) @@ -155,6 +138,16 @@ const ChatWindow: React.FC = ({ imageDragRef.current = null } + const handleReloadAvatars = async (): Promise => { + if (!onReloadAvatars || isReloadingAvatars) return + setIsReloadingAvatars(true) + try { + await onReloadAvatars() + } finally { + setIsReloadingAvatars(false) + } + } + useEffect(() => { if (!previewImage) return @@ -186,14 +179,11 @@ const ChatWindow: React.FC = ({ }, [messages, contentFilter]) if (!contact) return - const dateRangeLabel = getChatHeaderRangeLabel(dateRange) - return (
= ({ count={filteredMessages.length} showAvatar={showAvatar} isAtLatest={isAtLatest} + isReloadingAvatars={isReloadingAvatars} onShowAvatarChange={setShowAvatar} + onReloadAvatars={() => void handleReloadAvatars()} onJumpToLatest={scrollToBottom} /> diff --git a/src/renderer/src/components/ImageBubble.tsx b/src/renderer/src/components/ImageBubble.tsx index 99f2d2d..381430c 100644 --- a/src/renderer/src/components/ImageBubble.tsx +++ b/src/renderer/src/components/ImageBubble.tsx @@ -1,6 +1,45 @@ import { useState, useCallback, useEffect, useRef } from 'react' import type { JSX, MouseEvent } from 'react' +type CachedImage = { data: string; isThumbnail: boolean } + +const MAX_IMAGE_CACHE_ENTRIES = 80 +const imageDataUrlCache = new Map() + +function imageCacheKeys(imageMd5?: string, imageDatName?: string): string[] { + return [imageMd5 ? `md5:${imageMd5}` : '', imageDatName ? `dat:${imageDatName}` : ''].filter( + Boolean + ) +} + +function getCachedImage(imageMd5?: string, imageDatName?: string): CachedImage | undefined { + for (const key of imageCacheKeys(imageMd5, imageDatName)) { + const cached = imageDataUrlCache.get(key) + if (cached) { + imageDataUrlCache.delete(key) + imageDataUrlCache.set(key, cached) + return cached + } + } + return undefined +} + +function cacheImage( + imageMd5: string | undefined, + imageDatName: string | undefined, + cached: CachedImage +): void { + for (const key of imageCacheKeys(imageMd5, imageDatName)) { + imageDataUrlCache.delete(key) + imageDataUrlCache.set(key, cached) + } + while (imageDataUrlCache.size > MAX_IMAGE_CACHE_ENTRIES) { + const oldestKey = imageDataUrlCache.keys().next().value + if (!oldestKey) break + imageDataUrlCache.delete(oldestKey) + } +} + interface ImageBubbleProps { imageMd5?: string imageDatName?: string @@ -16,11 +55,12 @@ export function ImageBubble({ isThumb = false, onImageClick }: ImageBubbleProps): JSX.Element { - const [imageUrl, setImageUrl] = useState(null) + const initialCachedImage = getCachedImage(imageMd5, imageDatName) + const [imageUrl, setImageUrl] = useState(initialCachedImage?.data || null) const [loading, setLoading] = useState(false) const [upgrading, setUpgrading] = useState(false) const [error, setError] = useState(null) - const [isThumbnail, setIsThumbnail] = useState(false) + const [isThumbnail, setIsThumbnail] = useState(Boolean(initialCachedImage?.isThumbnail)) const containerRef = useRef(null) const loadImage = useCallback(async () => { @@ -34,6 +74,10 @@ export function ImageBubble({ try { const result = await window.api.getImage(imageMd5, imageDatName || isThumb, sessionId) if (result.success && result.data?.startsWith('data:image/')) { + cacheImage(imageMd5, imageDatName, { + data: result.data, + isThumbnail: Boolean(result.isThumb) + }) setImageUrl(result.data) setIsThumbnail(Boolean(result.isThumb)) setError(null) @@ -92,6 +136,10 @@ export function ImageBubble({ force: true }) if (result.success && result.data?.startsWith('data:image/')) { + cacheImage(imageMd5, imageDatName, { + data: result.data, + isThumbnail: Boolean(result.isThumb) + }) setImageUrl(result.data) setIsThumbnail(Boolean(result.isThumb)) setError(null) diff --git a/src/renderer/src/components/chat/ChatHeader.tsx b/src/renderer/src/components/chat/ChatHeader.tsx index 1a56016..ff4cfec 100644 --- a/src/renderer/src/components/chat/ChatHeader.tsx +++ b/src/renderer/src/components/chat/ChatHeader.tsx @@ -6,7 +6,6 @@ import { AiIcon, MoreIcon, RefreshIcon, SearchIcon } from './icons' interface ChatHeaderProps { contact: Contact isGroupChat: boolean - dateRangeLabel: string loadedCount: number filteredCount: number contentFilter: string @@ -20,7 +19,6 @@ interface ChatHeaderProps { export function ChatHeader({ contact, isGroupChat, - dateRangeLabel, loadedCount, filteredCount, contentFilter, @@ -65,7 +63,6 @@ export function ChatHeader({

{displayName}

{typeLabel} - {dateRangeLabel} {visibleCount} 条消息
diff --git a/src/renderer/src/components/chat/ChatStatusBar.tsx b/src/renderer/src/components/chat/ChatStatusBar.tsx index 707cb38..235a7ec 100644 --- a/src/renderer/src/components/chat/ChatStatusBar.tsx +++ b/src/renderer/src/components/chat/ChatStatusBar.tsx @@ -1,10 +1,13 @@ import React from 'react' +import { RefreshIcon } from './icons' interface ChatStatusBarProps { count: number showAvatar: boolean isAtLatest: boolean + isReloadingAvatars: boolean onShowAvatarChange: (show: boolean) => void + onReloadAvatars: () => void onJumpToLatest: () => void } @@ -12,7 +15,9 @@ export function ChatStatusBar({ count, showAvatar, isAtLatest, + isReloadingAvatars, onShowAvatarChange, + onReloadAvatars, onJumpToLatest }: ChatStatusBarProps): React.ReactElement { const jumpDisabled = isAtLatest || count === 0 @@ -29,6 +34,22 @@ export function ChatStatusBar({ /> 显示头像 + + 头像或名称缺失时,请先在微信中进入该聊天 + + - ))} - - ) -}