diff --git a/.gitignore b/.gitignore index 25fdeab..da6cc34 100644 --- a/.gitignore +++ b/.gitignore @@ -75,4 +75,5 @@ pnpm-lock.yaml wechat-research-site .codex weflow-web-offical -/Wedecrypt \ No newline at end of file +/Wedecrypt +/scripts/syncwcdb.py \ No newline at end of file diff --git a/electron/main.ts b/electron/main.ts index 50bcf37..57a9112 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -3997,4 +3997,3 @@ app.on('window-all-closed', () => { }) - diff --git a/electron/services/analyticsService.ts b/electron/services/analyticsService.ts index 1ba6c00..3bdee87 100644 --- a/electron/services/analyticsService.ts +++ b/electron/services/analyticsService.ts @@ -103,8 +103,10 @@ class AnalyticsService { if (username === 'filehelper') return false if (username.startsWith('gh_')) return false + if (username.toLowerCase() === 'weixin') return false + const excludeList = [ - 'weixin', 'qqmail', 'fmessage', 'medianote', 'floatbottle', + 'qqmail', 'fmessage', 'medianote', 'floatbottle', 'newsapp', 'brandsessionholder', 'brandservicesessionholder', 'notifymessage', 'opencustomerservicemsg', 'notification_messages', 'userexperience_alarm', 'helper_folders', 'placeholder_foldgroup', diff --git a/electron/services/annualReportService.ts b/electron/services/annualReportService.ts index 9686ec3..fcd29c4 100644 --- a/electron/services/annualReportService.ts +++ b/electron/services/annualReportService.ts @@ -170,7 +170,7 @@ class AnnualReportService { const rows = sessionResult.sessions as Record[] const excludeList = [ - 'weixin', 'qqmail', 'fmessage', 'medianote', 'floatbottle', + 'qqmail', 'fmessage', 'medianote', 'floatbottle', 'newsapp', 'brandsessionholder', 'brandservicesessionholder', 'notifymessage', 'opencustomerservicemsg', 'notification_messages', 'userexperience_alarm', 'helper_folders', 'placeholder_foldgroup', @@ -185,6 +185,7 @@ class AnnualReportService { if (username === 'filehelper') return false if (username.startsWith('gh_')) return false if (username.toLowerCase() === cleanedWxid.toLowerCase()) return false + if (username.toLowerCase() === 'weixin') return false for (const prefix of excludeList) { if (username.startsWith(prefix) || username === prefix) return false diff --git a/electron/services/chatService.ts b/electron/services/chatService.ts index 2390ae0..047a42e 100644 --- a/electron/services/chatService.ts +++ b/electron/services/chatService.ts @@ -428,6 +428,9 @@ class ChatService { private contactExtendedSelectableColumns: string[] | null = null private contactLabelNameMapCache: Map | null = null private contactLabelNameMapCacheAt = 0 + private readonly visibilityAnomalyLogWindowMs = 30000 + private readonly visibilityAnomalyLogBurst = 3 + private visibilityAnomalyLogState = new Map() private readonly contactLabelNameMapCacheTtlMs = 10 * 60 * 1000 private contactsLoadInFlight: { mode: 'lite' | 'full'; promise: Promise<{ success: boolean; contacts?: ContactInfo[]; error?: string }> } | null = null private contactsMemoryCache = new Map<'lite' | 'full', { scope: string; updatedAt: number; contacts: ContactInfo[] }>() @@ -480,7 +483,7 @@ class ChatService { return true } - private extractErrorCode(message?: string): number | null { + private extractErrorCode(message?: string | null): number | null { const text = String(message || '').trim() if (!text) return null const match = text.match(/(?:错误码\s*[::]\s*|\()(-?\d{2,6})(?:\)|\b)/) @@ -804,6 +807,20 @@ class ChatService { return { success: false, error: `会话表异常: ${detail}${tableInfo}${tables}${columns}` } } + const openimLocalTypeMap = await this.loadContactLocalTypeMapForEnterpriseOpenim(rows.map((row) => + String( + row.username || + row.user_name || + row.userName || + row.usrName || + row.UsrName || + row.talker || + row.talker_id || + row.talkerId || + '' + ).trim() + )) + // 转换为 ChatSession(先加载缓存,但不等待额外状态查询) const sessions: ChatSession[] = [] const now = Date.now() @@ -821,7 +838,11 @@ class ChatService { row.talkerId || '' - if (!this.shouldKeepSession(username)) continue + let sessionLocalType = this.getSessionLocalType(row) + if (!Number.isFinite(sessionLocalType) && this.isEnterpriseOpenimUsername(username)) { + sessionLocalType = openimLocalTypeMap.get(username) + } + if (!this.shouldKeepSession(username, sessionLocalType)) continue const sortTs = parseInt( row.sort_timestamp || @@ -921,13 +942,19 @@ class ChatService { for (const row of contactResult.contacts as Record[]) { const username = String(row.username || '').trim() - if (!username.startsWith('gh_') || existing.has(username)) continue + if (!username || existing.has(username)) continue + const lowered = username.toLowerCase() + const localType = this.getRowInt(row, ['local_type', 'localType', 'WCDB_CT_local_type'], Number.NaN) + const isOfficial = username.startsWith('gh_') + const isSpecialWeixin = lowered.startsWith('weixin') && lowered !== 'weixin' + const isSpecialOpenim = this.isAllowedEnterpriseOpenimByLocalType(username, localType) + if (!isOfficial && !isSpecialWeixin && !isSpecialOpenim) continue sessions.push({ username, type: 0, unreadCount: 0, - summary: '查看公众号历史消息', + summary: isOfficial ? '查看公众号历史消息' : '暂无会话记录', sortTimestamp: 0, lastTimestamp: 0, lastMsgType: 0, @@ -1875,11 +1902,21 @@ class ChatService { let type: 'friend' | 'group' | 'official' | 'former_friend' | 'other' = 'other' const localType = this.getRowInt(row, ['local_type', 'localType', 'WCDB_CT_local_type'], 0) const quanPin = String(this.getRowField(row, ['quan_pin', 'quanPin', 'WCDB_CT_quan_pin']) || '').trim() + const loweredUsername = username.toLowerCase() + const isOpenimEnterprise = this.isEnterpriseOpenimUsername(username) + if (isOpenimEnterprise && !this.isAllowedEnterpriseOpenimByLocalType(username, localType)) { + continue + } + const isVisibleWeixinContact = loweredUsername.startsWith('weixin') && loweredUsername !== 'weixin' if (username.endsWith('@chatroom')) { type = 'group' } else if (username.startsWith('gh_')) { type = 'official' + } else if (isOpenimEnterprise) { + type = 'friend' + } else if (isVisibleWeixinContact) { + type = 'friend' } else if (localType === 1 && !FRIEND_EXCLUDE_USERNAMES.has(username)) { type = 'friend' } else if (localType === 0 && quanPin) { @@ -1965,7 +2002,7 @@ class ChatService { return { success: false, error: connectResult.error || '数据库未连接' } } - const batchSize = Math.max(1, limit || this.messageBatchDefault) + const requestLimit = Math.max(1, Math.floor(limit || this.messageBatchDefault)) // 使用互斥锁保护游标状态访问 while (this.messageCursorMutex) { @@ -1988,13 +2025,14 @@ class ChatService { // 只在以下情况重新创建游标: // 1. 没有游标状态 - // 2. offset 为 0 (重新加载会话) - // 3. batchSize 改变 - // 4. startTime/endTime 改变(视为全新查询) - // 5. ascending 改变 + // 2. offset 变化导致游标位置不一致 + // 3. startTime/endTime 改变(视为全新查询) + // 4. ascending 改变 + // + // 注意:requestLimit 允许动态变化(前端可按“越往上拉批次越大”策略请求), + // 不应触发游标重建,否则会造成额外 reopen/skip 开销与抖动。 const needNewCursor = !state || offset !== state.fetched || // Offset mismatch -> must reset cursor - state.batchSize !== batchSize || state.startTime !== startTime || state.endTime !== endTime || state.ascending !== ascending @@ -2011,15 +2049,16 @@ class ChatService { // 创建新游标 // 注意:WeFlow 数据库中的 create_time 是以秒为单位的 + const cursorBatchSize = Math.max(1, Math.floor(state?.batchSize || requestLimit || this.messageBatchDefault)) const beginTimestamp = startTime > 10000000000 ? Math.floor(startTime / 1000) : startTime const endTimestamp = endTime > 10000000000 ? Math.floor(endTime / 1000) : endTime - const cursorResult = await wcdbService.openMessageCursor(sessionId, batchSize, ascending, beginTimestamp, endTimestamp) + const cursorResult = await wcdbService.openMessageCursor(sessionId, cursorBatchSize, ascending, beginTimestamp, endTimestamp) if (!cursorResult.success || !cursorResult.cursor) { console.error('[ChatService] 打开消息游标失败:', cursorResult.error) return { success: false, error: cursorResult.error || '打开消息游标失败' } } - state = { cursor: cursorResult.cursor, fetched: 0, batchSize, startTime, endTime, ascending } + state = { cursor: cursorResult.cursor, fetched: 0, batchSize: cursorBatchSize, startTime, endTime, ascending } this.messageCursors.set(sessionId, state) await this.trimMessageCursorStates(sessionId) @@ -2030,19 +2069,53 @@ class ChatService { if (offset > 0) { console.warn(`[ChatService] 新游标需跳过 ${offset} 条消息(startTime=${startTime}, endTime=${endTime})`) let skipped = 0 - const maxSkipAttempts = Math.ceil(offset / batchSize) + 5 // 防止无限循环 + const maxSkipAttempts = Math.ceil(offset / cursorBatchSize) + 5 // 防止无限循环 let attempts = 0 + let emptySkipBatchStreak = 0 while (skipped < offset && attempts < maxSkipAttempts) { attempts++ const skipBatch = await wcdbService.fetchMessageBatch(state.cursor) if (!skipBatch.success) { console.error('[ChatService] 跳过消息批次失败:', skipBatch.error) + await this.closeMessageCursorBySession(sessionId) return { success: false, error: skipBatch.error || '跳过消息失败' } } if (!skipBatch.rows || skipBatch.rows.length === 0) { + if (skipBatch.hasMore && emptySkipBatchStreak < 2) { + emptySkipBatchStreak += 1 + console.warn( + `[ChatService] 跳过遇到空批次,继续重试: streak=${emptySkipBatchStreak}, skipped=${skipped}/${offset}` + ) + continue + } + + // 部分会话在“新游标 + offset 跳过”路径会出现首批空数据但实际仍有消息, + // 回退到稳定的 direct-offset 路径避免误判到底。 + if (skipped === 0 && startTime === 0 && endTime === 0 && !ascending) { + const fallbackResult = await this.getMessagesByOffsetStable(sessionId, offset, requestLimit) + if (fallbackResult.success && Array.isArray(fallbackResult.messages)) { + await this.closeMessageCursorBySession(sessionId) + releaseMessageCursorMutex?.() + this.messageCacheService.set(sessionId, fallbackResult.messages) + console.warn( + `[ChatService] 游标跳过异常,已切换 direct-offset 兜底: session=${sessionId}, offset=${offset}, returned=${fallbackResult.messages.length}, hasMore=${fallbackResult.hasMore === true}` + ) + return { + success: true, + messages: fallbackResult.messages, + hasMore: fallbackResult.hasMore === true, + nextOffset: Number.isFinite(fallbackResult.nextOffset) + ? Math.floor(fallbackResult.nextOffset as number) + : offset + fallbackResult.messages.length + } + } + } + console.warn(`[ChatService] 跳过时数据耗尽: skipped=${skipped}/${offset}`) + await this.closeMessageCursorBySession(sessionId) return { success: true, messages: [], hasMore: false, nextOffset: skipped } } + emptySkipBatchStreak = 0 const count = skipBatch.rows.length // Check if we overshot the offset @@ -2060,6 +2133,7 @@ class ChatService { if (!skipBatch.hasMore) { console.warn(`[ChatService] 跳过后无更多数据: skipped=${skipped}/${offset}`) + await this.closeMessageCursorBySession(sessionId) return { success: true, messages: [], hasMore: false, nextOffset: skipped } } } @@ -2080,7 +2154,7 @@ class ChatService { const collected = await this.collectVisibleMessagesFromCursor( sessionId, state.cursor, - limit, + requestLimit, state.bufferedMessages as Record[] | undefined ) state.bufferedMessages = collected.bufferedRows @@ -2202,6 +2276,54 @@ class ChatService { return null } + private async getMessagesByOffsetStable( + sessionId: string, + offset: number, + limit: number + ): Promise<{ + success: boolean + messages?: Message[] + hasMore?: boolean + nextOffset?: number + rawRows?: number + filteredOut?: number + error?: string + }> { + const pageLimit = Math.max(1, Math.floor(limit || this.messageBatchDefault)) + const safeOffset = Math.max(0, Math.floor(offset || 0)) + const probeLimit = Math.min(500, pageLimit + 1) + + const result = await wcdbService.getMessages(sessionId, probeLimit, safeOffset) + if (!result.success || !Array.isArray(result.messages)) { + return { success: false, error: result.error || '获取消息失败' } + } + + const rawRows = result.messages as Record[] + const hasMore = rawRows.length > pageLimit + const selectedRows = hasMore ? rawRows.slice(0, pageLimit) : rawRows + const mapped = this.mapRowsToMessages(selectedRows) + const visible = mapped.filter((msg) => this.isMessageVisibleForSession(sessionId, msg)) + const outputMessages = (visible.length === 0 && mapped.length > 0) + ? mapped + : visible + if (visible.length === 0 && mapped.length > 0) { + console.warn(`[ChatService] getMessagesByOffsetStable 可见性过滤回退: session=${sessionId} mapped=${mapped.length}`) + } + const normalized = this.normalizeMessageOrder(outputMessages) + if (normalized.length > 0) { + await this.repairEmojiMessages(normalized) + } + + return { + success: true, + messages: normalized, + hasMore, + nextOffset: safeOffset + selectedRows.length, + rawRows: selectedRows.length, + filteredOut: Math.max(0, mapped.length - visible.length) + } + } + async getLatestMessages(sessionId: string, limit: number = this.messageBatchDefault): Promise<{ success: boolean; messages?: Message[]; hasMore?: boolean; nextOffset?: number; error?: string }> { try { @@ -2210,31 +2332,22 @@ class ChatService { return { success: false, error: connectResult.error || '数据库未连接' } } - // 聊天页首屏优先走稳定路径:直接拉取固定窗口并做本地确定性排序, - // 避免游标首批在极端数据分布下出现不稳定边界。 - const pageLimit = Math.max(1, Math.floor(limit || this.messageBatchDefault)) - const probeLimit = Math.min(500, pageLimit + 1) - const result = await wcdbService.getMessages(sessionId, probeLimit, 0) - if (!result.success || !Array.isArray(result.messages)) { - return { success: false, error: result.error || '获取最新消息失败' } + // 聊天页首屏优先走稳定路径:固定 offset=0 的 direct-offset 读取。 + const stableResult = await this.getMessagesByOffsetStable(sessionId, 0, limit) + if (!stableResult.success || !Array.isArray(stableResult.messages)) { + return { success: false, error: stableResult.error || '获取最新消息失败' } } - const rawRows = result.messages as Record[] - const hasMore = rawRows.length > pageLimit - const selectedRows = hasMore ? rawRows.slice(0, pageLimit) : rawRows - const mapped = this.mapRowsToMessages(selectedRows) - const visible = mapped.filter((msg) => this.isMessageVisibleForSession(sessionId, msg)) - const normalized = this.normalizeMessageOrder(visible) - await this.repairEmojiMessages(normalized) - console.log( - `[ChatService] getLatestMessages(stable) session=${sessionId} rawRows=${rawRows.length} visibleMessagesReturned=${normalized.length} nextOffset=${selectedRows.length} hasMore=${hasMore}` + `[ChatService] getLatestMessages(stable) session=${sessionId} rawRows=${stableResult.rawRows || 0} visibleMessagesReturned=${stableResult.messages.length} filteredOut=${stableResult.filteredOut || 0} nextOffset=${stableResult.nextOffset || 0} hasMore=${stableResult.hasMore === true}` ) return { success: true, - messages: normalized, - hasMore, - nextOffset: selectedRows.length + messages: stableResult.messages, + hasMore: stableResult.hasMore === true, + nextOffset: Number.isFinite(stableResult.nextOffset) + ? Math.floor(stableResult.nextOffset as number) + : stableResult.messages.length } } catch (e) { console.error('ChatService: 获取最新消息失败:', e) @@ -2365,18 +2478,55 @@ class ChatService { const sortSeq = Number.isFinite(input.sortSeq) ? Math.max(0, Math.floor(input.sortSeq)) : 0 const localType = Number.isFinite(input.localType) ? Math.floor(input.localType) : 0 const senderUsername = this.encodeMessageKeySegment(input.senderUsername || '') + const dbPath = String(input.dbPath || '').trim() const dbName = String(input.dbName || '').trim() || (input.dbPath ? basename(input.dbPath, extname(input.dbPath)) : '') const tableName = String(input.tableName || '').trim() + const sourceScope = dbPath || dbName - if (localId > 0 && dbName && tableName) { - return `${this.encodeMessageKeySegment(dbName)}:${this.encodeMessageKeySegment(tableName)}:${localId}` + if (localId > 0 && sourceScope && tableName) { + return `${this.encodeMessageKeySegment(sourceScope)}:${this.encodeMessageKeySegment(tableName)}:${localId}` + } + + if (localId > 0 && sourceScope) { + // 当底层未返回 table_name 时,避免使用 db:_:localId(会误并同库不同表的消息)。 + return `local:${this.encodeMessageKeySegment(sourceScope)}:${localId}:${createTime}:${sortSeq}:${senderUsername}:${localType}` } if (serverId > 0) { - return `server:${serverId}:${createTime}:${sortSeq}:${localId}:${senderUsername}:${localType}` + const scopedServer = sourceScope ? `${this.encodeMessageKeySegment(sourceScope)}:${serverId}` : String(serverId) + return `server:${scopedServer}:${createTime}:${sortSeq}:${localId}:${senderUsername}:${localType}` } - return `fallback:${createTime}:${sortSeq}:${localId}:${senderUsername}:${localType}` + return `fallback:${this.encodeMessageKeySegment(sourceScope)}:${createTime}:${sortSeq}:${localId}:${senderUsername}:${localType}` + } + + private logVisibilityAnomaly(sessionId: string, msg: Message): void { + const key = String(sessionId || '').trim() || '__unknown__' + const now = Date.now() + let state = this.visibilityAnomalyLogState.get(key) + if (!state || (now - state.windowStart) > this.visibilityAnomalyLogWindowMs) { + if (state && state.suppressed > 0) { + console.warn( + `[ChatService] 会话可见性异常日志已抑制: sessionId=${key}, suppressed=${state.suppressed}, windowMs=${this.visibilityAnomalyLogWindowMs}` + ) + } + state = { windowStart: now, total: 0, suppressed: 0 } + this.visibilityAnomalyLogState.set(key, state) + if (this.visibilityAnomalyLogState.size > 256) { + const oldest = this.visibilityAnomalyLogState.keys().next() + if (!oldest.done) { + this.visibilityAnomalyLogState.delete(oldest.value) + } + } + } + + state.total += 1 + if (state.total <= this.visibilityAnomalyLogBurst) { + console.warn(`[ChatService] 检测到异常消息: sessionId=${sessionId}, senderUsername=${msg.senderUsername}, localId=${msg.localId}`) + return + } + + state.suppressed += 1 } private isMessageVisibleForSession(sessionId: string, msg: Message): boolean { @@ -2390,7 +2540,7 @@ class ChatService { if (msg.isSend === 1) { return true } - console.warn(`[ChatService] 检测到异常消息: sessionId=${sessionId}, senderUsername=${msg.senderUsername}, localId=${msg.localId}`) + this.logVisibilityAnomaly(sessionId, msg) return false } @@ -2421,10 +2571,12 @@ class ChatService { bufferedRows?: Record[] }> { const visibleMessages: Message[] = [] + const filteredCandidates: Message[] = [] let queuedRows = Array.isArray(initialRows) ? initialRows.slice() : [] let rawRowsConsumed = 0 let filteredOut = 0 let cursorMayHaveMore = queuedRows.length > 0 + let emptyBatchStreak = 0 while (visibleMessages.length < limit) { if (queuedRows.length === 0) { @@ -2441,8 +2593,13 @@ class ChatService { const batchRows = Array.isArray(batch.rows) ? batch.rows as Record[] : [] cursorMayHaveMore = batch.hasMore === true if (batchRows.length === 0) { + if (cursorMayHaveMore && emptyBatchStreak < 2) { + emptyBatchStreak += 1 + continue + } break } + emptyBatchStreak = 0 queuedRows = batchRows } @@ -2462,6 +2619,9 @@ class ChatService { } } else { filteredOut += 1 + if (visibleMessages.length === 0 && filteredCandidates.length < limit) { + filteredCandidates.push(msg) + } } } @@ -2478,8 +2638,19 @@ class ChatService { console.warn(`[ChatService] 过滤了 ${filteredOut} 条异常消息`) } - const normalized = this.normalizeMessageOrder(visibleMessages) - await this.repairEmojiMessages(normalized) + let outputMessages = visibleMessages + if (outputMessages.length === 0 && filteredCandidates.length > 0) { + // 回退策略:某些会话 sender_username 与 sessionId 可能不一致,避免整批被误过滤为 0 条。 + outputMessages = filteredCandidates + console.warn( + `[ChatService] 会话可见性过滤触发回退: session=${sessionId} fallbackCount=${filteredCandidates.length}` + ) + } + + const normalized = this.normalizeMessageOrder(outputMessages) + if (normalized.length > 0) { + await this.repairEmojiMessages(normalized) + } return { success: true, messages: normalized, @@ -6519,15 +6690,60 @@ class ChatService { return String(raw || '').replace(/\s+/g, '').trim() } - private shouldKeepSession(username: string): boolean { + private getSessionLocalType(row: Record): number | undefined { + const localType = this.getRowInt(row, ['local_type', 'localType', 'WCDB_CT_local_type'], Number.NaN) + return Number.isFinite(localType) ? Math.floor(localType) : undefined + } + + private async loadContactLocalTypeMapForEnterpriseOpenim(usernames: string[]): Promise> { + const normalizedUsernames = Array.from(new Set( + (usernames || []) + .map((value) => String(value || '').trim()) + .filter((value) => value && this.isEnterpriseOpenimUsername(value)) + )) + const localTypeMap = new Map() + if (normalizedUsernames.length === 0) { + return localTypeMap + } + try { + const contactResult = await wcdbService.getContactsCompact(normalizedUsernames) + if (!contactResult.success || !Array.isArray(contactResult.contacts)) { + return localTypeMap + } + for (const row of contactResult.contacts as Record[]) { + const username = String(row.username || '').trim() + if (!username) continue + const localType = this.getRowInt(row, ['local_type', 'localType', 'WCDB_CT_local_type'], Number.NaN) + if (!Number.isFinite(localType)) continue + localTypeMap.set(username, Math.floor(localType)) + } + } catch { + return localTypeMap + } + return localTypeMap + } + + private isEnterpriseOpenimUsername(username: string): boolean { + const lowered = String(username || '').trim().toLowerCase() + return lowered.includes('@openim') && !lowered.includes('@kefu.openim') + } + + private isAllowedEnterpriseOpenimByLocalType(username: string, localType?: number): boolean { + if (!this.isEnterpriseOpenimUsername(username)) return false + return Number.isFinite(localType) && Math.floor(localType as number) === 5 + } + + private shouldKeepSession(username: string, localType?: number): boolean { if (!username) return false const lowered = username.toLowerCase() // 排除所有 placeholder 会话(包括折叠群) if (lowered.includes('@placeholder')) return false if (username.startsWith('gh_')) return false + if (lowered === 'weixin') return false + const excludeList = [ - 'weixin', 'qqmail', 'fmessage', 'medianote', 'floatbottle', + 'qqmail', 'fmessage', 'medianote', 'floatbottle', 'newsapp', 'brandsessionholder', 'brandservicesessionholder', 'notifymessage', 'opencustomerservicemsg', 'notification_messages', 'userexperience_alarm', 'helper_folders', @@ -6538,7 +6754,11 @@ class ChatService { if (username.startsWith(prefix) || username === prefix) return false } - if (username.includes('@kefu.openim') || username.includes('@openim')) return false + if (username.includes('@kefu.openim')) return false + // 全局约束:企业 openim 仅允许 localType=5。 + if (this.isEnterpriseOpenimUsername(username)) { + return this.isAllowedEnterpriseOpenimByLocalType(username, localType) + } if (username.includes('service_')) return false return true @@ -8618,6 +8838,7 @@ class ChatService { let groupSessionIds = Array.isArray(options?.groupSessionIds) ? options!.groupSessionIds!.map((value) => String(value || '').trim()).filter(Boolean) : [] + const privateSessionLocalTypeMap = new Map() const hasExplicitGroupScope = Array.isArray(options?.groupSessionIds) && options!.groupSessionIds!.some((value) => String(value || '').trim().length > 0) @@ -8626,26 +8847,46 @@ class ChatService { if (!sessionsResult.success || !Array.isArray(sessionsResult.sessions)) { return { success: false, error: sessionsResult.error || '读取会话列表失败' } } + const openimLocalTypeMap = await this.loadContactLocalTypeMapForEnterpriseOpenim( + (sessionsResult.sessions as Array>).map((session) => String(session.username || session.user_name || '').trim()) + ) for (const session of sessionsResult.sessions as Array>) { const sessionId = String(session.username || session.user_name || '').trim() if (!sessionId) continue + let sessionLocalType = this.getSessionLocalType(session) + if (!Number.isFinite(sessionLocalType) && this.isEnterpriseOpenimUsername(sessionId)) { + sessionLocalType = openimLocalTypeMap.get(sessionId) + } + if (typeof sessionLocalType === 'number' && Number.isFinite(sessionLocalType)) { + privateSessionLocalTypeMap.set(sessionId, sessionLocalType) + } const sessionLastTs = this.normalizeTimestampSeconds( Number(session.lastTimestamp || session.sortTimestamp || 0) ) if (sessionId.endsWith('@chatroom')) { groupSessionIds.push(sessionId) } else { - if (!this.shouldKeepSession(sessionId)) continue + if (!this.shouldKeepSession(sessionId, sessionLocalType)) continue if (begin > 0 && sessionLastTs > 0 && sessionLastTs < begin) continue privateSessionIds.push(sessionId) } } } + const unresolvedOpenimPrivateSessionIds = privateSessionIds.filter((value) => + this.isEnterpriseOpenimUsername(value) && !privateSessionLocalTypeMap.has(value) + ) + if (unresolvedOpenimPrivateSessionIds.length > 0) { + const fallbackMap = await this.loadContactLocalTypeMapForEnterpriseOpenim(unresolvedOpenimPrivateSessionIds) + for (const [username, localType] of fallbackMap.entries()) { + privateSessionLocalTypeMap.set(username, localType) + } + } + privateSessionIds = Array.from(new Set( privateSessionIds .map((value) => String(value || '').trim()) - .filter((value) => value && !value.endsWith('@chatroom') && this.shouldKeepSession(value)) + .filter((value) => value && !value.endsWith('@chatroom') && this.shouldKeepSession(value, privateSessionLocalTypeMap.get(value))) )) groupSessionIds = Array.from(new Set( groupSessionIds diff --git a/resources/wcdb/linux/x64/libwcdb_api.so b/resources/wcdb/linux/x64/libwcdb_api.so index 9dd03b0..e367305 100644 Binary files a/resources/wcdb/linux/x64/libwcdb_api.so and b/resources/wcdb/linux/x64/libwcdb_api.so differ diff --git a/resources/wcdb/macos/universal/libwcdb_api.dylib b/resources/wcdb/macos/universal/libwcdb_api.dylib index eae0d9c..88c9fd4 100644 Binary files a/resources/wcdb/macos/universal/libwcdb_api.dylib and b/resources/wcdb/macos/universal/libwcdb_api.dylib differ diff --git a/resources/wcdb/win32/arm64/wcdb_api.dll b/resources/wcdb/win32/arm64/wcdb_api.dll index 674b99b..a0abeb1 100644 Binary files a/resources/wcdb/win32/arm64/wcdb_api.dll and b/resources/wcdb/win32/arm64/wcdb_api.dll differ diff --git a/resources/wcdb/win32/x64/wcdb_api.dll b/resources/wcdb/win32/x64/wcdb_api.dll index 13c9939..8d02f6c 100644 Binary files a/resources/wcdb/win32/x64/wcdb_api.dll and b/resources/wcdb/win32/x64/wcdb_api.dll differ diff --git a/src/components/GlobalSessionMonitor.tsx b/src/components/GlobalSessionMonitor.tsx index a8f65b0..42a8880 100644 --- a/src/components/GlobalSessionMonitor.tsx +++ b/src/components/GlobalSessionMonitor.tsx @@ -22,7 +22,7 @@ export function GlobalSessionMonitor() { // 去重辅助函数:获取消息 key const getMessageKey = (msg: Message) => { if (msg.messageKey) return msg.messageKey - return `fallback:${msg.serverId || 0}:${msg.createTime}:${msg.sortSeq || 0}:${msg.localId || 0}:${msg.senderUsername || ''}:${msg.localType || 0}` + return `fallback:${msg._db_path || ''}:${msg.serverId || 0}:${msg.createTime}:${msg.sortSeq || 0}:${msg.localId || 0}:${msg.senderUsername || ''}:${msg.localType || 0}` } // 处理数据库变更 diff --git a/src/pages/ChatPage.tsx b/src/pages/ChatPage.tsx index 5a35d47..6194a5f 100644 --- a/src/pages/ChatPage.tsx +++ b/src/pages/ChatPage.tsx @@ -72,11 +72,146 @@ const GLOBAL_MSG_SEARCH_CANCELED_ERROR = '__WEFLOW_GLOBAL_MSG_SEARCH_CANCELED__' const GLOBAL_MSG_SHADOW_COMPARE_SAMPLE_RATE = 0.2 const GLOBAL_MSG_SHADOW_COMPARE_STORAGE_KEY = 'weflow.debug.searchShadowCompare' const MESSAGE_LIST_SCROLL_IDLE_MS = 160 -const MESSAGE_TOP_WHEEL_LOAD_COOLDOWN_MS = 160 +const MESSAGE_TOP_EDGE_LOAD_COOLDOWN_MS = 160 const MESSAGE_EDGE_TRIGGER_DISTANCE_PX = 96 +const MESSAGE_HISTORY_INITIAL_LIMIT = 50 +const MESSAGE_HISTORY_HEAVY_UNREAD_INITIAL_LIMIT = 70 +const MESSAGE_HISTORY_GROWTH_STEP = 20 +const MESSAGE_HISTORY_MAX_LIMIT = 180 +const MESSAGE_VIRTUAL_OVERSCAN_PX = 140 +const BYTES_PER_MEGABYTE = 1024 * 1024 +const EMOJI_CACHE_MAX_ENTRIES = 260 +const EMOJI_CACHE_MAX_BYTES = 32 * BYTES_PER_MEGABYTE +const IMAGE_CACHE_MAX_ENTRIES = 360 +const IMAGE_CACHE_MAX_BYTES = 64 * BYTES_PER_MEGABYTE +const VOICE_CACHE_MAX_ENTRIES = 120 +const VOICE_CACHE_MAX_BYTES = 24 * BYTES_PER_MEGABYTE +const VOICE_TRANSCRIPT_CACHE_MAX_ENTRIES = 1800 +const VOICE_TRANSCRIPT_CACHE_MAX_BYTES = 2 * BYTES_PER_MEGABYTE +const SENDER_AVATAR_CACHE_MAX_ENTRIES = 2000 +const AUTO_MEDIA_TASK_MAX_CONCURRENCY = 2 +const AUTO_MEDIA_TASK_MAX_QUEUE = 80 type RequestIdleCallbackCompat = (callback: () => void, options?: { timeout?: number }) => number +type BoundedCacheOptions = { + maxEntries: number + maxBytes?: number + estimate?: (value: V) => number +} + +type BoundedCache = { + get: (key: string) => V | undefined + set: (key: string, value: V) => void + has: (key: string) => boolean + delete: (key: string) => boolean + clear: () => void + readonly size: number +} + +function estimateStringBytes(value: string): number { + return Math.max(0, value.length * 2) +} + +function createBoundedCache(options: BoundedCacheOptions): BoundedCache { + const { maxEntries, maxBytes, estimate } = options + const storage = new Map() + const valueSizes = new Map() + let currentBytes = 0 + + const estimateSize = (value: V): number => { + if (!estimate) return 1 + const raw = estimate(value) + if (!Number.isFinite(raw) || raw <= 0) return 1 + return Math.max(1, Math.round(raw)) + } + + const removeKey = (key: string): boolean => { + if (!storage.has(key)) return false + const previousSize = valueSizes.get(key) || 0 + currentBytes = Math.max(0, currentBytes - previousSize) + valueSizes.delete(key) + return storage.delete(key) + } + + const touch = (key: string, value: V) => { + storage.delete(key) + storage.set(key, value) + } + + const prune = () => { + const shouldPruneByBytes = Number.isFinite(maxBytes) && (maxBytes as number) > 0 + while (storage.size > maxEntries || (shouldPruneByBytes && currentBytes > (maxBytes as number))) { + const oldestKey = storage.keys().next().value as string | undefined + if (!oldestKey) break + removeKey(oldestKey) + } + } + + return { + get(key: string) { + const value = storage.get(key) + if (value === undefined) return undefined + touch(key, value) + return value + }, + set(key: string, value: V) { + const nextSize = estimateSize(value) + if (storage.has(key)) { + const previousSize = valueSizes.get(key) || 0 + currentBytes = Math.max(0, currentBytes - previousSize) + } + storage.set(key, value) + valueSizes.set(key, nextSize) + currentBytes += nextSize + prune() + }, + has(key: string) { + return storage.has(key) + }, + delete(key: string) { + return removeKey(key) + }, + clear() { + storage.clear() + valueSizes.clear() + currentBytes = 0 + }, + get size() { + return storage.size + } + } +} + +const autoMediaTaskQueue: Array<() => void> = [] +let autoMediaTaskRunningCount = 0 + +function enqueueAutoMediaTask(task: () => Promise): Promise { + return new Promise((resolve, reject) => { + const runTask = () => { + autoMediaTaskRunningCount += 1 + task() + .then(resolve) + .catch(reject) + .finally(() => { + autoMediaTaskRunningCount = Math.max(0, autoMediaTaskRunningCount - 1) + const next = autoMediaTaskQueue.shift() + if (next) next() + }) + } + + if (autoMediaTaskRunningCount < AUTO_MEDIA_TASK_MAX_CONCURRENCY) { + runTask() + return + } + if (autoMediaTaskQueue.length >= AUTO_MEDIA_TASK_MAX_QUEUE) { + reject(new Error('AUTO_MEDIA_TASK_QUEUE_FULL')) + return + } + autoMediaTaskQueue.push(runTask) + }) +} + function scheduleWhenIdle(task: () => void, options?: { timeout?: number; fallbackDelay?: number }): void { const requestIdleCallbackFn = ( globalThis as typeof globalThis & { requestIdleCallback?: RequestIdleCallbackCompat } @@ -1293,7 +1428,7 @@ function ChatPage(props: ChatPageProps) { const getMessageKey = useCallback((msg: Message): string => { if (msg.messageKey) return msg.messageKey - return `fallback:${msg.serverId || 0}:${msg.createTime}:${msg.sortSeq || 0}:${msg.localId || 0}:${msg.senderUsername || ''}:${msg.localType || 0}` + return `fallback:${msg._db_path || ''}:${msg.serverId || 0}:${msg.createTime}:${msg.sortSeq || 0}:${msg.localId || 0}:${msg.senderUsername || ''}:${msg.localType || 0}` }, []) const initialRevealTimerRef = useRef(null) const sessionListRef = useRef(null) @@ -1473,6 +1608,7 @@ function ChatPage(props: ChatPageProps) { const searchKeywordRef = useRef('') const preloadImageKeysRef = useRef>(new Set()) const lastPreloadSessionRef = useRef(null) + const messageMediaPreloadTimerRef = useRef(null) const detailRequestSeqRef = useRef(0) const groupMembersRequestSeqRef = useRef(0) const groupMembersPanelCacheRef = useRef>(new Map()) @@ -2793,6 +2929,11 @@ function ChatPage(props: ChatPageProps) { }, [loadMyAvatar, resolveChatCacheScope]) const handleAccountChanged = useCallback(async () => { + emojiDataUrlCache.clear() + imageDataUrlCache.clear() + voiceDataUrlCache.clear() + voiceTranscriptCache.clear() + imageDecryptInFlight.clear() senderAvatarCache.clear() senderAvatarLoading.clear() quotedSenderDisplayCache.clear() @@ -2804,6 +2945,10 @@ function ChatPage(props: ChatPageProps) { sessionContactEnrichAttemptAtRef.current.clear() preloadImageKeysRef.current.clear() lastPreloadSessionRef.current = null + if (messageMediaPreloadTimerRef.current !== null) { + window.clearTimeout(messageMediaPreloadTimerRef.current) + messageMediaPreloadTimerRef.current = null + } pendingSessionLoadRef.current = null initialLoadRequestedSessionRef.current = null sessionSwitchRequestSeqRef.current += 1 @@ -3321,8 +3466,8 @@ function ChatPage(props: ChatPageProps) { setIsRefreshingMessages(false) } } - // 消息批量大小控制(保持稳定,避免游标反复重建) - const currentBatchSizeRef = useRef(50) + // 消息批量大小控制(会话内逐步增大,减少频繁触顶加载) + const currentBatchSizeRef = useRef(MESSAGE_HISTORY_INITIAL_LIMIT) const warmupGroupSenderProfiles = useCallback((usernames: string[], defer = false) => { if (!Array.isArray(usernames) || usernames.length === 0) return @@ -3386,14 +3531,21 @@ function ChatPage(props: ChatPageProps) { let messageLimit: number if (offset === 0) { + const defaultInitialLimit = unreadCount > 99 + ? MESSAGE_HISTORY_HEAVY_UNREAD_INITIAL_LIMIT + : MESSAGE_HISTORY_INITIAL_LIMIT const preferredLimit = Number.isFinite(options.forceInitialLimit) ? Math.max(10, Math.floor(options.forceInitialLimit as number)) - : (unreadCount > 99 ? 30 : 40) - currentBatchSizeRef.current = preferredLimit - messageLimit = preferredLimit - } else { - // 同一会话内保持固定批量,避免后端游标因 batch 改变而重建 + : defaultInitialLimit + currentBatchSizeRef.current = Math.min(preferredLimit, MESSAGE_HISTORY_MAX_LIMIT) messageLimit = currentBatchSizeRef.current + } else { + const grownBatchSize = Math.min( + Math.max(currentBatchSizeRef.current, MESSAGE_HISTORY_INITIAL_LIMIT) + MESSAGE_HISTORY_GROWTH_STEP, + MESSAGE_HISTORY_MAX_LIMIT + ) + currentBatchSizeRef.current = grownBatchSize + messageLimit = grownBatchSize } @@ -4099,7 +4251,7 @@ function ChatPage(props: ChatPageProps) { void loadMessages(normalizedSessionId, 0, 0, 0, false, { preferLatestPath: true, deferGroupSenderWarmup: true, - forceInitialLimit: 30, + forceInitialLimit: MESSAGE_HISTORY_INITIAL_LIMIT, switchRequestSeq }) } @@ -4590,24 +4742,40 @@ function ChatPage(props: ChatPageProps) { setShowScrollToBottom(prev => (prev === shouldShow ? prev : shouldShow)) }, [messages.length, isLoadingMessages, isLoadingMore, isSessionSwitching]) + const triggerTopEdgeHistoryLoad = useCallback((): boolean => { + if (!currentSessionId || isLoadingMore || isLoadingMessages || !hasMoreMessages) return false + const listEl = messageListRef.current + if (!listEl) return false + const distanceFromTop = Math.max(0, listEl.scrollTop) + if (distanceFromTop > MESSAGE_EDGE_TRIGGER_DISTANCE_PX) return false + if (topRangeLoadLockRef.current) return false + const now = Date.now() + if (now - topRangeLoadLastTriggerAtRef.current < MESSAGE_TOP_EDGE_LOAD_COOLDOWN_MS) return false + topRangeLoadLastTriggerAtRef.current = now + topRangeLoadLockRef.current = true + isMessageListAtBottomRef.current = false + void loadMessages(currentSessionId, currentOffset, jumpStartTime, jumpEndTime) + return true + }, [ + currentSessionId, + isLoadingMore, + isLoadingMessages, + hasMoreMessages, + loadMessages, + currentOffset, + jumpStartTime, + jumpEndTime + ]) + const handleMessageListWheel = useCallback((event: React.WheelEvent) => { markMessageListScrolling() if (!currentSessionId || isLoadingMore || isLoadingMessages) return const listEl = messageListRef.current if (!listEl) return - const distanceFromTop = listEl.scrollTop const distanceFromBottom = listEl.scrollHeight - (listEl.scrollTop + listEl.clientHeight) if (event.deltaY <= -18) { - if (!hasMoreMessages) return - if (distanceFromTop > MESSAGE_EDGE_TRIGGER_DISTANCE_PX) return - if (topRangeLoadLockRef.current) return - const now = Date.now() - if (now - topRangeLoadLastTriggerAtRef.current < MESSAGE_TOP_WHEEL_LOAD_COOLDOWN_MS) return - topRangeLoadLastTriggerAtRef.current = now - topRangeLoadLockRef.current = true - isMessageListAtBottomRef.current = false - void loadMessages(currentSessionId, currentOffset, jumpStartTime, jumpEndTime) + triggerTopEdgeHistoryLoad() return } @@ -4623,22 +4791,21 @@ function ChatPage(props: ChatPageProps) { }, [ currentSessionId, hasMoreLater, - hasMoreMessages, isLoadingMessages, isLoadingMore, - currentOffset, - jumpStartTime, - jumpEndTime, markMessageListScrolling, - loadMessages, - loadLaterMessages + loadLaterMessages, + triggerTopEdgeHistoryLoad ]) const handleMessageAtTopStateChange = useCallback((atTop: boolean) => { if (!atTop) { topRangeLoadLockRef.current = false + return } - }, []) + // 支持拖动右侧滚动条到顶部时直接触发加载,不依赖滚轮事件。 + triggerTopEdgeHistoryLoad() + }, [triggerTopEdgeHistoryLoad]) const isSameSession = useCallback((prev: ChatSession, next: ChatSession): boolean => { @@ -4791,6 +4958,10 @@ function ChatPage(props: ChatPageProps) { window.clearTimeout(messageListScrollTimeoutRef.current) messageListScrollTimeoutRef.current = null } + if (messageMediaPreloadTimerRef.current !== null) { + window.clearTimeout(messageMediaPreloadTimerRef.current) + messageMediaPreloadTimerRef.current = null + } isMessageListScrollingRef.current = false contactUpdateQueueRef.current.clear() pendingSessionContactEnrichRef.current.clear() @@ -4861,36 +5032,54 @@ function ChatPage(props: ChatPageProps) { }, [currentSessionId]) useEffect(() => { - if (!currentSessionId || messages.length === 0) return - const preloadEdgeCount = 40 - const maxPreload = 30 - const head = messages.slice(0, preloadEdgeCount) - const tail = messages.slice(-preloadEdgeCount) - const candidates = [...head, ...tail] - const queued = preloadImageKeysRef.current - const seen = new Set() - const payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string; createTime?: number }> = [] - for (const msg of candidates) { - if (payloads.length >= maxPreload) break - if (msg.localType !== 3) continue - const cacheKey = msg.imageMd5 || msg.imageDatName || `local:${msg.localId}` - if (!msg.imageMd5 && !msg.imageDatName) continue - if (imageDataUrlCache.has(cacheKey)) continue - const taskKey = `${currentSessionId}|${cacheKey}` - if (queued.has(taskKey) || seen.has(taskKey)) continue - queued.add(taskKey) - seen.add(taskKey) - payloads.push({ - sessionId: currentSessionId, - imageMd5: msg.imageMd5 || undefined, - imageDatName: msg.imageDatName, - createTime: msg.createTime - }) + if (messageMediaPreloadTimerRef.current !== null) { + window.clearTimeout(messageMediaPreloadTimerRef.current) + messageMediaPreloadTimerRef.current = null } - if (payloads.length > 0) { - window.electronAPI.image.preload(payloads, { - allowCacheIndex: false - }).catch(() => { }) + if (!currentSessionId || messages.length === 0) return + + messageMediaPreloadTimerRef.current = window.setTimeout(() => { + messageMediaPreloadTimerRef.current = null + scheduleWhenIdle(() => { + if (isMessageListScrollingRef.current) return + const preloadEdgeCount = 20 + const maxPreload = 12 + const head = messages.slice(0, preloadEdgeCount) + const tail = messages.slice(-preloadEdgeCount) + const candidates = [...head, ...tail] + const queued = preloadImageKeysRef.current + const seen = new Set() + const payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string; createTime?: number }> = [] + for (const msg of candidates) { + if (payloads.length >= maxPreload) break + if (msg.localType !== 3) continue + const cacheKey = msg.imageMd5 || msg.imageDatName || `local:${msg.localId}` + if (!msg.imageMd5 && !msg.imageDatName) continue + if (imageDataUrlCache.has(cacheKey)) continue + const taskKey = `${currentSessionId}|${cacheKey}` + if (queued.has(taskKey) || seen.has(taskKey)) continue + queued.add(taskKey) + seen.add(taskKey) + payloads.push({ + sessionId: currentSessionId, + imageMd5: msg.imageMd5 || undefined, + imageDatName: msg.imageDatName, + createTime: msg.createTime + }) + } + if (payloads.length > 0) { + window.electronAPI.image.preload(payloads, { + allowCacheIndex: false + }).catch(() => { }) + } + }, { timeout: 1400, fallbackDelay: 120 }) + }, 120) + + return () => { + if (messageMediaPreloadTimerRef.current !== null) { + window.clearTimeout(messageMediaPreloadTimerRef.current) + messageMediaPreloadTimerRef.current = null + } } }, [currentSessionId, messages]) @@ -4987,7 +5176,7 @@ function ChatPage(props: ChatPageProps) { void loadMessages(currentSessionId, 0, 0, 0, false, { preferLatestPath: true, deferGroupSenderWarmup: true, - forceInitialLimit: 30 + forceInitialLimit: MESSAGE_HISTORY_INITIAL_LIMIT }) } }, [currentSessionId, isConnected, messages.length, isLoadingMessages, isLoadingMore, noMessageTable]) @@ -5120,6 +5309,18 @@ function ChatPage(props: ChatPageProps) { return [] } + const getSessionSortTime = (session: Pick) => + Number(session.sortTimestamp || session.lastTimestamp || 0) + const insertSessionByTimeDesc = (list: ChatSession[], entry: ChatSession) => { + const entryTime = getSessionSortTime(entry) + const insertIndex = list.findIndex(s => getSessionSortTime(s) < entryTime) + if (insertIndex === -1) { + list.push(entry) + } else { + list.splice(insertIndex, 0, entry) + } + } + const officialSessions = sessions.filter(s => s.username.startsWith('gh_')) // 检查是否有折叠的群聊 @@ -5134,11 +5335,12 @@ function ChatPage(props: ChatPageProps) { const latestOfficial = officialSessions.reduce((latest, current) => { if (!latest) return current - const latestTime = latest.sortTimestamp || latest.lastTimestamp - const currentTime = current.sortTimestamp || current.lastTimestamp + const latestTime = getSessionSortTime(latest) + const currentTime = getSessionSortTime(current) return currentTime > latestTime ? current : latest }, null) const officialUnreadCount = officialSessions.reduce((sum, s) => sum + (s.unreadCount || 0), 0) + const officialLatestTime = latestOfficial ? getSessionSortTime(latestOfficial) : 0 const bizEntry: ChatSession = { username: OFFICIAL_ACCOUNTS_VIRTUAL_ID, @@ -5147,8 +5349,8 @@ function ChatPage(props: ChatPageProps) { ? `${latestOfficial.displayName || latestOfficial.username}: ${latestOfficial.summary || '查看公众号历史消息'}` : '查看公众号历史消息', type: 0, - sortTimestamp: 9999999999, // 放到最前面? 目前还没有严格的对时间进行排序, 后面可以改一下 - lastTimestamp: latestOfficial?.lastTimestamp || latestOfficial?.sortTimestamp || 0, + sortTimestamp: officialLatestTime, + lastTimestamp: officialLatestTime, lastMsgType: latestOfficial?.lastMsgType || 0, unreadCount: officialUnreadCount, isMuted: false, @@ -5156,7 +5358,7 @@ function ChatPage(props: ChatPageProps) { } if (!visible.some(s => s.username === OFFICIAL_ACCOUNTS_VIRTUAL_ID)) { - visible.unshift(bizEntry) + insertSessionByTimeDesc(visible, bizEntry) } if (hasFoldedGroups && !visible.some(s => s.username.toLowerCase().includes('placeholder_foldgroup'))) { @@ -5180,17 +5382,7 @@ function ChatPage(props: ChatPageProps) { isFolded: false } - // 按时间戳插入到正确位置 - const foldTime = foldEntry.sortTimestamp || foldEntry.lastTimestamp - const insertIndex = visible.findIndex(s => { - const sTime = s.sortTimestamp || s.lastTimestamp - return sTime < foldTime - }) - if (insertIndex === -1) { - visible.push(foldEntry) - } else { - visible.splice(insertIndex, 0, foldEntry) - } + insertSessionByTimeDesc(visible, foldEntry) } if (!searchKeyword.trim()) { @@ -7078,7 +7270,7 @@ function ChatPage(props: ChatPageProps) { className="message-virtuoso" customScrollParent={messageListScrollParent ?? undefined} data={messages} - overscan={220} + overscan={MESSAGE_VIRTUAL_OVERSCAN_PX} followOutput={(atBottom) => ( prependingHistoryRef.current ? false @@ -8022,10 +8214,26 @@ const globalVoiceManager = { } // 前端表情包缓存 -const emojiDataUrlCache = new Map() -const imageDataUrlCache = new Map() -const voiceDataUrlCache = new Map() -const voiceTranscriptCache = new Map() +const emojiDataUrlCache = createBoundedCache({ + maxEntries: EMOJI_CACHE_MAX_ENTRIES, + maxBytes: EMOJI_CACHE_MAX_BYTES, + estimate: estimateStringBytes +}) +const imageDataUrlCache = createBoundedCache({ + maxEntries: IMAGE_CACHE_MAX_ENTRIES, + maxBytes: IMAGE_CACHE_MAX_BYTES, + estimate: estimateStringBytes +}) +const voiceDataUrlCache = createBoundedCache({ + maxEntries: VOICE_CACHE_MAX_ENTRIES, + maxBytes: VOICE_CACHE_MAX_BYTES, + estimate: estimateStringBytes +}) +const voiceTranscriptCache = createBoundedCache({ + maxEntries: VOICE_TRANSCRIPT_CACHE_MAX_ENTRIES, + maxBytes: VOICE_TRANSCRIPT_CACHE_MAX_BYTES, + estimate: estimateStringBytes +}) type SharedImageDecryptResult = { success: boolean localPath?: string @@ -8034,7 +8242,9 @@ type SharedImageDecryptResult = { failureKind?: 'not_found' | 'decrypt_failed' } const imageDecryptInFlight = new Map>() -const senderAvatarCache = new Map() +const senderAvatarCache = createBoundedCache<{ avatarUrl?: string; displayName?: string }>({ + maxEntries: SENDER_AVATAR_CACHE_MAX_ENTRIES +}) const senderAvatarLoading = new Map>() function getSharedImageDecryptTask( @@ -8088,7 +8298,7 @@ function QuotedEmoji({ cdnUrl, md5 }: { cdnUrl: string; md5?: string }) { if (error || (!loading && !localPath)) return [动画表情] if (loading) return [动画表情] - return 动画表情 + return 动画表情 } // 消息气泡组件 @@ -8191,7 +8401,10 @@ function MessageBubble({ const [voiceCurrentTime, setVoiceCurrentTime] = useState(0) const [voiceDuration, setVoiceDuration] = useState(0) const [voiceWaveform, setVoiceWaveform] = useState([]) + const [voiceWaveformRequested, setVoiceWaveformRequested] = useState(false) const voiceAutoDecryptTriggered = useRef(false) + const pendingScrollerDeltaRef = useRef(0) + const pendingScrollerDeltaRafRef = useRef(null) const [systemAlert, setSystemAlert] = useState<{ @@ -8282,7 +8495,7 @@ function MessageBubble({ const stabilizeScrollerByDelta = useCallback((host: HTMLElement | null, delta: number) => { if (!host) return - if (!Number.isFinite(delta) || Math.abs(delta) < 1) return + if (!Number.isFinite(delta) || Math.abs(delta) < 1.5) return const scroller = host.closest('.message-list') as HTMLDivElement | null if (!scroller) return @@ -8295,7 +8508,17 @@ function MessageBubble({ const viewportBottom = scroller.scrollTop + scroller.clientHeight if (hostTopInScroller > viewportBottom + 24) return - scroller.scrollTop += delta + pendingScrollerDeltaRef.current += delta + if (pendingScrollerDeltaRafRef.current !== null) return + pendingScrollerDeltaRafRef.current = window.requestAnimationFrame(() => { + pendingScrollerDeltaRafRef.current = null + const applyDelta = pendingScrollerDeltaRef.current + pendingScrollerDeltaRef.current = 0 + if (!Number.isFinite(applyDelta) || Math.abs(applyDelta) < 1.5) return + const nextScroller = host.closest('.message-list') as HTMLDivElement | null + if (!nextScroller) return + nextScroller.scrollTop += applyDelta + }) }, []) const bindResizeObserverForHost = useCallback(( @@ -8386,12 +8609,12 @@ function MessageBubble({ useEffect(() => { if (!isImage) return return bindResizeObserverForHost(imageContainerRef.current, imageObservedHeightRef, imageResizeBaselineRef) - }, [isImage, imageLocalPath, imageLoading, imageError, bindResizeObserverForHost]) + }, [isImage, bindResizeObserverForHost]) useEffect(() => { if (!isEmoji) return return bindResizeObserverForHost(emojiContainerRef.current, emojiObservedHeightRef, emojiResizeBaselineRef) - }, [isEmoji, emojiLocalPath, emojiLoading, emojiError, bindResizeObserverForHost]) + }, [isEmoji, bindResizeObserverForHost]) // 下载表情包 const downloadEmoji = () => { @@ -8572,13 +8795,13 @@ function MessageBubble({ return { success: false } }, [isImage, message.imageMd5, message.imageDatName, message.createTime, message.localId, session.username, imageCacheKey, detectImageMimeFromBase64, imageLocalPath, captureImageResizeBaseline, lockImageStageHeight]) - const triggerForceHd = useCallback(() => { + const triggerForceHd = useCallback(async (): Promise => { if (!message.imageMd5 && !message.imageDatName) return if (imageForceHdAttempted.current === imageCacheKey) return if (imageForceHdPending.current) return imageForceHdAttempted.current = imageCacheKey imageForceHdPending.current = true - requestImageDecrypt(true, true).finally(() => { + await requestImageDecrypt(true, true).finally(() => { imageForceHdPending.current = false }) }, [imageCacheKey, message.imageDatName, message.imageMd5, requestImageDecrypt]) @@ -8666,6 +8889,11 @@ function MessageBubble({ if (imageClickTimerRef.current) { window.clearTimeout(imageClickTimerRef.current) } + if (pendingScrollerDeltaRafRef.current !== null) { + window.cancelAnimationFrame(pendingScrollerDeltaRafRef.current) + pendingScrollerDeltaRafRef.current = null + } + pendingScrollerDeltaRef.current = 0 } }, []) @@ -8799,14 +9027,16 @@ function MessageBubble({ if (!message.imageMd5 && !message.imageDatName) return if (imageAutoDecryptTriggered.current) return imageAutoDecryptTriggered.current = true - void requestImageDecrypt() + void enqueueAutoMediaTask(async () => requestImageDecrypt()).catch(() => { }) }, [isImage, imageInView, imageLocalPath, imageLoading, message.imageMd5, message.imageDatName, requestImageDecrypt]) useEffect(() => { if (!isImage || !imageHasUpdate || !imageInView) return if (imageAutoHdTriggered.current === imageCacheKey) return imageAutoHdTriggered.current = imageCacheKey - triggerForceHd() + void enqueueAutoMediaTask(async () => { + await triggerForceHd() + }).catch(() => { }) }, [isImage, imageHasUpdate, imageInView, imageCacheKey, triggerForceHd]) @@ -8848,30 +9078,36 @@ function MessageBubble({ // 生成波形数据 useEffect(() => { - if (!voiceDataUrl) { + if (!voiceDataUrl || !voiceWaveformRequested) { setVoiceWaveform([]) return } + let cancelled = false + let audioCtx: AudioContext | null = null + const generateWaveform = async () => { try { // 从 data:audio/wav;base64,... 提取 base64 const base64 = voiceDataUrl.split(',')[1] + if (!base64) return const binaryString = window.atob(base64) const bytes = new Uint8Array(binaryString.length) for (let i = 0; i < binaryString.length; i++) { bytes[i] = binaryString.charCodeAt(i) } - const audioCtx = new (window.AudioContext || (window as any).webkitAudioContext)() + audioCtx = new (window.AudioContext || (window as any).webkitAudioContext)() const audioBuffer = await audioCtx.decodeAudioData(bytes.buffer) + if (cancelled) return const rawData = audioBuffer.getChannelData(0) // 获取单声道数据 - const samples = 35 // 波形柱子数量 + const samples = 24 // 波形柱子数量(降低解码计算成本) const blockSize = Math.floor(rawData.length / samples) + if (blockSize <= 0) return const filteredData: number[] = [] for (let i = 0; i < samples; i++) { - let blockStart = blockSize * i + const blockStart = blockSize * i let sum = 0 for (let j = 0; j < blockSize; j++) { sum = sum + Math.abs(rawData[blockStart + j]) @@ -8880,19 +9116,39 @@ function MessageBubble({ } // 归一化 - const multiplier = Math.pow(Math.max(...filteredData), -1) + const peak = Math.max(...filteredData) + if (!Number.isFinite(peak) || peak <= 0) return + const multiplier = Math.pow(peak, -1) const normalizedData = filteredData.map(n => n * multiplier) - setVoiceWaveform(normalizedData) - void audioCtx.close() + if (!cancelled) { + setVoiceWaveform(normalizedData) + } } catch (e) { console.error('Failed to generate waveform:', e) // 降级:生成随机但平滑的波形 - setVoiceWaveform(Array.from({ length: 35 }, () => 0.2 + Math.random() * 0.8)) + if (!cancelled) { + setVoiceWaveform(Array.from({ length: 24 }, () => 0.2 + Math.random() * 0.8)) + } + } finally { + if (audioCtx) { + void audioCtx.close().catch(() => { }) + } } } - void generateWaveform() - }, [voiceDataUrl]) + scheduleWhenIdle(() => { + if (cancelled) return + void generateWaveform() + }, { timeout: 900, fallbackDelay: 80 }) + + return () => { + cancelled = true + if (audioCtx) { + void audioCtx.close().catch(() => { }) + audioCtx = null + } + } + }, [voiceDataUrl, voiceWaveformRequested]) // 消息加载时自动检测语音缓存 useEffect(() => { @@ -9076,7 +9332,9 @@ function MessageBubble({ if (videoAutoLoadTriggered.current) return videoAutoLoadTriggered.current = true - void requestVideoInfo() + void enqueueAutoMediaTask(async () => requestVideoInfo()).catch(() => { + videoAutoLoadTriggered.current = false + }) }, [isVideo, isVideoVisible, videoInfo, requestVideoInfo]) useEffect(() => { @@ -9395,6 +9653,8 @@ function MessageBubble({ src={imageLocalPath} alt="图片" className={`image-message ${imageLoaded ? 'ready' : 'pending'}`} + loading="lazy" + decoding="async" onClick={() => { void handleOpenImageViewer() }} onLoad={() => { setImageLoaded(true) @@ -9471,9 +9731,9 @@ function MessageBubble({ // 默认显示缩略图,点击打开独立播放窗口 const thumbSrc = videoInfo.thumbUrl || videoInfo.coverUrl return ( -
} onClick={handlePlayVideo}> +
} onClick={handlePlayVideo}> {thumbSrc ? ( - 视频缩略图 + 视频缩略图 ) : (
@@ -9493,6 +9753,9 @@ function MessageBubble({ const durationText = message.voiceDurationSeconds ? `${message.voiceDurationSeconds}"` : '' const handleToggle = async () => { if (voiceLoading) return + if (!voiceWaveformRequested) { + setVoiceWaveformRequested(true) + } const audio = voiceAudioRef.current || new Audio() if (!voiceAudioRef.current) { voiceAudioRef.current = audio diff --git a/src/stores/chatStore.ts b/src/stores/chatStore.ts index 3ca03f1..9911ac7 100644 --- a/src/stores/chatStore.ts +++ b/src/stores/chatStore.ts @@ -3,13 +3,15 @@ import type { ChatSession, Message, Contact } from '../types/models' const messageAliasIndex = new Set() -function buildPrimaryMessageKey(message: Message): string { +function buildPrimaryMessageKey(message: Message, sourceScope?: string): string { if (message.messageKey) return String(message.messageKey) - return `fallback:${message.serverId || 0}:${message.createTime}:${message.sortSeq || 0}:${message.localId || 0}:${message.senderUsername || ''}:${message.localType || 0}` + const normalizedSourceScope = sourceScope ?? String(message._db_path || '').trim() + return `fallback:${normalizedSourceScope}:${message.serverId || 0}:${message.createTime}:${message.sortSeq || 0}:${message.localId || 0}:${message.senderUsername || ''}:${message.localType || 0}` } function buildMessageAliasKeys(message: Message): string[] { - const keys = [buildPrimaryMessageKey(message)] + const sourceScope = String(message._db_path || '').trim() + const keys = [buildPrimaryMessageKey(message, sourceScope)] const localId = Math.max(0, Number(message.localId || 0)) const serverId = Math.max(0, Number(message.serverId || 0)) const createTime = Math.max(0, Number(message.createTime || 0)) @@ -18,15 +20,26 @@ function buildMessageAliasKeys(message: Message): string[] { const isSend = Number(message.isSend ?? -1) if (localId > 0) { - keys.push(`lid:${localId}`) + // 跨 message_*.db 时 local_id 可能重复,必须带分库上下文避免误去重。 + if (sourceScope) { + keys.push(`lid:${sourceScope}:${localId}`) + } else { + // 缺库信息时使用更保守组合,尽量避免把不同消息误判成重复。 + keys.push(`lid_fallback:${localId}:${createTime}:${sender}:${localType}:${serverId}`) + } } if (serverId > 0) { - keys.push(`sid:${serverId}`) + // server_id 在跨库场景并非绝对全局唯一;必须带来源作用域避免误去重。 + if (sourceScope) { + keys.push(`sid:${sourceScope}:${serverId}`) + } else { + keys.push(`sid_fallback:${serverId}:${createTime}:${sender}:${localType}`) + } } if (localType === 3) { const imageIdentity = String(message.imageMd5 || message.imageDatName || '').trim() if (imageIdentity) { - keys.push(`img:${createTime}:${sender}:${isSend}:${imageIdentity}`) + keys.push(`img:${sourceScope}:${createTime}:${sender}:${isSend}:${imageIdentity}`) } } @@ -37,7 +50,9 @@ function rebuildMessageAliasIndex(messages: Message[]): void { messageAliasIndex.clear() for (const message of messages) { const aliasKeys = buildMessageAliasKeys(message) - aliasKeys.forEach((key) => messageAliasIndex.add(key)) + for (const key of aliasKeys) { + messageAliasIndex.add(key) + } } } @@ -136,10 +151,18 @@ export const useChatStore = create((set, get) => ({ const filtered: Message[] = [] newMessages.forEach((msg) => { const aliasKeys = buildMessageAliasKeys(msg) - const exists = aliasKeys.some((key) => messageAliasIndex.has(key)) + let exists = false + for (const key of aliasKeys) { + if (messageAliasIndex.has(key)) { + exists = true + break + } + } if (exists) return filtered.push(msg) - aliasKeys.forEach((key) => messageAliasIndex.add(key)) + for (const key of aliasKeys) { + messageAliasIndex.add(key) + } }) if (filtered.length === 0) return state diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts index 20e3d3c..ce86088 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -1221,4 +1221,3 @@ declare global { export { } -