diff --git a/.github/ISSUE_TEMPLATE/bug.yml b/.github/ISSUE_TEMPLATE/bug.yml index bccc91c..582a9d2 100644 --- a/.github/ISSUE_TEMPLATE/bug.yml +++ b/.github/ISSUE_TEMPLATE/bug.yml @@ -19,6 +19,17 @@ body: required: true - label: 我已阅读过相关文档 required: true + - type: dropdown + id: platform + attributes: + label: 使用平台 + description: 选择出现问题的平台 + options: + - Windows + - macOS + - Linux + validations: + required: true - type: dropdown id: severity attributes: @@ -76,9 +87,9 @@ body: - type: input id: os attributes: - label: 操作系统 - description: 例如:Windows 11、macOS 14.2、Ubuntu 22.04 - placeholder: Windows 11 + label: 操作系统版本 + description: 例如:Windows 11 24H2、macOS 15.0、Ubuntu 24.04 + placeholder: Windows 11 24H2 validations: required: true - type: input diff --git a/.github/workflows/issue-auto-assign.yml b/.github/workflows/issue-auto-assign.yml new file mode 100644 index 0000000..cc76345 --- /dev/null +++ b/.github/workflows/issue-auto-assign.yml @@ -0,0 +1,84 @@ +name: Issue Auto Assign + +on: + issues: + types: [opened, edited, reopened] + +permissions: + issues: write + +jobs: + assign-by-platform: + runs-on: ubuntu-latest + steps: + - name: Assign issue by selected platform + uses: actions/github-script@v7 + env: + ASSIGNEE_WINDOWS: ${{ vars.ISSUE_ASSIGNEE_WINDOWS }} + ASSIGNEE_MACOS: ${{ vars.ISSUE_ASSIGNEE_MACOS }} + ASSIGNEE_LINUX: ${{ vars.ISSUE_ASSIGNEE_LINUX || 'H3CoF6' }} + with: + script: | + const issue = context.payload.issue; + if (!issue) { + core.info("No issue payload."); + return; + } + + const labels = (issue.labels || []).map((l) => l.name); + if (!labels.includes("type: bug")) { + core.info("Skip non-bug issue."); + return; + } + + const body = issue.body || ""; + const match = body.match(/###\s*(?:使用平台|平台|Platform)\s*\r?\n+([^\r\n]+)/i); + if (!match) { + core.info("No platform field found in issue body."); + return; + } + + const rawPlatform = match[1].trim().toLowerCase(); + let platformKey = null; + if (rawPlatform.includes("windows")) platformKey = "windows"; + if (rawPlatform.includes("macos")) platformKey = "macos"; + if (rawPlatform.includes("linux")) platformKey = "linux"; + + if (!platformKey) { + core.info(`Unrecognized platform value: ${rawPlatform}`); + return; + } + + const parseAssignees = (value) => + (value || "") + .split(",") + .map((v) => v.trim()) + .filter(Boolean); + + const assigneeMap = { + windows: parseAssignees(process.env.ASSIGNEE_WINDOWS), + macos: parseAssignees(process.env.ASSIGNEE_MACOS), + linux: parseAssignees(process.env.ASSIGNEE_LINUX), + }; + + const candidates = assigneeMap[platformKey] || []; + if (candidates.length === 0) { + core.info(`No assignee configured for platform: ${platformKey}`); + return; + } + + const existing = new Set((issue.assignees || []).map((a) => a.login)); + const toAdd = candidates.filter((u) => !existing.has(u)); + if (toAdd.length === 0) { + core.info("All configured assignees already assigned."); + return; + } + + await github.rest.issues.addAssignees({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issue.number, + assignees: toAdd, + }); + + core.info(`Assigned issue #${issue.number} to: ${toAdd.join(", ")}`); diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c46b41b..7bcbfde 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -49,20 +49,41 @@ jobs: run: | npx electron-builder --mac dmg --arm64 --publish always - - name: Update Release Notes - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + release-linux: + runs-on: ubuntu-latest + + steps: + - name: Check out git repository + uses: actions/checkout@v5 + with: + fetch-depth: 0 + + - name: Install Node.js + uses: actions/setup-node@v5 + with: + node-version: 24 + cache: "npm" + + - name: Install Dependencies + run: npm ci + + - name: Sync version with tag shell: bash run: | - cat < release_notes.md - ## 更新日志 - 修复了一些已知问题 + VERSION=${GITHUB_REF_NAME#v} + echo "Syncing package.json version to $VERSION" + npm version $VERSION --no-git-tag-version --allow-same-version - ## 查看更多日志/获取最新动态 - [点击加入 Telegram 频道](https://t.me/weflow_cc) - EOF + - name: Build Frontend & Type Check + run: | + npx tsc + npx vite build - gh release edit "$GITHUB_REF_NAME" --notes-file release_notes.md + - name: Package and Publish Linux + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + npx electron-builder --linux --publish always release: runs-on: windows-latest @@ -100,17 +121,66 @@ jobs: run: | npx electron-builder --publish always - - name: Update Release Notes + update-release-notes: + runs-on: ubuntu-latest + needs: + - release-mac-arm64 + - release-linux + - release + + steps: + - name: Generate release notes with platform download links env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} shell: bash run: | - cat < release_notes.md + set -euo pipefail + + TAG="$GITHUB_REF_NAME" + REPO="$GITHUB_REPOSITORY" + RELEASE_PAGE="https://github.com/$REPO/releases/tag/$TAG" + + ASSETS_JSON="$(gh release view "$TAG" --repo "$REPO" --json assets)" + + pick_asset() { + local pattern="$1" + echo "$ASSETS_JSON" | jq -r --arg p "$pattern" '[.assets[].name | select(test($p))][0] // ""' + } + + WINDOWS_ASSET="$(pick_asset "\\.exe$")" + MAC_ASSET="$(pick_asset "\\.dmg$")" + LINUX_DEB_ASSET="$(pick_asset "\\.deb$")" + LINUX_TAR_ASSET="$(pick_asset "\\.tar\\.gz$")" + LINUX_PACMAN_ASSET="$(pick_asset "\\.pacman$")" + + build_link() { + local name="$1" + if [ -n "$name" ]; then + echo "https://github.com/$REPO/releases/download/$TAG/$name" + fi + } + + WINDOWS_URL="$(build_link "$WINDOWS_ASSET")" + MAC_URL="$(build_link "$MAC_ASSET")" + LINUX_DEB_URL="$(build_link "$LINUX_DEB_ASSET")" + LINUX_TAR_URL="$(build_link "$LINUX_TAR_ASSET")" + LINUX_PACMAN_URL="$(build_link "$LINUX_PACMAN_ASSET")" + + cat > release_notes.md < 如果某个平台链接暂时未生成,可进入完整发布页查看全部资源:$RELEASE_PAGE EOF - - gh release edit "$GITHUB_REF_NAME" --notes-file release_notes.md + + gh release edit "$TAG" --repo "$REPO" --notes-file release_notes.md diff --git a/.gitignore b/.gitignore index 0623e78..dbde240 100644 --- a/.gitignore +++ b/.gitignore @@ -62,9 +62,11 @@ server/ chatlab-format.md *.bak AGENTS.md +AGENT.md .claude/ CLAUDE.md .agents/ resources/wx_send 概述.md pnpm-lock.yaml +/pnpm-workspace.yaml diff --git a/.npmrc b/.npmrc index 9291011..5e1ea93 100644 --- a/.npmrc +++ b/.npmrc @@ -1,3 +1,3 @@ registry=https://registry.npmmirror.com -electron_mirror=https://npmmirror.com/mirrors/electron/ -electron_builder_binaries_mirror=https://npmmirror.com/mirrors/electron-builder-binaries/ +electron-mirror=https://npmmirror.com/mirrors/electron/ +electron-builder-binaries-mirror=https://npmmirror.com/mirrors/electron-builder-binaries/ diff --git a/docs/HTTP-API.md b/docs/HTTP-API.md index c82725b..ca2a89a 100644 --- a/docs/HTTP-API.md +++ b/docs/HTTP-API.md @@ -1,6 +1,6 @@ -# WeFlow HTTP API 文档 +# WeFlow HTTP API / Push 文档 -WeFlow 提供本地 HTTP API,便于外部脚本或工具读取聊天记录、会话、联系人、群成员和导出的媒体文件。 +WeFlow 提供本地 HTTP API,便于外部脚本或工具读取聊天记录、会话、联系人、群成员和导出的媒体文件;也支持在检测到新消息后通过固定 SSE 地址主动推送消息事件。 ## 启用方式 @@ -9,12 +9,15 @@ WeFlow 提供本地 HTTP API,便于外部脚本或工具读取聊天记录、 - 默认监听地址:`127.0.0.1` - 默认端口:`5031` - 基础地址:`http://127.0.0.1:5031` +- 可选开启 `主动推送`,检测到新收到的消息后会通过 `GET /api/v1/push/messages` 推送给 SSE 订阅端 ## 接口列表 - `GET /health` - `GET /api/v1/health` +- `GET /api/v1/push/messages` - `GET /api/v1/messages` +- `GET /api/v1/messages/new` - `GET /api/v1/sessions` - `GET /api/v1/contacts` - `GET /api/v1/group-members` @@ -46,7 +49,50 @@ GET /api/v1/health --- -## 2. 获取消息 +## 2. 主动推送 + +通过 SSE 长连接接收新消息事件,端口与 HTTP API 共用。 + +**请求** + +```http +GET /api/v1/push/messages +``` + +### 说明 + +- 需要先在设置页开启 `HTTP API 服务` +- 同时需要开启 `主动推送` +- 响应类型为 `text/event-stream` +- 新消息事件名固定为 `message.new` +- 建议接收端按 `messageKey` 去重 + +### 事件字段 + +- `event` +- `sessionId` +- `messageKey` +- `avatarUrl` +- `sourceName` +- `groupName`(仅群聊) +- `content` + +### 示例 + +```bash +curl -N "http://127.0.0.1:5031/api/v1/push/messages" +``` + +示例事件: + +```text +event: message.new +data: {"event":"message.new","sessionId":"xxx@chatroom","messageKey":"server:123456:1760000123:1760000123000:321:wxid_member:1","avatarUrl":"https://example.com/group.jpg","sourceName":"李四","groupName":"项目群","content":"[图片]"} +``` + +--- + +## 3. 获取消息 读取指定会话的消息,支持原始 JSON 和 ChatLab 格式。 @@ -183,7 +229,7 @@ curl "http://127.0.0.1:5031/api/v1/messages?talker=xxx@chatroom&media=1&image=1& --- -## 3. 获取会话列表 +## 4. 获取会话列表 **请求** @@ -228,7 +274,7 @@ GET /api/v1/sessions --- -## 4. 获取联系人列表 +## 5. 获取联系人列表 **请求** @@ -277,7 +323,7 @@ GET /api/v1/contacts --- -## 5. 获取群成员列表 +## 6. 获取群成员列表 返回群成员的 `wxid`、群昵称、备注、微信号等信息。 @@ -369,7 +415,7 @@ curl "http://127.0.0.1:5031/api/v1/group-members?chatroomId=xxx@chatroom&include --- -## 6. 访问导出媒体 +## 7. 访问导出媒体 通过消息接口启用 `media=1` 后,接口会先把图片、语音、视频、表情导出到本地缓存目录,再返回可访问的 HTTP 地址。 @@ -410,7 +456,7 @@ curl "http://127.0.0.1:5031/api/v1/media/xxx@chatroom/emojis/emoji_300.gif" --- -## 7. 使用示例 +## 8. 使用示例 ### PowerShell @@ -453,7 +499,7 @@ print(members) --- -## 8. 注意事项 +## 9. 注意事项 1. API 仅监听本机 `127.0.0.1`,不对外网开放。 2. 使用前需要先在 WeFlow 中完成数据库连接。 diff --git a/electron/exportWorker.ts b/electron/exportWorker.ts new file mode 100644 index 0000000..1f98439 --- /dev/null +++ b/electron/exportWorker.ts @@ -0,0 +1,56 @@ +import { parentPort, workerData } from 'worker_threads' +import type { ExportOptions } from './services/exportService' + +interface ExportWorkerConfig { + sessionIds: string[] + outputDir: string + options: ExportOptions + resourcesPath?: string + userDataPath?: string + logEnabled?: boolean +} + +const config = workerData as ExportWorkerConfig +process.env.WEFLOW_WORKER = '1' +if (config.resourcesPath) { + process.env.WCDB_RESOURCES_PATH = config.resourcesPath +} +if (config.userDataPath) { + process.env.WEFLOW_USER_DATA_PATH = config.userDataPath + process.env.WEFLOW_CONFIG_CWD = config.userDataPath +} +process.env.WEFLOW_PROJECT_NAME = process.env.WEFLOW_PROJECT_NAME || 'WeFlow' + +async function run() { + const [{ wcdbService }, { exportService }] = await Promise.all([ + import('./services/wcdbService'), + import('./services/exportService') + ]) + + wcdbService.setPaths(config.resourcesPath || '', config.userDataPath || '') + wcdbService.setLogEnabled(config.logEnabled === true) + + const result = await exportService.exportSessions( + Array.isArray(config.sessionIds) ? config.sessionIds : [], + String(config.outputDir || ''), + config.options || { format: 'json' }, + (progress) => { + parentPort?.postMessage({ + type: 'export:progress', + data: progress + }) + } + ) + + parentPort?.postMessage({ + type: 'export:result', + data: result + }) +} + +run().catch((error) => { + parentPort?.postMessage({ + type: 'export:error', + error: String(error) + }) +}) diff --git a/electron/main.ts b/electron/main.ts index 6ba867a..a60c898 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -1,6 +1,7 @@ import './preload-env' import { app, BrowserWindow, ipcMain, nativeTheme, session, Tray, Menu, nativeImage } from 'electron' import { Worker } from 'worker_threads' +import { randomUUID } from 'crypto' import { join, dirname } from 'path' import { autoUpdater } from 'electron-updater' import { readFile, writeFile, mkdir, rm, readdir } from 'fs/promises' @@ -16,6 +17,7 @@ import { groupAnalyticsService } from './services/groupAnalyticsService' import { annualReportService } from './services/annualReportService' import { exportService, ExportOptions, ExportProgress } from './services/exportService' import { KeyService } from './services/keyService' +import { KeyServiceLinux } from './services/keyServiceLinux' import { KeyServiceMac } from './services/keyServiceMac' import { voiceTranscribeService } from './services/voiceTranscribeService' import { videoService } from './services/videoService' @@ -27,6 +29,7 @@ import { cloudControlService } from './services/cloudControlService' import { destroyNotificationWindow, registerNotificationHandlers, showNotification } from './windows/notificationWindow' import { httpService } from './services/httpService' +import { messagePushService } from './services/messagePushService' // 配置自动更新 @@ -89,15 +92,28 @@ let onboardingWindow: BrowserWindow | null = null let splashWindow: BrowserWindow | null = null const sessionChatWindows = new Map() const sessionChatWindowSources = new Map() -const keyService = process.platform === 'darwin' - ? new KeyServiceMac() as any - : new KeyService() + +let keyService: any +if (process.platform === 'darwin') { + keyService = new KeyServiceMac() +} else if (process.platform === 'linux') { + // const { KeyServiceLinux } = require('./services/keyServiceLinux') + // keyService = new KeyServiceLinux() + + import('./services/keyServiceLinux').then(({ KeyServiceLinux }) => { + keyService = new KeyServiceLinux(); + }); + +} else { + keyService = new KeyService() +} let mainWindowReady = false let shouldShowMain = true let isAppQuitting = false let tray: Tray | null = null let isClosePromptVisible = false +const chatHistoryPayloadStore = new Map() type WindowCloseBehavior = 'ask' | 'tray' | 'quit' @@ -272,12 +288,18 @@ const requestMainWindowCloseConfirmation = (win: BrowserWindow): void => { function createWindow(options: { autoShow?: boolean } = {}) { // 获取图标路径 - 打包后在 resources 目录 const { autoShow = true } = options + let iconName = 'icon.ico'; + if (process.platform === 'linux') { + iconName = 'icon.png'; + } else if (process.platform === 'darwin') { + iconName = 'icon.icns'; + } + const isDev = !!process.env.VITE_DEV_SERVER_URL + const iconPath = isDev - ? join(__dirname, '../public/icon.ico') - : (process.platform === 'darwin' - ? join(process.resourcesPath, 'icon.icns') - : join(process.resourcesPath, 'icon.ico')) + ? join(__dirname, `../public/${iconName}`) + : join(process.resourcesPath, iconName); const win = new BrowserWindow({ width: 1400, @@ -749,6 +771,14 @@ function createImageViewerWindow(imagePath: string, liveVideoPath?: string) { * 创建独立的聊天记录窗口 */ function createChatHistoryWindow(sessionId: string, messageId: number) { + return createChatHistoryRouteWindow(`/chat-history/${sessionId}/${messageId}`) +} + +function createChatHistoryPayloadWindow(payloadId: string) { + return createChatHistoryRouteWindow(`/chat-history-inline/${payloadId}`) +} + +function createChatHistoryRouteWindow(route: string) { const isDev = !!process.env.VITE_DEV_SERVER_URL const iconPath = isDev ? join(__dirname, '../public/icon.ico') @@ -783,7 +813,7 @@ function createChatHistoryWindow(sessionId: string, messageId: number) { }) if (process.env.VITE_DEV_SERVER_URL) { - win.loadURL(`${process.env.VITE_DEV_SERVER_URL}#/chat-history/${sessionId}/${messageId}`) + win.loadURL(`${process.env.VITE_DEV_SERVER_URL}#${route}`) win.webContents.on('before-input-event', (event, input) => { if (input.key === 'F12' || (input.control && input.shift && input.key === 'I')) { @@ -797,7 +827,7 @@ function createChatHistoryWindow(sessionId: string, messageId: number) { }) } else { win.loadFile(join(__dirname, '../dist/index.html'), { - hash: `/chat-history/${sessionId}/${messageId}` + hash: route }) } @@ -965,11 +995,14 @@ function registerIpcHandlers() { }) ipcMain.handle('config:set', async (_, key: string, value: any) => { - return configService?.set(key as any, value) + const result = configService?.set(key as any, value) + void messagePushService.handleConfigChanged(key) + return result }) ipcMain.handle('config:clear', async () => { configService?.clear() + messagePushService.handleConfigCleared() return true }) @@ -1237,6 +1270,23 @@ function registerIpcHandlers() { return true }) + ipcMain.handle('window:openChatHistoryPayloadWindow', (_, payload: { sessionId: string; title?: string; recordList: any[] }) => { + const payloadId = randomUUID() + chatHistoryPayloadStore.set(payloadId, { + sessionId: String(payload?.sessionId || '').trim(), + title: String(payload?.title || '').trim() || '聊天记录', + recordList: Array.isArray(payload?.recordList) ? payload.recordList : [] + }) + createChatHistoryPayloadWindow(payloadId) + return true + }) + + ipcMain.handle('window:getChatHistoryPayload', (_, payloadId: string) => { + const payload = chatHistoryPayloadStore.get(String(payloadId || '').trim()) + if (!payload) return { success: false, error: '聊天记录载荷不存在或已失效' } + return { success: true, payload } + }) + // 打开会话聊天窗口(同会话仅保留一个窗口并聚焦) ipcMain.handle('window:openSessionChatWindow', (_, sessionId: string, options?: OpenSessionChatWindowOptions) => { const win = createSessionChatWindow(sessionId, options) @@ -1611,7 +1661,7 @@ function registerIpcHandlers() { ipcMain.handle('chat:getVoiceTranscript', async (event, sessionId: string, msgId: string, createTime?: number) => { return chatService.getVoiceTranscript(sessionId, msgId, createTime, (text) => { - event.sender.send('chat:voiceTranscriptPartial', { msgId, text }) + event.sender.send('chat:voiceTranscriptPartial', { sessionId, msgId, createTime, text }) }) }) @@ -1623,10 +1673,6 @@ function registerIpcHandlers() { return chatService.searchMessages(keyword, sessionId, limit, offset, beginTimestamp, endTimestamp) }) - ipcMain.handle('chat:execQuery', async (_, kind: string, path: string | null, sql: string) => { - return chatService.execQuery(kind, path, sql) - }) - ipcMain.handle('sns:getTimeline', async (_, limit: number, offset: number, usernames?: string[], keyword?: string, startTime?: number, endTime?: number) => { return snsService.getTimeline(limit, offset, usernames, keyword, startTime, endTime) }) @@ -1838,7 +1884,83 @@ function registerIpcHandlers() { } } - return exportService.exportSessions(sessionIds, outputDir, options, onProgress) + const runMainFallback = async (reason: string) => { + console.warn(`[fallback-export-main] ${reason}`) + return exportService.exportSessions(sessionIds, outputDir, options, onProgress) + } + + const cfg = configService || new ConfigService() + configService = cfg + const logEnabled = cfg.get('logEnabled') + const resourcesPath = app.isPackaged + ? join(process.resourcesPath, 'resources') + : join(app.getAppPath(), 'resources') + const userDataPath = app.getPath('userData') + const workerPath = join(__dirname, 'exportWorker.js') + + const runWorker = async () => { + return await new Promise((resolve, reject) => { + const worker = new Worker(workerPath, { + workerData: { + sessionIds, + outputDir, + options, + resourcesPath, + userDataPath, + logEnabled + } + }) + + let settled = false + const finalizeResolve = (value: any) => { + if (settled) return + settled = true + worker.removeAllListeners() + void worker.terminate() + resolve(value) + } + const finalizeReject = (error: Error) => { + if (settled) return + settled = true + worker.removeAllListeners() + void worker.terminate() + reject(error) + } + + worker.on('message', (msg: any) => { + if (msg && msg.type === 'export:progress') { + onProgress(msg.data as ExportProgress) + return + } + if (msg && msg.type === 'export:result') { + finalizeResolve(msg.data) + return + } + if (msg && msg.type === 'export:error') { + finalizeReject(new Error(String(msg.error || '导出 Worker 执行失败'))) + } + }) + + worker.on('error', (error) => { + finalizeReject(error instanceof Error ? error : new Error(String(error))) + }) + + worker.on('exit', (code) => { + if (settled) return + if (code === 0) { + finalizeResolve({ success: false, successCount: 0, failCount: 0, error: '导出 Worker 未返回结果' }) + } else { + finalizeReject(new Error(`导出 Worker 异常退出: ${code}`)) + } + }) + }) + } + + try { + return await runWorker() + } catch (error) { + return runMainFallback(error instanceof Error ? error.message : String(error)) + } }) ipcMain.handle('export:exportSession', async (_, sessionId: string, outputPath: string, options: ExportOptions) => { @@ -2508,6 +2630,10 @@ app.whenReady().then(async () => { // 注册 IPC 处理器 updateSplashProgress(25, '正在初始化...') registerIpcHandlers() + chatService.addDbMonitorListener((type, json) => { + messagePushService.handleDbMonitorChange(type, json) + }) + messagePushService.start() await delay(200) // 检查配置状态 @@ -2518,12 +2644,20 @@ app.whenReady().then(async () => { updateSplashProgress(30, '正在加载界面...') mainWindow = createWindow({ autoShow: false }) - // 初始化系统托盘图标(与其他窗口 icon 路径逻辑保持一致) - const resolvedTrayIcon = process.platform === 'win32' - ? join(__dirname, '../public/icon.ico') - : (process.platform === 'darwin' - ? join(process.resourcesPath, 'icon.icns') - : join(process.resourcesPath, 'icon.ico')) + let iconName = 'icon.ico'; + if (process.platform === 'linux') { + iconName = 'icon.png'; + } else if (process.platform === 'darwin') { + iconName = 'icon.icns'; + } + + const isDev = !!process.env.VITE_DEV_SERVER_URL + + const resolvedTrayIcon = isDev + ? join(__dirname, `../public/${iconName}`) + : join(process.resourcesPath, iconName); + + try { tray = new Tray(resolvedTrayIcon) tray.setToolTip('WeFlow') diff --git a/electron/preload.ts b/electron/preload.ts index 4cce51c..f12a272 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -113,6 +113,10 @@ contextBridge.exposeInMainWorld('electronAPI', { ipcRenderer.invoke('window:openImageViewerWindow', imagePath, liveVideoPath), openChatHistoryWindow: (sessionId: string, messageId: number) => ipcRenderer.invoke('window:openChatHistoryWindow', sessionId, messageId), + openChatHistoryPayloadWindow: (payload: { sessionId: string; title?: string; recordList: any[] }) => + ipcRenderer.invoke('window:openChatHistoryPayloadWindow', payload), + getChatHistoryPayload: (payloadId: string) => + ipcRenderer.invoke('window:getChatHistoryPayload', payloadId), openSessionChatWindow: ( sessionId: string, options?: { @@ -215,13 +219,11 @@ contextBridge.exposeInMainWorld('electronAPI', { getMessageDateCounts: (sessionId: string) => ipcRenderer.invoke('chat:getMessageDateCounts', sessionId), resolveVoiceCache: (sessionId: string, msgId: string) => ipcRenderer.invoke('chat:resolveVoiceCache', sessionId, msgId), getVoiceTranscript: (sessionId: string, msgId: string, createTime?: number) => ipcRenderer.invoke('chat:getVoiceTranscript', sessionId, msgId, createTime), - onVoiceTranscriptPartial: (callback: (payload: { msgId: string; text: string }) => void) => { - const listener = (_: any, payload: { msgId: string; text: string }) => callback(payload) + onVoiceTranscriptPartial: (callback: (payload: { sessionId?: string; msgId: string; createTime?: number; text: string }) => void) => { + const listener = (_: any, payload: { sessionId?: string; msgId: string; createTime?: number; text: string }) => callback(payload) ipcRenderer.on('chat:voiceTranscriptPartial', listener) return () => ipcRenderer.removeListener('chat:voiceTranscriptPartial', listener) }, - execQuery: (kind: string, path: string | null, sql: string) => - ipcRenderer.invoke('chat:execQuery', kind, path, sql), getContacts: () => ipcRenderer.invoke('chat:getContacts'), getMessage: (sessionId: string, localId: number) => ipcRenderer.invoke('chat:getMessage', sessionId, localId), @@ -244,12 +246,14 @@ contextBridge.exposeInMainWorld('electronAPI', { preload: (payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string }>) => ipcRenderer.invoke('image:preload', payloads), onUpdateAvailable: (callback: (payload: { cacheKey: string; imageMd5?: string; imageDatName?: string }) => void) => { - ipcRenderer.on('image:updateAvailable', (_, payload) => callback(payload)) - return () => ipcRenderer.removeAllListeners('image:updateAvailable') + const listener = (_: unknown, payload: { cacheKey: string; imageMd5?: string; imageDatName?: string }) => callback(payload) + ipcRenderer.on('image:updateAvailable', listener) + return () => ipcRenderer.removeListener('image:updateAvailable', listener) }, onCacheResolved: (callback: (payload: { cacheKey: string; imageMd5?: string; imageDatName?: string; localPath: string }) => void) => { - ipcRenderer.on('image:cacheResolved', (_, payload) => callback(payload)) - return () => ipcRenderer.removeAllListeners('image:cacheResolved') + const listener = (_: unknown, payload: { cacheKey: string; imageMd5?: string; imageDatName?: string; localPath: string }) => callback(payload) + ipcRenderer.on('image:cacheResolved', listener) + return () => ipcRenderer.removeListener('image:cacheResolved', listener) } }, @@ -352,7 +356,20 @@ contextBridge.exposeInMainWorld('electronAPI', { ipcRenderer.invoke('export:exportSession', sessionId, outputPath, options), exportContacts: (outputDir: string, options: any) => ipcRenderer.invoke('export:exportContacts', outputDir, options), - onProgress: (callback: (payload: { current: number; total: number; currentSession: string; currentSessionId?: string; phase: string }) => void) => { + onProgress: (callback: (payload: { + current: number + total: number + currentSession: string + currentSessionId?: string + phase: string + phaseProgress?: number + phaseTotal?: number + phaseLabel?: string + collectedMessages?: number + exportedMessages?: number + estimatedTotalMessages?: number + writtenFiles?: number + }) => void) => { ipcRenderer.on('export:progress', (_, payload) => callback(payload)) return () => ipcRenderer.removeAllListeners('export:progress') } diff --git a/electron/services/analyticsService.ts b/electron/services/analyticsService.ts index 875be7a..1ba6c00 100644 --- a/electron/services/analyticsService.ts +++ b/electron/services/analyticsService.ts @@ -68,29 +68,14 @@ class AnalyticsService { return new Set(this.getExcludedUsernamesList()) } - private escapeSqlValue(value: string): string { - return value.replace(/'/g, "''") - } - private async getAliasMap(usernames: string[]): Promise> { const map: Record = {} if (usernames.length === 0) return map - // C++ 层不支持参数绑定,直接内联转义后的字符串值 - const chunkSize = 200 - for (let i = 0; i < usernames.length; i += chunkSize) { - const chunk = usernames.slice(i, i + chunkSize) - const inList = chunk.map((u) => `'${this.escapeSqlValue(u)}'`).join(',') - const sql = `SELECT username, alias FROM contact WHERE username IN (${inList})` - const result = await wcdbService.execQuery('contact', null, sql) - if (!result.success || !result.rows) continue - for (const row of result.rows as Record[]) { - const username = row.username || '' - const alias = row.alias || '' - if (username && alias) { - map[username] = alias - } - } + const result = await wcdbService.getContactAliasMap(usernames) + if (!result.success || !result.map) return map + for (const [username, alias] of Object.entries(result.map)) { + if (username && alias) map[username] = alias } return map diff --git a/electron/services/annualReportService.ts b/electron/services/annualReportService.ts index f91cfc6..e6e0967 100644 --- a/electron/services/annualReportService.ts +++ b/electron/services/annualReportService.ts @@ -278,16 +278,16 @@ class AnnualReportService { return cached || null } - const result = await wcdbService.execQuery('message', dbPath, `PRAGMA table_info(${this.quoteSqlIdentifier(tableName)})`) - if (!result.success || !Array.isArray(result.rows) || result.rows.length === 0) { + const result = await wcdbService.getMessageTableColumns(dbPath, tableName) + if (!result.success || !Array.isArray(result.columns) || result.columns.length === 0) { this.availableYearsColumnCache.set(cacheKey, '') return null } const candidates = ['create_time', 'createtime', 'msg_create_time', 'msg_time', 'msgtime', 'time'] const columns = new Set() - for (const row of result.rows as Record[]) { - const name = String(row.name || row.column_name || row.columnName || '').trim().toLowerCase() + for (const columnName of result.columns) { + const name = String(columnName || '').trim().toLowerCase() if (name) columns.add(name) } @@ -309,10 +309,11 @@ class AnnualReportService { const tried = new Set() const queryByColumn = async (column: string): Promise<{ first: number; last: number } | null> => { - const sql = `SELECT MIN(${this.quoteSqlIdentifier(column)}) AS first_ts, MAX(${this.quoteSqlIdentifier(column)}) AS last_ts FROM ${this.quoteSqlIdentifier(tableName)}` - const result = await wcdbService.execQuery('message', dbPath, sql) - if (!result.success || !Array.isArray(result.rows) || result.rows.length === 0) return null - const row = result.rows[0] as Record + const result = await wcdbService.getMessageTableTimeRange(dbPath, tableName) + if (!result.success || !result.data) return null + const row = result.data as Record + const actualColumn = String(row.column || '').trim().toLowerCase() + if (column && actualColumn && column.toLowerCase() !== actualColumn) return null const first = this.toUnixTimestamp(row.first_ts ?? row.firstTs ?? row.min_ts ?? row.minTs) const last = this.toUnixTimestamp(row.last_ts ?? row.lastTs ?? row.max_ts ?? row.maxTs) return { first, last } diff --git a/electron/services/chatService.ts b/electron/services/chatService.ts index f2f508d..e8d1c1f 100644 --- a/electron/services/chatService.ts +++ b/electron/services/chatService.ts @@ -1,5 +1,5 @@ import { join, dirname, basename, extname } from 'path' -import { existsSync, mkdirSync, readdirSync, statSync, readFileSync, writeFileSync, copyFileSync, unlinkSync, watch } from 'fs' +import { existsSync, mkdirSync, readdirSync, statSync, readFileSync, writeFileSync, copyFileSync, unlinkSync, watch, promises as fsPromises } from 'fs' import * as path from 'path' import * as fs from 'fs' import * as https from 'https' @@ -40,6 +40,7 @@ export interface Message { messageKey: string localId: number serverId: number + serverIdRaw?: string localType: number createTime: number sortSeq: number @@ -113,8 +114,28 @@ export interface Message { datatype: number sourcename: string sourcetime: string - datadesc: string + sourceheadurl?: string + datadesc?: string datatitle?: string + fileext?: string + datasize?: number + messageuuid?: string + dataurl?: string + datathumburl?: string + datacdnurl?: string + cdndatakey?: string + cdnthumbkey?: string + aeskey?: string + md5?: string + fullmd5?: string + thumbfullmd5?: string + srcMsgLocalid?: number + imgheight?: number + imgwidth?: number + duration?: number + chatRecordTitle?: string + chatRecordDesc?: string + chatRecordList?: any[] }> _db_path?: string // 内部字段:记录消息所属数据库路径 } @@ -202,6 +223,7 @@ const FRIEND_EXCLUDE_USERNAMES = new Set(['medianote', 'floatbottle', 'qmessage' class ChatService { private configService: ConfigService private connected = false + private readonly dbMonitorListeners = new Set<(type: string, json: string) => void>() private messageCursors: Map = new Map() private messageCursorMutex: boolean = false private readonly messageBatchDefault = 50 @@ -232,12 +254,18 @@ class ChatService { name2IdTable?: string }>() // 缓存会话表信息,避免每次查询 - private sessionTablesCache = new Map>() + private sessionTablesCache = new Map; updatedAt: number }>() private messageTableColumnsCache = new Map; updatedAt: number }>() private messageName2IdTableCache = new Map() private messageSenderIdCache = new Map() private readonly sessionTablesCacheTtl = 300000 // 5分钟 private readonly messageTableColumnsCacheTtlMs = 30 * 60 * 1000 + private messageDbCountSnapshotCache: { + dbPaths: string[] + dbSignature: string + updatedAt: number + } | null = null + private readonly messageDbCountSnapshotCacheTtlMs = 8000 private sessionMessageCountCache = new Map() private sessionMessageCountHintCache = new Map() private sessionMessageCountBatchCache: { @@ -354,6 +382,13 @@ class ChatService { private monitorSetup = false + addDbMonitorListener(listener: (type: string, json: string) => void): () => void { + this.dbMonitorListeners.add(listener) + return () => { + this.dbMonitorListeners.delete(listener) + } + } + private setupDbMonitor() { if (this.monitorSetup) return this.monitorSetup = true @@ -362,6 +397,13 @@ class ChatService { // 这种方式更高效,且不占用 JS 线程,并能直接监听 session/message 目录变更 wcdbService.setMonitor((type, json) => { this.handleSessionStatsMonitorChange(type, json) + for (const listener of this.dbMonitorListeners) { + try { + listener(type, json) + } catch (error) { + console.error('[ChatService] 数据库监听回调失败:', error) + } + } const windows = BrowserWindow.getAllWindows() // 广播给所有渲染进程窗口 windows.forEach((win) => { @@ -593,11 +635,10 @@ class ChatService { const now = Date.now() for (const username of usernames) { - const state = result.map[username] - if (!state) continue + const state = result.map[username] || { isFolded: false, isMuted: false } this.sessionStatusCache.set(username, { - isFolded: state.isFolded, - isMuted: state.isMuted, + isFolded: Boolean(state.isFolded), + isMuted: Boolean(state.isMuted), updatedAt: now }) } @@ -741,30 +782,6 @@ class ChatService { if (usernames.length === 0) return result try { - const dbPath = this.configService.get('dbPath') - const wxid = this.configService.get('myWxid') - if (!dbPath || !wxid) return result - - const accountDir = this.resolveAccountDir(dbPath, wxid) - if (!accountDir) return result - - // head_image.db 可能在不同位置 - const headImageDbPaths = [ - join(accountDir, 'db_storage', 'head_image', 'head_image.db'), - join(accountDir, 'db_storage', 'head_image.db'), - join(accountDir, 'head_image.db') - ] - - let headImageDbPath: string | null = null - for (const path of headImageDbPaths) { - if (existsSync(path)) { - headImageDbPath = path - break - } - } - - if (!headImageDbPath) return result - const normalizedUsernames = Array.from( new Set( usernames @@ -778,38 +795,20 @@ class ChatService { for (let i = 0; i < normalizedUsernames.length; i += batchSize) { const batch = normalizedUsernames.slice(i, i + batchSize) if (batch.length === 0) continue - const usernamesExpr = batch.map((name) => `'${this.escapeSqlString(name)}'`).join(',') - const queryResult = await wcdbService.execQuery( - 'media', - headImageDbPath, - `SELECT username, image_buffer FROM head_image WHERE username IN (${usernamesExpr})` - ) - if (!queryResult.success || !queryResult.rows || queryResult.rows.length === 0) { - continue - } + const queryResult = await wcdbService.getHeadImageBuffers(batch) + if (!queryResult.success || !queryResult.map) continue - for (const row of queryResult.rows as any[]) { - const username = String(row?.username || '').trim() - if (!username || !row?.image_buffer) continue - - let base64Data: string | null = null - if (typeof row.image_buffer === 'string') { - // WCDB 返回的 BLOB 可能是十六进制字符串,需要转换为 base64 - if (row.image_buffer.toLowerCase().startsWith('ffd8')) { - const buffer = Buffer.from(row.image_buffer, 'hex') - base64Data = buffer.toString('base64') - } else { - base64Data = row.image_buffer + for (const [username, rawHex] of Object.entries(queryResult.map)) { + const hex = String(rawHex || '').trim() + if (!username || !hex) continue + try { + const base64Data = Buffer.from(hex, 'hex').toString('base64') + if (base64Data) { + result[username] = `data:image/jpeg;base64,${base64Data}` } - } else if (Buffer.isBuffer(row.image_buffer)) { - base64Data = row.image_buffer.toString('base64') - } else if (Array.isArray(row.image_buffer)) { - base64Data = Buffer.from(row.image_buffer).toString('base64') - } - - if (base64Data) { - result[username] = `data:image/jpeg;base64,${base64Data}` + } catch { + // ignore invalid blob hex } } } @@ -852,48 +851,16 @@ class ChatService { return { success: false, error: connectResult.error } } - const excludeExpr = Array.from(FRIEND_EXCLUDE_USERNAMES) - .map((username) => `'${this.escapeSqlString(username)}'`) - .join(',') - - const countsSql = ` - SELECT - SUM(CASE WHEN username LIKE '%@chatroom' THEN 1 ELSE 0 END) AS group_count, - SUM(CASE WHEN username LIKE 'gh_%' THEN 1 ELSE 0 END) AS official_count, - SUM( - CASE - WHEN username NOT LIKE '%@chatroom' - AND username NOT LIKE 'gh_%' - AND local_type = 1 - AND username NOT IN (${excludeExpr}) - THEN 1 ELSE 0 - END - ) AS private_count, - SUM( - CASE - WHEN username NOT LIKE '%@chatroom' - AND username NOT LIKE 'gh_%' - AND local_type = 0 - AND COALESCE(quan_pin, '') != '' - THEN 1 ELSE 0 - END - ) AS former_friend_count - FROM contact - WHERE username IS NOT NULL - AND username != '' - ` - - const result = await wcdbService.execQuery('contact', null, countsSql) - if (!result.success || !result.rows || result.rows.length === 0) { + const result = await wcdbService.getContactTypeCounts() + if (!result.success || !result.counts) { return { success: false, error: result.error || '获取联系人类型数量失败' } } - const row = result.rows[0] as Record const counts: ExportTabCounts = { - private: this.getRowInt(row, ['private_count', 'privateCount'], 0), - group: this.getRowInt(row, ['group_count', 'groupCount'], 0), - official: this.getRowInt(row, ['official_count', 'officialCount'], 0), - former_friend: this.getRowInt(row, ['former_friend_count', 'formerFriendCount'], 0) + private: Number(result.counts.private || 0), + group: Number(result.counts.group || 0), + official: Number(result.counts.official || 0), + former_friend: Number(result.counts.former_friend || 0) } return { success: true, counts } @@ -1014,87 +981,20 @@ class ChatService { return { success: true, counts: {}, dbSignature: 'empty' } } - const dbPathsResult = await this.listMessageDbPathsForCount() - if (!dbPathsResult.success) { - return { success: false, error: dbPathsResult.error || '获取消息数据库列表失败' } + const snapshotResult = await this.getMessageDbCountSnapshot() + const dbPaths = snapshotResult.success ? (snapshotResult.dbPaths || []) : [] + const dbSignature = snapshotResult.success + ? (snapshotResult.dbSignature || this.buildMessageDbSignature(dbPaths)) + : this.buildMessageDbSignature(dbPaths) + const nativeResult = await wcdbService.getSessionMessageCounts(normalizedSessionIds) + if (!nativeResult.success || !nativeResult.counts) { + return { success: false, error: nativeResult.error || '获取会话消息总数失败', dbSignature } } - const dbPaths = dbPathsResult.dbPaths || [] - const dbSignature = this.buildMessageDbSignature(dbPaths) - if (dbPaths.length === 0) { - const emptyCounts = normalizedSessionIds.reduce>((acc, sessionId) => { - acc[sessionId] = 0 - return acc - }, {}) - return { success: true, counts: emptyCounts, dbSignature } - } - - const hashLookup = this.buildSessionHashLookup(normalizedSessionIds) - const counts = normalizedSessionIds.reduce>((acc, sessionId) => { - acc[sessionId] = 0 + const counts = normalizedSessionIds.reduce>((acc, sid) => { + const raw = nativeResult.counts?.[sid] + acc[sid] = Number.isFinite(raw) ? Math.max(0, Math.floor(Number(raw))) : 0 return acc }, {}) - const unionChunkSize = 48 - const queryCountKeys = ['count', 'COUNT(*)', 'cnt', 'CNT', 'table_count', 'tableCount'] - - for (const dbPath of dbPaths) { - const tablesResult = await wcdbService.execQuery( - 'message', - dbPath, - "SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'Msg_%'" - ) - if (!tablesResult.success || !tablesResult.rows || tablesResult.rows.length === 0) { - continue - } - - const tableToSessionId = new Map() - for (const row of tablesResult.rows as Record[]) { - const tableName = String(this.getRowField(row, ['name', 'table_name', 'tableName']) || '').trim() - if (!tableName) continue - const sessionId = this.matchSessionIdByTableName(tableName, hashLookup) - if (!sessionId) continue - tableToSessionId.set(tableName, sessionId) - } - - if (tableToSessionId.size === 0) { - continue - } - - const matchedTables = Array.from(tableToSessionId.keys()) - for (let i = 0; i < matchedTables.length; i += unionChunkSize) { - const chunk = matchedTables.slice(i, i + unionChunkSize) - if (chunk.length === 0) continue - - const unionSql = chunk.map((tableName) => { - const tableAlias = tableName.replace(/'/g, "''") - return `SELECT '${tableAlias}' AS table_name, COUNT(*) AS count FROM ${this.quoteSqlIdentifier(tableName)}` - }).join(' UNION ALL ') - - const unionResult = await wcdbService.execQuery('message', dbPath, unionSql) - if (unionResult.success && unionResult.rows) { - for (const row of unionResult.rows as Record[]) { - const tableName = String(this.getRowField(row, ['table_name', 'tableName', 'name']) || '').trim() - const sessionId = tableToSessionId.get(tableName) - if (!sessionId) continue - const countValue = Math.max(0, Math.floor(this.getRowInt(row, queryCountKeys, 0))) - counts[sessionId] = (counts[sessionId] || 0) + countValue - } - continue - } - - // 回退到逐表查询,避免单个 UNION 查询失败导致整批丢失。 - for (const tableName of chunk) { - const sessionId = tableToSessionId.get(tableName) - if (!sessionId) continue - const countSql = `SELECT COUNT(*) AS count FROM ${this.quoteSqlIdentifier(tableName)}` - const singleResult = await wcdbService.execQuery('message', dbPath, countSql) - if (!singleResult.success || !singleResult.rows || singleResult.rows.length === 0) { - continue - } - const countValue = Math.max(0, Math.floor(this.getRowInt(singleResult.rows[0], queryCountKeys, 0))) - counts[sessionId] = (counts[sessionId] || 0) + countValue - } - } - } this.logExportDiag({ traceId, @@ -1199,21 +1099,18 @@ class ChatService { now - cachedBatch.updatedAt <= this.sessionMessageCountBatchCacheTtlMs if (cachedBatchFresh && cachedBatch.sessionIdsKey === sessionIdsKey) { - const dbPathsResult = await this.listMessageDbPathsForCount() - if (dbPathsResult.success) { - const currentDbSignature = this.buildMessageDbSignature(dbPathsResult.dbPaths || []) - if (currentDbSignature === cachedBatch.dbSignature) { - for (const sessionId of pendingSessionIds) { - const nextCountRaw = cachedBatch.counts[sessionId] - const nextCount = Number.isFinite(nextCountRaw) ? Math.max(0, Math.floor(nextCountRaw)) : 0 - counts[sessionId] = nextCount - this.sessionMessageCountCache.set(sessionId, { - count: nextCount, - updatedAt: now - }) - } - tableScanSucceeded = true + const snapshot = await this.getMessageDbCountSnapshot() + if (snapshot.success && snapshot.dbSignature === cachedBatch.dbSignature) { + for (const sessionId of pendingSessionIds) { + const nextCountRaw = cachedBatch.counts[sessionId] + const nextCount = Number.isFinite(nextCountRaw) ? Math.max(0, Math.floor(nextCountRaw)) : 0 + counts[sessionId] = nextCount + this.sessionMessageCountCache.set(sessionId, { + count: nextCount, + updatedAt: now + }) } + tableScanSucceeded = true } } @@ -1325,29 +1222,15 @@ class ChatService { return { success: false, error: connectResult.error } } - // 使用execQuery直接查询加密的contact.db - // kind='contact', path=null表示使用已打开的contact.db - const contactQuery = ` - SELECT username, remark, nick_name, alias, local_type, quan_pin - FROM contact - WHERE username IS NOT NULL - AND username != '' - AND ( - username LIKE '%@chatroom' - OR username LIKE 'gh_%' - OR local_type = 1 - OR (local_type = 0 AND COALESCE(quan_pin, '') != '') - ) - ` - const contactResult = await wcdbService.execQuery('contact', null, contactQuery) + const contactResult = await wcdbService.getContactsCompact() - if (!contactResult.success || !contactResult.rows) { + if (!contactResult.success || !contactResult.contacts) { console.error('查询联系人失败:', contactResult.error) return { success: false, error: contactResult.error || '查询联系人失败' } } - const rows = contactResult.rows as Record[] + const rows = contactResult.contacts as Record[] // 获取会话表的最后联系时间用于排序 const lastContactTimeMap = new Map() const sessionResult = await wcdbService.getSessions() @@ -1945,6 +1828,69 @@ class ChatService { return Number.isFinite(parsed) ? parsed : fallback } + private normalizeUnsignedIntegerToken(raw: any): string | undefined { + if (raw === undefined || raw === null || raw === '') return undefined + + if (typeof raw === 'bigint') { + return raw >= 0n ? raw.toString() : '0' + } + + if (typeof raw === 'number') { + if (!Number.isFinite(raw)) return undefined + return String(Math.max(0, Math.floor(raw))) + } + + if (Buffer.isBuffer(raw)) { + return this.normalizeUnsignedIntegerToken(raw.toString('utf-8').trim()) + } + if (raw instanceof Uint8Array) { + return this.normalizeUnsignedIntegerToken(Buffer.from(raw).toString('utf-8').trim()) + } + if (Array.isArray(raw)) { + return this.normalizeUnsignedIntegerToken(Buffer.from(raw).toString('utf-8').trim()) + } + + if (typeof raw === 'object') { + if ('value' in raw) return this.normalizeUnsignedIntegerToken(raw.value) + if ('intValue' in raw) return this.normalizeUnsignedIntegerToken(raw.intValue) + if ('low' in raw && 'high' in raw) { + try { + const low = BigInt(raw.low >>> 0) + const high = BigInt(raw.high >>> 0) + const value = (high << 32n) + low + return value >= 0n ? value.toString() : '0' + } catch { + return undefined + } + } + const text = raw.toString ? String(raw).trim() : '' + if (text && text !== '[object Object]') { + return this.normalizeUnsignedIntegerToken(text) + } + return undefined + } + + const text = String(raw).trim() + if (!text) return undefined + if (/^\d+$/.test(text)) { + return text.replace(/^0+(?=\d)/, '') || '0' + } + if (/^[+-]?\d+$/.test(text)) { + try { + const value = BigInt(text) + return value >= 0n ? value.toString() : '0' + } catch { + return undefined + } + } + + const parsed = Number(text) + if (Number.isFinite(parsed)) { + return String(Math.max(0, Math.floor(parsed))) + } + return undefined + } + private coerceRowNumber(raw: any): number { if (raw === undefined || raw === null) return NaN if (typeof raw === 'number') return raw @@ -2065,16 +2011,12 @@ class ChatService { private async getFriendIdentitySet(): Promise> { const identities = new Set() - const contactResult = await wcdbService.execQuery( - 'contact', - null, - 'SELECT username, local_type, quan_pin FROM contact' - ) - if (!contactResult.success || !contactResult.rows) { + const contactResult = await wcdbService.getContactsCompact() + if (!contactResult.success || !contactResult.contacts) { return identities } - for (const rowAny of contactResult.rows) { + for (const rowAny of contactResult.contacts) { const row = rowAny as Record const username = String(row.username || '').trim() if (!username || username.includes('@chatroom') || username.startsWith('gh_')) continue @@ -2203,7 +2145,9 @@ class ChatService { this.sessionDetailFastCache.clear() this.sessionDetailExtraCache.clear() this.sessionStatusCache.clear() + this.sessionTablesCache.clear() this.messageTableColumnsCache.clear() + this.messageDbCountSnapshotCache = null this.refreshSessionStatsCacheScope(scope) this.refreshGroupMyMessageCountCacheScope(scope) } @@ -2395,6 +2339,13 @@ class ChatService { if (!this.sessionStatsCacheScope) return const normalizedType = String(type || '').toLowerCase() + if ( + normalizedType.includes('message') || + normalizedType.includes('session') || + normalizedType.includes('db') + ) { + this.messageDbCountSnapshotCache = null + } const maybeJson = String(json || '').trim() let ids = new Set() if (maybeJson) { @@ -2457,9 +2408,13 @@ class ChatService { } private async getSessionMessageTables(sessionId: string): Promise> { + const now = Date.now() const cached = this.sessionTablesCache.get(sessionId) - if (cached && cached.length > 0) { - return cached + if (cached && now - cached.updatedAt <= this.sessionTablesCacheTtl && cached.tables.length > 0) { + return cached.tables + } + if (cached) { + this.sessionTablesCache.delete(sessionId) } const tableStats = await wcdbService.getMessageTableStats(sessionId) @@ -2472,8 +2427,10 @@ class ChatService { .filter(t => t.tableName && t.dbPath) as Array<{ tableName: string; dbPath: string }> if (tables.length > 0) { - this.sessionTablesCache.set(sessionId, tables) - setTimeout(() => { this.sessionTablesCache.delete(sessionId) }, this.sessionTablesCacheTtl) + this.sessionTablesCache.set(sessionId, { + tables, + updatedAt: now + }) } return tables } @@ -2486,14 +2443,12 @@ class ChatService { return new Set(cached.columns) } - const pragmaSql = `PRAGMA table_info(${this.quoteSqlIdentifier(tableName)})` - const result = await wcdbService.execQuery('message', dbPath, pragmaSql) - if (!result.success || !result.rows || result.rows.length === 0) { - return new Set() - } + const result = await wcdbService.getMessageTableColumns(dbPath, tableName) + if (!result.success || !Array.isArray(result.columns) || result.columns.length === 0) return new Set() + const columns = new Set() - for (const row of result.rows as Record[]) { - const name = String(this.getRowField(row, ['name', 'column_name', 'columnName']) || '').trim().toLowerCase() + for (const columnName of result.columns) { + const name = String(columnName || '').trim().toLowerCase() if (name) columns.add(name) } this.messageTableColumnsCache.set(cacheKey, { @@ -2702,136 +2657,32 @@ class ChatService { redPacketMessages: 0, callMessages: 0 } - if (sessionId.endsWith('@chatroom')) { + const isGroup = sessionId.endsWith('@chatroom') + if (isGroup) { stats.groupMyMessages = 0 stats.groupActiveSpeakers = 0 } - const tables = await this.getSessionMessageTables(sessionId) - if (tables.length === 0) { - return stats - } - - const senderIdentities = new Set() - let aggregatedTableCount = 0 - const isGroup = sessionId.endsWith('@chatroom') - const escapedSelfKeys = Array.from(selfIdentitySet) - .filter(Boolean) - .map((key) => `'${this.escapeSqlLiteral(key.toLowerCase())}'`) - - for (const { tableName, dbPath } of tables) { - const columnSet = await this.getMessageTableColumns(dbPath, tableName) - if (columnSet.size === 0) continue - - const typeCol = this.pickFirstColumn(columnSet, ['local_type', 'type', 'msg_type', 'msgtype']) - const timeCol = this.pickFirstColumn(columnSet, ['create_time', 'createtime', 'msg_create_time', 'time']) - const senderCol = this.pickFirstColumn(columnSet, ['sender_username', 'senderusername', 'sender']) - const isSendCol = this.pickFirstColumn(columnSet, ['computed_is_send', 'computedissend', 'is_send', 'issend']) - - const selectParts: string[] = [ - 'COUNT(*) AS total_messages', - typeCol ? `SUM(CASE WHEN ${this.quoteSqlIdentifier(typeCol)} = 34 THEN 1 ELSE 0 END) AS voice_messages` : '0 AS voice_messages', - typeCol ? `SUM(CASE WHEN ${this.quoteSqlIdentifier(typeCol)} = 3 THEN 1 ELSE 0 END) AS image_messages` : '0 AS image_messages', - typeCol ? `SUM(CASE WHEN ${this.quoteSqlIdentifier(typeCol)} = 43 THEN 1 ELSE 0 END) AS video_messages` : '0 AS video_messages', - typeCol ? `SUM(CASE WHEN ${this.quoteSqlIdentifier(typeCol)} = 47 THEN 1 ELSE 0 END) AS emoji_messages` : '0 AS emoji_messages', - typeCol ? `SUM(CASE WHEN ${this.quoteSqlIdentifier(typeCol)} = 50 THEN 1 ELSE 0 END) AS call_messages` : '0 AS call_messages', - typeCol ? `SUM(CASE WHEN ${this.quoteSqlIdentifier(typeCol)} = 8589934592049 THEN 1 ELSE 0 END) AS transfer_messages` : '0 AS transfer_messages', - typeCol ? `SUM(CASE WHEN ${this.quoteSqlIdentifier(typeCol)} = 8594229559345 THEN 1 ELSE 0 END) AS red_packet_messages` : '0 AS red_packet_messages', - timeCol ? `MIN(${this.quoteSqlIdentifier(timeCol)}) AS first_timestamp` : 'NULL AS first_timestamp', - timeCol ? `MAX(${this.quoteSqlIdentifier(timeCol)}) AS last_timestamp` : 'NULL AS last_timestamp' - ] - - if (isGroup) { - if (senderCol) { - const normalizedSender = `LOWER(TRIM(CAST(${this.quoteSqlIdentifier(senderCol)} AS TEXT)))` - if (escapedSelfKeys.length > 0 && isSendCol) { - selectParts.push( - `SUM(CASE WHEN ${normalizedSender} != '' THEN CASE WHEN ${normalizedSender} IN (${escapedSelfKeys.join(', ')}) THEN 1 ELSE 0 END ELSE CASE WHEN ${this.quoteSqlIdentifier(isSendCol)} = 1 THEN 1 ELSE 0 END END) AS group_my_messages` - ) - } else if (escapedSelfKeys.length > 0) { - selectParts.push(`SUM(CASE WHEN ${normalizedSender} IN (${escapedSelfKeys.join(', ')}) THEN 1 ELSE 0 END) AS group_my_messages`) - } else if (isSendCol) { - selectParts.push(`SUM(CASE WHEN ${this.quoteSqlIdentifier(isSendCol)} = 1 THEN 1 ELSE 0 END) AS group_my_messages`) - } else { - selectParts.push('0 AS group_my_messages') - } - } else if (isSendCol) { - selectParts.push(`SUM(CASE WHEN ${this.quoteSqlIdentifier(isSendCol)} = 1 THEN 1 ELSE 0 END) AS group_my_messages`) - } else { - selectParts.push('0 AS group_my_messages') - } - - const aggregateSql = `SELECT ${selectParts.join(', ')} FROM ${this.quoteSqlIdentifier(tableName)}` - const aggregateResult = await wcdbService.execQuery('message', dbPath, aggregateSql) - if (!aggregateResult.success || !aggregateResult.rows || aggregateResult.rows.length === 0) { - continue - } - - const aggregateRow = aggregateResult.rows[0] as Record - aggregatedTableCount += 1 - stats.totalMessages += this.getRowInt(aggregateRow, ['total_messages', 'totalMessages'], 0) - stats.voiceMessages += this.getRowInt(aggregateRow, ['voice_messages', 'voiceMessages'], 0) - stats.imageMessages += this.getRowInt(aggregateRow, ['image_messages', 'imageMessages'], 0) - stats.videoMessages += this.getRowInt(aggregateRow, ['video_messages', 'videoMessages'], 0) - stats.emojiMessages += this.getRowInt(aggregateRow, ['emoji_messages', 'emojiMessages'], 0) - stats.callMessages += this.getRowInt(aggregateRow, ['call_messages', 'callMessages'], 0) - stats.transferMessages += this.getRowInt(aggregateRow, ['transfer_messages', 'transferMessages'], 0) - stats.redPacketMessages += this.getRowInt(aggregateRow, ['red_packet_messages', 'redPacketMessages'], 0) - - const firstTs = this.getRowInt(aggregateRow, ['first_timestamp', 'firstTimestamp'], 0) - if (firstTs > 0 && (stats.firstTimestamp === undefined || firstTs < stats.firstTimestamp)) { - stats.firstTimestamp = firstTs - } - const lastTs = this.getRowInt(aggregateRow, ['last_timestamp', 'lastTimestamp'], 0) - if (lastTs > 0 && (stats.lastTimestamp === undefined || lastTs > stats.lastTimestamp)) { - stats.lastTimestamp = lastTs - } - stats.groupMyMessages = (stats.groupMyMessages || 0) + this.getRowInt(aggregateRow, ['group_my_messages', 'groupMyMessages'], 0) - - if (senderCol) { - const normalizedSender = `LOWER(TRIM(CAST(${this.quoteSqlIdentifier(senderCol)} AS TEXT)))` - const distinctSenderSql = `SELECT DISTINCT ${normalizedSender} AS sender_identity FROM ${this.quoteSqlIdentifier(tableName)} WHERE ${normalizedSender} != ''` - const senderResult = await wcdbService.execQuery('message', dbPath, distinctSenderSql) - if (senderResult.success && senderResult.rows) { - for (const row of senderResult.rows as Record[]) { - const senderIdentity = String(this.getRowField(row, ['sender_identity', 'senderIdentity']) || '').trim() - if (!senderIdentity) continue - senderIdentities.add(senderIdentity) - } - } - } - } else { - const aggregateSql = `SELECT ${selectParts.join(', ')} FROM ${this.quoteSqlIdentifier(tableName)}` - const aggregateResult = await wcdbService.execQuery('message', dbPath, aggregateSql) - if (!aggregateResult.success || !aggregateResult.rows || aggregateResult.rows.length === 0) { - continue - } - const aggregateRow = aggregateResult.rows[0] as Record - aggregatedTableCount += 1 - stats.totalMessages += this.getRowInt(aggregateRow, ['total_messages', 'totalMessages'], 0) - stats.voiceMessages += this.getRowInt(aggregateRow, ['voice_messages', 'voiceMessages'], 0) - stats.imageMessages += this.getRowInt(aggregateRow, ['image_messages', 'imageMessages'], 0) - stats.videoMessages += this.getRowInt(aggregateRow, ['video_messages', 'videoMessages'], 0) - stats.emojiMessages += this.getRowInt(aggregateRow, ['emoji_messages', 'emojiMessages'], 0) - stats.callMessages += this.getRowInt(aggregateRow, ['call_messages', 'callMessages'], 0) - stats.transferMessages += this.getRowInt(aggregateRow, ['transfer_messages', 'transferMessages'], 0) - stats.redPacketMessages += this.getRowInt(aggregateRow, ['red_packet_messages', 'redPacketMessages'], 0) - - const firstTs = this.getRowInt(aggregateRow, ['first_timestamp', 'firstTimestamp'], 0) - if (firstTs > 0 && (stats.firstTimestamp === undefined || firstTs < stats.firstTimestamp)) { - stats.firstTimestamp = firstTs - } - const lastTs = this.getRowInt(aggregateRow, ['last_timestamp', 'lastTimestamp'], 0) - if (lastTs > 0 && (stats.lastTimestamp === undefined || lastTs > stats.lastTimestamp)) { - stats.lastTimestamp = lastTs - } - } - } - - if (aggregatedTableCount === 0) { + const nativeResult = await wcdbService.getSessionMessageTypeStats(sessionId, 0, 0) + if (!nativeResult.success || !nativeResult.data) { return this.collectSessionExportStatsByCursorScan(sessionId, selfIdentitySet) } + const data = nativeResult.data as Record + stats.totalMessages = Math.max(0, Math.floor(Number(data.total_messages || 0))) + stats.voiceMessages = Math.max(0, Math.floor(Number(data.voice_messages || 0))) + stats.imageMessages = Math.max(0, Math.floor(Number(data.image_messages || 0))) + stats.videoMessages = Math.max(0, Math.floor(Number(data.video_messages || 0))) + stats.emojiMessages = Math.max(0, Math.floor(Number(data.emoji_messages || 0))) + stats.callMessages = Math.max(0, Math.floor(Number(data.call_messages || 0))) + stats.transferMessages = Math.max(0, Math.floor(Number(data.transfer_messages || 0))) + stats.redPacketMessages = Math.max(0, Math.floor(Number(data.red_packet_messages || 0))) + + const firstTs = Math.max(0, Math.floor(Number(data.first_timestamp || 0))) + const lastTs = Math.max(0, Math.floor(Number(data.last_timestamp || 0))) + if (firstTs > 0) stats.firstTimestamp = firstTs + if (lastTs > 0) stats.lastTimestamp = lastTs + if (preferAccurateSpecialTypes) { try { const preciseCounters = await this.collectSpecialMessageCountsByCursorScan(sessionId) @@ -2839,12 +2690,13 @@ class ChatService { stats.redPacketMessages = preciseCounters.redPacketMessages stats.callMessages = preciseCounters.callMessages } catch { - // 保留聚合统计结果作为兜底 + // 保留 native 聚合结果作为兜底 } } if (isGroup) { - stats.groupActiveSpeakers = senderIdentities.size + stats.groupMyMessages = Math.max(0, Math.floor(Number(data.group_my_messages || 0))) + stats.groupActiveSpeakers = Math.max(0, Math.floor(Number(data.group_sender_count || 0))) if (Number.isFinite(stats.groupMyMessages)) { this.setGroupMyMessageCountHintEntry(sessionId, stats.groupMyMessages as number) } @@ -2852,6 +2704,64 @@ class ChatService { return stats } + private toExportSessionStatsFromNativeTypeRow(sessionId: string, row: Record): ExportSessionStats { + const stats: ExportSessionStats = { + totalMessages: Math.max(0, Math.floor(Number(row?.total_messages || 0))), + voiceMessages: Math.max(0, Math.floor(Number(row?.voice_messages || 0))), + imageMessages: Math.max(0, Math.floor(Number(row?.image_messages || 0))), + videoMessages: Math.max(0, Math.floor(Number(row?.video_messages || 0))), + emojiMessages: Math.max(0, Math.floor(Number(row?.emoji_messages || 0))), + callMessages: Math.max(0, Math.floor(Number(row?.call_messages || 0))), + transferMessages: Math.max(0, Math.floor(Number(row?.transfer_messages || 0))), + redPacketMessages: Math.max(0, Math.floor(Number(row?.red_packet_messages || 0))) + } + + const firstTs = Math.max(0, Math.floor(Number(row?.first_timestamp || 0))) + const lastTs = Math.max(0, Math.floor(Number(row?.last_timestamp || 0))) + if (firstTs > 0) stats.firstTimestamp = firstTs + if (lastTs > 0) stats.lastTimestamp = lastTs + + if (sessionId.endsWith('@chatroom')) { + stats.groupMyMessages = Math.max(0, Math.floor(Number(row?.group_my_messages || 0))) + stats.groupActiveSpeakers = Math.max(0, Math.floor(Number(row?.group_sender_count || 0))) + if (Number.isFinite(stats.groupMyMessages)) { + this.setGroupMyMessageCountHintEntry(sessionId, stats.groupMyMessages as number) + } + } + return stats + } + + private async getMessageDbCountSnapshot(forceRefresh = false): Promise<{ + success: boolean + dbPaths?: string[] + dbSignature?: string + error?: string + }> { + const now = Date.now() + if (!forceRefresh && this.messageDbCountSnapshotCache) { + if (now - this.messageDbCountSnapshotCache.updatedAt <= this.messageDbCountSnapshotCacheTtlMs) { + return { + success: true, + dbPaths: [...this.messageDbCountSnapshotCache.dbPaths], + dbSignature: this.messageDbCountSnapshotCache.dbSignature + } + } + } + + const dbPathsResult = await this.listMessageDbPathsForCount() + if (!dbPathsResult.success || !dbPathsResult.dbPaths) { + return { success: false, error: dbPathsResult.error || '获取消息数据库列表失败' } + } + const dbPaths = dbPathsResult.dbPaths + const dbSignature = this.buildMessageDbSignature(dbPaths) + this.messageDbCountSnapshotCache = { + dbPaths: [...dbPaths], + dbSignature, + updatedAt: now + } + return { success: true, dbPaths, dbSignature } + } + private async buildGroupRelationStats( groupSessionIds: string[], privateSessionIds: string[], @@ -3003,7 +2913,8 @@ class ChatService { const privateSessionIds = normalizedSessionIds.filter(sessionId => !sessionId.endsWith('@chatroom')) let memberCountMap: Record = {} - if (groupSessionIds.length > 0) { + const shouldLoadGroupMemberCount = groupSessionIds.length > 0 && (includeRelations || normalizedSessionIds.length === 1) + if (shouldLoadGroupMemberCount) { try { const memberCountsResult = await wcdbService.getGroupMemberCounts(groupSessionIds) memberCountMap = memberCountsResult.success && memberCountsResult.map ? memberCountsResult.map : {} @@ -3039,13 +2950,43 @@ class ChatService { } } + const nativeBatchStats: Record = {} + let hasNativeBatchStats = false + if (!preferAccurateSpecialTypes) { + try { + const quickMode = !includeRelations && normalizedSessionIds.length > 1 + const nativeBatch = await wcdbService.getSessionMessageTypeStatsBatch(normalizedSessionIds, { + beginTimestamp: 0, + endTimestamp: 0, + quickMode, + includeGroupSenderCount: true + }) + if (nativeBatch.success && nativeBatch.data) { + for (const sessionId of normalizedSessionIds) { + const row = nativeBatch.data?.[sessionId] as Record | undefined + if (!row || typeof row !== 'object') continue + nativeBatchStats[sessionId] = this.toExportSessionStatsFromNativeTypeRow(sessionId, row) + } + hasNativeBatchStats = Object.keys(nativeBatchStats).length > 0 + } else { + console.warn('[fallback-exec] getSessionMessageTypeStatsBatch failed, fallback to per-session stats path') + } + } catch (error) { + console.warn('[fallback-exec] getSessionMessageTypeStatsBatch exception, fallback to per-session stats path:', error) + } + } + await this.forEachWithConcurrency(normalizedSessionIds, 3, async (sessionId) => { try { - const stats = await this.collectSessionExportStats(sessionId, selfIdentitySet, preferAccurateSpecialTypes) + const stats = hasNativeBatchStats && nativeBatchStats[sessionId] + ? { ...nativeBatchStats[sessionId] } + : await this.collectSessionExportStats(sessionId, selfIdentitySet, preferAccurateSpecialTypes) if (sessionId.endsWith('@chatroom')) { - stats.groupMemberCount = typeof memberCountMap[sessionId] === 'number' - ? Math.max(0, Math.floor(memberCountMap[sessionId])) - : 0 + if (shouldLoadGroupMemberCount) { + stats.groupMemberCount = typeof memberCountMap[sessionId] === 'number' + ? Math.max(0, Math.floor(memberCountMap[sessionId])) + : 0 + } if (includeRelations) { stats.groupMutualFriends = typeof groupMutualFriendMap[sessionId] === 'number' ? Math.max(0, Math.floor(groupMutualFriendMap[sessionId])) @@ -3199,8 +3140,28 @@ class ChatService { datatype: number sourcename: string sourcetime: string - datadesc: string + sourceheadurl?: string + datadesc?: string datatitle?: string + fileext?: string + datasize?: number + messageuuid?: string + dataurl?: string + datathumburl?: string + datacdnurl?: string + cdndatakey?: string + cdnthumbkey?: string + aeskey?: string + md5?: string + fullmd5?: string + thumbfullmd5?: string + srcMsgLocalid?: number + imgheight?: number + imgwidth?: number + duration?: number + chatRecordTitle?: string + chatRecordDesc?: string + chatRecordList?: any[] }> | undefined if (localType === 47 && content) { @@ -3301,6 +3262,7 @@ class ChatService { } const localId = this.getRowInt(row, ['local_id', 'localId', 'LocalId', 'msg_local_id', 'msgLocalId', 'MsgLocalId', 'msg_id', 'msgId', 'MsgId', 'id', 'WCDB_CT_local_id'], 0) + const serverIdRaw = this.normalizeUnsignedIntegerToken(this.getRowField(row, ['server_id', 'serverId', 'ServerId', 'msg_server_id', 'msgServerId', 'MsgServerId', 'WCDB_CT_server_id'])) const serverId = this.getRowInt(row, ['server_id', 'serverId', 'ServerId', 'msg_server_id', 'msgServerId', 'MsgServerId', 'WCDB_CT_server_id'], 0) const sortSeq = this.getRowInt(row, ['sort_seq', 'sortSeq', 'seq', 'sequence', 'WCDB_CT_sort_seq'], createTime) @@ -3316,6 +3278,7 @@ class ChatService { }), localId, serverId, + serverIdRaw, localType, createTime, sortSeq, @@ -3950,8 +3913,28 @@ class ChatService { datatype: number sourcename: string sourcetime: string - datadesc: string + sourceheadurl?: string + datadesc?: string datatitle?: string + fileext?: string + datasize?: number + messageuuid?: string + dataurl?: string + datathumburl?: string + datacdnurl?: string + cdndatakey?: string + cdnthumbkey?: string + aeskey?: string + md5?: string + fullmd5?: string + thumbfullmd5?: string + srcMsgLocalid?: number + imgheight?: number + imgwidth?: number + duration?: number + chatRecordTitle?: string + chatRecordDesc?: string + chatRecordList?: any[] }> } { try { @@ -4134,41 +4117,8 @@ class ChatService { case '19': { // 聊天记录 result.chatRecordTitle = title || '聊天记录' - - // 解析聊天记录列表 - const recordList: Array<{ - datatype: number - sourcename: string - sourcetime: string - datadesc: string - datatitle?: string - }> = [] - - // 查找所有 标签 - const recordItemRegex = /([\s\S]*?)<\/recorditem>/gi - let match: RegExpExecArray | null - - while ((match = recordItemRegex.exec(content)) !== null) { - const itemXml = match[1] - - const datatypeStr = this.extractXmlValue(itemXml, 'datatype') - const sourcename = this.extractXmlValue(itemXml, 'sourcename') - const sourcetime = this.extractXmlValue(itemXml, 'sourcetime') - const datadesc = this.extractXmlValue(itemXml, 'datadesc') - const datatitle = this.extractXmlValue(itemXml, 'datatitle') - - if (sourcename && datadesc) { - recordList.push({ - datatype: datatypeStr ? parseInt(datatypeStr, 10) : 0, - sourcename, - sourcetime: sourcetime || '', - datadesc, - datatitle: datatitle || undefined - }) - } - } - - if (recordList.length > 0) { + const recordList = this.parseForwardChatRecordList(content) + if (recordList && recordList.length > 0) { result.chatRecordList = recordList } break @@ -4235,6 +4185,224 @@ class ChatService { } } + private parseForwardChatRecordList(content: string): any[] | undefined { + const normalized = this.decodeHtmlEntities(content || '') + if (!normalized.includes('() + const recordItemRegex = /([\s\S]*?)<\/recorditem>/gi + let recordItemMatch: RegExpExecArray | null + while ((recordItemMatch = recordItemRegex.exec(normalized)) !== null) { + const parsed = this.parseForwardChatRecordContainer(recordItemMatch[1] || '') + for (const item of parsed) { + const key = `${item.datatype}|${item.sourcename}|${item.sourcetime}|${item.datadesc || ''}|${item.datatitle || ''}|${item.messageuuid || ''}` + if (!dedupe.has(key)) { + dedupe.add(key) + items.push(item) + } + } + } + + if (items.length === 0 && normalized.includes(' 0 ? items : undefined + } + + private extractTopLevelXmlElements(source: string, tagName: string): Array<{ attrs: string; inner: string }> { + const xml = source || '' + if (!xml) return [] + + const pattern = new RegExp(`<(/?)${tagName}\\b([^>]*)>`, 'gi') + const result: Array<{ attrs: string; inner: string }> = [] + let match: RegExpExecArray | null + let depth = 0 + let openEnd = -1 + let openStart = -1 + let openAttrs = '' + + while ((match = pattern.exec(xml)) !== null) { + const isClosing = match[1] === '/' + const attrs = match[2] || '' + const rawTag = match[0] || '' + const selfClosing = !isClosing && /\/\s*>$/.test(rawTag) + + if (!isClosing) { + if (depth === 0) { + openStart = match.index + openEnd = pattern.lastIndex + openAttrs = attrs + } + if (!selfClosing) { + depth += 1 + } else if (depth === 0 && openEnd >= 0) { + result.push({ attrs: openAttrs, inner: '' }) + openStart = -1 + openEnd = -1 + openAttrs = '' + } + continue + } + + if (depth <= 0) continue + depth -= 1 + if (depth === 0 && openEnd >= 0 && openStart >= 0) { + result.push({ + attrs: openAttrs, + inner: xml.slice(openEnd, match.index) + }) + openStart = -1 + openEnd = -1 + openAttrs = '' + } + } + + return result + } + + private parseForwardChatRecordContainer(containerXml: string): any[] { + const source = containerXml || '' + if (!source) return [] + + const segments: string[] = [source] + const decodedContainer = this.decodeHtmlEntities(source) + if (decodedContainer !== source) { + segments.push(decodedContainer) + } + + const cdataRegex = //g + let cdataMatch: RegExpExecArray | null + while ((cdataMatch = cdataRegex.exec(source)) !== null) { + const cdataInner = cdataMatch[1] || '' + if (!cdataInner) continue + segments.push(cdataInner) + const decodedInner = this.decodeHtmlEntities(cdataInner) + if (decodedInner !== cdataInner) { + segments.push(decodedInner) + } + } + + const items: any[] = [] + const seen = new Set() + for (const segment of segments) { + if (!segment) continue + const dataItems = this.extractTopLevelXmlElements(segment, 'dataitem') + for (const dataItem of dataItems) { + const parsed = this.parseForwardChatRecordDataItem(dataItem.inner || '', dataItem.attrs || '') + if (!parsed) continue + const key = `${parsed.datatype}|${parsed.sourcename}|${parsed.sourcetime}|${parsed.datadesc || ''}|${parsed.datatitle || ''}|${parsed.messageuuid || ''}` + if (!seen.has(key)) { + seen.add(key) + items.push(parsed) + } + } + } + + if (items.length > 0) return items + const fallback = this.parseForwardChatRecordDataItem(source, '') + return fallback ? [fallback] : [] + } + + private parseForwardChatRecordDataItem(itemXml: string, attrs: string): any | null { + const datatypeMatch = /datatype\s*=\s*["']?(\d+)["']?/i.exec(attrs || '') + const datatype = datatypeMatch ? parseInt(datatypeMatch[1], 10) : parseInt(this.extractXmlValue(itemXml, 'datatype') || '0', 10) + const sourcename = this.decodeHtmlEntities(this.extractXmlValue(itemXml, 'sourcename') || '') + const sourcetime = this.extractXmlValue(itemXml, 'sourcetime') || '' + const sourceheadurl = this.extractXmlValue(itemXml, 'sourceheadurl') || undefined + const datadesc = this.decodeHtmlEntities( + this.extractXmlValue(itemXml, 'datadesc') || + this.extractXmlValue(itemXml, 'content') || + '' + ) || undefined + const datatitle = this.decodeHtmlEntities(this.extractXmlValue(itemXml, 'datatitle') || '') || undefined + const fileext = this.extractXmlValue(itemXml, 'fileext') || undefined + const datasize = parseInt(this.extractXmlValue(itemXml, 'datasize') || '0', 10) || undefined + const messageuuid = this.extractXmlValue(itemXml, 'messageuuid') || undefined + const dataurl = this.decodeHtmlEntities(this.extractXmlValue(itemXml, 'dataurl') || '') || undefined + const datathumburl = this.decodeHtmlEntities( + this.extractXmlValue(itemXml, 'datathumburl') || + this.extractXmlValue(itemXml, 'thumburl') || + this.extractXmlValue(itemXml, 'cdnthumburl') || + '' + ) || undefined + const datacdnurl = this.decodeHtmlEntities( + this.extractXmlValue(itemXml, 'datacdnurl') || + this.extractXmlValue(itemXml, 'cdnurl') || + this.extractXmlValue(itemXml, 'cdndataurl') || + '' + ) || undefined + const cdndatakey = this.extractXmlValue(itemXml, 'cdndatakey') || undefined + const cdnthumbkey = this.extractXmlValue(itemXml, 'cdnthumbkey') || undefined + const aeskey = this.decodeHtmlEntities( + this.extractXmlValue(itemXml, 'aeskey') || + this.extractXmlValue(itemXml, 'qaeskey') || + '' + ) || undefined + const md5 = this.extractXmlValue(itemXml, 'md5') || this.extractXmlValue(itemXml, 'datamd5') || undefined + const fullmd5 = this.extractXmlValue(itemXml, 'fullmd5') || undefined + const thumbfullmd5 = this.extractXmlValue(itemXml, 'thumbfullmd5') || undefined + const srcMsgLocalid = parseInt(this.extractXmlValue(itemXml, 'srcMsgLocalid') || '0', 10) || undefined + const imgheight = parseInt(this.extractXmlValue(itemXml, 'imgheight') || '0', 10) || undefined + const imgwidth = parseInt(this.extractXmlValue(itemXml, 'imgwidth') || '0', 10) || undefined + const duration = parseInt(this.extractXmlValue(itemXml, 'duration') || '0', 10) || undefined + const nestedRecordXml = this.extractXmlValue(itemXml, 'recordxml') || undefined + const chatRecordTitle = this.decodeHtmlEntities( + (nestedRecordXml && this.extractXmlValue(nestedRecordXml, 'title')) || + datatitle || + '' + ) || undefined + const chatRecordDesc = this.decodeHtmlEntities( + (nestedRecordXml && this.extractXmlValue(nestedRecordXml, 'desc')) || + datadesc || + '' + ) || undefined + const chatRecordList = + datatype === 17 && nestedRecordXml + ? this.parseForwardChatRecordContainer(nestedRecordXml) + : undefined + + if (!(datatype || sourcename || datadesc || datatitle || messageuuid || srcMsgLocalid)) return null + + return { + datatype: Number.isFinite(datatype) ? datatype : 0, + sourcename, + sourcetime, + sourceheadurl, + datadesc, + datatitle, + fileext, + datasize, + messageuuid, + dataurl, + datathumburl, + datacdnurl, + cdndatakey, + cdnthumbkey, + aeskey, + md5, + fullmd5, + thumbfullmd5, + srcMsgLocalid, + imgheight, + imgwidth, + duration, + chatRecordTitle, + chatRecordDesc, + chatRecordList + } + } + //手动查找 media_*.db 文件(当 WCDB DLL 不支持 listMediaDbs 时的 fallback) private async findMediaDbsManually(): Promise { try { @@ -4360,24 +4528,6 @@ class ChatService { return candidates } - private async resolveChatNameId(dbPath: string, senderWxid: string): Promise { - const escaped = this.escapeSqlString(senderWxid) - const name2IdTable = await this.resolveName2IdTableName(dbPath) - if (!name2IdTable) return null - const info = await wcdbService.execQuery('media', dbPath, `PRAGMA table_info('${name2IdTable}')`) - if (!info.success || !info.rows) return null - const columns = info.rows.map((row) => String(row.name || row.Name || row.column || '')).filter(Boolean) - const lower = new Map(columns.map((col) => [col.toLowerCase(), col])) - const column = lower.get('name_id') || lower.get('id') || 'rowid' - const sql = `SELECT ${column} AS id FROM ${name2IdTable} WHERE user_name = '${escaped}' LIMIT 1` - const result = await wcdbService.execQuery('media', dbPath, sql) - if (!result.success || !result.rows || result.rows.length === 0) return null - const value = result.rows[0]?.id - if (value === null || value === undefined) return null - const parsed = typeof value === 'number' ? value : parseInt(String(value), 10) - return Number.isFinite(parsed) ? parsed : null - } - private decodeVoiceBlob(raw: any): Buffer | null { if (!raw) return null if (Buffer.isBuffer(raw)) return raw @@ -4400,66 +4550,10 @@ class ChatService { return null } - private async resolveVoiceInfoColumns(dbPath: string, tableName: string): Promise<{ - dataColumn: string; - chatNameIdColumn?: string; - createTimeColumn?: string; - msgLocalIdColumn?: string; - } | null> { - const info = await wcdbService.execQuery('media', dbPath, `PRAGMA table_info('${tableName}')`) - if (!info.success || !info.rows) return null - const columns = info.rows.map((row) => String(row.name || row.Name || row.column || '')).filter(Boolean) - if (columns.length === 0) return null - const lower = new Map(columns.map((col) => [col.toLowerCase(), col])) - const dataColumn = - lower.get('voice_data') || - lower.get('buf') || - lower.get('voicebuf') || - lower.get('data') - if (!dataColumn) return null - return { - dataColumn, - chatNameIdColumn: lower.get('chat_name_id') || lower.get('chatnameid') || lower.get('chat_nameid'), - createTimeColumn: lower.get('create_time') || lower.get('createtime') || lower.get('time'), - msgLocalIdColumn: lower.get('msg_local_id') || lower.get('msglocalid') || lower.get('localid') - } - } - private escapeSqlString(value: string): string { return value.replace(/'/g, "''") } - private async resolveVoiceInfoTableName(dbPath: string): Promise { - // 1. 优先尝试标准表名 'VoiceInfo' - const checkStandard = await wcdbService.execQuery( - 'media', - dbPath, - "SELECT name FROM sqlite_master WHERE type='table' AND name='VoiceInfo'" - ) - if (checkStandard.success && checkStandard.rows && checkStandard.rows.length > 0) { - return 'VoiceInfo' - } - - // 2. 只有在找不到标准表时,才尝试模糊匹配 (兼容性) - const result = await wcdbService.execQuery( - 'media', - dbPath, - "SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'VoiceInfo%' ORDER BY name DESC LIMIT 1" - ) - if (!result.success || !result.rows || result.rows.length === 0) return null - return result.rows[0]?.name || null - } - - private async resolveName2IdTableName(dbPath: string): Promise { - const result = await wcdbService.execQuery( - 'media', - dbPath, - "SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'Name2Id%' ORDER BY name DESC LIMIT 1" - ) - if (!result.success || !result.rows || result.rows.length === 0) return null - return result.rows[0]?.name || null - } - private async resolveMessageName2IdTableName(dbPath: string): Promise { const normalizedDbPath = String(dbPath || '').trim() if (!normalizedDbPath) return null @@ -4467,6 +4561,7 @@ class ChatService { return this.messageName2IdTableCache.get(normalizedDbPath) || null } + // fallback-exec: 当前缺少按 message.db 反查 Name2Id 表名的专属接口 const result = await wcdbService.execQuery( 'message', normalizedDbPath, @@ -4498,6 +4593,7 @@ class ChatService { } const escapedTableName = String(name2IdTable).replace(/"/g, '""') + // fallback-exec: 当前缺少按 rowid -> user_name 的 message.db 专属接口 const result = await wcdbService.execQuery( 'message', normalizedDbPath, @@ -4675,8 +4771,8 @@ class ChatService { /** * 清理拍一拍消息 * 格式示例: - * 纯文本: 我拍了拍 "梨绒" ງ໐໐໓ ຖiງht620000wxid_... - * XML: "有幸"拍了拍"浩天空"相信未来!... + * 纯文本: 我拍了拍 "XX" + * XML: "XX"拍了拍"XX"相信未来!... */ private cleanPatMessage(content: string): string { if (!content) return '[拍一拍]' @@ -4869,11 +4965,9 @@ class ChatService { // DLL 有时不返回 alias 字段,补一条直接 SQL 查询兜底 if (!alias) { try { - const safe = username.replace(/'/g, "''") - const sqlResult = await wcdbService.execQuery('contact', null, - `SELECT alias FROM contact WHERE username = '${safe}' LIMIT 1`) - if (sqlResult.success && Array.isArray(sqlResult.rows) && sqlResult.rows.length > 0) { - alias = String(sqlResult.rows[0]?.alias || sqlResult.rows[0]?.Alias || '') + const aliasResult = await wcdbService.getContactAliasMap([username]) + if (aliasResult.success && aliasResult.map && aliasResult.map[username]) { + alias = String(aliasResult.map[username] || '') } } catch { // 兜底失败不影响主流程 @@ -5767,44 +5861,128 @@ class ChatService { } /** - * getVoiceData (绕过WCDB的buggy getVoiceData,直接用execQuery读取) + * getVoiceData(主用批量专属接口读取语音数据) */ async getVoiceData(sessionId: string, msgId: string, createTime?: number, serverId?: string | number, senderWxidOpt?: string): Promise<{ success: boolean; data?: string; error?: string }> { const startTime = Date.now() + const verboseVoiceTrace = process.env.WEFLOW_VOICE_TRACE === '1' + const msgCreateTimeLabel = (value?: number): string => { + return Number.isFinite(Number(value)) ? String(Math.floor(Number(value))) : '无' + } + const lookupPath: string[] = [] + const logLookupPath = (status: 'success' | 'fail', error?: string): void => { + const timeline = lookupPath.map((step, idx) => `${idx + 1}.${step}`).join(' -> ') + if (status === 'success') { + if (verboseVoiceTrace) { + console.info(`[Voice] 定位流程成功: ${timeline}`) + } + } else { + console.warn(`[Voice] 定位流程失败${error ? `(${error})` : ''}: ${timeline}`) + } + } + try { + lookupPath.push(`会话=${sessionId}, 消息=${msgId}, 传入createTime=${msgCreateTimeLabel(createTime)}, serverId=${String(serverId || 0)}`) + lookupPath.push(`消息来源提示=${senderWxidOpt || '无'}`) + const localId = parseInt(msgId, 10) if (isNaN(localId)) { + logLookupPath('fail', '无效的消息ID') return { success: false, error: '无效的消息ID' } } let msgCreateTime = createTime let senderWxid: string | null = senderWxidOpt || null + let resolvedServerId: string | number = this.normalizeUnsignedIntegerToken(serverId) || 0 + let locatedMsg: Message | null = null + let rejectedNonVoiceLookup = false - // 如果前端没传 createTime,才需要查询消息(这个很慢) - if (!msgCreateTime) { + lookupPath.push(`初始解析localId=${localId}成功`) + + // 已提供强键(createTime + serverId)时,直接走语音定位,避免 localId 反查噪音与误导 + const hasStrongInput = Number.isFinite(Number(msgCreateTime)) && Number(msgCreateTime) > 0 + && Boolean(this.normalizeUnsignedIntegerToken(serverId)) + + if (hasStrongInput) { + lookupPath.push('调用入参已具备强键(createTime+serverId),跳过localId反查') + } else { const t1 = Date.now() const msgResult = await this.getMessageByLocalId(sessionId, localId) const t2 = Date.now() + lookupPath.push(`消息反查耗时=${t2 - t1}ms`) + if (!msgResult.success || !msgResult.message) { + lookupPath.push('未命中: getMessageByLocalId') + } else { + const dbMsg = msgResult.message as Message + const locatedServerId = this.normalizeUnsignedIntegerToken(dbMsg.serverIdRaw ?? dbMsg.serverId) + const incomingServerId = this.normalizeUnsignedIntegerToken(serverId) + lookupPath.push(`命中消息定位: localId=${dbMsg.localId}, createTime=${dbMsg.createTime}, sender=${dbMsg.senderUsername || ''}, serverId=${locatedServerId || '0'}, localType=${dbMsg.localType}, voice时长=${dbMsg.voiceDurationSeconds ?? 0}`) + if (incomingServerId && locatedServerId && incomingServerId !== locatedServerId) { + lookupPath.push(`serverId纠正: input=${incomingServerId}, db=${locatedServerId}`) + } - if (msgResult.success && msgResult.message) { - const msg = msgResult.message as any - msgCreateTime = msg.createTime - senderWxid = msg.senderUsername || null + // localId 在不同表可能重复,反查命中非语音时不覆盖调用侧入参 + if (Number(dbMsg.localType) === 34) { + locatedMsg = dbMsg + msgCreateTime = dbMsg.createTime || msgCreateTime + senderWxid = dbMsg.senderUsername || senderWxid || null + if (locatedServerId) { + resolvedServerId = locatedServerId + } + } else { + rejectedNonVoiceLookup = true + lookupPath.push('消息反查命中但localType!=34,忽略反查覆盖,继续使用调用入参定位') + } } } if (!msgCreateTime) { + lookupPath.push('定位失败: 未找到消息时间戳') + logLookupPath('fail', '未找到消息时间戳') return { success: false, error: '未找到消息时间戳' } } + if (!locatedMsg) { + lookupPath.push(rejectedNonVoiceLookup + ? `定位结果: 反查命中非语音并已忽略, createTime=${msgCreateTime}, sender=${senderWxid || '无'}` + : `定位结果: 未走消息反查流程, createTime=${msgCreateTime}, sender=${senderWxid || '无'}`) + } else { + lookupPath.push(`定位结果: 语音消息被确认 localId=${localId}, createTime=${msgCreateTime}, sender=${senderWxid || '无'}`) + } + lookupPath.push(`最终serverId=${String(resolvedServerId || 0)}`) - // 使用 sessionId + createTime 作为缓存key - const cacheKey = `${sessionId}_${msgCreateTime}` + if (verboseVoiceTrace) { + if (locatedMsg) { + console.log('[Voice] 定位到的具体语音消息:', { + sessionId, + msgId, + localId: locatedMsg.localId, + createTime: locatedMsg.createTime, + senderUsername: locatedMsg.senderUsername, + serverId: locatedMsg.serverIdRaw || locatedMsg.serverId, + localType: locatedMsg.localType, + voiceDurationSeconds: locatedMsg.voiceDurationSeconds + }) + } else { + console.log('[Voice] 定位到的语音消息:', { + sessionId, + msgId, + localId, + createTime: msgCreateTime, + senderUsername: senderWxid, + serverId: resolvedServerId + }) + } + } + + // 使用 sessionId + createTime + msgId 作为缓存 key,避免同秒语音串音 + const cacheKey = this.getVoiceCacheKey(sessionId, String(localId), msgCreateTime) // 检查 WAV 内存缓存 const wavCache = this.voiceWavCache.get(cacheKey) if (wavCache) { - + lookupPath.push('命中内存WAV缓存') + logLookupPath('success', '内存缓存') return { success: true, data: wavCache.toString('base64') } } @@ -5814,14 +5992,16 @@ class ChatService { if (existsSync(wavFilePath)) { try { const wavData = readFileSync(wavFilePath) - // 同时缓存到内存 this.cacheVoiceWav(cacheKey, wavData) - + lookupPath.push('命中磁盘WAV缓存') + logLookupPath('success', '磁盘缓存') return { success: true, data: wavData.toString('base64') } } catch (e) { + lookupPath.push('命中磁盘WAV缓存但读取失败') console.error('[Voice] 读取缓存文件失败:', e) } } + lookupPath.push('缓存未命中,进入DB定位') // 构建查找候选 const candidates: string[] = [] @@ -5841,31 +6021,39 @@ class ChatService { if (myWxid && !candidates.includes(myWxid)) { candidates.push(myWxid) } + lookupPath.push(`定位候选链=${JSON.stringify(candidates)}`) const t3 = Date.now() // 从数据库读取 silk 数据 - const silkData = await this.getVoiceDataFromMediaDb(msgCreateTime, candidates) + const silkData = await this.getVoiceDataFromMediaDb(sessionId, msgCreateTime, localId, resolvedServerId || 0, candidates, lookupPath, myWxid) const t4 = Date.now() + lookupPath.push(`DB定位耗时=${t4 - t3}ms`) if (!silkData) { + logLookupPath('fail', '未找到语音数据') return { success: false, error: '未找到语音数据 (请确保已在微信中播放过该语音)' } } + lookupPath.push('语音二进制定位完成') const t5 = Date.now() // 使用 silk-wasm 解码 const pcmData = await this.decodeSilkToPcm(silkData, 24000) const t6 = Date.now() + lookupPath.push(`silk解码耗时=${t6 - t5}ms`) if (!pcmData) { + logLookupPath('fail', 'Silk解码失败') return { success: false, error: 'Silk 解码失败' } } + lookupPath.push('silk解码成功') const t7 = Date.now() // PCM -> WAV const wavData = this.createWavBuffer(pcmData, 24000) const t8 = Date.now() + lookupPath.push(`WAV转码耗时=${t8 - t7}ms`) // 缓存 WAV 数据到内存 @@ -5874,9 +6062,13 @@ class ChatService { // 缓存 WAV 数据到文件(异步,不阻塞返回) this.cacheVoiceWavToFile(cacheKey, wavData) + lookupPath.push(`总耗时=${t8 - startTime}ms`) + logLookupPath('success') return { success: true, data: wavData.toString('base64') } } catch (e) { + lookupPath.push(`异常: ${String(e)}`) + logLookupPath('fail', String(e)) console.error('ChatService: getVoiceData 失败:', e) return { success: false, error: String(e) } } @@ -5888,216 +6080,230 @@ class ChatService { private async cacheVoiceWavToFile(cacheKey: string, wavData: Buffer): Promise { try { const voiceCacheDir = this.getVoiceCacheDir() - if (!existsSync(voiceCacheDir)) { - mkdirSync(voiceCacheDir, { recursive: true }) - } - + await fsPromises.mkdir(voiceCacheDir, { recursive: true }) const wavFilePath = join(voiceCacheDir, `${cacheKey}.wav`) - writeFileSync(wavFilePath, wavData) + await fsPromises.writeFile(wavFilePath, wavData) } catch (e) { console.error('[Voice] 缓存文件失败:', e) } } /** - * 通过 WCDB 的 execQuery 直接查询 media.db(绕过有bug的getVoiceData接口) - * 策略:批量查询 + 多种兜底方案 + * 通过 WCDB 专属接口查询语音数据 + * 策略:批量查询 + 单条 native 兜底 */ - private async getVoiceDataFromMediaDb(createTime: number, candidates: string[]): Promise { - const startTime = Date.now() + private async getVoiceDataFromMediaDb( + sessionId: string, + createTime: number, + localId: number, + svrId: string | number, + candidates: string[], + lookupPath?: string[], + myWxid?: string + ): Promise { try { - const t1 = Date.now() - // 获取所有 media 数据库(永久缓存,直到应用重启) - let mediaDbFiles: string[] - if (this.mediaDbsCache) { - mediaDbFiles = this.mediaDbsCache + const candidatesList = Array.isArray(candidates) + ? candidates.filter((value, index, arr) => { + const key = String(value || '').trim() + return Boolean(key) && arr.findIndex(v => String(v || '').trim() === key) === index + }) + : [] + const createTimeInt = Math.max(0, Math.floor(Number(createTime || 0))) + const localIdInt = Math.max(0, Math.floor(Number(localId || 0))) + const svrIdToken = svrId || 0 + const plans: Array<{ label: string; list: string[] }> = [] + if (candidatesList.length > 0) { + const strict = String(myWxid || '').trim() + ? candidatesList.filter(item => item !== String(myWxid || '').trim()) + : candidatesList.slice() + if (strict.length > 0 && strict.length !== candidatesList.length) { + plans.push({ label: 'strict(no-self)', list: strict }) + } + plans.push({ label: 'full', list: candidatesList }) } else { - const mediaDbsResult = await wcdbService.listMediaDbs() - const t2 = Date.now() - - - let files = mediaDbsResult.success && mediaDbsResult.data ? (mediaDbsResult.data as string[]) : [] - - // Fallback: 如果 WCDB DLL 没找到,手动查找 - if (files.length === 0) { - console.warn('[Voice] listMediaDbs returned empty, trying manual search') - files = await this.findMediaDbsManually() - } - - if (files.length === 0) { - console.error('[Voice] No media DBs found') - return null - } - - mediaDbFiles = files - this.mediaDbsCache = mediaDbFiles // 永久缓存 + plans.push({ label: 'empty', list: [] }) } - // 在所有 media 数据库中查找 - for (const dbPath of mediaDbFiles) { - try { - // 检查缓存 - let schema = this.mediaDbSchemaCache.get(dbPath) + lookupPath?.push(`构建音频查询参数 createTime=${createTimeInt}, localId=${localIdInt}, svrId=${svrIdToken}, plans=${plans.map(p => `${p.label}:${p.list.length}`).join('|')}`) - if (!schema) { - const t3 = Date.now() - // 第一次查询,获取表结构并缓存 - const tablesResult = await wcdbService.execQuery('media', dbPath, - "SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'VoiceInfo%'" - ) - const t4 = Date.now() - - - if (!tablesResult.success || !tablesResult.rows || tablesResult.rows.length === 0) { - continue - } - - const voiceTable = tablesResult.rows[0].name - - const t5 = Date.now() - const columnsResult = await wcdbService.execQuery('media', dbPath, - `PRAGMA table_info('${voiceTable}')` - ) - const t6 = Date.now() - - - if (!columnsResult.success || !columnsResult.rows) { - continue - } - - // 创建列名映射(原始名称 -> 小写名称) - const columnMap = new Map() - for (const c of columnsResult.rows) { - const name = String(c.name || '') - if (name) { - columnMap.set(name.toLowerCase(), name) - } - } - - // 查找数据列(使用原始列名) - const dataColumnLower = ['voice_data', 'buf', 'voicebuf', 'data'].find(n => columnMap.has(n)) - const dataColumn = dataColumnLower ? columnMap.get(dataColumnLower) : undefined - - if (!dataColumn) { - continue - } - - // 查找 chat_name_id 列 - const chatNameIdColumnLower = ['chat_name_id', 'chatnameid', 'chat_nameid'].find(n => columnMap.has(n)) - const chatNameIdColumn = chatNameIdColumnLower ? columnMap.get(chatNameIdColumnLower) : undefined - - // 查找时间列 - const timeColumnLower = ['create_time', 'createtime', 'time'].find(n => columnMap.has(n)) - const timeColumn = timeColumnLower ? columnMap.get(timeColumnLower) : undefined - - const t7 = Date.now() - // 查找 Name2Id 表 - const name2IdTablesResult = await wcdbService.execQuery('media', dbPath, - "SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'Name2Id%'" - ) - const t8 = Date.now() - - - const name2IdTable = (name2IdTablesResult.success && name2IdTablesResult.rows && name2IdTablesResult.rows.length > 0) - ? name2IdTablesResult.rows[0].name - : undefined - - schema = { - voiceTable, - dataColumn, - chatNameIdColumn, - timeColumn, - name2IdTable - } - - // 缓存表结构 - this.mediaDbSchemaCache.set(dbPath, schema) + for (const plan of plans) { + lookupPath?.push(`尝试候选集[${plan.label}]=${JSON.stringify(plan.list)}`) + // 先走单条 native:svr_id 通过 int64 直传,避免 batch JSON 的大整数精度/解析差异 + lookupPath?.push(`先尝试单条查询(${plan.label})`) + const single = await wcdbService.getVoiceData( + sessionId, + createTimeInt, + plan.list, + localIdInt, + svrIdToken + ) + lookupPath?.push(`单条查询(${plan.label})结果: success=${single.success}, hasHex=${Boolean(single.hex)}`) + if (single.success && single.hex) { + const decoded = this.decodeVoiceBlob(single.hex) + if (decoded && decoded.length > 0) { + lookupPath?.push(`单条查询(${plan.label})解码成功`) + return decoded } + lookupPath?.push(`单条查询(${plan.label})解码为空`) + } - // 策略1: 通过 chat_name_id + create_time 查找(最准确) - if (schema.chatNameIdColumn && schema.timeColumn && schema.name2IdTable) { - const t9 = Date.now() - // 批量获取所有 candidates 的 chat_name_id(减少查询次数) - const candidatesStr = candidates.map(c => `'${c.replace(/'/g, "''")}'`).join(',') - const name2IdResult = await wcdbService.execQuery('media', dbPath, - `SELECT user_name, rowid FROM ${schema.name2IdTable} WHERE user_name IN (${candidatesStr})` - ) - const t10 = Date.now() + const batchResult = await wcdbService.getVoiceDataBatch([{ + session_id: sessionId, + create_time: createTimeInt, + local_id: localIdInt, + svr_id: svrIdToken, + candidates: plan.list + }]) + lookupPath?.push(`批量查询(${plan.label})结果: success=${batchResult.success}, rows=${Array.isArray(batchResult.rows) ? batchResult.rows.length : 0}`) + if (!batchResult.success) { + lookupPath?.push(`批量查询(${plan.label})失败: ${batchResult.error || '无错误信息'}`) + } - - if (name2IdResult.success && name2IdResult.rows && name2IdResult.rows.length > 0) { - // 构建 chat_name_id 列表 - const chatNameIds = name2IdResult.rows.map((r: any) => r.rowid) - const chatNameIdsStr = chatNameIds.join(',') - - const t11 = Date.now() - // 一次查询所有可能的语音 - const voiceResult = await wcdbService.execQuery('media', dbPath, - `SELECT ${schema.dataColumn} AS data FROM ${schema.voiceTable} WHERE ${schema.chatNameIdColumn} IN (${chatNameIdsStr}) AND ${schema.timeColumn} = ${createTime} LIMIT 1` - ) - const t12 = Date.now() - - - if (voiceResult.success && voiceResult.rows && voiceResult.rows.length > 0) { - const row = voiceResult.rows[0] - const silkData = this.decodeVoiceBlob(row.data) - if (silkData) { - - return silkData - } - } + if (batchResult.success && Array.isArray(batchResult.rows) && batchResult.rows.length > 0) { + const hex = String(batchResult.rows[0]?.hex || '').trim() + lookupPath?.push(`命中批量结果(${plan.label})[0], hexLen=${hex.length}`) + if (hex) { + const decoded = this.decodeVoiceBlob(hex) + if (decoded && decoded.length > 0) { + lookupPath?.push(`批量结果(${plan.label})解码成功`) + return decoded } + lookupPath?.push(`批量结果(${plan.label})解码为空`) } - - // 策略2: 只通过 create_time 查找(兜底) - if (schema.timeColumn) { - const t13 = Date.now() - const voiceResult = await wcdbService.execQuery('media', dbPath, - `SELECT ${schema.dataColumn} AS data FROM ${schema.voiceTable} WHERE ${schema.timeColumn} = ${createTime} LIMIT 1` - ) - const t14 = Date.now() - - - if (voiceResult.success && voiceResult.rows && voiceResult.rows.length > 0) { - const row = voiceResult.rows[0] - const silkData = this.decodeVoiceBlob(row.data) - if (silkData) { - - return silkData - } - } - } - - // 策略3: 时间范围查找(±5秒,处理时间戳不精确的情况) - if (schema.timeColumn) { - const t15 = Date.now() - const voiceResult = await wcdbService.execQuery('media', dbPath, - `SELECT ${schema.dataColumn} AS data FROM ${schema.voiceTable} WHERE ${schema.timeColumn} BETWEEN ${createTime - 5} AND ${createTime + 5} ORDER BY ABS(${schema.timeColumn} - ${createTime}) LIMIT 1` - ) - const t16 = Date.now() - - - if (voiceResult.success && voiceResult.rows && voiceResult.rows.length > 0) { - const row = voiceResult.rows[0] - const silkData = this.decodeVoiceBlob(row.data) - if (silkData) { - - return silkData - } - } - } - } catch (e) { - // 静默失败,继续尝试下一个数据库 + } else { + lookupPath?.push(`批量结果(${plan.label})未命中`) } } + lookupPath?.push('音频定位失败:未命中任何结果') return null } catch (e) { + lookupPath?.push(`音频定位异常: ${String(e)}`) return null } } + async preloadVoiceDataBatch( + sessionId: string, + messages: Array<{ + localId?: number | string + createTime?: number | string + serverId?: number | string + senderWxid?: string | null + }>, + options?: { chunkSize?: number; decodeConcurrency?: number } + ): Promise<{ success: boolean; prepared?: number; error?: string }> { + try { + const connectResult = await this.ensureConnected() + if (!connectResult.success) { + return { success: false, error: connectResult.error || '数据库未连接' } + } + + const normalizedSessionId = String(sessionId || '').trim() + if (!normalizedSessionId) return { success: true, prepared: 0 } + if (!Array.isArray(messages) || messages.length === 0) return { success: true, prepared: 0 } + + const myWxid = String(this.configService.get('myWxid') || '').trim() + const nowPrepared = new Set() + const pending: Array<{ + cacheKey: string + request: { session_id: string; create_time: number; local_id: number; svr_id: string | number; candidates: string[] } + }> = [] + + for (const item of messages) { + const localId = Math.max(0, Math.floor(Number(item?.localId || 0))) + const createTime = Math.max(0, Math.floor(Number(item?.createTime || 0))) + if (!localId || !createTime) continue + + const cacheKey = this.getVoiceCacheKey(normalizedSessionId, String(localId), createTime) + if (nowPrepared.has(cacheKey)) continue + nowPrepared.add(cacheKey) + + const inMemory = this.voiceWavCache.get(cacheKey) + if (inMemory && inMemory.length > 0) continue + + const wavFilePath = join(this.getVoiceCacheDir(), `${cacheKey}.wav`) + if (existsSync(wavFilePath)) { + try { + const wavData = readFileSync(wavFilePath) + if (wavData.length > 0) { + this.cacheVoiceWav(cacheKey, wavData) + continue + } + } catch { + // ignore corrupted cache file + } + } + + const senderWxid = String(item?.senderWxid || '').trim() + const candidates: string[] = [] + if (senderWxid) candidates.push(senderWxid) + if (!candidates.includes(normalizedSessionId)) candidates.push(normalizedSessionId) + if (myWxid && !candidates.includes(myWxid)) candidates.push(myWxid) + + pending.push({ + cacheKey, + request: { + session_id: normalizedSessionId, + create_time: createTime, + local_id: localId, + svr_id: item?.serverId || 0, + candidates + } + }) + } + + if (pending.length === 0) { + return { success: true, prepared: nowPrepared.size } + } + + const chunkSize = Math.max(8, Math.min(128, Math.floor(Number(options?.chunkSize || 48)))) + const decodeConcurrency = Math.max(1, Math.min(6, Math.floor(Number(options?.decodeConcurrency || 3)))) + let prepared = nowPrepared.size - pending.length + + for (let i = 0; i < pending.length; i += chunkSize) { + const chunk = pending.slice(i, i + chunkSize) + const batchResult = await wcdbService.getVoiceDataBatch(chunk.map(item => item.request)) + if (!batchResult.success || !Array.isArray(batchResult.rows)) { + continue + } + + const byIndex = new Map() + for (const row of batchResult.rows as Array>) { + const idx = Number.parseInt(String(row?.index ?? ''), 10) + const hex = String(row?.hex || '').trim() + if (!Number.isFinite(idx) || idx < 0 || !hex) continue + byIndex.set(idx, hex) + } + + const readyItems: Array<{ cacheKey: string; hex: string }> = [] + for (let rowIdx = 0; rowIdx < chunk.length; rowIdx += 1) { + const hex = byIndex.get(rowIdx) + if (!hex) continue + readyItems.push({ cacheKey: chunk[rowIdx].cacheKey, hex }) + } + + await this.forEachWithConcurrency(readyItems, decodeConcurrency, async (item) => { + const silkData = this.decodeVoiceBlob(item.hex) + if (!silkData || silkData.length === 0) return + + const pcmData = await this.decodeSilkToPcm(silkData, 24000) + if (!pcmData || pcmData.length === 0) return + + const wavData = this.createWavBuffer(pcmData, 24000) + this.cacheVoiceWav(item.cacheKey, wavData) + this.cacheVoiceWavToFile(item.cacheKey, wavData) + prepared += 1 + }) + } + + return { success: true, prepared } + } catch (e) { + return { success: false, error: String(e) } + } + } + /** * 检查语音是否已有缓存(只检查内存,不查询数据库) */ @@ -6126,121 +6332,8 @@ class ChatService { const msgResult = await this.getMessageByLocalId(sessionId, localId) if (!msgResult.success || !msgResult.message) return { success: false, error: '未找到该消息' } const msg = msgResult.message - if (msg.isSend === 1) { - console.info('[ChatService][Voice] self-sent voice, continue decrypt flow') - } - - const candidates = this.getVoiceLookupCandidates(sessionId, msg) - if (candidates.length === 0) { - return { success: false, error: '未找到语音关联账号' } - } - console.info('[ChatService][Voice] request', { - sessionId, - localId: msg.localId, - createTime: msg.createTime, - candidates - }) - - // 2. 查找所有的 media_*.db - let mediaDbs = await wcdbService.listMediaDbs() - // Fallback: 如果 WCDB DLL 不支持 listMediaDbs,手动查找 - if (!mediaDbs.success || !mediaDbs.data || mediaDbs.data.length === 0) { - const manualMediaDbs = await this.findMediaDbsManually() - if (manualMediaDbs.length > 0) { - mediaDbs = { success: true, data: manualMediaDbs } - } else { - return { success: false, error: '未找到媒体库文件 (media_*.db)' } - } - } - - // 3. 在所有媒体库中查找该消息的语音数据 - let silkData: Buffer | null = null - for (const dbPath of (mediaDbs.data || [])) { - const voiceTable = await this.resolveVoiceInfoTableName(dbPath) - if (!voiceTable) { - continue - } - const columns = await this.resolveVoiceInfoColumns(dbPath, voiceTable) - if (!columns) { - continue - } - for (const candidate of candidates) { - const chatNameId = await this.resolveChatNameId(dbPath, candidate) - // 策略 1: 使用 ChatNameId + CreateTime (最准确) - if (chatNameId) { - let whereClause = '' - if (columns.chatNameIdColumn && columns.createTimeColumn) { - whereClause = `${columns.chatNameIdColumn} = ${chatNameId} AND ${columns.createTimeColumn} = ${msg.createTime}` - const sql = `SELECT ${columns.dataColumn} AS data FROM ${voiceTable} WHERE ${whereClause} LIMIT 1` - const result = await wcdbService.execQuery('media', dbPath, sql) - if (result.success && result.rows && result.rows.length > 0) { - const raw = result.rows[0]?.data - const decoded = this.decodeVoiceBlob(raw) - if (decoded && decoded.length > 0) { - console.info('[ChatService][Voice] hit by createTime', { dbPath, voiceTable, whereClause, bytes: decoded.length }) - silkData = decoded - break - } - } - } - } - - // 策略 2: 使用 MsgLocalId (兜底,如果表支持) - if (columns.msgLocalIdColumn) { - const whereClause = `${columns.msgLocalIdColumn} = ${msg.localId}` - const sql = `SELECT ${columns.dataColumn} AS data FROM ${voiceTable} WHERE ${whereClause} LIMIT 1` - const result = await wcdbService.execQuery('media', dbPath, sql) - if (result.success && result.rows && result.rows.length > 0) { - const raw = result.rows[0]?.data - const decoded = this.decodeVoiceBlob(raw) - if (decoded && decoded.length > 0) { - console.info('[ChatService][Voice] hit by localId', { dbPath, voiceTable, whereClause, bytes: decoded.length }) - silkData = decoded - break - } - } - } - } - if (silkData) break - - // 策略 3: 只使用 CreateTime (兜底) - if (!silkData && columns.createTimeColumn) { - const whereClause = `${columns.createTimeColumn} = ${msg.createTime}` - const sql = `SELECT ${columns.dataColumn} AS data FROM ${voiceTable} WHERE ${whereClause} LIMIT 1` - const result = await wcdbService.execQuery('media', dbPath, sql) - if (result.success && result.rows && result.rows.length > 0) { - const raw = result.rows[0]?.data - const decoded = this.decodeVoiceBlob(raw) - if (decoded && decoded.length > 0) { - console.info('[ChatService][Voice] hit by createTime only', { dbPath, voiceTable, whereClause, bytes: decoded.length }) - silkData = decoded - } - } - } - if (silkData) break - } - - if (!silkData) return { success: false, error: '未找到语音数据' } - - // 4. 使用 silk-wasm 解码 - try { - const pcmData = await this.decodeSilkToPcm(silkData, 24000) - if (!pcmData) { - return { success: false, error: 'Silk 解码失败' } - } - - // PCM -> WAV - const wavData = this.createWavBuffer(pcmData, 24000) - - // 缓存 WAV 数据 (内存缓存) - const cacheKey = this.getVoiceCacheKey(sessionId, msgId) - this.cacheVoiceWav(cacheKey, wavData) - - return { success: true, data: wavData.toString('base64') } - } catch (e) { - console.error('[ChatService][Voice] decoding error:', e) - return { success: false, error: '语音解码失败: ' + String(e) } - } + const senderWxid = msg.senderUsername || undefined + return this.getVoiceData(sessionId, msgId, msg.createTime, msg.serverIdRaw || msg.serverId, senderWxid) } catch (e) { console.error('ChatService: getVoiceData 失败:', e) return { success: false, error: String(e) } @@ -6330,7 +6423,7 @@ class ChatService { if (msgResult.success && msgResult.message) { msgCreateTime = msgResult.message.createTime - serverId = msgResult.message.serverId + serverId = msgResult.message.serverIdRaw || msgResult.message.serverId } } @@ -6434,9 +6527,9 @@ class ChatService { private getVoiceCacheKey(sessionId: string, msgId: string, createTime?: number): string { - // 优先使用 createTime 作为key,避免不同会话中localId相同导致的混乱 + // createTime + msgId 可避免同会话同秒多条语音互相覆盖 if (createTime) { - return `${sessionId}_${createTime}` + return `${sessionId}_${createTime}_${msgId}` } return `${sessionId}_${msgId}` } @@ -6531,10 +6624,10 @@ class ChatService { for (const key of this.voiceTranscriptCache.keys()) { const rawKey = String(key || '') if (!rawKey) continue - // cacheKey 形如 `${sessionId}_${createTime}`,createTime 为数字;兼容旧 key 时使用贪婪匹配。 - const match = /^(.*)_(\d+)$/.exec(rawKey) - if (!match) continue - const sessionId = String(match[1] || '').trim() + // 新 key: `${sessionId}_${createTime}_${msgId}`;旧 key: `${sessionId}_${createTime}` + const matchNew = /^(.*)_(\d+)_(\d+)$/.exec(rawKey) + const matchOld = matchNew ? null : /^(.*)_(\d+)$/.exec(rawKey) + const sessionId = String((matchNew ? matchNew[1] : (matchOld ? matchOld[1] : '')) || '').trim() if (!sessionId || !targetSet.has(sessionId)) continue countMap[sessionId] = (countMap[sessionId] || 0) + 1 } @@ -6552,36 +6645,12 @@ class ChatService { return { success: false, error: connectResult.error || '数据库未连接' } } - // 获取会话表信息 - let tables = this.sessionTablesCache.get(sessionId) - if (!tables) { - const tableStats = await wcdbService.getMessageTableStats(sessionId) - if (!tableStats.success || !tableStats.tables || tableStats.tables.length === 0) { - return { success: false, error: '未找到会话消息表' } - } - tables = tableStats.tables - .map(t => ({ tableName: t.table_name || t.name, dbPath: t.db_path })) - .filter(t => t.tableName && t.dbPath) as Array<{ tableName: string; dbPath: string }> - if (tables.length > 0) { - this.sessionTablesCache.set(sessionId, tables) - setTimeout(() => { this.sessionTablesCache.delete(sessionId) }, this.sessionTablesCacheTtl) - } + const result = await wcdbService.getMessagesByType(sessionId, 34, false, 0, 0) + if (!result.success || !Array.isArray(result.rows)) { + return { success: false, error: result.error || '查询语音消息失败' } } - let allVoiceMessages: Message[] = [] - - for (const { tableName, dbPath } of tables) { - try { - const sql = `SELECT * FROM ${tableName} WHERE local_type = 34 ORDER BY create_time DESC` - const result = await wcdbService.execQuery('message', dbPath, sql) - if (result.success && result.rows && result.rows.length > 0) { - const mapped = this.mapRowsToMessages(result.rows as Record[]) - allVoiceMessages.push(...mapped) - } - } catch (e) { - console.error(`[ChatService] 查询语音消息失败 (${dbPath}):`, e) - } - } + let allVoiceMessages: Message[] = this.mapRowsToMessages(result.rows as Record[]) // 按 createTime 降序排序 allVoiceMessages.sort((a, b) => b.createTime - a.createTime) @@ -6619,43 +6688,20 @@ class ChatService { return { success: false, error: connectResult.error || '数据库未连接' } } - let tables = this.sessionTablesCache.get(sessionId) - if (!tables) { - const tableStats = await wcdbService.getMessageTableStats(sessionId) - if (!tableStats.success || !tableStats.tables || tableStats.tables.length === 0) { - return { success: false, error: '未找到会话消息表' } - } - tables = tableStats.tables - .map(t => ({ tableName: t.table_name || t.name, dbPath: t.db_path })) - .filter(t => t.tableName && t.dbPath) as Array<{ tableName: string; dbPath: string }> - if (tables.length > 0) { - this.sessionTablesCache.set(sessionId, tables) - setTimeout(() => { this.sessionTablesCache.delete(sessionId) }, this.sessionTablesCacheTtl) - } + const result = await wcdbService.getMessagesByType(sessionId, 3, false, 0, 0) + if (!result.success || !Array.isArray(result.rows)) { + return { success: false, error: result.error || '查询图片消息失败' } } - let allImages: Array<{ imageMd5?: string; imageDatName?: string; createTime?: number }> = [] - - for (const { tableName, dbPath } of tables) { - try { - const sql = `SELECT * FROM ${tableName} WHERE local_type = 3 ORDER BY create_time DESC` - const result = await wcdbService.execQuery('message', dbPath, sql) - if (result.success && result.rows && result.rows.length > 0) { - const mapped = this.mapRowsToMessages(result.rows as Record[]) - const images = mapped - .filter(msg => msg.localType === 3) - .map(msg => ({ - imageMd5: msg.imageMd5 || undefined, - imageDatName: msg.imageDatName || undefined, - createTime: msg.createTime || undefined - })) - .filter(img => Boolean(img.imageMd5 || img.imageDatName)) - allImages.push(...images) - } - } catch (e) { - console.error(`[ChatService] 查询图片消息失败 (${dbPath}):`, e) - } - } + const mapped = this.mapRowsToMessages(result.rows as Record[]) + let allImages: Array<{ imageMd5?: string; imageDatName?: string; createTime?: number }> = mapped + .filter(msg => msg.localType === 3) + .map(msg => ({ + imageMd5: msg.imageMd5 || undefined, + imageDatName: msg.imageDatName || undefined, + createTime: msg.createTime || undefined + })) + .filter(img => Boolean(img.imageMd5 || img.imageDatName)) allImages.sort((a, b) => (b.createTime || 0) - (a.createTime || 0)) @@ -6704,50 +6750,11 @@ class ChatService { return { success: false, error: connectResult.error || '数据库未连接' } } - let tables = this.sessionTablesCache.get(sessionId) - if (!tables) { - const tableStats = await wcdbService.getMessageTableStats(sessionId) - if (!tableStats.success || !tableStats.tables || tableStats.tables.length === 0) { - return { success: false, error: '未找到会话消息表' } - } - tables = tableStats.tables - .map(t => ({ tableName: t.table_name || t.name, dbPath: t.db_path })) - .filter(t => t.tableName && t.dbPath) as Array<{ tableName: string; dbPath: string }> - if (tables.length > 0) { - this.sessionTablesCache.set(sessionId, tables) - setTimeout(() => { - this.sessionTablesCache.delete(sessionId) - }, this.sessionTablesCacheTtl) - } - } - - const counts: Record = {} - let hasAnySuccess = false - - for (const { tableName, dbPath } of tables) { - try { - const escapedTableName = String(tableName).replace(/"/g, '""') - const sql = `SELECT strftime('%Y-%m-%d', CASE WHEN create_time > 10000000000 THEN create_time / 1000 ELSE create_time END, 'unixepoch', 'localtime') AS date_key, COUNT(*) AS message_count FROM "${escapedTableName}" WHERE create_time IS NOT NULL GROUP BY date_key` - const result = await wcdbService.execQuery('message', dbPath, sql) - if (!result.success || !Array.isArray(result.rows)) { - console.warn(`[ChatService] 查询每日消息数失败 (${dbPath}):`, result.error) - continue - } - hasAnySuccess = true - result.rows.forEach((row: Record) => { - const date = String(row.date_key || '').trim() - const count = Number(row.message_count || 0) - if (!date || !Number.isFinite(count) || count <= 0) return - counts[date] = (counts[date] || 0) + count - }) - } catch (error) { - console.warn(`[ChatService] 聚合每日消息数失败 (${dbPath}):`, error) - } - } - - if (!hasAnySuccess) { - return { success: false, error: '查询每日消息数失败' } + const result = await wcdbService.getSessionMessageDateCounts(sessionId) + if (!result.success || !result.counts) { + return { success: false, error: result.error || '查询每日消息数失败' } } + const counts = result.counts console.log(`[ChatService] 会话 ${sessionId} 获取到 ${Object.keys(counts).length} 个日期的消息计数`) return { success: true, counts } @@ -6759,54 +6766,12 @@ class ChatService { async getMessageById(sessionId: string, localId: number): Promise<{ success: boolean; message?: Message; error?: string }> { try { - // 1. 尝试从缓存获取会话表信息 - let tables = this.sessionTablesCache.get(sessionId) - - if (!tables) { - // 缓存未命中,查询数据库 - const tableStats = await wcdbService.getMessageTableStats(sessionId) - if (!tableStats.success || !tableStats.tables || tableStats.tables.length === 0) { - return { success: false, error: '未找到会话消息表' } - } - - // 提取表信息并缓存 - tables = tableStats.tables - .map(t => ({ - tableName: t.table_name || t.name, - dbPath: t.db_path - })) - .filter(t => t.tableName && t.dbPath) as Array<{ tableName: string; dbPath: string }> - - if (tables.length > 0) { - this.sessionTablesCache.set(sessionId, tables) - // 设置过期清理 - setTimeout(() => { - this.sessionTablesCache.delete(sessionId) - }, this.sessionTablesCacheTtl) - } + const nativeResult = await wcdbService.getMessageById(sessionId, localId) + if (nativeResult.success && nativeResult.message) { + const message = await this.parseMessage(nativeResult.message as Record, { source: 'detail', sessionId }) + if (message.localId !== 0) return { success: true, message } } - - // 2. 遍历表查找消息 (通常只有一个主表,但可能有归档) - for (const { tableName, dbPath } of tables) { - // 构造查询 - const sql = `SELECT * FROM ${tableName} WHERE local_id = ${localId} LIMIT 1` - const result = await wcdbService.execQuery('message', dbPath, sql) - - if (result.success && result.rows && result.rows.length > 0) { - const row = { - ...(result.rows[0] as Record), - db_path: dbPath, - table_name: tableName - } - const message = await this.parseMessage(row, { source: 'detail', sessionId }) - - if (message.localId !== 0) { - return { success: true, message } - } - } - } - - return { success: false, error: '未找到消息' } + return { success: false, error: nativeResult.error || '未找到消息' } } catch (e) { console.error('ChatService: getMessageById 失败:', e) return { success: false, error: String(e) } @@ -6824,6 +6789,11 @@ class ChatService { for (const row of result.messages) { let message = await this.parseMessage(row, { source: 'search', sessionId }) + const resolvedSessionId = String( + sessionId || + this.getRowField(row, ['_session_id', 'session_id', 'sessionId', 'talker', 'username']) + || '' + ).trim() const needsDetailHydration = isGroupSearch && Boolean(sessionId) && message.localId > 0 && @@ -6842,19 +6812,9 @@ class ChatService { } } - if (isGroupSearch && (needsDetailHydration || message.isSend === 1)) { - console.info('[ChatService][GroupSearchHydratedHit]', { - sessionId, - localId: message.localId, - senderUsername: message.senderUsername, - isSend: message.isSend, - senderDisplayName: message.senderDisplayName, - senderAvatarUrl: message.senderAvatarUrl, - usedDetailHydration: needsDetailHydration, - parsedContent: message.parsedContent - }) + if (resolvedSessionId) { + ;(message as Message & { sessionId?: string }).sessionId = resolvedSessionId } - messages.push(message) } @@ -6888,6 +6848,7 @@ class ChatService { // 这里复用 parseMessagesBatch 里面的解析逻辑,为了简单我这里先写个基础的 // 实际项目中建议抽取 parseRawMessage(row) 供多处使用 const localId = this.getRowInt(row, ['local_id', 'localId', 'LocalId', 'msg_local_id', 'msgLocalId', 'MsgLocalId', 'msg_id', 'msgId', 'MsgId', 'id', 'WCDB_CT_local_id'], 0) + const serverIdRaw = this.normalizeUnsignedIntegerToken(this.getRowField(row, ['server_id', 'serverId', 'ServerId', 'msg_server_id', 'msgServerId', 'MsgServerId', 'WCDB_CT_server_id'])) const serverId = this.getRowInt(row, ['server_id', 'serverId', 'ServerId', 'msg_server_id', 'msgServerId', 'MsgServerId', 'WCDB_CT_server_id'], 0) const localType = this.getRowInt(row, ['local_type', 'localType', 'type', 'msg_type', 'msgType', 'WCDB_CT_local_type'], 0) const createTime = this.getRowInt(row, ['create_time', 'createTime', 'createtime', 'msg_create_time', 'msgCreateTime', 'msg_time', 'msgTime', 'time', 'WCDB_CT_create_time'], 0) @@ -6907,6 +6868,7 @@ class ChatService { }), localId, serverId, + serverIdRaw, localType, createTime, sortSeq, @@ -6931,19 +6893,6 @@ class ChatService { }) } - if (options?.source === 'search' && String(options.sessionId || '').endsWith('@chatroom') && sendState.selfMatched) { - console.info('[ChatService][GroupSearchSelfHit]', { - sessionId: options.sessionId, - localId, - createTime, - senderUsername, - rawIsSend, - resolvedIsSend: sendState.isSend, - correctedBySelfIdentity: sendState.correctedBySelfIdentity, - rowKeys: Object.keys(row) - }) - } - // 图片/语音解析逻辑 (简化示例,实际应调用现有解析方法) if (msg.localType === 3) { // Image const imgInfo = this.parseImageInfo(rawContent) @@ -7203,6 +7152,7 @@ class ChatService { if (!connectResult.success) { return { success: false, error: connectResult.error || '数据库未连接' } } + // fallback-exec: 仅用于诊断/低频兼容,不作为业务主路径 return wcdbService.execQuery(kind, path, sql) } catch (e) { console.error('ChatService: 执行自定义查询失败:', e) diff --git a/electron/services/cloudControlService.ts b/electron/services/cloudControlService.ts index c611bf0..c43dcd2 100644 --- a/electron/services/cloudControlService.ts +++ b/electron/services/cloudControlService.ts @@ -14,6 +14,7 @@ class CloudControlService { private deviceId: string = '' private timer: NodeJS.Timeout | null = null private pages: Set = new Set() + private platformVersionCache: string | null = null async init() { this.deviceId = this.getDeviceId() @@ -47,7 +48,12 @@ class CloudControlService { } private getPlatformVersion(): string { + if (this.platformVersionCache) { + return this.platformVersionCache + } + const os = require('os') + const fs = require('fs') const platform = process.platform if (platform === 'win32') { @@ -59,21 +65,79 @@ class CloudControlService { // Windows 11 是 10.0.22000+,且主版本必须是 10.0 if (major === 10 && minor === 0 && build >= 22000) { - return 'Windows 11' + this.platformVersionCache = 'Windows 11' + return this.platformVersionCache } else if (major === 10) { - return 'Windows 10' + this.platformVersionCache = 'Windows 10' + return this.platformVersionCache } - return `Windows ${release}` + this.platformVersionCache = `Windows ${release}` + return this.platformVersionCache } if (platform === 'darwin') { // `os.release()` returns Darwin kernel version (e.g. 25.3.0), // while cloud reporting expects the macOS product version (e.g. 26.3). const macVersion = typeof process.getSystemVersion === 'function' ? process.getSystemVersion() : os.release() - return `macOS ${macVersion}` + this.platformVersionCache = `macOS ${macVersion}` + return this.platformVersionCache } - return platform + if (platform === 'linux') { + try { + const osReleasePaths = ['/etc/os-release', '/usr/lib/os-release'] + for (const filePath of osReleasePaths) { + if (!fs.existsSync(filePath)) { + continue + } + + const content = fs.readFileSync(filePath, 'utf8') + const values: Record = {} + + for (const line of content.split('\n')) { + const trimmed = line.trim() + if (!trimmed || trimmed.startsWith('#')) { + continue + } + + const separatorIndex = trimmed.indexOf('=') + if (separatorIndex <= 0) { + continue + } + + const key = trimmed.slice(0, separatorIndex) + let value = trimmed.slice(separatorIndex + 1).trim() + if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith('\'') && value.endsWith('\''))) { + value = value.slice(1, -1) + } + values[key] = value + } + + if (values.PRETTY_NAME) { + this.platformVersionCache = values.PRETTY_NAME + return this.platformVersionCache + } + + if (values.NAME && values.VERSION_ID) { + this.platformVersionCache = `${values.NAME} ${values.VERSION_ID}` + return this.platformVersionCache + } + + if (values.NAME) { + this.platformVersionCache = values.NAME + return this.platformVersionCache + } + } + } catch (error) { + console.warn('[CloudControl] Failed to detect Linux distro version:', error) + } + + this.platformVersionCache = `Linux ${os.release()}` + return this.platformVersionCache + } + + this.platformVersionCache = platform + return this.platformVersionCache } recordPage(pageName: string) { diff --git a/electron/services/config.ts b/electron/services/config.ts index d783c49..4b8324d 100644 --- a/electron/services/config.ts +++ b/electron/services/config.ts @@ -16,7 +16,7 @@ interface ConfigSchema { imageXorKey: number imageAesKey: string wxidConfigs: Record - + exportPath?: string; // 缓存相关 cachePath: string lastOpenedDb: string @@ -50,6 +50,7 @@ interface ConfigSchema { notificationPosition: 'top-right' | 'top-left' | 'bottom-right' | 'bottom-left' | 'top-center' notificationFilterMode: 'all' | 'whitelist' | 'blacklist' notificationFilterList: string[] + messagePushEnabled: boolean windowCloseBehavior: 'ask' | 'tray' | 'quit' wordCloudExcludeWords: string[] } @@ -83,44 +84,71 @@ export class ConfigService { return ConfigService.instance } ConfigService.instance = this - this.store = new Store({ + const defaults: ConfigSchema = { + dbPath: '', + decryptKey: '', + myWxid: '', + onboardingDone: false, + imageXorKey: 0, + imageAesKey: '', + wxidConfigs: {}, + cachePath: '', + lastOpenedDb: '', + lastSession: '', + theme: 'system', + themeId: 'cloud-dancer', + language: 'zh-CN', + logEnabled: false, + llmModelPath: '', + whisperModelName: 'base', + whisperModelDir: '', + whisperDownloadSource: 'tsinghua', + autoTranscribeVoice: false, + transcribeLanguages: ['zh'], + exportDefaultConcurrency: 4, + analyticsExcludedUsernames: [], + authEnabled: false, + authPassword: '', + authUseHello: false, + authHelloSecret: '', + ignoredUpdateVersion: '', + notificationEnabled: true, + notificationPosition: 'top-right', + notificationFilterMode: 'all', + notificationFilterList: [], + messagePushEnabled: false, + windowCloseBehavior: 'ask', + wordCloudExcludeWords: [] + } + + const storeOptions: any = { name: 'WeFlow-config', - defaults: { - dbPath: '', - decryptKey: '', - myWxid: '', - onboardingDone: false, - imageXorKey: 0, - imageAesKey: '', - wxidConfigs: {}, - cachePath: '', - lastOpenedDb: '', - lastSession: '', - theme: 'system', - themeId: 'cloud-dancer', - language: 'zh-CN', - logEnabled: false, - llmModelPath: '', - whisperModelName: 'base', - whisperModelDir: '', - whisperDownloadSource: 'tsinghua', - autoTranscribeVoice: false, - transcribeLanguages: ['zh'], - exportDefaultConcurrency: 4, - analyticsExcludedUsernames: [], - authEnabled: false, - authPassword: '', - authUseHello: false, - authHelloSecret: '', - ignoredUpdateVersion: '', - notificationEnabled: true, - notificationPosition: 'top-right', - notificationFilterMode: 'all', - notificationFilterList: [], - windowCloseBehavior: 'ask', - wordCloudExcludeWords: [] + defaults, + projectName: String(process.env.WEFLOW_PROJECT_NAME || 'WeFlow').trim() || 'WeFlow' + } + const runningInWorker = process.env.WEFLOW_WORKER === '1' + if (runningInWorker) { + const cwd = String(process.env.WEFLOW_CONFIG_CWD || process.env.WEFLOW_USER_DATA_PATH || '').trim() + if (cwd) { + storeOptions.cwd = cwd } - }) + } + + try { + this.store = new Store(storeOptions) + } catch (error) { + const message = String((error as Error)?.message || error || '') + if (message.includes('projectName')) { + const fallbackOptions = { + ...storeOptions, + projectName: 'WeFlow', + cwd: storeOptions.cwd || process.env.WEFLOW_CONFIG_CWD || process.env.WEFLOW_USER_DATA_PATH || process.cwd() + } + this.store = new Store(fallbackOptions) + } else { + throw error + } + } this.migrateAuthFields() } diff --git a/electron/services/exportService.ts b/electron/services/exportService.ts index 0b8b5bc..6929f59 100644 --- a/electron/services/exportService.ts +++ b/electron/services/exportService.ts @@ -2,6 +2,7 @@ import * as path from 'path' import * as http from 'http' import * as https from 'https' +import crypto from 'crypto' import { fileURLToPath } from 'url' import ExcelJS from 'exceljs' import { getEmojiPath } from 'wechat-emojis' @@ -48,6 +49,20 @@ interface ChatLabMessage { chatRecords?: any[] // 嵌套的聊天记录 } +interface ForwardChatRecordItem { + datatype: number + sourcename: string + sourcetime: string + sourceheadurl?: string + datadesc?: string + datatitle?: string + fileext?: string + datasize?: number + chatRecordTitle?: string + chatRecordDesc?: string + chatRecordList?: ForwardChatRecordItem[] +} + interface ChatLabExport { chatlab: ChatLabHeader meta: ChatLabMeta @@ -128,6 +143,33 @@ export interface ExportProgress { phaseProgress?: number phaseTotal?: number phaseLabel?: string + collectedMessages?: number + exportedMessages?: number + estimatedTotalMessages?: number + writtenFiles?: number + mediaDoneFiles?: number + mediaCacheHitFiles?: number + mediaCacheMissFiles?: number + mediaCacheFillFiles?: number + mediaDedupReuseFiles?: number + mediaBytesWritten?: number +} + +interface MediaExportTelemetry { + doneFiles: number + cacheHitFiles: number + cacheMissFiles: number + cacheFillFiles: number + dedupReuseFiles: number + bytesWritten: number +} + +interface MediaSourceResolution { + sourcePath: string + cacheHit: boolean + cachePath?: string + fileStat?: { size: number; mtimeMs: number } + dedupeKey?: string } interface ExportTaskControl { @@ -211,6 +253,16 @@ class ExportService { private readonly exportAggregatedSessionStatsCacheTtlMs = 60 * 1000 private readonly exportStatsCacheMaxEntries = 16 private readonly STOP_ERROR_CODE = 'WEFLOW_EXPORT_STOP_REQUESTED' + private mediaFileCachePopulatePending = new Map>() + private mediaFileCacheReadyDirs = new Set() + private mediaExportTelemetry: MediaExportTelemetry | null = null + private mediaRunSourceDedupMap = new Map() + private mediaFileCacheCleanupPending: Promise | null = null + private mediaFileCacheLastCleanupAt = 0 + private readonly mediaFileCacheCleanupIntervalMs = 30 * 60 * 1000 + private readonly mediaFileCacheMaxBytes = 6 * 1024 * 1024 * 1024 + private readonly mediaFileCacheMaxFiles = 120000 + private readonly mediaFileCacheTtlMs = 45 * 24 * 60 * 60 * 1000 constructor() { this.configService = new ConfigService() @@ -350,6 +402,431 @@ class ExportService { return Math.max(1, Math.min(raw, max)) } + private createProgressEmitter(onProgress?: (progress: ExportProgress) => void): { + emit: (progress: ExportProgress, options?: { force?: boolean }) => void + flush: () => void + } { + if (!onProgress) { + return { + emit: () => { /* noop */ }, + flush: () => { /* noop */ } + } + } + + let pending: ExportProgress | null = null + let lastSentAt = 0 + let lastPhase = '' + let lastSessionId = '' + let lastCollected = 0 + let lastExported = 0 + + const commit = (progress: ExportProgress) => { + onProgress(progress) + pending = null + lastSentAt = Date.now() + lastPhase = String(progress.phase || '') + lastSessionId = String(progress.currentSessionId || '') + lastCollected = Number.isFinite(progress.collectedMessages) ? Math.max(0, Math.floor(progress.collectedMessages || 0)) : lastCollected + lastExported = Number.isFinite(progress.exportedMessages) ? Math.max(0, Math.floor(progress.exportedMessages || 0)) : lastExported + } + + const emit = (progress: ExportProgress, options?: { force?: boolean }) => { + pending = progress + const force = options?.force === true + const now = Date.now() + const phase = String(progress.phase || '') + const sessionId = String(progress.currentSessionId || '') + const collected = Number.isFinite(progress.collectedMessages) ? Math.max(0, Math.floor(progress.collectedMessages || 0)) : lastCollected + const exported = Number.isFinite(progress.exportedMessages) ? Math.max(0, Math.floor(progress.exportedMessages || 0)) : lastExported + const collectedDelta = Math.abs(collected - lastCollected) + const exportedDelta = Math.abs(exported - lastExported) + const shouldEmit = force || + phase !== lastPhase || + sessionId !== lastSessionId || + collectedDelta >= 200 || + exportedDelta >= 200 || + (now - lastSentAt >= 120) + + if (shouldEmit && pending) { + commit(pending) + } + } + + const flush = () => { + if (!pending) return + commit(pending) + } + + return { emit, flush } + } + + private async pathExists(filePath: string): Promise { + try { + await fs.promises.access(filePath, fs.constants.F_OK) + return true + } catch { + return false + } + } + + private isCloneUnsupportedError(code: string | undefined): boolean { + return code === 'ENOTSUP' || code === 'ENOSYS' || code === 'EINVAL' || code === 'EXDEV' || code === 'ENOTTY' + } + + private async copyFileOptimized(sourcePath: string, destPath: string): Promise<{ success: boolean; code?: string }> { + const cloneFlag = typeof fs.constants.COPYFILE_FICLONE === 'number' ? fs.constants.COPYFILE_FICLONE : 0 + try { + if (cloneFlag) { + await fs.promises.copyFile(sourcePath, destPath, cloneFlag) + } else { + await fs.promises.copyFile(sourcePath, destPath) + } + return { success: true } + } catch (e) { + const code = (e as NodeJS.ErrnoException | undefined)?.code + if (!this.isCloneUnsupportedError(code)) { + return { success: false, code } + } + } + + try { + await fs.promises.copyFile(sourcePath, destPath) + return { success: true } + } catch (e) { + return { success: false, code: (e as NodeJS.ErrnoException | undefined)?.code } + } + } + + private getMediaFileCacheRoot(): string { + return path.join(this.configService.getCacheBasePath(), 'export-media-files') + } + + private createEmptyMediaTelemetry(): MediaExportTelemetry { + return { + doneFiles: 0, + cacheHitFiles: 0, + cacheMissFiles: 0, + cacheFillFiles: 0, + dedupReuseFiles: 0, + bytesWritten: 0 + } + } + + private resetMediaRuntimeState(): void { + this.mediaExportTelemetry = this.createEmptyMediaTelemetry() + this.mediaRunSourceDedupMap.clear() + } + + private clearMediaRuntimeState(): void { + this.mediaExportTelemetry = null + this.mediaRunSourceDedupMap.clear() + } + + private getMediaTelemetrySnapshot(): Partial { + const stats = this.mediaExportTelemetry + if (!stats) return {} + return { + mediaDoneFiles: stats.doneFiles, + mediaCacheHitFiles: stats.cacheHitFiles, + mediaCacheMissFiles: stats.cacheMissFiles, + mediaCacheFillFiles: stats.cacheFillFiles, + mediaDedupReuseFiles: stats.dedupReuseFiles, + mediaBytesWritten: stats.bytesWritten + } + } + + private noteMediaTelemetry(delta: Partial): void { + if (!this.mediaExportTelemetry) return + if (Number.isFinite(delta.doneFiles)) { + this.mediaExportTelemetry.doneFiles += Math.max(0, Math.floor(Number(delta.doneFiles || 0))) + } + if (Number.isFinite(delta.cacheHitFiles)) { + this.mediaExportTelemetry.cacheHitFiles += Math.max(0, Math.floor(Number(delta.cacheHitFiles || 0))) + } + if (Number.isFinite(delta.cacheMissFiles)) { + this.mediaExportTelemetry.cacheMissFiles += Math.max(0, Math.floor(Number(delta.cacheMissFiles || 0))) + } + if (Number.isFinite(delta.cacheFillFiles)) { + this.mediaExportTelemetry.cacheFillFiles += Math.max(0, Math.floor(Number(delta.cacheFillFiles || 0))) + } + if (Number.isFinite(delta.dedupReuseFiles)) { + this.mediaExportTelemetry.dedupReuseFiles += Math.max(0, Math.floor(Number(delta.dedupReuseFiles || 0))) + } + if (Number.isFinite(delta.bytesWritten)) { + this.mediaExportTelemetry.bytesWritten += Math.max(0, Math.floor(Number(delta.bytesWritten || 0))) + } + } + + private async ensureMediaFileCacheDir(dirPath: string): Promise { + if (this.mediaFileCacheReadyDirs.has(dirPath)) return + await fs.promises.mkdir(dirPath, { recursive: true }) + this.mediaFileCacheReadyDirs.add(dirPath) + } + + private async getMediaFileStat(sourcePath: string): Promise<{ size: number; mtimeMs: number } | null> { + try { + const stat = await fs.promises.stat(sourcePath) + if (!stat.isFile()) return null + return { + size: Number.isFinite(stat.size) ? Math.max(0, Math.floor(stat.size)) : 0, + mtimeMs: Number.isFinite(stat.mtimeMs) ? Math.max(0, Math.floor(stat.mtimeMs)) : 0 + } + } catch { + return null + } + } + + private buildMediaFileCachePath( + kind: 'image' | 'video' | 'emoji', + sourcePath: string, + fileStat: { size: number; mtimeMs: number } + ): string { + const normalizedSource = path.resolve(sourcePath) + const rawKey = `${kind}\u001f${normalizedSource}\u001f${fileStat.size}\u001f${fileStat.mtimeMs}` + const digest = crypto.createHash('sha1').update(rawKey).digest('hex') + const ext = path.extname(normalizedSource) || '' + return path.join(this.getMediaFileCacheRoot(), kind, digest.slice(0, 2), `${digest}${ext}`) + } + + private async resolveMediaFileCachePath( + kind: 'image' | 'video' | 'emoji', + sourcePath: string + ): Promise<{ cachePath: string; fileStat: { size: number; mtimeMs: number } } | null> { + const fileStat = await this.getMediaFileStat(sourcePath) + if (!fileStat) return null + const cachePath = this.buildMediaFileCachePath(kind, sourcePath, fileStat) + return { cachePath, fileStat } + } + + private async populateMediaFileCache( + kind: 'image' | 'video' | 'emoji', + sourcePath: string + ): Promise { + const resolved = await this.resolveMediaFileCachePath(kind, sourcePath) + if (!resolved) return null + const { cachePath } = resolved + if (await this.pathExists(cachePath)) return cachePath + + const pending = this.mediaFileCachePopulatePending.get(cachePath) + if (pending) return pending + + const task = (async () => { + try { + await this.ensureMediaFileCacheDir(path.dirname(cachePath)) + if (await this.pathExists(cachePath)) return cachePath + + const tempPath = `${cachePath}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}` + const copied = await this.copyFileOptimized(sourcePath, tempPath) + if (!copied.success) { + await fs.promises.rm(tempPath, { force: true }).catch(() => { }) + return null + } + await fs.promises.rename(tempPath, cachePath).catch(async (error) => { + const code = (error as NodeJS.ErrnoException | undefined)?.code + if (code === 'EEXIST') { + await fs.promises.rm(tempPath, { force: true }).catch(() => { }) + return + } + await fs.promises.rm(tempPath, { force: true }).catch(() => { }) + throw error + }) + this.noteMediaTelemetry({ cacheFillFiles: 1 }) + return cachePath + } catch { + return null + } finally { + this.mediaFileCachePopulatePending.delete(cachePath) + } + })() + + this.mediaFileCachePopulatePending.set(cachePath, task) + return task + } + + private async resolvePreferredMediaSource( + kind: 'image' | 'video' | 'emoji', + sourcePath: string + ): Promise { + const resolved = await this.resolveMediaFileCachePath(kind, sourcePath) + if (!resolved) { + return { + sourcePath, + cacheHit: false + } + } + const dedupeKey = `${kind}\u001f${resolved.cachePath}` + if (await this.pathExists(resolved.cachePath)) { + return { + sourcePath: resolved.cachePath, + cacheHit: true, + cachePath: resolved.cachePath, + fileStat: resolved.fileStat, + dedupeKey + } + } + // 未命中缓存时异步回填,不阻塞当前导出路径 + void this.populateMediaFileCache(kind, sourcePath) + return { + sourcePath, + cacheHit: false, + cachePath: resolved.cachePath, + fileStat: resolved.fileStat, + dedupeKey + } + } + + private isHardlinkFallbackError(code: string | undefined): boolean { + return code === 'EXDEV' || code === 'EPERM' || code === 'EACCES' || code === 'EINVAL' || code === 'ENOSYS' || code === 'ENOTSUP' + } + + private async hardlinkOrCopyFile(sourcePath: string, destPath: string): Promise<{ success: boolean; code?: string; linked?: boolean }> { + try { + await fs.promises.link(sourcePath, destPath) + return { success: true, linked: true } + } catch (error) { + const code = (error as NodeJS.ErrnoException | undefined)?.code + if (code === 'EEXIST') { + return { success: true, linked: true } + } + if (!this.isHardlinkFallbackError(code)) { + return { success: false, code } + } + } + + const copied = await this.copyFileOptimized(sourcePath, destPath) + if (!copied.success) return copied + return { success: true, linked: false } + } + + private async copyMediaWithCacheAndDedup( + kind: 'image' | 'video' | 'emoji', + sourcePath: string, + destPath: string + ): Promise<{ success: boolean; code?: string }> { + const resolved = await this.resolvePreferredMediaSource(kind, sourcePath) + if (resolved.cacheHit) { + this.noteMediaTelemetry({ cacheHitFiles: 1 }) + } else { + this.noteMediaTelemetry({ cacheMissFiles: 1 }) + } + + const dedupeKey = resolved.dedupeKey + if (dedupeKey) { + const reusedPath = this.mediaRunSourceDedupMap.get(dedupeKey) + if (reusedPath && reusedPath !== destPath && await this.pathExists(reusedPath)) { + const reused = await this.hardlinkOrCopyFile(reusedPath, destPath) + if (!reused.success) return reused + this.noteMediaTelemetry({ + doneFiles: 1, + dedupReuseFiles: 1, + bytesWritten: resolved.fileStat?.size || 0 + }) + return { success: true } + } + } + + const copied = resolved.cacheHit + ? await this.hardlinkOrCopyFile(resolved.sourcePath, destPath) + : await this.copyFileOptimized(resolved.sourcePath, destPath) + if (!copied.success) return copied + + if (dedupeKey) { + this.mediaRunSourceDedupMap.set(dedupeKey, destPath) + } + this.noteMediaTelemetry({ + doneFiles: 1, + bytesWritten: resolved.fileStat?.size || 0 + }) + return { success: true } + } + + private triggerMediaFileCacheCleanup(force = false): void { + const now = Date.now() + if (!force && now - this.mediaFileCacheLastCleanupAt < this.mediaFileCacheCleanupIntervalMs) return + if (this.mediaFileCacheCleanupPending) return + this.mediaFileCacheLastCleanupAt = now + + this.mediaFileCacheCleanupPending = this.cleanupMediaFileCache().finally(() => { + this.mediaFileCacheCleanupPending = null + }) + } + + private async cleanupMediaFileCache(): Promise { + const root = this.getMediaFileCacheRoot() + if (!await this.pathExists(root)) return + const now = Date.now() + const files: Array<{ filePath: string; size: number; mtimeMs: number }> = [] + const dirs: string[] = [] + + const stack = [root] + while (stack.length > 0) { + const current = stack.pop() as string + dirs.push(current) + let entries: fs.Dirent[] + try { + entries = await fs.promises.readdir(current, { withFileTypes: true }) + } catch { + continue + } + for (const entry of entries) { + const entryPath = path.join(current, entry.name) + if (entry.isDirectory()) { + stack.push(entryPath) + continue + } + if (!entry.isFile()) continue + try { + const stat = await fs.promises.stat(entryPath) + if (!stat.isFile()) continue + files.push({ + filePath: entryPath, + size: Number.isFinite(stat.size) ? Math.max(0, Math.floor(stat.size)) : 0, + mtimeMs: Number.isFinite(stat.mtimeMs) ? Math.max(0, Math.floor(stat.mtimeMs)) : 0 + }) + } catch { } + } + } + + if (files.length === 0) return + + let totalBytes = files.reduce((sum, item) => sum + item.size, 0) + let totalFiles = files.length + const ttlThreshold = now - this.mediaFileCacheTtlMs + const removalSet = new Set() + + for (const item of files) { + if (item.mtimeMs > 0 && item.mtimeMs < ttlThreshold) { + removalSet.add(item.filePath) + totalBytes -= item.size + totalFiles -= 1 + } + } + + if (totalBytes > this.mediaFileCacheMaxBytes || totalFiles > this.mediaFileCacheMaxFiles) { + const ordered = files + .filter((item) => !removalSet.has(item.filePath)) + .sort((a, b) => a.mtimeMs - b.mtimeMs) + for (const item of ordered) { + if (totalBytes <= this.mediaFileCacheMaxBytes && totalFiles <= this.mediaFileCacheMaxFiles) break + removalSet.add(item.filePath) + totalBytes -= item.size + totalFiles -= 1 + } + } + + if (removalSet.size === 0) return + + for (const filePath of removalSet) { + await fs.promises.rm(filePath, { force: true }).catch(() => { }) + } + + dirs.sort((a, b) => b.length - a.length) + for (const dirPath of dirs) { + if (dirPath === root) continue + await fs.promises.rmdir(dirPath).catch(() => { }) + } + } + private isMediaExportEnabled(options: ExportOptions): boolean { return options.exportMedia === true && Boolean(options.exportImages || options.exportVoices || options.exportVideos || options.exportEmojis) @@ -428,7 +905,8 @@ class ExportService { total: 100, currentSession: sessionName, phase: 'preparing', - phaseLabel: `收集消息 ${fetched.toLocaleString()} 条` + phaseLabel: `收集消息 ${fetched.toLocaleString()} 条`, + collectedMessages: fetched }) } } @@ -464,6 +942,39 @@ class ExportService { return cleaned } + private getIntFromRow(row: Record, keys: string[], fallback = 0): number { + for (const key of keys) { + const raw = row?.[key] + if (raw === undefined || raw === null || raw === '') continue + const parsed = Number.parseInt(String(raw), 10) + if (Number.isFinite(parsed)) return parsed + } + return fallback + } + + private normalizeUnsignedIntToken(value: unknown): string { + const raw = String(value ?? '').trim() + if (!raw) return '0' + if (/^\d+$/.test(raw)) { + return raw.replace(/^0+(?=\d)/, '') + } + const num = Number(raw) + if (!Number.isFinite(num) || num <= 0) return '0' + return String(Math.floor(num)) + } + + private getStableMessageKey(msg: { localId?: unknown; createTime?: unknown; serverId?: unknown }): string { + const localId = this.normalizeUnsignedIntToken(msg?.localId) + const createTime = this.normalizeUnsignedIntToken(msg?.createTime) + const serverId = this.normalizeUnsignedIntToken(msg?.serverId) + return `${localId}:${createTime}:${serverId}` + } + + private getMediaCacheKey(msg: { localType?: unknown; localId?: unknown; createTime?: unknown; serverId?: unknown }): string { + const localType = this.normalizeUnsignedIntToken(msg?.localType) + return `${localType}_${this.getStableMessageKey(msg)}` + } + private async ensureConnected(): Promise<{ success: boolean; cleanedWxid?: string; error?: string }> { const wxid = this.configService.get('myWxid') const dbPath = this.configService.get('dbPath') @@ -577,13 +1088,11 @@ class ExportService { } try { - const sql = 'SELECT ext_buffer FROM chat_room WHERE username = ? LIMIT 1' - const result = await wcdbService.execQuery('contact', null, sql, [chatroomId]) - if (!result.success || !result.rows || result.rows.length === 0) { + const result = await wcdbService.getChatRoomExtBuffer(chatroomId) + if (!result.success || !result.extBuffer) { return nicknameMap } - - const extBuffer = this.decodeExtBuffer((result.rows[0] as any).ext_buffer) + const extBuffer = this.decodeExtBuffer(result.extBuffer) if (!extBuffer) return nicknameMap this.mergeGroupNicknameEntries(nicknameMap, this.parseGroupNicknamesFromExtBuffer(extBuffer, candidates).entries()) return nicknameMap @@ -736,12 +1245,13 @@ class ExportService { * 转换微信消息类型到 ChatLab 类型 */ private convertMessageType(localType: number, content: string): number { - // 检查 XML 中的 type 标签(支持大 localType 的情况) - const xmlTypeMatch = /(\d+)<\/type>/i.exec(content) - const xmlType = xmlTypeMatch ? parseInt(xmlTypeMatch[1]) : null + const normalized = this.normalizeAppMessageContent(content || '') + const xmlTypeRaw = this.extractAppMessageType(normalized) + const xmlType = xmlTypeRaw ? Number.parseInt(xmlTypeRaw, 10) : null + const looksLikeAppMessage = localType === 49 || normalized.includes('') // 特殊处理 type 49 或 XML type - if (localType === 49 || xmlType) { + if (looksLikeAppMessage || xmlType) { const subType = xmlType || 0 switch (subType) { case 6: return 4 // 文件 -> FILE @@ -753,7 +1263,7 @@ class ExportService { case 5: case 49: return 7 // 链接 -> LINK default: - if (xmlType) return 7 // 有 XML type 但未知,默认为链接 + if (xmlType || looksLikeAppMessage) return 7 // 有 appmsg 但未知,默认为链接 } } return MESSAGE_TYPE_MAP[localType] ?? 99 // 未知类型 -> OTHER @@ -1054,9 +1564,8 @@ class ExportService { ): string | null { if (!content) return null - // 检查 XML 中的 type 标签(支持大 localType 的情况) - const xmlTypeMatch = /(\d+)<\/type>/i.exec(content) - const xmlType = xmlTypeMatch ? xmlTypeMatch[1] : null + const normalizedContent = this.normalizeAppMessageContent(content) + const xmlType = this.extractAppMessageType(normalizedContent) switch (localType) { case 1: // 文本 @@ -1092,15 +1601,15 @@ class ExportService { return locParts.length > 0 ? `[位置] ${locParts.join(' ')}` : '[位置]' } case 49: { - const title = this.extractXmlValue(content, 'title') - const type = this.extractXmlValue(content, 'type') - const songName = this.extractXmlValue(content, 'songname') + const title = this.extractXmlValue(normalizedContent, 'title') + const type = this.extractAppMessageType(normalizedContent) + const songName = this.extractXmlValue(normalizedContent, 'songname') // 转账消息特殊处理 if (type === '2000') { - const feedesc = this.extractXmlValue(content, 'feedesc') - const payMemo = this.extractXmlValue(content, 'pay_memo') - const transferPrefix = this.getTransferPrefix(content, myWxid, senderWxid, isSend) + const feedesc = this.extractXmlValue(normalizedContent, 'feedesc') + const payMemo = this.extractXmlValue(normalizedContent, 'pay_memo') + const transferPrefix = this.getTransferPrefix(normalizedContent, myWxid, senderWxid, isSend) if (feedesc) { return payMemo ? `${transferPrefix} ${feedesc} ${payMemo}` : `${transferPrefix} ${feedesc}` } @@ -1109,7 +1618,7 @@ class ExportService { if (type === '3') return songName ? `[音乐] ${songName}` : (title ? `[音乐] ${title}` : '[音乐]') if (type === '6') return title ? `[文件] ${title}` : '[文件]' - if (type === '19') return title ? `[聊天记录] ${title}` : '[聊天记录]' + if (type === '19') return this.formatForwardChatRecordContent(normalizedContent) if (type === '33' || type === '36') return title ? `[小程序] ${title}` : '[小程序]' if (type === '57') return title || '[引用消息]' if (type === '5' || type === '49') return title ? `[链接] ${title}` : '[链接]' @@ -1151,7 +1660,7 @@ class ExportService { // 其他类型 if (xmlType === '3') return title ? `[音乐] ${title}` : '[音乐]' if (xmlType === '6') return title ? `[文件] ${title}` : '[文件]' - if (xmlType === '19') return title ? `[聊天记录] ${title}` : '[聊天记录]' + if (xmlType === '19') return this.formatForwardChatRecordContent(normalizedContent) if (xmlType === '33' || xmlType === '36') return title ? `[小程序] ${title}` : '[小程序]' if (xmlType === '57') return title || '[引用消息]' if (xmlType === '5' || xmlType === '49') return title ? `[链接] ${title}` : '[链接]' @@ -1161,7 +1670,7 @@ class ExportService { } // 最后尝试提取文本内容 - return this.stripSenderPrefix(content) || null + return this.stripSenderPrefix(normalizedContent) || null } } @@ -1224,8 +1733,8 @@ class ExportService { const normalized = this.normalizeAppMessageContent(safeContent) const isAppMessage = normalized.includes('') if (localType === 49 || isAppMessage) { - const typeMatch = /(\d+)<\/type>/i.exec(normalized) - const subType = typeMatch ? parseInt(typeMatch[1], 10) : 0 + const subTypeRaw = this.extractAppMessageType(normalized) + const subType = subTypeRaw ? parseInt(subTypeRaw, 10) : 0 const title = this.extractXmlValue(normalized, 'title') || this.extractXmlValue(normalized, 'appname') // 群公告消息(type 87) @@ -1271,12 +1780,7 @@ class ExportService { return `[红包]${title || '微信红包'}` } if (subType === 19 || normalized.includes('')) { if (xmlType === '6') return 'file' return 'text' } @@ -1528,8 +2033,8 @@ class ExportService { private getMessageTypeName(localType: number, content?: string): string { // 检查 XML 中的 type 标签(支持大 localType 的情况) if (content) { - const xmlTypeMatch = /(\d+)<\/type>/i.exec(content) - const xmlType = xmlTypeMatch ? xmlTypeMatch[1] : null + const normalized = this.normalizeAppMessageContent(content) + const xmlType = this.extractAppMessageType(normalized) if (xmlType) { switch (xmlType) { @@ -1651,45 +2156,38 @@ class ExportService { /** * 解析合并转发的聊天记录 (Type 19) */ - private parseChatHistory(content: string): any[] | undefined { + private parseChatHistory(content: string): ForwardChatRecordItem[] | undefined { try { - const type = this.extractXmlValue(content, 'type') - if (type !== '19') return undefined + const normalized = this.normalizeAppMessageContent(content || '') + const appMsgType = this.extractAppMessageType(normalized) + if (appMsgType !== '19' && !normalized.includes('[\s\S]*?[\s\S]*?<\/recorditem>/.exec(content) - if (!match) return undefined + const items: ForwardChatRecordItem[] = [] + const dedupe = new Set() + const recordItemRegex = /([\s\S]*?)<\/recorditem>/gi + let recordItemMatch: RegExpExecArray | null + while ((recordItemMatch = recordItemRegex.exec(normalized)) !== null) { + const parsedItems = this.parseForwardChatRecordContainer(recordItemMatch[1] || '') + for (const item of parsedItems) { + const dedupeKey = `${item.datatype}|${item.sourcename}|${item.sourcetime}|${item.datadesc || ''}|${item.datatitle || ''}` + if (!dedupe.has(dedupeKey)) { + dedupe.add(dedupeKey) + items.push(item) + } + } + } - const innerXml = match[1] - const items: any[] = [] - const itemRegex = /([\s\S]*?)<\/dataitem>/g - let itemMatch - - while ((itemMatch = itemRegex.exec(innerXml)) !== null) { - const attrs = itemMatch[1] - const body = itemMatch[2] - - const datatypeMatch = /datatype="(\d+)"/.exec(attrs) - const datatype = datatypeMatch ? parseInt(datatypeMatch[1]) : 0 - - const sourcename = this.extractXmlValue(body, 'sourcename') - const sourcetime = this.extractXmlValue(body, 'sourcetime') - const sourceheadurl = this.extractXmlValue(body, 'sourceheadurl') - const datadesc = this.extractXmlValue(body, 'datadesc') - const datatitle = this.extractXmlValue(body, 'datatitle') - const fileext = this.extractXmlValue(body, 'fileext') - const datasize = parseInt(this.extractXmlValue(body, 'datasize') || '0') - - items.push({ - datatype, - sourcename, - sourcetime, - sourceheadurl, - datadesc: this.decodeHtmlEntities(datadesc), - datatitle: this.decodeHtmlEntities(datatitle), - fileext, - datasize - }) + if (items.length === 0 && normalized.includes(' 0 ? items : undefined @@ -1699,6 +2197,139 @@ class ExportService { } } + private parseForwardChatRecordContainer(containerXml: string): ForwardChatRecordItem[] { + const source = containerXml || '' + if (!source) return [] + + const segments: string[] = [source] + const decodedContainer = this.decodeHtmlEntities(source) + if (decodedContainer !== source) { + segments.push(decodedContainer) + } + + const cdataRegex = //g + let cdataMatch: RegExpExecArray | null + while ((cdataMatch = cdataRegex.exec(source)) !== null) { + const cdataInner = cdataMatch[1] || '' + if (cdataInner) { + segments.push(cdataInner) + const decodedInner = this.decodeHtmlEntities(cdataInner) + if (decodedInner !== cdataInner) { + segments.push(decodedInner) + } + } + } + + const items: ForwardChatRecordItem[] = [] + const seen = new Set() + for (const segment of segments) { + if (!segment) continue + const dataItemRegex = /]*)>([\s\S]*?)<\/dataitem>/gi + let dataItemMatch: RegExpExecArray | null + while ((dataItemMatch = dataItemRegex.exec(segment)) !== null) { + const parsed = this.parseForwardChatRecordDataItem(dataItemMatch[2] || '', dataItemMatch[1] || '') + if (!parsed) continue + const key = `${parsed.datatype}|${parsed.sourcename}|${parsed.sourcetime}|${parsed.datadesc || ''}|${parsed.datatitle || ''}` + if (!seen.has(key)) { + seen.add(key) + items.push(parsed) + } + } + } + + if (items.length > 0) return items + const fallback = this.parseForwardChatRecordDataItem(source, '') + return fallback ? [fallback] : [] + } + + private parseForwardChatRecordDataItem(body: string, attrs: string): ForwardChatRecordItem | null { + const datatypeByAttr = /datatype\s*=\s*["']?(\d+)["']?/i.exec(attrs || '') + const datatypeRaw = datatypeByAttr?.[1] || this.extractXmlValue(body, 'datatype') || '0' + const datatype = Number.parseInt(datatypeRaw, 10) + const sourcename = this.decodeHtmlEntities(this.extractXmlValue(body, 'sourcename')) + const sourcetime = this.extractXmlValue(body, 'sourcetime') + const sourceheadurl = this.extractXmlValue(body, 'sourceheadurl') + const datadesc = this.decodeHtmlEntities(this.extractXmlValue(body, 'datadesc') || this.extractXmlValue(body, 'content')) + const datatitle = this.decodeHtmlEntities(this.extractXmlValue(body, 'datatitle')) + const fileext = this.extractXmlValue(body, 'fileext') + const datasizeRaw = this.extractXmlValue(body, 'datasize') + const datasize = datasizeRaw ? Number.parseInt(datasizeRaw, 10) : 0 + const nestedRecordXml = this.extractXmlValue(body, 'recordxml') || '' + const nestedRecordList = + datatype === 17 && nestedRecordXml + ? this.parseForwardChatRecordContainer(nestedRecordXml) + : undefined + const chatRecordTitle = this.decodeHtmlEntities( + (nestedRecordXml && this.extractXmlValue(nestedRecordXml, 'title')) || datatitle || '' + ) + const chatRecordDesc = this.decodeHtmlEntities( + (nestedRecordXml && this.extractXmlValue(nestedRecordXml, 'desc')) || datadesc || '' + ) + + if (!sourcename && !datadesc && !datatitle) return null + + return { + datatype: Number.isFinite(datatype) ? datatype : 0, + sourcename: sourcename || '', + sourcetime: sourcetime || '', + sourceheadurl: sourceheadurl || undefined, + datadesc: datadesc || undefined, + datatitle: datatitle || undefined, + fileext: fileext || undefined, + datasize: Number.isFinite(datasize) && datasize > 0 ? datasize : undefined, + chatRecordTitle: chatRecordTitle || undefined, + chatRecordDesc: chatRecordDesc || undefined, + chatRecordList: nestedRecordList && nestedRecordList.length > 0 ? nestedRecordList : undefined + } + } + + private formatForwardChatRecordItemText(item: ForwardChatRecordItem): string { + const desc = (item.datadesc || '').trim() + const title = (item.datatitle || '').trim() + if (desc) return desc + if (title) return title + switch (item.datatype) { + case 3: return '[图片]' + case 34: return '[语音消息]' + case 43: return '[视频]' + case 47: return '[动画表情]' + case 49: + case 8: return title ? `[文件] ${title}` : '[文件]' + case 17: return item.chatRecordDesc || title || '[聊天记录]' + default: return '[消息]' + } + } + + private buildForwardChatRecordLines(record: ForwardChatRecordItem, depth = 0): string[] { + const indent = depth > 0 ? `${' '.repeat(Math.min(depth, 8))}` : '' + const senderPrefix = record.sourcename ? `${record.sourcename}: ` : '' + if (record.chatRecordList && record.chatRecordList.length > 0) { + const nestedTitle = record.chatRecordTitle || record.datatitle || record.chatRecordDesc || '聊天记录' + const header = `${indent}${senderPrefix}[转发的聊天记录]${nestedTitle}` + const nestedLines = record.chatRecordList.flatMap((item) => this.buildForwardChatRecordLines(item, depth + 1)) + return [header, ...nestedLines] + } + const text = this.formatForwardChatRecordItemText(record) + return [`${indent}${senderPrefix}${text}`] + } + + private formatForwardChatRecordContent(content: string): string { + const normalized = this.normalizeAppMessageContent(content || '') + const forwardName = + this.extractXmlValue(normalized, 'nickname') || + this.extractXmlValue(normalized, 'title') || + this.extractXmlValue(normalized, 'des') || + this.extractXmlValue(normalized, 'displayname') || + '聊天记录' + const records = this.parseChatHistory(normalized) + if (!records || records.length === 0) { + return forwardName ? `[转发的聊天记录]${forwardName}` : '[转发的聊天记录]' + } + + const lines = records.flatMap((record) => this.buildForwardChatRecordLines(record)) + return `${forwardName ? `[转发的聊天记录]${forwardName}` : '[转发的聊天记录]'}\n${lines.join('\n')}` + } + /** * 解码 HTML 实体 */ @@ -1735,7 +2366,8 @@ class ExportService { private extractAppMessageType(content: string): string { if (!content) return '' - const appmsgMatch = /([\s\S]*?)<\/appmsg>/i.exec(content) + const normalized = this.normalizeAppMessageContent(content) + const appmsgMatch = /([\s\S]*?)<\/appmsg>/i.exec(normalized) if (appmsgMatch) { const appmsgInner = appmsgMatch[1] .replace(//gi, '') @@ -1743,7 +2375,11 @@ class ExportService { const typeMatch = /([\s\S]*?)<\/type>/i.exec(appmsgInner) if (typeMatch) return typeMatch[1].trim() } - return this.extractXmlValue(content, 'type') + if (!normalized.includes('')) { + return '' + } + const fallbackTypeMatch = /(\d+)<\/type>/i.exec(normalized) + return fallbackTypeMatch ? fallbackTypeMatch[1] : '' } private looksLikeWxid(text: string): boolean { @@ -2105,7 +2741,7 @@ class ExportService { const isAppMessage = localType === 49 || normalized.includes('') if (!isAppMessage) return null - const subType = this.extractXmlValue(normalized, 'type') + const subType = this.extractAppMessageType(normalized) if (subType && subType !== '5' && subType !== '49') return null const url = this.normalizeHtmlLinkUrl(this.extractXmlValue(normalized, 'url')) @@ -2161,14 +2797,16 @@ class ExportService { exportVideos?: boolean exportEmojis?: boolean exportVoiceAsText?: boolean + includeVideoPoster?: boolean includeVoiceWithTranscript?: boolean + dirCache?: Set } ): Promise { const localType = msg.localType // 图片消息 if (localType === 3 && options.exportImages) { - const result = await this.exportImage(msg, sessionId, mediaRootDir, mediaRelativePrefix) + const result = await this.exportImage(msg, sessionId, mediaRootDir, mediaRelativePrefix, options.dirCache) if (result) { } return result @@ -2177,7 +2815,7 @@ class ExportService { // 语音消息 if (localType === 34) { if (options.exportVoices) { - return this.exportVoice(msg, sessionId, mediaRootDir, mediaRelativePrefix) + return this.exportVoice(msg, sessionId, mediaRootDir, mediaRelativePrefix, options.dirCache) } if (options.exportVoiceAsText) { return null @@ -2186,14 +2824,21 @@ class ExportService { // 动画表情 if (localType === 47 && options.exportEmojis) { - const result = await this.exportEmoji(msg, sessionId, mediaRootDir, mediaRelativePrefix) + const result = await this.exportEmoji(msg, sessionId, mediaRootDir, mediaRelativePrefix, options.dirCache) if (result) { } return result } if (localType === 43 && options.exportVideos) { - return this.exportVideo(msg, sessionId, mediaRootDir, mediaRelativePrefix) + return this.exportVideo( + msg, + sessionId, + mediaRootDir, + mediaRelativePrefix, + options.dirCache, + options.includeVideoPoster === true + ) } return null @@ -2206,12 +2851,14 @@ class ExportService { msg: any, sessionId: string, mediaRootDir: string, - mediaRelativePrefix: string + mediaRelativePrefix: string, + dirCache?: Set ): Promise { try { const imagesDir = path.join(mediaRootDir, mediaRelativePrefix, 'images') - if (!fs.existsSync(imagesDir)) { - fs.mkdirSync(imagesDir, { recursive: true }) + if (!dirCache?.has(imagesDir)) { + await fs.promises.mkdir(imagesDir, { recursive: true }) + dirCache?.add(imagesDir) } // 使用消息对象中已提取的字段 @@ -2226,7 +2873,8 @@ class ExportService { sessionId, imageMd5, imageDatName, - force: false // 先尝试缩略图 + force: true, // 导出优先高清,失败再回退缩略图 + preferFilePath: true }) if (!result.success || !result.localPath) { @@ -2235,7 +2883,8 @@ class ExportService { const thumbResult = await imageDecryptService.resolveCachedImage({ sessionId, imageMd5, - imageDatName + imageDatName, + preferFilePath: true }) if (thumbResult.success && thumbResult.localPath) { console.log(`[Export] 使用缩略图替代 (localId=${msg.localId}): ${thumbResult.localPath}`) @@ -2268,7 +2917,13 @@ class ExportService { const fileName = `${messageId}_${imageKey}${ext}` const destPath = path.join(imagesDir, fileName) - fs.writeFileSync(destPath, Buffer.from(base64Data, 'base64')) + const buffer = Buffer.from(base64Data, 'base64') + await fs.promises.writeFile(destPath, buffer) + this.noteMediaTelemetry({ + doneFiles: 1, + cacheMissFiles: 1, + bytesWritten: buffer.length + }) return { relativePath: path.posix.join(mediaRelativePrefix, 'images', fileName), @@ -2279,16 +2934,17 @@ class ExportService { } // 复制文件 - if (!fs.existsSync(sourcePath)) { - console.log(`[Export] 源图片文件不存在 (localId=${msg.localId}): ${sourcePath} → 将显示 [图片] 占位符`) - return null - } const ext = path.extname(sourcePath) || '.jpg' const fileName = `${messageId}_${imageKey}${ext}` const destPath = path.join(imagesDir, fileName) - - if (!fs.existsSync(destPath)) { - fs.copyFileSync(sourcePath, destPath) + const copied = await this.copyMediaWithCacheAndDedup('image', sourcePath, destPath) + if (!copied.success) { + if (copied.code === 'ENOENT') { + console.log(`[Export] 源图片文件不存在 (localId=${msg.localId}): ${sourcePath} → 将显示 [图片] 占位符`) + } else { + console.log(`[Export] 复制图片失败 (localId=${msg.localId}): ${sourcePath}, code=${copied.code || 'UNKNOWN'} → 将显示 [图片] 占位符`) + } + return null } return { @@ -2301,6 +2957,105 @@ class ExportService { } } + private async preloadMediaLookupCaches( + _sessionId: string, + messages: any[], + options: { exportImages?: boolean; exportVideos?: boolean }, + control?: ExportTaskControl + ): Promise { + if (!Array.isArray(messages) || messages.length === 0) return + + const md5Pattern = /^[a-f0-9]{32}$/i + const imageMd5Set = new Set() + const videoMd5Set = new Set() + + let scanIndex = 0 + for (const msg of messages) { + if ((scanIndex++ & 0x7f) === 0) { + this.throwIfStopRequested(control) + } + + if (options.exportImages && msg?.localType === 3) { + const imageMd5 = String(msg?.imageMd5 || '').trim().toLowerCase() + if (imageMd5) { + imageMd5Set.add(imageMd5) + } else { + const imageDatName = String(msg?.imageDatName || '').trim().toLowerCase() + if (md5Pattern.test(imageDatName)) { + imageMd5Set.add(imageDatName) + } + } + } + + if (options.exportVideos && msg?.localType === 43) { + const videoMd5 = String(msg?.videoMd5 || '').trim().toLowerCase() + if (videoMd5) videoMd5Set.add(videoMd5) + } + } + + const preloadTasks: Array> = [] + if (imageMd5Set.size > 0) { + preloadTasks.push(imageDecryptService.preloadImageHardlinkMd5s(Array.from(imageMd5Set))) + } + if (videoMd5Set.size > 0) { + preloadTasks.push(videoService.preloadVideoHardlinkMd5s(Array.from(videoMd5Set))) + } + if (preloadTasks.length === 0) return + + await Promise.all(preloadTasks.map((task) => task.catch(() => { }))) + this.throwIfStopRequested(control) + } + + /** + * 导出语音文件 + */ + private async preloadVoiceWavCache( + sessionId: string, + messages: any[], + control?: ExportTaskControl + ): Promise { + if (!Array.isArray(messages) || messages.length === 0) return + + const normalizedSessionId = String(sessionId || '').trim() + if (!normalizedSessionId) return + + const normalized: Array<{ + localId: number + createTime: number + serverId?: string | number + senderWxid?: string | null + }> = [] + const seen = new Set() + + for (const msg of messages) { + const localIdRaw = Number(msg?.localId) + const createTimeRaw = Number(msg?.createTime) + const localId = Number.isFinite(localIdRaw) ? Math.max(0, Math.floor(localIdRaw)) : 0 + const createTime = Number.isFinite(createTimeRaw) ? Math.max(0, Math.floor(createTimeRaw)) : 0 + if (!localId || !createTime) continue + const dedupeKey = this.getStableMessageKey(msg) + if (seen.has(dedupeKey)) continue + seen.add(dedupeKey) + normalized.push({ + localId, + createTime, + serverId: msg?.serverId, + senderWxid: msg?.senderUsername || null + }) + } + if (normalized.length === 0) return + + const chunkSize = 120 + for (let i = 0; i < normalized.length; i += chunkSize) { + this.throwIfStopRequested(control) + const chunk = normalized.slice(i, i + chunkSize) + await chatService.preloadVoiceDataBatch(normalizedSessionId, chunk, { + chunkSize: 48, + decodeConcurrency: 3 + }) + } + } + /** * 导出语音文件 */ @@ -2308,23 +3063,26 @@ class ExportService { msg: any, sessionId: string, mediaRootDir: string, - mediaRelativePrefix: string + mediaRelativePrefix: string, + dirCache?: Set ): Promise { try { const voicesDir = path.join(mediaRootDir, mediaRelativePrefix, 'voices') - if (!fs.existsSync(voicesDir)) { - fs.mkdirSync(voicesDir, { recursive: true }) + if (!dirCache?.has(voicesDir)) { + await fs.promises.mkdir(voicesDir, { recursive: true }) + dirCache?.add(voicesDir) } const msgId = String(msg.localId) const safeSession = this.cleanAccountDirName(sessionId) .replace(/[^a-zA-Z0-9_-]/g, '_') .slice(0, 48) || 'session' - const fileName = `voice_${safeSession}_${msgId}.wav` + const stableKey = this.getStableMessageKey(msg).replace(/:/g, '_') + const fileName = `voice_${safeSession}_${stableKey || msgId}.wav` const destPath = path.join(voicesDir, fileName) // 如果已存在则跳过 - if (fs.existsSync(destPath)) { + if (await this.pathExists(destPath)) { return { relativePath: path.posix.join(mediaRelativePrefix, 'voices', fileName), kind: 'voice' @@ -2332,14 +3090,24 @@ class ExportService { } // 调用 chatService 获取语音数据 - const voiceResult = await chatService.getVoiceData(sessionId, msgId) + const voiceResult = await chatService.getVoiceData( + sessionId, + msgId, + Number.isFinite(Number(msg?.createTime)) ? Number(msg.createTime) : undefined, + msg?.serverId, + msg?.senderUsername || undefined + ) if (!voiceResult.success || !voiceResult.data) { return null } // voiceResult.data 是 base64 编码的 wav 数据 const wavBuffer = Buffer.from(voiceResult.data, 'base64') - fs.writeFileSync(destPath, wavBuffer) + await fs.promises.writeFile(destPath, wavBuffer) + this.noteMediaTelemetry({ + doneFiles: 1, + bytesWritten: wavBuffer.length + }) return { relativePath: path.posix.join(mediaRelativePrefix, 'voices', fileName), @@ -2372,18 +3140,20 @@ class ExportService { msg: any, sessionId: string, mediaRootDir: string, - mediaRelativePrefix: string + mediaRelativePrefix: string, + dirCache?: Set ): Promise { try { const emojisDir = path.join(mediaRootDir, mediaRelativePrefix, 'emojis') - if (!fs.existsSync(emojisDir)) { - fs.mkdirSync(emojisDir, { recursive: true }) + if (!dirCache?.has(emojisDir)) { + await fs.promises.mkdir(emojisDir, { recursive: true }) + dirCache?.add(emojisDir) } // 使用 chatService 下载表情包 (利用其重试和 fallback 逻辑) const localPath = await chatService.downloadEmojiFile(msg) - if (!localPath || !fs.existsSync(localPath)) { + if (!localPath) { return null } @@ -2392,11 +3162,8 @@ class ExportService { const key = msg.emojiMd5 || String(msg.localId) const fileName = `${key}${ext}` const destPath = path.join(emojisDir, fileName) - - // 复制文件到导出目录 (如果不存在) - if (!fs.existsSync(destPath)) { - fs.copyFileSync(localPath, destPath) - } + const copied = await this.copyMediaWithCacheAndDedup('emoji', localPath, destPath) + if (!copied.success) return null return { relativePath: path.posix.join(mediaRelativePrefix, 'emojis', fileName), @@ -2415,18 +3182,21 @@ class ExportService { msg: any, sessionId: string, mediaRootDir: string, - mediaRelativePrefix: string + mediaRelativePrefix: string, + dirCache?: Set, + includePoster = false ): Promise { try { const videoMd5 = msg.videoMd5 if (!videoMd5) return null const videosDir = path.join(mediaRootDir, mediaRelativePrefix, 'videos') - if (!fs.existsSync(videosDir)) { - fs.mkdirSync(videosDir, { recursive: true }) + if (!dirCache?.has(videosDir)) { + await fs.promises.mkdir(videosDir, { recursive: true }) + dirCache?.add(videosDir) } - const videoInfo = await videoService.getVideoInfo(videoMd5) + const videoInfo = await videoService.getVideoInfo(videoMd5, { includePoster }) if (!videoInfo.exists || !videoInfo.videoUrl) { return null } @@ -2435,14 +3205,13 @@ class ExportService { const fileName = path.basename(sourcePath) const destPath = path.join(videosDir, fileName) - if (!fs.existsSync(destPath)) { - fs.copyFileSync(sourcePath, destPath) - } + const copied = await this.copyMediaWithCacheAndDedup('video', sourcePath, destPath) + if (!copied.success) return null return { relativePath: path.posix.join(mediaRelativePrefix, 'videos', fileName), kind: 'video', - posterDataUrl: videoInfo.coverUrl || videoInfo.thumbUrl + posterDataUrl: includePoster ? (videoInfo.coverUrl || videoInfo.thumbUrl) : undefined } } catch (e) { return null @@ -2707,12 +3476,19 @@ class ExportService { if ((rowIndex++ & 0x7f) === 0) { this.throwIfStopRequested(control) } - const createTime = parseInt(row.create_time || '0', 10) + const createTime = this.getIntFromRow(row, [ + 'create_time', 'createTime', 'createtime', + 'msg_create_time', 'msgCreateTime', + 'msg_time', 'msgTime', 'time', + 'WCDB_CT_create_time' + ], 0) if (dateRange) { if (createTime < dateRange.start || createTime > dateRange.end) continue } - const localType = parseInt(row.local_type || row.type || '1', 10) + const localType = this.getIntFromRow(row, [ + 'local_type', 'localType', 'type', 'msg_type', 'msgType', 'WCDB_CT_local_type' + ], 1) if (mediaTypeFilter && !mediaTypeFilter.has(localType)) { continue } @@ -2725,7 +3501,18 @@ class ExportService { const senderUsername = row.sender_username || '' const isSendRaw = row.computed_is_send ?? row.is_send ?? '0' const isSend = parseInt(isSendRaw, 10) === 1 - const localId = parseInt(row.local_id || row.localId || '0', 10) + const localId = this.getIntFromRow(row, [ + 'local_id', 'localId', 'LocalId', + 'msg_local_id', 'msgLocalId', 'MsgLocalId', + 'msg_id', 'msgId', 'MsgId', 'id', + 'WCDB_CT_local_id' + ], 0) + const serverId = this.getIntFromRow(row, [ + 'server_id', 'serverId', 'ServerId', + 'msg_server_id', 'msgServerId', 'MsgServerId', + 'svr_id', 'svrId', 'msg_svr_id', 'msgSvrId', 'MsgSvrId', + 'WCDB_CT_server_id' + ], 0) // 确定实际发送者 let actualSender: string @@ -2798,17 +3585,19 @@ class ExportService { } else if (localType === 43 && content) { // 视频消息 videoMd5 = videoMd5 || this.extractVideoMd5(content) - } else if (collectMode === 'full' && localType === 49 && content) { - // 检查是否是聊天记录消息(type=19) - const xmlType = this.extractXmlValue(content, 'type') + } else if (collectMode === 'full' && content && (localType === 49 || content.includes(' `'${username.replace(/'/g, "''")}'`).join(',') - const sql = `SELECT username, local_type FROM contact WHERE username IN (${inList})` - const query = await wcdbService.execQuery('contact', null, sql) - if (!query.success || !query.rows) continue - for (const row of query.rows) { - const username = String((row as any).username || '').trim() - if (!username) continue - const localType = Number.parseInt(String((row as any).local_type ?? (row as any).localType ?? (row as any).WCDB_CT_local_type ?? ''), 10) - result.set(username, Number.isFinite(localType) && localType === 1) + const query = await wcdbService.getContactFriendFlags(unique) + if (query.success && query.map) { + for (const [username, isFriend] of Object.entries(query.map)) { + const normalized = String(username || '').trim() + if (!normalized) continue + result.set(normalized, Boolean(isFriend)) } } @@ -3396,9 +4179,10 @@ class ExportService { collectProgressReporter ) const allMessages = collected.rows + const totalMessages = allMessages.length // 如果没有消息,不创建文件 - if (allMessages.length === 0) { + if (totalMessages === 0) { return { success: false, error: '该会话在指定时间范围内没有消息' } } @@ -3466,8 +4250,18 @@ class ExportService { : [] const mediaCache = new Map() + const mediaDirCache = new Set() if (mediaMessages.length > 0) { + await this.preloadMediaLookupCaches(sessionId, mediaMessages, { + exportImages: options.exportImages, + exportVideos: options.exportVideos + }, control) + const voiceMediaMessages = mediaMessages.filter(msg => msg.localType === 34) + if (voiceMediaMessages.length > 0) { + await this.preloadVoiceWavCache(sessionId, voiceMediaMessages, control) + } + onProgress?.({ current: 20, total: 100, @@ -3475,7 +4269,9 @@ class ExportService { phase: 'exporting-media', phaseProgress: 0, phaseTotal: mediaMessages.length, - phaseLabel: `导出媒体 0/${mediaMessages.length}` + phaseLabel: `导出媒体 0/${mediaMessages.length}`, + ...this.getMediaTelemetrySnapshot(), + estimatedTotalMessages: totalMessages }) // 并行导出媒体,并发数跟随导出设置 @@ -3483,14 +4279,16 @@ class ExportService { let mediaExported = 0 await parallelLimit(mediaMessages, mediaConcurrency, async (msg) => { this.throwIfStopRequested(control) - const mediaKey = `${msg.localType}_${msg.localId}` + const mediaKey = this.getMediaCacheKey(msg) if (!mediaCache.has(mediaKey)) { const mediaItem = await this.exportMediaForMessage(msg, sessionId, mediaRootDir, mediaRelativePrefix, { exportImages: options.exportImages, exportVoices: options.exportVoices, exportVideos: options.exportVideos, exportEmojis: options.exportEmojis, - exportVoiceAsText: options.exportVoiceAsText + exportVoiceAsText: options.exportVoiceAsText, + includeVideoPoster: options.format === 'html', + dirCache: mediaDirCache }) mediaCache.set(mediaKey, mediaItem) } @@ -3503,16 +4301,19 @@ class ExportService { phase: 'exporting-media', phaseProgress: mediaExported, phaseTotal: mediaMessages.length, - phaseLabel: `导出媒体 ${mediaExported}/${mediaMessages.length}` + phaseLabel: `导出媒体 ${mediaExported}/${mediaMessages.length}`, + ...this.getMediaTelemetrySnapshot() }) } }) } // ========== 阶段2:并行语音转文字 ========== - const voiceTranscriptMap = new Map() + const voiceTranscriptMap = new Map() if (voiceMessages.length > 0) { + await this.preloadVoiceWavCache(sessionId, voiceMessages, control) + onProgress?.({ current: 40, total: 100, @@ -3520,7 +4321,8 @@ class ExportService { phase: 'exporting-voice', phaseProgress: 0, phaseTotal: voiceMessages.length, - phaseLabel: `语音转文字 0/${voiceMessages.length}` + phaseLabel: `语音转文字 0/${voiceMessages.length}`, + estimatedTotalMessages: totalMessages }) // 并行转写语音,限制 4 个并发(转写比较耗资源) @@ -3529,7 +4331,7 @@ class ExportService { await parallelLimit(voiceMessages, VOICE_CONCURRENCY, async (msg) => { this.throwIfStopRequested(control) const transcript = await this.transcribeVoice(sessionId, String(msg.localId), msg.createTime, msg.senderUsername) - voiceTranscriptMap.set(msg.localId, transcript) + voiceTranscriptMap.set(this.getStableMessageKey(msg), transcript) voiceTranscribed++ onProgress?.({ current: 40, @@ -3548,7 +4350,10 @@ class ExportService { current: 60, total: 100, currentSession: sessionInfo.displayName, - phase: 'exporting' + phase: 'exporting', + estimatedTotalMessages: totalMessages, + collectedMessages: totalMessages, + exportedMessages: 0 }) const chatLabMessages: ChatLabMessage[] = [] @@ -3591,11 +4396,11 @@ class ExportService { // 确定消息内容 let content: string | null - const mediaKey = `${msg.localType}_${msg.localId}` + const mediaKey = this.getMediaCacheKey(msg) const mediaItem = mediaCache.get(mediaKey) if (msg.localType === 34 && options.exportVoiceAsText) { // 使用预先转写的文字 - content = voiceTranscriptMap.get(msg.localId) || '[语音消息 - 转文字失败]' + content = voiceTranscriptMap.get(this.getStableMessageKey(msg)) || '[语音消息 - 转文字失败]' } else if (mediaItem && msg.localType === 3) { content = mediaItem.relativePath } else { @@ -3730,6 +4535,18 @@ class ExportService { } chatLabMessages.push(message) + if ((chatLabMessages.length % 200) === 0 || chatLabMessages.length === totalMessages) { + const exportProgress = 60 + Math.floor((chatLabMessages.length / totalMessages) * 20) + onProgress?.({ + current: exportProgress, + total: 100, + currentSession: sessionInfo.displayName, + phase: 'exporting', + estimatedTotalMessages: totalMessages, + collectedMessages: totalMessages, + exportedMessages: chatLabMessages.length + }) + } } const avatarMap = options.exportAvatars @@ -3780,7 +4597,10 @@ class ExportService { current: 80, total: 100, currentSession: sessionInfo.displayName, - phase: 'writing' + phase: 'writing', + estimatedTotalMessages: totalMessages, + collectedMessages: totalMessages, + exportedMessages: totalMessages }) if (options.format === 'chatlab-jsonl') { @@ -3799,17 +4619,21 @@ class ExportService { lines.push(JSON.stringify({ _type: 'message', ...message })) } this.throwIfStopRequested(control) - fs.writeFileSync(outputPath, lines.join('\n'), 'utf-8') + await fs.promises.writeFile(outputPath, lines.join('\n'), 'utf-8') } else { this.throwIfStopRequested(control) - fs.writeFileSync(outputPath, JSON.stringify(chatLabExport, null, 2), 'utf-8') + await fs.promises.writeFile(outputPath, JSON.stringify(chatLabExport, null, 2), 'utf-8') } onProgress?.({ current: 100, total: 100, currentSession: sessionInfo.displayName, - phase: 'complete' + phase: 'complete', + estimatedTotalMessages: totalMessages, + collectedMessages: totalMessages, + exportedMessages: totalMessages, + writtenFiles: 1 }) return { success: true } @@ -3872,9 +4696,10 @@ class ExportService { control, collectProgressReporter ) + const totalMessages = collected.rows.length // 如果没有消息,不创建文件 - if (collected.rows.length === 0) { + if (totalMessages === 0) { return { success: false, error: '该会话在指定时间范围内没有消息' } } @@ -3915,8 +4740,18 @@ class ExportService { : [] const mediaCache = new Map() + const mediaDirCache = new Set() if (mediaMessages.length > 0) { + await this.preloadMediaLookupCaches(sessionId, mediaMessages, { + exportImages: options.exportImages, + exportVideos: options.exportVideos + }, control) + const voiceMediaMessages = mediaMessages.filter(msg => msg.localType === 34) + if (voiceMediaMessages.length > 0) { + await this.preloadVoiceWavCache(sessionId, voiceMediaMessages, control) + } + onProgress?.({ current: 15, total: 100, @@ -3924,21 +4759,25 @@ class ExportService { phase: 'exporting-media', phaseProgress: 0, phaseTotal: mediaMessages.length, - phaseLabel: `导出媒体 0/${mediaMessages.length}` + phaseLabel: `导出媒体 0/${mediaMessages.length}`, + ...this.getMediaTelemetrySnapshot(), + estimatedTotalMessages: totalMessages }) const mediaConcurrency = this.getClampedConcurrency(options.exportConcurrency) let mediaExported = 0 await parallelLimit(mediaMessages, mediaConcurrency, async (msg) => { this.throwIfStopRequested(control) - const mediaKey = `${msg.localType}_${msg.localId}` + const mediaKey = this.getMediaCacheKey(msg) if (!mediaCache.has(mediaKey)) { const mediaItem = await this.exportMediaForMessage(msg, sessionId, mediaRootDir, mediaRelativePrefix, { exportImages: options.exportImages, exportVoices: options.exportVoices, exportVideos: options.exportVideos, exportEmojis: options.exportEmojis, - exportVoiceAsText: options.exportVoiceAsText + exportVoiceAsText: options.exportVoiceAsText, + includeVideoPoster: options.format === 'html', + dirCache: mediaDirCache }) mediaCache.set(mediaKey, mediaItem) } @@ -3951,16 +4790,19 @@ class ExportService { phase: 'exporting-media', phaseProgress: mediaExported, phaseTotal: mediaMessages.length, - phaseLabel: `导出媒体 ${mediaExported}/${mediaMessages.length}` + phaseLabel: `导出媒体 ${mediaExported}/${mediaMessages.length}`, + ...this.getMediaTelemetrySnapshot() }) } }) } // ========== 阶段2:并行语音转文字 ========== - const voiceTranscriptMap = new Map() + const voiceTranscriptMap = new Map() if (voiceMessages.length > 0) { + await this.preloadVoiceWavCache(sessionId, voiceMessages, control) + onProgress?.({ current: 35, total: 100, @@ -3968,7 +4810,8 @@ class ExportService { phase: 'exporting-voice', phaseProgress: 0, phaseTotal: voiceMessages.length, - phaseLabel: `语音转文字 0/${voiceMessages.length}` + phaseLabel: `语音转文字 0/${voiceMessages.length}`, + estimatedTotalMessages: totalMessages }) const VOICE_CONCURRENCY = 4 @@ -3976,7 +4819,7 @@ class ExportService { await parallelLimit(voiceMessages, VOICE_CONCURRENCY, async (msg) => { this.throwIfStopRequested(control) const transcript = await this.transcribeVoice(sessionId, String(msg.localId), msg.createTime, msg.senderUsername) - voiceTranscriptMap.set(msg.localId, transcript) + voiceTranscriptMap.set(this.getStableMessageKey(msg), transcript) voiceTranscribed++ onProgress?.({ current: 35, @@ -4007,7 +4850,10 @@ class ExportService { current: 55, total: 100, currentSession: sessionInfo.displayName, - phase: 'exporting' + phase: 'exporting', + estimatedTotalMessages: totalMessages, + collectedMessages: totalMessages, + exportedMessages: 0 }) const allMessages: any[] = [] @@ -4030,11 +4876,11 @@ class ExportService { const source = sourceMatch ? sourceMatch[0] : '' let content: string | null - const mediaKey = `${msg.localType}_${msg.localId}` + const mediaKey = this.getMediaCacheKey(msg) const mediaItem = mediaCache.get(mediaKey) if (msg.localType === 34 && options.exportVoiceAsText) { - content = voiceTranscriptMap.get(msg.localId) || '[语音消息 - 转文字失败]' + content = voiceTranscriptMap.get(this.getStableMessageKey(msg)) || '[语音消息 - 转文字失败]' } else if (mediaItem) { content = mediaItem.relativePath } else { @@ -4124,6 +4970,18 @@ class ExportService { allMessages.push(msgObj) if (msg.createTime < lastCreateTime) needSort = true lastCreateTime = msg.createTime + if ((allMessages.length % 200) === 0 || allMessages.length === totalMessages) { + const exportProgress = 55 + Math.floor((allMessages.length / totalMessages) * 15) + onProgress?.({ + current: exportProgress, + total: 100, + currentSession: sessionInfo.displayName, + phase: 'exporting', + estimatedTotalMessages: totalMessages, + collectedMessages: totalMessages, + exportedMessages: allMessages.length + }) + } } if (transferCandidates.length > 0) { @@ -4172,7 +5030,10 @@ class ExportService { current: 70, total: 100, currentSession: sessionInfo.displayName, - phase: 'writing' + phase: 'writing', + estimatedTotalMessages: totalMessages, + collectedMessages: totalMessages, + exportedMessages: totalMessages }) // 获取会话的昵称和备注信息 @@ -4421,7 +5282,7 @@ class ExportService { } this.throwIfStopRequested(control) - fs.writeFileSync(outputPath, JSON.stringify(arkmeExport, null, 2), 'utf-8') + await fs.promises.writeFile(outputPath, JSON.stringify(arkmeExport, null, 2), 'utf-8') } else { const detailedExport: any = { weflow, @@ -4444,14 +5305,18 @@ class ExportService { } this.throwIfStopRequested(control) - fs.writeFileSync(outputPath, JSON.stringify(detailedExport, null, 2), 'utf-8') + await fs.promises.writeFile(outputPath, JSON.stringify(detailedExport, null, 2), 'utf-8') } onProgress?.({ current: 100, total: 100, currentSession: sessionInfo.displayName, - phase: 'complete' + phase: 'complete', + estimatedTotalMessages: totalMessages, + collectedMessages: totalMessages, + exportedMessages: totalMessages, + writtenFiles: 1 }) return { success: true } @@ -4519,9 +5384,10 @@ class ExportService { control, collectProgressReporter ) + const totalMessages = collected.rows.length // 如果没有消息,不创建文件 - if (collected.rows.length === 0) { + if (totalMessages === 0) { return { success: false, error: '该会话在指定时间范围内没有消息' } } @@ -4548,7 +5414,10 @@ class ExportService { current: 30, total: 100, currentSession: sessionInfo.displayName, - phase: 'exporting' + phase: 'exporting', + estimatedTotalMessages: totalMessages, + collectedMessages: totalMessages, + exportedMessages: 0 }) // 创建 Excel 工作簿 @@ -4685,8 +5554,18 @@ class ExportService { : [] const mediaCache = new Map() + const mediaDirCache = new Set() if (mediaMessages.length > 0) { + await this.preloadMediaLookupCaches(sessionId, mediaMessages, { + exportImages: options.exportImages, + exportVideos: options.exportVideos + }, control) + const voiceMediaMessages = mediaMessages.filter(msg => msg.localType === 34) + if (voiceMediaMessages.length > 0) { + await this.preloadVoiceWavCache(sessionId, voiceMediaMessages, control) + } + onProgress?.({ current: 35, total: 100, @@ -4694,21 +5573,25 @@ class ExportService { phase: 'exporting-media', phaseProgress: 0, phaseTotal: mediaMessages.length, - phaseLabel: `导出媒体 0/${mediaMessages.length}` + phaseLabel: `导出媒体 0/${mediaMessages.length}`, + ...this.getMediaTelemetrySnapshot(), + estimatedTotalMessages: totalMessages }) const mediaConcurrency = this.getClampedConcurrency(options.exportConcurrency) let mediaExported = 0 await parallelLimit(mediaMessages, mediaConcurrency, async (msg) => { this.throwIfStopRequested(control) - const mediaKey = `${msg.localType}_${msg.localId}` + const mediaKey = this.getMediaCacheKey(msg) if (!mediaCache.has(mediaKey)) { const mediaItem = await this.exportMediaForMessage(msg, sessionId, mediaRootDir, mediaRelativePrefix, { exportImages: options.exportImages, exportVoices: options.exportVoices, exportVideos: options.exportVideos, exportEmojis: options.exportEmojis, - exportVoiceAsText: options.exportVoiceAsText + exportVoiceAsText: options.exportVoiceAsText, + includeVideoPoster: options.format === 'html', + dirCache: mediaDirCache }) mediaCache.set(mediaKey, mediaItem) } @@ -4721,16 +5604,19 @@ class ExportService { phase: 'exporting-media', phaseProgress: mediaExported, phaseTotal: mediaMessages.length, - phaseLabel: `导出媒体 ${mediaExported}/${mediaMessages.length}` + phaseLabel: `导出媒体 ${mediaExported}/${mediaMessages.length}`, + ...this.getMediaTelemetrySnapshot() }) } }) } // ========== 并行预处理:语音转文字 ========== - const voiceTranscriptMap = new Map() + const voiceTranscriptMap = new Map() if (voiceMessages.length > 0) { + await this.preloadVoiceWavCache(sessionId, voiceMessages, control) + onProgress?.({ current: 50, total: 100, @@ -4738,7 +5624,8 @@ class ExportService { phase: 'exporting-voice', phaseProgress: 0, phaseTotal: voiceMessages.length, - phaseLabel: `语音转文字 0/${voiceMessages.length}` + phaseLabel: `语音转文字 0/${voiceMessages.length}`, + estimatedTotalMessages: totalMessages }) const VOICE_CONCURRENCY = 4 @@ -4746,7 +5633,7 @@ class ExportService { await parallelLimit(voiceMessages, VOICE_CONCURRENCY, async (msg) => { this.throwIfStopRequested(control) const transcript = await this.transcribeVoice(sessionId, String(msg.localId), msg.createTime, msg.senderUsername) - voiceTranscriptMap.set(msg.localId, transcript) + voiceTranscriptMap.set(this.getStableMessageKey(msg), transcript) voiceTranscribed++ onProgress?.({ current: 50, @@ -4760,15 +5647,41 @@ class ExportService { }) } + const shouldUseStreamingWriter = totalMessages > 20000 + if (shouldUseStreamingWriter) { + return this.exportSessionToExcelStreaming({ + outputPath, + options, + sessionId, + sessionInfo, + myInfo, + cleanedMyWxid, + rawMyWxid, + isGroup, + sortedMessages, + mediaCache, + voiceTranscriptMap, + getContactCached, + groupNicknamesMap, + onProgress, + control, + totalMessages + }) + } + onProgress?.({ current: 65, total: 100, currentSession: sessionInfo.displayName, - phase: 'exporting' + phase: 'exporting', + estimatedTotalMessages: totalMessages, + collectedMessages: totalMessages, + exportedMessages: 0 }) // ========== 写入 Excel 行 ========== - for (let i = 0; i < sortedMessages.length; i++) { + const senderProfileCache = new Map() + for (let i = 0; i < totalMessages; i++) { if ((i & 0x7f) === 0) { this.throwIfStopRequested(control) } @@ -4782,14 +5695,19 @@ class ExportService { let senderGroupNickname: string = '' // 群昵称 if (isGroup) { - const senderProfile = await this.resolveExportDisplayProfile( - msg.isSend ? cleanedMyWxid : (msg.senderUsername || cleanedMyWxid), - options.displayNamePreference, - getContactCached, - groupNicknamesMap, - msg.isSend ? (myInfo.displayName || cleanedMyWxid) : (msg.senderUsername || ''), - msg.isSend ? [rawMyWxid, cleanedMyWxid] : [] - ) + const senderProfileKey = `${msg.isSend ? cleanedMyWxid : (msg.senderUsername || cleanedMyWxid)}::${msg.isSend ? '1' : '0'}` + let senderProfile = senderProfileCache.get(senderProfileKey) + if (!senderProfile) { + senderProfile = await this.resolveExportDisplayProfile( + msg.isSend ? cleanedMyWxid : (msg.senderUsername || cleanedMyWxid), + options.displayNamePreference, + getContactCached, + groupNicknamesMap, + msg.isSend ? (myInfo.displayName || cleanedMyWxid) : (msg.senderUsername || ''), + msg.isSend ? [rawMyWxid, cleanedMyWxid] : [] + ) + senderProfileCache.set(senderProfileKey, senderProfile) + } senderWxid = senderProfile.wxid senderNickname = senderProfile.nickname senderRemark = senderProfile.remark @@ -4819,7 +5737,7 @@ class ExportService { const row = worksheet.getRow(currentRow) row.height = 24 - const mediaKey = `${msg.localType}_${msg.localId}` + const mediaKey = this.getMediaCacheKey(msg) const mediaItem = mediaCache.get(mediaKey) const shouldUseTranscript = msg.localType === 34 && options.exportVoiceAsText const contentValue = shouldUseTranscript @@ -4827,7 +5745,7 @@ class ExportService { msg.content, msg.localType, options, - voiceTranscriptMap.get(msg.localId), + voiceTranscriptMap.get(this.getStableMessageKey(msg)), cleanedMyWxid, msg.senderUsername, msg.isSend @@ -4837,7 +5755,7 @@ class ExportService { msg.content, msg.localType, options, - voiceTranscriptMap.get(msg.localId), + voiceTranscriptMap.get(this.getStableMessageKey(msg)), cleanedMyWxid, msg.senderUsername, msg.isSend @@ -4883,14 +5801,6 @@ class ExportService { worksheet.getCell(currentRow, 9).value = enrichedContentValue } - // 设置每个单元格的样式 - const maxColumns = useCompactColumns ? 5 : 9 - for (let col = 1; col <= maxColumns; col++) { - const cell = worksheet.getCell(currentRow, col) - cell.font = { name: 'Calibri', size: 11 } - cell.alignment = { vertical: 'middle', wrapText: false } - } - currentRow++ // 每处理 100 条消息报告一次进度 @@ -4900,7 +5810,10 @@ class ExportService { current: progress, total: 100, currentSession: sessionInfo.displayName, - phase: 'exporting' + phase: 'exporting', + estimatedTotalMessages: totalMessages, + collectedMessages: totalMessages, + exportedMessages: i + 1 }) } } @@ -4909,7 +5822,10 @@ class ExportService { current: 90, total: 100, currentSession: sessionInfo.displayName, - phase: 'writing' + phase: 'writing', + estimatedTotalMessages: totalMessages, + collectedMessages: totalMessages, + exportedMessages: totalMessages }) // 写入文件 @@ -4920,7 +5836,11 @@ class ExportService { current: 100, total: 100, currentSession: sessionInfo.displayName, - phase: 'complete' + phase: 'complete', + estimatedTotalMessages: totalMessages, + collectedMessages: totalMessages, + exportedMessages: totalMessages, + writtenFiles: 1 }) return { success: true } @@ -4939,6 +5859,236 @@ class ExportService { } } + private async exportSessionToExcelStreaming(params: { + outputPath: string + options: ExportOptions + sessionId: string + sessionInfo: { displayName: string } + myInfo: { displayName: string } + cleanedMyWxid: string + rawMyWxid: string + isGroup: boolean + sortedMessages: any[] + mediaCache: Map + voiceTranscriptMap: Map + getContactCached: (username: string) => Promise<{ success: boolean; contact?: any; error?: string }> + groupNicknamesMap: Map + onProgress?: (progress: ExportProgress) => void + control?: ExportTaskControl + totalMessages: number + }): Promise<{ success: boolean; error?: string }> { + const { + outputPath, + options, + sessionId, + sessionInfo, + myInfo, + cleanedMyWxid, + rawMyWxid, + isGroup, + sortedMessages, + mediaCache, + voiceTranscriptMap, + getContactCached, + groupNicknamesMap, + onProgress, + control, + totalMessages + } = params + + try { + const workbook = new ExcelJS.stream.xlsx.WorkbookWriter({ + filename: outputPath, + useStyles: true, + useSharedStrings: false + }) + const worksheet = workbook.addWorksheet('聊天记录') + const useCompactColumns = options.excelCompactColumns === true + const senderProfileCache = new Map() + + worksheet.columns = useCompactColumns + ? [ + { width: 8 }, + { width: 20 }, + { width: 18 }, + { width: 12 }, + { width: 50 } + ] + : [ + { width: 8 }, + { width: 20 }, + { width: 18 }, + { width: 25 }, + { width: 18 }, + { width: 18 }, + { width: 15 }, + { width: 12 }, + { width: 50 } + ] + + const appendRow = (values: any[]) => { + const row = worksheet.addRow(values) + row.commit() + } + + appendRow(['会话信息']) + appendRow(['微信ID', sessionId, '昵称', sessionInfo.displayName || sessionId]) + appendRow(['导出工具', 'WeFlow', '导出时间', this.formatTimestamp(Math.floor(Date.now() / 1000))]) + appendRow([]) + appendRow(useCompactColumns + ? ['序号', '时间', '发送者身份', '消息类型', '内容'] + : ['序号', '时间', '发送者昵称', '发送者微信ID', '发送者备注', '群昵称', '发送者身份', '消息类型', '内容']) + + for (let i = 0; i < totalMessages; i++) { + if ((i & 0x7f) === 0) this.throwIfStopRequested(control) + const msg = sortedMessages[i] + + let senderRole: string + let senderWxid: string + let senderNickname: string + let senderRemark = '' + let senderGroupNickname = '' + + if (isGroup) { + const senderProfileKey = `${msg.isSend ? cleanedMyWxid : (msg.senderUsername || cleanedMyWxid)}::${msg.isSend ? '1' : '0'}` + let senderProfile = senderProfileCache.get(senderProfileKey) + if (!senderProfile) { + senderProfile = await this.resolveExportDisplayProfile( + msg.isSend ? cleanedMyWxid : (msg.senderUsername || cleanedMyWxid), + options.displayNamePreference, + getContactCached, + groupNicknamesMap, + msg.isSend ? (myInfo.displayName || cleanedMyWxid) : (msg.senderUsername || ''), + msg.isSend ? [rawMyWxid, cleanedMyWxid] : [] + ) + senderProfileCache.set(senderProfileKey, senderProfile) + } + senderWxid = senderProfile.wxid + senderNickname = senderProfile.nickname + senderRemark = senderProfile.remark + senderGroupNickname = senderProfile.groupNickname + senderRole = senderProfile.displayName + } else if (msg.isSend) { + senderRole = '我' + senderWxid = cleanedMyWxid + senderNickname = myInfo.displayName || cleanedMyWxid + } else { + senderWxid = sessionId + const contactDetail = await getContactCached(sessionId) + if (contactDetail.success && contactDetail.contact) { + senderNickname = contactDetail.contact.nickName || sessionId + senderRemark = contactDetail.contact.remark || '' + senderRole = senderRemark || senderNickname + } else { + senderNickname = sessionInfo.displayName || sessionId + senderRole = senderNickname + } + } + + const mediaKey = this.getMediaCacheKey(msg) + const mediaItem = mediaCache.get(mediaKey) + const shouldUseTranscript = msg.localType === 34 && options.exportVoiceAsText + const contentValue = shouldUseTranscript + ? this.formatPlainExportContent( + msg.content, + msg.localType, + options, + voiceTranscriptMap.get(this.getStableMessageKey(msg)), + cleanedMyWxid, + msg.senderUsername, + msg.isSend + ) + : (mediaItem?.relativePath + || this.formatPlainExportContent( + msg.content, + msg.localType, + options, + voiceTranscriptMap.get(this.getStableMessageKey(msg)), + cleanedMyWxid, + msg.senderUsername, + msg.isSend + )) + + let enrichedContentValue = contentValue + if (this.isTransferExportContent(contentValue) && msg.content) { + const transferDesc = await this.resolveTransferDesc( + msg.content, + cleanedMyWxid, + groupNicknamesMap, + async (username) => { + const c = await getContactCached(username) + if (c.success && c.contact) { + return c.contact.remark || c.contact.nickName || c.contact.alias || username + } + return username + } + ) + if (transferDesc) { + enrichedContentValue = this.appendTransferDesc(contentValue, transferDesc) + } + } + + appendRow(useCompactColumns + ? [ + i + 1, + this.formatTimestamp(msg.createTime), + senderRole, + this.getMessageTypeName(msg.localType), + enrichedContentValue + ] + : [ + i + 1, + this.formatTimestamp(msg.createTime), + senderNickname, + senderWxid, + senderRemark, + senderGroupNickname, + senderRole, + this.getMessageTypeName(msg.localType), + enrichedContentValue + ]) + + if ((i + 1) % 200 === 0) { + onProgress?.({ + current: 65 + Math.floor((i + 1) / totalMessages * 25), + total: 100, + currentSession: sessionInfo.displayName, + phase: 'writing', + estimatedTotalMessages: totalMessages, + collectedMessages: totalMessages, + exportedMessages: i + 1 + }) + } + } + + worksheet.commit() + await workbook.commit() + + onProgress?.({ + current: 100, + total: 100, + currentSession: sessionInfo.displayName, + phase: 'complete', + estimatedTotalMessages: totalMessages, + collectedMessages: totalMessages, + exportedMessages: totalMessages, + writtenFiles: 1 + }) + + return { success: true } + } catch (e) { + if (this.isStopError(e)) { + return { success: false, error: '导出任务已停止' } + } + if (e instanceof Error) { + if (e.message.includes('EBUSY') || e.message.includes('resource busy') || e.message.includes('locked')) { + return { success: false, error: '文件已经打开,请关闭后再导出' } + } + } + return { success: false, error: String(e) } + } + } + /** * 确保语音转写模型已下载 */ @@ -5024,9 +6174,10 @@ class ExportService { control, collectProgressReporter ) + const totalMessages = collected.rows.length // 如果没有消息,不创建文件 - if (collected.rows.length === 0) { + if (totalMessages === 0) { return { success: false, error: '该会话在指定时间范围内没有消息' } } @@ -5076,8 +6227,18 @@ class ExportService { : [] const mediaCache = new Map() + const mediaDirCache = new Set() if (mediaMessages.length > 0) { + await this.preloadMediaLookupCaches(sessionId, mediaMessages, { + exportImages: options.exportImages, + exportVideos: options.exportVideos + }, control) + const voiceMediaMessages = mediaMessages.filter(msg => msg.localType === 34) + if (voiceMediaMessages.length > 0) { + await this.preloadVoiceWavCache(sessionId, voiceMediaMessages, control) + } + onProgress?.({ current: 25, total: 100, @@ -5085,21 +6246,25 @@ class ExportService { phase: 'exporting-media', phaseProgress: 0, phaseTotal: mediaMessages.length, - phaseLabel: `导出媒体 0/${mediaMessages.length}` + phaseLabel: `导出媒体 0/${mediaMessages.length}`, + ...this.getMediaTelemetrySnapshot(), + estimatedTotalMessages: totalMessages }) const mediaConcurrency = this.getClampedConcurrency(options.exportConcurrency) let mediaExported = 0 await parallelLimit(mediaMessages, mediaConcurrency, async (msg) => { this.throwIfStopRequested(control) - const mediaKey = `${msg.localType}_${msg.localId}` + const mediaKey = this.getMediaCacheKey(msg) if (!mediaCache.has(mediaKey)) { const mediaItem = await this.exportMediaForMessage(msg, sessionId, mediaRootDir, mediaRelativePrefix, { exportImages: options.exportImages, exportVoices: options.exportVoices, exportVideos: options.exportVideos, exportEmojis: options.exportEmojis, - exportVoiceAsText: options.exportVoiceAsText + exportVoiceAsText: options.exportVoiceAsText, + includeVideoPoster: options.format === 'html', + dirCache: mediaDirCache }) mediaCache.set(mediaKey, mediaItem) } @@ -5112,15 +6277,18 @@ class ExportService { phase: 'exporting-media', phaseProgress: mediaExported, phaseTotal: mediaMessages.length, - phaseLabel: `导出媒体 ${mediaExported}/${mediaMessages.length}` + phaseLabel: `导出媒体 ${mediaExported}/${mediaMessages.length}`, + ...this.getMediaTelemetrySnapshot() }) } }) } - const voiceTranscriptMap = new Map() + const voiceTranscriptMap = new Map() if (voiceMessages.length > 0) { + await this.preloadVoiceWavCache(sessionId, voiceMessages, control) + onProgress?.({ current: 45, total: 100, @@ -5128,7 +6296,8 @@ class ExportService { phase: 'exporting-voice', phaseProgress: 0, phaseTotal: voiceMessages.length, - phaseLabel: `语音转文字 0/${voiceMessages.length}` + phaseLabel: `语音转文字 0/${voiceMessages.length}`, + estimatedTotalMessages: totalMessages }) const VOICE_CONCURRENCY = 4 @@ -5136,7 +6305,7 @@ class ExportService { await parallelLimit(voiceMessages, VOICE_CONCURRENCY, async (msg) => { this.throwIfStopRequested(control) const transcript = await this.transcribeVoice(sessionId, String(msg.localId), msg.createTime, msg.senderUsername) - voiceTranscriptMap.set(msg.localId, transcript) + voiceTranscriptMap.set(this.getStableMessageKey(msg), transcript) voiceTranscribed++ onProgress?.({ current: 45, @@ -5154,17 +6323,21 @@ class ExportService { current: 60, total: 100, currentSession: sessionInfo.displayName, - phase: 'exporting' + phase: 'exporting', + estimatedTotalMessages: totalMessages, + collectedMessages: totalMessages, + exportedMessages: 0 }) const lines: string[] = [] + const senderProfileCache = new Map() - for (let i = 0; i < sortedMessages.length; i++) { + for (let i = 0; i < totalMessages; i++) { if ((i & 0x7f) === 0) { this.throwIfStopRequested(control) } const msg = sortedMessages[i] - const mediaKey = `${msg.localType}_${msg.localId}` + const mediaKey = this.getMediaCacheKey(msg) const mediaItem = mediaCache.get(mediaKey) const shouldUseTranscript = msg.localType === 34 && options.exportVoiceAsText const contentValue = shouldUseTranscript @@ -5172,7 +6345,7 @@ class ExportService { msg.content, msg.localType, options, - voiceTranscriptMap.get(msg.localId), + voiceTranscriptMap.get(this.getStableMessageKey(msg)), cleanedMyWxid, msg.senderUsername, msg.isSend @@ -5182,7 +6355,7 @@ class ExportService { msg.content, msg.localType, options, - voiceTranscriptMap.get(msg.localId), + voiceTranscriptMap.get(this.getStableMessageKey(msg)), cleanedMyWxid, msg.senderUsername, msg.isSend @@ -5214,14 +6387,19 @@ class ExportService { let senderRemark = '' if (isGroup) { - const senderProfile = await this.resolveExportDisplayProfile( - msg.isSend ? cleanedMyWxid : (msg.senderUsername || cleanedMyWxid), - options.displayNamePreference, - getContactCached, - groupNicknamesMap, - msg.isSend ? (myInfo.displayName || cleanedMyWxid) : (msg.senderUsername || ''), - msg.isSend ? [rawMyWxid, cleanedMyWxid] : [] - ) + const senderProfileKey = `${msg.isSend ? cleanedMyWxid : (msg.senderUsername || cleanedMyWxid)}::${msg.isSend ? '1' : '0'}` + let senderProfile = senderProfileCache.get(senderProfileKey) + if (!senderProfile) { + senderProfile = await this.resolveExportDisplayProfile( + msg.isSend ? cleanedMyWxid : (msg.senderUsername || cleanedMyWxid), + options.displayNamePreference, + getContactCached, + groupNicknamesMap, + msg.isSend ? (myInfo.displayName || cleanedMyWxid) : (msg.senderUsername || ''), + msg.isSend ? [rawMyWxid, cleanedMyWxid] : [] + ) + senderProfileCache.set(senderProfileKey, senderProfile) + } senderWxid = senderProfile.wxid senderNickname = senderProfile.nickname senderRemark = senderProfile.remark @@ -5253,7 +6431,10 @@ class ExportService { current: progress, total: 100, currentSession: sessionInfo.displayName, - phase: 'exporting' + phase: 'exporting', + estimatedTotalMessages: totalMessages, + collectedMessages: totalMessages, + exportedMessages: i + 1 }) } } @@ -5262,17 +6443,24 @@ class ExportService { current: 92, total: 100, currentSession: sessionInfo.displayName, - phase: 'writing' + phase: 'writing', + estimatedTotalMessages: totalMessages, + collectedMessages: totalMessages, + exportedMessages: totalMessages }) this.throwIfStopRequested(control) - fs.writeFileSync(outputPath, lines.join('\n'), 'utf-8') + await fs.promises.writeFile(outputPath, lines.join('\n'), 'utf-8') onProgress?.({ current: 100, total: 100, currentSession: sessionInfo.displayName, - phase: 'complete' + phase: 'complete', + estimatedTotalMessages: totalMessages, + collectedMessages: totalMessages, + exportedMessages: totalMessages, + writtenFiles: 1 }) return { success: true } @@ -5334,7 +6522,8 @@ class ExportService { control, collectProgressReporter ) - if (collected.rows.length === 0) { + const totalMessages = collected.rows.length + if (totalMessages === 0) { return { success: false, error: '该会话在指定时间范围内没有消息' } } @@ -5383,8 +6572,18 @@ class ExportService { : [] const mediaCache = new Map() + const mediaDirCache = new Set() if (mediaMessages.length > 0) { + await this.preloadMediaLookupCaches(sessionId, mediaMessages, { + exportImages: options.exportImages, + exportVideos: options.exportVideos + }, control) + const voiceMediaMessages = mediaMessages.filter(msg => msg.localType === 34) + if (voiceMediaMessages.length > 0) { + await this.preloadVoiceWavCache(sessionId, voiceMediaMessages, control) + } + onProgress?.({ current: 25, total: 100, @@ -5392,21 +6591,25 @@ class ExportService { phase: 'exporting-media', phaseProgress: 0, phaseTotal: mediaMessages.length, - phaseLabel: `导出媒体 0/${mediaMessages.length}` + phaseLabel: `导出媒体 0/${mediaMessages.length}`, + ...this.getMediaTelemetrySnapshot(), + estimatedTotalMessages: totalMessages }) const mediaConcurrency = this.getClampedConcurrency(options.exportConcurrency) let mediaExported = 0 await parallelLimit(mediaMessages, mediaConcurrency, async (msg) => { this.throwIfStopRequested(control) - const mediaKey = `${msg.localType}_${msg.localId}` + const mediaKey = this.getMediaCacheKey(msg) if (!mediaCache.has(mediaKey)) { const mediaItem = await this.exportMediaForMessage(msg, sessionId, mediaRootDir, mediaRelativePrefix, { exportImages: options.exportImages, exportVoices: options.exportVoices, exportVideos: options.exportVideos, exportEmojis: options.exportEmojis, - exportVoiceAsText: options.exportVoiceAsText + exportVoiceAsText: options.exportVoiceAsText, + includeVideoPoster: options.format === 'html', + dirCache: mediaDirCache }) mediaCache.set(mediaKey, mediaItem) } @@ -5419,15 +6622,18 @@ class ExportService { phase: 'exporting-media', phaseProgress: mediaExported, phaseTotal: mediaMessages.length, - phaseLabel: `导出媒体 ${mediaExported}/${mediaMessages.length}` + phaseLabel: `导出媒体 ${mediaExported}/${mediaMessages.length}`, + ...this.getMediaTelemetrySnapshot() }) } }) } - const voiceTranscriptMap = new Map() + const voiceTranscriptMap = new Map() if (voiceMessages.length > 0) { + await this.preloadVoiceWavCache(sessionId, voiceMessages, control) + onProgress?.({ current: 45, total: 100, @@ -5435,7 +6641,8 @@ class ExportService { phase: 'exporting-voice', phaseProgress: 0, phaseTotal: voiceMessages.length, - phaseLabel: `语音转文字 0/${voiceMessages.length}` + phaseLabel: `语音转文字 0/${voiceMessages.length}`, + estimatedTotalMessages: totalMessages }) const VOICE_CONCURRENCY = 4 @@ -5443,7 +6650,7 @@ class ExportService { await parallelLimit(voiceMessages, VOICE_CONCURRENCY, async (msg) => { this.throwIfStopRequested(control) const transcript = await this.transcribeVoice(sessionId, String(msg.localId), msg.createTime, msg.senderUsername) - voiceTranscriptMap.set(msg.localId, transcript) + voiceTranscriptMap.set(this.getStableMessageKey(msg), transcript) voiceTranscribed++ onProgress?.({ current: 45, @@ -5461,18 +6668,22 @@ class ExportService { current: 60, total: 100, currentSession: sessionInfo.displayName, - phase: 'exporting' + phase: 'exporting', + estimatedTotalMessages: totalMessages, + collectedMessages: totalMessages, + exportedMessages: 0 }) const lines: string[] = [] lines.push('id,MsgSvrID,type_name,is_sender,talker,msg,src,CreateTime') + const senderProfileCache = new Map() - for (let i = 0; i < sortedMessages.length; i++) { + for (let i = 0; i < totalMessages; i++) { if ((i & 0x7f) === 0) { this.throwIfStopRequested(control) } const msg = sortedMessages[i] - const mediaKey = `${msg.localType}_${msg.localId}` + const mediaKey = this.getMediaCacheKey(msg) const mediaItem = mediaCache.get(mediaKey) || null const typeName = this.getWeCloneTypeName(msg.localType, msg.content || '') @@ -5485,14 +6696,19 @@ class ExportService { let talker = myInfo.displayName || '我' if (isGroup) { - const senderProfile = await this.resolveExportDisplayProfile( - msg.isSend ? cleanedMyWxid : senderWxid, - options.displayNamePreference, - getContactCached, - groupNicknamesMap, - msg.isSend ? (myInfo.displayName || cleanedMyWxid) : senderWxid, - msg.isSend ? [rawMyWxid, cleanedMyWxid] : [] - ) + const senderProfileKey = `${msg.isSend ? cleanedMyWxid : senderWxid}::${msg.isSend ? '1' : '0'}` + let senderProfile = senderProfileCache.get(senderProfileKey) + if (!senderProfile) { + senderProfile = await this.resolveExportDisplayProfile( + msg.isSend ? cleanedMyWxid : senderWxid, + options.displayNamePreference, + getContactCached, + groupNicknamesMap, + msg.isSend ? (myInfo.displayName || cleanedMyWxid) : senderWxid, + msg.isSend ? [rawMyWxid, cleanedMyWxid] : [] + ) + senderProfileCache.set(senderProfileKey, senderProfile) + } talker = senderProfile.displayName } else if (!msg.isSend) { const contactDetail = await getContactCached(senderWxid) @@ -5515,7 +6731,7 @@ class ExportService { } const msgText = msg.localType === 34 && options.exportVoiceAsText - ? (voiceTranscriptMap.get(msg.localId) || '[语音消息 - 转文字失败]') + ? (voiceTranscriptMap.get(this.getStableMessageKey(msg)) || '[语音消息 - 转文字失败]') : (this.parseMessageContent( msg.content, msg.localType, @@ -5546,7 +6762,10 @@ class ExportService { current: progress, total: 100, currentSession: sessionInfo.displayName, - phase: 'exporting' + phase: 'exporting', + estimatedTotalMessages: totalMessages, + collectedMessages: totalMessages, + exportedMessages: i + 1 }) } } @@ -5555,17 +6774,24 @@ class ExportService { current: 92, total: 100, currentSession: sessionInfo.displayName, - phase: 'writing' + phase: 'writing', + estimatedTotalMessages: totalMessages, + collectedMessages: totalMessages, + exportedMessages: totalMessages }) this.throwIfStopRequested(control) - fs.writeFileSync(outputPath, `\uFEFF${lines.join('\r\n')}`, 'utf-8') + await fs.promises.writeFile(outputPath, `\uFEFF${lines.join('\r\n')}`, 'utf-8') onProgress?.({ current: 100, total: 100, currentSession: sessionInfo.displayName, - phase: 'complete' + phase: 'complete', + estimatedTotalMessages: totalMessages, + collectedMessages: totalMessages, + exportedMessages: totalMessages, + writtenFiles: 1 }) return { success: true } @@ -5763,6 +6989,15 @@ class ExportService { const mediaCache = new Map() if (mediaMessages.length > 0) { + await this.preloadMediaLookupCaches(sessionId, mediaMessages, { + exportImages: options.exportImages, + exportVideos: options.exportVideos + }, control) + const voiceMediaMessages = mediaMessages.filter(msg => msg.localType === 34) + if (voiceMediaMessages.length > 0) { + await this.preloadVoiceWavCache(sessionId, voiceMediaMessages, control) + } + onProgress?.({ current: 20, total: 100, @@ -5770,22 +7005,26 @@ class ExportService { phase: 'exporting-media', phaseProgress: 0, phaseTotal: mediaMessages.length, - phaseLabel: `导出媒体 0/${mediaMessages.length}` + phaseLabel: `导出媒体 0/${mediaMessages.length}`, + ...this.getMediaTelemetrySnapshot(), + estimatedTotalMessages: totalMessages }) const MEDIA_CONCURRENCY = 6 let mediaExported = 0 await parallelLimit(mediaMessages, MEDIA_CONCURRENCY, async (msg) => { this.throwIfStopRequested(control) - const mediaKey = `${msg.localType}_${msg.localId}` + const mediaKey = this.getMediaCacheKey(msg) if (!mediaCache.has(mediaKey)) { const mediaItem = await this.exportMediaForMessage(msg, sessionId, mediaRootDir, mediaRelativePrefix, { exportImages: options.exportImages, exportVoices: options.exportVoices, exportEmojis: options.exportEmojis, exportVoiceAsText: options.exportVoiceAsText, + includeVideoPoster: options.format === 'html', includeVoiceWithTranscript: true, - exportVideos: options.exportVideos + exportVideos: options.exportVideos, + dirCache: mediaDirCache }) mediaCache.set(mediaKey, mediaItem) } @@ -5798,7 +7037,8 @@ class ExportService { phase: 'exporting-media', phaseProgress: mediaExported, phaseTotal: mediaMessages.length, - phaseLabel: `导出媒体 ${mediaExported}/${mediaMessages.length}` + phaseLabel: `导出媒体 ${mediaExported}/${mediaMessages.length}`, + ...this.getMediaTelemetrySnapshot() }) } }) @@ -5808,9 +7048,11 @@ class ExportService { const voiceMessages = useVoiceTranscript ? sortedMessages.filter(msg => msg.localType === 34) : [] - const voiceTranscriptMap = new Map() + const voiceTranscriptMap = new Map() if (voiceMessages.length > 0) { + await this.preloadVoiceWavCache(sessionId, voiceMessages, control) + onProgress?.({ current: 40, total: 100, @@ -5818,7 +7060,8 @@ class ExportService { phase: 'exporting-voice', phaseProgress: 0, phaseTotal: voiceMessages.length, - phaseLabel: `语音转文字 0/${voiceMessages.length}` + phaseLabel: `语音转文字 0/${voiceMessages.length}`, + estimatedTotalMessages: totalMessages }) const VOICE_CONCURRENCY = 4 @@ -5826,7 +7069,7 @@ class ExportService { await parallelLimit(voiceMessages, VOICE_CONCURRENCY, async (msg) => { this.throwIfStopRequested(control) const transcript = await this.transcribeVoice(sessionId, String(msg.localId), msg.createTime, msg.senderUsername) - voiceTranscriptMap.set(msg.localId, transcript) + voiceTranscriptMap.set(this.getStableMessageKey(msg), transcript) voiceTranscribed++ onProgress?.({ current: 40, @@ -5858,7 +7101,10 @@ class ExportService { current: 60, total: 100, currentSession: sessionInfo.displayName, - phase: 'writing' + phase: 'writing', + estimatedTotalMessages: totalMessages, + collectedMessages: totalMessages, + exportedMessages: 0 }) // ================= BEGIN STREAM WRITING ================= @@ -5919,6 +7165,7 @@ class ExportService { // Pre-build avatar HTML lookup to avoid per-message rebuilds const avatarHtmlCache = new Map() + const senderProfileCache = new Map() const getAvatarHtml = (username: string, name: string): string => { const cached = avatarHtmlCache.get(username) if (cached !== undefined) return cached @@ -5934,28 +7181,41 @@ class ExportService { const WRITE_BATCH = 100 let writeBuf: string[] = [] - for (let i = 0; i < sortedMessages.length; i++) { + for (let i = 0; i < totalMessages; i++) { if ((i & 0x7f) === 0) { this.throwIfStopRequested(control) } const msg = sortedMessages[i] - const mediaKey = `${msg.localType}_${msg.localId}` + const mediaKey = this.getMediaCacheKey(msg) const mediaItem = mediaCache.get(mediaKey) || null const isSenderMe = msg.isSend const senderInfo = collected.memberSet.get(msg.senderUsername)?.member const senderName = isGroup - ? (await this.resolveExportDisplayProfile( - isSenderMe ? cleanedMyWxid : (msg.senderUsername || cleanedMyWxid), - options.displayNamePreference, - getContactCached, - groupNicknamesMap, - isSenderMe ? (myInfo.displayName || cleanedMyWxid) : (senderInfo?.accountName || msg.senderUsername || ''), - isSenderMe ? [rawMyWxid, cleanedMyWxid] : [] - )).displayName + ? (() => { + const senderKey = `${isSenderMe ? cleanedMyWxid : (msg.senderUsername || cleanedMyWxid)}::${isSenderMe ? '1' : '0'}` + const cached = senderProfileCache.get(senderKey) + if (cached) return cached.displayName + return '' + })() : (isSenderMe ? (myInfo.displayName || '我') : (sessionInfo.displayName || sessionId)) + const resolvedSenderName = isGroup && !senderName + ? (await (async () => { + const senderKey = `${isSenderMe ? cleanedMyWxid : (msg.senderUsername || cleanedMyWxid)}::${isSenderMe ? '1' : '0'}` + const profile = await this.resolveExportDisplayProfile( + isSenderMe ? cleanedMyWxid : (msg.senderUsername || cleanedMyWxid), + options.displayNamePreference, + getContactCached, + groupNicknamesMap, + isSenderMe ? (myInfo.displayName || cleanedMyWxid) : (senderInfo?.accountName || msg.senderUsername || ''), + isSenderMe ? [rawMyWxid, cleanedMyWxid] : [] + ) + senderProfileCache.set(senderKey, profile) + return profile.displayName + })()) + : senderName - const avatarHtml = getAvatarHtml(isSenderMe ? cleanedMyWxid : msg.senderUsername, senderName) + const avatarHtml = getAvatarHtml(isSenderMe ? cleanedMyWxid : msg.senderUsername, resolvedSenderName) const timeText = this.formatTimestamp(msg.createTime) const typeName = this.getMessageTypeName(msg.localType) @@ -5968,7 +7228,7 @@ class ExportService { msg.isSend ) if (msg.localType === 34 && useVoiceTranscript) { - textContent = voiceTranscriptMap.get(msg.localId) || '[语音消息 - 转文字失败]' + textContent = voiceTranscriptMap.get(this.getStableMessageKey(msg)) || '[语音消息 - 转文字失败]' } if (mediaItem && (msg.localType === 3 || msg.localType === 47)) { textContent = '' @@ -6013,7 +7273,7 @@ class ExportService { ? `
${this.renderTextWithEmoji(textContent).replace(/\r?\n/g, '
')}
` : '') const senderNameHtml = isGroup - ? `
${this.escapeHtml(senderName)}
` + ? `
${this.escapeHtml(resolvedSenderName)}
` : '' const timeHtml = `
${this.escapeHtml(timeText)}
` const messageBody = `${timeHtml}${senderNameHtml}
${mediaHtml}${textHtml}
` @@ -6043,7 +7303,10 @@ class ExportService { current: 60 + Math.floor((i + 1) / sortedMessages.length * 30), total: 100, currentSession: sessionInfo.displayName, - phase: 'writing' + phase: 'writing', + estimatedTotalMessages: totalMessages, + collectedMessages: totalMessages, + exportedMessages: i + 1 }) } } @@ -6168,7 +7431,11 @@ class ExportService { current: 100, total: 100, currentSession: sessionInfo.displayName, - phase: 'complete' + phase: 'complete', + estimatedTotalMessages: totalMessages, + collectedMessages: totalMessages, + exportedMessages: totalMessages, + writtenFiles: 1 }) resolve({ success: true }) }) @@ -6443,6 +7710,14 @@ class ExportService { let failCount = 0 const successSessionIds: string[] = [] const failedSessionIds: string[] = [] + const progressEmitter = this.createProgressEmitter(onProgress) + let attachMediaTelemetry = false + const emitProgress = (progress: ExportProgress, options?: { force?: boolean }) => { + const payload = attachMediaTelemetry + ? { ...progress, ...this.getMediaTelemetrySnapshot() } + : progress + progressEmitter.emit(payload, options) + } try { const conn = await this.ensureConnected() @@ -6450,12 +7725,17 @@ class ExportService { return { success: false, successCount: 0, failCount: sessionIds.length, error: conn.error } } + this.resetMediaRuntimeState() const effectiveOptions: ExportOptions = this.isMediaContentBatchExport(options) ? { ...options, exportVoiceAsText: false } : options const exportMediaEnabled = effectiveOptions.exportMedia === true && Boolean(effectiveOptions.exportImages || effectiveOptions.exportVoices || effectiveOptions.exportVideos || effectiveOptions.exportEmojis) + attachMediaTelemetry = exportMediaEnabled + if (exportMediaEnabled) { + this.triggerMediaFileCacheCleanup() + } const rawWriteLayout = this.configService.get('exportWriteLayout') const writeLayout = rawWriteLayout === 'A' || rawWriteLayout === 'B' || rawWriteLayout === 'C' ? rawWriteLayout @@ -6463,9 +7743,13 @@ class ExportService { const exportBaseDir = writeLayout === 'A' ? path.join(outputDir, 'texts') : outputDir - if (!fs.existsSync(exportBaseDir)) { - fs.mkdirSync(exportBaseDir, { recursive: true }) + const createdTaskDirs = new Set() + const ensureTaskDir = async (dirPath: string) => { + if (createdTaskDirs.has(dirPath)) return + await fs.promises.mkdir(dirPath, { recursive: true }) + createdTaskDirs.add(dirPath) } + await ensureTaskDir(exportBaseDir) const sessionLayout = exportMediaEnabled ? (effectiveOptions.sessionLayout ?? 'per-session') : 'shared' @@ -6521,7 +7805,7 @@ class ExportService { const EMPTY_SESSION_PRECHECK_LIMIT = 1200 if (precheckSessionIds.length <= EMPTY_SESSION_PRECHECK_LIMIT) { let checkedCount = 0 - onProgress?.({ + emitProgress({ current: computeAggregateCurrent(), total: sessionIds.length, currentSession: '', @@ -6558,7 +7842,7 @@ class ExportService { } checkedCount = Math.min(precheckSessionIds.length, checkedCount + batchSessionIds.length) - onProgress?.({ + emitProgress({ current: computeAggregateCurrent(), total: sessionIds.length, currentSession: '', @@ -6570,7 +7854,7 @@ class ExportService { }) } } else { - onProgress?.({ + emitProgress({ current: computeAggregateCurrent(), total: sessionIds.length, currentSession: '', @@ -6653,14 +7937,16 @@ class ExportService { successSessionIds.push(sessionId) activeSessionRatios.delete(sessionId) completedCount++ - onProgress?.({ + emitProgress({ current: computeAggregateCurrent(), total: sessionIds.length, currentSession: sessionInfo.displayName, currentSessionId: sessionId, phase: 'complete', - phaseLabel: '该会话没有消息,已跳过' - }) + phaseLabel: '该会话没有消息,已跳过', + estimatedTotalMessages: 0, + exportedMessages: 0 + }, { force: true }) return 'done' } @@ -6669,14 +7955,16 @@ class ExportService { successSessionIds.push(sessionId) activeSessionRatios.delete(sessionId) completedCount++ - onProgress?.({ + emitProgress({ current: computeAggregateCurrent(), total: sessionIds.length, currentSession: sessionInfo.displayName, currentSessionId: sessionId, phase: 'complete', - phaseLabel: '该会话没有消息,已跳过' - }) + phaseLabel: '该会话没有消息,已跳过', + estimatedTotalMessages: 0, + exportedMessages: 0 + }, { force: true }) return 'done' } @@ -6687,13 +7975,13 @@ class ExportService { ? 1 : Math.max(0, Math.min(1, phaseCurrent / phaseTotal)) activeSessionRatios.set(sessionId, ratio) - onProgress?.({ + emitProgress({ ...progress, current: computeAggregateCurrent(), total: sessionIds.length, currentSession: sessionInfo.displayName, currentSessionId: sessionId - }) + }, { force: progress.phase === 'complete' }) } sessionProgress({ @@ -6715,8 +8003,8 @@ class ExportService { const sessionDirName = sessionNameWithTypePrefix ? `${sessionTypePrefix}${safeName}` : safeName const sessionDir = useSessionFolder ? path.join(exportBaseDir, sessionDirName) : exportBaseDir - if (useSessionFolder && !fs.existsSync(sessionDir)) { - fs.mkdirSync(sessionDir, { recursive: true }) + if (useSessionFolder) { + await ensureTaskDir(sessionDir) } let ext = '.json' @@ -6731,7 +8019,7 @@ class ExportService { messageCountHint >= 0 && typeof latestTimestampHint === 'number' && latestTimestampHint > 0 && - fs.existsSync(outputPath) + await this.pathExists(outputPath) if (canTrySkipUnchanged) { const latestRecord = exportRecordService.getLatestRecord(sessionId, effectiveOptions.format) const hasNoDataChange = Boolean( @@ -6744,14 +8032,16 @@ class ExportService { successSessionIds.push(sessionId) activeSessionRatios.delete(sessionId) completedCount++ - onProgress?.({ + emitProgress({ current: computeAggregateCurrent(), total: sessionIds.length, currentSession: sessionInfo.displayName, currentSessionId: sessionId, phase: 'complete', - phaseLabel: '无变化,已跳过' - }) + phaseLabel: '无变化,已跳过', + estimatedTotalMessages: Math.max(0, Math.floor(messageCountHint || 0)), + exportedMessages: Math.max(0, Math.floor(messageCountHint || 0)) + }, { force: true }) return 'done' } } @@ -6797,14 +8087,14 @@ class ExportService { activeSessionRatios.delete(sessionId) completedCount++ - onProgress?.({ + emitProgress({ current: computeAggregateCurrent(), total: sessionIds.length, currentSession: sessionInfo.displayName, currentSessionId: sessionId, phase: 'complete', phaseLabel: result.success ? '完成' : '导出失败' - }) + }, { force: true }) return 'done' } catch (error) { if (this.isStopError(error)) { @@ -6886,17 +8176,21 @@ class ExportService { } } - onProgress?.({ + emitProgress({ current: sessionIds.length, total: sessionIds.length, currentSession: '', currentSessionId: '', phase: 'complete' - }) + }, { force: true }) + progressEmitter.flush() return { success: true, successCount, failCount, successSessionIds, failedSessionIds } } catch (e) { + progressEmitter.flush() return { success: false, successCount, failCount, error: String(e) } + } finally { + this.clearMediaRuntimeState() } } } diff --git a/electron/services/groupAnalyticsService.ts b/electron/services/groupAnalyticsService.ts index 7a03d37..8d66ce9 100644 --- a/electron/services/groupAnalyticsService.ts +++ b/electron/services/groupAnalyticsService.ts @@ -230,10 +230,9 @@ class GroupAnalyticsService { } try { - const escapedChatroomId = chatroomId.replace(/'/g, "''") - const roomResult = await wcdbService.execQuery('contact', null, `SELECT * FROM chat_room WHERE username='${escapedChatroomId}' LIMIT 1`) - if (roomResult.success && roomResult.rows && roomResult.rows.length > 0) { - const owner = tryResolve(roomResult.rows[0]) + const roomExt = await wcdbService.getChatRoomExtBuffer(chatroomId) + if (roomExt.success && roomExt.extBuffer) { + const owner = tryResolve({ ext_buffer: roomExt.extBuffer }) if (owner) return owner } } catch { @@ -273,13 +272,12 @@ class GroupAnalyticsService { } try { - const sql = 'SELECT ext_buffer FROM chat_room WHERE username = ? LIMIT 1' - const result = await wcdbService.execQuery('contact', null, sql, [chatroomId]) - if (!result.success || !result.rows || result.rows.length === 0) { + const result = await wcdbService.getChatRoomExtBuffer(chatroomId) + if (!result.success || !result.extBuffer) { return nicknameMap } - const extBuffer = this.decodeExtBuffer((result.rows[0] as any).ext_buffer) + const extBuffer = this.decodeExtBuffer(result.extBuffer) if (!extBuffer) return nicknameMap this.mergeGroupNicknameEntries(nicknameMap, this.parseGroupNicknamesFromExtBuffer(extBuffer, candidates).entries()) return nicknameMap @@ -583,19 +581,9 @@ class GroupAnalyticsService { const batch = candidates.slice(i, i + batchSize) if (batch.length === 0) continue - const inList = batch.map((username) => `'${username.replace(/'/g, "''")}'`).join(',') - const lightweightSql = ` - SELECT username, user_name, encrypt_username, encrypt_user_name, remark, nick_name, alias, local_type - FROM contact - WHERE username IN (${inList}) - ` - let result = await wcdbService.execQuery('contact', null, lightweightSql) - if (!result.success || !result.rows) { - // 兼容历史/变体列名,轻查询失败时回退全字段查询,避免好友标识丢失 - result = await wcdbService.execQuery('contact', null, `SELECT * FROM contact WHERE username IN (${inList})`) - } - if (!result.success || !result.rows) continue - appendContactsToLookup(result.rows as Record[]) + const result = await wcdbService.getContactsCompact(batch) + if (!result.success || !result.contacts) continue + appendContactsToLookup(result.contacts as Record[]) } return lookup } @@ -774,31 +762,111 @@ class GroupAnalyticsService { return '' } + private normalizeCursorTimestamp(value: number): number { + if (!Number.isFinite(value) || value <= 0) return 0 + const normalized = Math.floor(value) + return normalized > 10000000000 ? Math.floor(normalized / 1000) : normalized + } + + private extractRowSenderUsername(row: Record): string { + const candidates = [ + row.sender_username, + row.senderUsername, + row.sender, + row.WCDB_CT_sender_username + ] + for (const candidate of candidates) { + const value = String(candidate || '').trim() + if (value) return value + } + for (const [key, value] of Object.entries(row)) { + const normalizedKey = key.toLowerCase() + if ( + normalizedKey === 'sender_username' || + normalizedKey === 'senderusername' || + normalizedKey === 'sender' || + normalizedKey === 'wcdb_ct_sender_username' + ) { + const normalizedValue = String(value || '').trim() + if (normalizedValue) return normalizedValue + } + } + return '' + } + + private parseSingleMessageRow(row: Record): Message | null { + try { + const mapped = chatService.mapRowsToMessagesForApi([row]) + return Array.isArray(mapped) && mapped.length > 0 ? mapped[0] : null + } catch { + return null + } + } + + private async openMemberMessageCursor( + chatroomId: string, + batchSize: number, + ascending: boolean, + startTime: number, + endTime: number + ): Promise<{ success: boolean; cursor?: number; error?: string }> { + const beginTimestamp = this.normalizeCursorTimestamp(startTime) + const endTimestamp = this.normalizeCursorTimestamp(endTime) + const liteResult = await wcdbService.openMessageCursorLite(chatroomId, batchSize, ascending, beginTimestamp, endTimestamp) + if (liteResult.success && liteResult.cursor) return liteResult + return wcdbService.openMessageCursor(chatroomId, batchSize, ascending, beginTimestamp, endTimestamp) + } + private async collectMessagesByMember( chatroomId: string, memberUsername: string, startTime: number, endTime: number ): Promise<{ success: boolean; data?: Message[]; error?: string }> { - const batchSize = 500 + const batchSize = 800 const matchedMessages: Message[] = [] - let offset = 0 + const senderMatchCache = new Map() + const matchesTargetSender = (sender: string | null | undefined): boolean => { + const key = String(sender || '').trim().toLowerCase() + if (!key) return false + const cached = senderMatchCache.get(key) + if (typeof cached === 'boolean') return cached + const matched = this.isSameAccountIdentity(memberUsername, sender) + senderMatchCache.set(key, matched) + return matched + } - while (true) { - const batch = await chatService.getMessages(chatroomId, offset, batchSize, startTime, endTime, true) - if (!batch.success || !batch.messages) { - return { success: false, error: batch.error || '获取群消息失败' } - } + const cursorResult = await this.openMemberMessageCursor(chatroomId, batchSize, true, startTime, endTime) + if (!cursorResult.success || !cursorResult.cursor) { + return { success: false, error: cursorResult.error || '创建群消息游标失败' } + } - for (const message of batch.messages) { - if (this.isSameAccountIdentity(memberUsername, message.senderUsername)) { - matchedMessages.push(message) + const cursor = cursorResult.cursor + try { + while (true) { + const batch = await wcdbService.fetchMessageBatch(cursor) + if (!batch.success) { + return { success: false, error: batch.error || '获取群消息失败' } } - } + const rows = Array.isArray(batch.rows) ? batch.rows as Record[] : [] + if (rows.length === 0) break - const fetchedCount = batch.messages.length - if (fetchedCount <= 0 || !batch.hasMore) break - offset += fetchedCount + for (const row of rows) { + const senderFromRow = this.extractRowSenderUsername(row) + if (senderFromRow && !matchesTargetSender(senderFromRow)) { + continue + } + const message = this.parseSingleMessageRow(row) + if (!message) continue + if (matchesTargetSender(message.senderUsername)) { + matchedMessages.push(message) + } + } + + if (!batch.hasMore) break + } + } finally { + await wcdbService.closeMessageCursor(cursor) } return { success: true, data: matchedMessages } @@ -832,57 +900,93 @@ class GroupAnalyticsService { : 0 const matchedMessages: Message[] = [] - const batchSize = Math.max(limit * 2, 100) + const senderMatchCache = new Map() + const matchesTargetSender = (sender: string | null | undefined): boolean => { + const key = String(sender || '').trim().toLowerCase() + if (!key) return false + const cached = senderMatchCache.get(key) + if (typeof cached === 'boolean') return cached + const matched = this.isSameAccountIdentity(normalizedMemberUsername, sender) + senderMatchCache.set(key, matched) + return matched + } + const batchSize = Math.max(limit * 4, 240) let hasMore = false - while (matchedMessages.length < limit) { - const batch = await chatService.getMessages( - normalizedChatroomId, - cursor, - batchSize, - startTimeValue, - endTimeValue, - false - ) - if (!batch.success || !batch.messages) { - return { success: false, error: batch.error || '获取群成员消息失败' } - } + const cursorResult = await this.openMemberMessageCursor( + normalizedChatroomId, + batchSize, + false, + startTimeValue, + endTimeValue + ) + if (!cursorResult.success || !cursorResult.cursor) { + return { success: false, error: cursorResult.error || '创建群成员消息游标失败' } + } - const currentMessages = batch.messages - const nextCursor = typeof batch.nextOffset === 'number' - ? Math.max(cursor, Math.floor(batch.nextOffset)) - : cursor + currentMessages.length + let consumedRows = 0 + const dbCursor = cursorResult.cursor - let overflowMatchFound = false - for (const message of currentMessages) { - if (!this.isSameAccountIdentity(normalizedMemberUsername, message.senderUsername)) { - continue + try { + while (matchedMessages.length < limit) { + const batch = await wcdbService.fetchMessageBatch(dbCursor) + if (!batch.success) { + return { success: false, error: batch.error || '获取群成员消息失败' } } - if (matchedMessages.length < limit) { + const rows = Array.isArray(batch.rows) ? batch.rows as Record[] : [] + if (rows.length === 0) { + hasMore = false + break + } + + let startIndex = 0 + if (cursor > consumedRows) { + const skipCount = Math.min(cursor - consumedRows, rows.length) + consumedRows += skipCount + startIndex = skipCount + if (startIndex >= rows.length) { + if (!batch.hasMore) { + hasMore = false + break + } + continue + } + } + + for (let index = startIndex; index < rows.length; index += 1) { + const row = rows[index] + consumedRows += 1 + + const senderFromRow = this.extractRowSenderUsername(row) + if (senderFromRow && !matchesTargetSender(senderFromRow)) { + continue + } + + const message = this.parseSingleMessageRow(row) + if (!message) continue + if (!matchesTargetSender(message.senderUsername)) { + continue + } + matchedMessages.push(message) - } else { - overflowMatchFound = true + if (matchedMessages.length >= limit) { + cursor = consumedRows + hasMore = index < rows.length - 1 || batch.hasMore === true + break + } + } + + if (matchedMessages.length >= limit) break + + cursor = consumedRows + if (!batch.hasMore) { + hasMore = false break } } - - cursor = nextCursor - - if (overflowMatchFound) { - hasMore = true - break - } - - if (currentMessages.length === 0 || !batch.hasMore) { - hasMore = false - break - } - - if (matchedMessages.length >= limit) { - hasMore = true - break - } + } finally { + await wcdbService.closeMessageCursor(dbCursor) } return { diff --git a/electron/services/httpService.ts b/electron/services/httpService.ts index 47f3f8c..7b95996 100644 --- a/electron/services/httpService.ts +++ b/electron/services/httpService.ts @@ -103,6 +103,8 @@ class HttpService { private port: number = 5031 private running: boolean = false private connections: Set = new Set() + private messagePushClients: Set = new Set() + private messagePushHeartbeatTimer: ReturnType | null = null private connectionMutex: boolean = false constructor() { @@ -153,6 +155,7 @@ class HttpService { this.server.listen(this.port, '127.0.0.1', () => { this.running = true + this.startMessagePushHeartbeat() console.log(`[HttpService] HTTP API server started on http://127.0.0.1:${this.port}`) resolve({ success: true, port: this.port }) }) @@ -165,6 +168,16 @@ class HttpService { async stop(): Promise { return new Promise((resolve) => { if (this.server) { + for (const client of this.messagePushClients) { + try { + client.end() + } catch {} + } + this.messagePushClients.clear() + if (this.messagePushHeartbeatTimer) { + clearInterval(this.messagePushHeartbeatTimer) + this.messagePushHeartbeatTimer = null + } // 使用互斥锁保护连接集合操作 this.connectionMutex = true const socketsToClose = Array.from(this.connections) @@ -211,6 +224,28 @@ class HttpService { return this.getApiMediaExportPath() } + getMessagePushStreamUrl(): string { + return `http://127.0.0.1:${this.port}/api/v1/push/messages` + } + + broadcastMessagePush(payload: Record): void { + if (!this.running || this.messagePushClients.size === 0) return + const eventBody = `event: message.new\ndata: ${JSON.stringify(payload)}\n\n` + + for (const client of Array.from(this.messagePushClients)) { + try { + if (client.writableEnded || client.destroyed) { + this.messagePushClients.delete(client) + continue + } + client.write(eventBody) + } catch { + this.messagePushClients.delete(client) + try { client.end() } catch {} + } + } + } + /** * 处理 HTTP 请求 */ @@ -233,6 +268,8 @@ class HttpService { // 路由处理 if (pathname === '/health' || pathname === '/api/v1/health') { this.sendJson(res, { status: 'ok' }) + } else if (pathname === '/api/v1/push/messages') { + this.handleMessagePushStream(req, res) } else if (pathname === '/api/v1/messages') { await this.handleMessages(url, res) } else if (pathname === '/api/v1/sessions') { @@ -252,6 +289,50 @@ class HttpService { } } + private startMessagePushHeartbeat(): void { + if (this.messagePushHeartbeatTimer) return + this.messagePushHeartbeatTimer = setInterval(() => { + for (const client of Array.from(this.messagePushClients)) { + try { + if (client.writableEnded || client.destroyed) { + this.messagePushClients.delete(client) + continue + } + client.write(': ping\n\n') + } catch { + this.messagePushClients.delete(client) + try { client.end() } catch {} + } + } + }, 25000) + } + + private handleMessagePushStream(req: http.IncomingMessage, res: http.ServerResponse): void { + if (this.configService.get('messagePushEnabled') !== true) { + this.sendError(res, 403, 'Message push is disabled') + return + } + + res.writeHead(200, { + 'Content-Type': 'text/event-stream; charset=utf-8', + 'Cache-Control': 'no-cache, no-transform', + Connection: 'keep-alive', + 'X-Accel-Buffering': 'no' + }) + res.flushHeaders?.() + res.write(`event: ready\ndata: ${JSON.stringify({ success: true, stream: this.getMessagePushStreamUrl() })}\n\n`) + + this.messagePushClients.add(res) + + const cleanup = () => { + this.messagePushClients.delete(res) + } + + req.on('close', cleanup) + res.on('close', cleanup) + res.on('error', cleanup) + } + private handleMediaRequest(pathname: string, res: http.ServerResponse): void { const mediaBasePath = this.getApiMediaExportPath() const relativePath = pathname.replace('/api/v1/media/', '') diff --git a/electron/services/imageDecryptService.ts b/electron/services/imageDecryptService.ts index 9ad2c25..32a2ae0 100644 --- a/electron/services/imageDecryptService.ts +++ b/electron/services/imageDecryptService.ts @@ -55,14 +55,19 @@ type DecryptResult = { isThumb?: boolean // 是否是缩略图(没有高清图时返回缩略图) } -type HardlinkState = { - imageTable?: string - dirTable?: string +type CachedImagePayload = { + sessionId?: string + imageMd5?: string + imageDatName?: string + preferFilePath?: boolean +} + +type DecryptImagePayload = CachedImagePayload & { + force?: boolean } export class ImageDecryptService { private configService = new ConfigService() - private hardlinkCache = new Map() private resolvedCache = new Map() private pending = new Map>() private readonly defaultV1AesKey = 'cfcd208495d565ef' @@ -106,7 +111,7 @@ export class ImageDecryptService { } } - async resolveCachedImage(payload: { sessionId?: string; imageMd5?: string; imageDatName?: string }): Promise { + async resolveCachedImage(payload: CachedImagePayload): Promise { await this.ensureCacheIndexed() const cacheKeys = this.getCacheKeys(payload) const cacheKey = cacheKeys[0] @@ -116,7 +121,7 @@ export class ImageDecryptService { for (const key of cacheKeys) { const cached = this.resolvedCache.get(key) if (cached && existsSync(cached) && this.isImageFile(cached)) { - const dataUrl = this.fileToDataUrl(cached) + const localPath = this.resolveLocalPathForPayload(cached, payload.preferFilePath) const isThumb = this.isThumbnailPath(cached) const hasUpdate = isThumb ? (this.updateFlags.get(key) ?? false) : false if (isThumb) { @@ -124,8 +129,8 @@ export class ImageDecryptService { } else { this.updateFlags.delete(key) } - this.emitCacheResolved(payload, key, dataUrl || this.filePathToUrl(cached)) - return { success: true, localPath: dataUrl || this.filePathToUrl(cached), hasUpdate } + this.emitCacheResolved(payload, key, this.resolveEmitPath(cached, payload.preferFilePath)) + return { success: true, localPath, hasUpdate } } if (cached && !this.isImageFile(cached)) { this.resolvedCache.delete(key) @@ -136,7 +141,7 @@ export class ImageDecryptService { const existing = this.findCachedOutput(key, false, payload.sessionId) if (existing) { this.cacheResolvedPaths(key, payload.imageMd5, payload.imageDatName, existing) - const dataUrl = this.fileToDataUrl(existing) + const localPath = this.resolveLocalPathForPayload(existing, payload.preferFilePath) const isThumb = this.isThumbnailPath(existing) const hasUpdate = isThumb ? (this.updateFlags.get(key) ?? false) : false if (isThumb) { @@ -144,27 +149,53 @@ export class ImageDecryptService { } else { this.updateFlags.delete(key) } - this.emitCacheResolved(payload, key, dataUrl || this.filePathToUrl(existing)) - return { success: true, localPath: dataUrl || this.filePathToUrl(existing), hasUpdate } + this.emitCacheResolved(payload, key, this.resolveEmitPath(existing, payload.preferFilePath)) + return { success: true, localPath, hasUpdate } } } this.logInfo('未找到缓存', { md5: payload.imageMd5, datName: payload.imageDatName }) return { success: false, error: '未找到缓存图片' } } - async decryptImage(payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; force?: boolean }): Promise { + async decryptImage(payload: DecryptImagePayload): Promise { await this.ensureCacheIndexed() - const cacheKey = payload.imageMd5 || payload.imageDatName + const cacheKeys = this.getCacheKeys(payload) + const cacheKey = cacheKeys[0] if (!cacheKey) { return { success: false, error: '缺少图片标识' } } + if (payload.force) { + for (const key of cacheKeys) { + const cached = this.resolvedCache.get(key) + if (cached && existsSync(cached) && this.isImageFile(cached) && !this.isThumbnailPath(cached)) { + this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, cached) + this.clearUpdateFlags(cacheKey, payload.imageMd5, payload.imageDatName) + const localPath = this.resolveLocalPathForPayload(cached, payload.preferFilePath) + this.emitCacheResolved(payload, cacheKey, this.resolveEmitPath(cached, payload.preferFilePath)) + return { success: true, localPath } + } + if (cached && !this.isImageFile(cached)) { + this.resolvedCache.delete(key) + } + } + + for (const key of cacheKeys) { + const existingHd = this.findCachedOutput(key, true, payload.sessionId) + if (!existingHd || this.isThumbnailPath(existingHd)) continue + this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, existingHd) + this.clearUpdateFlags(cacheKey, payload.imageMd5, payload.imageDatName) + const localPath = this.resolveLocalPathForPayload(existingHd, payload.preferFilePath) + this.emitCacheResolved(payload, cacheKey, this.resolveEmitPath(existingHd, payload.preferFilePath)) + return { success: true, localPath } + } + } + if (!payload.force) { const cached = this.resolvedCache.get(cacheKey) if (cached && existsSync(cached) && this.isImageFile(cached)) { - const dataUrl = this.fileToDataUrl(cached) - const localPath = dataUrl || this.filePathToUrl(cached) - this.emitCacheResolved(payload, cacheKey, localPath) + const localPath = this.resolveLocalPathForPayload(cached, payload.preferFilePath) + this.emitCacheResolved(payload, cacheKey, this.resolveEmitPath(cached, payload.preferFilePath)) return { success: true, localPath } } if (cached && !this.isImageFile(cached)) { @@ -184,8 +215,44 @@ export class ImageDecryptService { } } + async preloadImageHardlinkMd5s(md5List: string[]): Promise { + const normalizedList = Array.from( + new Set((md5List || []).map((item) => String(item || '').trim().toLowerCase()).filter(Boolean)) + ) + if (normalizedList.length === 0) return + + const wxid = this.configService.get('myWxid') + const dbPath = this.configService.get('dbPath') + if (!wxid || !dbPath) return + + const accountDir = this.resolveAccountDir(dbPath, wxid) + if (!accountDir) return + + try { + const ready = await this.ensureWcdbReady() + if (!ready) return + const requests = normalizedList.map((md5) => ({ md5, accountDir })) + const result = await wcdbService.resolveImageHardlinkBatch(requests) + if (!result.success || !Array.isArray(result.rows)) return + + for (const row of result.rows) { + const md5 = String(row?.md5 || '').trim().toLowerCase() + if (!md5) continue + const fullPath = String(row?.data?.full_path || '').trim() + if (!fullPath || !existsSync(fullPath)) continue + this.cacheDatPath(accountDir, md5, fullPath) + const fileName = String(row?.data?.file_name || '').trim().toLowerCase() + if (fileName) { + this.cacheDatPath(accountDir, fileName, fullPath) + } + } + } catch { + // ignore preload failures + } + } + private async decryptImageInternal( - payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; force?: boolean }, + payload: DecryptImagePayload, cacheKey: string ): Promise { this.logInfo('开始解密图片', { md5: payload.imageMd5, datName: payload.imageDatName, force: payload.force }) @@ -225,10 +292,9 @@ export class ImageDecryptService { if (!extname(datPath).toLowerCase().includes('dat')) { this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, datPath) - const dataUrl = this.fileToDataUrl(datPath) - const localPath = dataUrl || this.filePathToUrl(datPath) + const localPath = this.resolveLocalPathForPayload(datPath, payload.preferFilePath) const isThumb = this.isThumbnailPath(datPath) - this.emitCacheResolved(payload, cacheKey, localPath) + this.emitCacheResolved(payload, cacheKey, this.resolveEmitPath(datPath, payload.preferFilePath)) return { success: true, localPath, isThumb } } @@ -240,10 +306,9 @@ export class ImageDecryptService { // 如果要求高清但找到的是缩略图,继续解密高清图 if (!(payload.force && !isHd)) { this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, existing) - const dataUrl = this.fileToDataUrl(existing) - const localPath = dataUrl || this.filePathToUrl(existing) + const localPath = this.resolveLocalPathForPayload(existing, payload.preferFilePath) const isThumb = this.isThumbnailPath(existing) - this.emitCacheResolved(payload, cacheKey, localPath) + this.emitCacheResolved(payload, cacheKey, this.resolveEmitPath(existing, payload.preferFilePath)) return { success: true, localPath, isThumb } } } @@ -303,9 +368,11 @@ export class ImageDecryptService { if (!isThumb) { this.clearUpdateFlags(cacheKey, payload.imageMd5, payload.imageDatName) } - const dataUrl = this.bufferToDataUrl(decrypted, finalExt) - const localPath = dataUrl || this.filePathToUrl(outputPath) - this.emitCacheResolved(payload, cacheKey, localPath) + const localPath = payload.preferFilePath + ? outputPath + : (this.bufferToDataUrl(decrypted, finalExt) || this.filePathToUrl(outputPath)) + const emitPath = this.resolveEmitPath(outputPath, payload.preferFilePath) + this.emitCacheResolved(payload, cacheKey, emitPath) return { success: true, localPath, isThumb } } catch (e) { this.logError('解密失败', e, { md5: payload.imageMd5, datName: payload.imageDatName }) @@ -654,45 +721,19 @@ export class ImageDecryptService { private async resolveHardlinkPath(accountDir: string, md5: string, _sessionId?: string): Promise { try { - const hardlinkPath = this.resolveHardlinkDbPath(accountDir) - if (!hardlinkPath) { - return null - } - const ready = await this.ensureWcdbReady() if (!ready) { this.logInfo('[ImageDecrypt] hardlink db not ready') return null } - const state = await this.getHardlinkState(accountDir, hardlinkPath) - if (!state.imageTable) { - this.logInfo('[ImageDecrypt] hardlink table missing', { hardlinkPath }) - return null - } + const resolveResult = await wcdbService.resolveImageHardlink(md5, accountDir) + if (!resolveResult.success || !resolveResult.data) return null + const fileName = String(resolveResult.data.file_name || '').trim() + const fullPath = String(resolveResult.data.full_path || '').trim() + if (!fileName) return null - const escapedMd5 = this.escapeSqlString(md5) - const rowResult = await wcdbService.execQuery( - 'media', - hardlinkPath, - `SELECT dir1, dir2, file_name FROM ${state.imageTable} WHERE lower(md5) = lower('${escapedMd5}') LIMIT 1` - ) - const row = rowResult.success && rowResult.rows ? rowResult.rows[0] : null - - if (!row) { - this.logInfo('[ImageDecrypt] hardlink row miss', { md5, table: state.imageTable }) - return null - } - - const dir1 = this.getRowValue(row, 'dir1') - const dir2 = this.getRowValue(row, 'dir2') - const fileName = this.getRowValue(row, 'file_name') ?? this.getRowValue(row, 'fileName') - if (dir1 === undefined || dir2 === undefined || !fileName) { - this.logInfo('[ImageDecrypt] hardlink row incomplete', { row }) - return null - } - - const lowerFileName = fileName.toLowerCase() + const lowerFileName = String(fileName).toLowerCase() if (lowerFileName.endsWith('.dat')) { const baseLower = lowerFileName.slice(0, -4) if (!this.isLikelyImageDatBase(baseLower) && !this.looksLikeMd5(baseLower)) { @@ -701,57 +742,11 @@ export class ImageDecryptService { } } - // dir1 和 dir2 是 rowid,需要从 dir2id 表查询对应的目录名 - let dir1Name: string | null = null - let dir2Name: string | null = null - - if (state.dirTable) { - try { - // 通过 rowid 查询目录名 - const dir1Result = await wcdbService.execQuery( - 'media', - hardlinkPath, - `SELECT username FROM ${state.dirTable} WHERE rowid = ${Number(dir1)} LIMIT 1` - ) - if (dir1Result.success && dir1Result.rows && dir1Result.rows.length > 0) { - const value = this.getRowValue(dir1Result.rows[0], 'username') - if (value) dir1Name = String(value) - } - - const dir2Result = await wcdbService.execQuery( - 'media', - hardlinkPath, - `SELECT username FROM ${state.dirTable} WHERE rowid = ${Number(dir2)} LIMIT 1` - ) - if (dir2Result.success && dir2Result.rows && dir2Result.rows.length > 0) { - const value = this.getRowValue(dir2Result.rows[0], 'username') - if (value) dir2Name = String(value) - } - } catch { - // ignore - } + if (fullPath && existsSync(fullPath)) { + this.logInfo('[ImageDecrypt] hardlink path hit', { fullPath }) + return fullPath } - - if (!dir1Name || !dir2Name) { - this.logInfo('[ImageDecrypt] hardlink dir resolve miss', { dir1, dir2, dir1Name, dir2Name }) - return null - } - - // 构建路径: msg/attach/{dir1Name}/{dir2Name}/Img/{fileName} - const possiblePaths = [ - join(accountDir, 'msg', 'attach', dir1Name, dir2Name, 'Img', fileName), - join(accountDir, 'msg', 'attach', dir1Name, dir2Name, 'mg', fileName), - join(accountDir, 'msg', 'attach', dir1Name, dir2Name, fileName), - ] - - for (const fullPath of possiblePaths) { - if (existsSync(fullPath)) { - this.logInfo('[ImageDecrypt] hardlink path hit', { fullPath }) - return fullPath - } - } - - this.logInfo('[ImageDecrypt] hardlink path miss', { possiblePaths }) + this.logInfo('[ImageDecrypt] hardlink path miss', { fullPath, md5 }) return null } catch { // ignore @@ -759,35 +754,6 @@ export class ImageDecryptService { return null } - private async getHardlinkState(accountDir: string, hardlinkPath: string): Promise { - const cached = this.hardlinkCache.get(hardlinkPath) - if (cached) return cached - - const imageResult = await wcdbService.execQuery( - 'media', - hardlinkPath, - "SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'image_hardlink_info%' ORDER BY name DESC LIMIT 1" - ) - const dirResult = await wcdbService.execQuery( - 'media', - hardlinkPath, - "SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'dir2id%' LIMIT 1" - ) - const imageTable = imageResult.success && imageResult.rows && imageResult.rows.length > 0 - ? this.getRowValue(imageResult.rows[0], 'name') - : undefined - const dirTable = dirResult.success && dirResult.rows && dirResult.rows.length > 0 - ? this.getRowValue(dirResult.rows[0], 'name') - : undefined - const state: HardlinkState = { - imageTable: imageTable ? String(imageTable) : undefined, - dirTable: dirTable ? String(dirTable) : undefined - } - this.logInfo('[ImageDecrypt] hardlink state', { hardlinkPath, imageTable: state.imageTable, dirTable: state.dirTable }) - this.hardlinkCache.set(hardlinkPath, state) - return state - } - private async ensureWcdbReady(): Promise { if (wcdbService.isReady()) return true const dbPath = this.configService.get('dbPath') @@ -1572,6 +1538,16 @@ export class ImageDecryptService { return `data:${mimeType};base64,${buffer.toString('base64')}` } + private resolveLocalPathForPayload(filePath: string, preferFilePath?: boolean): string { + if (preferFilePath) return filePath + return this.resolveEmitPath(filePath, false) + } + + private resolveEmitPath(filePath: string, preferFilePath?: boolean): string { + if (preferFilePath) return this.filePathToUrl(filePath) + return this.fileToDataUrl(filePath) || this.filePathToUrl(filePath) + } + private fileToDataUrl(filePath: string): string | null { try { const ext = extname(filePath).toLowerCase() @@ -1963,7 +1939,6 @@ export class ImageDecryptService { async clearCache(): Promise<{ success: boolean; error?: string }> { this.resolvedCache.clear() - this.hardlinkCache.clear() this.pending.clear() this.updateFlags.clear() this.cacheIndexed = false diff --git a/electron/services/keyServiceLinux.ts b/electron/services/keyServiceLinux.ts new file mode 100644 index 0000000..1d9e10b --- /dev/null +++ b/electron/services/keyServiceLinux.ts @@ -0,0 +1,292 @@ +import { app } from 'electron' +import { join } from 'path' +import { existsSync, readdirSync, statSync, readFileSync } from 'fs' +import { execFile, exec } from 'child_process' +import { promisify } from 'util' +import { createRequire } from 'module'; +const require = createRequire(import.meta.url); + +const execFileAsync = promisify(execFile) +const execAsync = promisify(exec) + +type DbKeyResult = { success: boolean; key?: string; error?: string; logs?: string[] } +type ImageKeyResult = { success: boolean; xorKey?: number; aesKey?: string; error?: string } + +export class KeyServiceLinux { + private sudo: any + + constructor() { + try { + this.sudo = require('sudo-prompt'); + } catch (e) { + console.error('Failed to load sudo-prompt', e); + } + } + + private getHelperPath(): string { + const isPackaged = app.isPackaged + const candidates: string[] = [] + if (process.env.WX_KEY_HELPER_PATH) candidates.push(process.env.WX_KEY_HELPER_PATH) + if (isPackaged) { + candidates.push(join(process.resourcesPath, 'resources', 'xkey_helper_linux')) + candidates.push(join(process.resourcesPath, 'xkey_helper_linux')) + } else { + candidates.push(join(app.getAppPath(), 'resources', 'xkey_helper_linux')) + candidates.push(join(app.getAppPath(), '..', 'Xkey', 'build', 'xkey_helper_linux')) + } + for (const p of candidates) { + if (existsSync(p)) return p + } + throw new Error('找不到 xkey_helper_linux,请检查路径') + } + + public async autoGetDbKey( + timeoutMs = 60_000, + onStatus?: (message: string, level: number) => void + ): Promise { + try { + onStatus?.('正在尝试结束当前微信进程...', 0) + await execAsync('killall -9 wechat wechat-bin xwechat').catch(() => {}) + // 稍微等待进程完全退出 + await new Promise(r => setTimeout(r, 1000)) + + onStatus?.('正在尝试拉起微信...', 0) + const startCmds = [ + 'nohup wechat >/dev/null 2>&1 &', + 'nohup wechat-bin >/dev/null 2>&1 &', + 'nohup xwechat >/dev/null 2>&1 &' + ] + for (const cmd of startCmds) execAsync(cmd).catch(() => {}) + + onStatus?.('等待微信进程出现...', 0) + let pid = 0 + for (let i = 0; i < 15; i++) { // 最多等 15 秒 + await new Promise(r => setTimeout(r, 1000)) + const { stdout } = await execAsync('pidof wechat wechat-bin xwechat').catch(() => ({ stdout: '' })) + const pids = stdout.trim().split(/\s+/).filter(p => p) + if (pids.length > 0) { + pid = parseInt(pids[0], 10) + break + } + } + + if (!pid) { + const err = '未能自动启动微信,请手动启动并登录。' + onStatus?.(err, 2) + return { success: false, error: err } + } + + onStatus?.(`捕获到微信 PID: ${pid},准备获取密钥...`, 0) + + await new Promise(r => setTimeout(r, 2000)) + + return await this.getDbKey(pid, onStatus) + } catch (err: any) { + const errMsg = '自动获取微信 PID 失败: ' + err.message + onStatus?.(errMsg, 2) + return { success: false, error: errMsg } + } + } + + public async getDbKey(pid: number, onStatus?: (message: string, level: number) => void): Promise { + try { + const helperPath = this.getHelperPath() + + onStatus?.('正在扫描数据库基址...', 0) + const { stdout: scanOut } = await execFileAsync(helperPath, ['db_scan', pid.toString()]) + const scanRes = JSON.parse(scanOut.trim()) + + if (!scanRes.success) { + const err = scanRes.result || '扫描失败,请确保微信已完全登录' + onStatus?.(err, 2) + return { success: false, error: err } + } + + const targetAddr = scanRes.target_addr + onStatus?.('基址扫描成功,正在请求管理员权限进行内存 Hook...', 0) + + return await new Promise((resolve) => { + const options = { name: 'WeFlow' } + const command = `"${helperPath}" db_hook ${pid} ${targetAddr}` + + this.sudo.exec(command, options, (error, stdout) => { + execAsync(`kill -CONT ${pid}`).catch(() => {}) + if (error) { + onStatus?.('授权失败或被取消', 2) + resolve({ success: false, error: `授权失败或被取消: ${error.message}` }) + return + } + try { + const hookRes = JSON.parse((stdout as string).trim()) + if (hookRes.success) { + onStatus?.('密钥获取成功', 1) + resolve({ success: true, key: hookRes.key }) + } else { + onStatus?.(hookRes.result, 2) + resolve({ success: false, error: hookRes.result }) + } + } catch (e) { + onStatus?.('解析 Hook 结果失败', 2) + resolve({ success: false, error: '解析 Hook 结果失败' }) + } + }) + }) + } catch (err: any) { + onStatus?.(err.message, 2) + return { success: false, error: err.message } + } + } + + public async autoGetImageKey( + accountPath?: string, + onProgress?: (msg: string) => void, + wxid?: string + ): Promise { + try { + onProgress?.('正在初始化缓存扫描...'); + const helperPath = this.getHelperPath() + const { stdout } = await execFileAsync(helperPath, ['image_local']) + const res = JSON.parse(stdout.trim()) + if (!res.success) return { success: false, error: res.result } + + const accounts = res.data.accounts || [] + let account = accounts.find((a: any) => a.wxid === wxid) + if (!account && accounts.length > 0) account = accounts[0] + + if (account && account.keys && account.keys.length > 0) { + onProgress?.(`已找到匹配的图片密钥 (wxid: ${account.wxid})`); + const keyObj = account.keys[0] + return { success: true, xorKey: keyObj.xorKey, aesKey: keyObj.aesKey } + } + return { success: false, error: '未在缓存中找到匹配的图片密钥' } + } catch (err: any) { + return { success: false, error: err.message } + } + } + + public async autoGetImageKeyByMemoryScan( + accountPath: string, + onProgress?: (msg: string) => void + ): Promise { + try { + onProgress?.('正在查找模板文件...') + let result = await this._findTemplateData(accountPath, 32) + let { ciphertext, xorKey } = result + + if (ciphertext && xorKey === null) { + onProgress?.('未找到有效密钥,尝试扫描更多文件...') + result = await this._findTemplateData(accountPath, 100) + xorKey = result.xorKey + } + + if (!ciphertext) return { success: false, error: '未找到 V2 模板文件,请先在微信中查看几张图片' } + if (xorKey === null) return { success: false, error: '未能从模板文件中计算出有效的 XOR 密钥' } + + onProgress?.(`XOR 密钥: 0x${xorKey.toString(16).padStart(2, '0')},正在查找微信进程...`) + + // 2. 找微信 PID + const { stdout } = await execAsync('pidof wechat wechat-bin xwechat').catch(() => ({ stdout: '' })) + const pids = stdout.trim().split(/\s+/).filter(p => p) + if (pids.length === 0) return { success: false, error: '微信未运行,无法扫描内存' } + const pid = parseInt(pids[0], 10) + + onProgress?.(`已找到微信进程 PID=${pid},正在提权扫描进程内存...`); + + // 3. 将 Buffer 转换为 hex 传递给 helper + const ciphertextHex = ciphertext.toString('hex') + const helperPath = this.getHelperPath() + + try { + console.log(`[Debug] 准备执行 Helper: ${helperPath} image_mem ${pid} ${ciphertextHex}`); + + const { stdout: memOut, stderr } = await execFileAsync(helperPath, ['image_mem', pid.toString(), ciphertextHex]) + + console.log(`[Debug] Helper stdout: ${memOut}`); + if (stderr) { + console.warn(`[Debug] Helper stderr: ${stderr}`); + } + + if (!memOut || memOut.trim() === '') { + return { success: false, error: 'Helper 返回为空,请检查是否有足够的权限(如需sudo)读取进程内存。' } + } + + const res = JSON.parse(memOut.trim()) + + if (res.success) { + onProgress?.('内存扫描成功'); + return { success: true, xorKey, aesKey: res.key } + } + return { success: false, error: res.result || '未知错误' } + + } catch (err: any) { + console.error('[Debug] 执行或解析 Helper 时发生崩溃:', err); + return { + success: false, + error: `内存扫描失败: ${err.message}\nstdout: ${err.stdout || '无'}\nstderr: ${err.stderr || '无'}` + } + } + } catch (err: any) { + return { success: false, error: `内存扫描失败: ${err.message}` } + } + } + + private async _findTemplateData(userDir: string, limit: number = 32): Promise<{ ciphertext: Buffer | null; xorKey: number | null }> { + const V2_MAGIC = Buffer.from([0x07, 0x08, 0x56, 0x32, 0x08, 0x07]) + + // 递归收集 *_t.dat 文件 + const collect = (dir: string, results: string[], maxFiles: number) => { + if (results.length >= maxFiles) return + try { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + if (results.length >= maxFiles) break + const full = join(dir, entry.name) + if (entry.isDirectory()) collect(full, results, maxFiles) + else if (entry.isFile() && entry.name.endsWith('_t.dat')) results.push(full) + } + } catch { /* 忽略无权限目录 */ } + } + + const files: string[] = [] + collect(userDir, files, limit) + + // 按修改时间降序 + files.sort((a, b) => { + try { return statSync(b).mtimeMs - statSync(a).mtimeMs } catch { return 0 } + }) + + let ciphertext: Buffer | null = null + const tailCounts: Record = {} + + for (const f of files.slice(0, 32)) { + try { + const data = readFileSync(f) + if (data.length < 8) continue + + // 统计末尾两字节用于 XOR 密钥 + if (data.subarray(0, 6).equals(V2_MAGIC) && data.length >= 2) { + const key = `${data[data.length - 2]}_${data[data.length - 1]}` + tailCounts[key] = (tailCounts[key] ?? 0) + 1 + } + + // 提取密文(取第一个有效的) + if (!ciphertext && data.subarray(0, 6).equals(V2_MAGIC) && data.length >= 0x1F) { + ciphertext = data.subarray(0xF, 0x1F) + } + } catch { /* 忽略 */ } + } + + // 计算 XOR 密钥 + let xorKey: number | null = null + let maxCount = 0 + for (const [key, count] of Object.entries(tailCounts)) { + if (count > maxCount) { + maxCount = count + const [x, y] = key.split('_').map(Number) + const k = x ^ 0xFF + if (k === (y ^ 0xD9)) xorKey = k + } + } + + return { ciphertext, xorKey } + } +} \ No newline at end of file diff --git a/electron/services/keyServiceMac.ts b/electron/services/keyServiceMac.ts index f87e8d0..af33b75 100644 --- a/electron/services/keyServiceMac.ts +++ b/electron/services/keyServiceMac.ts @@ -136,7 +136,7 @@ export class KeyServiceMac { if (sipStatus.enabled) { return { success: false, - error: 'SIP (系统完整性保护) 已开启,无法获取密钥。请关闭 SIP 后重试。\n\n关闭方法:\n1. 重启 Mac 并按住 Command + R 进入恢复模式\n2. 打开终端,输入: csrutil disable\n3. 重启电脑' + error: 'SIP (系统完整性保护) 已开启,无法获取密钥。请关闭 SIP 后重试。\n\n关闭方法:\n1. Intel 芯片:重启 Mac 并按住 Command + R 进入恢复模式\n2. Apple 芯片(M 系列):关机后长按开机(指纹)键,选择“设置(选项)”进入恢复模式\n3. 打开终端,输入: csrutil disable\n4. 重启电脑' } } diff --git a/electron/services/messagePushService.ts b/electron/services/messagePushService.ts new file mode 100644 index 0000000..07b219b --- /dev/null +++ b/electron/services/messagePushService.ts @@ -0,0 +1,371 @@ +import { ConfigService } from './config' +import { chatService, type ChatSession, type Message } from './chatService' +import { wcdbService } from './wcdbService' +import { httpService } from './httpService' + +interface SessionBaseline { + lastTimestamp: number + unreadCount: number +} + +interface MessagePushPayload { + event: 'message.new' + sessionId: string + messageKey: string + avatarUrl?: string + sourceName: string + groupName?: string + content: string | null +} + +const PUSH_CONFIG_KEYS = new Set([ + 'messagePushEnabled', + 'dbPath', + 'decryptKey', + 'myWxid' +]) + +class MessagePushService { + private readonly configService: ConfigService + private readonly sessionBaseline = new Map() + private readonly recentMessageKeys = new Map() + private readonly groupNicknameCache = new Map; updatedAt: number }>() + private readonly debounceMs = 350 + private readonly recentMessageTtlMs = 10 * 60 * 1000 + private readonly groupNicknameCacheTtlMs = 5 * 60 * 1000 + private debounceTimer: ReturnType | null = null + private processing = false + private rerunRequested = false + private started = false + private baselineReady = false + + constructor() { + this.configService = ConfigService.getInstance() + } + + start(): void { + if (this.started) return + this.started = true + void this.refreshConfiguration('startup') + } + + handleDbMonitorChange(type: string, json: string): void { + if (!this.started) return + if (!this.isPushEnabled()) return + + let payload: Record | null = null + try { + payload = JSON.parse(json) + } catch { + payload = null + } + + const tableName = String(payload?.table || '').trim().toLowerCase() + if (tableName && tableName !== 'session') { + return + } + + this.scheduleSync() + } + + async handleConfigChanged(key: string): Promise { + if (!PUSH_CONFIG_KEYS.has(String(key || '').trim())) return + if (key === 'dbPath' || key === 'decryptKey' || key === 'myWxid') { + this.resetRuntimeState() + chatService.close() + } + await this.refreshConfiguration(`config:${key}`) + } + + handleConfigCleared(): void { + this.resetRuntimeState() + chatService.close() + } + + private isPushEnabled(): boolean { + return this.configService.get('messagePushEnabled') === true + } + + private resetRuntimeState(): void { + this.sessionBaseline.clear() + this.recentMessageKeys.clear() + this.groupNicknameCache.clear() + this.baselineReady = false + if (this.debounceTimer) { + clearTimeout(this.debounceTimer) + this.debounceTimer = null + } + } + + private async refreshConfiguration(reason: string): Promise { + if (!this.isPushEnabled()) { + this.resetRuntimeState() + return + } + + const connectResult = await chatService.connect() + if (!connectResult.success) { + console.warn(`[MessagePushService] Bootstrap connect failed (${reason}):`, connectResult.error) + return + } + + await this.bootstrapBaseline() + } + + private async bootstrapBaseline(): Promise { + const sessionsResult = await chatService.getSessions() + if (!sessionsResult.success || !sessionsResult.sessions) { + return + } + this.setBaseline(sessionsResult.sessions as ChatSession[]) + this.baselineReady = true + } + + private scheduleSync(): void { + if (this.debounceTimer) { + clearTimeout(this.debounceTimer) + } + + this.debounceTimer = setTimeout(() => { + this.debounceTimer = null + void this.flushPendingChanges() + }, this.debounceMs) + } + + private async flushPendingChanges(): Promise { + if (this.processing) { + this.rerunRequested = true + return + } + + this.processing = true + try { + if (!this.isPushEnabled()) return + + const connectResult = await chatService.connect() + if (!connectResult.success) { + console.warn('[MessagePushService] Sync connect failed:', connectResult.error) + return + } + + const sessionsResult = await chatService.getSessions() + if (!sessionsResult.success || !sessionsResult.sessions) { + return + } + + const sessions = sessionsResult.sessions as ChatSession[] + if (!this.baselineReady) { + this.setBaseline(sessions) + this.baselineReady = true + return + } + + const previousBaseline = new Map(this.sessionBaseline) + this.setBaseline(sessions) + + const candidates = sessions.filter((session) => this.shouldInspectSession(previousBaseline.get(session.username), session)) + for (const session of candidates) { + await this.pushSessionMessages(session, previousBaseline.get(session.username)) + } + } finally { + this.processing = false + if (this.rerunRequested) { + this.rerunRequested = false + this.scheduleSync() + } + } + } + + private setBaseline(sessions: ChatSession[]): void { + this.sessionBaseline.clear() + for (const session of sessions) { + this.sessionBaseline.set(session.username, { + lastTimestamp: Number(session.lastTimestamp || 0), + unreadCount: Number(session.unreadCount || 0) + }) + } + } + + private shouldInspectSession(previous: SessionBaseline | undefined, session: ChatSession): boolean { + const sessionId = String(session.username || '').trim() + if (!sessionId || sessionId.toLowerCase().includes('placeholder_foldgroup')) { + return false + } + + const summary = String(session.summary || '').trim() + if (Number(session.lastMsgType || 0) === 10002 || summary.includes('撤回了一条消息')) { + return false + } + + const lastTimestamp = Number(session.lastTimestamp || 0) + const unreadCount = Number(session.unreadCount || 0) + + if (!previous) { + return unreadCount > 0 && lastTimestamp > 0 + } + + if (lastTimestamp <= previous.lastTimestamp) { + return false + } + + // unread 未增长时,大概率是自己发送、其他设备已读或状态同步,不作为主动推送 + return unreadCount > previous.unreadCount + } + + private async pushSessionMessages(session: ChatSession, previous: SessionBaseline | undefined): Promise { + const since = Math.max(0, Number(previous?.lastTimestamp || 0) - 1) + const newMessagesResult = await chatService.getNewMessages(session.username, since, 1000) + if (!newMessagesResult.success || !newMessagesResult.messages || newMessagesResult.messages.length === 0) { + return + } + + for (const message of newMessagesResult.messages) { + const messageKey = String(message.messageKey || '').trim() + if (!messageKey) continue + if (message.isSend === 1) continue + + if (previous && Number(message.createTime || 0) < Number(previous.lastTimestamp || 0)) { + continue + } + + if (this.isRecentMessage(messageKey)) { + continue + } + + const payload = await this.buildPayload(session, message) + if (!payload) continue + + httpService.broadcastMessagePush(payload) + this.rememberMessageKey(messageKey) + } + } + + private async buildPayload(session: ChatSession, message: Message): Promise { + const sessionId = String(session.username || '').trim() + const messageKey = String(message.messageKey || '').trim() + if (!sessionId || !messageKey) return null + + const isGroup = sessionId.endsWith('@chatroom') + const content = this.getMessageDisplayContent(message) + + if (isGroup) { + const groupInfo = await chatService.getContactAvatar(sessionId) + const groupName = session.displayName || groupInfo?.displayName || sessionId + const sourceName = await this.resolveGroupSourceName(sessionId, message, session) + return { + event: 'message.new', + sessionId, + messageKey, + avatarUrl: session.avatarUrl || groupInfo?.avatarUrl, + groupName, + sourceName, + content + } + } + + const contactInfo = await chatService.getContactAvatar(sessionId) + return { + event: 'message.new', + sessionId, + messageKey, + avatarUrl: session.avatarUrl || contactInfo?.avatarUrl, + sourceName: session.displayName || contactInfo?.displayName || sessionId, + content + } + } + + private getMessageDisplayContent(message: Message): string | null { + switch (Number(message.localType || 0)) { + case 1: + return message.rawContent || null + case 3: + return '[图片]' + case 34: + return '[语音]' + case 43: + return '[视频]' + case 47: + return '[表情]' + case 42: + return message.cardNickname || '[名片]' + case 48: + return '[位置]' + case 49: + return message.linkTitle || message.fileName || '[消息]' + default: + return message.parsedContent || message.rawContent || null + } + } + + private async resolveGroupSourceName(chatroomId: string, message: Message, session: ChatSession): Promise { + const senderUsername = String(message.senderUsername || '').trim() + if (!senderUsername) { + return session.lastSenderDisplayName || '未知发送者' + } + + const groupNicknames = await this.getGroupNicknames(chatroomId) + const normalizedSender = this.normalizeAccountId(senderUsername) + const nickname = groupNicknames[senderUsername] + || groupNicknames[senderUsername.toLowerCase()] + || groupNicknames[normalizedSender] + || groupNicknames[normalizedSender.toLowerCase()] + + if (nickname) { + return nickname + } + + const contactInfo = await chatService.getContactAvatar(senderUsername) + return contactInfo?.displayName || senderUsername + } + + private async getGroupNicknames(chatroomId: string): Promise> { + const cacheKey = String(chatroomId || '').trim() + if (!cacheKey) return {} + + const cached = this.groupNicknameCache.get(cacheKey) + if (cached && Date.now() - cached.updatedAt < this.groupNicknameCacheTtlMs) { + return cached.nicknames + } + + const result = await wcdbService.getGroupNicknames(cacheKey) + const nicknames = result.success && result.nicknames ? result.nicknames : {} + this.groupNicknameCache.set(cacheKey, { nicknames, updatedAt: Date.now() }) + return nicknames + } + + private normalizeAccountId(value: string): string { + const trimmed = String(value || '').trim() + if (!trimmed) return trimmed + + if (trimmed.toLowerCase().startsWith('wxid_')) { + const match = trimmed.match(/^(wxid_[^_]+)/i) + return match ? match[1] : trimmed + } + + const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/) + return suffixMatch ? suffixMatch[1] : trimmed + } + + private isRecentMessage(messageKey: string): boolean { + this.pruneRecentMessageKeys() + const timestamp = this.recentMessageKeys.get(messageKey) + return typeof timestamp === 'number' && Date.now() - timestamp < this.recentMessageTtlMs + } + + private rememberMessageKey(messageKey: string): void { + this.recentMessageKeys.set(messageKey, Date.now()) + this.pruneRecentMessageKeys() + } + + private pruneRecentMessageKeys(): void { + const now = Date.now() + for (const [key, timestamp] of this.recentMessageKeys.entries()) { + if (now - timestamp > this.recentMessageTtlMs) { + this.recentMessageKeys.delete(key) + } + } + } + +} + +export const messagePushService = new MessagePushService() diff --git a/electron/services/snsService.ts b/electron/services/snsService.ts index 2bb2908..15497a9 100644 --- a/electron/services/snsService.ts +++ b/electron/services/snsService.ts @@ -663,100 +663,24 @@ class SnsService { } async getSnsUsernames(): Promise<{ success: boolean; usernames?: string[]; error?: string }> { - const collect = (rows?: any[]): string[] => { - if (!Array.isArray(rows)) return [] - const usernames: string[] = [] - for (const row of rows) { - const raw = row?.user_name ?? row?.userName ?? row?.username ?? Object.values(row || {})[0] - const username = typeof raw === 'string' ? raw.trim() : String(raw || '').trim() - if (username) usernames.push(username) - } - return usernames + const result = await wcdbService.getSnsUsernames() + if (!result.success) { + return { success: false, error: result.error || '获取朋友圈联系人失败' } } - - const primary = await wcdbService.execQuery( - 'sns', - null, - "SELECT DISTINCT user_name FROM SnsTimeLine WHERE user_name IS NOT NULL AND user_name <> ''" - ) - const fallback = await wcdbService.execQuery( - 'sns', - null, - "SELECT DISTINCT userName FROM SnsTimeLine WHERE userName IS NOT NULL AND userName <> ''" - ) - - const merged = Array.from(new Set([ - ...collect(primary.rows), - ...collect(fallback.rows) - ])) - - // 任一查询成功且拿到用户名即视为成功,避免因为列名差异导致误判为空。 - if (merged.length > 0) { - return { success: true, usernames: merged } - } - - // 两条查询都成功但无数据,说明确实没有朋友圈发布者。 - if (primary.success || fallback.success) { - return { success: true, usernames: [] } - } - - return { success: false, error: primary.error || fallback.error || '获取朋友圈联系人失败' } + return { success: true, usernames: result.usernames || [] } } private async getExportStatsFromTableCount(myWxid?: string): Promise<{ totalPosts: number; totalFriends: number; myPosts: number | null }> { - let totalPosts = 0 - let totalFriends = 0 - let myPosts: number | null = null - - const postCountResult = await wcdbService.execQuery('sns', null, 'SELECT COUNT(1) AS total FROM SnsTimeLine') - if (postCountResult.success && postCountResult.rows && postCountResult.rows.length > 0) { - totalPosts = this.parseCountValue(postCountResult.rows[0]) - } - - if (totalPosts > 0) { - const friendCountPrimary = await wcdbService.execQuery( - 'sns', - null, - "SELECT COUNT(DISTINCT user_name) AS total FROM SnsTimeLine WHERE user_name IS NOT NULL AND user_name <> ''" - ) - if (friendCountPrimary.success && friendCountPrimary.rows && friendCountPrimary.rows.length > 0) { - totalFriends = this.parseCountValue(friendCountPrimary.rows[0]) - } else { - const friendCountFallback = await wcdbService.execQuery( - 'sns', - null, - "SELECT COUNT(DISTINCT userName) AS total FROM SnsTimeLine WHERE userName IS NOT NULL AND userName <> ''" - ) - if (friendCountFallback.success && friendCountFallback.rows && friendCountFallback.rows.length > 0) { - totalFriends = this.parseCountValue(friendCountFallback.rows[0]) - } - } - } - const normalizedMyWxid = this.toOptionalString(myWxid) - if (normalizedMyWxid) { - const myPostPrimary = await wcdbService.execQuery( - 'sns', - null, - "SELECT COUNT(1) AS total FROM SnsTimeLine WHERE user_name = ?", - [normalizedMyWxid] - ) - if (myPostPrimary.success && myPostPrimary.rows && myPostPrimary.rows.length > 0) { - myPosts = this.parseCountValue(myPostPrimary.rows[0]) - } else { - const myPostFallback = await wcdbService.execQuery( - 'sns', - null, - "SELECT COUNT(1) AS total FROM SnsTimeLine WHERE userName = ?", - [normalizedMyWxid] - ) - if (myPostFallback.success && myPostFallback.rows && myPostFallback.rows.length > 0) { - myPosts = this.parseCountValue(myPostFallback.rows[0]) - } - } + const result = await wcdbService.getSnsExportStats(normalizedMyWxid || undefined) + if (!result.success || !result.data) { + return { totalPosts: 0, totalFriends: 0, myPosts: normalizedMyWxid ? 0 : null } + } + return { + totalPosts: Number(result.data.totalPosts || 0), + totalFriends: Number(result.data.totalFriends || 0), + myPosts: result.data.myPosts === null || result.data.myPosts === undefined ? null : Number(result.data.myPosts || 0) } - - return { totalPosts, totalFriends, myPosts } } async getExportStats(options?: { diff --git a/electron/services/videoService.ts b/electron/services/videoService.ts index 10eb1d2..761e017 100644 --- a/electron/services/videoService.ts +++ b/electron/services/videoService.ts @@ -5,316 +5,553 @@ import { ConfigService } from './config' import { wcdbService } from './wcdbService' export interface VideoInfo { - videoUrl?: string // 视频文件路径(用于 readFile) - coverUrl?: string // 封面 data URL - thumbUrl?: string // 缩略图 data URL - exists: boolean + videoUrl?: string // 视频文件路径(用于 readFile) + coverUrl?: string // 封面 data URL + thumbUrl?: string // 缩略图 data URL + exists: boolean +} + +interface TimedCacheEntry { + value: T + expiresAt: number +} + +interface VideoIndexEntry { + videoPath?: string + coverPath?: string + thumbPath?: string } class VideoService { - private configService: ConfigService + private configService: ConfigService + private hardlinkResolveCache = new Map>() + private videoInfoCache = new Map>() + private videoDirIndexCache = new Map>>() + private pendingVideoInfo = new Map>() + private readonly hardlinkCacheTtlMs = 10 * 60 * 1000 + private readonly videoInfoCacheTtlMs = 2 * 60 * 1000 + private readonly videoIndexCacheTtlMs = 90 * 1000 + private readonly maxCacheEntries = 2000 + private readonly maxIndexEntries = 6 - constructor() { - this.configService = new ConfigService() + constructor() { + this.configService = new ConfigService() + } + + private log(message: string, meta?: Record): void { + try { + const timestamp = new Date().toISOString() + const metaStr = meta ? ` ${JSON.stringify(meta)}` : '' + const logDir = join(app.getPath('userData'), 'logs') + if (!existsSync(logDir)) mkdirSync(logDir, { recursive: true }) + appendFileSync(join(logDir, 'wcdb.log'), `[${timestamp}] [VideoService] ${message}${metaStr}\n`, 'utf8') + } catch { } + } + + private readTimedCache(cache: Map>, key: string): T | undefined { + const hit = cache.get(key) + if (!hit) return undefined + if (hit.expiresAt <= Date.now()) { + cache.delete(key) + return undefined + } + return hit.value + } + + private writeTimedCache( + cache: Map>, + key: string, + value: T, + ttlMs: number, + maxEntries: number + ): void { + cache.set(key, { value, expiresAt: Date.now() + ttlMs }) + if (cache.size <= maxEntries) return + + const now = Date.now() + for (const [cacheKey, entry] of cache) { + if (entry.expiresAt <= now) { + cache.delete(cacheKey) + } } - private log(message: string, meta?: Record): void { - try { - const timestamp = new Date().toISOString() - const metaStr = meta ? ` ${JSON.stringify(meta)}` : '' - const logDir = join(app.getPath('userData'), 'logs') - if (!existsSync(logDir)) mkdirSync(logDir, { recursive: true }) - appendFileSync(join(logDir, 'wcdb.log'), `[${timestamp}] [VideoService] ${message}${metaStr}\n`, 'utf8') - } catch {} + while (cache.size > maxEntries) { + const oldestKey = cache.keys().next().value as string | undefined + if (!oldestKey) break + cache.delete(oldestKey) + } + } + + /** + * 获取数据库根目录 + */ + private getDbPath(): string { + return this.configService.get('dbPath') || '' + } + + /** + * 获取当前用户的wxid + */ + private getMyWxid(): string { + return this.configService.get('myWxid') || '' + } + + /** + * 清理 wxid 目录名(去掉后缀) + */ + private cleanWxid(wxid: string): string { + const trimmed = wxid.trim() + if (!trimmed) return trimmed + + if (trimmed.toLowerCase().startsWith('wxid_')) { + const match = trimmed.match(/^(wxid_[^_]+)/i) + if (match) return match[1] + return trimmed } - /** - * 获取数据库根目录 - */ - private getDbPath(): string { - return this.configService.get('dbPath') || '' + const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/) + if (suffixMatch) return suffixMatch[1] + + return trimmed + } + + private getScopeKey(dbPath: string, wxid: string): string { + return `${dbPath}::${this.cleanWxid(wxid)}`.toLowerCase() + } + + private resolveVideoBaseDir(dbPath: string, wxid: string): string { + const cleanedWxid = this.cleanWxid(wxid) + const dbPathLower = dbPath.toLowerCase() + const wxidLower = wxid.toLowerCase() + const cleanedWxidLower = cleanedWxid.toLowerCase() + const dbPathContainsWxid = dbPathLower.includes(wxidLower) || dbPathLower.includes(cleanedWxidLower) + if (dbPathContainsWxid) { + return join(dbPath, 'msg', 'video') + } + return join(dbPath, wxid, 'msg', 'video') + } + + private getHardlinkDbPaths(dbPath: string, wxid: string, cleanedWxid: string): string[] { + const dbPathLower = dbPath.toLowerCase() + const wxidLower = wxid.toLowerCase() + const cleanedWxidLower = cleanedWxid.toLowerCase() + const dbPathContainsWxid = dbPathLower.includes(wxidLower) || dbPathLower.includes(cleanedWxidLower) + + if (dbPathContainsWxid) { + return [join(dbPath, 'db_storage', 'hardlink', 'hardlink.db')] } - /** - * 获取当前用户的wxid - */ - private getMyWxid(): string { - return this.configService.get('myWxid') || '' + return [ + join(dbPath, wxid, 'db_storage', 'hardlink', 'hardlink.db'), + join(dbPath, cleanedWxid, 'db_storage', 'hardlink', 'hardlink.db') + ] + } + + /** + * 从 video_hardlink_info_v4 表查询视频文件名 + * 使用 wcdb 专属接口查询加密的 hardlink.db + */ + private async resolveVideoHardlinks( + md5List: string[], + dbPath: string, + wxid: string, + cleanedWxid: string + ): Promise> { + const scopeKey = this.getScopeKey(dbPath, wxid) + const normalizedList = Array.from( + new Set((md5List || []).map((item) => String(item || '').trim().toLowerCase()).filter(Boolean)) + ) + const resolvedMap = new Map() + const unresolvedSet = new Set(normalizedList) + + for (const md5 of normalizedList) { + const cacheKey = `${scopeKey}|${md5}` + const cached = this.readTimedCache(this.hardlinkResolveCache, cacheKey) + if (cached === undefined) continue + if (cached) resolvedMap.set(md5, cached) + unresolvedSet.delete(md5) } - /** - * 获取缓存目录(解密后的数据库存放位置) - */ - private getCachePath(): string { - return this.configService.getCacheBasePath() - } + if (unresolvedSet.size === 0) return resolvedMap - /** - * 清理 wxid 目录名(去掉后缀) - */ - private cleanWxid(wxid: string): string { - const trimmed = wxid.trim() - if (!trimmed) return trimmed - - if (trimmed.toLowerCase().startsWith('wxid_')) { - const match = trimmed.match(/^(wxid_[^_]+)/i) - if (match) return match[1] - return trimmed - } - - const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/) - if (suffixMatch) return suffixMatch[1] - - return trimmed - } - - /** - * 从 video_hardlink_info_v4 表查询视频文件名 - * 使用 wcdbService.execQuery 查询加密的 hardlink.db - */ - private async queryVideoFileName(md5: string): Promise { - const dbPath = this.getDbPath() - const wxid = this.getMyWxid() - const cleanedWxid = this.cleanWxid(wxid) - - this.log('queryVideoFileName 开始', { md5, wxid, cleanedWxid, dbPath }) - - if (!wxid) { - this.log('queryVideoFileName: wxid 为空') - return undefined - } - - // 使用 wcdbService.execQuery 查询加密的 hardlink.db - if (dbPath) { - const dbPathLower = dbPath.toLowerCase() - const wxidLower = wxid.toLowerCase() - const cleanedWxidLower = cleanedWxid.toLowerCase() - const dbPathContainsWxid = dbPathLower.includes(wxidLower) || dbPathLower.includes(cleanedWxidLower) - - const encryptedDbPaths: string[] = [] - if (dbPathContainsWxid) { - encryptedDbPaths.push(join(dbPath, 'db_storage', 'hardlink', 'hardlink.db')) - } else { - encryptedDbPaths.push(join(dbPath, wxid, 'db_storage', 'hardlink', 'hardlink.db')) - encryptedDbPaths.push(join(dbPath, cleanedWxid, 'db_storage', 'hardlink', 'hardlink.db')) - } - - for (const p of encryptedDbPaths) { - if (existsSync(p)) { - try { - this.log('尝试加密 hardlink.db', { path: p }) - const escapedMd5 = md5.replace(/'/g, "''") - const sql = `SELECT file_name FROM video_hardlink_info_v4 WHERE md5 = '${escapedMd5}' LIMIT 1` - const result = await wcdbService.execQuery('media', p, sql) - - if (result.success && result.rows && result.rows.length > 0) { - const row = result.rows[0] - if (row?.file_name) { - const realMd5 = String(row.file_name).replace(/\.[^.]+$/, '') - this.log('加密 hardlink.db 命中', { file_name: row.file_name, realMd5 }) - return realMd5 - } - } - this.log('加密 hardlink.db 未命中', { path: p, result: JSON.stringify(result).slice(0, 200) }) - } catch (e) { - this.log('加密 hardlink.db 查询失败', { path: p, error: String(e) }) - } - } else { - this.log('加密 hardlink.db 不存在', { path: p }) - } - } - } - this.log('queryVideoFileName: 所有方法均未找到', { md5 }) - return undefined - } - - /** - * 将文件转换为 data URL - */ - private fileToDataUrl(filePath: string, mimeType: string): string | undefined { - try { - if (!existsSync(filePath)) return undefined - const buffer = readFileSync(filePath) - return `data:${mimeType};base64,${buffer.toString('base64')}` - } catch { - return undefined - } - } - - /** - * 根据视频MD5获取视频文件信息 - * 视频存放在: {数据库根目录}/{用户wxid}/msg/video/{年月}/ - * 文件命名: {md5}.mp4, {md5}.jpg, {md5}_thumb.jpg - */ - async getVideoInfo(videoMd5: string): Promise { - const dbPath = this.getDbPath() - const wxid = this.getMyWxid() - - this.log('getVideoInfo 开始', { videoMd5, dbPath, wxid }) - - if (!dbPath || !wxid || !videoMd5) { - this.log('getVideoInfo: 参数缺失', { dbPath: !!dbPath, wxid: !!wxid, videoMd5: !!videoMd5 }) - return { exists: false } - } - - // 先尝试从数据库查询真正的视频文件名 - const realVideoMd5 = await this.queryVideoFileName(videoMd5) || videoMd5 - this.log('realVideoMd5', { input: videoMd5, resolved: realVideoMd5, changed: realVideoMd5 !== videoMd5 }) - - // 检查 dbPath 是否已经包含 wxid,避免重复拼接 - const dbPathLower = dbPath.toLowerCase() - const wxidLower = wxid.toLowerCase() - const cleanedWxid = this.cleanWxid(wxid) - - let videoBaseDir: string - if (dbPathLower.includes(wxidLower) || dbPathLower.includes(cleanedWxid.toLowerCase())) { - videoBaseDir = join(dbPath, 'msg', 'video') + const encryptedDbPaths = this.getHardlinkDbPaths(dbPath, wxid, cleanedWxid) + for (const p of encryptedDbPaths) { + if (!existsSync(p) || unresolvedSet.size === 0) continue + const unresolved = Array.from(unresolvedSet) + const requests = unresolved.map((md5) => ({ md5, dbPath: p })) + try { + const batchResult = await wcdbService.resolveVideoHardlinkMd5Batch(requests) + if (batchResult.success && Array.isArray(batchResult.rows)) { + for (const row of batchResult.rows) { + const index = Number.isFinite(Number(row?.index)) ? Math.floor(Number(row?.index)) : -1 + const inputMd5 = index >= 0 && index < requests.length + ? requests[index].md5 + : String(row?.md5 || '').trim().toLowerCase() + if (!inputMd5) continue + const resolvedMd5 = row?.success && row?.data?.resolved_md5 + ? String(row.data.resolved_md5).trim().toLowerCase() + : '' + if (!resolvedMd5) continue + const cacheKey = `${scopeKey}|${inputMd5}` + this.writeTimedCache(this.hardlinkResolveCache, cacheKey, resolvedMd5, this.hardlinkCacheTtlMs, this.maxCacheEntries) + resolvedMap.set(inputMd5, resolvedMd5) + unresolvedSet.delete(inputMd5) + } } else { - videoBaseDir = join(dbPath, wxid, 'msg', 'video') + // 兼容不支持批量接口的版本,回退单条请求。 + for (const req of requests) { + try { + const single = await wcdbService.resolveVideoHardlinkMd5(req.md5, req.dbPath) + const resolvedMd5 = single.success && single.data?.resolved_md5 + ? String(single.data.resolved_md5).trim().toLowerCase() + : '' + if (!resolvedMd5) continue + const cacheKey = `${scopeKey}|${req.md5}` + this.writeTimedCache(this.hardlinkResolveCache, cacheKey, resolvedMd5, this.hardlinkCacheTtlMs, this.maxCacheEntries) + resolvedMap.set(req.md5, resolvedMd5) + unresolvedSet.delete(req.md5) + } catch { } + } } - - this.log('videoBaseDir', { videoBaseDir, exists: existsSync(videoBaseDir) }) - - if (!existsSync(videoBaseDir)) { - this.log('getVideoInfo: videoBaseDir 不存在') - return { exists: false } - } - - // 遍历年月目录查找视频文件 - try { - const allDirs = readdirSync(videoBaseDir) - const yearMonthDirs = allDirs - .filter(dir => { - const dirPath = join(videoBaseDir, dir) - return statSync(dirPath).isDirectory() - }) - .sort((a, b) => b.localeCompare(a)) - - this.log('扫描目录', { dirs: yearMonthDirs }) - - for (const yearMonth of yearMonthDirs) { - const dirPath = join(videoBaseDir, yearMonth) - const videoPath = join(dirPath, `${realVideoMd5}.mp4`) - - if (existsSync(videoPath)) { - // 封面/缩略图使用不带 _raw 后缀的基础名(自己发的视频文件名带 _raw,但封面不带) - const baseMd5 = realVideoMd5.replace(/_raw$/, '') - const coverPath = join(dirPath, `${baseMd5}.jpg`) - const thumbPath = join(dirPath, `${baseMd5}_thumb.jpg`) - - // 列出同目录下与该 md5 相关的所有文件,帮助排查封面命名 - const allFiles = readdirSync(dirPath) - const relatedFiles = allFiles.filter(f => f.toLowerCase().startsWith(realVideoMd5.slice(0, 8).toLowerCase())) - this.log('找到视频,相关文件列表', { - videoPath, - coverExists: existsSync(coverPath), - thumbExists: existsSync(thumbPath), - relatedFiles, - coverPath, - thumbPath - }) - - return { - videoUrl: videoPath, - coverUrl: this.fileToDataUrl(coverPath, 'image/jpeg'), - thumbUrl: this.fileToDataUrl(thumbPath, 'image/jpeg'), - exists: true - } - } - } - - // 没找到,列出所有目录里的 mp4 文件帮助排查(最多每目录 10 个) - this.log('未找到视频,开始全目录扫描', { - lookingForOriginal: `${videoMd5}.mp4`, - lookingForResolved: `${realVideoMd5}.mp4`, - hardlinkResolved: realVideoMd5 !== videoMd5 - }) - for (const yearMonth of yearMonthDirs) { - const dirPath = join(videoBaseDir, yearMonth) - try { - const allFiles = readdirSync(dirPath) - const mp4Files = allFiles.filter(f => f.endsWith('.mp4')).slice(0, 10) - // 检查原始 md5 是否部分匹配(前8位) - const partialMatch = mp4Files.filter(f => f.toLowerCase().startsWith(videoMd5.slice(0, 8).toLowerCase())) - this.log(`目录 ${yearMonth} 扫描结果`, { - totalFiles: allFiles.length, - mp4Count: allFiles.filter(f => f.endsWith('.mp4')).length, - sampleMp4: mp4Files, - partialMatchByOriginalMd5: partialMatch - }) - } catch (e) { - this.log(`目录 ${yearMonth} 读取失败`, { error: String(e) }) - } - } - } catch (e) { - this.log('getVideoInfo 遍历出错', { error: String(e) }) - } - - this.log('getVideoInfo: 未找到视频', { videoMd5, realVideoMd5 }) - return { exists: false } + } catch (e) { + this.log('resolveVideoHardlinks 批量查询失败', { path: p, error: String(e) }) + } } - /** - * 根据消息内容解析视频MD5 - */ - parseVideoMd5(content: string): string | undefined { - if (!content) return undefined + for (const md5 of unresolvedSet) { + const cacheKey = `${scopeKey}|${md5}` + this.writeTimedCache(this.hardlinkResolveCache, cacheKey, null, this.hardlinkCacheTtlMs, this.maxCacheEntries) + } - // 打印原始 XML 前 800 字符,帮助排查自己发的视频结构 - this.log('parseVideoMd5 原始内容', { preview: content.slice(0, 800) }) + return resolvedMap + } + private async queryVideoFileName(md5: string): Promise { + const normalizedMd5 = String(md5 || '').trim().toLowerCase() + const dbPath = this.getDbPath() + const wxid = this.getMyWxid() + const cleanedWxid = this.cleanWxid(wxid) + + this.log('queryVideoFileName 开始', { md5: normalizedMd5, wxid, cleanedWxid, dbPath }) + + if (!normalizedMd5 || !wxid || !dbPath) { + this.log('queryVideoFileName: 参数缺失', { hasMd5: !!normalizedMd5, hasWxid: !!wxid, hasDbPath: !!dbPath }) + return undefined + } + + const resolvedMap = await this.resolveVideoHardlinks([normalizedMd5], dbPath, wxid, cleanedWxid) + const resolved = resolvedMap.get(normalizedMd5) + if (resolved) { + this.log('queryVideoFileName 命中', { input: normalizedMd5, resolved }) + return resolved + } + return undefined + } + + async preloadVideoHardlinkMd5s(md5List: string[]): Promise { + const dbPath = this.getDbPath() + const wxid = this.getMyWxid() + const cleanedWxid = this.cleanWxid(wxid) + if (!dbPath || !wxid) return + await this.resolveVideoHardlinks(md5List, dbPath, wxid, cleanedWxid) + } + + /** + * 将文件转换为 data URL + */ + private fileToDataUrl(filePath: string | undefined, mimeType: string): string | undefined { + try { + if (!filePath || !existsSync(filePath)) return undefined + const buffer = readFileSync(filePath) + return `data:${mimeType};base64,${buffer.toString('base64')}` + } catch { + return undefined + } + } + + private getOrBuildVideoIndex(videoBaseDir: string): Map { + const cached = this.readTimedCache(this.videoDirIndexCache, videoBaseDir) + if (cached) return cached + + const index = new Map() + const ensureEntry = (key: string): VideoIndexEntry => { + let entry = index.get(key) + if (!entry) { + entry = {} + index.set(key, entry) + } + return entry + } + + try { + const yearMonthDirs = readdirSync(videoBaseDir) + .filter((dir) => { + const dirPath = join(videoBaseDir, dir) + try { + return statSync(dirPath).isDirectory() + } catch { + return false + } + }) + .sort((a, b) => b.localeCompare(a)) + + for (const yearMonth of yearMonthDirs) { + const dirPath = join(videoBaseDir, yearMonth) + let files: string[] = [] try { - // 收集所有 md5 相关属性,方便对比 - const allMd5Attrs: string[] = [] - const md5Regex = /(?:md5|rawmd5|newmd5|originsourcemd5)\s*=\s*['"]([a-fA-F0-9]*)['"]/gi - let match - while ((match = md5Regex.exec(content)) !== null) { - allMd5Attrs.push(match[0]) - } - this.log('parseVideoMd5 所有 md5 属性', { attrs: allMd5Attrs }) - - // 方法1:从 提取(收到的视频) - const videoMsgMd5Match = /]*\smd5\s*=\s*['"]([a-fA-F0-9]+)['"]/i.exec(content) - if (videoMsgMd5Match) { - this.log('parseVideoMd5 命中 videomsg md5 属性', { md5: videoMsgMd5Match[1] }) - return videoMsgMd5Match[1].toLowerCase() - } - - // 方法2:从 提取(自己发的视频,没有 md5 只有 rawmd5) - const rawMd5Match = /]*\srawmd5\s*=\s*['"]([a-fA-F0-9]+)['"]/i.exec(content) - if (rawMd5Match) { - this.log('parseVideoMd5 命中 videomsg rawmd5 属性(自发视频)', { rawmd5: rawMd5Match[1] }) - return rawMd5Match[1].toLowerCase() - } - - // 方法3:任意属性 md5="..."(非 rawmd5/cdnthumbaeskey 等) - const attrMatch = /(?... 标签 - const md5TagMatch = /([a-fA-F0-9]+)<\/md5>/i.exec(content) - if (md5TagMatch) { - this.log('parseVideoMd5 命中 md5 标签', { md5: md5TagMatch[1] }) - return md5TagMatch[1].toLowerCase() - } - - // 方法5:兜底取 rawmd5 属性(任意位置) - const rawMd5Fallback = /\srawmd5\s*=\s*['"]([a-fA-F0-9]+)['"]/i.exec(content) - if (rawMd5Fallback) { - this.log('parseVideoMd5 兜底命中 rawmd5', { rawmd5: rawMd5Fallback[1] }) - return rawMd5Fallback[1].toLowerCase() - } - - this.log('parseVideoMd5 未提取到任何 md5', { contentLength: content.length }) - } catch (e) { - this.log('parseVideoMd5 异常', { error: String(e) }) + files = readdirSync(dirPath) + } catch { + continue } - return undefined + for (const file of files) { + const lower = file.toLowerCase() + const fullPath = join(dirPath, file) + + if (lower.endsWith('.mp4')) { + const md5 = lower.slice(0, -4) + const entry = ensureEntry(md5) + if (!entry.videoPath) entry.videoPath = fullPath + if (md5.endsWith('_raw')) { + const baseMd5 = md5.replace(/_raw$/, '') + const baseEntry = ensureEntry(baseMd5) + if (!baseEntry.videoPath) baseEntry.videoPath = fullPath + } + continue + } + + if (!lower.endsWith('.jpg')) continue + const jpgBase = lower.slice(0, -4) + if (jpgBase.endsWith('_thumb')) { + const baseMd5 = jpgBase.slice(0, -6) + const entry = ensureEntry(baseMd5) + if (!entry.thumbPath) entry.thumbPath = fullPath + } else { + const entry = ensureEntry(jpgBase) + if (!entry.coverPath) entry.coverPath = fullPath + } + } + } + + for (const [key, entry] of index) { + if (!key.endsWith('_raw')) continue + const baseKey = key.replace(/_raw$/, '') + const baseEntry = index.get(baseKey) + if (!baseEntry) continue + if (!entry.coverPath) entry.coverPath = baseEntry.coverPath + if (!entry.thumbPath) entry.thumbPath = baseEntry.thumbPath + } + } catch (e) { + this.log('构建视频索引失败', { videoBaseDir, error: String(e) }) } + + this.writeTimedCache( + this.videoDirIndexCache, + videoBaseDir, + index, + this.videoIndexCacheTtlMs, + this.maxIndexEntries + ) + return index + } + + private getVideoInfoFromIndex(index: Map, md5: string, includePoster = true): VideoInfo | null { + const normalizedMd5 = String(md5 || '').trim().toLowerCase() + if (!normalizedMd5) return null + + const candidates = [normalizedMd5] + const baseMd5 = normalizedMd5.replace(/_raw$/, '') + if (baseMd5 !== normalizedMd5) { + candidates.push(baseMd5) + } else { + candidates.push(`${normalizedMd5}_raw`) + } + + for (const key of candidates) { + const entry = index.get(key) + if (!entry?.videoPath) continue + if (!existsSync(entry.videoPath)) continue + if (!includePoster) { + return { + videoUrl: entry.videoPath, + exists: true + } + } + return { + videoUrl: entry.videoPath, + coverUrl: this.fileToDataUrl(entry.coverPath, 'image/jpeg'), + thumbUrl: this.fileToDataUrl(entry.thumbPath, 'image/jpeg'), + exists: true + } + } + + return null + } + + private fallbackScanVideo(videoBaseDir: string, realVideoMd5: string, includePoster = true): VideoInfo | null { + try { + const yearMonthDirs = readdirSync(videoBaseDir) + .filter((dir) => { + const dirPath = join(videoBaseDir, dir) + try { + return statSync(dirPath).isDirectory() + } catch { + return false + } + }) + .sort((a, b) => b.localeCompare(a)) + + for (const yearMonth of yearMonthDirs) { + const dirPath = join(videoBaseDir, yearMonth) + const videoPath = join(dirPath, `${realVideoMd5}.mp4`) + if (!existsSync(videoPath)) continue + if (!includePoster) { + return { + videoUrl: videoPath, + exists: true + } + } + const baseMd5 = realVideoMd5.replace(/_raw$/, '') + const coverPath = join(dirPath, `${baseMd5}.jpg`) + const thumbPath = join(dirPath, `${baseMd5}_thumb.jpg`) + return { + videoUrl: videoPath, + coverUrl: this.fileToDataUrl(coverPath, 'image/jpeg'), + thumbUrl: this.fileToDataUrl(thumbPath, 'image/jpeg'), + exists: true + } + } + } catch (e) { + this.log('fallback 扫描视频目录失败', { error: String(e) }) + } + return null + } + + /** + * 根据视频MD5获取视频文件信息 + * 视频存放在: {数据库根目录}/{用户wxid}/msg/video/{年月}/ + * 文件命名: {md5}.mp4, {md5}.jpg, {md5}_thumb.jpg + */ + async getVideoInfo(videoMd5: string, options?: { includePoster?: boolean }): Promise { + const normalizedMd5 = String(videoMd5 || '').trim().toLowerCase() + const includePoster = options?.includePoster !== false + const dbPath = this.getDbPath() + const wxid = this.getMyWxid() + + this.log('getVideoInfo 开始', { videoMd5: normalizedMd5, dbPath, wxid }) + + if (!dbPath || !wxid || !normalizedMd5) { + this.log('getVideoInfo: 参数缺失', { hasDbPath: !!dbPath, hasWxid: !!wxid, hasVideoMd5: !!normalizedMd5 }) + return { exists: false } + } + + const scopeKey = this.getScopeKey(dbPath, wxid) + const cacheKey = `${scopeKey}|${normalizedMd5}|poster=${includePoster ? 1 : 0}` + + const cachedInfo = this.readTimedCache(this.videoInfoCache, cacheKey) + if (cachedInfo) return cachedInfo + + const pending = this.pendingVideoInfo.get(cacheKey) + if (pending) return pending + + const task = (async (): Promise => { + const realVideoMd5 = await this.queryVideoFileName(normalizedMd5) || normalizedMd5 + const videoBaseDir = this.resolveVideoBaseDir(dbPath, wxid) + + if (!existsSync(videoBaseDir)) { + const miss = { exists: false } + this.writeTimedCache(this.videoInfoCache, cacheKey, miss, this.videoInfoCacheTtlMs, this.maxCacheEntries) + return miss + } + + const index = this.getOrBuildVideoIndex(videoBaseDir) + const indexed = this.getVideoInfoFromIndex(index, realVideoMd5, includePoster) + if (indexed) { + this.writeTimedCache(this.videoInfoCache, cacheKey, indexed, this.videoInfoCacheTtlMs, this.maxCacheEntries) + return indexed + } + + const fallback = this.fallbackScanVideo(videoBaseDir, realVideoMd5, includePoster) + if (fallback) { + this.writeTimedCache(this.videoInfoCache, cacheKey, fallback, this.videoInfoCacheTtlMs, this.maxCacheEntries) + return fallback + } + + const miss = { exists: false } + this.writeTimedCache(this.videoInfoCache, cacheKey, miss, this.videoInfoCacheTtlMs, this.maxCacheEntries) + this.log('getVideoInfo: 未找到视频', { inputMd5: normalizedMd5, resolvedMd5: realVideoMd5 }) + return miss + })() + + this.pendingVideoInfo.set(cacheKey, task) + try { + return await task + } finally { + this.pendingVideoInfo.delete(cacheKey) + } + } + + /** + * 根据消息内容解析视频MD5 + */ + parseVideoMd5(content: string): string | undefined { + if (!content) return undefined + + // 打印原始 XML 前 800 字符,帮助排查自己发的视频结构 + this.log('parseVideoMd5 原始内容', { preview: content.slice(0, 800) }) + + try { + // 收集所有 md5 相关属性,方便对比 + const allMd5Attrs: string[] = [] + const md5Regex = /(?:md5|rawmd5|newmd5|originsourcemd5)\s*=\s*['"]([a-fA-F0-9]*)['"]/gi + let match + while ((match = md5Regex.exec(content)) !== null) { + allMd5Attrs.push(match[0]) + } + this.log('parseVideoMd5 所有 md5 属性', { attrs: allMd5Attrs }) + + // 方法1:从 提取(收到的视频) + const videoMsgMd5Match = /]*\smd5\s*=\s*['"]([a-fA-F0-9]+)['"]/i.exec(content) + if (videoMsgMd5Match) { + this.log('parseVideoMd5 命中 videomsg md5 属性', { md5: videoMsgMd5Match[1] }) + return videoMsgMd5Match[1].toLowerCase() + } + + // 方法2:从 提取(自己发的视频,没有 md5 只有 rawmd5) + const rawMd5Match = /]*\srawmd5\s*=\s*['"]([a-fA-F0-9]+)['"]/i.exec(content) + if (rawMd5Match) { + this.log('parseVideoMd5 命中 videomsg rawmd5 属性(自发视频)', { rawmd5: rawMd5Match[1] }) + return rawMd5Match[1].toLowerCase() + } + + // 方法3:任意属性 md5="..."(非 rawmd5/cdnthumbaeskey 等) + const attrMatch = /(?... 标签 + const md5TagMatch = /([a-fA-F0-9]+)<\/md5>/i.exec(content) + if (md5TagMatch) { + this.log('parseVideoMd5 命中 md5 标签', { md5: md5TagMatch[1] }) + return md5TagMatch[1].toLowerCase() + } + + // 方法5:兜底取 rawmd5 属性(任意位置) + const rawMd5Fallback = /\srawmd5\s*=\s*['"]([a-fA-F0-9]+)['"]/i.exec(content) + if (rawMd5Fallback) { + this.log('parseVideoMd5 兜底命中 rawmd5', { rawmd5: rawMd5Fallback[1] }) + return rawMd5Fallback[1].toLowerCase() + } + + this.log('parseVideoMd5 未提取到任何 md5', { contentLength: content.length }) + } catch (e) { + this.log('parseVideoMd5 异常', { error: String(e) }) + } + + return undefined + } } export const videoService = new VideoService() diff --git a/electron/services/wcdbCore.ts b/electron/services/wcdbCore.ts index a9b99dd..028f2b5 100644 --- a/electron/services/wcdbCore.ts +++ b/electron/services/wcdbCore.ts @@ -5,47 +5,6 @@ import { tmpdir } from 'os' // DLL 初始化错误信息,用于帮助用户诊断问题 let lastDllInitError: string | null = null -/** - * 解析 extra_buffer(protobuf)中的免打扰状态 - * - field 12 (tag 0x60): 值非0 = 免打扰 - * 折叠状态通过 contact.flag & 0x10000000 判断 - */ -function parseExtraBuffer(raw: Buffer | string | null | undefined): { isMuted: boolean } { - if (!raw) return { isMuted: false } - // execQuery 返回的 BLOB 列是十六进制字符串,需要先解码 - const buf: Buffer = typeof raw === 'string' ? Buffer.from(raw, 'hex') : raw - if (buf.length === 0) return { isMuted: false } - let isMuted = false - let i = 0 - const len = buf.length - - const readVarint = (): number => { - let result = 0, shift = 0 - while (i < len) { - const b = buf[i++] - result |= (b & 0x7f) << shift - shift += 7 - if (!(b & 0x80)) break - } - return result - } - - while (i < len) { - const tag = readVarint() - const fieldNum = tag >>> 3 - const wireType = tag & 0x07 - if (wireType === 0) { - const val = readVarint() - if (fieldNum === 12 && val !== 0) isMuted = true - } else if (wireType === 2) { - const sz = readVarint() - i += sz - } else if (wireType === 5) { i += 4 - } else if (wireType === 1) { i += 8 - } else { break } - } - return { isMuted } -} export function getLastDllInitError(): string | null { return lastDllInitError } @@ -86,6 +45,11 @@ export class WcdbCore { private wcdbGetMessageMeta: any = null private wcdbGetContact: any = null private wcdbGetContactStatus: any = null + private wcdbGetContactTypeCounts: any = null + private wcdbGetContactsCompact: any = null + private wcdbGetContactAliasMap: any = null + private wcdbGetContactFriendFlags: any = null + private wcdbGetChatRoomExtBuffer: any = null private wcdbGetMessageTableStats: any = null private wcdbGetAggregateStats: any = null private wcdbGetAvailableYears: any = null @@ -106,9 +70,26 @@ export class WcdbCore { private wcdbGetEmoticonCdnUrl: any = null private wcdbGetDbStatus: any = null private wcdbGetVoiceData: any = null + private wcdbGetVoiceDataBatch: any = null + private wcdbGetMediaSchemaSummary: any = null + private wcdbGetSessionMessageCounts: any = null + private wcdbGetSessionMessageTypeStats: any = null + private wcdbGetSessionMessageTypeStatsBatch: any = null + private wcdbGetSessionMessageDateCounts: any = null + private wcdbGetSessionMessageDateCountsBatch: any = null + private wcdbGetMessagesByType: any = null + private wcdbGetHeadImageBuffers: any = null private wcdbSearchMessages: any = null private wcdbGetSnsTimeline: any = null private wcdbGetSnsAnnualStats: any = null + private wcdbGetSnsUsernames: any = null + private wcdbGetSnsExportStats: any = null + private wcdbGetMessageTableColumns: any = null + private wcdbGetMessageTableTimeRange: any = null + private wcdbResolveImageHardlink: any = null + private wcdbResolveImageHardlinkBatch: any = null + private wcdbResolveVideoHardlinkMd5: any = null + private wcdbResolveVideoHardlinkMd5Batch: any = null private wcdbInstallSnsBlockDeleteTrigger: any = null private wcdbUninstallSnsBlockDeleteTrigger: any = null private wcdbCheckSnsBlockDeleteTrigger: any = null @@ -129,6 +110,10 @@ export class WcdbCore { private avatarUrlCache: Map = new Map() private readonly avatarCacheTtlMs = 10 * 60 * 1000 + private imageHardlinkCache: Map = new Map() + private videoHardlinkCache: Map = new Map() + private readonly hardlinkCacheTtlMs = 10 * 60 * 1000 + private readonly hardlinkCacheMaxEntries = 20000 private logTimer: NodeJS.Timeout | null = null private lastLogTail: string | null = null private lastResolvedLogPath: string | null = null @@ -278,8 +263,9 @@ export class WcdbCore { */ private getDllPath(): string { const isMac = process.platform === 'darwin' - const libName = isMac ? 'libwcdb_api.dylib' : 'wcdb_api.dll' - const subDir = isMac ? 'macos' : '' + const isLinux = process.platform === 'linux' + const libName = isMac ? 'libwcdb_api.dylib' : isLinux ? 'libwcdb_api.so' : 'wcdb_api.dll' + const subDir = isMac ? 'macos' : isLinux ? 'linux' : '' const envDllPath = process.env.WCDB_DLL_PATH if (envDllPath && envDllPath.length > 0) { @@ -531,6 +517,48 @@ export class WcdbCore { return '' } + private escapeSqlString(value: string): string { + return String(value || '').replace(/'/g, "''") + } + + private buildContactSelectSql(usernames: string[] = []): string { + const uniq = Array.from(new Set((usernames || []).map((item) => String(item || '').trim()).filter(Boolean))) + if (uniq.length === 0) return 'SELECT * FROM contact' + const inList = uniq.map((username) => `'${this.escapeSqlString(username)}'`).join(',') + return `SELECT * FROM contact WHERE username IN (${inList})` + } + + private deriveContactTypeCounts(rows: Array>): { private: number; group: number; official: number; former_friend: number } { + const counts = { + private: 0, + group: 0, + official: 0, + former_friend: 0 + } + const excludeNames = new Set(['medianote', 'floatbottle', 'qmessage', 'qqmail', 'fmessage']) + + for (const row of rows || []) { + const username = this.pickFirstStringField(row, ['username', 'user_name', 'userName']) + if (!username) continue + + const localTypeRaw = row.local_type ?? row.localType ?? row.WCDB_CT_local_type ?? 0 + const localType = Number.isFinite(Number(localTypeRaw)) ? Math.floor(Number(localTypeRaw)) : 0 + const quanPin = this.pickFirstStringField(row, ['quan_pin', 'quanPin', 'WCDB_CT_quan_pin']) + + if (username.endsWith('@chatroom')) { + counts.group += 1 + } else if (username.startsWith('gh_')) { + counts.official += 1 + } else if (localType === 1 && !excludeNames.has(username)) { + counts.private += 1 + } else if (localType === 0 && quanPin) { + counts.former_friend += 1 + } + } + + return counts + } + /** * 初始化 WCDB */ @@ -550,6 +578,7 @@ export class WcdbCore { const dllDir = dirname(dllPath) const isMac = process.platform === 'darwin' + const isLinux = process.platform === 'linux' // 预加载依赖库 if (isMac) { @@ -563,6 +592,8 @@ export class WcdbCore { this.writeLog(`预加载 libWCDB.dylib 失败: ${String(e)}`) } } + } else if (isLinux) { + // 如果有libWCDB.so的话, 没有就算了 } else { const wcdbCorePath = join(dllDir, 'WCDB.dll') if (existsSync(wcdbCorePath)) { @@ -715,6 +746,32 @@ export class WcdbCore { this.wcdbGetContactStatus = null } + try { + this.wcdbGetContactTypeCounts = this.lib.func('int32 wcdb_get_contact_type_counts(int64 handle, _Out_ void** outJson)') + } catch { + this.wcdbGetContactTypeCounts = null + } + try { + this.wcdbGetContactsCompact = this.lib.func('int32 wcdb_get_contacts_compact(int64 handle, const char* usernamesJson, _Out_ void** outJson)') + } catch { + this.wcdbGetContactsCompact = null + } + try { + this.wcdbGetContactAliasMap = this.lib.func('int32 wcdb_get_contact_alias_map(int64 handle, const char* usernamesJson, _Out_ void** outJson)') + } catch { + this.wcdbGetContactAliasMap = null + } + try { + this.wcdbGetContactFriendFlags = this.lib.func('int32 wcdb_get_contact_friend_flags(int64 handle, const char* usernamesJson, _Out_ void** outJson)') + } catch { + this.wcdbGetContactFriendFlags = null + } + try { + this.wcdbGetChatRoomExtBuffer = this.lib.func('int32 wcdb_get_chat_room_ext_buffer(int64 handle, const char* chatroomId, _Out_ void** outJson)') + } catch { + this.wcdbGetChatRoomExtBuffer = null + } + // wcdb_status wcdb_get_message_table_stats(wcdb_handle handle, const char* session_id, char** out_json) this.wcdbGetMessageTableStats = this.lib.func('int32 wcdb_get_message_table_stats(int64 handle, const char* sessionId, _Out_ void** outJson)') @@ -817,6 +874,51 @@ export class WcdbCore { } catch { this.wcdbGetVoiceData = null } + try { + this.wcdbGetVoiceDataBatch = this.lib.func('int32 wcdb_get_voice_data_batch(int64 handle, const char* requestsJson, _Out_ void** outJson)') + } catch { + this.wcdbGetVoiceDataBatch = null + } + try { + this.wcdbGetMediaSchemaSummary = this.lib.func('int32 wcdb_get_media_schema_summary(int64 handle, const char* dbPath, _Out_ void** outJson)') + } catch { + this.wcdbGetMediaSchemaSummary = null + } + try { + this.wcdbGetSessionMessageCounts = this.lib.func('int32 wcdb_get_session_message_counts(int64 handle, const char* sessionIdsJson, _Out_ void** outJson)') + } catch { + this.wcdbGetSessionMessageCounts = null + } + try { + this.wcdbGetSessionMessageTypeStats = this.lib.func('int32 wcdb_get_session_message_type_stats(int64 handle, const char* sessionId, int32 beginTimestamp, int32 endTimestamp, _Out_ void** outJson)') + } catch { + this.wcdbGetSessionMessageTypeStats = null + } + try { + this.wcdbGetSessionMessageTypeStatsBatch = this.lib.func('int32 wcdb_get_session_message_type_stats_batch(int64 handle, const char* sessionIdsJson, const char* optionsJson, _Out_ void** outJson)') + } catch { + this.wcdbGetSessionMessageTypeStatsBatch = null + } + try { + this.wcdbGetSessionMessageDateCounts = this.lib.func('int32 wcdb_get_session_message_date_counts(int64 handle, const char* sessionId, _Out_ void** outJson)') + } catch { + this.wcdbGetSessionMessageDateCounts = null + } + try { + this.wcdbGetSessionMessageDateCountsBatch = this.lib.func('int32 wcdb_get_session_message_date_counts_batch(int64 handle, const char* sessionIdsJson, _Out_ void** outJson)') + } catch { + this.wcdbGetSessionMessageDateCountsBatch = null + } + try { + this.wcdbGetMessagesByType = this.lib.func('int32 wcdb_get_messages_by_type(int64 handle, const char* sessionId, int64 localType, int32 ascending, int32 limit, int32 offset, _Out_ void** outJson)') + } catch { + this.wcdbGetMessagesByType = null + } + try { + this.wcdbGetHeadImageBuffers = this.lib.func('int32 wcdb_get_head_image_buffers(int64 handle, const char* usernamesJson, _Out_ void** outJson)') + } catch { + this.wcdbGetHeadImageBuffers = null + } // wcdb_status wcdb_search_messages(wcdb_handle handle, const char* session_id, const char* keyword, int32_t limit, int32_t offset, int32_t begin_timestamp, int32_t end_timestamp, char** out_json) try { @@ -838,6 +940,46 @@ export class WcdbCore { } catch { this.wcdbGetSnsAnnualStats = null } + try { + this.wcdbGetSnsUsernames = this.lib.func('int32 wcdb_get_sns_usernames(int64 handle, _Out_ void** outJson)') + } catch { + this.wcdbGetSnsUsernames = null + } + try { + this.wcdbGetSnsExportStats = this.lib.func('int32 wcdb_get_sns_export_stats(int64 handle, const char* myWxid, _Out_ void** outJson)') + } catch { + this.wcdbGetSnsExportStats = null + } + try { + this.wcdbGetMessageTableColumns = this.lib.func('int32 wcdb_get_message_table_columns(int64 handle, const char* dbPath, const char* tableName, _Out_ void** outJson)') + } catch { + this.wcdbGetMessageTableColumns = null + } + try { + this.wcdbGetMessageTableTimeRange = this.lib.func('int32 wcdb_get_message_table_time_range(int64 handle, const char* dbPath, const char* tableName, _Out_ void** outJson)') + } catch { + this.wcdbGetMessageTableTimeRange = null + } + try { + this.wcdbResolveImageHardlink = this.lib.func('int32 wcdb_resolve_image_hardlink(int64 handle, const char* md5, const char* accountDir, _Out_ void** outJson)') + } catch { + this.wcdbResolveImageHardlink = null + } + try { + this.wcdbResolveImageHardlinkBatch = this.lib.func('int32 wcdb_resolve_image_hardlink_batch(int64 handle, const char* requestsJson, _Out_ void** outJson)') + } catch { + this.wcdbResolveImageHardlinkBatch = null + } + try { + this.wcdbResolveVideoHardlinkMd5 = this.lib.func('int32 wcdb_resolve_video_hardlink_md5(int64 handle, const char* md5, const char* dbPath, _Out_ void** outJson)') + } catch { + this.wcdbResolveVideoHardlinkMd5 = null + } + try { + this.wcdbResolveVideoHardlinkMd5Batch = this.lib.func('int32 wcdb_resolve_video_hardlink_md5_batch(int64 handle, const char* requestsJson, _Out_ void** outJson)') + } catch { + this.wcdbResolveVideoHardlinkMd5Batch = null + } // wcdb_status wcdb_install_sns_block_delete_trigger(wcdb_handle handle, char** out_error) try { @@ -1107,6 +1249,21 @@ export class WcdbCore { } } + private parseMessageJson(jsonStr: string): any { + const raw = String(jsonStr || '') + if (!raw) return [] + // 热路径优化:仅在检测到 16+ 位整数字段时才进行字符串包裹,避免每批次多轮全量 replace。 + const needsInt64Normalize = /"(?:server_id|serverId|ServerId|msg_server_id|msgServerId|MsgServerId)"\s*:\s*-?\d{16,}/.test(raw) + if (!needsInt64Normalize) { + return JSON.parse(raw) + } + const normalized = raw.replace( + /("(?:server_id|serverId|ServerId|msg_server_id|msgServerId|MsgServerId)"\s*:\s*)(-?\d{16,})/g, + '$1"$2"' + ) + return JSON.parse(normalized) + } + private ensureReady(): boolean { return this.initialized && this.handle !== null } @@ -1133,6 +1290,66 @@ export class WcdbCore { return { begin: normalizedBegin, end: normalizedEnd } } + private makeHardlinkCacheKey(primary: string, secondary?: string | null): string { + const a = String(primary || '').trim().toLowerCase() + const b = String(secondary || '').trim().toLowerCase() + return `${a}\u001f${b}` + } + + private readHardlinkCache( + cache: Map, + key: string + ): { success: boolean; data?: any; error?: string } | null { + const entry = cache.get(key) + if (!entry) return null + if (Date.now() - entry.updatedAt > this.hardlinkCacheTtlMs) { + cache.delete(key) + return null + } + return this.cloneHardlinkResult(entry.result) + } + + private writeHardlinkCache( + cache: Map, + key: string, + result: { success: boolean; data?: any; error?: string } + ): void { + cache.set(key, { + result: this.cloneHardlinkResult(result), + updatedAt: Date.now() + }) + if (cache.size <= this.hardlinkCacheMaxEntries) return + + const now = Date.now() + for (const [cacheKey, entry] of cache) { + if (now - entry.updatedAt > this.hardlinkCacheTtlMs) { + cache.delete(cacheKey) + } + } + + while (cache.size > this.hardlinkCacheMaxEntries) { + const oldestKey = cache.keys().next().value as string | undefined + if (!oldestKey) break + cache.delete(oldestKey) + } + } + + private cloneHardlinkResult(result: { success: boolean; data?: any; error?: string }): { success: boolean; data?: any; error?: string } { + const data = result.data && typeof result.data === 'object' + ? { ...result.data } + : result.data + return { + success: result.success === true, + data, + error: result.error + } + } + + private clearHardlinkCaches(): void { + this.imageHardlinkCache.clear() + this.videoHardlinkCache.clear() + } + isReady(): boolean { return this.ensureReady() } @@ -1240,6 +1457,7 @@ export class WcdbCore { this.currentWxid = null this.currentDbStoragePath = null this.initialized = false + this.clearHardlinkCaches() this.stopLogPolling() } } @@ -1300,7 +1518,7 @@ export class WcdbCore { } const jsonStr = this.decodeJsonPtr(outPtr[0]) if (!jsonStr) return { success: false, error: '解析消息失败' } - const messages = JSON.parse(jsonStr) + const messages = this.parseMessageJson(jsonStr) return { success: true, messages } } catch (e) { return { success: false, error: String(e) } @@ -1388,6 +1606,197 @@ export class WcdbCore { } } + async getSessionMessageCounts(sessionIds: string[]): Promise<{ success: boolean; counts?: Record; error?: string }> { + if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' } + if (!this.wcdbGetSessionMessageCounts) return this.getMessageCounts(sessionIds) + try { + const outPtr = [null as any] + const result = this.wcdbGetSessionMessageCounts(this.handle, JSON.stringify(sessionIds || []), outPtr) + if (result !== 0 || !outPtr[0]) { + return { success: false, error: `获取会话消息总数失败: ${result}` } + } + const jsonStr = this.decodeJsonPtr(outPtr[0]) + if (!jsonStr) return { success: false, error: '解析会话消息总数失败' } + const raw = JSON.parse(jsonStr) || {} + const counts: Record = {} + for (const sid of sessionIds || []) { + const value = Number(raw?.[sid] ?? 0) + counts[sid] = Number.isFinite(value) ? Math.max(0, Math.floor(value)) : 0 + } + return { success: true, counts } + } catch (e) { + return { success: false, error: String(e) } + } + } + + async getSessionMessageTypeStats( + sessionId: string, + beginTimestamp: number = 0, + endTimestamp: number = 0 + ): Promise<{ success: boolean; data?: any; error?: string }> { + if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' } + if (!this.wcdbGetSessionMessageTypeStats) return { success: false, error: '接口未就绪' } + try { + const outPtr = [null as any] + const result = this.wcdbGetSessionMessageTypeStats( + this.handle, + sessionId, + this.normalizeTimestamp(beginTimestamp), + this.normalizeTimestamp(endTimestamp), + outPtr + ) + if (result !== 0 || !outPtr[0]) { + return { success: false, error: `获取会话类型统计失败: ${result}` } + } + const jsonStr = this.decodeJsonPtr(outPtr[0]) + if (!jsonStr) return { success: false, error: '解析会话类型统计失败' } + return { success: true, data: JSON.parse(jsonStr) || {} } + } catch (e) { + return { success: false, error: String(e) } + } + } + + async getSessionMessageTypeStatsBatch( + sessionIds: string[], + options?: { + beginTimestamp?: number + endTimestamp?: number + quickMode?: boolean + includeGroupSenderCount?: boolean + } + ): Promise<{ success: boolean; data?: Record; error?: string }> { + if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' } + const normalizedSessionIds = Array.from(new Set((sessionIds || []).map((id) => String(id || '').trim()).filter(Boolean))) + if (normalizedSessionIds.length === 0) return { success: true, data: {} } + + if (!this.wcdbGetSessionMessageTypeStatsBatch) { + const data: Record = {} + for (const sessionId of normalizedSessionIds) { + const single = await this.getSessionMessageTypeStats( + sessionId, + options?.beginTimestamp || 0, + options?.endTimestamp || 0 + ) + if (single.success) { + data[sessionId] = single.data || {} + } + } + return { success: true, data } + } + + try { + const outPtr = [null as any] + const optionsJson = JSON.stringify({ + begin: this.normalizeTimestamp(options?.beginTimestamp || 0), + end: this.normalizeTimestamp(options?.endTimestamp || 0), + quick_mode: options?.quickMode === true, + include_group_sender_count: options?.includeGroupSenderCount !== false + }) + const result = this.wcdbGetSessionMessageTypeStatsBatch( + this.handle, + JSON.stringify(normalizedSessionIds), + optionsJson, + outPtr + ) + if (result !== 0 || !outPtr[0]) return { success: false, error: `批量获取会话类型统计失败: ${result}` } + const jsonStr = this.decodeJsonPtr(outPtr[0]) + if (!jsonStr) return { success: false, error: '解析批量会话类型统计失败' } + return { success: true, data: JSON.parse(jsonStr) || {} } + } catch (e) { + return { success: false, error: String(e) } + } + } + + async getSessionMessageDateCounts(sessionId: string): Promise<{ success: boolean; counts?: Record; error?: string }> { + if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' } + if (!this.wcdbGetSessionMessageDateCounts) return { success: false, error: '接口未就绪' } + try { + const outPtr = [null as any] + const result = this.wcdbGetSessionMessageDateCounts(this.handle, sessionId, outPtr) + if (result !== 0 || !outPtr[0]) return { success: false, error: `获取会话日消息统计失败: ${result}` } + const jsonStr = this.decodeJsonPtr(outPtr[0]) + if (!jsonStr) return { success: false, error: '解析会话日消息统计失败' } + const raw = JSON.parse(jsonStr) || {} + const counts: Record = {} + for (const [dateKey, value] of Object.entries(raw)) { + const count = Number(value) + if (!dateKey || !Number.isFinite(count) || count <= 0) continue + counts[String(dateKey)] = Math.floor(count) + } + return { success: true, counts } + } catch (e) { + return { success: false, error: String(e) } + } + } + + async getSessionMessageDateCountsBatch(sessionIds: string[]): Promise<{ success: boolean; data?: Record>; error?: string }> { + if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' } + const normalizedSessionIds = Array.from(new Set((sessionIds || []).map((id) => String(id || '').trim()).filter(Boolean))) + if (normalizedSessionIds.length === 0) return { success: true, data: {} } + + if (!this.wcdbGetSessionMessageDateCountsBatch) { + const data: Record> = {} + for (const sessionId of normalizedSessionIds) { + const single = await this.getSessionMessageDateCounts(sessionId) + data[sessionId] = single.success && single.counts ? single.counts : {} + } + return { success: true, data } + } + + try { + const outPtr = [null as any] + const result = this.wcdbGetSessionMessageDateCountsBatch(this.handle, JSON.stringify(normalizedSessionIds), outPtr) + if (result !== 0 || !outPtr[0]) return { success: false, error: `批量获取会话日消息统计失败: ${result}` } + const jsonStr = this.decodeJsonPtr(outPtr[0]) + if (!jsonStr) return { success: false, error: '解析批量会话日消息统计失败' } + const raw = JSON.parse(jsonStr) || {} + const data: Record> = {} + for (const sessionId of normalizedSessionIds) { + const source = raw?.[sessionId] || {} + const next: Record = {} + for (const [dateKey, value] of Object.entries(source)) { + const count = Number(value) + if (!dateKey || !Number.isFinite(count) || count <= 0) continue + next[String(dateKey)] = Math.floor(count) + } + data[sessionId] = next + } + return { success: true, data } + } catch (e) { + return { success: false, error: String(e) } + } + } + + async getMessagesByType( + sessionId: string, + localType: number, + ascending = false, + limit = 0, + offset = 0 + ): Promise<{ success: boolean; rows?: any[]; error?: string }> { + if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' } + if (!this.wcdbGetMessagesByType) return { success: false, error: '接口未就绪' } + try { + const outPtr = [null as any] + const result = this.wcdbGetMessagesByType( + this.handle, + sessionId, + BigInt(localType), + ascending ? 1 : 0, + Math.max(0, Math.floor(limit || 0)), + Math.max(0, Math.floor(offset || 0)), + outPtr + ) + if (result !== 0 || !outPtr[0]) return { success: false, error: `按类型读取消息失败: ${result}` } + const jsonStr = this.decodeJsonPtr(outPtr[0]) + if (!jsonStr) return { success: false, error: '解析按类型消息失败' } + const rows = JSON.parse(jsonStr) + return { success: true, rows: Array.isArray(rows) ? rows : [] } + } catch (e) { + return { success: false, error: String(e) } + } + } + async getDisplayNames(usernames: string[]): Promise<{ success: boolean; map?: Record; error?: string }> { if (!this.ensureReady()) { return { success: false, error: 'WCDB 未连接' } @@ -1762,24 +2171,25 @@ export class WcdbCore { if (!this.ensureReady()) { return { success: false, error: 'WCDB 未连接' } } + if (!this.wcdbGetContactStatus) { + return { success: false, error: '接口未就绪' } + } try { - // 分批查询,避免 SQL 过长(execQuery 不支持参数绑定,直接拼 SQL) - const BATCH = 200 + const outPtr = [null as any] + const code = this.wcdbGetContactStatus(this.handle, JSON.stringify(usernames || []), outPtr) + if (code !== 0 || !outPtr[0]) { + return { success: false, error: `获取会话状态失败: ${code}` } + } + const jsonStr = this.decodeJsonPtr(outPtr[0]) + if (!jsonStr) return { success: false, error: '解析会话状态失败' } + + const rawMap = JSON.parse(jsonStr) || {} const map: Record = {} - for (let i = 0; i < usernames.length; i += BATCH) { - const batch = usernames.slice(i, i + BATCH) - const inList = batch.map(u => `'${u.replace(/'/g, "''")}'`).join(',') - const sql = `SELECT username, flag, extra_buffer FROM contact WHERE username IN (${inList})` - const result = await this.execQuery('contact', null, sql) - if (!result.success || !result.rows) continue - for (const row of result.rows) { - const uname: string = row.username - // 折叠:flag bit 28 (0x10000000) - const flag = parseInt(row.flag ?? '0', 10) - const isFolded = (flag & 0x10000000) !== 0 - // 免打扰:extra_buffer field 12 非0 - const { isMuted } = parseExtraBuffer(row.extra_buffer) - map[uname] = { isFolded, isMuted } + for (const username of usernames || []) { + const state = rawMap[username] || {} + map[username] = { + isFolded: Boolean(state.isFolded), + isMuted: Boolean(state.isMuted) } } return { success: true, map } @@ -1788,6 +2198,148 @@ export class WcdbCore { } } + async getMessageTableColumns(dbPath: string, tableName: string): Promise<{ success: boolean; columns?: string[]; error?: string }> { + if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' } + if (!this.wcdbGetMessageTableColumns) return { success: false, error: '接口未就绪' } + try { + const outPtr = [null as any] + const result = this.wcdbGetMessageTableColumns(this.handle, dbPath, tableName, outPtr) + if (result !== 0 || !outPtr[0]) return { success: false, error: `获取消息表列失败: ${result}` } + const jsonStr = this.decodeJsonPtr(outPtr[0]) + if (!jsonStr) return { success: false, error: '解析消息表列失败' } + const columns = JSON.parse(jsonStr) + return { success: true, columns: Array.isArray(columns) ? columns.map((c: any) => String(c || '')) : [] } + } catch (e) { + return { success: false, error: String(e) } + } + } + + async getMessageTableTimeRange(dbPath: string, tableName: string): Promise<{ success: boolean; data?: any; error?: string }> { + if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' } + if (!this.wcdbGetMessageTableTimeRange) return { success: false, error: '接口未就绪' } + try { + const outPtr = [null as any] + const result = this.wcdbGetMessageTableTimeRange(this.handle, dbPath, tableName, outPtr) + if (result !== 0 || !outPtr[0]) return { success: false, error: `获取消息表时间范围失败: ${result}` } + const jsonStr = this.decodeJsonPtr(outPtr[0]) + if (!jsonStr) return { success: false, error: '解析消息表时间范围失败' } + const data = JSON.parse(jsonStr) || {} + return { success: true, data } + } catch (e) { + return { success: false, error: String(e) } + } + } + + async getContactTypeCounts(): Promise<{ success: boolean; counts?: { private: number; group: number; official: number; former_friend: number }; error?: string }> { + if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' } + const runFallback = async (reason: string) => { + const contactsResult = await this.getContactsCompact() + if (!contactsResult.success || !Array.isArray(contactsResult.contacts)) { + return { success: false, error: `获取联系人分类统计失败: ${reason}; fallback=${contactsResult.error || 'unknown'}` } + } + const counts = this.deriveContactTypeCounts(contactsResult.contacts as Array>) + this.writeLog(`[diag:getContactTypeCounts] fallback reason=${reason} private=${counts.private} group=${counts.group} official=${counts.official} former_friend=${counts.former_friend}`, true) + return { success: true, counts } + } + + if (!this.wcdbGetContactTypeCounts) return await runFallback('api_missing') + try { + const outPtr = [null as any] + const code = this.wcdbGetContactTypeCounts(this.handle, outPtr) + if (code !== 0 || !outPtr[0]) return await runFallback(`code=${code}`) + const jsonStr = this.decodeJsonPtr(outPtr[0]) + if (!jsonStr) return await runFallback('decode_empty') + const raw = JSON.parse(jsonStr) || {} + return { + success: true, + counts: { + private: Number(raw.private || 0), + group: Number(raw.group || 0), + official: Number(raw.official || 0), + former_friend: Number(raw.former_friend || 0) + } + } + } catch (e) { + return await runFallback(`exception=${String(e)}`) + } + } + + async getContactsCompact(usernames: string[] = []): Promise<{ success: boolean; contacts?: any[]; error?: string }> { + if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' } + const runFallback = async (reason: string) => { + const fallback = await this.execQuery('contact', null, this.buildContactSelectSql(usernames)) + if (!fallback.success) { + return { success: false, error: `获取联系人列表失败: ${reason}; fallback=${fallback.error || 'unknown'}` } + } + const rows = Array.isArray(fallback.rows) ? fallback.rows : [] + this.writeLog(`[diag:getContactsCompact] fallback reason=${reason} usernames=${Array.isArray(usernames) ? usernames.length : 0} rows=${rows.length}`, true) + return { success: true, contacts: rows } + } + + if (!this.wcdbGetContactsCompact) return await runFallback('api_missing') + try { + const outPtr = [null as any] + const payload = Array.isArray(usernames) && usernames.length > 0 ? JSON.stringify(usernames) : null + const code = this.wcdbGetContactsCompact(this.handle, payload, outPtr) + if (code !== 0 || !outPtr[0]) return await runFallback(`code=${code}`) + const jsonStr = this.decodeJsonPtr(outPtr[0]) + if (!jsonStr) return await runFallback('decode_empty') + const contacts = JSON.parse(jsonStr) + return { success: true, contacts: Array.isArray(contacts) ? contacts : [] } + } catch (e) { + return await runFallback(`exception=${String(e)}`) + } + } + + async getContactAliasMap(usernames: string[]): Promise<{ success: boolean; map?: Record; error?: string }> { + if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' } + if (!this.wcdbGetContactAliasMap) return { success: false, error: '接口未就绪' } + try { + const outPtr = [null as any] + const code = this.wcdbGetContactAliasMap(this.handle, JSON.stringify(usernames || []), outPtr) + if (code !== 0 || !outPtr[0]) return { success: false, error: `获取联系人 alias 失败: ${code}` } + const jsonStr = this.decodeJsonPtr(outPtr[0]) + if (!jsonStr) return { success: false, error: '解析联系人 alias 失败' } + const map = JSON.parse(jsonStr) + return { success: true, map } + } catch (e) { + return { success: false, error: String(e) } + } + } + + async getContactFriendFlags(usernames: string[]): Promise<{ success: boolean; map?: Record; error?: string }> { + if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' } + if (!this.wcdbGetContactFriendFlags) return { success: false, error: '接口未就绪' } + try { + const outPtr = [null as any] + const code = this.wcdbGetContactFriendFlags(this.handle, JSON.stringify(usernames || []), outPtr) + if (code !== 0 || !outPtr[0]) return { success: false, error: `获取联系人好友标记失败: ${code}` } + const jsonStr = this.decodeJsonPtr(outPtr[0]) + if (!jsonStr) return { success: false, error: '解析联系人好友标记失败' } + const map = JSON.parse(jsonStr) + return { success: true, map } + } catch (e) { + return { success: false, error: String(e) } + } + } + + async getChatRoomExtBuffer(chatroomId: string): Promise<{ success: boolean; extBuffer?: string; error?: string }> { + if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' } + if (!this.wcdbGetChatRoomExtBuffer) return { success: false, error: '接口未就绪' } + try { + const outPtr = [null as any] + const code = this.wcdbGetChatRoomExtBuffer(this.handle, chatroomId, outPtr) + if (code !== 0 || !outPtr[0]) return { success: false, error: `获取群聊 ext_buffer 失败: ${code}` } + const jsonStr = this.decodeJsonPtr(outPtr[0]) + if (!jsonStr) return { success: false, error: '解析群聊 ext_buffer 失败' } + const data = JSON.parse(jsonStr) || {} + const extBuffer = String(data.ext_buffer || '').trim() + return { success: true, extBuffer: extBuffer || undefined } + } catch (e) { + return { success: false, error: String(e) } + } + } + async getAggregateStats(sessionIds: string[], beginTimestamp: number = 0, endTimestamp: number = 0): Promise<{ success: boolean; data?: any; error?: string }> { if (!this.ensureReady()) { return { success: false, error: 'WCDB 未连接' } @@ -2031,7 +2583,7 @@ export class WcdbCore { } const jsonStr = this.decodeJsonPtr(outPtr[0]) if (!jsonStr) return { success: false, error: '解析批次失败' } - const rows = JSON.parse(jsonStr) + const rows = this.parseMessageJson(jsonStr) return { success: true, rows, hasMore: outHasMore[0] === 1 } } catch (e) { return { success: false, error: String(e) } @@ -2074,8 +2626,11 @@ export class WcdbCore { if (!this.ensureReady()) { return { success: false, error: 'WCDB 未连接' } } + const startedAt = Date.now() try { if (!this.wcdbExecQuery) return { success: false, error: '接口未就绪' } + const fallbackFlag = /fallback|diag|diagnostic/i.test(String(sql || '')) + this.writeLog(`[audit:execQuery] kind=${kind} path=${path || ''} sql_len=${String(sql || '').length} fallback=${fallbackFlag ? 1 : 0}`) // 如果提供了参数,使用参数化查询(需要 C++ 层支持) // 注意:当前 wcdbExecQuery 可能不支持参数化,这是一个占位符实现 @@ -2110,12 +2665,14 @@ export class WcdbCore { const jsonStr = this.decodeJsonPtr(outPtr[0]) if (!jsonStr) return { success: false, error: '解析查询结果失败' } const rows = JSON.parse(jsonStr) + this.writeLog(`[audit:execQuery] done kind=${kind} cost_ms=${Date.now() - startedAt} rows=${Array.isArray(rows) ? rows.length : -1}`) if (isContactQuery) { const count = Array.isArray(rows) ? rows.length : -1 this.writeLog(`[diag:execQuery] contact query ok rows=${count} kind=${kind} path=${effectivePath} sql="${this.formatSqlForLog(sql)}"`, true) } return { success: true, rows } } catch (e) { + this.writeLog(`[audit:execQuery] fail kind=${kind} cost_ms=${Date.now() - startedAt} err=${String(e)}`) const isContactQuery = String(kind).toLowerCase() === 'contact' || /\bfrom\s+contact\b/i.test(String(sql)) if (isContactQuery) { this.writeLog(`[diag:execQuery] contact query exception kind=${kind} path=${path || ''} sql="${this.formatSqlForLog(sql)}" err=${String(e)}`, true) @@ -2179,7 +2736,7 @@ export class WcdbCore { if (result !== 0 || !outPtr[0]) return { success: false, error: `查询消息失败: ${result}` } const jsonStr = this.decodeJsonPtr(outPtr[0]) if (!jsonStr) return { success: false, error: '解析消息失败' } - const message = JSON.parse(jsonStr) + const message = this.parseMessageJson(jsonStr) // 处理 wcdb_get_message_by_id 返回空对象的情况 if (Object.keys(message).length === 0) return { success: false, error: '未找到消息' } return { success: true, message } @@ -2205,6 +2762,321 @@ export class WcdbCore { } } + async getVoiceDataBatch( + requests: Array<{ session_id: string; create_time: number; local_id?: number; svr_id?: string | number; candidates?: string[] }> + ): Promise<{ success: boolean; rows?: Array<{ index: number; hex?: string }>; error?: string }> { + if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' } + if (!this.wcdbGetVoiceDataBatch) return { success: false, error: '接口未就绪' } + try { + const outPtr = [null as any] + const payload = JSON.stringify(Array.isArray(requests) ? requests : []) + const result = this.wcdbGetVoiceDataBatch(this.handle, payload, outPtr) + if (result !== 0 || !outPtr[0]) return { success: false, error: `批量获取语音数据失败: ${result}` } + const jsonStr = this.decodeJsonPtr(outPtr[0]) + if (!jsonStr) return { success: false, error: '解析批量语音数据失败' } + const rows = JSON.parse(jsonStr) + const normalized = Array.isArray(rows) ? rows.map((row: any) => ({ + index: Number(row?.index ?? 0), + hex: row?.hex ? String(row.hex) : undefined + })) : [] + return { success: true, rows: normalized } + } catch (e) { + return { success: false, error: String(e) } + } + } + + async getMediaSchemaSummary(dbPath: string): Promise<{ success: boolean; data?: any; error?: string }> { + if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' } + if (!this.wcdbGetMediaSchemaSummary) return { success: false, error: '接口未就绪' } + try { + const outPtr = [null as any] + const result = this.wcdbGetMediaSchemaSummary(this.handle, dbPath, outPtr) + if (result !== 0 || !outPtr[0]) return { success: false, error: `获取媒体表结构摘要失败: ${result}` } + const jsonStr = this.decodeJsonPtr(outPtr[0]) + if (!jsonStr) return { success: false, error: '解析媒体表结构摘要失败' } + const data = JSON.parse(jsonStr) || {} + return { success: true, data } + } catch (e) { + return { success: false, error: String(e) } + } + } + + async getHeadImageBuffers(usernames: string[]): Promise<{ success: boolean; map?: Record; error?: string }> { + if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' } + if (!this.wcdbGetHeadImageBuffers) return { success: false, error: '接口未就绪' } + try { + const outPtr = [null as any] + const result = this.wcdbGetHeadImageBuffers(this.handle, JSON.stringify(usernames || []), outPtr) + if (result !== 0 || !outPtr[0]) return { success: false, error: `获取头像二进制失败: ${result}` } + const jsonStr = this.decodeJsonPtr(outPtr[0]) + if (!jsonStr) return { success: false, error: '解析头像二进制失败' } + const map = JSON.parse(jsonStr) || {} + return { success: true, map } + } catch (e) { + return { success: false, error: String(e) } + } + } + + async resolveImageHardlink(md5: string, accountDir?: string): Promise<{ success: boolean; data?: any; error?: string }> { + if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' } + if (!this.wcdbResolveImageHardlink) return { success: false, error: '接口未就绪' } + try { + const normalizedMd5 = String(md5 || '').trim().toLowerCase() + const normalizedAccountDir = String(accountDir || '').trim() + if (!normalizedMd5) return { success: false, error: 'md5 为空' } + const cacheKey = this.makeHardlinkCacheKey(normalizedMd5, normalizedAccountDir) + const cached = this.readHardlinkCache(this.imageHardlinkCache, cacheKey) + if (cached) return cached + + const outPtr = [null as any] + const result = this.wcdbResolveImageHardlink(this.handle, normalizedMd5, normalizedAccountDir || null, outPtr) + if (result !== 0 || !outPtr[0]) return { success: false, error: `解析图片 hardlink 失败: ${result}` } + const jsonStr = this.decodeJsonPtr(outPtr[0]) + if (!jsonStr) return { success: false, error: '解析图片 hardlink 响应失败' } + const data = JSON.parse(jsonStr) || {} + const finalResult = { success: true, data } + this.writeHardlinkCache(this.imageHardlinkCache, cacheKey, finalResult) + return finalResult + } catch (e) { + return { success: false, error: String(e) } + } + } + + async resolveVideoHardlinkMd5(md5: string, dbPath?: string): Promise<{ success: boolean; data?: any; error?: string }> { + if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' } + if (!this.wcdbResolveVideoHardlinkMd5) return { success: false, error: '接口未就绪' } + try { + const normalizedMd5 = String(md5 || '').trim().toLowerCase() + const normalizedDbPath = String(dbPath || '').trim() + if (!normalizedMd5) return { success: false, error: 'md5 为空' } + const cacheKey = this.makeHardlinkCacheKey(normalizedMd5, normalizedDbPath) + const cached = this.readHardlinkCache(this.videoHardlinkCache, cacheKey) + if (cached) return cached + + const outPtr = [null as any] + const result = this.wcdbResolveVideoHardlinkMd5(this.handle, normalizedMd5, normalizedDbPath || null, outPtr) + if (result !== 0 || !outPtr[0]) return { success: false, error: `解析视频 hardlink 失败: ${result}` } + const jsonStr = this.decodeJsonPtr(outPtr[0]) + if (!jsonStr) return { success: false, error: '解析视频 hardlink 响应失败' } + const data = JSON.parse(jsonStr) || {} + const finalResult = { success: true, data } + this.writeHardlinkCache(this.videoHardlinkCache, cacheKey, finalResult) + return finalResult + } catch (e) { + return { success: false, error: String(e) } + } + } + + async resolveImageHardlinkBatch( + requests: Array<{ md5: string; accountDir?: string }> + ): Promise<{ success: boolean; rows?: Array<{ index: number; md5: string; success: boolean; data?: any; error?: string }>; error?: string }> { + if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' } + if (!Array.isArray(requests)) return { success: false, error: '参数错误: requests 必须是数组' } + try { + const normalizedRequests = requests.map((req) => ({ + md5: String(req?.md5 || '').trim().toLowerCase(), + accountDir: String(req?.accountDir || '').trim() + })) + const rows: Array<{ index: number; md5: string; success: boolean; data?: any; error?: string }> = new Array(normalizedRequests.length) + const unresolved: Array<{ index: number; md5: string; accountDir: string }> = [] + + for (let i = 0; i < normalizedRequests.length; i += 1) { + const req = normalizedRequests[i] + if (!req.md5) { + rows[i] = { index: i, md5: '', success: false, error: 'md5 为空' } + continue + } + const cacheKey = this.makeHardlinkCacheKey(req.md5, req.accountDir) + const cached = this.readHardlinkCache(this.imageHardlinkCache, cacheKey) + if (cached) { + rows[i] = { + index: i, + md5: req.md5, + success: cached.success === true, + data: cached.data, + error: cached.error + } + } else { + unresolved.push({ index: i, md5: req.md5, accountDir: req.accountDir }) + } + } + + if (unresolved.length === 0) { + return { success: true, rows } + } + + if (this.wcdbResolveImageHardlinkBatch) { + try { + const outPtr = [null as any] + const payload = JSON.stringify(unresolved.map((req) => ({ + md5: req.md5, + account_dir: req.accountDir || undefined + }))) + const result = this.wcdbResolveImageHardlinkBatch(this.handle, payload, outPtr) + if (result === 0 && outPtr[0]) { + const jsonStr = this.decodeJsonPtr(outPtr[0]) + if (jsonStr) { + const nativeRows = JSON.parse(jsonStr) + const mappedRows = Array.isArray(nativeRows) ? nativeRows.map((row: any, index: number) => { + const rowIndexRaw = Number(row?.index) + const rowIndex = Number.isFinite(rowIndexRaw) ? Math.floor(rowIndexRaw) : index + const fallbackReq = rowIndex >= 0 && rowIndex < unresolved.length ? unresolved[rowIndex] : { md5: '', accountDir: '', index: -1 } + const rowMd5 = String(row?.md5 || fallbackReq.md5 || '').trim().toLowerCase() + const success = row?.success === true || row?.success === 1 || row?.success === '1' + const data = row?.data && typeof row.data === 'object' ? row.data : {} + const error = row?.error ? String(row.error) : undefined + if (success && rowMd5) { + const cacheKey = this.makeHardlinkCacheKey(rowMd5, fallbackReq.accountDir) + this.writeHardlinkCache(this.imageHardlinkCache, cacheKey, { success: true, data }) + } + return { + index: rowIndex, + md5: rowMd5, + success, + data, + error + } + }) : [] + for (const row of mappedRows) { + const fallbackReq = row.index >= 0 && row.index < unresolved.length ? unresolved[row.index] : null + if (!fallbackReq) continue + rows[fallbackReq.index] = { + index: fallbackReq.index, + md5: row.md5 || fallbackReq.md5, + success: row.success, + data: row.data, + error: row.error + } + } + } + } + } catch { + // 回退到单条循环实现 + } + } + + for (const req of unresolved) { + if (rows[req.index]) continue + const result = await this.resolveImageHardlink(req.md5, req.accountDir) + rows[req.index] = { + index: req.index, + md5: req.md5, + success: result.success === true, + data: result.data, + error: result.error + } + } + return { success: true, rows } + } catch (e) { + return { success: false, error: String(e) } + } + } + + async resolveVideoHardlinkMd5Batch( + requests: Array<{ md5: string; dbPath?: string }> + ): Promise<{ success: boolean; rows?: Array<{ index: number; md5: string; success: boolean; data?: any; error?: string }>; error?: string }> { + if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' } + if (!Array.isArray(requests)) return { success: false, error: '参数错误: requests 必须是数组' } + try { + const normalizedRequests = requests.map((req) => ({ + md5: String(req?.md5 || '').trim().toLowerCase(), + dbPath: String(req?.dbPath || '').trim() + })) + const rows: Array<{ index: number; md5: string; success: boolean; data?: any; error?: string }> = new Array(normalizedRequests.length) + const unresolved: Array<{ index: number; md5: string; dbPath: string }> = [] + + for (let i = 0; i < normalizedRequests.length; i += 1) { + const req = normalizedRequests[i] + if (!req.md5) { + rows[i] = { index: i, md5: '', success: false, error: 'md5 为空' } + continue + } + const cacheKey = this.makeHardlinkCacheKey(req.md5, req.dbPath) + const cached = this.readHardlinkCache(this.videoHardlinkCache, cacheKey) + if (cached) { + rows[i] = { + index: i, + md5: req.md5, + success: cached.success === true, + data: cached.data, + error: cached.error + } + } else { + unresolved.push({ index: i, md5: req.md5, dbPath: req.dbPath }) + } + } + + if (unresolved.length === 0) { + return { success: true, rows } + } + + if (this.wcdbResolveVideoHardlinkMd5Batch) { + try { + const outPtr = [null as any] + const payload = JSON.stringify(unresolved.map((req) => ({ + md5: req.md5, + db_path: req.dbPath || undefined + }))) + const result = this.wcdbResolveVideoHardlinkMd5Batch(this.handle, payload, outPtr) + if (result === 0 && outPtr[0]) { + const jsonStr = this.decodeJsonPtr(outPtr[0]) + if (jsonStr) { + const nativeRows = JSON.parse(jsonStr) + const mappedRows = Array.isArray(nativeRows) ? nativeRows.map((row: any, index: number) => { + const rowIndexRaw = Number(row?.index) + const rowIndex = Number.isFinite(rowIndexRaw) ? Math.floor(rowIndexRaw) : index + const fallbackReq = rowIndex >= 0 && rowIndex < unresolved.length ? unresolved[rowIndex] : { md5: '', dbPath: '', index: -1 } + const rowMd5 = String(row?.md5 || fallbackReq.md5 || '').trim().toLowerCase() + const success = row?.success === true || row?.success === 1 || row?.success === '1' + const data = row?.data && typeof row.data === 'object' ? row.data : {} + const error = row?.error ? String(row.error) : undefined + if (success && rowMd5) { + const cacheKey = this.makeHardlinkCacheKey(rowMd5, fallbackReq.dbPath) + this.writeHardlinkCache(this.videoHardlinkCache, cacheKey, { success: true, data }) + } + return { + index: rowIndex, + md5: rowMd5, + success, + data, + error + } + }) : [] + for (const row of mappedRows) { + const fallbackReq = row.index >= 0 && row.index < unresolved.length ? unresolved[row.index] : null + if (!fallbackReq) continue + rows[fallbackReq.index] = { + index: fallbackReq.index, + md5: row.md5 || fallbackReq.md5, + success: row.success, + data: row.data, + error: row.error + } + } + } + } + } catch { + // 回退到单条循环实现 + } + } + + for (const req of unresolved) { + if (rows[req.index]) continue + const result = await this.resolveVideoHardlinkMd5(req.md5, req.dbPath) + rows[req.index] = { + index: req.index, + md5: req.md5, + success: result.success === true, + data: result.data, + error: result.error + } + } + return { success: true, rows } + } catch (e) { + return { success: false, error: String(e) } + } + } + /** * 数据收集初始化 */ @@ -2310,7 +3182,7 @@ export class WcdbCore { } const jsonStr = this.decodeJsonPtr(outPtr[0]) if (!jsonStr) return { success: false, error: '解析搜索结果失败' } - const messages = JSON.parse(jsonStr) + const messages = this.parseMessageJson(jsonStr) return { success: true, messages } } catch (e) { return { success: false, error: String(e) } @@ -2369,6 +3241,45 @@ export class WcdbCore { return { success: false, error: String(e) } } } + + async getSnsUsernames(): Promise<{ success: boolean; usernames?: string[]; error?: string }> { + if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' } + if (!this.wcdbGetSnsUsernames) return { success: false, error: '接口未就绪' } + try { + const outPtr = [null as any] + const result = this.wcdbGetSnsUsernames(this.handle, outPtr) + if (result !== 0 || !outPtr[0]) return { success: false, error: `获取朋友圈用户名失败: ${result}` } + const jsonStr = this.decodeJsonPtr(outPtr[0]) + if (!jsonStr) return { success: false, error: '解析朋友圈用户名失败' } + const usernames = JSON.parse(jsonStr) + return { success: true, usernames: Array.isArray(usernames) ? usernames.map((u: any) => String(u || '').trim()).filter(Boolean) : [] } + } catch (e) { + return { success: false, error: String(e) } + } + } + + async getSnsExportStats(myWxid?: string): Promise<{ success: boolean; data?: { totalPosts: number; totalFriends: number; myPosts: number | null }; error?: string }> { + if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' } + if (!this.wcdbGetSnsExportStats) return { success: false, error: '接口未就绪' } + try { + const outPtr = [null as any] + const result = this.wcdbGetSnsExportStats(this.handle, myWxid || null, outPtr) + if (result !== 0 || !outPtr[0]) return { success: false, error: `获取朋友圈导出统计失败: ${result}` } + const jsonStr = this.decodeJsonPtr(outPtr[0]) + if (!jsonStr) return { success: false, error: '解析朋友圈导出统计失败' } + const raw = JSON.parse(jsonStr) || {} + return { + success: true, + data: { + totalPosts: Number(raw.total_posts || 0), + totalFriends: Number(raw.total_friends || 0), + myPosts: raw.my_posts === null || raw.my_posts === undefined ? null : Number(raw.my_posts || 0) + } + } + } catch (e) { + return { success: false, error: String(e) } + } + } /** * 为朋友圈安装删除 */ diff --git a/electron/services/wcdbService.ts b/electron/services/wcdbService.ts index b5fcb24..d8f331d 100644 --- a/electron/services/wcdbService.ts +++ b/electron/services/wcdbService.ts @@ -222,6 +222,48 @@ export class WcdbService { return this.callWorker('getMessageCounts', { sessionIds }) } + async getSessionMessageCounts(sessionIds: string[]): Promise<{ success: boolean; counts?: Record; error?: string }> { + return this.callWorker('getSessionMessageCounts', { sessionIds }) + } + + async getSessionMessageTypeStats( + sessionId: string, + beginTimestamp: number = 0, + endTimestamp: number = 0 + ): Promise<{ success: boolean; data?: any; error?: string }> { + return this.callWorker('getSessionMessageTypeStats', { sessionId, beginTimestamp, endTimestamp }) + } + + async getSessionMessageTypeStatsBatch( + sessionIds: string[], + options?: { + beginTimestamp?: number + endTimestamp?: number + quickMode?: boolean + includeGroupSenderCount?: boolean + } + ): Promise<{ success: boolean; data?: Record; error?: string }> { + return this.callWorker('getSessionMessageTypeStatsBatch', { sessionIds, options }) + } + + async getSessionMessageDateCounts(sessionId: string): Promise<{ success: boolean; counts?: Record; error?: string }> { + return this.callWorker('getSessionMessageDateCounts', { sessionId }) + } + + async getSessionMessageDateCountsBatch(sessionIds: string[]): Promise<{ success: boolean; data?: Record>; error?: string }> { + return this.callWorker('getSessionMessageDateCountsBatch', { sessionIds }) + } + + async getMessagesByType( + sessionId: string, + localType: number, + ascending = false, + limit = 0, + offset = 0 + ): Promise<{ success: boolean; rows?: any[]; error?: string }> { + return this.callWorker('getMessagesByType', { sessionId, localType, ascending, limit, offset }) + } + /** * 获取联系人昵称 */ @@ -287,6 +329,14 @@ export class WcdbService { return this.callWorker('getMessageMeta', { dbPath, tableName, limit, offset }) } + async getMessageTableColumns(dbPath: string, tableName: string): Promise<{ success: boolean; columns?: string[]; error?: string }> { + return this.callWorker('getMessageTableColumns', { dbPath, tableName }) + } + + async getMessageTableTimeRange(dbPath: string, tableName: string): Promise<{ success: boolean; data?: any; error?: string }> { + return this.callWorker('getMessageTableTimeRange', { dbPath, tableName }) + } + /** * 获取联系人详情 */ @@ -301,6 +351,26 @@ export class WcdbService { return this.callWorker('getContactStatus', { usernames }) } + async getContactTypeCounts(): Promise<{ success: boolean; counts?: { private: number; group: number; official: number; former_friend: number }; error?: string }> { + return this.callWorker('getContactTypeCounts') + } + + async getContactsCompact(usernames: string[] = []): Promise<{ success: boolean; contacts?: any[]; error?: string }> { + return this.callWorker('getContactsCompact', { usernames }) + } + + async getContactAliasMap(usernames: string[]): Promise<{ success: boolean; map?: Record; error?: string }> { + return this.callWorker('getContactAliasMap', { usernames }) + } + + async getContactFriendFlags(usernames: string[]): Promise<{ success: boolean; map?: Record; error?: string }> { + return this.callWorker('getContactFriendFlags', { usernames }) + } + + async getChatRoomExtBuffer(chatroomId: string): Promise<{ success: boolean; extBuffer?: string; error?: string }> { + return this.callWorker('getChatRoomExtBuffer', { chatroomId }) + } + /** * 获取聚合统计数据 */ @@ -372,7 +442,7 @@ export class WcdbService { } /** - * 执行 SQL 查询(支持参数化查询) + * 执行 SQL 查询(仅主进程内部使用:fallback/diagnostic/低频兼容) */ async execQuery(kind: string, path: string | null, sql: string, params: any[] = []): Promise<{ success: boolean; rows?: any[]; error?: string }> { return this.callWorker('execQuery', { kind, path, sql, params }) @@ -417,6 +487,40 @@ export class WcdbService { return this.callWorker('getVoiceData', { sessionId, createTime, candidates, localId, svrId }) } + async getVoiceDataBatch( + requests: Array<{ session_id: string; create_time: number; local_id?: number; svr_id?: string | number; candidates?: string[] }> + ): Promise<{ success: boolean; rows?: Array<{ index: number; hex?: string }>; error?: string }> { + return this.callWorker('getVoiceDataBatch', { requests }) + } + + async getMediaSchemaSummary(dbPath: string): Promise<{ success: boolean; data?: any; error?: string }> { + return this.callWorker('getMediaSchemaSummary', { dbPath }) + } + + async getHeadImageBuffers(usernames: string[]): Promise<{ success: boolean; map?: Record; error?: string }> { + return this.callWorker('getHeadImageBuffers', { usernames }) + } + + async resolveImageHardlink(md5: string, accountDir?: string): Promise<{ success: boolean; data?: any; error?: string }> { + return this.callWorker('resolveImageHardlink', { md5, accountDir }) + } + + async resolveImageHardlinkBatch( + requests: Array<{ md5: string; accountDir?: string }> + ): Promise<{ success: boolean; rows?: Array<{ index: number; md5: string; success: boolean; data?: any; error?: string }>; error?: string }> { + return this.callWorker('resolveImageHardlinkBatch', { requests }) + } + + async resolveVideoHardlinkMd5(md5: string, dbPath?: string): Promise<{ success: boolean; data?: any; error?: string }> { + return this.callWorker('resolveVideoHardlinkMd5', { md5, dbPath }) + } + + async resolveVideoHardlinkMd5Batch( + requests: Array<{ md5: string; dbPath?: string }> + ): Promise<{ success: boolean; rows?: Array<{ index: number; md5: string; success: boolean; data?: any; error?: string }>; error?: string }> { + return this.callWorker('resolveVideoHardlinkMd5Batch', { requests }) + } + /** * 获取朋友圈 */ @@ -431,6 +535,14 @@ export class WcdbService { return this.callWorker('getSnsAnnualStats', { beginTimestamp, endTimestamp }) } + async getSnsUsernames(): Promise<{ success: boolean; usernames?: string[]; error?: string }> { + return this.callWorker('getSnsUsernames') + } + + async getSnsExportStats(myWxid?: string): Promise<{ success: boolean; data?: { totalPosts: number; totalFriends: number; myPosts: number | null }; error?: string }> { + return this.callWorker('getSnsExportStats', { myWxid }) + } + /** * 安装朋友圈删除拦截 */ diff --git a/electron/wcdbWorker.ts b/electron/wcdbWorker.ts index 5d02904..61b637c 100644 --- a/electron/wcdbWorker.ts +++ b/electron/wcdbWorker.ts @@ -59,6 +59,24 @@ if (parentPort) { case 'getMessageCounts': result = await core.getMessageCounts(payload.sessionIds) break + case 'getSessionMessageCounts': + result = await core.getSessionMessageCounts(payload.sessionIds) + break + case 'getSessionMessageTypeStats': + result = await core.getSessionMessageTypeStats(payload.sessionId, payload.beginTimestamp, payload.endTimestamp) + break + case 'getSessionMessageTypeStatsBatch': + result = await core.getSessionMessageTypeStatsBatch(payload.sessionIds, payload.options) + break + case 'getSessionMessageDateCounts': + result = await core.getSessionMessageDateCounts(payload.sessionId) + break + case 'getSessionMessageDateCountsBatch': + result = await core.getSessionMessageDateCountsBatch(payload.sessionIds) + break + case 'getMessagesByType': + result = await core.getMessagesByType(payload.sessionId, payload.localType, payload.ascending, payload.limit, payload.offset) + break case 'getDisplayNames': result = await core.getDisplayNames(payload.usernames) break @@ -89,12 +107,33 @@ if (parentPort) { case 'getMessageMeta': result = await core.getMessageMeta(payload.dbPath, payload.tableName, payload.limit, payload.offset) break + case 'getMessageTableColumns': + result = await core.getMessageTableColumns(payload.dbPath, payload.tableName) + break + case 'getMessageTableTimeRange': + result = await core.getMessageTableTimeRange(payload.dbPath, payload.tableName) + break case 'getContact': result = await core.getContact(payload.username) break case 'getContactStatus': result = await core.getContactStatus(payload.usernames) break + case 'getContactTypeCounts': + result = await core.getContactTypeCounts() + break + case 'getContactsCompact': + result = await core.getContactsCompact(payload.usernames) + break + case 'getContactAliasMap': + result = await core.getContactAliasMap(payload.usernames) + break + case 'getContactFriendFlags': + result = await core.getContactFriendFlags(payload.usernames) + break + case 'getChatRoomExtBuffer': + result = await core.getChatRoomExtBuffer(payload.chatroomId) + break case 'getAggregateStats': result = await core.getAggregateStats(payload.sessionIds, payload.beginTimestamp, payload.endTimestamp) break @@ -149,12 +188,39 @@ if (parentPort) { console.error('[wcdbWorker] getVoiceData failed:', result.error) } break + case 'getVoiceDataBatch': + result = await core.getVoiceDataBatch(payload.requests) + break + case 'getMediaSchemaSummary': + result = await core.getMediaSchemaSummary(payload.dbPath) + break + case 'getHeadImageBuffers': + result = await core.getHeadImageBuffers(payload.usernames) + break + case 'resolveImageHardlink': + result = await core.resolveImageHardlink(payload.md5, payload.accountDir) + break + case 'resolveImageHardlinkBatch': + result = await core.resolveImageHardlinkBatch(payload.requests) + break + case 'resolveVideoHardlinkMd5': + result = await core.resolveVideoHardlinkMd5(payload.md5, payload.dbPath) + break + case 'resolveVideoHardlinkMd5Batch': + result = await core.resolveVideoHardlinkMd5Batch(payload.requests) + break case 'getSnsTimeline': result = await core.getSnsTimeline(payload.limit, payload.offset, payload.usernames, payload.keyword, payload.startTime, payload.endTime) break case 'getSnsAnnualStats': result = await core.getSnsAnnualStats(payload.beginTimestamp, payload.endTimestamp) break + case 'getSnsUsernames': + result = await core.getSnsUsernames() + break + case 'getSnsExportStats': + result = await core.getSnsExportStats(payload.myWxid) + break case 'installSnsBlockDeleteTrigger': result = await core.installSnsBlockDeleteTrigger() break diff --git a/package.json b/package.json index 569874d..f68aacc 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "remark-gfm": "^4.0.1", "sherpa-onnx-node": "^1.10.38", "silk-wasm": "^3.7.1", + "sudo-prompt": "^9.2.1", "wechat-emojis": "^1.0.2", "zustand": "^5.0.2" }, @@ -88,6 +89,17 @@ ], "icon": "public/icon.ico" }, + "linux": { + "icon": "public/icon.png", + "target": [ + "pacman", + "deb", + "tar.gz" + ], + "category": "Utility", + "executableName": "weflow", + "synopsis": "WeFlow for Linux" + }, "nsis": { "oneClick": false, "differentialPackage": false, @@ -118,6 +130,10 @@ "from": "public/icon.ico", "to": "icon.ico" }, + { + "from": "public/icon.png", + "to": "icon.png" + }, { "from": "electron/assets/wasm/", "to": "assets/wasm/" @@ -154,4 +170,4 @@ ], "icon": "resources/icon.icns" } -} \ No newline at end of file +} diff --git a/public/icon.png b/public/icon.png new file mode 100644 index 0000000..7372ec7 Binary files /dev/null and b/public/icon.png differ diff --git a/resources/libwcdb_api.dylib b/resources/libwcdb_api.dylib index c4db20f..07cb87f 100755 Binary files a/resources/libwcdb_api.dylib and b/resources/libwcdb_api.dylib differ diff --git a/resources/linux/libwcdb_api.so b/resources/linux/libwcdb_api.so new file mode 100755 index 0000000..f08bafa Binary files /dev/null and b/resources/linux/libwcdb_api.so differ diff --git a/resources/macos/libwcdb_api.dylib b/resources/macos/libwcdb_api.dylib index 7ccdf6f..07cb87f 100755 Binary files a/resources/macos/libwcdb_api.dylib and b/resources/macos/libwcdb_api.dylib differ diff --git a/resources/wcdb_api.dll b/resources/wcdb_api.dll index 31aa4a2..1e9abad 100644 Binary files a/resources/wcdb_api.dll and b/resources/wcdb_api.dll differ diff --git a/resources/xkey_helper_linux b/resources/xkey_helper_linux new file mode 100755 index 0000000..54f7cb3 Binary files /dev/null and b/resources/xkey_helper_linux differ diff --git a/src/App.tsx b/src/App.tsx index 6f41759..eb8cd5c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -75,7 +75,7 @@ function App() { const isAgreementWindow = location.pathname === '/agreement-window' const isOnboardingWindow = location.pathname === '/onboarding-window' const isVideoPlayerWindow = location.pathname === '/video-player-window' - const isChatHistoryWindow = location.pathname.startsWith('/chat-history/') + const isChatHistoryWindow = location.pathname.startsWith('/chat-history/') || location.pathname.startsWith('/chat-history-inline/') const isStandaloneChatWindow = location.pathname === '/chat-window' const isNotificationWindow = location.pathname === '/notification-window' const isSettingsRoute = location.pathname === '/settings' @@ -660,6 +660,7 @@ function App() { } /> } /> } /> + } /> diff --git a/src/components/BatchTranscribeGlobal.tsx b/src/components/BatchTranscribeGlobal.tsx index 3aa7c10..0c6d825 100644 --- a/src/components/BatchTranscribeGlobal.tsx +++ b/src/components/BatchTranscribeGlobal.tsx @@ -1,6 +1,6 @@ import React, { useEffect, useState } from 'react' import { createPortal } from 'react-dom' -import { Loader2, X, CheckCircle, XCircle, AlertCircle, Clock } from 'lucide-react' +import { Loader2, X, CheckCircle, XCircle, AlertCircle, Clock, Mic } from 'lucide-react' import { useBatchTranscribeStore } from '../stores/batchTranscribeStore' import '../styles/batchTranscribe.scss' @@ -17,6 +17,7 @@ export const BatchTranscribeGlobal: React.FC = () => { result, sessionName, startTime, + taskType, setShowToast, setShowResult } = useBatchTranscribeStore() @@ -64,7 +65,7 @@ export const BatchTranscribeGlobal: React.FC = () => {
- 批量转写中{sessionName ? `(${sessionName})` : ''} + {taskType === 'decrypt' ? '批量解密语音中' : '批量转写中'}{sessionName ? `(${sessionName})` : ''}
+ ) +} + +function HistoryItem({ item, sessionId }: { item: ChatRecordItem; sessionId: string }) { // sourcetime 在合并转发里有两种格式: // 1) 时间戳(秒) 2) 已格式化的字符串 "2026-01-21 09:56:46" let time = '' @@ -191,31 +556,18 @@ function HistoryItem({ item }: { item: ChatRecordItem }) { } } + const senderDisplayName = item.sourcename ?? '未知发送者' + const renderContent = () => { if (item.datatype === 1) { // 文本消息 return
{item.datadesc || ''}
} - if (item.datatype === 3) { - // 图片 - const src = item.datathumburl || item.datacdnurl - if (src) { - return ( -
- {imageError ? ( -
图片无法加载
- ) : ( - 图片 setImageError(true)} - /> - )} -
- ) - } - return
[图片]
+ if (item.datatype === 2 || item.datatype === 3) { + return + } + if (item.datatype === 17) { + return } if (item.datatype === 43) { return
[视频] {item.datatitle}
@@ -229,21 +581,20 @@ function HistoryItem({ item }: { item: ChatRecordItem }) { return (
-
- {item.sourceheadurl ? ( - - ) : ( -
- {item.sourcename?.slice(0, 1)} -
- )} +
+
- {item.sourcename || '未知发送者'} + {senderDisplayName} {time}
-
+
{renderContent()}
diff --git a/src/pages/ChatPage.scss b/src/pages/ChatPage.scss index be4d15f..8776e5f 100644 --- a/src/pages/ChatPage.scss +++ b/src/pages/ChatPage.scss @@ -566,7 +566,8 @@ flex: 1; background: var(--chat-pattern); background-color: var(--bg-secondary); - padding: 20px 24px; + padding: 20px 24px 112px; + padding-bottom: calc(112px + env(safe-area-inset-bottom)); &::-webkit-scrollbar { width: 6px; @@ -600,7 +601,8 @@ } .message-wrapper { - margin-bottom: 16px; + box-sizing: border-box; + padding-bottom: 16px; } .message-bubble { @@ -1748,7 +1750,8 @@ overflow-y: auto; overflow-x: hidden; min-height: 0; - padding: 20px 24px; + padding: 20px 24px 112px; + padding-bottom: calc(112px + env(safe-area-inset-bottom)); display: flex; flex-direction: column; gap: 16px; @@ -1777,6 +1780,10 @@ } } +.message-virtuoso { + width: 100%; +} + .loading-messages.loading-overlay { position: absolute; inset: 0; @@ -1834,9 +1841,9 @@ // 回到底部按钮 .scroll-to-bottom { - position: sticky; + position: absolute; bottom: 20px; - align-self: center; + left: 50%; padding: 8px 16px; border-radius: 20px; background: var(--bg-secondary); @@ -1851,13 +1858,13 @@ font-size: 13px; z-index: 10; opacity: 0; - transform: translateY(20px); + transform: translate(-50%, 20px); pointer-events: none; transition: all 0.3s ease; &.show { opacity: 1; - transform: translateY(0); + transform: translate(-50%, 0); pointer-events: auto; } @@ -1894,6 +1901,8 @@ .message-wrapper { display: flex; flex-direction: column; + box-sizing: border-box; + padding-bottom: 16px; -webkit-app-region: no-drag; &.sent { @@ -2060,6 +2069,10 @@ object-fit: contain; } +.emoji-message-wrapper { + display: inline-block; +} + .emoji-loading { width: 90px; height: 90px; @@ -3294,13 +3307,89 @@ // 聊天记录消息 (合并转发) .chat-record-message { - background: var(--card-inner-bg) !important; - border: 1px solid var(--border-color) !important; - transition: opacity 0.2s ease; + width: 300px; + min-width: 240px; + max-width: 336px; + background: color-mix(in srgb, var(--bg-secondary) 97%, #f5f7fb); + border: 1px solid var(--border-color); + border-radius: 16px; + overflow: hidden; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.04); + transition: transform 0.15s ease, box-shadow 0.15s ease, border-color 0.15s ease; cursor: pointer; + padding: 0; &:hover { - opacity: 0.85; + transform: translateY(-1px); + box-shadow: 0 6px 18px rgba(0, 0, 0, 0.08); + border-color: color-mix(in srgb, var(--primary) 28%, var(--border-color)); + } + + .chat-record-title { + padding: 13px 16px 6px; + font-size: 13px; + font-weight: 600; + color: var(--text-primary); + line-height: 1.45; + display: -webkit-box; + -webkit-line-clamp: 2; + line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + } + + .chat-record-meta-line { + padding: 0 16px 10px; + font-size: 11px; + color: var(--text-tertiary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .chat-record-list { + padding: 0 16px 11px; + display: flex; + flex-direction: column; + gap: 4px; + max-height: 92px; + overflow: hidden; + border-bottom: 1px solid var(--border-color); + } + + .chat-record-item { + font-size: 12px; + line-height: 1.45; + color: var(--text-secondary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .source-name { + color: currentColor; + opacity: 0.92; + font-weight: 500; + margin-right: 4px; + } + + .chat-record-more { + font-size: 11px; + color: var(--text-tertiary); + } + + .chat-record-desc { + padding: 0 16px 11px; + font-size: 12px; + line-height: 1.45; + color: var(--text-secondary); + border-bottom: 1px solid var(--border-color); + } + + .chat-record-footer { + padding: 8px 16px 10px; + font-size: 11px; + color: var(--text-tertiary); } } @@ -3374,75 +3463,6 @@ } } -// 聊天记录消息 - 复用 link-message 基础样式 -.chat-record-message { - cursor: pointer; - - .link-header { - padding-bottom: 4px; - } - - .chat-record-preview { - display: flex; - flex-direction: column; - gap: 4px; - flex: 1; - min-width: 0; - } - - .chat-record-meta-line { - font-size: 11px; - color: var(--text-tertiary); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - } - - .chat-record-list { - display: flex; - flex-direction: column; - gap: 2px; - max-height: 70px; - overflow: hidden; - } - - .chat-record-item { - font-size: 12px; - color: var(--text-secondary); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - } - - .source-name { - color: var(--text-primary); - font-weight: 500; - margin-right: 4px; - } - - .chat-record-more { - font-size: 12px; - color: var(--primary); - } - - .chat-record-desc { - font-size: 12px; - color: var(--text-secondary); - } - - .chat-record-icon { - width: 40px; - height: 40px; - border-radius: 10px; - background: var(--primary-gradient); - display: flex; - align-items: center; - justify-content: center; - color: #fff; - flex-shrink: 0; - } -} - // 小程序消息 .miniapp-message { display: flex; @@ -3539,23 +3559,18 @@ .message-bubble.sent { .card-message, - .chat-record-message, .miniapp-message, .appmsg-rich-card { background: var(--sent-card-bg); .card-name, .miniapp-title, - .source-name, .link-title { color: white; } .card-label, .miniapp-label, - .chat-record-item, - .chat-record-meta-line, - .chat-record-desc, .link-desc, .appmsg-url-line { color: rgba(255, 255, 255, 0.8); @@ -3563,14 +3578,10 @@ .card-icon, .miniapp-icon, - .chat-record-icon { + .link-thumb-placeholder { color: white; } - .chat-record-more { - color: rgba(255, 255, 255, 0.9); - } - .appmsg-meta-badge { color: rgba(255, 255, 255, 0.92); background: rgba(255, 255, 255, 0.12); @@ -3651,11 +3662,11 @@ // 批量转写按钮 .batch-transcribe-btn { &:hover:not(:disabled) { - color: var(--primary-color); + color: var(--primary); } &.transcribing { - color: var(--primary-color); + color: var(--primary); cursor: pointer; opacity: 1 !important; } @@ -3679,7 +3690,7 @@ border-bottom: 1px solid var(--border-color); svg { - color: var(--primary-color); + color: var(--primary); } h3 { @@ -3700,6 +3711,36 @@ line-height: 1.6; } + .batch-task-switch { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0.5rem; + margin-bottom: 1rem; + + .batch-task-btn { + border: 1px solid var(--border-color); + background: var(--bg-secondary); + color: var(--text-secondary); + border-radius: 8px; + padding: 0.55rem 0.75rem; + font-size: 13px; + cursor: pointer; + transition: all 0.2s; + + &:hover { + border-color: color-mix(in srgb, var(--primary) 50%, var(--border-color)); + color: var(--text-primary); + } + + &.active { + border-color: var(--primary); + color: var(--primary); + background: color-mix(in srgb, var(--primary) 10%, var(--bg-primary)); + box-shadow: 0 0 0 1px color-mix(in srgb, var(--primary) 25%, transparent); + } + } + } + .batch-dates-list-wrap { margin-bottom: 1rem; background: var(--bg-tertiary); @@ -3717,7 +3758,7 @@ .batch-dates-btn { padding: 0.35rem 0.75rem; font-size: 12px; - color: var(--primary-color); + color: var(--primary); background: transparent; border: 1px solid var(--border-color); border-radius: 6px; @@ -3726,7 +3767,7 @@ &:hover { background: var(--bg-hover); - border-color: var(--primary-color); + border-color: var(--primary); } } } @@ -3759,9 +3800,14 @@ } input[type="checkbox"] { - accent-color: var(--primary-color); + accent-color: var(--primary); cursor: pointer; flex-shrink: 0; + + &:focus-visible { + outline: 2px solid color-mix(in srgb, var(--primary) 45%, transparent); + outline-offset: 1px; + } } .batch-date-label { @@ -3804,7 +3850,7 @@ .value { font-size: 14px; font-weight: 600; - color: var(--primary-color); + color: var(--primary); } .batch-concurrency-field { @@ -3930,7 +3976,7 @@ &.btn-primary, &.batch-transcribe-start-btn { - background: var(--primary-color); + background: var(--primary); color: #000; &:hover { @@ -4177,43 +4223,6 @@ } } -// 聊天记录消息外观 -.chat-record-message { - background: var(--card-inner-bg) !important; - border: 1px solid var(--border-color); - border-radius: 8px; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06); - - &:hover { - background: var(--bg-hover) !important; - } - - .chat-record-list { - font-size: 13px; - color: var(--text-tertiary); - line-height: 1.6; - margin-top: 8px; - padding-top: 8px; - border-top: 1px solid var(--border-color); - - .chat-record-item { - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - - .source-name { - color: var(--text-secondary); - } - } - } - - .chat-record-more { - font-size: 12px; - color: var(--text-tertiary); - margin-top: 4px; - } -} - // 公众号文章图文消息外观 (大图模式) .official-message { display: flex; @@ -4800,6 +4809,18 @@ color: var(--text-tertiary); background: var(--bg-secondary); font-weight: 500; + display: flex; + align-items: center; + gap: 6px; + + .search-phase-hint { + color: var(--primary); + font-weight: 400; + + &.done { + color: var(--text-tertiary); + } + } } // 全局消息搜索结果面板 diff --git a/src/pages/ChatPage.tsx b/src/pages/ChatPage.tsx index 36e784b..c2502f0 100644 --- a/src/pages/ChatPage.tsx +++ b/src/pages/ChatPage.tsx @@ -2,10 +2,12 @@ import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react' import { Search, MessageSquare, AlertCircle, Loader2, RefreshCw, X, ChevronDown, ChevronLeft, Info, Calendar, Database, Hash, Play, Pause, Image as ImageIcon, Link, Mic, CheckCircle, Copy, Check, CheckSquare, Download, BarChart3, Edit2, Trash2, BellOff, Users, FolderClosed, UserCheck, Crown, Aperture } from 'lucide-react' import { useNavigate } from 'react-router-dom' import { createPortal } from 'react-dom' +import { Virtuoso, type VirtuosoHandle } from 'react-virtuoso' +import { useShallow } from 'zustand/react/shallow' import { useChatStore } from '../stores/chatStore' -import { useBatchTranscribeStore } from '../stores/batchTranscribeStore' +import { useBatchTranscribeStore, type BatchVoiceTaskType } from '../stores/batchTranscribeStore' import { useBatchImageDecryptStore } from '../stores/batchImageDecryptStore' -import type { ChatSession, Message } from '../types/models' +import type { ChatRecordItem, ChatSession, Message } from '../types/models' import { getEmojiPath } from 'wechat-emojis' import { VoiceTranscribeDialog } from '../components/VoiceTranscribeDialog' import { LivePhotoIcon } from '../components/LivePhotoIcon' @@ -41,6 +43,160 @@ interface PendingInSessionSearchPayload { results: Message[] } +type GlobalMsgSearchPhase = 'idle' | 'seed' | 'backfill' | 'done' +type GlobalMsgSearchResult = Message & { sessionId: string } + +interface GlobalMsgPrefixCacheEntry { + keyword: string + matchedSessionIds: Set + completed: boolean +} + +const GLOBAL_MSG_PER_SESSION_LIMIT = 10 +const GLOBAL_MSG_SEED_LIMIT = 120 +const GLOBAL_MSG_BACKFILL_CONCURRENCY = 3 +const GLOBAL_MSG_LEGACY_CONCURRENCY = 6 +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' + +function isGlobalMsgSearchCanceled(error: unknown): boolean { + return String(error || '') === GLOBAL_MSG_SEARCH_CANCELED_ERROR +} + +function normalizeGlobalMsgSearchSessionId(value: unknown): string | null { + const sessionId = String(value || '').trim() + if (!sessionId) return null + return sessionId +} + +function normalizeGlobalMsgSearchMessages( + messages: Message[] | undefined, + fallbackSessionId?: string +): GlobalMsgSearchResult[] { + if (!Array.isArray(messages) || messages.length === 0) return [] + const dedup = new Set() + const normalized: GlobalMsgSearchResult[] = [] + const normalizedFallback = normalizeGlobalMsgSearchSessionId(fallbackSessionId) + + for (const message of messages) { + const raw = message as Message & { sessionId?: string; _session_id?: string } + const sessionId = normalizeGlobalMsgSearchSessionId(raw.sessionId || raw._session_id || normalizedFallback) + if (!sessionId) continue + const uniqueKey = raw.localId > 0 + ? `${sessionId}::local:${raw.localId}` + : `${sessionId}::key:${raw.messageKey || ''}:${raw.createTime || 0}` + if (dedup.has(uniqueKey)) continue + dedup.add(uniqueKey) + normalized.push({ ...message, sessionId }) + } + + return normalized +} + +function buildGlobalMsgSearchSessionMap(messages: GlobalMsgSearchResult[]): Map { + const map = new Map() + for (const message of messages) { + if (!message.sessionId) continue + const list = map.get(message.sessionId) || [] + if (list.length >= GLOBAL_MSG_PER_SESSION_LIMIT) continue + list.push(message) + map.set(message.sessionId, list) + } + return map +} + +function flattenGlobalMsgSearchSessionMap(map: Map): GlobalMsgSearchResult[] { + const all: GlobalMsgSearchResult[] = [] + for (const list of map.values()) { + if (list.length > 0) all.push(...list) + } + return sortMessagesByCreateTimeDesc(all) +} + +function normalizeChatRecordText(value?: string): string { + return String(value || '') + .replace(/\u00a0/g, ' ') + .replace(/\s+/g, ' ') + .trim() +} + +function hasRenderableChatRecordName(value?: string): boolean { + return value !== undefined && value !== null && String(value).length > 0 +} + +function getChatRecordPreviewText(item: ChatRecordItem): string { + const text = normalizeChatRecordText(item.datadesc) || normalizeChatRecordText(item.datatitle) + if (item.datatype === 17) { + return normalizeChatRecordText(item.chatRecordTitle) || normalizeChatRecordText(item.datatitle) || '聊天记录' + } + if (item.datatype === 2 || item.datatype === 3) return '[媒体消息]' + if (item.datatype === 43) return '[视频]' + if (item.datatype === 34) return '[语音]' + if (item.datatype === 47) return '[表情]' + return text || '[媒体消息]' +} + +function buildChatRecordPreviewItems(recordList: ChatRecordItem[], maxVisible = 3): ChatRecordItem[] { + if (recordList.length <= maxVisible) return recordList.slice(0, maxVisible) + const firstNestedIndex = recordList.findIndex(item => item.datatype === 17) + if (firstNestedIndex < 0 || firstNestedIndex < maxVisible) { + return recordList.slice(0, maxVisible) + } + if (maxVisible <= 1) { + return [recordList[firstNestedIndex]] + } + return [ + ...recordList.slice(0, maxVisible - 1), + recordList[firstNestedIndex] + ] +} + +function composeGlobalMsgSearchResults( + seedMap: Map, + authoritativeMap: Map +): GlobalMsgSearchResult[] { + const merged = new Map() + for (const [sessionId, seedRows] of seedMap.entries()) { + if (authoritativeMap.has(sessionId)) { + merged.set(sessionId, authoritativeMap.get(sessionId) || []) + } else { + merged.set(sessionId, seedRows) + } + } + for (const [sessionId, rows] of authoritativeMap.entries()) { + if (!merged.has(sessionId)) merged.set(sessionId, rows) + } + return flattenGlobalMsgSearchSessionMap(merged) +} + +function shouldRunGlobalMsgShadowCompareSample(): boolean { + if (!import.meta.env.DEV) return false + try { + const forced = window.localStorage.getItem(GLOBAL_MSG_SHADOW_COMPARE_STORAGE_KEY) + if (forced === '1') return true + if (forced === '0') return false + } catch { + // ignore storage read failures + } + return Math.random() < GLOBAL_MSG_SHADOW_COMPARE_SAMPLE_RATE +} + +function buildGlobalMsgSearchSessionLocalIds(results: GlobalMsgSearchResult[]): Record { + const grouped = new Map() + for (const row of results) { + if (!row.sessionId || row.localId <= 0) continue + const list = grouped.get(row.sessionId) || [] + list.push(row.localId) + grouped.set(row.sessionId, list) + } + const output: Record = {} + for (const [sessionId, localIds] of grouped.entries()) { + output[sessionId] = localIds + } + return output +} + function sortMessagesByCreateTimeDesc>(items: T[]): T[] { return [...items].sort((a, b) => { const timeDiff = (b.createTime || 0) - (a.createTime || 0) @@ -72,6 +228,21 @@ function normalizeSearchAvatarUrl(value?: string | null): string | undefined { return normalized } +function resolveSessionDisplayName( + displayName?: string | null, + sessionId?: string | null +): string | undefined { + const normalizedSessionId = String(sessionId || '').trim() + const normalizedDisplayName = normalizeSearchIdentityText(displayName) + if (!normalizedDisplayName) return undefined + if (normalizedSessionId && normalizedDisplayName === normalizedSessionId) return undefined + return normalizedDisplayName +} + +function isFoldPlaceholderSession(sessionId?: string | null): boolean { + return String(sessionId || '').toLowerCase().includes('placeholder_foldgroup') +} + function isWxidLikeSearchIdentity(value?: string | null): boolean { const normalized = String(value || '').trim().toLowerCase() if (!normalized) return false @@ -280,6 +451,8 @@ const CHAT_SESSION_WINDOW_CACHE_TTL_MS = 12 * 60 * 60 * 1000 const CHAT_SESSION_WINDOW_CACHE_MAX_SESSIONS = 30 const CHAT_SESSION_WINDOW_CACHE_MAX_MESSAGES = 300 const GROUP_MEMBERS_PANEL_CACHE_TTL_MS = 10 * 60 * 1000 +const SESSION_CONTACT_PROFILE_RETRY_INTERVAL_MS = 15 * 1000 +const SESSION_CONTACT_PROFILE_CACHE_TTL_MS = 3 * 24 * 60 * 60 * 1000 function buildChatSessionListCacheKey(scope: string): string { return `weflow.chat.sessions.v1::${scope || 'default'}` @@ -389,6 +562,13 @@ interface SessionExportCacheMeta { source: 'memory' | 'disk' | 'fresh' } +interface SessionContactProfile { + displayName?: string + avatarUrl?: string + alias?: string + updatedAt: number +} + type GroupMessageCountStatus = 'loading' | 'ready' | 'failed' interface GroupPanelMember { @@ -441,6 +621,7 @@ interface LoadMessagesOptions { deferGroupSenderWarmup?: boolean forceInitialLimit?: number switchRequestSeq?: number + inSessionJumpRequestSeq?: number } // 全局头像加载队列管理器已移至 src/utils/AvatarLoadQueue.ts @@ -593,9 +774,6 @@ const SessionItem = React.memo(function SessionItem({ {(() => { const shouldHighlight = (session.matchedField as any) === 'name' && searchKeyword - if (shouldHighlight) { - console.log('高亮名字:', session.displayName, 'keyword:', searchKeyword) - } return shouldHighlight ? ( ) : ( @@ -660,7 +838,6 @@ function ChatPage(props: ChatPageProps) { isConnecting, connectionError, sessions, - filteredSessions, currentSessionId, isLoadingSessions, messages, @@ -672,7 +849,6 @@ function ChatPage(props: ChatPageProps) { setConnecting, setConnectionError, setSessions, - setFilteredSessions, setCurrentSession, setLoadingSessions, setMessages, @@ -683,11 +859,47 @@ function ChatPage(props: ChatPageProps) { hasMoreLater, setHasMoreLater, setSearchKeyword - } = useChatStore() + } = useChatStore(useShallow((state) => ({ + isConnected: state.isConnected, + isConnecting: state.isConnecting, + connectionError: state.connectionError, + sessions: state.sessions, + currentSessionId: state.currentSessionId, + isLoadingSessions: state.isLoadingSessions, + messages: state.messages, + isLoadingMessages: state.isLoadingMessages, + isLoadingMore: state.isLoadingMore, + hasMoreMessages: state.hasMoreMessages, + searchKeyword: state.searchKeyword, + setConnected: state.setConnected, + setConnecting: state.setConnecting, + setConnectionError: state.setConnectionError, + setSessions: state.setSessions, + setCurrentSession: state.setCurrentSession, + setLoadingSessions: state.setLoadingSessions, + setMessages: state.setMessages, + appendMessages: state.appendMessages, + setLoadingMessages: state.setLoadingMessages, + setLoadingMore: state.setLoadingMore, + setHasMoreMessages: state.setHasMoreMessages, + hasMoreLater: state.hasMoreLater, + setHasMoreLater: state.setHasMoreLater, + setSearchKeyword: state.setSearchKeyword + }))) const messageListRef = useRef(null) + const [messageListScrollParent, setMessageListScrollParent] = useState(null) + const messageVirtuosoRef = useRef(null) + const visibleMessageRangeRef = useRef<{ startIndex: number; endIndex: number }>({ startIndex: 0, endIndex: 0 }) + const topRangeLoadLockRef = useRef(false) + const bottomRangeLoadLockRef = useRef(false) + const suppressAutoLoadLaterRef = useRef(false) const searchInputRef = useRef(null) const sidebarRef = useRef(null) + const handleMessageListScrollParentRef = useCallback((node: HTMLDivElement | null) => { + messageListRef.current = node + setMessageListScrollParent(node) + }, []) const getMessageKey = useCallback((msg: Message): string => { if (msg.messageKey) return msg.messageKey @@ -743,6 +955,7 @@ function ChatPage(props: ChatPageProps) { ) const [standaloneInitialLoadRequested, setStandaloneInitialLoadRequested] = useState(false) const [showVoiceTranscribeDialog, setShowVoiceTranscribeDialog] = useState(false) + const [autoTranscribeVoiceEnabled, setAutoTranscribeVoiceEnabled] = useState(false) const [pendingVoiceTranscriptRequest, setPendingVoiceTranscriptRequest] = useState<{ sessionId: string; messageId: string } | null>(null) const [inProgressExportSessionIds, setInProgressExportSessionIds] = useState>(new Set()) const [isPreparingExportDialog, setIsPreparingExportDialog] = useState(false) @@ -763,13 +976,44 @@ function ChatPage(props: ChatPageProps) { const [tempFields, setTempFields] = useState([]) // 批量语音转文字相关状态(进度/结果 由全局 store 管理) - const { isBatchTranscribing, progress: batchTranscribeProgress, showToast: showBatchProgress, startTranscribe, updateProgress, finishTranscribe, setShowToast: setShowBatchProgress } = useBatchTranscribeStore() - const { isBatchDecrypting, progress: batchDecryptProgress, startDecrypt, updateProgress: updateDecryptProgress, finishDecrypt, setShowToast: setShowBatchDecryptToast } = useBatchImageDecryptStore() + const { + isBatchTranscribing, + runningBatchVoiceTaskType, + batchTranscribeProgress, + startTranscribe, + updateProgress, + finishTranscribe, + setShowBatchProgress + } = useBatchTranscribeStore(useShallow((state) => ({ + isBatchTranscribing: state.isBatchTranscribing, + runningBatchVoiceTaskType: state.taskType, + batchTranscribeProgress: state.progress, + startTranscribe: state.startTranscribe, + updateProgress: state.updateProgress, + finishTranscribe: state.finishTranscribe, + setShowBatchProgress: state.setShowToast + }))) + const { + isBatchDecrypting, + batchDecryptProgress, + startDecrypt, + updateDecryptProgress, + finishDecrypt, + setShowBatchDecryptToast + } = useBatchImageDecryptStore(useShallow((state) => ({ + isBatchDecrypting: state.isBatchDecrypting, + batchDecryptProgress: state.progress, + startDecrypt: state.startDecrypt, + updateDecryptProgress: state.updateProgress, + finishDecrypt: state.finishDecrypt, + setShowBatchDecryptToast: state.setShowToast + }))) const [showBatchConfirm, setShowBatchConfirm] = useState(false) const [batchVoiceCount, setBatchVoiceCount] = useState(0) const [batchVoiceMessages, setBatchVoiceMessages] = useState(null) const [batchVoiceDates, setBatchVoiceDates] = useState([]) const [batchSelectedDates, setBatchSelectedDates] = useState>(new Set()) + const [batchVoiceTaskType, setBatchVoiceTaskType] = useState('transcribe') const [showBatchDecryptConfirm, setShowBatchDecryptConfirm] = useState(false) const [batchImageMessages, setBatchImageMessages] = useState(null) const [batchImageDates, setBatchImageDates] = useState([]) @@ -789,14 +1033,20 @@ function ChatPage(props: ChatPageProps) { const [inSessionEnriching, setInSessionEnriching] = useState(false) const [inSessionSearchError, setInSessionSearchError] = useState(null) const inSessionSearchRef = useRef(null) + const inSessionResultJumpTimerRef = useRef(null) + const inSessionResultJumpRequestSeqRef = useRef(0) // 全局消息搜索 const [showGlobalMsgSearch, setShowGlobalMsgSearch] = useState(false) const [globalMsgQuery, setGlobalMsgQuery] = useState('') - const [globalMsgResults, setGlobalMsgResults] = useState>([]) + const [globalMsgResults, setGlobalMsgResults] = useState([]) const [globalMsgSearching, setGlobalMsgSearching] = useState(false) + const [globalMsgSearchPhase, setGlobalMsgSearchPhase] = useState('idle') + const [globalMsgIsBackfilling, setGlobalMsgIsBackfilling] = useState(false) + const [globalMsgAuthoritativeSessionCount, setGlobalMsgAuthoritativeSessionCount] = useState(0) const [globalMsgSearchError, setGlobalMsgSearchError] = useState(null) const pendingInSessionSearchRef = useRef(null) const pendingGlobalMsgSearchReplayRef = useRef(null) + const globalMsgPrefixCacheRef = useRef(null) // 自定义删除确认对话框 const [deleteConfirm, setDeleteConfirm] = useState<{ @@ -811,11 +1061,17 @@ function ChatPage(props: ChatPageProps) { const enrichCancelledRef = useRef(false) const isScrollingRef = useRef(false) const sessionScrollTimeoutRef = useRef(null) + const pendingSessionContactEnrichRef = useRef>(new Set()) + const sessionContactEnrichAttemptAtRef = useRef>(new Map()) + const sessionContactProfileCacheRef = useRef>(new Map()) const highlightedMessageSet = useMemo(() => new Set(highlightedMessageKeys), [highlightedMessageKeys]) const messageKeySetRef = useRef>(new Set()) const lastMessageTimeRef = useRef(0) + const isMessageListAtBottomRef = useRef(true) + const lastObservedMessageCountRef = useRef(0) + const lastVisibleSenderWarmupAtRef = useRef(0) const sessionMapRef = useRef>(new Map()) const sessionsRef = useRef([]) const currentSessionRef = useRef(null) @@ -823,8 +1079,6 @@ function ChatPage(props: ChatPageProps) { const sessionSwitchRequestSeqRef = useRef(0) const initialLoadRequestedSessionRef = useRef(null) const prevSessionRef = useRef(null) - const isLoadingMessagesRef = useRef(false) - const isLoadingMoreRef = useRef(false) const isConnectedRef = useRef(false) const isRefreshingRef = useRef(false) const searchKeywordRef = useRef('') @@ -839,15 +1093,74 @@ function ChatPage(props: ChatPageProps) { const sessionWindowCacheRef = useRef>(new Map()) const previewPersistTimerRef = useRef(null) const sessionListPersistTimerRef = useRef(null) + const scrollBottomButtonArmTimerRef = useRef(null) + const suppressScrollToBottomButtonRef = useRef(false) const pendingExportRequestIdRef = useRef(null) const exportPrepareLongWaitTimerRef = useRef(null) const jumpDatesRequestSeqRef = useRef(0) const jumpDateCountsRequestSeqRef = useRef(0) + const suppressScrollToBottomButton = useCallback((delayMs = 180) => { + suppressScrollToBottomButtonRef.current = true + if (scrollBottomButtonArmTimerRef.current !== null) { + window.clearTimeout(scrollBottomButtonArmTimerRef.current) + scrollBottomButtonArmTimerRef.current = null + } + scrollBottomButtonArmTimerRef.current = window.setTimeout(() => { + suppressScrollToBottomButtonRef.current = false + scrollBottomButtonArmTimerRef.current = null + }, delayMs) + }, []) + const isGroupChatSession = useCallback((username: string) => { return username.includes('@chatroom') }, []) + const mergeSessionContactPresentation = useCallback((session: ChatSession, previousSession?: ChatSession): ChatSession => { + const username = String(session.username || '').trim() + if (!username || isFoldPlaceholderSession(username)) { + return session + } + + const now = Date.now() + const cacheMap = sessionContactProfileCacheRef.current + const cachedProfile = cacheMap.get(username) + if (cachedProfile && now - cachedProfile.updatedAt > SESSION_CONTACT_PROFILE_CACHE_TTL_MS) { + cacheMap.delete(username) + } + const profile = cacheMap.get(username) + + const sessionDisplayName = resolveSessionDisplayName(session.displayName, username) + const previousDisplayName = resolveSessionDisplayName(previousSession?.displayName, username) + const profileDisplayName = resolveSessionDisplayName(profile?.displayName, username) + const resolvedDisplayName = sessionDisplayName || previousDisplayName || profileDisplayName || session.displayName || username + + const sessionAvatarUrl = normalizeSearchAvatarUrl(session.avatarUrl) + const previousAvatarUrl = normalizeSearchAvatarUrl(previousSession?.avatarUrl) + const profileAvatarUrl = normalizeSearchAvatarUrl(profile?.avatarUrl) + const resolvedAvatarUrl = sessionAvatarUrl || previousAvatarUrl || profileAvatarUrl + + const sessionAlias = normalizeSearchIdentityText(session.alias) + const previousAlias = normalizeSearchIdentityText(previousSession?.alias) + const profileAlias = normalizeSearchIdentityText(profile?.alias) + const resolvedAlias = sessionAlias || previousAlias || profileAlias + + if ( + resolvedDisplayName === session.displayName && + resolvedAvatarUrl === session.avatarUrl && + resolvedAlias === session.alias + ) { + return session + } + + return { + ...session, + displayName: resolvedDisplayName, + avatarUrl: resolvedAvatarUrl, + alias: resolvedAlias + } + }, []) + const clearExportPrepareState = useCallback(() => { pendingExportRequestIdRef.current = null setIsPreparingExportDialog(false) @@ -1440,7 +1753,7 @@ function ChatPage(props: ChatPageProps) { window.electronAPI.chat.getSessionDetailExtra(normalizedSessionId), window.electronAPI.chat.getExportSessionStats( [normalizedSessionId], - { includeRelations: false, forceRefresh: true, preferAccurateSpecialTypes: true } + { includeRelations: false, allowStaleCache: true, cacheOnly: true } ) ]) @@ -1473,6 +1786,7 @@ function ChatPage(props: ChatPageProps) { } let refreshIncludeRelations = false + let shouldRefreshStatsInBackground = false if (statsResultSettled.status === 'fulfilled' && statsResultSettled.value.success) { const metric = statsResultSettled.value.data?.[normalizedSessionId] as SessionExportMetric | undefined const cacheMeta = statsResultSettled.value.cache?.[normalizedSessionId] as SessionExportCacheMeta | undefined @@ -1490,11 +1804,49 @@ function ChatPage(props: ChatPageProps) { } }) } + shouldRefreshStatsInBackground = !metric || Boolean(cacheMeta?.stale) + } else { + shouldRefreshStatsInBackground = true } finishBackgroundTask(taskId, 'completed', { detail: '聊天页会话详情统计完成', progressText: '已完成' }) + + if (shouldRefreshStatsInBackground) { + setIsRefreshingDetailStats(true) + void (async () => { + try { + const freshResult = await window.electronAPI.chat.getExportSessionStats( + [normalizedSessionId], + { includeRelations: false, forceRefresh: true } + ) + if (requestSeq !== detailRequestSeqRef.current) return + if (freshResult.success && freshResult.data) { + const freshMetric = freshResult.data[normalizedSessionId] as SessionExportMetric | undefined + const freshMeta = freshResult.cache?.[normalizedSessionId] as SessionExportCacheMeta | undefined + if (freshMetric) { + applySessionDetailStats(normalizedSessionId, freshMetric, freshMeta, false) + } else if (freshMeta) { + setSessionDetail((prev) => { + if (!prev || prev.wxid !== normalizedSessionId) return prev + return { + ...prev, + statsUpdatedAt: freshMeta.updatedAt, + statsStale: freshMeta.stale + } + }) + } + } + } catch (error) { + console.error('聊天页后台刷新会话统计失败:', error) + } finally { + if (requestSeq === detailRequestSeqRef.current) { + setIsRefreshingDetailStats(false) + } + } + })() + } } catch (e) { console.error('加载会话详情补充统计失败:', e) finishBackgroundTask(taskId, 'failed', { @@ -2042,6 +2394,9 @@ function ChatPage(props: ChatPageProps) { const handleAccountChanged = useCallback(async () => { senderAvatarCache.clear() senderAvatarLoading.clear() + sessionContactProfileCacheRef.current.clear() + pendingSessionContactEnrichRef.current.clear() + sessionContactEnrichAttemptAtRef.current.clear() preloadImageKeysRef.current.clear() lastPreloadSessionRef.current = null pendingSessionLoadRef.current = null @@ -2065,8 +2420,9 @@ function ChatPage(props: ChatPageProps) { setIsLoadingGroupMembers(false) setCurrentSession(null) setSessions([]) - setFilteredSessions([]) setMessages([]) + setShowScrollToBottom(false) + suppressScrollToBottomButton(260) setSearchKeyword('') setConnectionError(null) setConnected(false) @@ -2084,7 +2440,6 @@ function ChatPage(props: ChatPageProps) { setConnecting, setConnectionError, setCurrentSession, - setFilteredSessions, setHasMoreLater, setHasMoreMessages, setMessages, @@ -2092,9 +2447,28 @@ function ChatPage(props: ChatPageProps) { setSessionDetail, setShowDetailPanel, setShowGroupMembersPanel, + suppressScrollToBottomButton, setSessions ]) + useEffect(() => { + let canceled = false + void configService.getAutoTranscribeVoice() + .then((enabled) => { + if (!canceled) { + setAutoTranscribeVoiceEnabled(Boolean(enabled)) + } + }) + .catch(() => { + if (!canceled) { + setAutoTranscribeVoiceEnabled(false) + } + }) + return () => { + canceled = true + } + }, []) + useEffect(() => { let cancelled = false void (async () => { @@ -2111,7 +2485,12 @@ function ChatPage(props: ChatPageProps) { // 同步 currentSessionId 到 ref useEffect(() => { currentSessionRef.current = currentSessionId - }, [currentSessionId]) + isMessageListAtBottomRef.current = true + topRangeLoadLockRef.current = false + bottomRangeLoadLockRef.current = false + setShowScrollToBottom(false) + suppressScrollToBottomButton(260) + }, [currentSessionId, suppressScrollToBottomButton]) const hydrateSessionStatuses = useCallback(async (sessionList: ChatSession[]) => { const usernames = sessionList.map((s) => s.username).filter(Boolean) @@ -2165,11 +2544,9 @@ function ChatPage(props: ChatPageProps) { if (result.success && result.sessions) { // 确保 sessions 是数组 const sessionsArray = Array.isArray(result.sessions) ? result.sessions : [] - const nextSessions = options?.silent ? mergeSessions(sessionsArray) : sessionsArray + const nextSessions = mergeSessions(sessionsArray) // 确保 nextSessions 也是数组 if (Array.isArray(nextSessions)) { - - setSessions(nextSessions) sessionsRef.current = nextSessions persistSessionListCache(scope, nextSessions) @@ -2178,11 +2555,12 @@ function ChatPage(props: ChatPageProps) { void enrichSessionsContactInfo(nextSessions) } else { console.error('mergeSessions returned non-array:', nextSessions) - setSessions(sessionsArray) - sessionsRef.current = sessionsArray - persistSessionListCache(scope, sessionsArray) - void hydrateSessionStatuses(sessionsArray) - void enrichSessionsContactInfo(sessionsArray) + const fallbackSessions = sessionsArray.map((session) => mergeSessionContactPresentation(session)) + setSessions(fallbackSessions) + sessionsRef.current = fallbackSessions + persistSessionListCache(scope, fallbackSessions) + void hydrateSessionStatuses(fallbackSessions) + void enrichSessionsContactInfo(fallbackSessions) } } else if (!result.success) { setConnectionError(result.error || '获取会话失败') @@ -2199,99 +2577,102 @@ function ChatPage(props: ChatPageProps) { } } - // 分批异步加载联系人信息(优化性能:防止重复加载,滚动时暂停,只在空闲时加载) + // 分批异步加载联系人信息(优化:缓存优先 + 可持续队列 + 首屏优先批次) const enrichSessionsContactInfo = async (sessions: ChatSession[]) => { - if (sessions.length === 0) return + if (Array.isArray(sessions) && sessions.length > 0) { + const now = Date.now() + for (const session of sessions) { + const username = String(session.username || '').trim() + if (!username || isFoldPlaceholderSession(username)) continue - // 防止重复加载 - if (isEnrichingRef.current) { + const profileCache = sessionContactProfileCacheRef.current + const cachedProfile = profileCache.get(username) + if (cachedProfile && now - cachedProfile.updatedAt > SESSION_CONTACT_PROFILE_CACHE_TTL_MS) { + profileCache.delete(username) + } - return + const hasAvatar = Boolean(normalizeSearchAvatarUrl(session.avatarUrl)) + const hasDisplayName = Boolean(resolveSessionDisplayName(session.displayName, username)) + if (hasAvatar && hasDisplayName) continue + + const profile = profileCache.get(username) + const profileHasAvatar = Boolean(normalizeSearchAvatarUrl(profile?.avatarUrl)) + const profileHasDisplayName = Boolean(resolveSessionDisplayName(profile?.displayName, username)) + if (profileHasAvatar && profileHasDisplayName) continue + + const lastAttemptAt = sessionContactEnrichAttemptAtRef.current.get(username) || 0 + if (now - lastAttemptAt < SESSION_CONTACT_PROFILE_RETRY_INTERVAL_MS) continue + + pendingSessionContactEnrichRef.current.add(username) + } } + if (pendingSessionContactEnrichRef.current.size === 0) return + if (isEnrichingRef.current) return + isEnrichingRef.current = true enrichCancelledRef.current = false - - const totalStart = performance.now() - - // 移除初始 500ms 延迟,让后台加载与 UI 渲染并行 - - // 检查是否被取消 - if (enrichCancelledRef.current) { - isEnrichingRef.current = false - return - } + const batchSize = 8 + let processedBatchCount = 0 try { - // 找出需要加载联系人信息的会话(没有头像或者没有显示名称的) - const needEnrich = sessions.filter(s => !s.avatarUrl || !s.displayName || s.displayName === s.username) - if (needEnrich.length === 0) { - - isEnrichingRef.current = false - return - } - - - - // 批量补齐联系人,平衡吞吐和 UI 流畅性 - const batchSize = 8 - let loadedCount = 0 - - for (let i = 0; i < needEnrich.length; i += batchSize) { - // 如果正在滚动,暂停加载 + while (!enrichCancelledRef.current && pendingSessionContactEnrichRef.current.size > 0) { if (isScrollingRef.current) { - - // 等待滚动结束 while (isScrollingRef.current && !enrichCancelledRef.current) { await new Promise(resolve => setTimeout(resolve, 120)) } - if (enrichCancelledRef.current) break } - - // 检查是否被取消 if (enrichCancelledRef.current) break + const usernames = Array.from(pendingSessionContactEnrichRef.current).slice(0, batchSize) + if (usernames.length === 0) break + usernames.forEach((username) => pendingSessionContactEnrichRef.current.delete(username)) + + const attemptAt = Date.now() + usernames.forEach((username) => sessionContactEnrichAttemptAtRef.current.set(username, attemptAt)) + const batchStart = performance.now() - const batch = needEnrich.slice(i, i + batchSize) - const usernames = batch.map(s => s.username) + const shouldRunImmediately = processedBatchCount < 2 + if (shouldRunImmediately) { + await loadContactInfoBatch(usernames) + } else { + await new Promise((resolve) => { + if ('requestIdleCallback' in window) { + window.requestIdleCallback(() => { + void loadContactInfoBatch(usernames).finally(resolve) + }, { timeout: 700 }) + } else { + setTimeout(() => { + void loadContactInfoBatch(usernames).finally(resolve) + }, 80) + } + }) + } + processedBatchCount += 1 - // 使用 requestIdleCallback 延迟执行,避免阻塞UI - await new Promise((resolve) => { - if ('requestIdleCallback' in window) { - window.requestIdleCallback(() => { - void loadContactInfoBatch(usernames).then(() => resolve()) - }, { timeout: 700 }) - } else { - setTimeout(() => { - void loadContactInfoBatch(usernames).then(() => resolve()) - }, 80) - } - }) - - loadedCount += batch.length const batchTime = performance.now() - batchStart if (batchTime > 200) { - console.warn(`[性能监控] 批次 ${Math.floor(i / batchSize) + 1}/${Math.ceil(needEnrich.length / batchSize)} 耗时: ${batchTime.toFixed(2)}ms (已加载: ${loadedCount}/${needEnrich.length})`) + console.warn(`[性能监控] 联系人批次 ${processedBatchCount} 耗时: ${batchTime.toFixed(2)}ms, batch=${usernames.length}`) } - // 批次间延迟,给UI更多时间(DLL调用可能阻塞,需要更长的延迟) - if (i + batchSize < needEnrich.length && !enrichCancelledRef.current) { - const delay = isScrollingRef.current ? 260 : 120 + if (!enrichCancelledRef.current && pendingSessionContactEnrichRef.current.size > 0) { + const delay = isScrollingRef.current ? 220 : 90 await new Promise(resolve => setTimeout(resolve, delay)) } } const totalTime = performance.now() - totalStart - if (!enrichCancelledRef.current) { - - } else { - + if (totalTime > 500) { + console.info(`[性能监控] 联系人补齐总耗时: ${totalTime.toFixed(2)}ms`) } } catch (e) { console.error('加载联系人信息失败:', e) } finally { isEnrichingRef.current = false + if (!enrichCancelledRef.current && pendingSessionContactEnrichRef.current.size > 0) { + void enrichSessionsContactInfo([]) + } } } @@ -2347,6 +2728,7 @@ function ChatPage(props: ChatPageProps) { if (hasChanges) { const updateStart = performance.now() setSessions(updatedSessions) + sessionsRef.current = updatedSessions lastUpdateTimeRef.current = Date.now() const updateTime = performance.now() - updateStart if (updateTime > 50) { @@ -2386,18 +2768,34 @@ function ChatPage(props: ChatPageProps) { // 将更新加入队列,用于侧边栏更新 const contacts = result.contacts || {} for (const [username, contact] of Object.entries(contacts)) { - contactUpdateQueueRef.current.set(username, contact) + const normalizedDisplayName = resolveSessionDisplayName(contact.displayName, username) || contact.displayName + const normalizedAvatarUrl = normalizeSearchAvatarUrl(contact.avatarUrl) + const normalizedAlias = normalizeSearchIdentityText(contact.alias) + contactUpdateQueueRef.current.set(username, { + displayName: normalizedDisplayName, + avatarUrl: normalizedAvatarUrl, + alias: normalizedAlias + }) + + if (normalizedDisplayName || normalizedAvatarUrl || normalizedAlias) { + sessionContactProfileCacheRef.current.set(username, { + displayName: normalizedDisplayName, + avatarUrl: normalizedAvatarUrl, + alias: normalizedAlias, + updatedAt: Date.now() + }) + } // 如果是自己的信息且当前个人头像为空,同步更新 - if (myWxid && username === myWxid && contact.avatarUrl && !myAvatarUrl) { + if (myWxid && username === myWxid && normalizedAvatarUrl && !myAvatarUrl) { - setMyAvatarUrl(contact.avatarUrl) + setMyAvatarUrl(normalizedAvatarUrl) } // 【核心优化】同步更新全局发送者头像缓存,供 MessageBubble 使用 senderAvatarCache.set(username, { - avatarUrl: contact.avatarUrl, - displayName: contact.displayName + avatarUrl: normalizedAvatarUrl, + displayName: normalizedDisplayName }) } // 触发批量更新 @@ -2452,7 +2850,11 @@ function ChatPage(props: ChatPageProps) { flashNewMessages(newOnes.map(getMessageKey)) // 滚动到底部 requestAnimationFrame(() => { - if (messageListRef.current) { + const latestMessages = useChatStore.getState().messages || [] + const lastIndex = latestMessages.length - 1 + if (lastIndex >= 0 && messageVirtuosoRef.current) { + messageVirtuosoRef.current.scrollToIndex({ index: lastIndex, align: 'end', behavior: 'auto' }) + } else if (messageListRef.current) { messageListRef.current.scrollTop = messageListRef.current.scrollHeight } }) @@ -2501,7 +2903,11 @@ function ChatPage(props: ChatPageProps) { flashNewMessages(newMessages.map(getMessageKey)) // 滚动到底部 requestAnimationFrame(() => { - if (messageListRef.current) { + const currentMessages = useChatStore.getState().messages || [] + const lastIndex = currentMessages.length - 1 + if (lastIndex >= 0 && messageVirtuosoRef.current) { + messageVirtuosoRef.current.scrollToIndex({ index: lastIndex, align: 'end', behavior: 'auto' }) + } else if (messageListRef.current) { messageListRef.current.scrollTop = messageListRef.current.scrollHeight } }) @@ -2573,6 +2979,8 @@ function ChatPage(props: ChatPageProps) { if (offset === 0) { + suppressScrollToBottomButton(260) + setShowScrollToBottom(false) setLoadingMessages(true) // 切会话时保留旧内容作为过渡,避免大面积闪烁 setHasInitialMessages(true) @@ -2580,7 +2988,16 @@ function ChatPage(props: ChatPageProps) { setLoadingMore(true) } - // 记录加载前的第一条消息元素 + const visibleRange = visibleMessageRangeRef.current + const visibleStartIndex = Math.min( + Math.max(visibleRange.startIndex, 0), + Math.max(messages.length - 1, 0) + ) + const anchorMessageKeyBeforePrepend = offset > 0 && messages.length > 0 + ? getMessageKey(messages[visibleStartIndex]) + : null + + // 记录加载前的第一条消息元素(非虚拟列表回退路径) const firstMsgEl = listEl?.querySelector('.message-wrapper') as HTMLElement | null try { @@ -2595,6 +3012,15 @@ function ChatPage(props: ChatPageProps) { nextOffset?: number; error?: string } + const isStaleSwitchRequest = Boolean( + options.switchRequestSeq && options.switchRequestSeq !== sessionSwitchRequestSeqRef.current + ) + const isStaleInSessionJumpRequest = Boolean( + options.inSessionJumpRequestSeq && options.inSessionJumpRequestSeq !== inSessionResultJumpRequestSeqRef.current + ) + if (isStaleSwitchRequest || isStaleInSessionJumpRequest) { + return + } if (options.switchRequestSeq && options.switchRequestSeq !== sessionSwitchRequestSeqRef.current) { return } @@ -2624,13 +3050,21 @@ function ChatPage(props: ChatPageProps) { // 日期跳转时滚动到顶部,否则滚动到底部 requestAnimationFrame(() => { - if (messageListRef.current) { - if (isDateJumpRef.current) { + if (isDateJumpRef.current) { + if (messageVirtuosoRef.current && result.messages.length > 0) { + messageVirtuosoRef.current.scrollToIndex({ index: 0, align: 'start', behavior: 'auto' }) + } else if (messageListRef.current) { messageListRef.current.scrollTop = 0 - isDateJumpRef.current = false - } else { - messageListRef.current.scrollTop = messageListRef.current.scrollHeight } + isDateJumpRef.current = false + return + } + + const lastIndex = result.messages.length - 1 + if (lastIndex >= 0 && messageVirtuosoRef.current) { + messageVirtuosoRef.current.scrollToIndex({ index: lastIndex, align: 'end', behavior: 'auto' }) + } else if (messageListRef.current) { + messageListRef.current.scrollTop = messageListRef.current.scrollHeight } }) } else { @@ -2648,12 +3082,27 @@ function ChatPage(props: ChatPageProps) { } } - // 加载更多后保持位置:让之前的第一条消息保持在原来的视觉位置 - if (firstMsgEl && listEl) { - requestAnimationFrame(() => { + // 加载更早消息后保持视口锚点,避免跳屏 + requestAnimationFrame(() => { + if (messageVirtuosoRef.current) { + if (anchorMessageKeyBeforePrepend) { + const latestMessages = useChatStore.getState().messages || [] + const anchorIndex = latestMessages.findIndex((msg) => getMessageKey(msg) === anchorMessageKeyBeforePrepend) + if (anchorIndex >= 0) { + messageVirtuosoRef.current.scrollToIndex({ index: anchorIndex, align: 'start', behavior: 'auto' }) + return + } + } + if (result.messages.length > 0) { + messageVirtuosoRef.current.scrollToIndex({ index: result.messages.length, align: 'start', behavior: 'auto' }) + } + return + } + + if (firstMsgEl && listEl) { listEl.scrollTop = firstMsgEl.offsetTop - 80 - }) - } + } + }) } // 日期跳转(ascending=true):不往上加载更早的,往下加载更晚的 if (ascending) { @@ -2731,6 +3180,14 @@ function ChatPage(props: ChatPageProps) { setInSessionEnriching(false) }, []) + const cancelInSessionSearchJump = useCallback(() => { + inSessionResultJumpRequestSeqRef.current += 1 + if (inSessionResultJumpTimerRef.current) { + window.clearTimeout(inSessionResultJumpTimerRef.current) + inSessionResultJumpTimerRef.current = null + } + }, []) + const resolveSearchSessionContext = useCallback((sessionId?: string) => { const normalizedSessionId = String(sessionId || currentSessionRef.current || currentSessionId || '').trim() const currentSearchSession = normalizedSessionId && Array.isArray(sessions) @@ -2828,22 +3285,6 @@ function ChatPage(props: ChatPageProps) { ? (senderAvatarUrl || myAvatarUrl) : (senderAvatarUrl || (isDirectSearchSession ? resolvedSessionAvatarUrl : undefined)) - if (inferredSelfFromSender) { - console.info('[InSessionSearch][GroupSelfHit][hydrate]', { - sessionId: normalizedSessionId, - localId: message.localId, - senderUsername, - rawIsSend: message.isSend, - nextIsSend, - rawSenderDisplayName: message.senderDisplayName, - nextSenderDisplayName, - rawSenderAvatarUrl: message.senderAvatarUrl, - nextSenderAvatarUrl, - myWxid, - hasMyAvatarUrl: Boolean(myAvatarUrl) - }) - } - if ( senderUsername === message.senderUsername && nextIsSend === message.isSend && @@ -3050,24 +3491,6 @@ function ChatPage(props: ChatPageProps) { (isDirectSearchSession ? resolvedSessionAvatarUrl : undefined) ) - if (inferredSelfFromSender) { - console.info('[InSessionSearch][GroupSelfHit][enrich]', { - sessionId: normalizedSessionId, - localId: message.localId, - senderUsername: sender, - rawIsSend: message.isSend, - nextIsSend, - profileDisplayName, - currentSenderDisplayName, - nextSenderDisplayName, - profileAvatarUrl: normalizeSearchAvatarUrl(profile?.avatarUrl), - currentSenderAvatarUrl, - nextSenderAvatarUrl, - myWxid, - hasMyAvatarUrl: Boolean(myAvatarUrl) - }) - } - if ( sender === message.senderUsername && nextIsSend === message.isSend && @@ -3122,8 +3545,8 @@ function ChatPage(props: ChatPageProps) { if (switchRequestSeq && switchRequestSeq !== sessionSwitchRequestSeqRef.current) return if (currentSessionRef.current !== normalizedSessionId) return setInSessionResults(enrichedResults) - }).catch((error) => { - console.warn('[InSessionSearch] 恢复全局搜索结果发送者信息失败:', error) + }).catch(() => { + // ignore sender enrichment errors and keep current search results usable }).finally(() => { if (switchRequestSeq && switchRequestSeq !== sessionSwitchRequestSeqRef.current) return if (currentSessionRef.current !== normalizedSessionId) return @@ -3201,6 +3624,7 @@ function ChatPage(props: ChatPageProps) { const pendingSearch = pendingInSessionSearchRef.current const shouldPreservePendingSearch = pendingSearch?.sessionId === normalizedSessionId cancelInSessionSearchTasks() + cancelInSessionSearchJump() // 清空会话内搜索状态(除非是从全局搜索跳转过来) if (!shouldPreservePendingSearch) { @@ -3262,6 +3686,7 @@ function ChatPage(props: ChatPageProps) { refreshSessionIncrementally, hydrateSessionPreview, loadMessages, + cancelInSessionSearchJump, cancelInSessionSearchTasks, applyPendingInSessionSearch ]) @@ -3321,8 +3746,8 @@ function ChatPage(props: ChatPageProps) { void enrichMessagesWithSenderProfiles(messages, sid).then((enriched) => { if (gen !== inSessionSearchGenRef.current || currentSessionRef.current !== sid) return setInSessionResults(enriched) - }).catch((error) => { - console.warn('[InSessionSearch] 补充发送者信息失败:', error) + }).catch(() => { + // ignore sender enrichment errors and keep current search results usable }).finally(() => { if (gen !== inSessionSearchGenRef.current || currentSessionRef.current !== sid) return setInSessionEnriching(false) @@ -3342,6 +3767,7 @@ function ChatPage(props: ChatPageProps) { setShowInSessionSearch(v => { if (v) { cancelInSessionSearchTasks() + cancelInSessionSearchJump() setInSessionQuery('') setInSessionResults([]) setInSessionSearchError(null) @@ -3350,11 +3776,114 @@ function ChatPage(props: ChatPageProps) { } return !v }) - }, [cancelInSessionSearchTasks]) + }, [cancelInSessionSearchJump, cancelInSessionSearchTasks]) // 全局消息搜索 const globalMsgSearchTimerRef = useRef | null>(null) const globalMsgSearchGenRef = useRef(0) + const ensureGlobalMsgSearchNotStale = useCallback((gen: number) => { + if (gen !== globalMsgSearchGenRef.current) { + throw new Error(GLOBAL_MSG_SEARCH_CANCELED_ERROR) + } + }, []) + + const runLegacyGlobalMsgSearch = useCallback(async ( + keyword: string, + sessionList: ChatSession[], + gen: number + ): Promise => { + const results: GlobalMsgSearchResult[] = [] + for (let index = 0; index < sessionList.length; index += GLOBAL_MSG_LEGACY_CONCURRENCY) { + ensureGlobalMsgSearchNotStale(gen) + const chunk = sessionList.slice(index, index + GLOBAL_MSG_LEGACY_CONCURRENCY) + const chunkResults = await Promise.allSettled( + chunk.map(async (session) => { + const res = await window.electronAPI.chat.searchMessages(keyword, session.username, GLOBAL_MSG_PER_SESSION_LIMIT, 0) + if (!res?.success) { + throw new Error(res?.error || `搜索失败: ${session.username}`) + } + return normalizeGlobalMsgSearchMessages(res?.messages || [], session.username) + }) + ) + ensureGlobalMsgSearchNotStale(gen) + + for (const item of chunkResults) { + if (item.status === 'rejected') { + throw item.reason instanceof Error ? item.reason : new Error(String(item.reason)) + } + if (item.value.length > 0) { + results.push(...item.value) + } + } + } + return sortMessagesByCreateTimeDesc(results) + }, [ensureGlobalMsgSearchNotStale]) + + const compareGlobalMsgSearchShadow = useCallback(( + keyword: string, + stagedResults: GlobalMsgSearchResult[], + legacyResults: GlobalMsgSearchResult[] + ) => { + const stagedMap = buildGlobalMsgSearchSessionLocalIds(stagedResults) + const legacyMap = buildGlobalMsgSearchSessionLocalIds(legacyResults) + const stagedSessions = Object.keys(stagedMap).sort() + const legacySessions = Object.keys(legacyMap).sort() + + let mismatch = stagedSessions.length !== legacySessions.length + if (!mismatch) { + for (let i = 0; i < stagedSessions.length; i += 1) { + if (stagedSessions[i] !== legacySessions[i]) { + mismatch = true + break + } + } + } + + if (!mismatch) { + for (const sessionId of stagedSessions) { + const stagedIds = stagedMap[sessionId] || [] + const legacyIds = legacyMap[sessionId] || [] + if (stagedIds.length !== legacyIds.length) { + mismatch = true + break + } + for (let i = 0; i < stagedIds.length; i += 1) { + if (stagedIds[i] !== legacyIds[i]) { + mismatch = true + break + } + } + if (mismatch) break + } + } + + if (!mismatch) { + const stagedOrder = stagedResults.map((row) => `${row.sessionId}:${row.localId || 0}:${row.messageKey || ''}`) + const legacyOrder = legacyResults.map((row) => `${row.sessionId}:${row.localId || 0}:${row.messageKey || ''}`) + if (stagedOrder.length !== legacyOrder.length) { + mismatch = true + } else { + for (let i = 0; i < stagedOrder.length; i += 1) { + if (stagedOrder[i] !== legacyOrder[i]) { + mismatch = true + break + } + } + } + } + + if (!mismatch) return + console.warn('[GlobalMsgSearch] shadow compare mismatch', { + keyword, + stagedSessionCount: stagedSessions.length, + legacySessionCount: legacySessions.length, + stagedResultCount: stagedResults.length, + legacyResultCount: legacyResults.length, + stagedMap, + legacyMap + }) + }, []) + const handleGlobalMsgSearch = useCallback(async (keyword: string) => { const normalizedKeyword = keyword.trim() setGlobalMsgQuery(keyword) @@ -3363,14 +3892,21 @@ function ChatPage(props: ChatPageProps) { globalMsgSearchGenRef.current += 1 if (!normalizedKeyword) { pendingGlobalMsgSearchReplayRef.current = null + globalMsgPrefixCacheRef.current = null setGlobalMsgResults([]) setGlobalMsgSearchError(null) setShowGlobalMsgSearch(false) setGlobalMsgSearching(false) + setGlobalMsgSearchPhase('idle') + setGlobalMsgIsBackfilling(false) + setGlobalMsgAuthoritativeSessionCount(0) return } setShowGlobalMsgSearch(true) setGlobalMsgSearchError(null) + setGlobalMsgSearchPhase('seed') + setGlobalMsgIsBackfilling(false) + setGlobalMsgAuthoritativeSessionCount(0) const sessionList = Array.isArray(sessionsRef.current) ? sessionsRef.current.filter((session) => String(session.username || '').trim()) : [] if (!isConnectedRef.current || sessionList.length === 0) { @@ -3378,6 +3914,9 @@ function ChatPage(props: ChatPageProps) { setGlobalMsgResults([]) setGlobalMsgSearchError(null) setGlobalMsgSearching(false) + setGlobalMsgSearchPhase('idle') + setGlobalMsgIsBackfilling(false) + setGlobalMsgAuthoritativeSessionCount(0) return } @@ -3386,103 +3925,288 @@ function ChatPage(props: ChatPageProps) { globalMsgSearchTimerRef.current = setTimeout(async () => { if (gen !== globalMsgSearchGenRef.current) return setGlobalMsgSearching(true) + setGlobalMsgSearchPhase('seed') + setGlobalMsgIsBackfilling(false) + setGlobalMsgAuthoritativeSessionCount(0) try { - const results: Array = [] - const concurrency = 6 + ensureGlobalMsgSearchNotStale(gen) - for (let index = 0; index < sessionList.length; index += concurrency) { - const chunk = sessionList.slice(index, index + concurrency) + const seedResponse = await window.electronAPI.chat.searchMessages(normalizedKeyword, undefined, GLOBAL_MSG_SEED_LIMIT, 0) + if (!seedResponse?.success) { + throw new Error(seedResponse?.error || '搜索失败') + } + ensureGlobalMsgSearchNotStale(gen) + + const seedRows = normalizeGlobalMsgSearchMessages(seedResponse?.messages || []) + const seedMap = buildGlobalMsgSearchSessionMap(seedRows) + const authoritativeMap = new Map() + setGlobalMsgResults(composeGlobalMsgSearchResults(seedMap, authoritativeMap)) + setGlobalMsgSearchError(null) + setGlobalMsgSearchPhase('backfill') + setGlobalMsgIsBackfilling(true) + + const previousPrefixCache = globalMsgPrefixCacheRef.current + const previousKeyword = String(previousPrefixCache?.keyword || '').trim() + const canUsePrefixCache = Boolean( + previousPrefixCache && + previousPrefixCache.completed && + previousKeyword && + normalizedKeyword.startsWith(previousKeyword) + ) + let targetSessionList = canUsePrefixCache + ? sessionList.filter((session) => previousPrefixCache?.matchedSessionIds.has(session.username)) + : sessionList + if (canUsePrefixCache && previousPrefixCache) { + let foundOutsidePrefix = false + for (const sessionId of seedMap.keys()) { + if (!previousPrefixCache.matchedSessionIds.has(sessionId)) { + foundOutsidePrefix = true + break + } + } + if (foundOutsidePrefix) { + targetSessionList = sessionList + } + } + + for (let index = 0; index < targetSessionList.length; index += GLOBAL_MSG_BACKFILL_CONCURRENCY) { + ensureGlobalMsgSearchNotStale(gen) + const chunk = targetSessionList.slice(index, index + GLOBAL_MSG_BACKFILL_CONCURRENCY) const chunkResults = await Promise.allSettled( chunk.map(async (session) => { - const res = await window.electronAPI.chat.searchMessages(normalizedKeyword, session.username, 10, 0) + const res = await window.electronAPI.chat.searchMessages(normalizedKeyword, session.username, GLOBAL_MSG_PER_SESSION_LIMIT, 0) if (!res?.success) { throw new Error(res?.error || `搜索失败: ${session.username}`) } - if (!res?.messages?.length) return [] - return res.messages.map((msg) => ({ ...msg, sessionId: session.username })) + return { + sessionId: session.username, + messages: normalizeGlobalMsgSearchMessages(res?.messages || [], session.username) + } }) ) - - if (gen !== globalMsgSearchGenRef.current) return + ensureGlobalMsgSearchNotStale(gen) for (const item of chunkResults) { if (item.status === 'rejected') { throw item.reason instanceof Error ? item.reason : new Error(String(item.reason)) } - if (item.value.length > 0) { - results.push(...item.value) - } + authoritativeMap.set(item.value.sessionId, item.value.messages) } + setGlobalMsgAuthoritativeSessionCount(authoritativeMap.size) + setGlobalMsgResults(composeGlobalMsgSearchResults(seedMap, authoritativeMap)) } - results.sort((a, b) => { - const timeDiff = (b.createTime || 0) - (a.createTime || 0) - if (timeDiff !== 0) return timeDiff - return (b.localId || 0) - (a.localId || 0) - }) - - if (gen !== globalMsgSearchGenRef.current) return - setGlobalMsgResults(results) + ensureGlobalMsgSearchNotStale(gen) + const finalResults = composeGlobalMsgSearchResults(seedMap, authoritativeMap) + setGlobalMsgResults(finalResults) setGlobalMsgSearchError(null) + setGlobalMsgSearchPhase('done') + setGlobalMsgIsBackfilling(false) + + const matchedSessionIds = new Set() + for (const row of finalResults) { + matchedSessionIds.add(row.sessionId) + } + globalMsgPrefixCacheRef.current = { + keyword: normalizedKeyword, + matchedSessionIds, + completed: true + } + + if (shouldRunGlobalMsgShadowCompareSample()) { + void (async () => { + try { + const legacyResults = await runLegacyGlobalMsgSearch(normalizedKeyword, sessionList, gen) + if (gen !== globalMsgSearchGenRef.current) return + compareGlobalMsgSearchShadow(normalizedKeyword, finalResults, legacyResults) + } catch (error) { + if (isGlobalMsgSearchCanceled(error)) return + console.warn('[GlobalMsgSearch] shadow compare failed:', error) + } + })() + } } catch (error) { + if (isGlobalMsgSearchCanceled(error)) return if (gen !== globalMsgSearchGenRef.current) return setGlobalMsgResults([]) setGlobalMsgSearchError(error instanceof Error ? error.message : String(error)) + setGlobalMsgSearchPhase('done') + setGlobalMsgIsBackfilling(false) + setGlobalMsgAuthoritativeSessionCount(0) + globalMsgPrefixCacheRef.current = null } finally { if (gen === globalMsgSearchGenRef.current) setGlobalMsgSearching(false) } }, 500) - }, []) + }, [compareGlobalMsgSearchShadow, ensureGlobalMsgSearchNotStale, runLegacyGlobalMsgSearch]) const handleCloseGlobalMsgSearch = useCallback(() => { globalMsgSearchGenRef.current += 1 if (globalMsgSearchTimerRef.current) clearTimeout(globalMsgSearchTimerRef.current) globalMsgSearchTimerRef.current = null pendingGlobalMsgSearchReplayRef.current = null + globalMsgPrefixCacheRef.current = null setShowGlobalMsgSearch(false) setGlobalMsgQuery('') setGlobalMsgResults([]) setGlobalMsgSearchError(null) setGlobalMsgSearching(false) + setGlobalMsgSearchPhase('idle') + setGlobalMsgIsBackfilling(false) + setGlobalMsgAuthoritativeSessionCount(0) }, []) - // 滚动加载更多 + 显示/隐藏回到底部按钮(优化:节流,避免频繁执行) - const scrollTimeoutRef = useRef(null) - const handleScroll = useCallback(() => { - if (!messageListRef.current) return - - // 节流:延迟执行,避免滚动时频繁计算 - if (scrollTimeoutRef.current) { - cancelAnimationFrame(scrollTimeoutRef.current) + const handleMessageRangeChanged = useCallback((range: { startIndex: number; endIndex: number }) => { + visibleMessageRangeRef.current = range + const total = messages.length + const shouldWarmupVisibleGroupSenders = Boolean( + currentSessionId && ( + isGroupChatSession(currentSessionId) || + ( + standaloneSessionWindow && + normalizedInitialSessionId && + currentSessionId === normalizedInitialSessionId && + normalizedStandaloneInitialContactType === 'group' + ) + ) + ) + if (total <= 0) { + isMessageListAtBottomRef.current = true + setShowScrollToBottom(prev => (prev ? false : prev)) + return } - scrollTimeoutRef.current = requestAnimationFrame(() => { - if (!messageListRef.current) return + if (range.endIndex >= Math.max(total - 2, 0)) { + isMessageListAtBottomRef.current = true + setShowScrollToBottom(prev => (prev ? false : prev)) + } - const { scrollTop, clientHeight, scrollHeight } = messageListRef.current + if ( + range.startIndex <= 2 && + !topRangeLoadLockRef.current && + !isLoadingMore && + !isLoadingMessages && + hasMoreMessages && + currentSessionId + ) { + topRangeLoadLockRef.current = true + void loadMessages(currentSessionId, currentOffset, jumpStartTime, jumpEndTime) + } - // 显示回到底部按钮:距离底部超过 300px - const distanceFromBottom = scrollHeight - scrollTop - clientHeight - setShowScrollToBottom(distanceFromBottom > 300) + if ( + range.endIndex >= total - 3 && + !bottomRangeLoadLockRef.current && + !suppressAutoLoadLaterRef.current && + !isLoadingMore && + !isLoadingMessages && + hasMoreLater && + currentSessionId + ) { + bottomRangeLoadLockRef.current = true + void loadLaterMessages() + } - // 预加载:当滚动到顶部 30% 区域时开始加载 - if (!isLoadingMore && !isLoadingMessages && hasMoreMessages && currentSessionId) { - const threshold = clientHeight * 0.3 - if (scrollTop < threshold) { - loadMessages(currentSessionId, currentOffset, jumpStartTime, jumpEndTime) + if (shouldWarmupVisibleGroupSenders) { + const now = Date.now() + if (now - lastVisibleSenderWarmupAtRef.current >= 180) { + lastVisibleSenderWarmupAtRef.current = now + const latestMessages = useChatStore.getState().messages || [] + const visibleStart = Math.max(range.startIndex - 12, 0) + const visibleEnd = Math.min(range.endIndex + 20, total - 1) + const pendingUsernames = new Set() + for (let index = visibleStart; index <= visibleEnd; index += 1) { + const msg = latestMessages[index] + if (!msg || msg.isSend === 1) continue + const sender = String(msg.senderUsername || '').trim() + if (!sender) continue + if (senderAvatarCache.has(sender) || senderAvatarLoading.has(sender)) continue + pendingUsernames.add(sender) + if (pendingUsernames.size >= 24) break + } + if (pendingUsernames.size > 0) { + warmupGroupSenderProfiles([...pendingUsernames], false) } } + } + }, [ + messages.length, + isLoadingMore, + isLoadingMessages, + hasMoreMessages, + hasMoreLater, + currentSessionId, + currentOffset, + jumpStartTime, + jumpEndTime, + isGroupChatSession, + standaloneSessionWindow, + normalizedInitialSessionId, + normalizedStandaloneInitialContactType, + warmupGroupSenderProfiles, + loadMessages, + loadLaterMessages + ]) - // 预加载更晚的消息 - if (!isLoadingMore && !isLoadingMessages && hasMoreLater && currentSessionId) { - const threshold = clientHeight * 0.3 - const distanceFromBottom = scrollHeight - scrollTop - clientHeight - if (distanceFromBottom < threshold) { - loadLaterMessages() - } - } - }) - }, [isLoadingMore, isLoadingMessages, hasMoreMessages, hasMoreLater, currentSessionId, currentOffset, jumpStartTime, jumpEndTime, loadMessages, loadLaterMessages]) + const handleMessageAtBottomStateChange = useCallback((atBottom: boolean) => { + if (messages.length <= 0) { + isMessageListAtBottomRef.current = true + setShowScrollToBottom(prev => (prev ? false : prev)) + return + } + + const listEl = messageListRef.current + const distanceFromBottom = listEl + ? (listEl.scrollHeight - (listEl.scrollTop + listEl.clientHeight)) + : Number.POSITIVE_INFINITY + const nearBottomByRange = visibleMessageRangeRef.current.endIndex >= Math.max(messages.length - 2, 0) + const nearBottomByDistance = distanceFromBottom <= 140 + const effectiveAtBottom = atBottom || nearBottomByRange || nearBottomByDistance + isMessageListAtBottomRef.current = effectiveAtBottom + + if (!effectiveAtBottom) { + bottomRangeLoadLockRef.current = false + // 用户主动离开底部后,解除“搜索跳转后的自动向后加载抑制” + suppressAutoLoadLaterRef.current = false + } + + if ( + isLoadingMessages || + isSessionSwitching || + isLoadingMore || + suppressScrollToBottomButtonRef.current + ) { + setShowScrollToBottom(prev => (prev ? false : prev)) + return + } + + if (effectiveAtBottom) { + setShowScrollToBottom(prev => (prev ? false : prev)) + return + } + const shouldShow = distanceFromBottom > 180 + setShowScrollToBottom(prev => (prev === shouldShow ? prev : shouldShow)) + }, [messages.length, isLoadingMessages, isLoadingMore, isSessionSwitching]) + + const handleMessageListWheel = useCallback((event: React.WheelEvent) => { + if (event.deltaY <= 18) return + if (!currentSessionId || isLoadingMore || isLoadingMessages || !hasMoreLater) return + const listEl = messageListRef.current + if (!listEl) return + const distanceFromBottom = listEl.scrollHeight - (listEl.scrollTop + listEl.clientHeight) + if (distanceFromBottom > 96) return + if (bottomRangeLoadLockRef.current) return + + // 用户明确向下滚动时允许加载后续消息 + suppressAutoLoadLaterRef.current = false + bottomRangeLoadLockRef.current = true + void loadLaterMessages() + }, [currentSessionId, hasMoreLater, isLoadingMessages, isLoadingMore, loadLaterMessages]) + + const handleMessageAtTopStateChange = useCallback((atTop: boolean) => { + if (!atTop) { + topRangeLoadLockRef.current = false + } + }, []) const isSameSession = useCallback((prev: ChatSession, next: ChatSession): boolean => { @@ -3495,7 +4219,8 @@ function ChatPage(props: ChatPageProps) { prev.lastTimestamp === next.lastTimestamp && prev.lastMsgType === next.lastMsgType && prev.displayName === next.displayName && - prev.avatarUrl === next.avatarUrl + prev.avatarUrl === next.avatarUrl && + prev.alias === next.alias ) }, []) @@ -3506,15 +4231,16 @@ function ChatPage(props: ChatPageProps) { return Array.isArray(sessionsRef.current) ? sessionsRef.current : [] } if (!Array.isArray(sessionsRef.current) || sessionsRef.current.length === 0) { - return nextSessions + return nextSessions.map((next) => mergeSessionContactPresentation(next)) } const prevMap = new Map(sessionsRef.current.map((s) => [s.username, s])) return nextSessions.map((next) => { const prev = prevMap.get(next.username) - if (!prev) return next - return isSameSession(prev, next) ? prev : next + const merged = mergeSessionContactPresentation(next, prev) + if (!prev) return merged + return isSameSession(prev, merged) ? prev : merged }) - }, [isSameSession]) + }, [isSameSession, mergeSessionContactPresentation]) const flashNewMessages = useCallback((keys: string[]) => { if (keys.length === 0) return @@ -3524,15 +4250,59 @@ function ChatPage(props: ChatPageProps) { }, 2500) }, []) + const handleInSessionResultJump = useCallback((msg: Message) => { + const targetTime = Number(msg.createTime || 0) + const targetSessionId = String(currentSessionRef.current || currentSessionId || '').trim() + if (!targetTime || !targetSessionId) return + + if (inSessionResultJumpTimerRef.current) { + window.clearTimeout(inSessionResultJumpTimerRef.current) + inSessionResultJumpTimerRef.current = null + } + + const requestSeq = inSessionResultJumpRequestSeqRef.current + 1 + inSessionResultJumpRequestSeqRef.current = requestSeq + const anchorEndTime = targetTime + 1 + const targetMessageKey = getMessageKey(msg) + + inSessionResultJumpTimerRef.current = window.setTimeout(() => { + inSessionResultJumpTimerRef.current = null + if (requestSeq !== inSessionResultJumpRequestSeqRef.current) return + if (currentSessionRef.current !== targetSessionId) return + + setCurrentOffset(0) + setJumpStartTime(0) + setJumpEndTime(anchorEndTime) + // 搜索跳转后默认不自动回流到最新消息,仅在用户主动向下滚动时加载后续 + suppressAutoLoadLaterRef.current = true + flashNewMessages([targetMessageKey]) + void loadMessages(targetSessionId, 0, 0, anchorEndTime, false, { + inSessionJumpRequestSeq: requestSeq + }) + }, 220) + }, [currentSessionId, flashNewMessages, getMessageKey, loadMessages]) + // 滚动到底部 const scrollToBottom = useCallback(() => { + suppressScrollToBottomButton(220) + isMessageListAtBottomRef.current = true + setShowScrollToBottom(false) + const lastIndex = messages.length - 1 + if (lastIndex >= 0 && messageVirtuosoRef.current) { + messageVirtuosoRef.current.scrollToIndex({ + index: lastIndex, + align: 'end', + behavior: 'auto' + }) + return + } if (messageListRef.current) { messageListRef.current.scrollTo({ top: messageListRef.current.scrollHeight, - behavior: 'smooth' + behavior: 'auto' }) } - }, []) + }, [messages.length, suppressScrollToBottomButton]) // 拖动调节侧边栏宽度 const handleResizeStart = useCallback((e: React.MouseEvent) => { @@ -3575,6 +4345,10 @@ function ChatPage(props: ChatPageProps) { window.clearTimeout(sessionListPersistTimerRef.current) sessionListPersistTimerRef.current = null } + if (scrollBottomButtonArmTimerRef.current !== null) { + window.clearTimeout(scrollBottomButtonArmTimerRef.current) + scrollBottomButtonArmTimerRef.current = null + } if (contactUpdateTimerRef.current) { clearTimeout(contactUpdateTimerRef.current) } @@ -3582,6 +4356,9 @@ function ChatPage(props: ChatPageProps) { clearTimeout(sessionScrollTimeoutRef.current) } contactUpdateQueueRef.current.clear() + pendingSessionContactEnrichRef.current.clear() + sessionContactEnrichAttemptAtRef.current.clear() + sessionContactProfileCacheRef.current.clear() enrichCancelledRef.current = true isEnrichingRef.current = false } @@ -3605,6 +4382,32 @@ function ChatPage(props: ChatPageProps) { lastMessageTimeRef.current = lastMsg?.createTime ?? 0 }, [messages, getMessageKey]) + useEffect(() => { + lastObservedMessageCountRef.current = messages.length + if (messages.length <= 0) { + isMessageListAtBottomRef.current = true + } + }, [currentSessionId]) + + useEffect(() => { + const previousCount = lastObservedMessageCountRef.current + const currentCount = messages.length + lastObservedMessageCountRef.current = currentCount + if (currentCount <= previousCount) return + if (!currentSessionId || isLoadingMessages || isSessionSwitching) return + const wasNearBottomByRange = visibleMessageRangeRef.current.endIndex >= Math.max(previousCount - 2, 0) + if (!isMessageListAtBottomRef.current && !wasNearBottomByRange) return + suppressScrollToBottomButton(220) + isMessageListAtBottomRef.current = true + requestAnimationFrame(() => { + const latestMessages = useChatStore.getState().messages || [] + const lastIndex = latestMessages.length - 1 + if (lastIndex >= 0 && messageVirtuosoRef.current) { + messageVirtuosoRef.current.scrollToIndex({ index: lastIndex, align: 'end', behavior: 'auto' }) + } + }) + }, [messages.length, currentSessionId, isLoadingMessages, isSessionSwitching, suppressScrollToBottomButton]) + useEffect(() => { currentSessionRef.current = currentSessionId }, [currentSessionId]) @@ -3657,14 +4460,46 @@ function ChatPage(props: ChatPageProps) { sessionMapRef.current = nextMap }, [sessions]) + useEffect(() => { + if (!Array.isArray(sessions) || sessions.length === 0) return + const now = Date.now() + const cache = sessionContactProfileCacheRef.current + + for (const session of sessions) { + const username = String(session.username || '').trim() + if (!username || isFoldPlaceholderSession(username)) continue + + const displayName = resolveSessionDisplayName(session.displayName, username) + const avatarUrl = normalizeSearchAvatarUrl(session.avatarUrl) + const alias = normalizeSearchIdentityText(session.alias) + if (!displayName && !avatarUrl && !alias) continue + + const prev = cache.get(username) + cache.set(username, { + displayName: displayName || prev?.displayName, + avatarUrl: avatarUrl || prev?.avatarUrl, + alias: alias || prev?.alias, + updatedAt: now + }) + } + + for (const [username, profile] of cache.entries()) { + if (now - profile.updatedAt > SESSION_CONTACT_PROFILE_CACHE_TTL_MS) { + cache.delete(username) + } + } + }, [sessions]) + useEffect(() => { sessionsRef.current = Array.isArray(sessions) ? sessions : [] }, [sessions]) useEffect(() => { - isLoadingMessagesRef.current = isLoadingMessages - isLoadingMoreRef.current = isLoadingMore - }, [isLoadingMessages, isLoadingMore]) + if (!isLoadingMore) { + topRangeLoadLockRef.current = false + bottomRangeLoadLockRef.current = false + } + }, [isLoadingMore]) useEffect(() => { if (initialRevealTimerRef.current !== null) { @@ -3745,6 +4580,7 @@ function ChatPage(props: ChatPageProps) { clearTimeout(globalMsgSearchTimerRef.current) globalMsgSearchTimerRef.current = null } + globalMsgPrefixCacheRef.current = null } }, []) @@ -3814,6 +4650,15 @@ function ChatPage(props: ChatPageProps) { saveSessionWindowCache ]) + useEffect(() => { + return () => { + inSessionResultJumpRequestSeqRef.current += 1 + if (inSessionResultJumpTimerRef.current) { + window.clearTimeout(inSessionResultJumpTimerRef.current) + } + } + }, []) + useEffect(() => { if (!Array.isArray(sessions) || sessions.length === 0) return if (sessionListPersistTimerRef.current !== null) { @@ -3826,17 +4671,16 @@ function ChatPage(props: ChatPageProps) { }, [sessions, persistSessionListCache]) // 普通视图:隐藏 isFolded 的群,保留 placeholder_foldgroup 入口 - useEffect(() => { + const filteredSessions = useMemo(() => { if (!Array.isArray(sessions)) { - setFilteredSessions([]) - return + return [] } // 检查是否有折叠的群聊 const foldedGroups = sessions.filter(s => s.isFolded && !s.username.toLowerCase().includes('placeholder_foldgroup')) const hasFoldedGroups = foldedGroups.length > 0 - let visible = sessions.filter(s => { + const visible = sessions.filter(s => { if (s.isFolded && !s.username.toLowerCase().includes('placeholder_foldgroup')) return false return true }) @@ -3877,37 +4721,34 @@ function ChatPage(props: ChatPageProps) { } if (!searchKeyword.trim()) { - setFilteredSessions(visible) - return + return visible } const lower = searchKeyword.toLowerCase() - setFilteredSessions(visible - .filter(s => { - const matchedByName = s.displayName?.toLowerCase().includes(lower) - const matchedByUsername = s.username.toLowerCase().includes(lower) - const matchedByAlias = s.alias?.toLowerCase().includes(lower) - return matchedByName || matchedByUsername || matchedByAlias - }) - .map(s => { - const matchedByName = s.displayName?.toLowerCase().includes(lower) - const matchedByUsername = s.username.toLowerCase().includes(lower) - const matchedByAlias = s.alias?.toLowerCase().includes(lower) + return visible + .filter(s => { + const matchedByName = s.displayName?.toLowerCase().includes(lower) + const matchedByUsername = s.username.toLowerCase().includes(lower) + const matchedByAlias = s.alias?.toLowerCase().includes(lower) + return matchedByName || matchedByUsername || matchedByAlias + }) + .map(s => { + const matchedByName = s.displayName?.toLowerCase().includes(lower) + const matchedByUsername = s.username.toLowerCase().includes(lower) + const matchedByAlias = s.alias?.toLowerCase().includes(lower) - let matchedField: 'wxid' | 'alias' | 'name' | undefined = undefined - - if (matchedByUsername && !matchedByName && !matchedByAlias) { - matchedField = 'wxid' - } else if (matchedByAlias && !matchedByName && !matchedByUsername) { - matchedField = 'alias' - } else if (matchedByName && !matchedByUsername && !matchedByAlias) { - matchedField = 'name' - } + let matchedField: 'wxid' | 'alias' | 'name' | undefined = undefined - // ✅ 关键点:返回一个新对象,解耦全局状态 - return { ...s, matchedField } - }) - ) - }, [sessions, searchKeyword, setFilteredSessions]) + if (matchedByUsername && !matchedByName && !matchedByAlias) { + matchedField = 'wxid' + } else if (matchedByAlias && !matchedByName && !matchedByUsername) { + matchedField = 'alias' + } else if (matchedByName && !matchedByUsername && !matchedByAlias) { + matchedField = 'name' + } + + return { ...s, matchedField } + }) + }, [sessions, searchKeyword]) // 折叠群列表(独立计算,供折叠 panel 使用) const foldedSessions = useMemo(() => { @@ -3945,6 +4786,25 @@ function ChatPage(props: ChatPageProps) { }) }, [sessions, searchKeyword, foldedView]) + const sessionLookupMap = useMemo(() => { + const map = new Map() + for (const session of sessions) { + const username = String(session.username || '').trim() + if (!username) continue + map.set(username, session) + } + return map + }, [sessions]) + const groupedGlobalMsgResults = useMemo(() => { + const grouped = globalMsgResults.reduce((acc, msg) => { + const sessionId = (msg as any).sessionId || '未知' + if (!acc[sessionId]) acc[sessionId] = [] + acc[sessionId].push(msg) + return acc + }, {} as Record) + return Object.entries(grouped) + }, [globalMsgResults]) + const hasSessionRecords = Array.isArray(sessions) && sessions.length > 0 const shouldShowSessionsSkeleton = isLoadingSessions && !hasSessionRecords const isSessionListSyncing = (isLoadingSessions || isRefreshingSessions) && hasSessionRecords @@ -4217,6 +5077,7 @@ function ChatPage(props: ChatPageProps) { setBatchVoiceCount(voiceMessages.length) setBatchVoiceDates(sortedDates) setBatchSelectedDates(new Set(sortedDates)) + setBatchVoiceTaskType('transcribe') setShowBatchConfirm(true) }, [sessions, currentSessionId, isBatchTranscribing]) @@ -4280,7 +5141,7 @@ function ChatPage(props: ChatPageProps) { }) }, [currentSessionId, navigate, isGroupChatSession]) - // 确认批量转写 + // 确认批量语音任务(解密/转写) const confirmBatchTranscribe = useCallback(async () => { if (!currentSessionId) return @@ -4312,23 +5173,35 @@ function ChatPage(props: ChatPageProps) { const session = sessions.find(s => s.username === currentSessionId) if (!session) return - startTranscribe(voiceMessages.length, session.displayName || session.username) + const taskType = batchVoiceTaskType + startTranscribe(voiceMessages.length, session.displayName || session.username, taskType) - // 检查模型状态 - const modelStatus = await window.electronAPI.whisper.getModelStatus() - if (!modelStatus?.exists) { - alert('SenseVoice 模型未下载,请先在设置中下载模型') - finishTranscribe(0, 0) - return + if (taskType === 'transcribe') { + // 检查模型状态 + const modelStatus = await window.electronAPI.whisper.getModelStatus() + if (!modelStatus?.exists) { + alert('SenseVoice 模型未下载,请先在设置中下载模型') + finishTranscribe(0, 0) + return + } } let successCount = 0 let failCount = 0 let completedCount = 0 - const concurrency = 10 + const concurrency = taskType === 'decrypt' ? 12 : 10 - const transcribeOne = async (msg: Message) => { + const runOne = async (msg: Message) => { try { + if (taskType === 'decrypt') { + const result = await window.electronAPI.chat.getVoiceData( + session.username, + String(msg.localId), + msg.createTime, + msg.serverIdRaw || msg.serverId + ) + return { success: Boolean(result.success && result.data) } + } const result = await window.electronAPI.chat.getVoiceTranscript( session.username, String(msg.localId), @@ -4342,7 +5215,7 @@ function ChatPage(props: ChatPageProps) { for (let i = 0; i < voiceMessages.length; i += concurrency) { const batch = voiceMessages.slice(i, i + concurrency) - const results = await Promise.all(batch.map(msg => transcribeOne(msg))) + const results = await Promise.all(batch.map(msg => runOne(msg))) results.forEach(result => { if (result.success) successCount++ @@ -4353,7 +5226,7 @@ function ChatPage(props: ChatPageProps) { } finishTranscribe(successCount, failCount) - }, [sessions, currentSessionId, batchSelectedDates, batchVoiceMessages, startTranscribe, updateProgress, finishTranscribe]) + }, [sessions, currentSessionId, batchSelectedDates, batchVoiceMessages, batchVoiceTaskType, startTranscribe, updateProgress, finishTranscribe]) // 批量转写:按日期的消息数量 const batchCountByDate = useMemo(() => { @@ -4374,6 +5247,12 @@ function ChatPage(props: ChatPageProps) { ).length }, [batchVoiceMessages, batchSelectedDates]) + const batchVoiceTaskTitle = batchVoiceTaskType === 'decrypt' ? '批量解密语音' : '批量语音转文字' + const batchVoiceTaskVerb = batchVoiceTaskType === 'decrypt' ? '解密' : '转写' + const batchVoiceTaskMinutes = Math.ceil( + batchSelectedMessageCount * (batchVoiceTaskType === 'decrypt' ? 0.6 : 2) / 60 + ) + const toggleBatchDate = useCallback((date: string) => { setBatchSelectedDates(prev => { const next = new Set(prev) @@ -4517,15 +5396,28 @@ function ChatPage(props: ChatPageProps) { return `${y}年${m}月${d}日` }, []) + const clampContextMenuPosition = useCallback((x: number, y: number) => { + const viewportPadding = 12 + const estimatedMenuWidth = 180 + const estimatedMenuHeight = 188 + const maxLeft = Math.max(viewportPadding, window.innerWidth - estimatedMenuWidth - viewportPadding) + const maxTop = Math.max(viewportPadding, window.innerHeight - estimatedMenuHeight - viewportPadding) + return { + x: Math.min(Math.max(x, viewportPadding), maxLeft), + y: Math.min(Math.max(y, viewportPadding), maxTop) + } + }, []) + // 消息右键菜单处理 const handleContextMenu = useCallback((e: React.MouseEvent, message: Message) => { e.preventDefault() + const nextPos = clampContextMenuPosition(e.clientX, e.clientY) setContextMenu({ - x: e.clientX, - y: e.clientY, + x: nextPos.x, + y: nextPos.y, message }) - }, []) + }, [clampContextMenuPosition]) // 关闭右键菜单 useEffect(() => { @@ -4711,6 +5603,85 @@ function ChatPage(props: ChatPageProps) { } } + const messageVirtuosoComponents = useMemo(() => ({ + Header: () => ( + hasMoreMessages ? ( +
+ {isLoadingMore ? ( + <> + + 加载更多... + + ) : ( + 向上滚动加载更多 + )} +
+ ) : null + ), + Footer: () => ( + hasMoreLater ? ( +
+ {isLoadingMore ? ( + <> + + 正在加载后续消息... + + ) : ( + 向下滚动查看更新消息 + )} +
+ ) : null + ) + }), [hasMoreMessages, hasMoreLater, isLoadingMore]) + + const renderMessageListItem = useCallback((index: number, msg: Message) => { + const prevMsg = index > 0 ? messages[index - 1] : undefined + const showDateDivider = shouldShowDateDivider(msg, prevMsg) + const showTime = !prevMsg || (msg.createTime - prevMsg.createTime > 300) + const isSent = msg.isSend === 1 + const isSystem = isSystemMessage(msg.localType) + const wrapperClass = isSystem ? 'system' : (isSent ? 'sent' : 'received') + const messageKey = getMessageKey(msg) + + return ( +
+ {showDateDivider && ( +
+ {formatDateDivider(msg.createTime)} +
+ )} + +
+ ) + }, [ + messages, + highlightedMessageSet, + getMessageKey, + formatDateDivider, + currentSession, + myAvatarUrl, + isCurrentSessionGroup, + autoTranscribeVoiceEnabled, + handleRequireModelDownload, + handleContextMenu, + isSelectionMode, + selectedMessages, + handleToggleSelection + ]) + return (
{/* 自定义删除确认对话框 */} @@ -4842,31 +5813,31 @@ function ChatPage(props: ChatPageProps) { {/* 全局消息搜索结果 */} {globalMsgQuery && (
- {globalMsgSearching ? ( -
- - 搜索中... -
- ) : globalMsgSearchError ? ( + {globalMsgSearchError ? (

{globalMsgSearchError}

) : globalMsgResults.length > 0 ? ( <> -
聊天记录:
+
+ 聊天记录: + {globalMsgSearching && ( + + {globalMsgIsBackfilling + ? `补全中 ${globalMsgAuthoritativeSessionCount > 0 ? `(${globalMsgAuthoritativeSessionCount})` : ''}...` + : '搜索中...'} + + )} + {!globalMsgSearching && globalMsgSearchPhase === 'done' && ( + 已完成 + )} +
- {Object.entries( - globalMsgResults.reduce((acc, msg) => { - const sessionId = (msg as any).sessionId || '未知'; - if (!acc[sessionId]) acc[sessionId] = []; - acc[sessionId].push(msg); - return acc; - }, {} as Record) - ).map(([sessionId, messages]) => { - const session = sessions.find(s => s.username === sessionId); - const firstMsg = messages[0]; - const count = messages.length; + {groupedGlobalMsgResults.map(([sessionId, messages]) => { + const session = sessionLookupMap.get(sessionId) + const firstMsg = messages[0] + const count = messages.length return (
- ); + ) })}
+ ) : globalMsgSearching ? ( +
+ + {globalMsgSearchPhase === 'seed' ? '搜索中...' : '补全中...'} +
) : (
@@ -5070,7 +6046,9 @@ function ChatPage(props: ChatPageProps) { } }} disabled={!currentSessionId} - title={isBatchTranscribing ? `批量转写中 (${batchTranscribeProgress.current}/${batchTranscribeProgress.total}),点击查看进度` : '批量语音转文字'} + title={isBatchTranscribing + ? `${runningBatchVoiceTaskType === 'decrypt' ? '批量语音解密' : '批量转写'}中 (${batchTranscribeProgress.current}/${batchTranscribeProgress.total}),点击查看进度` + : '批量语音处理(解密/转文字)'} > {isBatchTranscribing ? ( @@ -5238,17 +6216,10 @@ function ChatPage(props: ChatPageProps) { const displayTime = msg.createTime ? new Date(msg.createTime * 1000).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }) : '' + const resultKey = getMessageKey(msg) return ( -
{ - const ts = msg.createTime - if (ts && currentSessionId) { - setCurrentOffset(0) - setJumpStartTime(0) - setJumpEndTime(0) - void loadMessages(currentSessionId, 0, ts - 1, ts + 1, false) - } - }}> +
handleInSessionResultJump(msg)}>
@@ -5287,77 +6258,30 @@ function ChatPage(props: ChatPageProps) { )}
- {hasMoreMessages && ( -
- {isLoadingMore ? ( - <> - - 加载更多... - - ) : ( - 向上滚动加载更多 - )} -
- )} - - {!isLoadingMessages && messages.length === 0 && !hasMoreMessages && ( + {!isLoadingMessages && messages.length === 0 && !hasMoreMessages ? (
该联系人没有聊天记录
- )} - - {(messages || []).map((msg, index) => { - const prevMsg = index > 0 ? messages[index - 1] : undefined - const showDateDivider = shouldShowDateDivider(msg, prevMsg) - - // 显示时间:第一条消息,或者与上一条消息间隔超过5分钟 - const showTime = !prevMsg || (msg.createTime - prevMsg.createTime > 300) - const isSent = msg.isSend === 1 - const isSystem = isSystemMessage(msg.localType) - - // 系统消息居中显示 - const wrapperClass = isSystem ? 'system' : (isSent ? 'sent' : 'received') - - const messageKey = getMessageKey(msg) - return ( -
- {showDateDivider && ( -
- {formatDateDivider(msg.createTime)} -
- )} - -
- ) - })} - - {hasMoreLater && ( -
- {isLoadingMore ? ( - <> - - 正在加载后续消息... - - ) : ( - 向下滚动查看更新消息 - )} -
+ ) : ( + (atBottom || isMessageListAtBottomRef.current ? 'auto' : false)} + atBottomThreshold={80} + atBottomStateChange={handleMessageAtBottomStateChange} + atTopStateChange={handleMessageAtTopStateChange} + rangeChanged={handleMessageRangeChanged} + computeItemKey={(_, msg) => getMessageKey(msg)} + components={messageVirtuosoComponents} + itemContent={renderMessageListItem} + /> )} {/* 回到底部按钮 */} @@ -5723,12 +6647,13 @@ function ChatPage(props: ChatPageProps) { // 下载完成后,触发页面刷新让组件重新尝试转写 // 通过更新缓存触发组件重新检查 if (pendingVoiceTranscriptRequest) { - // 清除缓存中的请求标记,让组件可以重新尝试 - const cacheKey = `voice-transcript:${pendingVoiceTranscriptRequest.messageId}` // 不直接调用转写,而是让组件自己重试 // 通过触发一个自定义事件来通知所有 MessageBubble 组件 window.dispatchEvent(new CustomEvent('model-downloaded', { - detail: { messageId: pendingVoiceTranscriptRequest.messageId } + detail: { + sessionId: pendingVoiceTranscriptRequest.sessionId, + messageId: pendingVoiceTranscriptRequest.messageId + } })) } setPendingVoiceTranscriptRequest(null) @@ -5742,10 +6667,26 @@ function ChatPage(props: ChatPageProps) {
e.stopPropagation()}>
-

批量语音转文字

+

{batchVoiceTaskTitle}

-

选择要转写的日期(仅显示有语音的日期),然后开始转写。

+

先选择任务类型,再选择日期(仅显示有语音的日期),然后开始处理。

+
+ + +
{batchVoiceDates.length > 0 && (
@@ -5780,12 +6721,16 @@ function ChatPage(props: ChatPageProps) {
预计耗时: - 约 {Math.ceil(batchSelectedMessageCount * 2 / 60)} 分钟 + 约 {batchVoiceTaskMinutes} 分钟
- 批量转写可能需要较长时间,转写过程中可以继续使用其他功能。已转写过的语音会自动跳过。 + + {batchVoiceTaskType === 'decrypt' + ? '批量解密会预先缓存语音数据,之后播放和转写会更快。解密过程中可以继续使用其他功能。' + : '批量转写可能需要较长时间,转写过程中可以继续使用其他功能。已转写过的语音会自动跳过。'} +
@@ -5794,7 +6739,7 @@ function ChatPage(props: ChatPageProps) {
@@ -5898,14 +6843,16 @@ function ChatPage(props: ChatPageProps) { {contextMenu && createPortal( <>
setContextMenu(null)} - style={{ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, zIndex: 9998 }} /> + style={{ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, zIndex: 12040 }} />
e.stopPropagation()} > @@ -6240,9 +7187,40 @@ const emojiDataUrlCache = new Map() const imageDataUrlCache = new Map() const voiceDataUrlCache = new Map() const voiceTranscriptCache = new Map() +type SharedImageDecryptResult = { success: boolean; localPath?: string; liveVideoPath?: string; error?: string } +const imageDecryptInFlight = new Map>() const senderAvatarCache = new Map() const senderAvatarLoading = new Map>() +function getSharedImageDecryptTask( + key: string, + createTask: () => Promise +): Promise { + const existing = imageDecryptInFlight.get(key) + if (existing) return existing + const task = createTask().finally(() => { + if (imageDecryptInFlight.get(key) === task) { + imageDecryptInFlight.delete(key) + } + }) + imageDecryptInFlight.set(key, task) + return task +} + +const buildVoiceCacheIdentity = ( + sessionId: string, + message: Pick +): string => { + const normalizedSessionId = String(sessionId || '').trim() + const localId = Math.max(0, Math.floor(Number(message?.localId || 0))) + const createTime = Math.max(0, Math.floor(Number(message?.createTime || 0))) + const serverIdRaw = String(message?.serverIdRaw ?? message?.serverId ?? '').trim() + const serverId = /^\d+$/.test(serverIdRaw) + ? serverIdRaw.replace(/^0+(?=\d)/, '') + : String(Math.max(0, Math.floor(Number(serverIdRaw || 0)))) + return `${normalizedSessionId}:${localId}:${createTime}:${serverId || '0'}` +} + // 引用消息中的动画表情组件 function QuotedEmoji({ cdnUrl, md5 }: { cdnUrl: string; md5?: string }) { const cacheKey = md5 || cdnUrl @@ -6276,6 +7254,7 @@ function MessageBubble({ showTime, myAvatarUrl, isGroupChat, + autoTranscribeVoiceEnabled, onRequireModelDownload, onContextMenu, isSelectionMode, @@ -6288,6 +7267,7 @@ function MessageBubble({ showTime?: boolean; myAvatarUrl?: string; isGroupChat?: boolean; + autoTranscribeVoiceEnabled?: boolean; onRequireModelDownload?: (sessionId: string, messageId: string) => void; onContextMenu?: (e: React.MouseEvent, message: Message) => void; isSelectionMode?: boolean; @@ -6305,6 +7285,7 @@ function MessageBubble({ const isSent = message.isSend === 1 const [senderAvatarUrl, setSenderAvatarUrl] = useState(undefined) const [senderName, setSenderName] = useState(undefined) + const senderProfileRequestSeqRef = useRef(0) const [emojiError, setEmojiError] = useState(false) const [emojiLoading, setEmojiLoading] = useState(false) @@ -6317,11 +7298,12 @@ function MessageBubble({ const [imageLocalPath, setImageLocalPath] = useState( () => imageDataUrlCache.get(imageCacheKey) ) - const voiceCacheKey = `voice:${message.localId}` + const voiceIdentityKey = buildVoiceCacheIdentity(session.username, message) + const voiceCacheKey = `voice:${voiceIdentityKey}` const [voiceDataUrl, setVoiceDataUrl] = useState( () => voiceDataUrlCache.get(voiceCacheKey) ) - const voiceTranscriptCacheKey = `voice-transcript:${message.localId}` + const voiceTranscriptCacheKey = `voice-transcript:${voiceIdentityKey}` const [voiceTranscript, setVoiceTranscript] = useState( () => voiceTranscriptCache.get(voiceTranscriptCacheKey) ) @@ -6334,11 +7316,17 @@ function MessageBubble({ const imageUpdateCheckedRef = useRef(null) const imageClickTimerRef = useRef(null) const imageContainerRef = useRef(null) + const emojiContainerRef = useRef(null) + const imageResizeBaselineRef = useRef(null) + const emojiResizeBaselineRef = useRef(null) + const imageObservedHeightRef = useRef(null) + const emojiObservedHeightRef = useRef(null) const imageAutoDecryptTriggered = useRef(false) const imageAutoHdTriggered = useRef(null) const [imageInView, setImageInView] = useState(false) const imageForceHdAttempted = useRef(null) const imageForceHdPending = useRef(false) + const imageDecryptPendingRef = useRef(false) const [imageLiveVideoPath, setImageLiveVideoPath] = useState(undefined) const [voiceError, setVoiceError] = useState(false) const [voiceLoading, setVoiceLoading] = useState(false) @@ -6347,7 +7335,6 @@ function MessageBubble({ const [voiceTranscriptLoading, setVoiceTranscriptLoading] = useState(false) const [voiceTranscriptError, setVoiceTranscriptError] = useState(false) const voiceTranscriptRequestedRef = useRef(false) - const [autoTranscribeVoice, setAutoTranscribeVoice] = useState(true) const [voiceCurrentTime, setVoiceCurrentTime] = useState(0) const [voiceDuration, setVoiceDuration] = useState(0) const [voiceWaveform, setVoiceWaveform] = useState([]) @@ -6397,15 +7384,6 @@ function MessageBubble({ } }, [isVideo, message.videoMd5, message.content, message.parsedContent]) - // 加载自动转文字配置 - useEffect(() => { - const loadConfig = async () => { - const enabled = await configService.getAutoTranscribeVoice() - setAutoTranscribeVoice(enabled) - } - loadConfig() - }, []) - const formatTime = (timestamp: number): string => { if (!Number.isFinite(timestamp) || timestamp <= 0) return '未知时间' const date = new Date(timestamp * 1000) @@ -6434,12 +7412,108 @@ function MessageBubble({ return 'image/jpeg' }, []) - // 获取头像首字母 - const getAvatarLetter = (name: string): string => { - if (!name) return '?' - const chars = [...name] - return chars[0] || '?' - } + const getImageObserverRoot = useCallback((): Element | null => { + return imageContainerRef.current?.closest('.message-list') ?? null + }, []) + + const stabilizeScrollerByDelta = useCallback((host: HTMLElement | null, delta: number) => { + if (!host) return + if (!Number.isFinite(delta) || Math.abs(delta) < 1) return + const scroller = host.closest('.message-list') as HTMLDivElement | null + if (!scroller) return + + const distanceFromBottom = scroller.scrollHeight - (scroller.scrollTop + scroller.clientHeight) + if (distanceFromBottom <= 96) return + + const scrollerRect = scroller.getBoundingClientRect() + const hostRect = host.getBoundingClientRect() + const hostTopInScroller = hostRect.top - scrollerRect.top + scroller.scrollTop + const viewportBottom = scroller.scrollTop + scroller.clientHeight + if (hostTopInScroller > viewportBottom + 24) return + + scroller.scrollTop += delta + }, []) + + const bindResizeObserverForHost = useCallback(( + host: HTMLElement | null, + observedHeightRef: React.MutableRefObject, + pendingBaselineRef: React.MutableRefObject + ) => { + if (!host) return + + const initialHeight = host.getBoundingClientRect().height + observedHeightRef.current = Number.isFinite(initialHeight) && initialHeight > 0 ? initialHeight : null + if (typeof ResizeObserver === 'undefined') return + + const observer = new ResizeObserver(() => { + const nextHeight = host.getBoundingClientRect().height + if (!Number.isFinite(nextHeight) || nextHeight <= 0) { + observedHeightRef.current = null + return + } + const previousHeight = observedHeightRef.current + observedHeightRef.current = nextHeight + if (!Number.isFinite(previousHeight) || (previousHeight as number) <= 0) return + if (pendingBaselineRef.current !== null) return + stabilizeScrollerByDelta(host, nextHeight - (previousHeight as number)) + }) + + observer.observe(host) + return () => { + observer.disconnect() + } + }, [stabilizeScrollerByDelta]) + + const captureResizeBaseline = useCallback( + (host: HTMLElement | null, baselineRef: React.MutableRefObject) => { + if (!host) return + const height = host.getBoundingClientRect().height + if (!Number.isFinite(height) || height <= 0) return + baselineRef.current = height + }, + [] + ) + + const stabilizeScrollAfterResize = useCallback( + (host: HTMLElement | null, baselineRef: React.MutableRefObject) => { + if (!host) return + const baseline = baselineRef.current + baselineRef.current = null + if (!Number.isFinite(baseline) || (baseline as number) <= 0) return + + requestAnimationFrame(() => { + const nextHeight = host.getBoundingClientRect().height + stabilizeScrollerByDelta(host, nextHeight - (baseline as number)) + }) + }, + [stabilizeScrollerByDelta] + ) + + const captureImageResizeBaseline = useCallback(() => { + captureResizeBaseline(imageContainerRef.current, imageResizeBaselineRef) + }, [captureResizeBaseline]) + + const captureEmojiResizeBaseline = useCallback(() => { + captureResizeBaseline(emojiContainerRef.current, emojiResizeBaselineRef) + }, [captureResizeBaseline]) + + const stabilizeImageScrollAfterResize = useCallback(() => { + stabilizeScrollAfterResize(imageContainerRef.current, imageResizeBaselineRef) + }, [stabilizeScrollAfterResize]) + + const stabilizeEmojiScrollAfterResize = useCallback(() => { + stabilizeScrollAfterResize(emojiContainerRef.current, emojiResizeBaselineRef) + }, [stabilizeScrollAfterResize]) + + useEffect(() => { + if (!isImage) return + return bindResizeObserverForHost(imageContainerRef.current, imageObservedHeightRef, imageResizeBaselineRef) + }, [isImage, imageLocalPath, imageLoading, imageError, bindResizeObserverForHost]) + + useEffect(() => { + if (!isEmoji) return + return bindResizeObserverForHost(emojiContainerRef.current, emojiObservedHeightRef, emojiResizeBaselineRef) + }, [isEmoji, emojiLocalPath, emojiLoading, emojiError, bindResizeObserverForHost]) // 下载表情包 const downloadEmoji = () => { @@ -6448,6 +7522,7 @@ function MessageBubble({ // 先检查缓存 const cached = emojiDataUrlCache.get(cacheKey) if (cached) { + captureEmojiResizeBaseline() setEmojiLocalPath(cached) setEmojiError(false) return @@ -6458,6 +7533,7 @@ function MessageBubble({ window.electronAPI.chat.downloadEmoji(message.emojiCdnUrl, message.emojiMd5).then((result: { success: boolean; localPath?: string; error?: string }) => { if (result.success && result.localPath) { emojiDataUrlCache.set(cacheKey, result.localPath) + captureEmojiResizeBaseline() setEmojiLocalPath(result.localPath) } else { setEmojiError(true) @@ -6471,37 +7547,55 @@ function MessageBubble({ // 群聊中获取发送者信息 (如果自己发的没头像,也尝试拉取) useEffect(() => { - if (message.senderUsername && (isGroupChat || (isSent && !myAvatarUrl))) { - const sender = message.senderUsername - const cached = senderAvatarCache.get(sender) - if (cached) { - setSenderAvatarUrl(cached.avatarUrl) - setSenderName(cached.displayName) - return - } - const pending = senderAvatarLoading.get(sender) - if (pending) { - pending.then((result: { avatarUrl?: string; displayName?: string } | null) => { - if (result) { - setSenderAvatarUrl(result.avatarUrl) - setSenderName(result.displayName) - } - }) - return - } - const request = window.electronAPI.chat.getContactAvatar(sender) - senderAvatarLoading.set(sender, request) - request.then((result: { avatarUrl?: string; displayName?: string } | null) => { - if (result) { - senderAvatarCache.set(sender, result) - setSenderAvatarUrl(result.avatarUrl) - setSenderName(result.displayName) - } - }).catch(() => { }).finally(() => { - senderAvatarLoading.delete(sender) - }) + const sender = String(message.senderUsername || '').trim() + const cached = sender ? senderAvatarCache.get(sender) : undefined + setSenderAvatarUrl(cached?.avatarUrl || message.senderAvatarUrl || undefined) + setSenderName(cached?.displayName || message.senderDisplayName || undefined) + + if (!sender || !(isGroupChat || (isSent && !myAvatarUrl))) return + + const requestSeq = senderProfileRequestSeqRef.current + 1 + senderProfileRequestSeqRef.current = requestSeq + let cancelled = false + const applyProfile = (result: { avatarUrl?: string; displayName?: string } | null) => { + if (!result || cancelled) return + if (requestSeq !== senderProfileRequestSeqRef.current) return + if (result.avatarUrl) setSenderAvatarUrl(result.avatarUrl) + if (result.displayName) setSenderName(result.displayName) } - }, [isGroupChat, isSent, message.senderUsername, myAvatarUrl]) + + if (cached) { + applyProfile(cached) + return () => { + cancelled = true + } + } + + const pending = senderAvatarLoading.get(sender) + if (pending) { + pending.then(applyProfile).catch(() => { }) + return () => { + cancelled = true + } + } + + const request = window.electronAPI.chat.getContactAvatar(sender) + senderAvatarLoading.set(sender, request) + request.then((result: { avatarUrl?: string; displayName?: string } | null) => { + if (result) { + senderAvatarCache.set(sender, result) + } + applyProfile(result) + }).catch(() => { }).finally(() => { + if (senderAvatarLoading.get(sender) === request) { + senderAvatarLoading.delete(sender) + } + }) + + return () => { + cancelled = true + } + }, [isGroupChat, isSent, message.senderAvatarUrl, message.senderDisplayName, message.senderUsername, myAvatarUrl]) // 解析转账消息的付款方和收款方显示名称 useEffect(() => { @@ -6528,31 +7622,39 @@ function MessageBubble({ if (emojiLocalPath) return // 后端已从本地缓存找到文件(转发表情包无 CDN URL 的情况) if (isEmoji && message.emojiLocalPath && !emojiLocalPath) { + captureEmojiResizeBaseline() setEmojiLocalPath(message.emojiLocalPath) return } if (isEmoji && message.emojiCdnUrl && !emojiLoading && !emojiError) { downloadEmoji() } - }, [isEmoji, message.emojiCdnUrl, message.emojiLocalPath, emojiLocalPath, emojiLoading, emojiError]) + }, [isEmoji, message.emojiCdnUrl, message.emojiLocalPath, emojiLocalPath, emojiLoading, emojiError, captureEmojiResizeBaseline]) - const requestImageDecrypt = useCallback(async (forceUpdate = false, silent = false) => { - if (!isImage) return - if (imageLoading) return + const requestImageDecrypt = useCallback(async (forceUpdate = false, silent = false): Promise => { + if (!isImage) return { success: false } + if (imageDecryptPendingRef.current) return { success: false } + imageDecryptPendingRef.current = true if (!silent) { setImageLoading(true) setImageError(false) } try { if (message.imageMd5 || message.imageDatName) { - const result = await window.electronAPI.image.decrypt({ - sessionId: session.username, - imageMd5: message.imageMd5 || undefined, - imageDatName: message.imageDatName, - force: forceUpdate + const sharedDecryptKey = `${session.username}:${imageCacheKey}:${forceUpdate ? 'force' : 'normal'}` + const result = await getSharedImageDecryptTask(sharedDecryptKey, async () => { + return await window.electronAPI.image.decrypt({ + sessionId: session.username, + imageMd5: message.imageMd5 || undefined, + imageDatName: message.imageDatName, + force: forceUpdate + }) as SharedImageDecryptResult }) if (result.success && result.localPath) { imageDataUrlCache.set(imageCacheKey, result.localPath) + if (imageLocalPath !== result.localPath) { + captureImageResizeBaseline() + } setImageLocalPath(result.localPath) setImageHasUpdate(false) if (result.liveVideoPath) setImageLiveVideoPath(result.liveVideoPath) @@ -6565,18 +7667,22 @@ function MessageBubble({ const mime = detectImageMimeFromBase64(fallback.data) const dataUrl = `data:${mime};base64,${fallback.data}` imageDataUrlCache.set(imageCacheKey, dataUrl) + if (imageLocalPath !== dataUrl) { + captureImageResizeBaseline() + } setImageLocalPath(dataUrl) setImageHasUpdate(false) - return { success: true, localPath: dataUrl } as any + return { success: true, localPath: dataUrl } } if (!silent) setImageError(true) } catch { if (!silent) setImageError(true) } finally { if (!silent) setImageLoading(false) + imageDecryptPendingRef.current = false } - return { success: false } as any - }, [isImage, imageLoading, message.imageMd5, message.imageDatName, message.localId, session.username, imageCacheKey, detectImageMimeFromBase64]) + return { success: false } + }, [isImage, message.imageMd5, message.imageDatName, message.localId, session.username, imageCacheKey, detectImageMimeFromBase64, imageLocalPath, captureImageResizeBaseline]) const triggerForceHd = useCallback(() => { if (!message.imageMd5 && !message.imageDatName) return @@ -6637,6 +7743,9 @@ function MessageBubble({ finalImagePath = resolved.localPath finalLiveVideoPath = resolved.liveVideoPath || finalLiveVideoPath imageDataUrlCache.set(imageCacheKey, resolved.localPath) + if (imageLocalPath !== resolved.localPath) { + captureImageResizeBaseline() + } setImageLocalPath(resolved.localPath) if (resolved.liveVideoPath) setImageLiveVideoPath(resolved.liveVideoPath) setImageHasUpdate(Boolean(resolved.hasUpdate)) @@ -6649,6 +7758,7 @@ function MessageBubble({ imageLiveVideoPath, imageLocalPath, imageCacheKey, + captureImageResizeBaseline, message.imageDatName, message.imageMd5, requestImageDecrypt, @@ -6678,6 +7788,7 @@ function MessageBubble({ if (result.success && result.localPath) { imageDataUrlCache.set(imageCacheKey, result.localPath) if (!imageLocalPath || imageLocalPath !== result.localPath) { + captureImageResizeBaseline() setImageLocalPath(result.localPath) setImageError(false) } @@ -6688,7 +7799,7 @@ function MessageBubble({ return () => { cancelled = true } - }, [isImage, imageLocalPath, imageLoading, message.imageMd5, message.imageDatName, imageCacheKey, session.username]) + }, [isImage, imageLocalPath, imageLoading, message.imageMd5, message.imageDatName, imageCacheKey, session.username, captureImageResizeBaseline]) useEffect(() => { if (!isImage) return @@ -6716,15 +7827,21 @@ function MessageBubble({ (payload.imageMd5 && payload.imageMd5 === message.imageMd5) || (payload.imageDatName && payload.imageDatName === message.imageDatName) if (matchesCacheKey) { - imageDataUrlCache.set(imageCacheKey, payload.localPath) - setImageLocalPath(payload.localPath) + const cachedPath = imageDataUrlCache.get(imageCacheKey) + if (cachedPath !== payload.localPath) { + imageDataUrlCache.set(imageCacheKey, payload.localPath) + } + if (imageLocalPath !== payload.localPath) { + captureImageResizeBaseline() + } + setImageLocalPath((prev) => (prev === payload.localPath ? prev : payload.localPath)) setImageError(false) } }) return () => { unsubscribe?.() } - }, [isImage, imageCacheKey, message.imageDatName, message.imageMd5]) + }, [isImage, imageCacheKey, imageLocalPath, message.imageDatName, message.imageMd5, captureImageResizeBaseline]) // 图片进入视野前自动解密(懒加载) useEffect(() => { @@ -6738,34 +7855,25 @@ function MessageBubble({ const observer = new IntersectionObserver( (entries) => { const entry = entries[0] - // rootMargin 设置为 200px,提前触发解密 - if (entry.isIntersecting && !imageAutoDecryptTriggered.current) { - imageAutoDecryptTriggered.current = true - void requestImageDecrypt() - } - }, - { rootMargin: '200px', threshold: 0 } - ) - - observer.observe(container) - return () => observer.disconnect() - }, [isImage, imageLocalPath, message.imageMd5, message.imageDatName, requestImageDecrypt]) - - // 进入视野时自动尝试切换高清图 - useEffect(() => { - if (!isImage) return - const container = imageContainerRef.current - if (!container) return - const observer = new IntersectionObserver( - (entries) => { - const entry = entries[0] + // rootMargin 设置为 200px,提前感知即将进入视野的图片 setImageInView(entry.isIntersecting) }, - { rootMargin: '120px', threshold: 0 } + { root: getImageObserverRoot(), rootMargin: '200px', threshold: 0 } ) + observer.observe(container) return () => observer.disconnect() - }, [isImage]) + }, [getImageObserverRoot, isImage]) + + // 进入视野后自动触发一次普通解密 + useEffect(() => { + if (!isImage || !imageInView) return + if (imageLocalPath || imageLoading) return + if (!message.imageMd5 && !message.imageDatName) return + if (imageAutoDecryptTriggered.current) return + imageAutoDecryptTriggered.current = true + void requestImageDecrypt() + }, [isImage, imageInView, imageLocalPath, imageLoading, message.imageMd5, message.imageDatName, requestImageDecrypt]) useEffect(() => { if (!isImage || !imageHasUpdate || !imageInView) return @@ -6774,19 +7882,6 @@ function MessageBubble({ triggerForceHd() }, [isImage, imageHasUpdate, imageInView, imageCacheKey, triggerForceHd]) - useEffect(() => { - if (!isImage || !imageHasUpdate) return - if (imageAutoHdTriggered.current === imageCacheKey) return - imageAutoHdTriggered.current = imageCacheKey - triggerForceHd() - }, [isImage, imageHasUpdate, imageCacheKey, triggerForceHd]) - - // 更激进:进入视野/打开预览时,无论 hasUpdate 与否都尝试强制高清 - useEffect(() => { - if (!isImage || !imageInView) return - triggerForceHd() - }, [isImage, imageInView, triggerForceHd]) - useEffect(() => { if (!isVoice) return @@ -6888,14 +7983,16 @@ function MessageBubble({ // 监听流式转写结果 useEffect(() => { if (!isVoice) return - const removeListener = window.electronAPI.chat.onVoiceTranscriptPartial?.((payload: { msgId: string; text: string }) => { - if (payload.msgId === String(message.localId)) { - setVoiceTranscript(payload.text) - voiceTranscriptCache.set(voiceTranscriptCacheKey, payload.text) - } + const removeListener = window.electronAPI.chat.onVoiceTranscriptPartial?.((payload: { sessionId?: string; msgId: string; createTime?: number; text: string }) => { + const sameSession = !payload.sessionId || payload.sessionId === session.username + const sameMsgId = payload.msgId === String(message.localId) + const sameCreateTime = payload.createTime == null || Number(payload.createTime) === Number(message.createTime || 0) + if (!sameSession || !sameMsgId || !sameCreateTime) return + setVoiceTranscript(payload.text) + voiceTranscriptCache.set(voiceTranscriptCacheKey, payload.text) }) return () => removeListener?.() - }, [isVoice, message.localId, voiceTranscriptCacheKey]) + }, [isVoice, message.createTime, message.localId, session.username, voiceTranscriptCacheKey]) const requestVoiceTranscript = useCallback(async () => { if (voiceTranscriptLoading || voiceTranscriptRequestedRef.current) return @@ -6949,14 +8046,17 @@ function MessageBubble({ } finally { setVoiceTranscriptLoading(false) } - }, [message.localId, session.username, voiceTranscriptCacheKey, voiceTranscriptLoading, onRequireModelDownload]) + }, [message.createTime, message.localId, session.username, voiceTranscriptCacheKey, voiceTranscriptLoading, onRequireModelDownload]) // 监听模型下载完成事件 useEffect(() => { if (!isVoice) return const handleModelDownloaded = (event: CustomEvent) => { - if (event.detail?.messageId === String(message.localId)) { + if ( + event.detail?.messageId === String(message.localId) && + (!event.detail?.sessionId || event.detail?.sessionId === session.username) + ) { // 重置状态,允许重新尝试转写 voiceTranscriptRequestedRef.current = false setVoiceTranscriptError(false) @@ -6969,7 +8069,7 @@ function MessageBubble({ return () => { window.removeEventListener('model-downloaded', handleModelDownloaded as EventListener) } - }, [isVoice, message.localId, requestVoiceTranscript]) + }, [isVoice, message.localId, requestVoiceTranscript, session.username]) // 视频懒加载 const videoAutoLoadTriggered = useRef(false) @@ -7037,30 +8137,125 @@ function MessageBubble({ void requestVideoInfo() }, [isVideo, isVideoVisible, videoInfo, requestVideoInfo]) - - // 根据设置决定是否自动转写 - const [autoTranscribeEnabled, setAutoTranscribeEnabled] = useState(false) - useEffect(() => { - window.electronAPI.config.get('autoTranscribeVoice').then((value: unknown) => { - setAutoTranscribeEnabled(value === true) - }) - }, []) - - useEffect(() => { - if (!autoTranscribeEnabled) return + if (!autoTranscribeVoiceEnabled) return if (!isVoice) return if (!voiceDataUrl) return - if (!autoTranscribeVoice) return // 如果自动转文字已关闭,不自动转文字 if (voiceTranscriptError) return if (voiceTranscriptLoading || voiceTranscript !== undefined || voiceTranscriptRequestedRef.current) return void requestVoiceTranscript() - }, [autoTranscribeEnabled, isVoice, voiceDataUrl, voiceTranscript, voiceTranscriptError, voiceTranscriptLoading, requestVoiceTranscript]) + }, [autoTranscribeVoiceEnabled, isVoice, voiceDataUrl, voiceTranscript, voiceTranscriptError, voiceTranscriptLoading, requestVoiceTranscript]) + + // 去除企业微信 ID 前缀 + const cleanMessageContent = useCallback((content: string) => { + if (!content) return '' + return content.replace(/^[a-zA-Z0-9]+@openim:\n?/, '') + }, []) + + // 解析混合文本和表情 + const renderTextWithEmoji = useCallback((text: string) => { + if (!text) return text + const parts = text.split(/\[(.*?)\]/g) + return parts.map((part, index) => { + // 奇数索引是捕获组的内容(即括号内的文字) + if (index % 2 === 1) { + // @ts-ignore + const path = getEmojiPath(part as any) + if (path) { + // path 例如 'assets/face/微笑.png',需要添加 base 前缀 + return ( + {`[${part}]`} + ) + } + return `[${part}]` + } + return part + }) + }, []) + + const cleanedParsedContent = useMemo( + () => cleanMessageContent(message.parsedContent || ''), + [cleanMessageContent, message.parsedContent] + ) + + const appMsgRawXml = message.rawContent || message.parsedContent || '' + const appMsgContainsTag = useMemo( + () => appMsgRawXml.includes(' { + if (!appMsgContainsTag) return null + try { + const start = appMsgRawXml.indexOf('') + const xml = start >= 0 ? appMsgRawXml.slice(start) : appMsgRawXml + const doc = new DOMParser().parseFromString(xml, 'text/xml') + if (doc.querySelector('parsererror')) return null + return doc + } catch { + return null + } + }, [appMsgContainsTag, appMsgRawXml]) + const appMsgTextCache = useMemo(() => new Map(), [appMsgDoc]) + const queryAppMsgText = useCallback((selector: string): string => { + const cached = appMsgTextCache.get(selector) + if (cached !== undefined) return cached + const value = appMsgDoc?.querySelector(selector)?.textContent?.trim() || '' + appMsgTextCache.set(selector, value) + return value + }, [appMsgDoc, appMsgTextCache]) + + const locationMessageMeta = useMemo(() => { + if (message.localType !== 48) return null + const raw = message.rawContent || '' + const poiname = raw.match(/poiname="([^"]*)"/)?.[1] || message.locationPoiname || '位置' + const label = raw.match(/label="([^"]*)"/)?.[1] || message.locationLabel || '' + const lat = parseFloat(raw.match(/x="([^"]*)"/)?.[1] || String(message.locationLat || 0)) + const lng = parseFloat(raw.match(/y="([^"]*)"/)?.[1] || String(message.locationLng || 0)) + const zoom = 15 + const tileX = Math.floor((lng + 180) / 360 * Math.pow(2, zoom)) + const latRad = lat * Math.PI / 180 + const tileY = Math.floor((1 - Math.log(Math.tan(latRad) + 1 / Math.cos(latRad)) / Math.PI) / 2 * Math.pow(2, zoom)) + const mapTileUrl = (lat && lng) + ? `https://webrd01.is.autonavi.com/appmaptile?lang=zh_cn&size=1&scale=1&style=8&x=${tileX}&y=${tileY}&z=${zoom}` + : '' + return { poiname, label, lat, lng, mapTileUrl } + }, [message.localType, message.rawContent, message.locationPoiname, message.locationLabel, message.locationLat, message.locationLng]) + + // 检测是否为链接卡片消息 + const isLinkMessage = String(message.localType) === '21474836529' || appMsgContainsTag + const bubbleClass = isSent ? 'sent' : 'received' + + // 头像逻辑: + // - 自己发的:优先使用 myAvatarUrl,缺失则用 senderAvatarUrl (补救) + // - 群聊中对方发的:使用发送者头像 + // - 私聊中对方发的:使用会话头像 + const fallbackSenderName = String(message.senderDisplayName || message.senderUsername || '').trim() || undefined + const resolvedSenderName = senderName || fallbackSenderName + const resolvedSenderAvatarUrl = senderAvatarUrl || message.senderAvatarUrl + const avatarUrl = isSent + ? (myAvatarUrl || resolvedSenderAvatarUrl) + : (isGroupChat ? resolvedSenderAvatarUrl : session.avatarUrl) + + // 是否有引用消息 + const hasQuote = message.quotedContent && message.quotedContent.length > 0 + + const handlePlayVideo = useCallback(async () => { + if (!videoInfo?.videoUrl) return + try { + await window.electronAPI.window.openVideoPlayerWindow(videoInfo.videoUrl) + } catch (e) { + console.error('打开视频播放窗口失败:', e) + } + }, [videoInfo?.videoUrl]) // Selection mode handling removed from here to allow normal rendering // We will wrap the output instead - - // Regular rendering logic... if (isSystem) { return (
0 - - // 去除企业微信 ID 前缀 - const cleanMessageContent = (content: string) => { - if (!content) return '' - return content.replace(/^[a-zA-Z0-9]+@openim:\n?/, '') - } - - // 解析混合文本和表情 - const renderTextWithEmoji = (text: string) => { - if (!text) return text - const parts = text.split(/\[(.*?)\]/g) - return parts.map((part, index) => { - // 奇数索引是捕获组的内容(即括号内的文字) - if (index % 2 === 1) { - // @ts-ignore - const path = getEmojiPath(part as any) - if (path) { - // path 例如 'assets/face/微笑.png',需要添加 base 前缀 - return ( - {`[${part}]`} - ) - } - return `[${part}]` - } - return part - }) - } - // 渲染消息内容 const renderContent = () => { if (isImage) { @@ -7177,8 +8318,14 @@ function MessageBubble({ alt="图片" className="image-message" onClick={() => { void handleOpenImageViewer() }} - onLoad={() => setImageError(false)} - onError={() => setImageError(true)} + onLoad={() => { + setImageError(false) + stabilizeImageScrollAfterResize() + }} + onError={() => { + imageResizeBaselineRef.current = null + setImageError(true) + }} /> {imageLiveVideoPath && (
@@ -7194,15 +8341,6 @@ function MessageBubble({ // 视频消息 if (isVideo) { - const handlePlayVideo = useCallback(async () => { - if (!videoInfo?.videoUrl) return - try { - await window.electronAPI.window.openVideoPlayerWindow(videoInfo.videoUrl) - } catch (e) { - console.error('打开视频播放窗口失败:', e) - } - }, [videoInfo?.videoUrl]) - // 未进入可视区域时显示占位符 if (!isVideoVisible) { return ( @@ -7291,7 +8429,7 @@ function MessageBubble({ session.username, String(message.localId), message.createTime, - message.serverId + message.serverIdRaw || message.serverId ) if (result.success && result.data) { const url = `data:audio/wav;base64,${result.data}` @@ -7479,18 +8617,8 @@ function MessageBubble({ // 位置消息 if (message.localType === 48) { - const raw = message.rawContent || '' - const poiname = raw.match(/poiname="([^"]*)"/)?.[1] || message.locationPoiname || '位置' - const label = raw.match(/label="([^"]*)"/)?.[1] || message.locationLabel || '' - const lat = parseFloat(raw.match(/x="([^"]*)"/)?.[1] || String(message.locationLat || 0)) - const lng = parseFloat(raw.match(/y="([^"]*)"/)?.[1] || String(message.locationLng || 0)) - const zoom = 15 - const tileX = Math.floor((lng + 180) / 360 * Math.pow(2, zoom)) - const latRad = lat * Math.PI / 180 - const tileY = Math.floor((1 - Math.log(Math.tan(latRad) + 1 / Math.cos(latRad)) / Math.PI) / 2 * Math.pow(2, zoom)) - const mapTileUrl = (lat && lng) - ? `https://webrd01.is.autonavi.com/appmaptile?lang=zh_cn&size=1&scale=1&style=8&x=${tileX}&y=${tileY}&z=${zoom}` - : '' + if (!locationMessageMeta) return null + const { poiname, label, lat, lng, mapTileUrl } = locationMessageMeta return (
window.electronAPI.shell.openExternal(`https://uri.amap.com/marker?position=${lng},${lat}&name=${encodeURIComponent(poiname || label)}`)}>
@@ -7516,28 +8644,15 @@ function MessageBubble({ // 链接消息 (AppMessage) const appMsgRichPreview = (() => { - const rawXml = message.rawContent || '' - if (!rawXml || (!rawXml.includes(' { - if (doc) return doc - try { - const start = rawXml.indexOf('') - const xml = start >= 0 ? rawXml.slice(start) : rawXml - doc = new DOMParser().parseFromString(xml, 'text/xml') - } catch { - doc = null - } - return doc - } - const q = (selector: string) => getDoc()?.querySelector(selector)?.textContent?.trim() || '' + const rawXml = appMsgRawXml + if (!appMsgContainsTag) return null + const q = queryAppMsgText const xmlType = message.xmlType || q('appmsg > type') || q('type') // type 57: 引用回复消息,解析 refermsg 渲染为引用样式 if (xmlType === '57') { - const replyText = q('title') || cleanMessageContent(message.parsedContent) || '' + const replyText = q('title') || cleanedParsedContent || '' const referContent = q('refermsg > content') || '' const referSender = q('refermsg > displayname') || '' const referType = q('refermsg > type') || '' @@ -7579,7 +8694,7 @@ function MessageBubble({ ) } - const title = message.linkTitle || q('title') || cleanMessageContent(message.parsedContent) || 'Card' + const title = message.linkTitle || q('title') || cleanedParsedContent || 'Card' const desc = message.appMsgDesc || q('des') const url = message.linkUrl || q('url') const thumbUrl = message.linkThumb || message.appMsgThumbUrl || q('thumburl') || q('cdnthumburl') || q('cover') || q('coverurl') @@ -7614,12 +8729,7 @@ function MessageBubble({ // 对视频号提取真实标题,避免出现 "当前版本不支持该内容" let displayTitle = title if (kind === 'finder' && (!displayTitle || displayTitle.includes('不支持'))) { - try { - const d = new DOMParser().parseFromString(rawXml, 'text/xml') - displayTitle = d.querySelector('finderFeed desc')?.textContent?.trim() || desc || '' - } catch { - displayTitle = desc || '' - } + displayTitle = q('finderFeed > desc') || q('finderFeed desc') || desc || '' } const openExternal = (e: React.MouseEvent, nextUrl?: string) => { @@ -7670,7 +8780,7 @@ function MessageBubble({ if (kind === 'quote') { // 引用回复消息(appMsgKind='quote',xmlType=57) - const replyText = message.linkTitle || q('title') || cleanMessageContent(message.parsedContent) || '' + const replyText = message.linkTitle || q('title') || cleanedParsedContent || '' const referContent = message.quotedContent || q('refermsg > content') || '' const referSender = message.quotedSender || q('refermsg > displayname') || '' return ( @@ -7686,14 +8796,7 @@ function MessageBubble({ if (kind === 'red-packet') { // 专属红包卡片 - const greeting = (() => { - try { - const d = getDoc() - if (!d) return '' - return d.querySelector('receivertitle')?.textContent?.trim() || - d.querySelector('sendertitle')?.textContent?.trim() || '' - } catch { return '' } - })() + const greeting = q('receivertitle') || q('sendertitle') || '' return (
@@ -7861,36 +8964,18 @@ function MessageBubble({ return appMsgRichPreview } - const isAppMsg = message.rawContent?.includes('')) - - const parser = new DOMParser() - parsedDoc = parser.parseFromString(xmlContent, 'text/xml') - - title = parsedDoc.querySelector('title')?.textContent || '链接' - desc = parsedDoc.querySelector('des')?.textContent || '' - url = parsedDoc.querySelector('url')?.textContent || '' - appMsgType = parsedDoc.querySelector('appmsg > type')?.textContent || parsedDoc.querySelector('type')?.textContent || '' - textAnnouncement = parsedDoc.querySelector('textannouncement')?.textContent || '' - } catch (e) { - console.error('解析 AppMsg 失败:', e) - } + if (appMsgContainsTag) { + const q = queryAppMsgText + const title = q('title') || '链接' + const desc = q('des') + const url = q('url') + const appMsgType = message.xmlType || q('appmsg > type') || q('type') + const textAnnouncement = q('textannouncement') + const parsedDoc: Document | null = appMsgDoc // 引用回复消息 (type=57),防止被误判为链接 if (appMsgType === '57') { - const replyText = parsedDoc?.querySelector('title')?.textContent?.trim() || cleanMessageContent(message.parsedContent) || '' + const replyText = parsedDoc?.querySelector('title')?.textContent?.trim() || cleanedParsedContent || '' const referContent = parsedDoc?.querySelector('refermsg > content')?.textContent?.trim() || '' const referSender = parsedDoc?.querySelector('refermsg > displayname')?.textContent?.trim() || '' const referType = parsedDoc?.querySelector('refermsg > type')?.textContent?.trim() || '' @@ -7953,11 +9038,12 @@ function MessageBubble({ ? `共 ${recordList.length} 条聊天记录` : desc || '聊天记录' - const previewItems = recordList.slice(0, 4) + const previewItems = buildChatRecordPreviewItems(recordList, 3) + const remainingCount = Math.max(0, recordList.length - previewItems.length) return (
{ e.stopPropagation() // 打开聊天记录窗口 @@ -7965,42 +9051,32 @@ function MessageBubble({ }} title="点击查看详细聊天记录" > -
-
- {displayTitle} -
+
+ {displayTitle}
-
-
- {previewItems.length > 0 ? ( - <> -
- {metaText} -
-
- {previewItems.map((item, i) => ( -
- - {item.sourcename ? `${item.sourcename}: ` : ''} - - {item.datadesc || item.datatitle || '[媒体消息]'} -
- ))} - {recordList.length > previewItems.length && ( -
还有 {recordList.length - previewItems.length} 条…
- )} -
- - ) : ( -
- {desc || '点击打开查看完整聊天记录'} +
+ {metaText} +
+ {previewItems.length > 0 ? ( +
+ {previewItems.map((item, i) => ( +
+ + {hasRenderableChatRecordName(item.sourcename) ? `${item.sourcename}: ` : ''} + + {getChatRecordPreviewText(item)}
+ ))} + {remainingCount > 0 && ( +
还有 {remainingCount} 条…
)}
-
- + ) : ( +
+ {desc || '点击打开查看完整聊天记录'}
-
+ )} +
聊天记录
) } @@ -8160,14 +9236,16 @@ function MessageBubble({ // 没有 cdnUrl 或加载失败,显示占位符 if ((!message.emojiCdnUrl && !message.emojiLocalPath) || emojiError) { return ( -
- - - - - - - 表情包未缓存 +
+
+ + + + + + + 表情包未缓存 +
) } @@ -8175,20 +9253,31 @@ function MessageBubble({ // 显示加载中 if (emojiLoading || !emojiLocalPath) { return ( -
- +
+
+ +
) } // 显示表情图片 return ( - 表情 setEmojiError(true)} - /> +
+ 表情 { + setEmojiError(false) + stabilizeEmojiScrollAfterResize() + }} + onError={() => { + emojiResizeBaselineRef.current = null + setEmojiError(true) + }} + /> +
) } @@ -8203,13 +9292,13 @@ function MessageBubble({ {message.quotedSender && {message.quotedSender}} {renderTextWithEmoji(cleanMessageContent(message.quotedContent || ''))}
-
{renderTextWithEmoji(cleanMessageContent(message.parsedContent))}
+
{renderTextWithEmoji(cleanedParsedContent)}
) } // 普通消息 - return
{renderTextWithEmoji(cleanMessageContent(message.parsedContent))}
+ return
{renderTextWithEmoji(cleanedParsedContent)}
} return ( @@ -8260,7 +9349,7 @@ function MessageBubble({
@@ -8269,7 +9358,7 @@ function MessageBubble({ {/* 群聊中显示发送者名称 */} {isGroupChat && !isSent && (
- {senderName || message.senderUsername || '群成员'} + {resolvedSenderName || '群成员'}
)} {renderContent()} @@ -8299,4 +9388,24 @@ function MessageBubble({ ) } +const MemoMessageBubble = React.memo(MessageBubble, (prevProps, nextProps) => { + if (prevProps.message !== nextProps.message) return false + if (prevProps.messageKey !== nextProps.messageKey) return false + if (prevProps.showTime !== nextProps.showTime) return false + if (prevProps.myAvatarUrl !== nextProps.myAvatarUrl) return false + if (prevProps.isGroupChat !== nextProps.isGroupChat) return false + if (prevProps.autoTranscribeVoiceEnabled !== nextProps.autoTranscribeVoiceEnabled) return false + if (prevProps.isSelectionMode !== nextProps.isSelectionMode) return false + if (prevProps.isSelected !== nextProps.isSelected) return false + if (prevProps.onRequireModelDownload !== nextProps.onRequireModelDownload) return false + if (prevProps.onContextMenu !== nextProps.onContextMenu) return false + if (prevProps.onToggleSelection !== nextProps.onToggleSelection) return false + + return ( + prevProps.session.username === nextProps.session.username && + prevProps.session.displayName === nextProps.session.displayName && + prevProps.session.avatarUrl === nextProps.session.avatarUrl + ) +}) + export default ChatPage diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index 0b228ea..2bb57bf 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -1,5 +1,5 @@ import { memo, useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type PointerEvent, type UIEvent, type WheelEvent } from 'react' -import { useLocation } from 'react-router-dom' +import { useLocation, useNavigate } from 'react-router-dom' import { Virtuoso, type VirtuosoHandle } from 'react-virtuoso' import { createPortal } from 'react-dom' import { @@ -44,6 +44,7 @@ import { subscribeBackgroundTasks } from '../services/backgroundTaskMonitor' import { useContactTypeCountsStore } from '../stores/contactTypeCountsStore' +import { useChatStore } from '../stores/chatStore' import { SnsPostItem } from '../components/Sns/SnsPostItem' import { ContactSnsTimelineDialog } from '../components/Sns/ContactSnsTimelineDialog' import { ExportDateRangeDialog } from '../components/Export/ExportDateRangeDialog' @@ -104,6 +105,16 @@ interface TaskProgress { phaseLabel: string phaseProgress: number phaseTotal: number + exportedMessages: number + estimatedTotalMessages: number + collectedMessages: number + writtenFiles: number + mediaDoneFiles: number + mediaCacheHitFiles: number + mediaCacheMissFiles: number + mediaCacheFillFiles: number + mediaDedupReuseFiles: number + mediaBytesWritten: number } type TaskPerfStage = 'collect' | 'build' | 'write' | 'other' @@ -166,7 +177,7 @@ interface ExportDialogState { const defaultTxtColumns = ['index', 'time', 'senderRole', 'messageType', 'content'] const DETAIL_PRECISE_REFRESH_COOLDOWN_MS = 10 * 60 * 1000 const SESSION_MEDIA_METRIC_PREFETCH_ROWS = 10 -const SESSION_MEDIA_METRIC_BATCH_SIZE = 12 +const SESSION_MEDIA_METRIC_BATCH_SIZE = 8 const SESSION_MEDIA_METRIC_BACKGROUND_FEED_SIZE = 48 const SESSION_MEDIA_METRIC_BACKGROUND_FEED_INTERVAL_MS = 120 const SESSION_MEDIA_METRIC_CACHE_FLUSH_DELAY_MS = 1200 @@ -254,7 +265,17 @@ const createEmptyProgress = (): TaskProgress => ({ phase: '', phaseLabel: '', phaseProgress: 0, - phaseTotal: 0 + phaseTotal: 0, + exportedMessages: 0, + estimatedTotalMessages: 0, + collectedMessages: 0, + writtenFiles: 0, + mediaDoneFiles: 0, + mediaCacheHitFiles: 0, + mediaCacheMissFiles: 0, + mediaCacheFillFiles: 0, + mediaDedupReuseFiles: 0, + mediaBytesWritten: 0 }) const createEmptyTaskPerformance = (): TaskPerformance => ({ @@ -1280,6 +1301,45 @@ const TaskCenterModal = memo(function TaskCenterModal({ completedSessionTotal, (task.settledSessionIds || []).length ) + const exportedMessages = Math.max(0, Math.floor(task.progress.exportedMessages || 0)) + const estimatedTotalMessages = Math.max(0, Math.floor(task.progress.estimatedTotalMessages || 0)) + const collectedMessages = Math.max(0, Math.floor(task.progress.collectedMessages || 0)) + const messageProgressLabel = estimatedTotalMessages > 0 + ? `已导出 ${Math.min(exportedMessages, estimatedTotalMessages)}/${estimatedTotalMessages} 条` + : `已导出 ${exportedMessages} 条` + const effectiveMessageProgressLabel = ( + exportedMessages > 0 || estimatedTotalMessages > 0 || collectedMessages <= 0 || task.progress.phase !== 'preparing' + ) + ? messageProgressLabel + : `已收集 ${collectedMessages.toLocaleString()} 条` + const phaseProgress = Math.max(0, Math.floor(task.progress.phaseProgress || 0)) + const phaseTotal = Math.max(0, Math.floor(task.progress.phaseTotal || 0)) + const mediaDoneFiles = Math.max(0, Math.floor(task.progress.mediaDoneFiles || 0)) + const mediaCacheHitFiles = Math.max(0, Math.floor(task.progress.mediaCacheHitFiles || 0)) + const mediaCacheMissFiles = Math.max(0, Math.floor(task.progress.mediaCacheMissFiles || 0)) + const mediaDedupReuseFiles = Math.max(0, Math.floor(task.progress.mediaDedupReuseFiles || 0)) + const mediaCacheTotal = mediaCacheHitFiles + mediaCacheMissFiles + const mediaCacheMetricLabel = mediaCacheTotal > 0 + ? `缓存命中 ${mediaCacheHitFiles}/${mediaCacheTotal}` + : '' + const mediaDedupMetricLabel = mediaDedupReuseFiles > 0 + ? `复用 ${mediaDedupReuseFiles}` + : '' + const phaseMetricLabel = phaseTotal > 0 + ? ( + task.progress.phase === 'exporting-media' + ? `媒体 ${Math.min(phaseProgress, phaseTotal)}/${phaseTotal}` + : task.progress.phase === 'exporting-voice' + ? `语音 ${Math.min(phaseProgress, phaseTotal)}/${phaseTotal}` + : '' + ) + : '' + const mediaLiveMetricLabel = task.progress.phase === 'exporting-media' + ? (mediaDoneFiles > 0 ? `已处理 ${mediaDoneFiles}` : '') + : '' + const sessionProgressLabel = completedSessionTotal > 0 + ? `会话 ${completedSessionCount}/${completedSessionTotal}` + : '会话处理中' const currentSessionRatio = task.progress.phaseTotal > 0 ? Math.max(0, Math.min(1, task.progress.phaseProgress / task.progress.phaseTotal)) : null @@ -1300,9 +1360,11 @@ const TaskCenterModal = memo(function TaskCenterModal({ />
- {completedSessionTotal > 0 - ? `已完成 ${completedSessionCount} / ${completedSessionTotal}` - : '处理中'} + {`${sessionProgressLabel} · ${effectiveMessageProgressLabel}`} + {phaseMetricLabel ? ` · ${phaseMetricLabel}` : ''} + {mediaLiveMetricLabel ? ` · ${mediaLiveMetricLabel}` : ''} + {mediaCacheMetricLabel ? ` · ${mediaCacheMetricLabel}` : ''} + {mediaDedupMetricLabel ? ` · ${mediaDedupMetricLabel}` : ''} {task.status === 'running' && currentSessionRatio !== null ? `(当前会话 ${Math.round(currentSessionRatio * 100)}%)` : ''} @@ -1387,6 +1449,8 @@ const TaskCenterModal = memo(function TaskCenterModal({ }) function ExportPage() { + const navigate = useNavigate() + const { setCurrentSession } = useChatStore() const location = useLocation() const isExportRoute = location.pathname === '/export' @@ -2787,6 +2851,7 @@ function ExportPage() { }, []) const enqueueSessionMutualFriendsRequests = useCallback((sessionIds: string[], options?: { front?: boolean }) => { + if (activeTaskCountRef.current > 0) return const front = options?.front === true const incoming: string[] = [] for (const sessionIdRaw of sessionIds) { @@ -2976,6 +3041,7 @@ function ExportPage() { }, []) const enqueueSessionMediaMetricRequests = useCallback((sessionIds: string[], options?: { front?: boolean }) => { + if (activeTaskCountRef.current > 0) return const front = options?.front === true const incoming: string[] = [] for (const sessionIdRaw of sessionIds) { @@ -3025,13 +3091,27 @@ function ExportPage() { const runSessionMediaMetricWorker = useCallback(async (runId: number) => { if (sessionMediaMetricWorkerRunningRef.current) return sessionMediaMetricWorkerRunningRef.current = true + const withTimeout = async (promise: Promise, timeoutMs: number, stage: string): Promise => { + let timer: number | null = null + try { + const timeoutPromise = new Promise((_, reject) => { + timer = window.setTimeout(() => { + reject(new Error(`会话多媒体统计超时(${stage}, ${timeoutMs}ms)`)) + }, timeoutMs) + }) + return await Promise.race([promise, timeoutPromise]) + } finally { + if (timer !== null) { + window.clearTimeout(timer) + } + } + } try { while (runId === sessionMediaMetricRunIdRef.current) { - if (isLoadingSessionCountsRef.current || detailStatsPriorityRef.current) { - await new Promise(resolve => window.setTimeout(resolve, 80)) + if (activeTaskCountRef.current > 0) { + await new Promise(resolve => window.setTimeout(resolve, 150)) continue } - if (sessionMediaMetricQueueRef.current.length === 0) break const batchSessionIds: string[] = [] @@ -3050,9 +3130,13 @@ function ExportPage() { patchSessionLoadTraceStage(batchSessionIds, 'mediaMetrics', 'loading') try { - const cacheResult = await window.electronAPI.chat.getExportSessionStats( - batchSessionIds, - { includeRelations: false, allowStaleCache: true, cacheOnly: true } + const cacheResult = await withTimeout( + window.electronAPI.chat.getExportSessionStats( + batchSessionIds, + { includeRelations: false, allowStaleCache: true, cacheOnly: true } + ), + 12000, + 'cacheOnly' ) if (runId !== sessionMediaMetricRunIdRef.current) return if (cacheResult.success && cacheResult.data) { @@ -3061,15 +3145,26 @@ function ExportPage() { const missingSessionIds = batchSessionIds.filter(sessionId => !isSessionMediaMetricReady(sessionId)) if (missingSessionIds.length > 0) { - const freshResult = await window.electronAPI.chat.getExportSessionStats( - missingSessionIds, - { includeRelations: false, allowStaleCache: true } + const freshResult = await withTimeout( + window.electronAPI.chat.getExportSessionStats( + missingSessionIds, + { includeRelations: false, allowStaleCache: true } + ), + 45000, + 'fresh' ) if (runId !== sessionMediaMetricRunIdRef.current) return if (freshResult.success && freshResult.data) { applySessionMediaMetricsFromStats(freshResult.data as Record) } } + + const unresolvedSessionIds = batchSessionIds.filter(sessionId => !isSessionMediaMetricReady(sessionId)) + if (unresolvedSessionIds.length > 0) { + patchSessionLoadTraceStage(unresolvedSessionIds, 'mediaMetrics', 'failed', { + error: '统计结果缺失,已跳过当前批次' + }) + } } catch (error) { console.error('导出页加载会话媒体统计失败:', error) patchSessionLoadTraceStage(batchSessionIds, 'mediaMetrics', 'failed', { @@ -3100,12 +3195,11 @@ function ExportPage() { }, [applySessionMediaMetricsFromStats, isSessionMediaMetricReady, patchSessionLoadTraceStage]) const scheduleSessionMediaMetricWorker = useCallback(() => { - if (!isSessionCountStageReady) return - if (isLoadingSessionCountsRef.current) return + if (activeTaskCountRef.current > 0) return if (sessionMediaMetricWorkerRunningRef.current) return const runId = sessionMediaMetricRunIdRef.current void runSessionMediaMetricWorker(runId) - }, [isSessionCountStageReady, runSessionMediaMetricWorker]) + }, [runSessionMediaMetricWorker]) const loadSessionMutualFriendsMetric = useCallback(async (sessionId: string): Promise => { const normalizedSessionId = String(sessionId || '').trim() @@ -3150,6 +3244,10 @@ function ExportPage() { sessionMutualFriendsWorkerRunningRef.current = true try { while (runId === sessionMutualFriendsRunIdRef.current) { + if (activeTaskCountRef.current > 0) { + await new Promise(resolve => window.setTimeout(resolve, 150)) + continue + } if (hasPendingMetricLoads()) { await new Promise(resolve => window.setTimeout(resolve, 120)) continue @@ -3196,6 +3294,7 @@ function ExportPage() { ]) const scheduleSessionMutualFriendsWorker = useCallback(() => { + if (activeTaskCountRef.current > 0) return if (!isSessionCountStageReady) return if (hasPendingMetricLoads()) return if (sessionMutualFriendsWorkerRunningRef.current) return @@ -3291,9 +3390,6 @@ function ExportPage() { setIsLoadingSessionCounts(true) try { - if (detailStatsPriorityRef.current) { - return { ...accumulatedCounts } - } if (prioritizedSessionIds.length > 0) { patchSessionLoadTraceStage(prioritizedSessionIds, 'messageCount', 'loading') const priorityResult = await window.electronAPI.chat.getSessionMessageCounts(prioritizedSessionIds) @@ -3311,9 +3407,6 @@ function ExportPage() { } } - if (detailStatsPriorityRef.current) { - return { ...accumulatedCounts } - } if (remainingSessionIds.length > 0) { patchSessionLoadTraceStage(remainingSessionIds, 'messageCount', 'loading') const remainingResult = await window.electronAPI.chat.getSessionMessageCounts(remainingSessionIds) @@ -4135,6 +4228,168 @@ function ExportPage() { progressUnsubscribeRef.current?.() const settledSessionIdsFromProgress = new Set() + const sessionMessageProgress = new Map() + let queuedProgressPayload: ExportProgress | null = null + let queuedProgressRaf: number | null = null + let queuedProgressTimer: number | null = null + + const clearQueuedProgress = () => { + if (queuedProgressRaf !== null) { + window.cancelAnimationFrame(queuedProgressRaf) + queuedProgressRaf = null + } + if (queuedProgressTimer !== null) { + window.clearTimeout(queuedProgressTimer) + queuedProgressTimer = null + } + } + + const updateSessionMessageProgress = (payload: ExportProgress) => { + const sessionId = String(payload.currentSessionId || '').trim() + if (!sessionId) return + const prev = sessionMessageProgress.get(sessionId) || { exported: 0, total: 0, knownTotal: false } + const nextExported = Number.isFinite(payload.exportedMessages) + ? Math.max(prev.exported, Math.max(0, Math.floor(Number(payload.exportedMessages || 0)))) + : prev.exported + const hasEstimatedTotal = Number.isFinite(payload.estimatedTotalMessages) + const nextTotal = hasEstimatedTotal + ? Math.max(prev.total, Math.max(0, Math.floor(Number(payload.estimatedTotalMessages || 0)))) + : prev.total + const knownTotal = prev.knownTotal || hasEstimatedTotal + sessionMessageProgress.set(sessionId, { + exported: nextExported, + total: nextTotal, + knownTotal + }) + } + + const resolveAggregatedMessageProgress = () => { + let exported = 0 + let estimated = 0 + let allKnown = true + for (const sessionId of next.payload.sessionIds) { + const entry = sessionMessageProgress.get(sessionId) + if (!entry) { + allKnown = false + continue + } + exported += entry.exported + estimated += entry.total + if (!entry.knownTotal) { + allKnown = false + } + } + return { + exported: Math.max(0, Math.floor(exported)), + estimated: allKnown ? Math.max(0, Math.floor(estimated)) : 0 + } + } + + const flushQueuedProgress = () => { + if (!queuedProgressPayload) return + const payload = queuedProgressPayload + queuedProgressPayload = null + const now = Date.now() + const currentSessionId = String(payload.currentSessionId || '').trim() + updateTask(next.id, task => { + if (task.status !== 'running') return task + const performance = applyProgressToTaskPerformance(task, payload, now) + const settledSessionIds = task.settledSessionIds || [] + const nextSettledSessionIds = ( + payload.phase === 'complete' && + currentSessionId && + !settledSessionIds.includes(currentSessionId) + ) + ? [...settledSessionIds, currentSessionId] + : settledSessionIds + const aggregatedMessageProgress = resolveAggregatedMessageProgress() + const collectedMessages = Number.isFinite(payload.collectedMessages) + ? Math.max(0, Math.floor(Number(payload.collectedMessages || 0))) + : task.progress.collectedMessages + const writtenFiles = Number.isFinite(payload.writtenFiles) + ? Math.max(task.progress.writtenFiles, Math.max(0, Math.floor(Number(payload.writtenFiles || 0)))) + : task.progress.writtenFiles + const prevMediaDoneFiles = Number.isFinite(task.progress.mediaDoneFiles) + ? Math.max(0, Math.floor(Number(task.progress.mediaDoneFiles || 0))) + : 0 + const prevMediaCacheHitFiles = Number.isFinite(task.progress.mediaCacheHitFiles) + ? Math.max(0, Math.floor(Number(task.progress.mediaCacheHitFiles || 0))) + : 0 + const prevMediaCacheMissFiles = Number.isFinite(task.progress.mediaCacheMissFiles) + ? Math.max(0, Math.floor(Number(task.progress.mediaCacheMissFiles || 0))) + : 0 + const prevMediaCacheFillFiles = Number.isFinite(task.progress.mediaCacheFillFiles) + ? Math.max(0, Math.floor(Number(task.progress.mediaCacheFillFiles || 0))) + : 0 + const prevMediaDedupReuseFiles = Number.isFinite(task.progress.mediaDedupReuseFiles) + ? Math.max(0, Math.floor(Number(task.progress.mediaDedupReuseFiles || 0))) + : 0 + const prevMediaBytesWritten = Number.isFinite(task.progress.mediaBytesWritten) + ? Math.max(0, Math.floor(Number(task.progress.mediaBytesWritten || 0))) + : 0 + const mediaDoneFiles = Number.isFinite(payload.mediaDoneFiles) + ? Math.max(prevMediaDoneFiles, Math.max(0, Math.floor(Number(payload.mediaDoneFiles || 0)))) + : prevMediaDoneFiles + const mediaCacheHitFiles = Number.isFinite(payload.mediaCacheHitFiles) + ? Math.max(prevMediaCacheHitFiles, Math.max(0, Math.floor(Number(payload.mediaCacheHitFiles || 0)))) + : prevMediaCacheHitFiles + const mediaCacheMissFiles = Number.isFinite(payload.mediaCacheMissFiles) + ? Math.max(prevMediaCacheMissFiles, Math.max(0, Math.floor(Number(payload.mediaCacheMissFiles || 0)))) + : prevMediaCacheMissFiles + const mediaCacheFillFiles = Number.isFinite(payload.mediaCacheFillFiles) + ? Math.max(prevMediaCacheFillFiles, Math.max(0, Math.floor(Number(payload.mediaCacheFillFiles || 0)))) + : prevMediaCacheFillFiles + const mediaDedupReuseFiles = Number.isFinite(payload.mediaDedupReuseFiles) + ? Math.max(prevMediaDedupReuseFiles, Math.max(0, Math.floor(Number(payload.mediaDedupReuseFiles || 0)))) + : prevMediaDedupReuseFiles + const mediaBytesWritten = Number.isFinite(payload.mediaBytesWritten) + ? Math.max(prevMediaBytesWritten, Math.max(0, Math.floor(Number(payload.mediaBytesWritten || 0)))) + : prevMediaBytesWritten + return { + ...task, + progress: { + current: payload.current, + total: payload.total, + currentName: payload.currentSession, + phase: payload.phase, + phaseLabel: payload.phaseLabel || '', + phaseProgress: payload.phaseProgress || 0, + phaseTotal: payload.phaseTotal || 0, + exportedMessages: Math.max(task.progress.exportedMessages, aggregatedMessageProgress.exported), + estimatedTotalMessages: aggregatedMessageProgress.estimated > 0 + ? Math.max(task.progress.estimatedTotalMessages, aggregatedMessageProgress.estimated) + : (task.progress.estimatedTotalMessages > 0 ? task.progress.estimatedTotalMessages : 0), + collectedMessages: Math.max(task.progress.collectedMessages, collectedMessages), + writtenFiles, + mediaDoneFiles, + mediaCacheHitFiles, + mediaCacheMissFiles, + mediaCacheFillFiles, + mediaDedupReuseFiles, + mediaBytesWritten + }, + settledSessionIds: nextSettledSessionIds, + performance + } + }) + } + + const queueProgressUpdate = (payload: ExportProgress) => { + queuedProgressPayload = payload + if (payload.phase === 'complete') { + clearQueuedProgress() + flushQueuedProgress() + return + } + if (queuedProgressRaf !== null || queuedProgressTimer !== null) return + queuedProgressRaf = window.requestAnimationFrame(() => { + queuedProgressRaf = null + queuedProgressTimer = window.setTimeout(() => { + queuedProgressTimer = null + flushQueuedProgress() + }, 100) + }) + } if (next.payload.scope === 'sns') { progressUnsubscribeRef.current = window.electronAPI.sns.onExportProgress((payload) => { updateTask(next.id, task => { @@ -4148,7 +4403,17 @@ function ExportPage() { phase: 'exporting', phaseLabel: payload.status || '', phaseProgress: payload.total > 0 ? payload.current : 0, - phaseTotal: payload.total || 0 + phaseTotal: payload.total || 0, + exportedMessages: payload.total > 0 ? Math.max(0, Math.floor(payload.current || 0)) : task.progress.exportedMessages, + estimatedTotalMessages: payload.total > 0 ? Math.max(0, Math.floor(payload.total || 0)) : task.progress.estimatedTotalMessages, + collectedMessages: task.progress.collectedMessages, + writtenFiles: task.progress.writtenFiles, + mediaDoneFiles: task.progress.mediaDoneFiles, + mediaCacheHitFiles: task.progress.mediaCacheHitFiles, + mediaCacheMissFiles: task.progress.mediaCacheMissFiles, + mediaCacheFillFiles: task.progress.mediaCacheFillFiles, + mediaDedupReuseFiles: task.progress.mediaDedupReuseFiles, + mediaBytesWritten: task.progress.mediaBytesWritten } } }) @@ -4157,6 +4422,7 @@ function ExportPage() { progressUnsubscribeRef.current = window.electronAPI.export.onProgress((payload: ExportProgress) => { const now = Date.now() const currentSessionId = String(payload.currentSessionId || '').trim() + updateSessionMessageProgress(payload) if (payload.phase === 'complete' && currentSessionId && !settledSessionIdsFromProgress.has(currentSessionId)) { settledSessionIdsFromProgress.add(currentSessionId) const phaseLabel = String(payload.phaseLabel || '') @@ -4172,33 +4438,7 @@ function ExportPage() { markSessionExportRecords([currentSessionId], taskExportContentLabel, next.payload.outputDir, now) } } - - updateTask(next.id, task => { - if (task.status !== 'running') return task - const performance = applyProgressToTaskPerformance(task, payload, now) - const settledSessionIds = task.settledSessionIds || [] - const nextSettledSessionIds = ( - payload.phase === 'complete' && - currentSessionId && - !settledSessionIds.includes(currentSessionId) - ) - ? [...settledSessionIds, currentSessionId] - : settledSessionIds - return { - ...task, - progress: { - current: payload.current, - total: payload.total, - currentName: payload.currentSession, - phase: payload.phase, - phaseLabel: payload.phaseLabel || '', - phaseProgress: payload.phaseProgress || 0, - phaseTotal: payload.phaseTotal || 0 - }, - settledSessionIds: nextSettledSessionIds, - performance - } - }) + queueProgressUpdate(payload) }) } @@ -4310,6 +4550,8 @@ function ExportPage() { performance: finalizeTaskPerformance(task, doneAt) })) } finally { + clearQueuedProgress() + flushQueuedProgress() progressUnsubscribeRef.current?.() progressUnsubscribeRef.current = null runningTaskIdRef.current = null @@ -4715,10 +4957,22 @@ function ExportPage() { return new Date(value).toLocaleTimeString('zh-CN', { hour12: false }) }, []) - const getLoadDetailStatusLabel = useCallback((loaded: number, total: number, hasStarted: boolean): string => { + const getLoadDetailStatusLabel = useCallback(( + loaded: number, + total: number, + hasStarted: boolean, + hasLoading: boolean, + failedCount: number + ): string => { if (total <= 0) return '待加载' - if (loaded >= total) return `已完成 ${total}` - if (hasStarted) return `加载中 ${loaded}/${total}` + const terminalCount = loaded + failedCount + if (terminalCount >= total) { + if (failedCount > 0) return `已完成 ${loaded}/${total}(失败 ${failedCount})` + return `已完成 ${total}` + } + if (hasLoading) return `加载中 ${loaded}/${total}` + if (hasStarted && failedCount > 0) return `已完成 ${loaded}/${total}(失败 ${failedCount})` + if (hasStarted) return `已完成 ${loaded}/${total}` return '待加载' }, []) @@ -4728,7 +4982,9 @@ function ExportPage() { ): SessionLoadStageSummary => { const total = sessionIds.length let loaded = 0 + let failedCount = 0 let hasStarted = false + let hasLoading = false let earliestStart: number | undefined let latestFinish: number | undefined let latestProgressAt: number | undefined @@ -4742,6 +4998,12 @@ function ExportPage() { : Math.max(latestProgressAt, stage.finishedAt) } } + if (stage?.status === 'failed') { + failedCount += 1 + } + if (stage?.status === 'loading') { + hasLoading = true + } if (stage?.status === 'loading' || stage?.status === 'failed' || typeof stage?.startedAt === 'number') { hasStarted = true } @@ -4759,9 +5021,9 @@ function ExportPage() { return { total, loaded, - statusLabel: getLoadDetailStatusLabel(loaded, total, hasStarted), + statusLabel: getLoadDetailStatusLabel(loaded, total, hasStarted, hasLoading, failedCount), startedAt: earliestStart, - finishedAt: loaded >= total ? latestFinish : undefined, + finishedAt: (loaded + failedCount) >= total ? latestFinish : undefined, latestProgressAt } }, [getLoadDetailStatusLabel, sessionLoadTraceMap]) @@ -4907,7 +5169,6 @@ function ExportPage() { const endIndex = Number.isFinite(range?.endIndex) ? Math.max(startIndex, Math.floor(range.endIndex)) : startIndex sessionMediaMetricVisibleRangeRef.current = { startIndex, endIndex } sessionMutualFriendsVisibleRangeRef.current = { startIndex, endIndex } - if (isLoadingSessionCountsRef.current || !isSessionCountStageReady) return const visibleTargets = collectVisibleSessionMetricTargets(filteredContacts) if (visibleTargets.length === 0) return enqueueSessionMediaMetricRequests(visibleTargets, { front: true }) @@ -4923,13 +5184,13 @@ function ExportPage() { enqueueSessionMediaMetricRequests, enqueueSessionMutualFriendsRequests, filteredContacts, - isSessionCountStageReady, scheduleSessionMediaMetricWorker, scheduleSessionMutualFriendsWorker ]) useEffect(() => { - if (!isSessionCountStageReady || filteredContacts.length === 0) return + if (activeTaskCount > 0) return + if (filteredContacts.length === 0) return const runId = sessionMediaMetricRunIdRef.current const visibleTargets = collectVisibleSessionMetricTargets(filteredContacts) if (visibleTargets.length > 0) { @@ -4946,7 +5207,6 @@ function ExportPage() { let cursor = 0 const feedNext = () => { if (runId !== sessionMediaMetricRunIdRef.current) return - if (isLoadingSessionCountsRef.current) return const batchIds: string[] = [] while (cursor < filteredContacts.length && batchIds.length < SESSION_MEDIA_METRIC_BACKGROUND_FEED_SIZE) { const contact = filteredContacts[cursor] @@ -4976,15 +5236,61 @@ function ExportPage() { } } }, [ + activeTaskCount, collectVisibleSessionMetricTargets, enqueueSessionMediaMetricRequests, filteredContacts, - isSessionCountStageReady, scheduleSessionMediaMetricWorker, sessionRowByUsername ]) useEffect(() => { + if (activeTaskCount > 0) return + const runId = sessionMediaMetricRunIdRef.current + const allTargets = [ + ...(loadDetailTargetsByTab.private || []), + ...(loadDetailTargetsByTab.group || []), + ...(loadDetailTargetsByTab.former_friend || []) + ] + if (allTargets.length === 0) return + + let timer: number | null = null + let cursor = 0 + const feedNext = () => { + if (runId !== sessionMediaMetricRunIdRef.current) return + const batchIds: string[] = [] + while (cursor < allTargets.length && batchIds.length < SESSION_MEDIA_METRIC_BACKGROUND_FEED_SIZE) { + const sessionId = allTargets[cursor] + cursor += 1 + if (!sessionId) continue + batchIds.push(sessionId) + } + if (batchIds.length > 0) { + enqueueSessionMediaMetricRequests(batchIds) + scheduleSessionMediaMetricWorker() + } + if (cursor < allTargets.length) { + timer = window.setTimeout(feedNext, SESSION_MEDIA_METRIC_BACKGROUND_FEED_INTERVAL_MS) + } + } + + feedNext() + return () => { + if (timer !== null) { + window.clearTimeout(timer) + } + } + }, [ + activeTaskCount, + enqueueSessionMediaMetricRequests, + loadDetailTargetsByTab.former_friend, + loadDetailTargetsByTab.group, + loadDetailTargetsByTab.private, + scheduleSessionMediaMetricWorker + ]) + + useEffect(() => { + if (activeTaskCount > 0) return if (!isSessionCountStageReady || filteredContacts.length === 0) return const runId = sessionMutualFriendsRunIdRef.current const visibleTargets = collectVisibleSessionMutualFriendsTargets(filteredContacts) @@ -5031,6 +5337,7 @@ function ExportPage() { } } }, [ + activeTaskCount, collectVisibleSessionMutualFriendsTargets, enqueueSessionMutualFriendsRequests, filteredContacts, @@ -5348,16 +5655,16 @@ function ExportPage() { const lastPreciseAt = sessionPreciseRefreshAtRef.current[preciseCacheKey] || 0 const hasRecentPrecise = Date.now() - lastPreciseAt <= DETAIL_PRECISE_REFRESH_COOLDOWN_MS - const shouldRunPreciseRefresh = !hasRecentPrecise && (!quickMetric || Boolean(quickCacheMeta?.stale)) + const shouldRunBackgroundRefresh = !hasRecentPrecise && (!quickMetric || Boolean(quickCacheMeta?.stale)) - if (shouldRunPreciseRefresh) { + if (shouldRunBackgroundRefresh) { setIsRefreshingSessionDetailStats(true) void (async () => { try { - // 后台精确补算三类重字段(转账/红包/通话),不阻塞首屏基础统计显示。 + // 后台补齐非关系统计,不走精确特型扫描,避免阻塞列表统计队列。 const freshResult = await window.electronAPI.chat.getExportSessionStats( [normalizedSessionId], - { includeRelations: false, forceRefresh: true, preferAccurateSpecialTypes: true } + { includeRelations: false, forceRefresh: true } ) if (requestSeq !== detailRequestSeqRef.current) return if (freshResult.success && freshResult.data) { @@ -6083,14 +6390,10 @@ function ExportPage() { +
+
+ +
+ + SSE 事件名为 `message.new`;私聊推送 `avatarUrl/sourceName/content`,群聊额外附带 `groupName` +
+
+
+ GET + {`http://127.0.0.1:${httpApiPort}/api/v1/push/messages`} +
+

通过 SSE 长连接接收消息事件,建议接收端按 `messageKey` 去重。

+
+ {['event', 'sessionId', 'messageKey', 'avatarUrl', 'sourceName', 'groupName?', 'content'].map((param) => ( + + {param} + + ))} +
+
+
+
+ {showApiWarning && (
setShowApiWarning(false)}>
e.stopPropagation()}> diff --git a/src/pages/WelcomePage.tsx b/src/pages/WelcomePage.tsx index 185b23b..ff1fd0d 100644 --- a/src/pages/WelcomePage.tsx +++ b/src/pages/WelcomePage.tsx @@ -11,9 +11,19 @@ import { import ConfirmDialog from '../components/ConfirmDialog' import './WelcomePage.scss' +const isMac = navigator.userAgent.toLowerCase().includes('mac') +const isLinux = navigator.userAgent.toLowerCase().includes('linux') + +const dbDirName = isMac ? '2.0b4.0.9 目录' : 'xwechat_files 目录' +const dbPathPlaceholder = isMac + ? '例如: ~/Library/Containers/com.tencent.xinWeChat/Data/Library/Application Support/com.tencent.xinWeChat/2.0b4.0.9' + : isLinux + ? '例如: ~/.local/share/WeChat/xwechat_files 或者 ~/Documents/xwechat_files' + : '例如: C:\\Users\\xxx\\Documents\\xwechat_files' + const steps = [ { id: 'intro', title: '欢迎', desc: '准备开始你的本地数据探索' }, - { id: 'db', title: '数据库目录', desc: '定位 xwechat_files 目录' }, + { id: 'db', title: '数据库目录', desc: `定位 ${dbDirName}` }, { id: 'cache', title: '缓存目录', desc: '设置本地缓存存储位置(可选)' }, { id: 'key', title: '解密密钥', desc: '获取密钥与自动识别账号' }, { id: 'image', title: '图片密钥', desc: '获取 XOR 与 AES 密钥' }, @@ -637,7 +647,7 @@ function WelcomePage({ standalone = false }: WelcomePageProps) { setDbPath(e.target.value)} /> @@ -888,13 +898,17 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
setShowDbKeyConfirm(false)} + open={showDbKeyConfirm} + title="开始获取数据库密钥" + message={`当开始获取后 WeFlow 将会执行准备操作。 +${isLinux ? ` +【⚠️ Linux 用户特别注意】 +如果您在微信里勾选了“自动登录”,请务必先关闭自动登录,然后再点击下方确认! +(因为授权弹窗输入密码需要时间,若自动登录太快会导致获取失败) +` : ''} +当 WeFlow 内的提示条变为绿色显示允许登录或看到来自 WeFlow 的登录通知时,请在手机上确认登录微信。`} + onConfirm={handleDbKeyConfirm} + onCancel={() => setShowDbKeyConfirm(false)} />
diff --git a/src/services/config.ts b/src/services/config.ts index 76d4b62..ee85acd 100644 --- a/src/services/config.ts +++ b/src/services/config.ts @@ -63,6 +63,7 @@ export const CONFIG_KEYS = { NOTIFICATION_POSITION: 'notificationPosition', NOTIFICATION_FILTER_MODE: 'notificationFilterMode', NOTIFICATION_FILTER_LIST: 'notificationFilterList', + MESSAGE_PUSH_ENABLED: 'messagePushEnabled', WINDOW_CLOSE_BEHAVIOR: 'windowCloseBehavior', // 词云 @@ -1362,6 +1363,15 @@ export async function setNotificationFilterList(list: string[]): Promise { await config.set(CONFIG_KEYS.NOTIFICATION_FILTER_LIST, list) } +export async function getMessagePushEnabled(): Promise { + const value = await config.get(CONFIG_KEYS.MESSAGE_PUSH_ENABLED) + return value === true +} + +export async function setMessagePushEnabled(enabled: boolean): Promise { + await config.set(CONFIG_KEYS.MESSAGE_PUSH_ENABLED, enabled) +} + export async function getWindowCloseBehavior(): Promise { const value = await config.get(CONFIG_KEYS.WINDOW_CLOSE_BEHAVIOR) if (value === 'tray' || value === 'quit') return value diff --git a/src/stores/batchTranscribeStore.ts b/src/stores/batchTranscribeStore.ts index a6e1f1f..55cf199 100644 --- a/src/stores/batchTranscribeStore.ts +++ b/src/stores/batchTranscribeStore.ts @@ -1,8 +1,12 @@ import { create } from 'zustand' +export type BatchVoiceTaskType = 'transcribe' | 'decrypt' + export interface BatchTranscribeState { /** 是否正在批量转写 */ isBatchTranscribing: boolean + /** 当前批量任务类型 */ + taskType: BatchVoiceTaskType /** 转写进度 */ progress: { current: number; total: number } /** 是否显示进度浮窗 */ @@ -16,7 +20,7 @@ export interface BatchTranscribeState { sessionName: string // Actions - startTranscribe: (total: number, sessionName: string) => void + startTranscribe: (total: number, sessionName: string, taskType?: BatchVoiceTaskType) => void updateProgress: (current: number, total: number) => void finishTranscribe: (success: number, fail: number) => void setShowToast: (show: boolean) => void @@ -26,6 +30,7 @@ export interface BatchTranscribeState { export const useBatchTranscribeStore = create((set) => ({ isBatchTranscribing: false, + taskType: 'transcribe', progress: { current: 0, total: 0 }, showToast: false, showResult: false, @@ -33,8 +38,9 @@ export const useBatchTranscribeStore = create((set) => ({ sessionName: '', startTime: 0, - startTranscribe: (total, sessionName) => set({ + startTranscribe: (total, sessionName, taskType = 'transcribe') => set({ isBatchTranscribing: true, + taskType, showToast: true, progress: { current: 0, total }, showResult: false, @@ -60,6 +66,7 @@ export const useBatchTranscribeStore = create((set) => ({ reset: () => set({ isBatchTranscribing: false, + taskType: 'transcribe', progress: { current: 0, total: 0 }, showToast: false, showResult: false, diff --git a/src/stores/chatStore.ts b/src/stores/chatStore.ts index 691ae57..b4c04f7 100644 --- a/src/stores/chatStore.ts +++ b/src/stores/chatStore.ts @@ -81,13 +81,48 @@ export const useChatStore = create((set, get) => ({ setMessages: (messages) => set({ messages }), appendMessages: (newMessages, prepend = false) => set((state) => { - const getMsgKey = (m: Message) => { - if (m.messageKey) return m.messageKey + const buildPrimaryKey = (m: Message): string => { + if (m.messageKey) return String(m.messageKey) return `fallback:${m.serverId || 0}:${m.createTime}:${m.sortSeq || 0}:${m.localId || 0}:${m.senderUsername || ''}:${m.localType || 0}` } + const buildAliasKeys = (m: Message): string[] => { + const keys = [buildPrimaryKey(m)] + const localId = Math.max(0, Number(m.localId || 0)) + const serverId = Math.max(0, Number(m.serverId || 0)) + const createTime = Math.max(0, Number(m.createTime || 0)) + const localType = Math.floor(Number(m.localType || 0)) + const sender = String(m.senderUsername || '') + const isSend = Number(m.isSend ?? -1) + + if (localId > 0) { + keys.push(`lid:${localId}`) + } + if (serverId > 0) { + keys.push(`sid:${serverId}`) + } + if (localType === 3) { + const imageIdentity = String(m.imageMd5 || m.imageDatName || '').trim() + if (imageIdentity) { + keys.push(`img:${createTime}:${sender}:${isSend}:${imageIdentity}`) + } + } + return keys + } + const currentMessages = state.messages || [] - const existingKeys = new Set(currentMessages.map(getMsgKey)) - const filtered = newMessages.filter(m => !existingKeys.has(getMsgKey(m))) + const existingAliases = new Set() + currentMessages.forEach((msg) => { + buildAliasKeys(msg).forEach((key) => existingAliases.add(key)) + }) + + const filtered: Message[] = [] + newMessages.forEach((msg) => { + const aliasKeys = buildAliasKeys(msg) + const exists = aliasKeys.some((key) => existingAliases.has(key)) + if (exists) return + filtered.push(msg) + aliasKeys.forEach((key) => existingAliases.add(key)) + }) if (filtered.length === 0) return state diff --git a/src/styles/main.scss b/src/styles/main.scss index 3006e86..0de9c35 100644 --- a/src/styles/main.scss +++ b/src/styles/main.scss @@ -530,4 +530,4 @@ body { opacity: 0.5; cursor: not-allowed; } -} \ No newline at end of file +} diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts index a035f50..3824a16 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -1,4 +1,4 @@ -import type { ChatSession, Message, Contact, ContactInfo } from './models' +import type { ChatSession, Message, Contact, ContactInfo, ChatRecordItem } from './models' export interface SessionChatWindowOpenOptions { source?: 'chat' | 'export' @@ -24,6 +24,8 @@ export interface ElectronAPI { resizeToFitVideo: (videoWidth: number, videoHeight: number) => Promise openImageViewerWindow: (imagePath: string, liveVideoPath?: string) => Promise openChatHistoryWindow: (sessionId: string, messageId: number) => Promise + openChatHistoryPayloadWindow: (payload: { sessionId: string; title?: string; recordList: ChatRecordItem[] }) => Promise + getChatHistoryPayload: (payloadId: string) => Promise<{ success: boolean; payload?: { sessionId: string; title?: string; recordList: ChatRecordItem[] }; error?: string }> openSessionChatWindow: (sessionId: string, options?: SessionChatWindowOpenOptions) => Promise } config: { @@ -319,8 +321,7 @@ export interface ElectronAPI { getMessageDateCounts: (sessionId: string) => Promise<{ success: boolean; counts?: Record; error?: string }> resolveVoiceCache: (sessionId: string, msgId: string) => Promise<{ success: boolean; hasCache: boolean; data?: string }> getVoiceTranscript: (sessionId: string, msgId: string, createTime?: number) => Promise<{ success: boolean; transcript?: string; error?: string }> - onVoiceTranscriptPartial: (callback: (payload: { msgId: string; text: string }) => void) => () => void - execQuery: (kind: string, path: string | null, sql: string) => Promise<{ success: boolean; rows?: any[]; error?: string }> + onVoiceTranscriptPartial: (callback: (payload: { sessionId?: string; msgId: string; createTime?: number; text: string }) => void) => () => void getMessage: (sessionId: string, localId: number) => Promise<{ success: boolean; message?: Message; error?: string }> onWcdbChange: (callback: (event: any, data: { type: string; json: string }) => void) => () => void } @@ -862,6 +863,16 @@ export interface ExportProgress { phaseProgress?: number phaseTotal?: number phaseLabel?: string + collectedMessages?: number + exportedMessages?: number + estimatedTotalMessages?: number + writtenFiles?: number + mediaDoneFiles?: number + mediaCacheHitFiles?: number + mediaCacheMissFiles?: number + mediaCacheFillFiles?: number + mediaDedupReuseFiles?: number + mediaBytesWritten?: number } export interface WxidInfo { diff --git a/src/types/models.ts b/src/types/models.ts index 92d6506..74e81dd 100644 --- a/src/types/models.ts +++ b/src/types/models.ts @@ -46,6 +46,7 @@ export interface Message { messageKey: string localId: number serverId: number + serverIdRaw?: string localType: number createTime: number sortSeq: number @@ -128,11 +129,19 @@ export interface ChatRecordItem { dataurl?: string // 数据URL datathumburl?: string // 缩略图URL datacdnurl?: string // CDN URL + cdndatakey?: string // CDN 数据 key + cdnthumbkey?: string // CDN 缩略图 key aeskey?: string // AES密钥 md5?: string // MD5 + fullmd5?: string // 原图 MD5 + thumbfullmd5?: string // 缩略图 MD5 + srcMsgLocalid?: number // 源消息 LocalId imgheight?: number // 图片高度 imgwidth?: number // 图片宽度 duration?: number // 时长(毫秒) + chatRecordTitle?: string // 嵌套聊天记录标题 + chatRecordDesc?: string // 嵌套聊天记录描述 + chatRecordList?: ChatRecordItem[] // 嵌套聊天记录列表 } diff --git a/vite.config.ts b/vite.config.ts index e17ffbf..442ce9e 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -34,7 +34,8 @@ export default defineConfig({ 'whisper-node', 'shelljs', 'exceljs', - 'node-llama-cpp' + 'node-llama-cpp', + 'sudo-prompt' ] } } @@ -126,6 +127,26 @@ export default defineConfig({ } } }, + { + entry: 'electron/exportWorker.ts', + vite: { + build: { + outDir: 'dist-electron', + rollupOptions: { + external: [ + 'better-sqlite3', + 'koffi', + 'fsevents', + 'exceljs' + ], + output: { + entryFileNames: 'exportWorker.js', + inlineDynamicImports: true + } + } + } + } + }, { entry: 'electron/preload.ts', onstart(options) {