Compare commits

...

25 Commits

Author SHA1 Message Date
xuncha
503a77c7cf fix:尝试修复闪退的问题 2026-01-22 15:28:00 +08:00
xuncha
4452e4921c fix:修复了导出时媒体导出出错的问题 2026-01-21 20:56:36 +08:00
xuncha
97c1aa582d feat(export): 多会话导出布局选择与无媒体直出
- 多会话媒体导出支持共享/分会话目录
- 无媒体导出时直接输出到目标目录
2026-01-21 19:37:05 +08:00
xuncha
076c008329 fix:优化了设置中下拉菜单的视觉表现 2026-01-21 19:23:11 +08:00
xuncha
21d785dd3c fix:修复了导出时因为头像在后台加载导致的不导出的问题 2026-01-21 19:08:22 +08:00
xuncha
348f6c81bf fix:尝试修复了新手引导闪退的问题 2026-01-21 19:02:14 +08:00
xuncha
d5a2e2bb62 word: 优化了新手引导的提示词 2026-01-21 18:59:54 +08:00
xuncha
2b51e0659e Merge pull request #65 from yunxilyf/main
fix:修复初始化的时候获取微信启动路径出现错误问题
2026-01-21 18:34:25 +08:00
yunxilyf
3efca5e60c fix:修复初始化的时候获取微信启动路径出现错误问题 2026-01-21 09:12:10 +08:00
cc
2f7b917f1c Merge branch 'main' of https://github.com/hicccc77/WeFlow 2026-01-20 22:40:26 +08:00
cc
8623f86505 fix: 修复了一些已知问题 2026-01-20 22:40:21 +08:00
Forrest
dc74641c19 Merge pull request #62 from hicccc77/dev
修复了一些已知问题。
2026-01-20 18:52:42 +08:00
Forrest
db7817cc22 refactor(WcdbCore, SettingsPage): 优化数据库测试的连接处理:恢复测试后活跃连接,拆分设置页配置保存与连接测试逻辑,避免连接干扰。 2026-01-20 18:25:05 +08:00
xuncha
ada0f68182 fix: 修复了一些情况下无法触发语音转文字的功能
Fix exporting voice-to-text for JSON and Excel exports
2026-01-20 02:13:40 +08:00
QingXiao
fe806895f0 Merge pull request #2 from 5xiao0qing5/codex/locate-cause-of-export-speech-to-text-bug-hv6kr5
Add export defaults and compact Excel column mode
2026-01-19 12:39:48 +08:00
QingXiao
da137d0a8f Add export defaults and compact Excel columns 2026-01-19 12:39:06 +08:00
Yeqing Zhang
93ebc3bce3 Merge pull request #1 from 5xiao0qing5/codex/locate-cause-of-export-speech-to-text-bug
Fix exporting voice-to-text for JSON and Excel exports
2026-01-19 12:01:25 +08:00
Yeqing Zhang
9f6e9eb9bc Fix voice transcript export for json and excel 2026-01-19 11:33:28 +08:00
xuncha
996b133a4f Merge branch 'dev' 2026-01-19 01:08:01 +08:00
Forrest
dd2602ea35 fix(ChatService, VideoService): 优化系统消息清理逻辑并移除冗余日志
- 改进 cleanSystemMessage 方法,增强 XML 声明移除和尾部时间戳清理
- 优化正则表达式以更准确地处理 XML/HTML 标签
- 从 VideoService 中移除大量冗余的 console.log 调试日志
- 简化错误处理,使用注释替代冗余日志输出
- 提升代码可读性和性能,减少不必要的日志输出开销
2026-01-18 23:41:55 +08:00
Forrest
e5cf71b7c5 Merge: 解决冲突 - 保留链接消息和视频消息样式,合并 rawContent 和 content 字段 2026-01-18 23:25:28 +08:00
Forrest
f2e4e21010 feat(ChatPage): 新增链接卡片消息渲染(支持解析 XML 并展示标题 / 描述 / 图标),采用 flexbox 优化消息气泡布局,添加文本截断、响应式样式及悬浮效果。 2026-01-18 23:20:26 +08:00
xuncha
240514f1e5 feat: 新增了聊天页面播放视频的功能 2026-01-18 23:19:58 +08:00
Forrest
d4c7e86e05 fix(UI): 修复聊天窗口的气泡宽度问题。 2026-01-18 21:28:55 +08:00
Forrest
2876c7a539 feat(voice-transcribe): 优化语音转写流程并增强数据库缓存机制
- 添加 createTime 参数到语音转写接口,支持更精确的消息定位
- 实现 media.db 列表缓存机制(5分钟TTL),减少重复查询开销
- 添加 media.db 表结构信息缓存,提升数据库操作效率
- 优化语音缓存目录获取逻辑,支持自定义缓存路径配置
- 重构语音数据获取实现,绕过WCDB的buggy getVoiceData方法
- 移除冗余的调试日志,提升代码整洁度
- 删除不再使用的 silk_v3_decoder.exe 文件
- 优化数据库连接流程,后台预热缓存提升响应速度
2026-01-18 17:12:45 +08:00
26 changed files with 2845 additions and 375 deletions

View File

@@ -39,56 +39,10 @@ jobs:
npx tsc npx tsc
npx vite build npx vite build
- name: Create Changelog Config
shell: bash
run: |
cat <<EOF > changelog_config.json
{
"template": "# ${{ github.ref_name }} 更新日志\n\n{{CHANGELOG}}\n\n---\n> 此更新由系统自动构建",
"categories": [
{
"title": "## 新功能",
"filter": { "pattern": "^feat", "flags": "i" }
},
{
"title": "## 修复",
"filter": { "pattern": "^fix", "flags": "i" }
},
{
"title": "## 性能与维护",
"filter": { "pattern": "^(chore|docs|perf|refactor|ci|style|test)", "flags": "i" }
},
{
"title": "## 其他改动",
"filter": { "pattern": ".*", "flags": "i" }
}
],
"ignore_labels": [],
"commitMode": true,
"empty_summary": "## 更新详情\n- 常规代码优化与维护"
}
EOF
- name: Build Changelog
id: build_changelog
uses: mikepenz/release-changelog-builder-action@v5
with:
configuration: "changelog_config.json"
outputFile: "release-notes.md"
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Check Changelog Content
shell: bash
run: |
echo "=== RELEASE NOTES START ==="
cat release-notes.md
echo "=== RELEASE NOTES END ==="
- name: Inject Configuration - name: Inject Configuration
shell: bash shell: bash
run: | run: |
npm pkg set build.releaseInfo.releaseNotesFile=release-notes.md npm pkg set build.releaseInfo.releaseNotes="修复了一些已知问题"
- name: Package and Publish - name: Package and Publish
env: env:

View File

@@ -1,6 +1,6 @@
import { app, BrowserWindow, ipcMain, nativeTheme } from 'electron' import { app, BrowserWindow, ipcMain, nativeTheme } from 'electron'
import { Worker } from 'worker_threads' import { Worker } from 'worker_threads'
import { join } from 'path' import { join, dirname } from 'path'
import { autoUpdater } from 'electron-updater' import { autoUpdater } from 'electron-updater'
import { readFile, writeFile, mkdir } from 'fs/promises' import { readFile, writeFile, mkdir } from 'fs/promises'
import { existsSync } from 'fs' import { existsSync } from 'fs'
@@ -13,9 +13,10 @@ import { imagePreloadService } from './services/imagePreloadService'
import { analyticsService } from './services/analyticsService' import { analyticsService } from './services/analyticsService'
import { groupAnalyticsService } from './services/groupAnalyticsService' import { groupAnalyticsService } from './services/groupAnalyticsService'
import { annualReportService } from './services/annualReportService' import { annualReportService } from './services/annualReportService'
import { exportService, ExportOptions } from './services/exportService' import { exportService, ExportOptions, ExportProgress } from './services/exportService'
import { KeyService } from './services/keyService' import { KeyService } from './services/keyService'
import { voiceTranscribeService } from './services/voiceTranscribeService' import { voiceTranscribeService } from './services/voiceTranscribeService'
import { videoService } from './services/videoService'
// 配置自动更新 // 配置自动更新
@@ -27,6 +28,47 @@ const AUTO_UPDATE_ENABLED =
process.env.AUTO_UPDATE_ENABLED === '1' || process.env.AUTO_UPDATE_ENABLED === '1' ||
(process.env.AUTO_UPDATE_ENABLED == null && !process.env.VITE_DEV_SERVER_URL) (process.env.AUTO_UPDATE_ENABLED == null && !process.env.VITE_DEV_SERVER_URL)
// 使用白名单过滤 PATH避免被第三方目录中的旧版 VC++ 运行库劫持。
// 仅保留系统目录Windows/System32/SysWOW64和应用自身目录可执行目录、resources
function sanitizePathEnv() {
// 开发模式不做裁剪,避免影响本地工具链
if (process.env.VITE_DEV_SERVER_URL) return
const rawPath = process.env.PATH || process.env.Path
if (!rawPath) return
const sep = process.platform === 'win32' ? ';' : ':'
const parts = rawPath.split(sep).filter(Boolean)
const systemRoot = process.env.SystemRoot || process.env.WINDIR || ''
const safePrefixes = [
systemRoot,
systemRoot ? join(systemRoot, 'System32') : '',
systemRoot ? join(systemRoot, 'SysWOW64') : '',
dirname(process.execPath),
process.resourcesPath,
join(process.resourcesPath || '', 'resources')
].filter(Boolean)
const normalize = (p: string) => p.replace(/\\/g, '/').toLowerCase()
const isSafe = (p: string) => {
const np = normalize(p)
return safePrefixes.some((prefix) => np.startsWith(normalize(prefix)))
}
const filtered = parts.filter(isSafe)
if (filtered.length !== parts.length) {
const removed = parts.filter((p) => !isSafe(p))
console.warn('[WeFlow] 使用白名单裁剪 PATH移除目录:', removed)
const nextPath = filtered.join(sep)
process.env.PATH = nextPath
process.env.Path = nextPath
}
}
// 启动时立即清理 PATH后续创建的 worker 也能继承安全的环境
sanitizePathEnv()
// 单例服务 // 单例服务
let configService: ConfigService | null = null let configService: ConfigService | null = null
@@ -200,6 +242,107 @@ function createOnboardingWindow() {
return onboardingWindow return onboardingWindow
} }
/**
* 创建独立的视频播放窗口
* 窗口大小会根据视频比例自动调整
*/
function createVideoPlayerWindow(videoPath: string, videoWidth?: number, videoHeight?: number) {
const isDev = !!process.env.VITE_DEV_SERVER_URL
const iconPath = isDev
? join(__dirname, '../public/icon.ico')
: join(process.resourcesPath, 'icon.ico')
// 获取屏幕尺寸
const { screen } = require('electron')
const primaryDisplay = screen.getPrimaryDisplay()
const { width: screenWidth, height: screenHeight } = primaryDisplay.workAreaSize
// 计算窗口尺寸,只有标题栏 40px控制栏悬浮
let winWidth = 854
let winHeight = 520
const titleBarHeight = 40
if (videoWidth && videoHeight && videoWidth > 0 && videoHeight > 0) {
const aspectRatio = videoWidth / videoHeight
const maxWidth = Math.floor(screenWidth * 0.85)
const maxHeight = Math.floor(screenHeight * 0.85)
if (aspectRatio >= 1) {
// 横向视频
winWidth = Math.min(videoWidth, maxWidth)
winHeight = Math.floor(winWidth / aspectRatio) + titleBarHeight
if (winHeight > maxHeight) {
winHeight = maxHeight
winWidth = Math.floor((winHeight - titleBarHeight) * aspectRatio)
}
} else {
// 竖向视频
const videoDisplayHeight = Math.min(videoHeight, maxHeight - titleBarHeight)
winHeight = videoDisplayHeight + titleBarHeight
winWidth = Math.floor(videoDisplayHeight * aspectRatio)
if (winWidth < 300) {
winWidth = 300
winHeight = Math.floor(winWidth / aspectRatio) + titleBarHeight
}
}
winWidth = Math.max(winWidth, 360)
winHeight = Math.max(winHeight, 280)
}
const win = new BrowserWindow({
width: winWidth,
height: winHeight,
minWidth: 360,
minHeight: 280,
icon: iconPath,
webPreferences: {
preload: join(__dirname, 'preload.js'),
contextIsolation: true,
nodeIntegration: false,
webSecurity: false
},
titleBarStyle: 'hidden',
titleBarOverlay: {
color: '#1a1a1a',
symbolColor: '#ffffff',
height: 40
},
show: false,
backgroundColor: '#000000',
autoHideMenuBar: true
})
win.once('ready-to-show', () => {
win.show()
})
const videoParam = `videoPath=${encodeURIComponent(videoPath)}`
if (process.env.VITE_DEV_SERVER_URL) {
win.loadURL(`${process.env.VITE_DEV_SERVER_URL}#/video-player-window?${videoParam}`)
win.webContents.on('before-input-event', (event, input) => {
if (input.key === 'F12' || (input.control && input.shift && input.key === 'I')) {
if (win.webContents.isDevToolsOpened()) {
win.webContents.closeDevTools()
} else {
win.webContents.openDevTools()
}
event.preventDefault()
}
})
} else {
win.loadFile(join(__dirname, '../dist/index.html'), {
hash: `/video-player-window?${videoParam}`
})
}
return win
}
function showMainWindow() { function showMainWindow() {
shouldShowMain = true shouldShowMain = true
if (mainWindowReady) { if (mainWindowReady) {
@@ -356,6 +499,79 @@ function registerIpcHandlers() {
} }
}) })
// 打开视频播放窗口
ipcMain.handle('window:openVideoPlayerWindow', (_, videoPath: string, videoWidth?: number, videoHeight?: number) => {
createVideoPlayerWindow(videoPath, videoWidth, videoHeight)
})
// 根据视频尺寸调整窗口大小
ipcMain.handle('window:resizeToFitVideo', (event, videoWidth: number, videoHeight: number) => {
const win = BrowserWindow.fromWebContents(event.sender)
if (!win || !videoWidth || !videoHeight) return
const { screen } = require('electron')
const primaryDisplay = screen.getPrimaryDisplay()
const { width: screenWidth, height: screenHeight } = primaryDisplay.workAreaSize
// 只有标题栏 40px控制栏悬浮在视频上
const titleBarHeight = 40
const aspectRatio = videoWidth / videoHeight
const maxWidth = Math.floor(screenWidth * 0.85)
const maxHeight = Math.floor(screenHeight * 0.85)
let winWidth: number
let winHeight: number
if (aspectRatio >= 1) {
// 横向视频 - 以宽度为基准
winWidth = Math.min(videoWidth, maxWidth)
winHeight = Math.floor(winWidth / aspectRatio) + titleBarHeight
if (winHeight > maxHeight) {
winHeight = maxHeight
winWidth = Math.floor((winHeight - titleBarHeight) * aspectRatio)
}
} else {
// 竖向视频 - 以高度为基准
const videoDisplayHeight = Math.min(videoHeight, maxHeight - titleBarHeight)
winHeight = videoDisplayHeight + titleBarHeight
winWidth = Math.floor(videoDisplayHeight * aspectRatio)
// 确保宽度不会太窄
if (winWidth < 300) {
winWidth = 300
winHeight = Math.floor(winWidth / aspectRatio) + titleBarHeight
}
}
winWidth = Math.max(winWidth, 360)
winHeight = Math.max(winHeight, 280)
// 调整窗口大小并居中
win.setSize(winWidth, winHeight)
win.center()
})
// 视频相关
ipcMain.handle('video:getVideoInfo', async (_, videoMd5: string) => {
try {
const result = await videoService.getVideoInfo(videoMd5)
return { success: true, ...result }
} catch (e) {
return { success: false, error: String(e), exists: false }
}
})
ipcMain.handle('video:parseVideoMd5', async (_, content: string) => {
try {
const md5 = videoService.parseVideoMd5(content)
return { success: true, md5 }
} catch (e) {
return { success: false, error: String(e) }
}
})
// 数据库路径相关 // 数据库路径相关
ipcMain.handle('dbpath:autoDetect', async () => { ipcMain.handle('dbpath:autoDetect', async () => {
return dbPathService.autoDetect() return dbPathService.autoDetect()
@@ -446,8 +662,8 @@ function registerIpcHandlers() {
return chatService.resolveVoiceCache(sessionId, msgId) return chatService.resolveVoiceCache(sessionId, msgId)
}) })
ipcMain.handle('chat:getVoiceTranscript', async (event, sessionId: string, msgId: string) => { ipcMain.handle('chat:getVoiceTranscript', async (event, sessionId: string, msgId: string, createTime?: number) => {
return chatService.getVoiceTranscript(sessionId, msgId, (text) => { return chatService.getVoiceTranscript(sessionId, msgId, createTime, (text) => {
event.sender.send('chat:voiceTranscriptPartial', { msgId, text }) event.sender.send('chat:voiceTranscriptPartial', { msgId, text })
}) })
}) })
@@ -471,8 +687,13 @@ function registerIpcHandlers() {
}) })
// 导出相关 // 导出相关
ipcMain.handle('export:exportSessions', async (_, sessionIds: string[], outputDir: string, options: ExportOptions) => { ipcMain.handle('export:exportSessions', async (event, sessionIds: string[], outputDir: string, options: ExportOptions) => {
return exportService.exportSessions(sessionIds, outputDir, options) const onProgress = (progress: ExportProgress) => {
if (!event.sender.isDestroyed()) {
event.sender.send('export:progress', progress)
}
}
return exportService.exportSessions(sessionIds, outputDir, options, onProgress)
}) })
ipcMain.handle('export:exportSession', async (_, sessionId: string, outputPath: string, options: ExportOptions) => { ipcMain.handle('export:exportSession', async (_, sessionId: string, outputPath: string, options: ExportOptions) => {

View File

@@ -53,7 +53,11 @@ contextBridge.exposeInMainWorld('electronAPI', {
openAgreementWindow: () => ipcRenderer.invoke('window:openAgreementWindow'), openAgreementWindow: () => ipcRenderer.invoke('window:openAgreementWindow'),
completeOnboarding: () => ipcRenderer.invoke('window:completeOnboarding'), completeOnboarding: () => ipcRenderer.invoke('window:completeOnboarding'),
openOnboardingWindow: () => ipcRenderer.invoke('window:openOnboardingWindow'), openOnboardingWindow: () => ipcRenderer.invoke('window:openOnboardingWindow'),
setTitleBarOverlay: (options: { symbolColor: string }) => ipcRenderer.send('window:setTitleBarOverlay', options) setTitleBarOverlay: (options: { symbolColor: string }) => ipcRenderer.send('window:setTitleBarOverlay', options),
openVideoPlayerWindow: (videoPath: string, videoWidth?: number, videoHeight?: number) =>
ipcRenderer.invoke('window:openVideoPlayerWindow', videoPath, videoWidth, videoHeight),
resizeToFitVideo: (videoWidth: number, videoHeight: number) =>
ipcRenderer.invoke('window:resizeToFitVideo', videoWidth, videoHeight)
}, },
// 数据库路径 // 数据库路径
@@ -109,7 +113,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
getVoiceData: (sessionId: string, msgId: string, createTime?: number, serverId?: string | number) => getVoiceData: (sessionId: string, msgId: string, createTime?: number, serverId?: string | number) =>
ipcRenderer.invoke('chat:getVoiceData', sessionId, msgId, createTime, serverId), ipcRenderer.invoke('chat:getVoiceData', sessionId, msgId, createTime, serverId),
resolveVoiceCache: (sessionId: string, msgId: string) => ipcRenderer.invoke('chat:resolveVoiceCache', sessionId, msgId), resolveVoiceCache: (sessionId: string, msgId: string) => ipcRenderer.invoke('chat:resolveVoiceCache', sessionId, msgId),
getVoiceTranscript: (sessionId: string, msgId: string) => ipcRenderer.invoke('chat:getVoiceTranscript', sessionId, msgId), getVoiceTranscript: (sessionId: string, msgId: string, createTime?: number) => ipcRenderer.invoke('chat:getVoiceTranscript', sessionId, msgId, createTime),
onVoiceTranscriptPartial: (callback: (payload: { msgId: string; text: string }) => void) => { onVoiceTranscriptPartial: (callback: (payload: { msgId: string; text: string }) => void) => {
const listener = (_: any, payload: { msgId: string; text: string }) => callback(payload) const listener = (_: any, payload: { msgId: string; text: string }) => callback(payload)
ipcRenderer.on('chat:voiceTranscriptPartial', listener) ipcRenderer.on('chat:voiceTranscriptPartial', listener)
@@ -137,6 +141,12 @@ contextBridge.exposeInMainWorld('electronAPI', {
} }
}, },
// 视频
video: {
getVideoInfo: (videoMd5: string) => ipcRenderer.invoke('video:getVideoInfo', videoMd5),
parseVideoMd5: (content: string) => ipcRenderer.invoke('video:parseVideoMd5', content)
},
// 数据分析 // 数据分析
analytics: { analytics: {
getOverallStatistics: () => ipcRenderer.invoke('analytics:getOverallStatistics'), getOverallStatistics: () => ipcRenderer.invoke('analytics:getOverallStatistics'),
@@ -181,7 +191,11 @@ contextBridge.exposeInMainWorld('electronAPI', {
exportSessions: (sessionIds: string[], outputDir: string, options: any) => exportSessions: (sessionIds: string[], outputDir: string, options: any) =>
ipcRenderer.invoke('export:exportSessions', sessionIds, outputDir, options), ipcRenderer.invoke('export:exportSessions', sessionIds, outputDir, options),
exportSession: (sessionId: string, outputPath: string, options: any) => exportSession: (sessionId: string, outputPath: string, options: any) =>
ipcRenderer.invoke('export:exportSession', sessionId, outputPath, options) ipcRenderer.invoke('export:exportSession', sessionId, outputPath, options),
onProgress: (callback: (payload: { current: number; total: number; currentSession: string; phase: string }) => void) => {
ipcRenderer.on('export:progress', (_, payload) => callback(payload))
return () => ipcRenderer.removeAllListeners('export:progress')
}
}, },
whisper: { whisper: {

View File

@@ -42,6 +42,7 @@ export interface Message {
senderUsername: string | null senderUsername: string | null
parsedContent: string parsedContent: string
rawContent: string rawContent: string
content?: string // 原始XML内容与rawContent相同供前端使用
// 表情包相关 // 表情包相关
emojiCdnUrl?: string emojiCdnUrl?: string
emojiMd5?: string emojiMd5?: string
@@ -52,6 +53,7 @@ export interface Message {
// 图片/视频相关 // 图片/视频相关
imageMd5?: string imageMd5?: string
imageDatName?: string imageDatName?: string
videoMd5?: string
aesKey?: string aesKey?: string
encrypVer?: number encrypVer?: number
cdnThumbUrl?: string cdnThumbUrl?: string
@@ -83,7 +85,18 @@ class ChatService {
private voiceWavCache = new Map<string, Buffer>() private voiceWavCache = new Map<string, Buffer>()
private voiceTranscriptCache = new Map<string, string>() private voiceTranscriptCache = new Map<string, string>()
private voiceTranscriptPending = new Map<string, Promise<{ success: boolean; transcript?: string; error?: string }>>() private voiceTranscriptPending = new Map<string, Promise<{ success: boolean; transcript?: string; error?: string }>>()
private mediaDbsCache: string[] | null = null
private mediaDbsCacheTime = 0
private readonly mediaDbsCacheTtl = 300000 // 5分钟
private readonly voiceCacheMaxEntries = 50 private readonly voiceCacheMaxEntries = 50
// 缓存 media.db 的表结构信息
private mediaDbSchemaCache = new Map<string, {
voiceTable: string
dataColumn: string
chatNameIdColumn?: string
timeColumn?: string
name2IdTable?: string
}>()
constructor() { constructor() {
this.configService = new ConfigService() this.configService = new ConfigService()
@@ -140,6 +153,10 @@ class ChatService {
} }
this.connected = true this.connected = true
// 预热 listMediaDbs 缓存(后台异步执行,不阻塞连接)
this.warmupMediaDbsCache()
return { success: true } return { success: true }
} catch (e) { } catch (e) {
console.error('ChatService: 连接数据库失败:', e) console.error('ChatService: 连接数据库失败:', e)
@@ -147,6 +164,21 @@ class ChatService {
} }
} }
/**
* 预热 media 数据库列表缓存(后台异步执行)
*/
private async warmupMediaDbsCache(): Promise<void> {
try {
const result = await wcdbService.listMediaDbs()
if (result.success && result.data) {
this.mediaDbsCache = result.data as string[]
this.mediaDbsCacheTime = Date.now()
}
} catch (e) {
// 静默失败,不影响主流程
}
}
private async ensureConnected(): Promise<{ success: boolean; error?: string }> { private async ensureConnected(): Promise<{ success: boolean; error?: string }> {
if (this.connected && wcdbService.isReady()) { if (this.connected && wcdbService.isReady()) {
return { success: true } return { success: true }
@@ -382,8 +414,6 @@ class ChatService {
const needNewCursor = !state || offset === 0 || state.batchSize !== batchSize const needNewCursor = !state || offset === 0 || state.batchSize !== batchSize
if (needNewCursor) { if (needNewCursor) {
console.log(`[ChatService] 创建新游标: sessionId=${sessionId}, offset=${offset}, batchSize=${batchSize}`)
// 关闭旧游标 // 关闭旧游标
if (state) { if (state) {
try { try {
@@ -440,7 +470,6 @@ class ChatService {
} }
// 获取当前批次的消息 // 获取当前批次的消息
console.log(`[ChatService] 获取消息批次: cursor=${state.cursor}, fetched=${state.fetched}`)
const batch = await wcdbService.fetchMessageBatch(state.cursor) const batch = await wcdbService.fetchMessageBatch(state.cursor)
if (!batch.success) { if (!batch.success) {
console.error('[ChatService] 获取消息批次失败:', batch.error) console.error('[ChatService] 获取消息批次失败:', batch.error)
@@ -716,6 +745,7 @@ class ChatService {
let quotedSender: string | undefined let quotedSender: string | undefined
let imageMd5: string | undefined let imageMd5: string | undefined
let imageDatName: string | undefined let imageDatName: string | undefined
let videoMd5: string | undefined
let aesKey: string | undefined let aesKey: string | undefined
let encrypVer: number | undefined let encrypVer: number | undefined
let cdnThumbUrl: string | undefined let cdnThumbUrl: string | undefined
@@ -732,6 +762,9 @@ class ChatService {
encrypVer = imageInfo.encrypVer encrypVer = imageInfo.encrypVer
cdnThumbUrl = imageInfo.cdnThumbUrl cdnThumbUrl = imageInfo.cdnThumbUrl
imageDatName = this.parseImageDatNameFromRow(row) imageDatName = this.parseImageDatNameFromRow(row)
} else if (localType === 43 && content) {
// 视频消息
videoMd5 = this.parseVideoMd5(content)
} else if (localType === 34 && content) { } else if (localType === 34 && content) {
voiceDurationSeconds = this.parseVoiceDurationSeconds(content) voiceDurationSeconds = this.parseVoiceDurationSeconds(content)
} else if (localType === 244813135921 || (content && content.includes('<type>57</type>'))) { } else if (localType === 244813135921 || (content && content.includes('<type>57</type>'))) {
@@ -756,6 +789,7 @@ class ChatService {
quotedSender, quotedSender,
imageMd5, imageMd5,
imageDatName, imageDatName,
videoMd5,
voiceDurationSeconds, voiceDurationSeconds,
aesKey, aesKey,
encrypVer, encrypVer,
@@ -937,6 +971,26 @@ class ChatService {
} }
} }
/**
* 解析视频MD5
* 注意:提取 md5 字段用于查询 hardlink.db获取实际视频文件名
*/
private parseVideoMd5(content: string): string | undefined {
if (!content) return undefined
try {
// 提取 md5这是用于查询 hardlink.db 的值
const md5 =
this.extractXmlAttribute(content, 'videomsg', 'md5') ||
this.extractXmlValue(content, 'md5') ||
undefined
return md5?.toLowerCase()
} catch {
return undefined
}
}
/** /**
* 解析通话消息 * 解析通话消息
* 格式: <voipmsg type="VoIPBubbleMsg"><VoIPBubbleMsg><msg><![CDATA[...]]></msg><room_type>0/1</room_type>...</VoIPBubbleMsg></voipmsg> * 格式: <voipmsg type="VoIPBubbleMsg"><VoIPBubbleMsg><msg><![CDATA[...]]></msg><room_type>0/1</room_type>...</VoIPBubbleMsg></voipmsg>
@@ -1419,21 +1473,22 @@ class ChatService {
} }
private extractXmlAttribute(xml: string, tagName: string, attrName: string): string { private extractXmlAttribute(xml: string, tagName: string, attrName: string): string {
const tagRegex = new RegExp(`<${tagName}[^>]*>`, 'i') // 匹配 <tagName ... attrName="value" ... /> 或 <tagName ... attrName="value" ...>
const tagMatch = tagRegex.exec(xml) const regex = new RegExp(`<${tagName}[^>]*\\s${attrName}\\s*=\\s*['"]([^'"]*)['"']`, 'i')
if (!tagMatch) return '' const match = regex.exec(xml)
return match ? match[1] : ''
const attrRegex = new RegExp(`${attrName}\\s*=\\s*['"]([^'"]*)['"]`, 'i')
const attrMatch = attrRegex.exec(tagMatch[0])
return attrMatch ? attrMatch[1] : ''
} }
private cleanSystemMessage(content: string): string { private cleanSystemMessage(content: string): string {
return content // 移除 XML 声明
.replace(/<img[^>]*>/gi, '') let cleaned = content.replace(/<\?xml[^?]*\?>/gi, '')
.replace(/<\/?[a-zA-Z0-9_]+[^>]*>/g, '') // 移除所有 XML/HTML 标签
.replace(/\s+/g, ' ') cleaned = cleaned.replace(/<[^>]+>/g, '')
.trim() || '[系统消息]' // 移除尾部的数字(如撤回消息后的时间戳)
cleaned = cleaned.replace(/\d+\s*$/, '')
// 清理多余空白
cleaned = cleaned.replace(/\s+/g, ' ').trim()
return cleaned || '[系统消息]'
} }
private stripSenderPrefix(content: string): string { private stripSenderPrefix(content: string): string {
@@ -1691,21 +1746,17 @@ class ChatService {
// 增加 'self' 作为兜底标识符,微信有时将个人信息存储在 'self' 记录中 // 增加 'self' 作为兜底标识符,微信有时将个人信息存储在 'self' 记录中
const fetchList = Array.from(new Set([myWxid, cleanedWxid, 'self'])) const fetchList = Array.from(new Set([myWxid, cleanedWxid, 'self']))
console.log(`[ChatService] 尝试获取个人头像, wxids: ${JSON.stringify(fetchList)}`)
const result = await wcdbService.getAvatarUrls(fetchList) const result = await wcdbService.getAvatarUrls(fetchList)
if (result.success && result.map) { if (result.success && result.map) {
// 按优先级尝试匹配 // 按优先级尝试匹配
const avatarUrl = result.map[myWxid] || result.map[cleanedWxid] || result.map['self'] const avatarUrl = result.map[myWxid] || result.map[cleanedWxid] || result.map['self']
if (avatarUrl) { if (avatarUrl) {
console.log(`[ChatService] 成功获取个人头像: ${avatarUrl.substring(0, 50)}...`)
return { success: true, avatarUrl } return { success: true, avatarUrl }
} }
console.warn(`[ChatService] 未能在 contact.db 中找到个人头像, 请求列表: ${JSON.stringify(fetchList)}`)
return { success: true, avatarUrl: undefined } return { success: true, avatarUrl: undefined }
} }
console.error(`[ChatService] 查询个人头像失败: ${result.error || '未知错误'}`)
return { success: true, avatarUrl: undefined } return { success: true, avatarUrl: undefined }
} catch (e) { } catch (e) {
console.error('ChatService: 获取当前用户头像失败:', e) console.error('ChatService: 获取当前用户头像失败:', e)
@@ -1716,6 +1767,19 @@ class ChatService {
/** /**
* 获取表情包缓存目录 * 获取表情包缓存目录
*/ */
/**
* 获取语音缓存目录
*/
private getVoiceCacheDir(): string {
const cachePath = this.configService.get('cachePath')
if (cachePath) {
return join(cachePath, 'Voices')
}
// 回退到默认目录
const documentsPath = app.getPath('documents')
return join(documentsPath, 'WeFlow', 'Voices')
}
private getEmojiCacheDir(): string { private getEmojiCacheDir(): string {
const cachePath = this.configService.get('cachePath') const cachePath = this.configService.get('cachePath')
if (cachePath) { if (cachePath) {
@@ -2085,12 +2149,6 @@ class ChatService {
return { success: false, error: '未找到消息' } return { success: false, error: '未找到消息' }
} }
const msg = msgResult.message const msg = msgResult.message
console.info('[ChatService][Image] request', {
sessionId,
localId: msg.localId,
imageMd5: msg.imageMd5,
imageDatName: msg.imageDatName
})
// 2. 确定搜索的基础名 // 2. 确定搜索的基础名
const baseName = msg.imageMd5 || msg.imageDatName || String(msg.localId) const baseName = msg.imageMd5 || msg.imageDatName || String(msg.localId)
@@ -2107,7 +2165,6 @@ class ChatService {
const datPath = await this.findDatFile(actualAccountDir, baseName, sessionId) const datPath = await this.findDatFile(actualAccountDir, baseName, sessionId)
if (!datPath) return { success: false, error: '未找到图片源文件 (.dat)' } if (!datPath) return { success: false, error: '未找到图片源文件 (.dat)' }
console.info('[ChatService][Image] dat path', datPath)
// 4. 获取解密密钥 // 4. 获取解密密钥
const xorKeyRaw = this.configService.get('imageXorKey') const xorKeyRaw = this.configService.get('imageXorKey')
@@ -2135,7 +2192,6 @@ class ChatService {
const aesKey = this.asciiKey16(trimmed) const aesKey = this.asciiKey16(trimmed)
decrypted = this.decryptDatV4(data, xorKey, aesKey) decrypted = this.decryptDatV4(data, xorKey, aesKey)
} }
console.info('[ChatService][Image] decrypted bytes', decrypted.length)
// 返回 base64 // 返回 base64
return { success: true, data: decrypted.toString('base64') } return { success: true, data: decrypted.toString('base64') }
@@ -2146,44 +2202,30 @@ class ChatService {
} }
/** /**
* getVoiceData (优化的 C++ 实现 + 文件缓存) * getVoiceData (绕过WCDB的buggy getVoiceData直接用execQuery读取)
*/ */
async getVoiceData(sessionId: string, msgId: string, createTime?: number, serverId?: string | number): Promise<{ success: boolean; data?: string; error?: string }> { async getVoiceData(sessionId: string, msgId: string, createTime?: number, serverId?: string | number): Promise<{ success: boolean; data?: string; error?: string }> {
const startTime = Date.now()
try { try {
const localId = parseInt(msgId, 10) const localId = parseInt(msgId, 10)
if (isNaN(localId)) { if (isNaN(localId)) {
return { success: false, error: '无效的消息ID' } return { success: false, error: '无效的消息ID' }
} }
// 检查文件缓存
const cacheKey = this.getVoiceCacheKey(sessionId, msgId)
const cachedFile = this.getVoiceCacheFilePath(cacheKey)
if (existsSync(cachedFile)) {
try {
const wavData = readFileSync(cachedFile)
console.info('[ChatService][Voice] 使用缓存文件:', cachedFile)
return { success: true, data: wavData.toString('base64') }
} catch (e) {
console.error('[ChatService][Voice] 读取缓存失败:', e)
// 继续重新解密
}
}
// 1. 确定 createTime 和 svrId
let msgCreateTime = createTime let msgCreateTime = createTime
let msgSvrId: string | number = serverId || 0 let senderWxid: string | null = null
// 如果提供了传来的参数,验证其有效性 // 如果前端没传 createTime才需要查询消息这个很慢
if (!msgCreateTime || msgCreateTime === 0) { if (!msgCreateTime) {
const t1 = Date.now()
const msgResult = await this.getMessageByLocalId(sessionId, localId) const msgResult = await this.getMessageByLocalId(sessionId, localId)
const t2 = Date.now()
console.log(`[Voice] getMessageByLocalId: ${t2 - t1}ms`)
if (msgResult.success && msgResult.message) { if (msgResult.success && msgResult.message) {
const msg = msgResult.message as any const msg = msgResult.message as any
msgCreateTime = msg.createTime || msg.create_time msgCreateTime = msg.createTime
// 尝试获取各种可能的 server id 列名 (只有在没有传入 serverId 时才查找) senderWxid = msg.senderUsername || null
if (!msgSvrId || msgSvrId === 0) {
msgSvrId = msg.serverId || msg.svr_id || msg.msg_svr_id || msg.message_id || 0
}
} }
} }
@@ -2191,54 +2233,84 @@ class ChatService {
return { success: false, error: '未找到消息时间戳' } return { success: false, error: '未找到消息时间戳' }
} }
// 2. 构建查找候选 (sessionId, myWxid) // 使用 sessionId + createTime 作为缓存key
const cacheKey = `${sessionId}_${msgCreateTime}`
// 检查 WAV 内存缓存
const wavCache = this.voiceWavCache.get(cacheKey)
if (wavCache) {
console.log(`[Voice] 内存缓存命中,总耗时: ${Date.now() - startTime}ms`)
return { success: true, data: wavCache.toString('base64') }
}
// 检查 WAV 文件缓存
const voiceCacheDir = this.getVoiceCacheDir()
const wavFilePath = join(voiceCacheDir, `${cacheKey}.wav`)
if (existsSync(wavFilePath)) {
try {
const wavData = readFileSync(wavFilePath)
// 同时缓存到内存
this.cacheVoiceWav(cacheKey, wavData)
console.log(`[Voice] 文件缓存命中,总耗时: ${Date.now() - startTime}ms`)
return { success: true, data: wavData.toString('base64') }
} catch (e) {
console.error('[Voice] 读取缓存文件失败:', e)
}
}
// 构建查找候选
const candidates: string[] = [] const candidates: string[] = []
if (sessionId) candidates.push(sessionId)
const myWxid = this.configService.get('myWxid') as string const myWxid = this.configService.get('myWxid') as string
// 如果有 senderWxid优先使用群聊中最重要
if (senderWxid) {
candidates.push(senderWxid)
}
// sessionId1对1聊天时是对方wxid群聊时是群id
if (sessionId && !candidates.includes(sessionId)) {
candidates.push(sessionId)
}
// 我的wxid兜底
if (myWxid && !candidates.includes(myWxid)) { if (myWxid && !candidates.includes(myWxid)) {
candidates.push(myWxid) candidates.push(myWxid)
} }
const t3 = Date.now()
// 从数据库读取 silk 数据
const silkData = await this.getVoiceDataFromMediaDb(msgCreateTime, candidates)
const t4 = Date.now()
console.log(`[Voice] getVoiceDataFromMediaDb: ${t4 - t3}ms`)
if (!silkData) {
// 3. 调用 C++ 接口获取语音 (Hex) return { success: false, error: '未找到语音数据' }
const voiceRes = await wcdbService.getVoiceData(sessionId, msgCreateTime, candidates, localId, msgSvrId)
if (!voiceRes.success || !voiceRes.hex) {
return { success: false, error: voiceRes.error || '未找到语音数据' }
} }
const t5 = Date.now()
// 使用 silk-wasm 解码
const pcmData = await this.decodeSilkToPcm(silkData, 24000)
const t6 = Date.now()
console.log(`[Voice] decodeSilkToPcm: ${t6 - t5}ms`)
if (!pcmData) {
// 4. Hex 转 Buffer (Silk) return { success: false, error: 'Silk 解码失败' }
const silkData = Buffer.from(voiceRes.hex, 'hex')
// 5. 使用 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)
// 保存到文件缓存
try {
this.saveVoiceCache(cacheKey, wavData)
console.info('[ChatService][Voice] 已保存缓存:', cachedFile)
} catch (e) {
console.error('[ChatService][Voice] 保存缓存失败:', e)
// 不影响返回
}
// 缓存 WAV 数据 (内存缓存)
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 t7 = Date.now()
// PCM -> WAV
const wavData = this.createWavBuffer(pcmData, 24000)
const t8 = Date.now()
console.log(`[Voice] createWavBuffer: ${t8 - t7}ms`)
// 缓存 WAV 数据到内存
this.cacheVoiceWav(cacheKey, wavData)
// 缓存 WAV 数据到文件(异步,不阻塞返回)
this.cacheVoiceWavToFile(cacheKey, wavData)
console.log(`[Voice] 总耗时: ${Date.now() - startTime}ms`)
return { success: true, data: wavData.toString('base64') }
} catch (e) { } catch (e) {
console.error('ChatService: getVoiceData 失败:', e) console.error('ChatService: getVoiceData 失败:', e)
return { success: false, error: String(e) } return { success: false, error: String(e) }
@@ -2246,26 +2318,228 @@ class ChatService {
} }
/** /**
* 检查语音是否已有缓存 * 缓存 WAV 数据到文件(异步)
*/
private async cacheVoiceWavToFile(cacheKey: string, wavData: Buffer): Promise<void> {
try {
const voiceCacheDir = this.getVoiceCacheDir()
if (!existsSync(voiceCacheDir)) {
mkdirSync(voiceCacheDir, { recursive: true })
}
const wavFilePath = join(voiceCacheDir, `${cacheKey}.wav`)
writeFileSync(wavFilePath, wavData)
} catch (e) {
console.error('[Voice] 缓存文件失败:', e)
}
}
/**
* 通过 WCDB 的 execQuery 直接查询 media.db绕过有bug的getVoiceData接口
* 策略:批量查询 + 多种兜底方案
*/
private async getVoiceDataFromMediaDb(createTime: number, candidates: string[]): Promise<Buffer | null> {
const startTime = Date.now()
try {
const t1 = Date.now()
// 获取所有 media 数据库(永久缓存,直到应用重启)
let mediaDbFiles: string[]
if (this.mediaDbsCache) {
mediaDbFiles = this.mediaDbsCache
console.log(`[Voice] listMediaDbs (缓存): 0ms`)
} else {
const mediaDbsResult = await wcdbService.listMediaDbs()
const t2 = Date.now()
console.log(`[Voice] listMediaDbs: ${t2 - t1}ms`)
if (!mediaDbsResult.success || !mediaDbsResult.data || mediaDbsResult.data.length === 0) {
return null
}
mediaDbFiles = mediaDbsResult.data as string[]
this.mediaDbsCache = mediaDbFiles // 永久缓存
}
// 在所有 media 数据库中查找
for (const dbPath of mediaDbFiles) {
try {
// 检查缓存
let schema = this.mediaDbSchemaCache.get(dbPath)
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()
console.log(`[Voice] 查询VoiceInfo表: ${t4 - t3}ms`)
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()
console.log(`[Voice] 查询表结构: ${t6 - t5}ms`)
if (!columnsResult.success || !columnsResult.rows) {
continue
}
// 创建列名映射(原始名称 -> 小写名称)
const columnMap = new Map<string, string>()
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()
console.log(`[Voice] 查询Name2Id表: ${t8 - t7}ms`)
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)
}
// 策略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()
console.log(`[Voice] 查询chat_name_id: ${t10 - t9}ms`)
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()
console.log(`[Voice] 策略1查询语音: ${t12 - t11}ms`)
if (voiceResult.success && voiceResult.rows && voiceResult.rows.length > 0) {
const row = voiceResult.rows[0]
const silkData = this.decodeVoiceBlob(row.data)
if (silkData) {
console.log(`[Voice] getVoiceDataFromMediaDb总耗时: ${Date.now() - startTime}ms`)
return silkData
}
}
}
}
// 策略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()
console.log(`[Voice] 策略2查询语音: ${t14 - t13}ms`)
if (voiceResult.success && voiceResult.rows && voiceResult.rows.length > 0) {
const row = voiceResult.rows[0]
const silkData = this.decodeVoiceBlob(row.data)
if (silkData) {
console.log(`[Voice] getVoiceDataFromMediaDb总耗时: ${Date.now() - startTime}ms`)
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()
console.log(`[Voice] 策略3查询语音: ${t16 - t15}ms`)
if (voiceResult.success && voiceResult.rows && voiceResult.rows.length > 0) {
const row = voiceResult.rows[0]
const silkData = this.decodeVoiceBlob(row.data)
if (silkData) {
console.log(`[Voice] getVoiceDataFromMediaDb总耗时: ${Date.now() - startTime}ms`)
return silkData
}
}
}
} catch (e) {
// 静默失败,继续尝试下一个数据库
}
}
return null
} catch (e) {
return null
}
}
/**
* 检查语音是否已有缓存(只检查内存,不查询数据库)
*/ */
async resolveVoiceCache(sessionId: string, msgId: string): Promise<{ success: boolean; hasCache: boolean; data?: string }> { async resolveVoiceCache(sessionId: string, msgId: string): Promise<{ success: boolean; hasCache: boolean; data?: string }> {
try { try {
// 直接用 msgId 生成 cacheKey不查询数据库
// 注意:这里的 cacheKey 可能不准确(因为没有 createTime但只是用来快速检查缓存
// 如果缓存未命中,用户点击时会重新用正确的 cacheKey 查询
const cacheKey = this.getVoiceCacheKey(sessionId, msgId) const cacheKey = this.getVoiceCacheKey(sessionId, msgId)
// 1. 检查内存缓存 // 检查内存缓存
const inMemory = this.voiceWavCache.get(cacheKey) const inMemory = this.voiceWavCache.get(cacheKey)
if (inMemory) { if (inMemory) {
return { success: true, hasCache: true, data: inMemory.toString('base64') } return { success: true, hasCache: true, data: inMemory.toString('base64') }
} }
// 2. 检查文件缓存
const cachedFile = this.getVoiceCacheFilePath(cacheKey)
if (existsSync(cachedFile)) {
const wavData = readFileSync(cachedFile)
this.cacheVoiceWav(cacheKey, wavData) // 回甜内存
return { success: true, hasCache: true, data: wavData.toString('base64') }
}
return { success: true, hasCache: false } return { success: true, hasCache: false }
} catch (e) { } catch (e) {
return { success: false, hasCache: false } return { success: false, hasCache: false }
@@ -2460,60 +2734,133 @@ class ChatService {
async getVoiceTranscript( async getVoiceTranscript(
sessionId: string, sessionId: string,
msgId: string, msgId: string,
createTime?: number,
onPartial?: (text: string) => void onPartial?: (text: string) => void
): Promise<{ success: boolean; transcript?: string; error?: string }> { ): Promise<{ success: boolean; transcript?: string; error?: string }> {
const cacheKey = this.getVoiceCacheKey(sessionId, msgId) const startTime = Date.now()
const cached = this.voiceTranscriptCache.get(cacheKey) console.log(`[Transcribe] 开始转写: sessionId=${sessionId}, msgId=${msgId}, createTime=${createTime}`)
if (cached) {
return { success: true, transcript: cached }
}
const pending = this.voiceTranscriptPending.get(cacheKey) try {
if (pending) { let msgCreateTime = createTime
return pending let serverId: string | number | undefined
}
const task = (async () => { // 如果前端没传 createTime才需要查询消息这个很慢
try { if (!msgCreateTime) {
let wavData = this.voiceWavCache.get(cacheKey) const t1 = Date.now()
if (!wavData) { const msgResult = await this.getMessageById(sessionId, parseInt(msgId, 10))
// 获取消息详情以拿到 createTime 和 serverId const t2 = Date.now()
let cTime: number | undefined console.log(`[Transcribe] getMessageById: ${t2 - t1}ms`)
let sId: string | number | undefined
const msgResult = await this.getMessageById(sessionId, parseInt(msgId, 10))
if (msgResult.success && msgResult.message) {
cTime = msgResult.message.createTime
sId = msgResult.message.serverId
}
const voiceResult = await this.getVoiceData(sessionId, msgId, cTime, sId) if (msgResult.success && msgResult.message) {
if (!voiceResult.success || !voiceResult.data) { msgCreateTime = msgResult.message.createTime
return { success: false, error: voiceResult.error || '语音解码失败' } serverId = msgResult.message.serverId
} console.log(`[Transcribe] 获取到 createTime=${msgCreateTime}, serverId=${serverId}`)
wavData = Buffer.from(voiceResult.data, 'base64')
} }
const result = await voiceTranscribeService.transcribeWavBuffer(wavData, (text) => {
onPartial?.(text)
})
if (result.success && result.transcript) {
this.cacheVoiceTranscript(cacheKey, result.transcript)
}
return result
} catch (error) {
return { success: false, error: String(error) }
} finally {
this.voiceTranscriptPending.delete(cacheKey)
} }
})()
this.voiceTranscriptPending.set(cacheKey, task) if (!msgCreateTime) {
return task console.error(`[Transcribe] 未找到消息时间戳`)
return { success: false, error: '未找到消息时间戳' }
}
// 使用正确的 cacheKey包含 createTime
const cacheKey = this.getVoiceCacheKey(sessionId, msgId, msgCreateTime)
console.log(`[Transcribe] cacheKey=${cacheKey}`)
// 检查转写缓存
const cached = this.voiceTranscriptCache.get(cacheKey)
if (cached) {
console.log(`[Transcribe] 缓存命中,总耗时: ${Date.now() - startTime}ms`)
return { success: true, transcript: cached }
}
// 检查是否正在转写
const pending = this.voiceTranscriptPending.get(cacheKey)
if (pending) {
console.log(`[Transcribe] 正在转写中,等待结果`)
return pending
}
const task = (async () => {
try {
// 检查内存中是否有 WAV 数据
let wavData = this.voiceWavCache.get(cacheKey)
if (wavData) {
console.log(`[Transcribe] WAV内存缓存命中大小: ${wavData.length} bytes`)
} else {
// 检查文件缓存
const voiceCacheDir = this.getVoiceCacheDir()
const wavFilePath = join(voiceCacheDir, `${cacheKey}.wav`)
if (existsSync(wavFilePath)) {
try {
wavData = readFileSync(wavFilePath)
console.log(`[Transcribe] WAV文件缓存命中大小: ${wavData.length} bytes`)
// 同时缓存到内存
this.cacheVoiceWav(cacheKey, wavData)
} catch (e) {
console.error(`[Transcribe] 读取缓存文件失败:`, e)
}
}
}
if (!wavData) {
console.log(`[Transcribe] WAV缓存未命中调用 getVoiceData`)
const t3 = Date.now()
// 调用 getVoiceData 获取并解码
const voiceResult = await this.getVoiceData(sessionId, msgId, msgCreateTime, serverId)
const t4 = Date.now()
console.log(`[Transcribe] getVoiceData: ${t4 - t3}ms, success=${voiceResult.success}`)
if (!voiceResult.success || !voiceResult.data) {
console.error(`[Transcribe] 语音解码失败: ${voiceResult.error}`)
return { success: false, error: voiceResult.error || '语音解码失败' }
}
wavData = Buffer.from(voiceResult.data, 'base64')
console.log(`[Transcribe] WAV数据大小: ${wavData.length} bytes`)
}
// 转写
console.log(`[Transcribe] 开始调用 transcribeWavBuffer`)
const t5 = Date.now()
const result = await voiceTranscribeService.transcribeWavBuffer(wavData, (text) => {
console.log(`[Transcribe] 部分结果: ${text}`)
onPartial?.(text)
})
const t6 = Date.now()
console.log(`[Transcribe] transcribeWavBuffer: ${t6 - t5}ms, success=${result.success}`)
if (result.success && result.transcript) {
console.log(`[Transcribe] 转写成功: ${result.transcript}`)
this.cacheVoiceTranscript(cacheKey, result.transcript)
} else {
console.error(`[Transcribe] 转写失败: ${result.error}`)
}
console.log(`[Transcribe] 总耗时: ${Date.now() - startTime}ms`)
return result
} catch (error) {
console.error(`[Transcribe] 异常:`, error)
return { success: false, error: String(error) }
} finally {
this.voiceTranscriptPending.delete(cacheKey)
}
})()
this.voiceTranscriptPending.set(cacheKey, task)
return task
} catch (error) {
console.error(`[Transcribe] 外层异常:`, error)
return { success: false, error: String(error) }
}
} }
private getVoiceCacheKey(sessionId: string, msgId: string): string { private getVoiceCacheKey(sessionId: string, msgId: string, createTime?: number): string {
// 优先使用 createTime 作为key避免不同会话中localId相同导致的混乱
if (createTime) {
return `${sessionId}_${createTime}`
}
return `${sessionId}_${msgId}` return `${sessionId}_${msgId}`
} }
@@ -2525,32 +2872,6 @@ class ChatService {
} }
} }
/**
* 获取语音缓存文件路径
*/
private getVoiceCacheFilePath(cacheKey: string): string {
const cachePath = this.configService.get('cachePath') as string | undefined
let baseDir: string
if (cachePath && cachePath.trim()) {
baseDir = join(cachePath, 'Voices')
} else {
const documentsPath = app.getPath('documents')
baseDir = join(documentsPath, 'WeFlow', 'Voices')
}
if (!existsSync(baseDir)) {
mkdirSync(baseDir, { recursive: true })
}
return join(baseDir, `${cacheKey}.wav`)
}
/**
* 保存语音到文件缓存
*/
private saveVoiceCache(cacheKey: string, wavData: Buffer): void {
const filePath = this.getVoiceCacheFilePath(cacheKey)
writeFileSync(filePath, wavData)
}
private cacheVoiceTranscript(cacheKey: string, transcript: string): void { private cacheVoiceTranscript(cacheKey: string, transcript: string): void {
this.voiceTranscriptCache.set(cacheKey, transcript) this.voiceTranscriptCache.set(cacheKey, transcript)
if (this.voiceTranscriptCache.size > this.voiceCacheMaxEntries) { if (this.voiceTranscriptCache.size > this.voiceCacheMaxEntries) {
@@ -2561,8 +2882,6 @@ class ChatService {
async getMessageById(sessionId: string, localId: number): Promise<{ success: boolean; message?: Message; error?: string }> { async getMessageById(sessionId: string, localId: number): Promise<{ success: boolean; message?: Message; error?: string }> {
try { try {
console.info('[ChatService] getMessageById (SQL)', { sessionId, localId })
// 1. 获取该会话所在的消息表 // 1. 获取该会话所在的消息表
// 注意:这里使用 getMessageTableStats 而不是 getMessageTables因为前者包含 db_path // 注意:这里使用 getMessageTableStats 而不是 getMessageTables因为前者包含 db_path
const tableStats = await wcdbService.getMessageTableStats(sessionId) const tableStats = await wcdbService.getMessageTableStats(sessionId)
@@ -2585,7 +2904,6 @@ class ChatService {
const message = this.parseMessage(row) const message = this.parseMessage(row)
if (message.localId !== 0) { if (message.localId !== 0) {
console.info('[ChatService] getMessageById hit', { tableName, localId: message.localId })
return { success: true, message } return { success: true, message }
} }
} }
@@ -2628,6 +2946,7 @@ class ChatService {
isSend: this.getRowInt(row, ['computed_is_send', 'computedIsSend', 'is_send', 'isSend', 'WCDB_CT_is_send'], 0), isSend: this.getRowInt(row, ['computed_is_send', 'computedIsSend', 'is_send', 'isSend', 'WCDB_CT_is_send'], 0),
senderUsername: this.getRowField(row, ['sender_username', 'senderUsername', 'sender', 'WCDB_CT_sender_username']) || null, senderUsername: this.getRowField(row, ['sender_username', 'senderUsername', 'sender', 'WCDB_CT_sender_username']) || null,
rawContent: rawContent, rawContent: rawContent,
content: rawContent, // 添加原始内容供视频MD5解析使用
parsedContent: this.parseMessageContent(rawContent, this.getRowInt(row, ['local_type', 'localType', 'type', 'msg_type', 'msgType', 'WCDB_CT_local_type'], 0)) parsedContent: this.parseMessageContent(rawContent, this.getRowInt(row, ['local_type', 'localType', 'type', 'msg_type', 'msgType', 'WCDB_CT_local_type'], 0))
} }

View File

@@ -71,6 +71,8 @@ export interface ExportOptions {
exportVoices?: boolean exportVoices?: boolean
exportEmojis?: boolean exportEmojis?: boolean
exportVoiceAsText?: boolean exportVoiceAsText?: boolean
excelCompactColumns?: boolean
sessionLayout?: 'shared' | 'per-session'
} }
interface MediaExportItem { interface MediaExportItem {
@@ -407,14 +409,15 @@ class ExportService {
private async exportMediaForMessage( private async exportMediaForMessage(
msg: any, msg: any,
sessionId: string, sessionId: string,
mediaDir: string, mediaRootDir: string,
mediaRelativePrefix: string,
options: { exportImages?: boolean; exportVoices?: boolean; exportEmojis?: boolean; exportVoiceAsText?: boolean } options: { exportImages?: boolean; exportVoices?: boolean; exportEmojis?: boolean; exportVoiceAsText?: boolean }
): Promise<MediaExportItem | null> { ): Promise<MediaExportItem | null> {
const localType = msg.localType const localType = msg.localType
// 图片消息 // 图片消息
if (localType === 3 && options.exportImages) { if (localType === 3 && options.exportImages) {
const result = await this.exportImage(msg, sessionId, mediaDir) const result = await this.exportImage(msg, sessionId, mediaRootDir, mediaRelativePrefix)
if (result) { if (result) {
} }
return result return result
@@ -428,13 +431,13 @@ class ExportService {
} }
// 否则导出语音文件 // 否则导出语音文件
if (options.exportVoices) { if (options.exportVoices) {
return this.exportVoice(msg, sessionId, mediaDir) return this.exportVoice(msg, sessionId, mediaRootDir, mediaRelativePrefix)
} }
} }
// 动画表情 // 动画表情
if (localType === 47 && options.exportEmojis) { if (localType === 47 && options.exportEmojis) {
const result = await this.exportEmoji(msg, sessionId, mediaDir) const result = await this.exportEmoji(msg, sessionId, mediaRootDir, mediaRelativePrefix)
if (result) { if (result) {
} }
return result return result
@@ -446,9 +449,14 @@ class ExportService {
/** /**
* 导出图片文件 * 导出图片文件
*/ */
private async exportImage(msg: any, sessionId: string, mediaDir: string): Promise<MediaExportItem | null> { private async exportImage(
msg: any,
sessionId: string,
mediaRootDir: string,
mediaRelativePrefix: string
): Promise<MediaExportItem | null> {
try { try {
const imagesDir = path.join(mediaDir, 'media', 'images') const imagesDir = path.join(mediaRootDir, mediaRelativePrefix, 'images')
if (!fs.existsSync(imagesDir)) { if (!fs.existsSync(imagesDir)) {
fs.mkdirSync(imagesDir, { recursive: true }) fs.mkdirSync(imagesDir, { recursive: true })
} }
@@ -493,7 +501,7 @@ class ExportService {
fs.writeFileSync(destPath, Buffer.from(base64Data, 'base64')) fs.writeFileSync(destPath, Buffer.from(base64Data, 'base64'))
return { return {
relativePath: `media/images/${fileName}`, relativePath: path.posix.join(mediaRelativePrefix, 'images', fileName),
kind: 'image' kind: 'image'
} }
} else if (sourcePath.startsWith('file://')) { } else if (sourcePath.startsWith('file://')) {
@@ -511,7 +519,7 @@ class ExportService {
} }
return { return {
relativePath: `media/images/${fileName}`, relativePath: path.posix.join(mediaRelativePrefix, 'images', fileName),
kind: 'image' kind: 'image'
} }
} }
@@ -525,9 +533,14 @@ class ExportService {
/** /**
* 导出语音文件 * 导出语音文件
*/ */
private async exportVoice(msg: any, sessionId: string, mediaDir: string): Promise<MediaExportItem | null> { private async exportVoice(
msg: any,
sessionId: string,
mediaRootDir: string,
mediaRelativePrefix: string
): Promise<MediaExportItem | null> {
try { try {
const voicesDir = path.join(mediaDir, 'media', 'voices') const voicesDir = path.join(mediaRootDir, mediaRelativePrefix, 'voices')
if (!fs.existsSync(voicesDir)) { if (!fs.existsSync(voicesDir)) {
fs.mkdirSync(voicesDir, { recursive: true }) fs.mkdirSync(voicesDir, { recursive: true })
} }
@@ -539,7 +552,7 @@ class ExportService {
// 如果已存在则跳过 // 如果已存在则跳过
if (fs.existsSync(destPath)) { if (fs.existsSync(destPath)) {
return { return {
relativePath: `media/voices/${fileName}`, relativePath: path.posix.join(mediaRelativePrefix, 'voices', fileName),
kind: 'voice' kind: 'voice'
} }
} }
@@ -555,7 +568,7 @@ class ExportService {
fs.writeFileSync(destPath, wavBuffer) fs.writeFileSync(destPath, wavBuffer)
return { return {
relativePath: `media/voices/${fileName}`, relativePath: path.posix.join(mediaRelativePrefix, 'voices', fileName),
kind: 'voice' kind: 'voice'
} }
} catch (e) { } catch (e) {
@@ -581,9 +594,14 @@ class ExportService {
/** /**
* 导出表情文件 * 导出表情文件
*/ */
private async exportEmoji(msg: any, sessionId: string, mediaDir: string): Promise<MediaExportItem | null> { private async exportEmoji(
msg: any,
sessionId: string,
mediaRootDir: string,
mediaRelativePrefix: string
): Promise<MediaExportItem | null> {
try { try {
const emojisDir = path.join(mediaDir, 'media', 'emojis') const emojisDir = path.join(mediaRootDir, mediaRelativePrefix, 'emojis')
if (!fs.existsSync(emojisDir)) { if (!fs.existsSync(emojisDir)) {
fs.mkdirSync(emojisDir, { recursive: true }) fs.mkdirSync(emojisDir, { recursive: true })
} }
@@ -612,7 +630,7 @@ class ExportService {
// 如果已存在则跳过 // 如果已存在则跳过
if (fs.existsSync(destPath)) { if (fs.existsSync(destPath)) {
return { return {
relativePath: `media/emojis/${fileName}`, relativePath: path.posix.join(mediaRelativePrefix, 'emojis', fileName),
kind: 'emoji' kind: 'emoji'
} }
} }
@@ -620,13 +638,13 @@ class ExportService {
// 下载表情 // 下载表情
if (emojiUrl) { if (emojiUrl) {
const downloaded = await this.downloadFile(emojiUrl, destPath) const downloaded = await this.downloadFile(emojiUrl, destPath)
if (downloaded) { if (downloaded) {
return { return {
relativePath: `media/emojis/${fileName}`, relativePath: path.posix.join(mediaRelativePrefix, 'emojis', fileName),
kind: 'emoji' kind: 'emoji'
} }
} else { } else {
} }
} }
return null return null
@@ -703,6 +721,22 @@ class ExportService {
return '.jpg' return '.jpg'
} }
private getMediaLayout(outputPath: string, options: ExportOptions): {
exportMediaEnabled: boolean
mediaRootDir: string
mediaRelativePrefix: string
} {
const exportMediaEnabled = options.exportMedia === true &&
Boolean(options.exportImages || options.exportVoices || options.exportEmojis)
const outputDir = path.dirname(outputPath)
const outputBaseName = path.basename(outputPath, path.extname(outputPath))
const useSharedMediaLayout = options.sessionLayout === 'shared'
const mediaRelativePrefix = useSharedMediaLayout
? path.posix.join('media', outputBaseName)
: 'media'
return { exportMediaEnabled, mediaRootDir: outputDir, mediaRelativePrefix }
}
/** /**
* 下载文件 * 下载文件
*/ */
@@ -1127,29 +1161,43 @@ class ExportService {
phase: 'exporting' phase: 'exporting'
}) })
const chatLabMessages: ChatLabMessage[] = [] const { exportMediaEnabled, mediaRootDir, mediaRelativePrefix } = this.getMediaLayout(outputPath, options)
for (const msg of allMessages) { const mediaCache = new Map<string, MediaExportItem | null>()
const memberInfo = collected.memberSet.get(msg.senderUsername)?.member || { const chatLabMessages: ChatLabMessage[] = []
platformId: msg.senderUsername, for (const msg of allMessages) {
accountName: msg.senderUsername, const memberInfo = collected.memberSet.get(msg.senderUsername)?.member || {
groupNickname: undefined platformId: msg.senderUsername,
} accountName: msg.senderUsername,
groupNickname: undefined
}
let content = this.parseMessageContent(msg.content, msg.localType) let content = this.parseMessageContent(msg.content, msg.localType)
// 如果是语音消息且开启了转文字 if (exportMediaEnabled) {
if (msg.localType === 34 && options.exportVoiceAsText) { const mediaKey = `${msg.localType}_${msg.localId}`
content = await this.transcribeVoice(sessionId, String(msg.localId)) 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
})
mediaCache.set(mediaKey, mediaItem)
}
}
if (msg.localType === 34 && options.exportVoiceAsText) {
// 如果是语音消息且开启了转文字
content = await this.transcribeVoice(sessionId, String(msg.localId))
}
chatLabMessages.push({ chatLabMessages.push({
sender: msg.senderUsername, sender: msg.senderUsername,
accountName: memberInfo.accountName, accountName: memberInfo.accountName,
groupNickname: memberInfo.groupNickname, groupNickname: memberInfo.groupNickname,
timestamp: msg.createTime, timestamp: msg.createTime,
type: this.convertMessageType(msg.localType, msg.content), type: this.convertMessageType(msg.localType, msg.content),
content: content content: content
}) })
} }
const avatarMap = options.exportAvatars const avatarMap = options.exportAvatars
? await this.exportAvatars( ? await this.exportAvatars(
@@ -1242,21 +1290,45 @@ class ExportService {
phase: 'preparing' phase: 'preparing'
}) })
const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange) const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange)
const allMessages: any[] = [] const { exportMediaEnabled, mediaRootDir, mediaRelativePrefix } = this.getMediaLayout(outputPath, options)
const mediaCache = new Map<string, MediaExportItem | null>()
const allMessages: any[] = []
for (const msg of collected.rows) { for (const msg of collected.rows) {
const senderInfo = await this.getContactInfo(msg.senderUsername) const senderInfo = await this.getContactInfo(msg.senderUsername)
const sourceMatch = /<msgsource>[\s\S]*?<\/msgsource>/i.exec(msg.content || '') const sourceMatch = /<msgsource>[\s\S]*?<\/msgsource>/i.exec(msg.content || '')
const source = sourceMatch ? sourceMatch[0] : '' const source = sourceMatch ? sourceMatch[0] : ''
allMessages.push({ let content = this.parseMessageContent(msg.content, msg.localType)
localId: allMessages.length + 1, let mediaItem: MediaExportItem | null = null
createTime: msg.createTime, if (exportMediaEnabled) {
const mediaKey = `${msg.localType}_${msg.localId}`
if (mediaCache.has(mediaKey)) {
mediaItem = mediaCache.get(mediaKey) || null
} else {
mediaItem = await this.exportMediaForMessage(msg, sessionId, mediaRootDir, mediaRelativePrefix, {
exportImages: options.exportImages,
exportVoices: options.exportVoices,
exportEmojis: options.exportEmojis,
exportVoiceAsText: options.exportVoiceAsText
})
mediaCache.set(mediaKey, mediaItem)
}
}
if (mediaItem) {
content = mediaItem.relativePath
} else if (msg.localType === 34 && options.exportVoiceAsText) {
content = await this.transcribeVoice(sessionId, String(msg.localId))
}
allMessages.push({
localId: allMessages.length + 1,
createTime: msg.createTime,
formattedTime: this.formatTimestamp(msg.createTime), formattedTime: this.formatTimestamp(msg.createTime),
type: this.getMessageTypeName(msg.localType), type: this.getMessageTypeName(msg.localType),
localType: msg.localType, localType: msg.localType,
content: this.parseMessageContent(msg.content, msg.localType), content,
isSend: msg.isSend ? 1 : 0, isSend: msg.isSend ? 1 : 0,
senderUsername: msg.senderUsername, senderUsername: msg.senderUsername,
senderDisplayName: senderInfo.displayName, senderDisplayName: senderInfo.displayName,
@@ -1379,8 +1451,9 @@ class ExportService {
let currentRow = 1 let currentRow = 1
const useCompactColumns = options.excelCompactColumns === true
// 第一行:会话信息标题 // 第一行:会话信息标题
worksheet.mergeCells(currentRow, 1, currentRow, 8)
const titleCell = worksheet.getCell(currentRow, 1) const titleCell = worksheet.getCell(currentRow, 1)
titleCell.value = '会话信息' titleCell.value = '会话信息'
titleCell.font = { name: 'Calibri', bold: true, size: 11 } titleCell.font = { name: 'Calibri', bold: true, size: 11 }
@@ -1436,7 +1509,9 @@ class ExportService {
currentRow++ currentRow++
// 表头行 // 表头行
const headers = ['序号', '时间', '发送者昵称', '发送者微信ID', '发送者备注', '发送者身份', '消息类型', '内容'] const headers = useCompactColumns
? ['序号', '时间', '发送者身份', '消息类型', '内容']
: ['序号', '时间', '发送者昵称', '发送者微信ID', '发送者备注', '发送者身份', '消息类型', '内容']
const headerRow = worksheet.getRow(currentRow) const headerRow = worksheet.getRow(currentRow)
headerRow.height = 22 headerRow.height = 22
@@ -1456,19 +1531,24 @@ class ExportService {
// 设置列宽 // 设置列宽
worksheet.getColumn(1).width = 8 // 序号 worksheet.getColumn(1).width = 8 // 序号
worksheet.getColumn(2).width = 20 // 时间 worksheet.getColumn(2).width = 20 // 时间
worksheet.getColumn(3).width = 18 // 发送者昵称 if (useCompactColumns) {
worksheet.getColumn(4).width = 25 // 发送者微信ID worksheet.getColumn(3).width = 18 // 发送者身份
worksheet.getColumn(5).width = 18 // 发送者备注 worksheet.getColumn(4).width = 12 // 消息类型
worksheet.getColumn(6).width = 15 // 发送者身份 worksheet.getColumn(5).width = 50 // 内容
worksheet.getColumn(7).width = 12 // 消息类型 } else {
worksheet.getColumn(8).width = 50 // 内容 worksheet.getColumn(3).width = 18 // 发送者昵称
worksheet.getColumn(4).width = 25 // 发送者微信ID
worksheet.getColumn(5).width = 18 // 发送者备注
worksheet.getColumn(6).width = 15 // 发送者身份
worksheet.getColumn(7).width = 12 // 消息类型
worksheet.getColumn(8).width = 50 // 内容
}
// 填充数据 // 填充数据
const sortedMessages = collected.rows.sort((a, b) => a.createTime - b.createTime) const sortedMessages = collected.rows.sort((a, b) => a.createTime - b.createTime)
// 媒体导出设置 // 媒体导出设置
const exportMediaEnabled = options.exportImages || options.exportVoices || options.exportEmojis const { exportMediaEnabled, mediaRootDir, mediaRelativePrefix } = this.getMediaLayout(outputPath, options)
const sessionDir = path.dirname(outputPath) // 会话目录,用于媒体导出
// 媒体导出缓存 // 媒体导出缓存
const mediaCache = new Map<string, MediaExportItem | null>() const mediaCache = new Map<string, MediaExportItem | null>()
@@ -1483,7 +1563,7 @@ class ExportService {
if (mediaCache.has(mediaKey)) { if (mediaCache.has(mediaKey)) {
mediaItem = mediaCache.get(mediaKey) || null mediaItem = mediaCache.get(mediaKey) || null
} else { } else {
mediaItem = await this.exportMediaForMessage(msg, sessionId, sessionDir, { mediaItem = await this.exportMediaForMessage(msg, sessionId, mediaRootDir, mediaRelativePrefix, {
exportImages: options.exportImages, exportImages: options.exportImages,
exportVoices: options.exportVoices, exportVoices: options.exportVoices,
exportEmojis: options.exportEmojis, exportEmojis: options.exportEmojis,
@@ -1541,9 +1621,12 @@ class ExportService {
row.height = 24 row.height = 24
// 确定内容:如果有媒体文件导出成功则显示相对路径,否则显示解析后的内容 // 确定内容:如果有媒体文件导出成功则显示相对路径,否则显示解析后的内容
const contentValue = mediaItem let contentValue = mediaItem
? mediaItem.relativePath ? mediaItem.relativePath
: (this.parseMessageContent(msg.content, msg.localType) || '') : (this.parseMessageContent(msg.content, msg.localType) || '')
if (!mediaItem && msg.localType === 34 && options.exportVoiceAsText) {
contentValue = await this.transcribeVoice(sessionId, String(msg.localId))
}
// 调试日志 // 调试日志
if (msg.localType === 3 || msg.localType === 47) { if (msg.localType === 3 || msg.localType === 47) {
@@ -1551,15 +1634,22 @@ class ExportService {
worksheet.getCell(currentRow, 1).value = i + 1 worksheet.getCell(currentRow, 1).value = i + 1
worksheet.getCell(currentRow, 2).value = this.formatTimestamp(msg.createTime) worksheet.getCell(currentRow, 2).value = this.formatTimestamp(msg.createTime)
worksheet.getCell(currentRow, 3).value = senderNickname if (useCompactColumns) {
worksheet.getCell(currentRow, 4).value = senderWxid worksheet.getCell(currentRow, 3).value = senderRole
worksheet.getCell(currentRow, 5).value = senderRemark worksheet.getCell(currentRow, 4).value = this.getMessageTypeName(msg.localType)
worksheet.getCell(currentRow, 6).value = senderRole worksheet.getCell(currentRow, 5).value = contentValue
worksheet.getCell(currentRow, 7).value = this.getMessageTypeName(msg.localType) } else {
worksheet.getCell(currentRow, 8).value = contentValue worksheet.getCell(currentRow, 3).value = senderNickname
worksheet.getCell(currentRow, 4).value = senderWxid
worksheet.getCell(currentRow, 5).value = senderRemark
worksheet.getCell(currentRow, 6).value = senderRole
worksheet.getCell(currentRow, 7).value = this.getMessageTypeName(msg.localType)
worksheet.getCell(currentRow, 8).value = contentValue
}
// 设置每个单元格的样式 // 设置每个单元格的样式
for (let col = 1; col <= 8; col++) { const maxColumns = useCompactColumns ? 5 : 8
for (let col = 1; col <= maxColumns; col++) {
const cell = worksheet.getCell(currentRow, col) const cell = worksheet.getCell(currentRow, col)
cell.font = { name: 'Calibri', size: 11 } cell.font = { name: 'Calibri', size: 11 }
cell.alignment = { vertical: 'middle', wrapText: false } cell.alignment = { vertical: 'middle', wrapText: false }
@@ -1631,9 +1721,15 @@ class ExportService {
fs.mkdirSync(outputDir, { recursive: true }) fs.mkdirSync(outputDir, { recursive: true })
} }
for (let i = 0; i < sessionIds.length; i++) { const exportMediaEnabled = options.exportMedia === true &&
const sessionId = sessionIds[i] Boolean(options.exportImages || options.exportVoices || options.exportEmojis)
const sessionInfo = await this.getContactInfo(sessionId) const sessionLayout = exportMediaEnabled
? (options.sessionLayout ?? 'per-session')
: 'shared'
for (let i = 0; i < sessionIds.length; i++) {
const sessionId = sessionIds[i]
const sessionInfo = await this.getContactInfo(sessionId)
onProgress?.({ onProgress?.({
current: i + 1, current: i + 1,
@@ -1642,13 +1738,13 @@ class ExportService {
phase: 'exporting' phase: 'exporting'
}) })
const safeName = sessionInfo.displayName.replace(/[<>:"/\\|?*]/g, '_') const safeName = sessionInfo.displayName.replace(/[<>:"/\\|?*]/g, '_')
const useSessionFolder = sessionLayout === 'per-session'
const sessionDir = useSessionFolder ? path.join(outputDir, safeName) : outputDir
// 为每个会话创建单独的文件夹 if (useSessionFolder && !fs.existsSync(sessionDir)) {
const sessionDir = path.join(outputDir, safeName) fs.mkdirSync(sessionDir, { recursive: true })
if (!fs.existsSync(sessionDir)) { }
fs.mkdirSync(sessionDir, { recursive: true })
}
let ext = '.json' let ext = '.json'
if (options.format === 'chatlab-jsonl') ext = '.jsonl' if (options.format === 'chatlab-jsonl') ext = '.jsonl'
@@ -1689,4 +1785,3 @@ class ExportService {
} }
export const exportService = new ExportService() export const exportService = new ExportService()

View File

@@ -68,14 +68,7 @@ export class ImageDecryptService {
const metaStr = meta ? ` ${JSON.stringify(meta)}` : '' const metaStr = meta ? ` ${JSON.stringify(meta)}` : ''
const logLine = `[${timestamp}] [ImageDecrypt] ${message}${metaStr}\n` const logLine = `[${timestamp}] [ImageDecrypt] ${message}${metaStr}\n`
// 同时输出到控制台 // 只写入文件,不输出到控制台
if (meta) {
console.info(message, meta)
} else {
console.info(message)
}
// 写入日志文件
this.writeLog(logLine) this.writeLog(logLine)
} }

View File

@@ -33,6 +33,7 @@ export class KeyService {
private ReadProcessMemory: any = null private ReadProcessMemory: any = null
private MEMORY_BASIC_INFORMATION: any = null private MEMORY_BASIC_INFORMATION: any = null
private TerminateProcess: any = null private TerminateProcess: any = null
private QueryFullProcessImageNameW: any = null
// User32 // User32
private EnumWindows: any = null private EnumWindows: any = null
@@ -194,6 +195,7 @@ export class KeyService {
this.OpenProcess = this.kernel32.func('OpenProcess', 'HANDLE', ['uint32', 'bool', 'uint32']) this.OpenProcess = this.kernel32.func('OpenProcess', 'HANDLE', ['uint32', 'bool', 'uint32'])
this.CloseHandle = this.kernel32.func('CloseHandle', 'bool', ['HANDLE']) this.CloseHandle = this.kernel32.func('CloseHandle', 'bool', ['HANDLE'])
this.TerminateProcess = this.kernel32.func('TerminateProcess', 'bool', ['HANDLE', 'uint32']) this.TerminateProcess = this.kernel32.func('TerminateProcess', 'bool', ['HANDLE', 'uint32'])
this.QueryFullProcessImageNameW = this.kernel32.func('QueryFullProcessImageNameW', 'bool', ['HANDLE', 'uint32', this.koffi.out('uint16*'), this.koffi.out('uint32*')])
this.VirtualQueryEx = this.kernel32.func('VirtualQueryEx', 'uint64', ['HANDLE', 'uint64', this.koffi.out(this.koffi.pointer(this.MEMORY_BASIC_INFORMATION)), 'uint64']) this.VirtualQueryEx = this.kernel32.func('VirtualQueryEx', 'uint64', ['HANDLE', 'uint64', this.koffi.out(this.koffi.pointer(this.MEMORY_BASIC_INFORMATION)), 'uint64'])
this.ReadProcessMemory = this.kernel32.func('ReadProcessMemory', 'bool', ['HANDLE', 'uint64', 'void*', 'uint64', this.koffi.out(this.koffi.pointer('uint64'))]) this.ReadProcessMemory = this.kernel32.func('ReadProcessMemory', 'bool', ['HANDLE', 'uint64', 'void*', 'uint64', this.koffi.out(this.koffi.pointer('uint64'))])
@@ -310,7 +312,46 @@ export class KeyService {
} }
} }
private async getProcessExecutablePath(pid: number): Promise<string | null> {
if (!this.ensureKernel32()) return null
// 0x1000 = PROCESS_QUERY_LIMITED_INFORMATION
const hProcess = this.OpenProcess(0x1000, false, pid)
if (!hProcess) return null
try {
const sizeBuf = Buffer.alloc(4)
sizeBuf.writeUInt32LE(1024, 0)
const pathBuf = Buffer.alloc(1024 * 2)
const ret = this.QueryFullProcessImageNameW(hProcess, 0, pathBuf, sizeBuf)
if (ret) {
const len = sizeBuf.readUInt32LE(0)
return pathBuf.toString('ucs2', 0, len * 2)
}
return null
} catch (e) {
console.error('获取进程路径失败:', e)
return null
} finally {
this.CloseHandle(hProcess)
}
}
private async findWeChatInstallPath(): Promise<string | null> { private async findWeChatInstallPath(): Promise<string | null> {
// 0. 优先尝试获取正在运行的微信进程路径
try {
const pid = await this.findWeChatPid()
if (pid) {
const runPath = await this.getProcessExecutablePath(pid)
if (runPath && existsSync(runPath)) {
console.log('发现正在运行的微信进程,使用路径:', runPath)
return runPath
}
}
} catch (e) {
console.error('尝试获取运行中微信路径失败:', e)
}
// 1. Registry - Uninstall Keys // 1. Registry - Uninstall Keys
const uninstallKeys = [ const uninstallKeys = [
'SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall', 'SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall',

View File

@@ -0,0 +1,256 @@
import { join } from 'path'
import { existsSync, readdirSync, statSync, readFileSync } from 'fs'
import { ConfigService } from './config'
import Database from 'better-sqlite3'
import { wcdbService } from './wcdbService'
export interface VideoInfo {
videoUrl?: string // 视频文件路径(用于 readFile
coverUrl?: string // 封面 data URL
thumbUrl?: string // 缩略图 data URL
exists: boolean
}
class VideoService {
private configService: ConfigService
constructor() {
this.configService = new ConfigService()
}
/**
* 获取数据库根目录
*/
private getDbPath(): string {
return this.configService.get('dbPath') || ''
}
/**
* 获取当前用户的wxid
*/
private getMyWxid(): string {
return this.configService.get('myWxid') || ''
}
/**
* 获取缓存目录(解密后的数据库存放位置)
*/
private getCachePath(): string {
return this.configService.get('cachePath') || ''
}
/**
* 清理 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 表查询视频文件名
* 优先使用 cachePath 中解密后的 hardlink.db使用 better-sqlite3
* 如果失败,则尝试使用 wcdbService.execQuery 查询加密的 hardlink.db
*/
private async queryVideoFileName(md5: string): Promise<string | undefined> {
const cachePath = this.getCachePath()
const dbPath = this.getDbPath()
const wxid = this.getMyWxid()
const cleanedWxid = this.cleanWxid(wxid)
if (!wxid) return undefined
// 方法1优先在 cachePath 下查找解密后的 hardlink.db
if (cachePath) {
const cacheDbPaths = [
join(cachePath, cleanedWxid, 'hardlink.db'),
join(cachePath, wxid, 'hardlink.db'),
join(cachePath, 'hardlink.db'),
join(cachePath, 'databases', cleanedWxid, 'hardlink.db'),
join(cachePath, 'databases', wxid, 'hardlink.db')
]
for (const p of cacheDbPaths) {
if (existsSync(p)) {
try {
const db = new Database(p, { readonly: true })
const row = db.prepare(`
SELECT file_name, md5 FROM video_hardlink_info_v4
WHERE md5 = ?
LIMIT 1
`).get(md5) as { file_name: string; md5: string } | undefined
db.close()
if (row?.file_name) {
const realMd5 = row.file_name.replace(/\.[^.]+$/, '')
return realMd5
}
} catch (e) {
// Silently fail
}
}
}
}
// 方法2使用 wcdbService.execQuery 查询加密的 hardlink.db
if (dbPath) {
const encryptedDbPaths = [
join(dbPath, wxid, 'db_storage', 'hardlink', 'hardlink.db'),
join(dbPath, cleanedWxid, 'db_storage', 'hardlink', 'hardlink.db')
]
for (const p of encryptedDbPaths) {
if (existsSync(p)) {
try {
const escapedMd5 = md5.replace(/'/g, "''")
// 用 md5 字段查询,获取 file_name
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) {
// 提取不带扩展名的文件名作为实际视频 MD5
const realMd5 = String(row.file_name).replace(/\.[^.]+$/, '')
return realMd5
}
}
} catch (e) {
}
}
}
}
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<VideoInfo> {
const dbPath = this.getDbPath()
const wxid = this.getMyWxid()
if (!dbPath || !wxid || !videoMd5) {
return { exists: false }
}
// 先尝试从数据库查询真正的视频文件名
const realVideoMd5 = await this.queryVideoFileName(videoMd5) || videoMd5
const videoBaseDir = join(dbPath, wxid, 'msg', 'video')
if (!existsSync(videoBaseDir)) {
return { exists: false }
}
// 遍历年月目录查找视频文件
try {
const allDirs = readdirSync(videoBaseDir)
// 支持多种目录格式: YYYY-MM, YYYYMM, 或其他
const yearMonthDirs = allDirs
.filter(dir => {
const dirPath = join(videoBaseDir, dir)
return statSync(dirPath).isDirectory()
})
.sort((a, b) => b.localeCompare(a)) // 从最新的目录开始查找
for (const yearMonth of yearMonthDirs) {
const dirPath = join(videoBaseDir, yearMonth)
const videoPath = join(dirPath, `${realVideoMd5}.mp4`)
const coverPath = join(dirPath, `${realVideoMd5}.jpg`)
const thumbPath = join(dirPath, `${realVideoMd5}_thumb.jpg`)
// 检查视频文件是否存在
if (existsSync(videoPath)) {
return {
videoUrl: videoPath, // 返回文件路径,前端通过 readFile 读取
coverUrl: this.fileToDataUrl(coverPath, 'image/jpeg'),
thumbUrl: this.fileToDataUrl(thumbPath, 'image/jpeg'),
exists: true
}
}
}
} catch (e) {
console.error('[VideoService] Error searching for video:', e)
}
return { exists: false }
}
/**
* 根据消息内容解析视频MD5
*/
parseVideoMd5(content: string): string | undefined {
// 打印前500字符看看 XML 结构
if (!content) return undefined
try {
// 提取所有可能的 md5 值进行日志
const allMd5s: string[] = []
const md5Regex = /(?:md5|rawmd5|newmd5|originsourcemd5)\s*=\s*['"]([a-fA-F0-9]+)['"]/gi
let match
while ((match = md5Regex.exec(content)) !== null) {
allMd5s.push(`${match[0]}`)
}
// 提取 md5用于查询 hardlink.db
// 注意:不是 rawmd5rawmd5 是另一个值
// 格式: md5="xxx" 或 <md5>xxx</md5>
// 尝试从videomsg标签中提取md5
const videoMsgMatch = /<videomsg[^>]*\smd5\s*=\s*['"]([a-fA-F0-9]+)['"]/i.exec(content)
if (videoMsgMatch) {
return videoMsgMatch[1].toLowerCase()
}
const attrMatch = /\smd5\s*=\s*['"]([a-fA-F0-9]+)['"]/i.exec(content)
if (attrMatch) {
return attrMatch[1].toLowerCase()
}
const md5Match = /<md5>([a-fA-F0-9]+)<\/md5>/i.exec(content)
if (md5Match) {
return md5Match[1].toLowerCase()
}
} catch (e) {
console.error('[VideoService] 解析视频MD5失败:', e)
}
return undefined
}
}
export const videoService = new VideoService()

View File

@@ -224,13 +224,16 @@ export class VoiceTranscribeService {
let finalTranscript = '' let finalTranscript = ''
worker.on('message', (msg: any) => { worker.on('message', (msg: any) => {
console.log('[VoiceTranscribe] Worker 消息:', msg)
if (msg.type === 'partial') { if (msg.type === 'partial') {
onPartial?.(msg.text) onPartial?.(msg.text)
} else if (msg.type === 'final') { } else if (msg.type === 'final') {
finalTranscript = msg.text finalTranscript = msg.text
console.log('[VoiceTranscribe] 最终文本:', finalTranscript)
resolve({ success: true, transcript: finalTranscript }) resolve({ success: true, transcript: finalTranscript })
worker.terminate() worker.terminate()
} else if (msg.type === 'error') { } else if (msg.type === 'error') {
console.error('[VoiceTranscribe] Worker 错误:', msg.error)
resolve({ success: false, error: msg.error }) resolve({ success: false, error: msg.error })
worker.terminate() worker.terminate()
} }

View File

@@ -1,6 +1,12 @@
import { join, dirname, basename } from 'path' import { join, dirname, basename } from 'path'
import { appendFileSync, existsSync, mkdirSync, readdirSync, statSync, readFileSync } from 'fs' import { appendFileSync, existsSync, mkdirSync, readdirSync, statSync, readFileSync } from 'fs'
// DLL 初始化错误信息,用于帮助用户诊断问题
let lastDllInitError: string | null = null
export function getLastDllInitError(): string | null {
return lastDllInitError
}
export class WcdbCore { export class WcdbCore {
private resourcesPath: string | null = null private resourcesPath: string | null = null
private userDataPath: string | null = null private userDataPath: string | null = null
@@ -110,7 +116,7 @@ export class WcdbCore {
private writeLog(message: string, force = false): void { private writeLog(message: string, force = false): void {
if (!force && !this.isLogEnabled()) return if (!force && !this.isLogEnabled()) return
const line = `[${new Date().toISOString()}] ${message}` const line = `[${new Date().toISOString()}] ${message}`
console.log(`[WCDB] ${line}`) // 移除控制台日志,只写入文件
try { try {
const base = this.userDataPath || process.env.WCDB_LOG_DIR || process.cwd() const base = this.userDataPath || process.env.WCDB_LOG_DIR || process.cwd()
const dir = join(base, 'logs') const dir = join(base, 'logs')
@@ -208,6 +214,31 @@ export class WcdbCore {
return false return false
} }
// 关键修复:显式预加载依赖库 WCDB.dll 和 SDL2.dll
// Windows 加载器默认不会查找子目录中的依赖,必须先将其加载到内存
// 这可以解决部分用户因为 VC++ 运行时或 DLL 依赖问题导致的闪退
const dllDir = dirname(dllPath)
const wcdbCorePath = join(dllDir, 'WCDB.dll')
if (existsSync(wcdbCorePath)) {
try {
this.koffi.load(wcdbCorePath)
this.writeLog('预加载 WCDB.dll 成功')
} catch (e) {
console.warn('预加载 WCDB.dll 失败(可能不是致命的):', e)
this.writeLog(`预加载 WCDB.dll 失败: ${String(e)}`)
}
}
const sdl2Path = join(dllDir, 'SDL2.dll')
if (existsSync(sdl2Path)) {
try {
this.koffi.load(sdl2Path)
this.writeLog('预加载 SDL2.dll 成功')
} catch (e) {
console.warn('预加载 SDL2.dll 失败(可能不是致命的):', e)
this.writeLog(`预加载 SDL2.dll 失败: ${String(e)}`)
}
}
this.lib = this.koffi.load(dllPath) this.lib = this.koffi.load(dllPath)
// 定义类型 // 定义类型
@@ -362,9 +393,20 @@ export class WcdbCore {
} }
this.initialized = true this.initialized = true
lastDllInitError = null
return true return true
} catch (e) { } catch (e) {
console.error('WCDB 初始化异常:', e) const errorMsg = e instanceof Error ? e.message : String(e)
console.error('WCDB 初始化异常:', errorMsg)
this.writeLog(`WCDB 初始化异常: ${errorMsg}`, true)
lastDllInitError = errorMsg
// 检查是否是常见的 VC++ 运行时缺失错误
if (errorMsg.includes('126') || errorMsg.includes('找不到指定的模块') ||
errorMsg.includes('The specified module could not be found')) {
lastDllInitError = '可能缺少 Visual C++ 运行时库。请安装 Microsoft Visual C++ Redistributable (x64)。'
} else if (errorMsg.includes('193') || errorMsg.includes('不是有效的 Win32 应用程序')) {
lastDllInitError = 'DLL 架构不匹配。请确保使用 64 位版本的应用程序。'
}
return false return false
} }
} }
@@ -382,10 +424,18 @@ export class WcdbCore {
return { success: true, sessionCount: 0 } return { success: true, sessionCount: 0 }
} }
// 记录当前活动连接,用于在测试结束后恢复(避免影响聊天页等正在使用的连接)
const hadActiveConnection = this.handle !== null
const prevPath = this.currentPath
const prevKey = this.currentKey
const prevWxid = this.currentWxid
if (!this.initialized) { if (!this.initialized) {
const initOk = await this.initialize() const initOk = await this.initialize()
if (!initOk) { if (!initOk) {
return { success: false, error: 'WCDB 初始化失败' } // 返回更详细的错误信息,帮助用户诊断问题
const detailedError = lastDllInitError || 'WCDB 初始化失败'
return { success: false, error: detailedError }
} }
} }
@@ -424,8 +474,8 @@ export class WcdbCore {
return { success: false, error: '无效的数据库句柄' } return { success: false, error: '无效的数据库句柄' }
} }
// 测试成功使用 shutdown 清理所有资源(包括测试句柄) // 测试成功使用 shutdown 清理资源(包括测试句柄)
// 这会中断当前活动连接,但 testConnection 本应该是独立测试 // 注意shutdown 会断开当前活动连接,因此需要在测试后尝试恢复之前的连接
try { try {
this.wcdbShutdown() this.wcdbShutdown()
this.handle = null this.handle = null
@@ -437,6 +487,15 @@ export class WcdbCore {
console.error('关闭测试数据库时出错:', closeErr) console.error('关闭测试数据库时出错:', closeErr)
} }
// 恢复测试前的连接(如果之前有活动连接)
if (hadActiveConnection && prevPath && prevKey && prevWxid) {
try {
await this.open(prevPath, prevKey, prevWxid)
} catch {
// 恢复失败则保持断开,由调用方处理
}
}
return { success: true, sessionCount: 0 } return { success: true, sessionCount: 0 }
} catch (e) { } catch (e) {
console.error('测试连接异常:', e) console.error('测试连接异常:', e)
@@ -620,7 +679,7 @@ export class WcdbCore {
try { try {
this.wcdbSetMyWxid(this.handle, wxid) this.wcdbSetMyWxid(this.handle, wxid)
} catch (e) { } catch (e) {
console.warn('设置 wxid 失败:', e) // 静默失败
} }
} }
if (this.isLogEnabled()) { if (this.isLogEnabled()) {
@@ -799,7 +858,6 @@ export class WcdbCore {
await new Promise(resolve => setImmediate(resolve)) await new Promise(resolve => setImmediate(resolve))
if (result !== 0 || !outPtr[0]) { if (result !== 0 || !outPtr[0]) {
console.warn(`[wcdbCore] getAvatarUrls DLL调用失败: result=${result}, usernames=${toFetch.length}`)
if (Object.keys(resultMap).length > 0) { if (Object.keys(resultMap).length > 0) {
return { success: true, map: resultMap, error: `获取头像失败: ${result}` } return { success: true, map: resultMap, error: `获取头像失败: ${result}` }
} }
@@ -807,25 +865,18 @@ export class WcdbCore {
} }
const jsonStr = this.decodeJsonPtr(outPtr[0]) const jsonStr = this.decodeJsonPtr(outPtr[0])
if (!jsonStr) { if (!jsonStr) {
console.error('[wcdbCore] getAvatarUrls 解析JSON失败')
return { success: false, error: '解析头像失败' } return { success: false, error: '解析头像失败' }
} }
const map = JSON.parse(jsonStr) as Record<string, string> const map = JSON.parse(jsonStr) as Record<string, string>
let successCount = 0
let emptyCount = 0
for (const username of toFetch) { for (const username of toFetch) {
const url = map[username] const url = map[username]
if (url && url.trim()) { if (url && url.trim()) {
resultMap[username] = url resultMap[username] = url
// 只缓存有效的URL // 只缓存有效的URL
this.avatarUrlCache.set(username, { url, updatedAt: now }) this.avatarUrlCache.set(username, { url, updatedAt: now })
successCount++
} else {
emptyCount++
// 不缓存空URL,下次可以重新尝试
} }
// 不缓存空URL,下次可以重新尝试
} }
console.log(`[wcdbCore] getAvatarUrls 成功: ${successCount}个, 空结果: ${emptyCount}个, 总请求: ${toFetch.length}`)
return { success: true, map: resultMap } return { success: true, map: resultMap }
} catch (e) { } catch (e) {
console.error('[wcdbCore] getAvatarUrls 异常:', e) console.error('[wcdbCore] getAvatarUrls 异常:', e)

View File

@@ -58,12 +58,24 @@ export class WcdbService {
}) })
this.worker.on('error', (err) => { this.worker.on('error', (err) => {
// Worker error // Worker 发生错误,需要 reject 所有 pending promises
console.error('WCDB Worker 错误:', err)
const errorMsg = err instanceof Error ? err.message : String(err)
for (const [id, p] of this.pending) {
p.reject(new Error(`Worker 错误: ${errorMsg}`))
}
this.pending.clear()
}) })
this.worker.on('exit', (code) => { this.worker.on('exit', (code) => {
// Worker 退出,需要 reject 所有 pending promises
if (code !== 0) { if (code !== 0) {
// Worker exited with error console.error('WCDB Worker 异常退出,退出码:', code)
const errorMsg = `Worker 异常退出 (退出码: ${code})。可能是 DLL 加载失败,请检查是否安装了 Visual C++ Redistributable。`
for (const [id, p] of this.pending) {
p.reject(new Error(errorMsg))
}
this.pending.clear()
} }
this.worker = null this.worker = null
}) })

6
package-lock.json generated
View File

@@ -8537,12 +8537,6 @@
"sherpa-onnx-win-x64": "^1.12.23" "sherpa-onnx-win-x64": "^1.12.23"
} }
}, },
"node_modules/sherpa-onnx-node/node_modules/sherpa-onnx-darwin-x64": {
"optional": true
},
"node_modules/sherpa-onnx-node/node_modules/sherpa-onnx-linux-arm64": {
"optional": true
},
"node_modules/sherpa-onnx-win-ia32": { "node_modules/sherpa-onnx-win-ia32": {
"version": "1.12.23", "version": "1.12.23",
"resolved": "https://registry.npmmirror.com/sherpa-onnx-win-ia32/-/sherpa-onnx-win-ia32-1.12.23.tgz", "resolved": "https://registry.npmmirror.com/sherpa-onnx-win-ia32/-/sherpa-onnx-win-ia32-1.12.23.tgz",

Binary file not shown.

View File

@@ -15,6 +15,7 @@ import GroupAnalyticsPage from './pages/GroupAnalyticsPage'
import DataManagementPage from './pages/DataManagementPage' import DataManagementPage from './pages/DataManagementPage'
import SettingsPage from './pages/SettingsPage' import SettingsPage from './pages/SettingsPage'
import ExportPage from './pages/ExportPage' import ExportPage from './pages/ExportPage'
import VideoWindow from './pages/VideoWindow'
import { useAppStore } from './stores/appStore' import { useAppStore } from './stores/appStore'
import { themes, useThemeStore, type ThemeId } from './stores/themeStore' import { themes, useThemeStore, type ThemeId } from './stores/themeStore'
@@ -29,6 +30,7 @@ function App() {
const { currentTheme, themeMode, setTheme, setThemeMode } = useThemeStore() const { currentTheme, themeMode, setTheme, setThemeMode } = useThemeStore()
const isAgreementWindow = location.pathname === '/agreement-window' const isAgreementWindow = location.pathname === '/agreement-window'
const isOnboardingWindow = location.pathname === '/onboarding-window' const isOnboardingWindow = location.pathname === '/onboarding-window'
const isVideoPlayerWindow = location.pathname === '/video-player-window'
const [themeHydrated, setThemeHydrated] = useState(false) const [themeHydrated, setThemeHydrated] = useState(false)
// 协议同意状态 // 协议同意状态
@@ -200,10 +202,22 @@ function App() {
} }
} else { } else {
console.log('自动连接失败:', result.error) console.log('自动连接失败:', result.error)
// 如果错误信息包含 VC++ 或 DLL 相关内容,不清除配置,只提示用户
// 其他错误可能需要重新配置
const errorMsg = result.error || ''
if (errorMsg.includes('Visual C++') ||
errorMsg.includes('DLL') ||
errorMsg.includes('Worker') ||
errorMsg.includes('126') ||
errorMsg.includes('模块')) {
console.warn('检测到可能的运行时依赖问题:', errorMsg)
// 不清除配置,让用户安装 VC++ 后重试
}
} }
} }
} catch (e) { } catch (e) {
console.error('自动连接出错:', e) console.error('自动连接出错:', e)
// 捕获异常但不清除配置,防止循环重新引导
} }
} }
@@ -219,6 +233,11 @@ function App() {
return <WelcomePage standalone /> return <WelcomePage standalone />
} }
// 独立视频播放窗口
if (isVideoPlayerWindow) {
return <VideoWindow />
}
// 主窗口 - 完整布局 // 主窗口 - 完整布局
return ( return (
<div className="app-container"> <div className="app-container">

View File

@@ -1,4 +1,4 @@
.chat-page { .chat-page {
display: flex; display: flex;
height: 100%; height: 100%;
gap: 16px; gap: 16px;
@@ -370,9 +370,23 @@
} }
.message-bubble { .message-bubble {
max-width: 65%; display: flex;
gap: 12px;
max-width: 80%;
margin-bottom: 4px;
align-items: flex-start;
.bubble-body {
display: flex;
flex-direction: column;
max-width: 100%;
min-width: 0; // 允许收缩
width: fit-content; // 让气泡宽度由内容决定
}
&.sent { &.sent {
flex-direction: row-reverse;
.bubble-content { .bubble-content {
background: var(--primary-gradient); background: var(--primary-gradient);
color: #fff; color: #fff;
@@ -382,6 +396,10 @@
line-height: 1.5; line-height: 1.5;
box-shadow: 0 2px 10px var(--primary-light); box-shadow: 0 2px 10px var(--primary-light);
} }
.bubble-body {
align-items: flex-end;
}
} }
&.received { &.received {
@@ -395,6 +413,10 @@
backdrop-filter: blur(10px); backdrop-filter: blur(10px);
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
} }
.bubble-body {
align-items: flex-start;
}
} }
&.system { &.system {
@@ -428,6 +450,11 @@
font-size: 12px; font-size: 12px;
color: var(--text-secondary); color: var(--text-secondary);
margin-bottom: 4px; margin-bottom: 4px;
// 防止名字撑开气泡宽度
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
} }
.quoted-message { .quoted-message {
@@ -790,6 +817,99 @@
} }
// 右侧消息区域 // 右侧消息区域
// ... (previous content) ...
// 链接卡片消息样式
.link-message {
cursor: pointer;
background: var(--card-bg);
border-radius: 8px;
overflow: hidden;
border: 1px solid var(--border-color);
transition: all 0.2s ease;
max-width: 300px;
margin-top: 4px;
&:hover {
background: var(--bg-hover);
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
}
.link-header {
display: flex;
align-items: flex-start;
padding: 12px;
gap: 12px;
}
.link-content {
flex: 1;
min-width: 0;
}
.link-title {
font-size: 14px;
font-weight: 500;
color: var(--text-primary);
margin-bottom: 4px;
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
line-height: 1.4;
}
.link-desc {
font-size: 12px;
color: var(--text-secondary);
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
line-height: 1.4;
opacity: 0.8;
}
.link-icon {
flex-shrink: 0;
width: 40px;
height: 40px;
background: var(--bg-tertiary);
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-secondary);
svg {
opacity: 0.8;
}
}
}
// 适配发送出去的消息中的链接卡片
.message-bubble.sent .link-message {
background: rgba(255, 255, 255, 0.1);
border-color: rgba(255, 255, 255, 0.2);
.link-title,
.link-desc {
color: #fff;
}
.link-icon {
background: rgba(255, 255, 255, 0.2);
color: #fff;
}
&:hover {
background: rgba(255, 255, 255, 0.2);
}
}
.message-area { .message-area {
flex: 1 1 70%; flex: 1 1 70%;
display: flex; display: flex;
@@ -1485,6 +1605,11 @@
font-size: 12px; font-size: 12px;
color: var(--text-tertiary); color: var(--text-tertiary);
margin-bottom: 4px; margin-bottom: 4px;
// 防止名字撑开气泡宽度
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
} }
// 引用消息样式 // 引用消息样式
@@ -1533,7 +1658,11 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
max-width: 100%; max-width: 100%;
min-width: 0; // 允许收缩
-webkit-app-region: no-drag; -webkit-app-region: no-drag;
// 让气泡宽度由内容决定,而不是被父容器撑开
width: fit-content;
} }
.bubble-content { .bubble-content {
@@ -1949,3 +2078,84 @@
height: 14px; height: 14px;
} }
} }
// 视频消息样式
.video-thumb-wrapper {
position: relative;
max-width: 300px;
min-width: 200px;
border-radius: 12px;
overflow: hidden;
cursor: pointer;
background: var(--bg-tertiary);
transition: transform 0.2s;
&:hover {
transform: scale(1.02);
.video-play-button {
opacity: 1;
transform: translate(-50%, -50%) scale(1.1);
}
}
.video-thumb {
width: 100%;
height: auto;
display: block;
}
.video-thumb-placeholder {
width: 100%;
aspect-ratio: 16/9;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg-hover);
color: var(--text-tertiary);
svg {
width: 32px;
height: 32px;
}
}
.video-play-button {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
opacity: 0.9;
transition: all 0.2s;
color: #fff;
filter: drop-shadow(0 2px 8px rgba(0, 0, 0, 0.5));
}
}
.video-placeholder,
.video-loading,
.video-unavailable {
min-width: 120px;
min-height: 80px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
padding: 16px;
border-radius: 12px;
background: var(--bg-tertiary);
color: var(--text-tertiary);
font-size: 13px;
svg {
width: 24px;
height: 24px;
}
}
.video-loading {
.spin {
animation: spin 1s linear infinite;
}
}

View File

@@ -1,5 +1,5 @@
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react' import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react'
import { Search, MessageSquare, AlertCircle, Loader2, RefreshCw, X, ChevronDown, Info, Calendar, Database, Hash, Play, Pause, Image as ImageIcon } from 'lucide-react' import { Search, MessageSquare, AlertCircle, Loader2, RefreshCw, X, ChevronDown, Info, Calendar, Database, Hash, Play, Pause, Image as ImageIcon, Link } from 'lucide-react'
import { createPortal } from 'react-dom' import { createPortal } from 'react-dom'
import { useChatStore } from '../stores/chatStore' import { useChatStore } from '../stores/chatStore'
import type { ChatSession, Message } from '../types/models' import type { ChatSession, Message } from '../types/models'
@@ -1343,6 +1343,7 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o
const isSystem = isSystemMessage(message.localType) const isSystem = isSystemMessage(message.localType)
const isEmoji = message.localType === 47 const isEmoji = message.localType === 47
const isImage = message.localType === 3 const isImage = message.localType === 3
const isVideo = message.localType === 43
const isVoice = message.localType === 34 const isVoice = message.localType === 34
const isSent = message.isSend === 1 const isSent = message.isSend === 1
const [senderAvatarUrl, setSenderAvatarUrl] = useState<string | undefined>(undefined) const [senderAvatarUrl, setSenderAvatarUrl] = useState<string | undefined>(undefined)
@@ -1371,6 +1372,56 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o
const [voiceWaveform, setVoiceWaveform] = useState<number[]>([]) const [voiceWaveform, setVoiceWaveform] = useState<number[]>([])
const voiceAutoDecryptTriggered = useRef(false) const voiceAutoDecryptTriggered = useRef(false)
// 视频相关状态
const [videoLoading, setVideoLoading] = useState(false)
const [videoInfo, setVideoInfo] = useState<{ videoUrl?: string; coverUrl?: string; thumbUrl?: string; exists: boolean } | null>(null)
const videoContainerRef = useRef<HTMLDivElement>(null)
const [isVideoVisible, setIsVideoVisible] = useState(false)
const [videoMd5, setVideoMd5] = useState<string | null>(null)
// 解析视频 MD5
useEffect(() => {
if (!isVideo) return
console.log('[Video Debug] Full message object:', JSON.stringify(message, null, 2))
console.log('[Video Debug] Message keys:', Object.keys(message))
console.log('[Video Debug] Message:', {
localId: message.localId,
localType: message.localType,
hasVideoMd5: !!message.videoMd5,
hasContent: !!message.content,
hasParsedContent: !!message.parsedContent,
hasRawContent: !!(message as any).rawContent,
contentPreview: message.content?.substring(0, 200),
parsedContentPreview: message.parsedContent?.substring(0, 200),
rawContentPreview: (message as any).rawContent?.substring(0, 200)
})
// 优先使用数据库中的 videoMd5
if (message.videoMd5) {
console.log('[Video Debug] Using videoMd5 from message:', message.videoMd5)
setVideoMd5(message.videoMd5)
return
}
// 尝试从多个可能的字段获取原始内容
const contentToUse = message.content || (message as any).rawContent || message.parsedContent
if (contentToUse) {
console.log('[Video Debug] Parsing MD5 from content, length:', contentToUse.length)
window.electronAPI.video.parseVideoMd5(contentToUse).then((result) => {
console.log('[Video Debug] Parse result:', result)
if (result && result.success && result.md5) {
console.log('[Video Debug] Parsed MD5:', result.md5)
setVideoMd5(result.md5)
} else {
console.error('[Video Debug] Failed to parse MD5:', result)
}
}).catch((err) => {
console.error('[Video Debug] Parse error:', err)
})
}
}, [isVideo, message.videoMd5, message.content, message.parsedContent])
// 加载自动转文字配置 // 加载自动转文字配置
useEffect(() => { useEffect(() => {
const loadConfig = async () => { const loadConfig = async () => {
@@ -1784,7 +1835,16 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o
throw error throw error
} }
const result = await window.electronAPI.chat.getVoiceTranscript(session.username, String(message.localId)) const result = await window.electronAPI.chat.getVoiceTranscript(
session.username,
String(message.localId),
message.createTime
)
console.log('[ChatPage] 调用转写:', {
sessionId: session.username,
msgId: message.localId,
createTime: message.createTime
})
if (result.success) { if (result.success) {
const transcriptText = (result.transcript || '').trim() const transcriptText = (result.transcript || '').trim()
voiceTranscriptCache.set(voiceTranscriptCacheKey, transcriptText) voiceTranscriptCache.set(voiceTranscriptCacheKey, transcriptText)
@@ -1829,6 +1889,62 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o
} }
}, [isVoice, message.localId, requestVoiceTranscript]) }, [isVoice, message.localId, requestVoiceTranscript])
// 视频懒加载
useEffect(() => {
if (!isVideo || !videoContainerRef.current) return
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
setIsVideoVisible(true)
observer.disconnect()
}
})
},
{
rootMargin: '200px 0px',
threshold: 0
}
)
observer.observe(videoContainerRef.current)
return () => observer.disconnect()
}, [isVideo])
// 加载视频信息
useEffect(() => {
if (!isVideo || !isVideoVisible || videoInfo || videoLoading) return
if (!videoMd5) {
console.log('[Video Debug] No videoMd5 available yet')
return
}
console.log('[Video Debug] Loading video info for MD5:', videoMd5)
setVideoLoading(true)
window.electronAPI.video.getVideoInfo(videoMd5).then((result) => {
console.log('[Video Debug] getVideoInfo result:', result)
if (result && result.success) {
setVideoInfo({
exists: result.exists,
videoUrl: result.videoUrl,
coverUrl: result.coverUrl,
thumbUrl: result.thumbUrl
})
} else {
console.error('[Video Debug] Video info failed:', result)
setVideoInfo({ exists: false })
}
}).catch((err) => {
console.error('[Video Debug] getVideoInfo error:', err)
setVideoInfo({ exists: false })
}).finally(() => {
setVideoLoading(false)
})
}, [isVideo, isVideoVisible, videoInfo, videoLoading, videoMd5])
// 根据设置决定是否自动转写 // 根据设置决定是否自动转写
const [autoTranscribeEnabled, setAutoTranscribeEnabled] = useState(false) const [autoTranscribeEnabled, setAutoTranscribeEnabled] = useState(false)
@@ -1856,6 +1972,10 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o
) )
} }
// 检测是否为链接卡片消息
const isLinkMessage = String(message.localType) === '21474836529' ||
(message.rawContent && (message.rawContent.includes('<appmsg') || message.rawContent.includes('&lt;appmsg'))) ||
(message.parsedContent && (message.parsedContent.includes('<appmsg') || message.parsedContent.includes('&lt;appmsg')))
const bubbleClass = isSent ? 'sent' : 'received' const bubbleClass = isSent ? 'sent' : 'received'
// 头像逻辑: // 头像逻辑:
@@ -1869,6 +1989,7 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o
? '我' ? '我'
: getAvatarLetter(isGroupChat ? (senderName || message.senderUsername || '?') : (session.displayName || session.username)) : getAvatarLetter(isGroupChat ? (senderName || message.senderUsername || '?') : (session.displayName || session.username))
// 是否有引用消息 // 是否有引用消息
const hasQuote = message.quotedContent && message.quotedContent.length > 0 const hasQuote = message.quotedContent && message.quotedContent.length > 0
@@ -1959,6 +2080,72 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o
) )
} }
// 视频消息
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 (
<div className="video-placeholder" ref={videoContainerRef}>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polygon points="23 7 16 12 23 17 23 7"></polygon>
<rect x="1" y="5" width="15" height="14" rx="2" ry="2"></rect>
</svg>
</div>
)
}
// 加载中
if (videoLoading) {
return (
<div className="video-loading" ref={videoContainerRef}>
<Loader2 size={20} className="spin" />
</div>
)
}
// 视频不存在
if (!videoInfo?.exists || !videoInfo.videoUrl) {
return (
<div className="video-unavailable" ref={videoContainerRef}>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polygon points="23 7 16 12 23 17 23 7"></polygon>
<rect x="1" y="5" width="15" height="14" rx="2" ry="2"></rect>
</svg>
<span></span>
</div>
)
}
// 默认显示缩略图,点击打开独立播放窗口
const thumbSrc = videoInfo.thumbUrl || videoInfo.coverUrl
return (
<div className="video-thumb-wrapper" ref={videoContainerRef} onClick={handlePlayVideo}>
{thumbSrc ? (
<img src={thumbSrc} alt="视频缩略图" className="video-thumb" />
) : (
<div className="video-thumb-placeholder">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polygon points="23 7 16 12 23 17 23 7"></polygon>
<rect x="1" y="5" width="15" height="14" rx="2" ry="2"></rect>
</svg>
</div>
)}
<div className="video-play-button">
<Play size={32} fill="white" />
</div>
</div>
)
}
if (isVoice) { if (isVoice) {
const durationText = message.voiceDurationSeconds ? `${message.voiceDurationSeconds}"` : '' const durationText = message.voiceDurationSeconds ? `${message.voiceDurationSeconds}"` : ''
const handleToggle = async () => { const handleToggle = async () => {
@@ -2157,6 +2344,10 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o
/> />
) )
} }
// 解析引用消息Links / App Messages
// localType: 21474836529 corresponds to AppMessage which often contains links
// 带引用的消息 // 带引用的消息
if (hasQuote) { if (hasQuote) {
return ( return (
@@ -2169,6 +2360,68 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o
</div> </div>
) )
} }
// 解析引用消息Links / App Messages
// localType: 21474836529 corresponds to AppMessage which often contains links
if (isLinkMessage) {
try {
// 清理内容:移除可能的 wxid 前缀,找到 XML 起始位置
let contentToParse = message.rawContent || message.parsedContent || '';
const xmlStartIndex = contentToParse.indexOf('<');
if (xmlStartIndex >= 0) {
contentToParse = contentToParse.substring(xmlStartIndex);
}
// 处理 HTML 转义字符
if (contentToParse.includes('&lt;')) {
contentToParse = contentToParse
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&amp;/g, '&')
.replace(/&quot;/g, '"')
.replace(/&apos;/g, "'");
}
const parser = new DOMParser();
const doc = parser.parseFromString(contentToParse, "text/xml");
const appMsg = doc.querySelector('appmsg');
if (appMsg) {
const title = doc.querySelector('title')?.textContent || '未命名链接';
const des = doc.querySelector('des')?.textContent || '无描述';
const url = doc.querySelector('url')?.textContent || '';
return (
<div
className="link-message"
onClick={(e) => {
e.stopPropagation();
if (url) {
// 优先使用 electron 接口打开外部浏览器
if (window.electronAPI?.shell?.openExternal) {
window.electronAPI.shell.openExternal(url);
} else {
window.open(url, '_blank');
}
}
}}
>
<div className="link-header">
<div className="link-content">
<div className="link-title" title={title}>{title}</div>
<div className="link-desc" title={des}>{des}</div>
</div>
<div className="link-icon">
<Link size={24} />
</div>
</div>
</div>
);
}
} catch (e) {
console.error('Failed to parse app message', e);
}
}
// 普通消息 // 普通消息
return <div className="bubble-content">{renderTextWithEmoji(cleanMessageContent(message.parsedContent))}</div> return <div className="bubble-content">{renderTextWithEmoji(cleanMessageContent(message.parsedContent))}</div>
} }

View File

@@ -602,6 +602,87 @@
} }
} }
.export-layout-modal {
background: var(--card-bg);
padding: 28px 32px;
border-radius: 16px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.25);
text-align: center;
width: min(520px, 90vw);
h3 {
font-size: 18px;
font-weight: 600;
color: var(--text-primary);
margin: 0 0 8px;
}
.layout-subtitle {
font-size: 14px;
color: var(--text-secondary);
margin: 0 0 20px;
}
.layout-options {
display: grid;
gap: 12px;
}
.layout-option-btn {
display: flex;
flex-direction: column;
gap: 6px;
padding: 14px 18px;
border-radius: 12px;
border: 1px solid var(--border-color);
background: var(--bg-secondary);
text-align: left;
cursor: pointer;
transition: all 0.2s;
&:hover {
border-color: var(--primary);
background: rgba(var(--primary-rgb), 0.08);
}
&.primary {
border-color: var(--primary);
background: rgba(var(--primary-rgb), 0.12);
}
.layout-title {
font-size: 15px;
font-weight: 600;
color: var(--text-primary);
}
.layout-desc {
font-size: 12px;
color: var(--text-tertiary);
}
}
.layout-actions {
margin-top: 18px;
display: flex;
justify-content: center;
}
.layout-cancel-btn {
padding: 8px 20px;
border-radius: 8px;
border: 1px solid var(--border-color);
background: var(--bg-secondary);
color: var(--text-primary);
cursor: pointer;
transition: all 0.2s;
&:hover {
background: var(--bg-hover);
}
}
}
.export-result-modal { .export-result-modal {
background: var(--card-bg); background: var(--card-bg);
padding: 32px 40px; padding: 32px 40px;

View File

@@ -21,6 +21,7 @@ interface ExportOptions {
exportVoices: boolean exportVoices: boolean
exportEmojis: boolean exportEmojis: boolean
exportVoiceAsText: boolean exportVoiceAsText: boolean
excelCompactColumns: boolean
} }
interface ExportResult { interface ExportResult {
@@ -30,6 +31,8 @@ interface ExportResult {
error?: string error?: string
} }
type SessionLayout = 'shared' | 'per-session'
function ExportPage() { function ExportPage() {
const [sessions, setSessions] = useState<ChatSession[]>([]) const [sessions, setSessions] = useState<ChatSession[]>([])
const [filteredSessions, setFilteredSessions] = useState<ChatSession[]>([]) const [filteredSessions, setFilteredSessions] = useState<ChatSession[]>([])
@@ -43,22 +46,43 @@ function ExportPage() {
const [showDatePicker, setShowDatePicker] = useState(false) const [showDatePicker, setShowDatePicker] = useState(false)
const [calendarDate, setCalendarDate] = useState(new Date()) const [calendarDate, setCalendarDate] = useState(new Date())
const [selectingStart, setSelectingStart] = useState(true) const [selectingStart, setSelectingStart] = useState(true)
const [showMediaLayoutPrompt, setShowMediaLayoutPrompt] = useState(false)
const [options, setOptions] = useState<ExportOptions>({ const [options, setOptions] = useState<ExportOptions>({
format: 'chatlab', format: 'excel',
dateRange: { dateRange: {
start: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000), start: new Date(new Date().setHours(0, 0, 0, 0)),
end: new Date() end: new Date()
}, },
useAllTime: true, useAllTime: false,
exportAvatars: true, exportAvatars: true,
exportMedia: false, exportMedia: false,
exportImages: true, exportImages: true,
exportVoices: true, exportVoices: true,
exportEmojis: true, exportEmojis: true,
exportVoiceAsText: false exportVoiceAsText: true,
excelCompactColumns: true
}) })
const buildDateRangeFromPreset = (preset: string) => {
const now = new Date()
if (preset === 'all') {
return { useAllTime: true, dateRange: { start: now, end: now } }
}
let rangeMs = 0
if (preset === '7d') rangeMs = 7 * 24 * 60 * 60 * 1000
if (preset === '30d') rangeMs = 30 * 24 * 60 * 60 * 1000
if (preset === '90d') rangeMs = 90 * 24 * 60 * 60 * 1000
if (preset === 'today' || rangeMs === 0) {
const start = new Date(now)
start.setHours(0, 0, 0, 0)
return { useAllTime: false, dateRange: { start, end: now } }
}
const start = new Date(now.getTime() - rangeMs)
start.setHours(0, 0, 0, 0)
return { useAllTime: false, dateRange: { start, end: now } }
}
const loadSessions = useCallback(async () => { const loadSessions = useCallback(async () => {
setIsLoading(true) setIsLoading(true)
try { try {
@@ -94,10 +118,57 @@ function ExportPage() {
} }
}, []) }, [])
const loadExportDefaults = useCallback(async () => {
try {
const [
savedFormat,
savedRange,
savedMedia,
savedVoiceAsText,
savedExcelCompactColumns
] = await Promise.all([
configService.getExportDefaultFormat(),
configService.getExportDefaultDateRange(),
configService.getExportDefaultMedia(),
configService.getExportDefaultVoiceAsText(),
configService.getExportDefaultExcelCompactColumns()
])
const preset = savedRange || 'today'
const rangeDefaults = buildDateRangeFromPreset(preset)
setOptions((prev) => ({
...prev,
format: (savedFormat as ExportOptions['format']) || 'excel',
useAllTime: rangeDefaults.useAllTime,
dateRange: rangeDefaults.dateRange,
exportMedia: savedMedia ?? false,
exportVoiceAsText: savedVoiceAsText ?? true,
excelCompactColumns: savedExcelCompactColumns ?? true
}))
} catch (e) {
console.error('加载导出默认设置失败:', e)
}
}, [])
useEffect(() => { useEffect(() => {
loadSessions() loadSessions()
loadExportPath() loadExportPath()
}, [loadSessions, loadExportPath]) loadExportDefaults()
}, [loadSessions, loadExportPath, loadExportDefaults])
useEffect(() => {
const removeListener = window.electronAPI.export.onProgress?.((payload) => {
setExportProgress({
current: payload.current,
total: payload.total,
currentName: payload.currentSession
})
})
return () => {
removeListener?.()
}
}, [])
useEffect(() => { useEffect(() => {
if (!searchKeyword.trim()) { if (!searchKeyword.trim()) {
@@ -144,7 +215,7 @@ function ExportPage() {
} }
} }
const startExport = async () => { const runExport = async (sessionLayout: SessionLayout) => {
if (selectedSessions.size === 0 || !exportFolder) return if (selectedSessions.size === 0 || !exportFolder) return
setIsExporting(true) setIsExporting(true)
@@ -160,10 +231,12 @@ function ExportPage() {
exportImages: options.exportMedia && options.exportImages, exportImages: options.exportMedia && options.exportImages,
exportVoices: options.exportMedia && options.exportVoices, exportVoices: options.exportMedia && options.exportVoices,
exportEmojis: options.exportMedia && options.exportEmojis, exportEmojis: options.exportMedia && options.exportEmojis,
exportVoiceAsText: options.exportVoiceAsText, // 独立于 exportMedia exportVoiceAsText: options.exportVoiceAsText, // ?????????exportMedia
excelCompactColumns: options.excelCompactColumns,
sessionLayout,
dateRange: options.useAllTime ? null : options.dateRange ? { dateRange: options.useAllTime ? null : options.dateRange ? {
start: Math.floor(options.dateRange.start.getTime() / 1000), start: Math.floor(options.dateRange.start.getTime() / 1000),
// 将结束日期设置为当天的 23:59:59,以包含当天的所有消息 // ?????????????????????????????????23:59:59,??????????????????????????????
end: Math.floor(new Date(options.dateRange.end.getFullYear(), options.dateRange.end.getMonth(), options.dateRange.end.getDate(), 23, 59, 59).getTime() / 1000) end: Math.floor(new Date(options.dateRange.end.getFullYear(), options.dateRange.end.getMonth(), options.dateRange.end.getDate(), 23, 59, 59).getTime() / 1000)
} : null } : null
} }
@@ -176,16 +249,28 @@ function ExportPage() {
) )
setExportResult(result) setExportResult(result)
} else { } else {
setExportResult({ success: false, error: `${options.format.toUpperCase()} 格式导出功能开发中...` }) setExportResult({ success: false, error: `${options.format.toUpperCase()} ???????????????????????????...` })
} }
} catch (e) { } catch (e) {
console.error('导出失败:', e) console.error('????????????:', e)
setExportResult({ success: false, error: String(e) }) setExportResult({ success: false, error: String(e) })
} finally { } finally {
setIsExporting(false) setIsExporting(false)
} }
} }
const startExport = () => {
if (selectedSessions.size === 0 || !exportFolder) return
if (options.exportMedia && selectedSessions.size > 1) {
setShowMediaLayoutPrompt(true)
return
}
const layout: SessionLayout = options.exportMedia ? 'per-session' : 'shared'
runExport(layout)
}
const getDaysInMonth = (date: Date) => { const getDaysInMonth = (date: Date) => {
const year = date.getFullYear() const year = date.getFullYear()
const month = date.getMonth() const month = date.getMonth()
@@ -544,6 +629,43 @@ function ExportPage() {
</div> </div>
</div> </div>
{/* 媒体导出布局选择弹窗 */}
{showMediaLayoutPrompt && (
<div className="export-overlay" onClick={() => setShowMediaLayoutPrompt(false)}>
<div className="export-layout-modal" onClick={e => e.stopPropagation()}>
<h3></h3>
<p className="layout-subtitle"></p>
<div className="layout-options">
<button
className="layout-option-btn primary"
onClick={() => {
setShowMediaLayoutPrompt(false)
runExport('shared')
}}
>
<span className="layout-title"></span>
<span className="layout-desc"> media </span>
</button>
<button
className="layout-option-btn"
onClick={() => {
setShowMediaLayoutPrompt(false)
runExport('per-session')
}}
>
<span className="layout-title"></span>
<span className="layout-desc"></span>
</button>
</div>
<div className="layout-actions">
<button className="layout-cancel-btn" onClick={() => setShowMediaLayoutPrompt(false)}>
</button>
</div>
</div>
</div>
)}
{/* 导出进度弹窗 */} {/* 导出进度弹窗 */}
{isExporting && ( {isExporting && (
<div className="export-overlay"> <div className="export-overlay">

View File

@@ -221,6 +221,100 @@
} }
} }
.select-field {
position: relative;
margin-bottom: 10px;
}
.select-trigger {
width: 100%;
padding: 10px 16px;
border: 1px solid var(--border-color);
border-radius: 9999px;
font-size: 14px;
background: var(--bg-primary);
color: var(--text-primary);
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
cursor: pointer;
transition: all 0.2s;
&:hover {
border-color: var(--text-tertiary);
}
&.open {
border-color: var(--primary);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 15%, transparent);
}
}
.select-value {
flex: 1;
min-width: 0;
text-align: left;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.select-dropdown {
position: absolute;
top: calc(100% + 6px);
left: 0;
right: 0;
background: color-mix(in srgb, var(--bg-primary) 85%, var(--bg-secondary));
border: 1px solid var(--border-color);
border-radius: 12px;
padding: 6px;
box-shadow: var(--shadow-md);
z-index: 20;
max-height: 320px;
overflow-y: auto;
backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(14px);
}
.select-option {
width: 100%;
text-align: left;
display: flex;
flex-direction: column;
gap: 4px;
padding: 10px 12px;
border: none;
border-radius: 10px;
background: transparent;
cursor: pointer;
transition: all 0.15s;
color: var(--text-primary);
font-size: 14px;
&:hover {
background: var(--bg-tertiary);
}
&.active {
background: color-mix(in srgb, var(--primary) 12%, transparent);
color: var(--primary);
}
}
.option-label {
font-weight: 500;
}
.option-desc {
font-size: 12px;
color: var(--text-tertiary);
}
.select-option.active .option-desc {
color: var(--primary);
}
.input-with-toggle { .input-with-toggle {
position: relative; position: relative;
display: flex; display: flex;
@@ -1096,13 +1190,15 @@
left: 0; left: 0;
right: 0; right: 0;
margin-top: 4px; margin-top: 4px;
background: var(--bg-secondary); background: color-mix(in srgb, var(--bg-primary) 85%, var(--bg-secondary));
border: 1px solid var(--border-primary); border: 1px solid var(--border-primary);
border-radius: 8px; border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
z-index: 100; z-index: 100;
max-height: 200px; max-height: 200px;
overflow-y: auto; overflow-y: auto;
backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(14px);
} }
.wxid-option { .wxid-option {

View File

@@ -1,4 +1,4 @@
import { useState, useEffect, useRef } from 'react' import { useState, useEffect, useRef } from 'react'
import { useAppStore } from '../stores/appStore' import { useAppStore } from '../stores/appStore'
import { useThemeStore, themes } from '../stores/themeStore' import { useThemeStore, themes } from '../stores/themeStore'
import { useAnalyticsStore } from '../stores/analyticsStore' import { useAnalyticsStore } from '../stores/analyticsStore'
@@ -11,12 +11,13 @@ import {
} from 'lucide-react' } from 'lucide-react'
import './SettingsPage.scss' import './SettingsPage.scss'
type SettingsTab = 'appearance' | 'database' | 'whisper' | 'cache' | 'about' type SettingsTab = 'appearance' | 'database' | 'whisper' | 'export' | 'cache' | 'about'
const tabs: { id: SettingsTab; label: string; icon: React.ElementType }[] = [ const tabs: { id: SettingsTab; label: string; icon: React.ElementType }[] = [
{ id: 'appearance', label: '外观', icon: Palette }, { id: 'appearance', label: '外观', icon: Palette },
{ id: 'database', label: '数据库连接', icon: Database }, { id: 'database', label: '数据库连接', icon: Database },
{ id: 'whisper', label: '语音识别模型', icon: Mic }, { id: 'whisper', label: '语音识别模型', icon: Mic },
{ id: 'export', label: '导出', icon: Download },
{ id: 'cache', label: '缓存', icon: HardDrive }, { id: 'cache', label: '缓存', icon: HardDrive },
{ id: 'about', label: '关于', icon: Info } { id: 'about', label: '关于', icon: Info }
] ]
@@ -40,6 +41,12 @@ function SettingsPage() {
const [wxidOptions, setWxidOptions] = useState<WxidOption[]>([]) const [wxidOptions, setWxidOptions] = useState<WxidOption[]>([])
const [showWxidSelect, setShowWxidSelect] = useState(false) const [showWxidSelect, setShowWxidSelect] = useState(false)
const wxidDropdownRef = useRef<HTMLDivElement>(null) const wxidDropdownRef = useRef<HTMLDivElement>(null)
const [showExportFormatSelect, setShowExportFormatSelect] = useState(false)
const [showExportDateRangeSelect, setShowExportDateRangeSelect] = useState(false)
const [showExportExcelColumnsSelect, setShowExportExcelColumnsSelect] = useState(false)
const exportFormatDropdownRef = useRef<HTMLDivElement>(null)
const exportDateRangeDropdownRef = useRef<HTMLDivElement>(null)
const exportExcelColumnsDropdownRef = useRef<HTMLDivElement>(null)
const [cachePath, setCachePath] = useState('') const [cachePath, setCachePath] = useState('')
const [logEnabled, setLogEnabled] = useState(false) const [logEnabled, setLogEnabled] = useState(false)
const [whisperModelName, setWhisperModelName] = useState('base') const [whisperModelName, setWhisperModelName] = useState('base')
@@ -49,6 +56,11 @@ function SettingsPage() {
const [whisperModelStatus, setWhisperModelStatus] = useState<{ exists: boolean; modelPath?: string; tokensPath?: string } | null>(null) const [whisperModelStatus, setWhisperModelStatus] = useState<{ exists: boolean; modelPath?: string; tokensPath?: string } | null>(null)
const [autoTranscribeVoice, setAutoTranscribeVoice] = useState(false) const [autoTranscribeVoice, setAutoTranscribeVoice] = useState(false)
const [transcribeLanguages, setTranscribeLanguages] = useState<string[]>(['zh']) const [transcribeLanguages, setTranscribeLanguages] = useState<string[]>(['zh'])
const [exportDefaultFormat, setExportDefaultFormat] = useState('excel')
const [exportDefaultDateRange, setExportDefaultDateRange] = useState('today')
const [exportDefaultMedia, setExportDefaultMedia] = useState(false)
const [exportDefaultVoiceAsText, setExportDefaultVoiceAsText] = useState(true)
const [exportDefaultExcelCompactColumns, setExportDefaultExcelCompactColumns] = useState(true)
const [isLoading, setIsLoadingState] = useState(false) const [isLoading, setIsLoadingState] = useState(false)
const [isTesting, setIsTesting] = useState(false) const [isTesting, setIsTesting] = useState(false)
@@ -79,13 +91,23 @@ function SettingsPage() {
// 点击外部关闭下拉框 // 点击外部关闭下拉框
useEffect(() => { useEffect(() => {
const handleClickOutside = (e: MouseEvent) => { const handleClickOutside = (e: MouseEvent) => {
if (showWxidSelect && wxidDropdownRef.current && !wxidDropdownRef.current.contains(e.target as Node)) { const target = e.target as Node
if (showWxidSelect && wxidDropdownRef.current && !wxidDropdownRef.current.contains(target)) {
setShowWxidSelect(false) setShowWxidSelect(false)
} }
if (showExportFormatSelect && exportFormatDropdownRef.current && !exportFormatDropdownRef.current.contains(target)) {
setShowExportFormatSelect(false)
}
if (showExportDateRangeSelect && exportDateRangeDropdownRef.current && !exportDateRangeDropdownRef.current.contains(target)) {
setShowExportDateRangeSelect(false)
}
if (showExportExcelColumnsSelect && exportExcelColumnsDropdownRef.current && !exportExcelColumnsDropdownRef.current.contains(target)) {
setShowExportExcelColumnsSelect(false)
}
} }
document.addEventListener('mousedown', handleClickOutside) document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside) return () => document.removeEventListener('mousedown', handleClickOutside)
}, [showWxidSelect]) }, [showWxidSelect, showExportFormatSelect, showExportDateRangeSelect, showExportExcelColumnsSelect])
useEffect(() => { useEffect(() => {
const removeDb = window.electronAPI.key.onDbKeyStatus((payload) => { const removeDb = window.electronAPI.key.onDbKeyStatus((payload) => {
@@ -114,6 +136,11 @@ function SettingsPage() {
const savedWhisperModelDir = await configService.getWhisperModelDir() const savedWhisperModelDir = await configService.getWhisperModelDir()
const savedAutoTranscribe = await configService.getAutoTranscribeVoice() const savedAutoTranscribe = await configService.getAutoTranscribeVoice()
const savedTranscribeLanguages = await configService.getTranscribeLanguages() const savedTranscribeLanguages = await configService.getTranscribeLanguages()
const savedExportDefaultFormat = await configService.getExportDefaultFormat()
const savedExportDefaultDateRange = await configService.getExportDefaultDateRange()
const savedExportDefaultMedia = await configService.getExportDefaultMedia()
const savedExportDefaultVoiceAsText = await configService.getExportDefaultVoiceAsText()
const savedExportDefaultExcelCompactColumns = await configService.getExportDefaultExcelCompactColumns()
if (savedKey) setDecryptKey(savedKey) if (savedKey) setDecryptKey(savedKey)
if (savedPath) setDbPath(savedPath) if (savedPath) setDbPath(savedPath)
@@ -126,6 +153,11 @@ function SettingsPage() {
setLogEnabled(savedLogEnabled) setLogEnabled(savedLogEnabled)
setAutoTranscribeVoice(savedAutoTranscribe) setAutoTranscribeVoice(savedAutoTranscribe)
setTranscribeLanguages(savedTranscribeLanguages) setTranscribeLanguages(savedTranscribeLanguages)
setExportDefaultFormat(savedExportDefaultFormat || 'excel')
setExportDefaultDateRange(savedExportDefaultDateRange || 'today')
setExportDefaultMedia(savedExportDefaultMedia ?? false)
setExportDefaultVoiceAsText(savedExportDefaultVoiceAsText ?? true)
setExportDefaultExcelCompactColumns(savedExportDefaultExcelCompactColumns ?? true)
// 如果语言列表为空,保存默认值 // 如果语言列表为空,保存默认值
if (!savedTranscribeLanguages || savedTranscribeLanguages.length === 0) { if (!savedTranscribeLanguages || savedTranscribeLanguages.length === 0) {
@@ -468,15 +500,8 @@ function SettingsPage() {
await configService.setTranscribeLanguages(transcribeLanguages) await configService.setTranscribeLanguages(transcribeLanguages)
await configService.setOnboardingDone(true) await configService.setOnboardingDone(true)
showMessage('配置保存成功,正在测试连接...', true) // 保存按钮只负责持久化配置,不做连接测试/重连,避免影响聊天页的活动连接
const result = await window.electronAPI.wcdb.testConnection(dbPath, decryptKey, wxid) showMessage('配置保存成功', true)
if (result.success) {
setDbConnected(true, dbPath)
showMessage('配置保存成功!数据库连接正常', true)
} else {
showMessage(result.error || '数据库连接失败,请检查配置', false)
}
} catch (e) { } catch (e) {
showMessage(`保存配置失败: ${e}`, false) showMessage(`保存配置失败: ${e}`, false)
} finally { } finally {
@@ -853,6 +878,205 @@ function SettingsPage() {
</div> </div>
</div> </div>
) )
const exportFormatOptions = [
{ value: 'excel', label: 'Excel', desc: '电子表格,适合统计分析' },
{ value: 'chatlab', label: 'ChatLab', desc: '标准格式,支持其他软件导入' },
{ value: 'chatlab-jsonl', label: 'ChatLab JSONL', desc: '流式格式,适合大量消息' },
{ value: 'json', label: 'JSON', desc: '详细格式,包含完整消息信息' },
{ value: 'html', label: 'HTML', desc: '网页格式,可直接浏览' },
{ value: 'txt', label: 'TXT', desc: '纯文本,通用格式' },
{ value: 'sql', label: 'PostgreSQL', desc: '数据库脚本,便于导入到数据库' }
]
const exportDateRangeOptions = [
{ value: 'today', label: '今天' },
{ value: '7d', label: '最近7天' },
{ value: '30d', label: '最近30天' },
{ value: '90d', label: '最近90天' },
{ value: 'all', label: '全部时间' }
]
const exportExcelColumnOptions = [
{ value: 'compact', label: '精简列', desc: '序号、时间、发送者身份、消息类型、内容' },
{ value: 'full', label: '完整列', desc: '含发送者昵称/微信ID/备注' }
]
const getOptionLabel = (options: { value: string; label: string }[], value: string) => {
return options.find((option) => option.value === value)?.label ?? value
}
const renderExportTab = () => {
const exportExcelColumnsValue = exportDefaultExcelCompactColumns ? 'compact' : 'full'
const exportFormatLabel = getOptionLabel(exportFormatOptions, exportDefaultFormat)
const exportDateRangeLabel = getOptionLabel(exportDateRangeOptions, exportDefaultDateRange)
const exportExcelColumnsLabel = getOptionLabel(exportExcelColumnOptions, exportExcelColumnsValue)
return (
<div className="tab-content">
<div className="form-group">
<label></label>
<span className="form-hint"></span>
<div className="select-field" ref={exportFormatDropdownRef}>
<button
type="button"
className={`select-trigger ${showExportFormatSelect ? 'open' : ''}`}
onClick={() => {
setShowExportFormatSelect(!showExportFormatSelect)
setShowExportDateRangeSelect(false)
setShowExportExcelColumnsSelect(false)
}}
>
<span className="select-value">{exportFormatLabel}</span>
<ChevronDown size={16} />
</button>
{showExportFormatSelect && (
<div className="select-dropdown">
{exportFormatOptions.map((option) => (
<button
key={option.value}
type="button"
className={`select-option ${exportDefaultFormat === option.value ? 'active' : ''}`}
onClick={async () => {
setExportDefaultFormat(option.value)
await configService.setExportDefaultFormat(option.value)
showMessage('已更新导出格式默认值', true)
setShowExportFormatSelect(false)
}}
>
<span className="option-label">{option.label}</span>
{option.desc && <span className="option-desc">{option.desc}</span>}
</button>
))}
</div>
)}
</div>
</div>
<div className="form-group">
<label></label>
<span className="form-hint"></span>
<div className="select-field" ref={exportDateRangeDropdownRef}>
<button
type="button"
className={`select-trigger ${showExportDateRangeSelect ? 'open' : ''}`}
onClick={() => {
setShowExportDateRangeSelect(!showExportDateRangeSelect)
setShowExportFormatSelect(false)
setShowExportExcelColumnsSelect(false)
}}
>
<span className="select-value">{exportDateRangeLabel}</span>
<ChevronDown size={16} />
</button>
{showExportDateRangeSelect && (
<div className="select-dropdown">
{exportDateRangeOptions.map((option) => (
<button
key={option.value}
type="button"
className={`select-option ${exportDefaultDateRange === option.value ? 'active' : ''}`}
onClick={async () => {
setExportDefaultDateRange(option.value)
await configService.setExportDefaultDateRange(option.value)
showMessage('已更新默认导出时间范围', true)
setShowExportDateRangeSelect(false)
}}
>
<span className="option-label">{option.label}</span>
</button>
))}
</div>
)}
</div>
</div>
<div className="form-group">
<label></label>
<span className="form-hint">//</span>
<div className="log-toggle-line">
<span className="log-status">{exportDefaultMedia ? '已开启' : '已关闭'}</span>
<label className="switch" htmlFor="export-default-media">
<input
id="export-default-media"
className="switch-input"
type="checkbox"
checked={exportDefaultMedia}
onChange={async (e) => {
const enabled = e.target.checked
setExportDefaultMedia(enabled)
await configService.setExportDefaultMedia(enabled)
showMessage(enabled ? '已开启默认媒体导出' : '已关闭默认媒体导出', true)
}}
/>
<span className="switch-slider" />
</label>
</div>
</div>
<div className="form-group">
<label></label>
<span className="form-hint"></span>
<div className="log-toggle-line">
<span className="log-status">{exportDefaultVoiceAsText ? '已开启' : '已关闭'}</span>
<label className="switch" htmlFor="export-default-voice-as-text">
<input
id="export-default-voice-as-text"
className="switch-input"
type="checkbox"
checked={exportDefaultVoiceAsText}
onChange={async (e) => {
const enabled = e.target.checked
setExportDefaultVoiceAsText(enabled)
await configService.setExportDefaultVoiceAsText(enabled)
showMessage(enabled ? '已开启默认语音转文字' : '已关闭默认语音转文字', true)
}}
/>
<span className="switch-slider" />
</label>
</div>
</div>
<div className="form-group">
<label>Excel </label>
<span className="form-hint"> Excel </span>
<div className="select-field" ref={exportExcelColumnsDropdownRef}>
<button
type="button"
className={`select-trigger ${showExportExcelColumnsSelect ? 'open' : ''}`}
onClick={() => {
setShowExportExcelColumnsSelect(!showExportExcelColumnsSelect)
setShowExportFormatSelect(false)
setShowExportDateRangeSelect(false)
}}
>
<span className="select-value">{exportExcelColumnsLabel}</span>
<ChevronDown size={16} />
</button>
{showExportExcelColumnsSelect && (
<div className="select-dropdown">
{exportExcelColumnOptions.map((option) => (
<button
key={option.value}
type="button"
className={`select-option ${exportExcelColumnsValue === option.value ? 'active' : ''}`}
onClick={async () => {
const compact = option.value === 'compact'
setExportDefaultExcelCompactColumns(compact)
await configService.setExportDefaultExcelCompactColumns(compact)
showMessage(compact ? '已启用精简列' : '已启用完整列', true)
setShowExportExcelColumnsSelect(false)
}}
>
<span className="option-label">{option.label}</span>
{option.desc && <span className="option-desc">{option.desc}</span>}
</button>
))}
</div>
)}
</div>
</div>
</div>
)
}
const renderCacheTab = () => ( const renderCacheTab = () => (
<div className="tab-content"> <div className="tab-content">
<p className="section-desc"></p> <p className="section-desc"></p>
@@ -992,6 +1216,7 @@ function SettingsPage() {
{activeTab === 'appearance' && renderAppearanceTab()} {activeTab === 'appearance' && renderAppearanceTab()}
{activeTab === 'database' && renderDatabaseTab()} {activeTab === 'database' && renderDatabaseTab()}
{activeTab === 'whisper' && renderWhisperTab()} {activeTab === 'whisper' && renderWhisperTab()}
{activeTab === 'export' && renderExportTab()}
{activeTab === 'cache' && renderCacheTab()} {activeTab === 'cache' && renderCacheTab()}
{activeTab === 'about' && renderAboutTab()} {activeTab === 'about' && renderAboutTab()}
</div> </div>
@@ -1001,4 +1226,3 @@ function SettingsPage() {
export default SettingsPage export default SettingsPage

216
src/pages/VideoWindow.scss Normal file
View File

@@ -0,0 +1,216 @@
.video-window-container {
width: 100vw;
height: 100vh;
background-color: #000;
display: flex;
flex-direction: column;
overflow: hidden;
user-select: none;
.title-bar {
height: 40px;
min-height: 40px;
display: flex;
background: #1a1a1a;
padding-right: 140px;
position: relative;
z-index: 10;
.window-drag-area {
flex: 1;
height: 100%;
-webkit-app-region: drag;
}
}
.video-viewport {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
position: relative;
cursor: pointer;
background: #000;
overflow: hidden;
min-height: 0; // 重要:让 flex 子元素可以收缩
video {
max-width: 100%;
max-height: 100%;
width: auto;
height: auto;
object-fit: contain;
}
.video-loading-overlay,
.video-error-overlay {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.5);
z-index: 5;
}
.video-error-overlay {
color: #ff6b6b;
font-size: 14px;
}
.spinner {
width: 40px;
height: 40px;
border: 3px solid rgba(255, 255, 255, 0.2);
border-top-color: #fff;
border-radius: 50%;
animation: spin 1s linear infinite;
}
.play-overlay {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.3);
opacity: 0;
transition: opacity 0.2s;
z-index: 4;
svg {
filter: drop-shadow(0 2px 8px rgba(0, 0, 0, 0.5));
}
}
&:hover .play-overlay {
opacity: 1;
}
}
.video-controls {
position: absolute;
bottom: 0;
left: 0;
right: 0;
background: linear-gradient(to top, rgba(0, 0, 0, 0.85), rgba(0, 0, 0, 0.4) 60%, transparent);
padding: 40px 16px 12px;
opacity: 0;
transition: opacity 0.25s;
z-index: 6;
.progress-bar {
height: 16px;
display: flex;
align-items: center;
cursor: pointer;
margin-bottom: 8px;
.progress-track {
flex: 1;
height: 3px;
background: rgba(255, 255, 255, 0.3);
border-radius: 2px;
overflow: hidden;
transition: height 0.15s;
.progress-fill {
height: 100%;
background: var(--primary, #4a9eff);
border-radius: 2px;
}
}
&:hover .progress-track {
height: 5px;
}
}
.controls-row {
display: flex;
align-items: center;
justify-content: space-between;
}
.controls-left,
.controls-right {
display: flex;
align-items: center;
gap: 6px;
}
button {
background: transparent;
border: none;
color: #fff;
cursor: pointer;
padding: 6px;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.2s;
&:hover {
background: rgba(255, 255, 255, 0.15);
}
}
.time-display {
color: rgba(255, 255, 255, 0.9);
font-size: 12px;
font-variant-numeric: tabular-nums;
margin-left: 4px;
}
.volume-control {
display: flex;
align-items: center;
gap: 4px;
.volume-slider {
width: 60px;
height: 3px;
appearance: none;
-webkit-appearance: none;
background: rgba(255, 255, 255, 0.3);
border-radius: 2px;
cursor: pointer;
&::-webkit-slider-thumb {
appearance: none;
-webkit-appearance: none;
width: 10px;
height: 10px;
background: #fff;
border-radius: 50%;
cursor: pointer;
}
}
}
}
// 鼠标悬停时显示控制栏
&:hover .video-controls {
opacity: 1;
}
// 播放时如果鼠标不动,隐藏控制栏
&.hide-controls .video-controls {
opacity: 0;
}
}
.video-window-empty {
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
color: rgba(255, 255, 255, 0.6);
background-color: #000;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}

199
src/pages/VideoWindow.tsx Normal file
View File

@@ -0,0 +1,199 @@
import { useState, useEffect, useRef, useCallback } from 'react'
import { useSearchParams } from 'react-router-dom'
import { Play, Pause, Volume2, VolumeX, RotateCcw } from 'lucide-react'
import './VideoWindow.scss'
export default function VideoWindow() {
const [searchParams] = useSearchParams()
const videoPath = searchParams.get('videoPath')
const [isPlaying, setIsPlaying] = useState(false)
const [isMuted, setIsMuted] = useState(false)
const [currentTime, setCurrentTime] = useState(0)
const [duration, setDuration] = useState(0)
const [volume, setVolume] = useState(1)
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const videoRef = useRef<HTMLVideoElement>(null)
const progressRef = useRef<HTMLDivElement>(null)
// 格式化时间
const formatTime = (seconds: number) => {
const mins = Math.floor(seconds / 60)
const secs = Math.floor(seconds % 60)
return `${mins}:${secs.toString().padStart(2, '0')}`
}
//播放/暂停
const togglePlay = useCallback(() => {
if (!videoRef.current) return
if (isPlaying) {
videoRef.current.pause()
} else {
videoRef.current.play()
}
}, [isPlaying])
// 静音切换
const toggleMute = useCallback(() => {
if (!videoRef.current) return
videoRef.current.muted = !isMuted
setIsMuted(!isMuted)
}, [isMuted])
// 进度条点击
const handleProgressClick = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
if (!videoRef.current || !progressRef.current) return
e.stopPropagation()
const rect = progressRef.current.getBoundingClientRect()
const percent = (e.clientX - rect.left) / rect.width
videoRef.current.currentTime = percent * duration
}, [duration])
// 音量调节
const handleVolumeChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const newVolume = parseFloat(e.target.value)
setVolume(newVolume)
if (videoRef.current) {
videoRef.current.volume = newVolume
setIsMuted(newVolume === 0)
}
}, [])
// 重新播放
const handleReplay = useCallback(() => {
if (!videoRef.current) return
videoRef.current.currentTime = 0
videoRef.current.play()
}, [])
// 快捷键
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') window.electronAPI.window.close()
if (e.key === ' ') {
e.preventDefault()
togglePlay()
}
if (e.key === 'm' || e.key === 'M') toggleMute()
if (e.key === 'ArrowLeft' && videoRef.current) {
videoRef.current.currentTime -= 5
}
if (e.key === 'ArrowRight' && videoRef.current) {
videoRef.current.currentTime += 5
}
if (e.key === 'ArrowUp' && videoRef.current) {
videoRef.current.volume = Math.min(1, videoRef.current.volume + 0.1)
setVolume(videoRef.current.volume)
}
if (e.key === 'ArrowDown' && videoRef.current) {
videoRef.current.volume = Math.max(0, videoRef.current.volume - 0.1)
setVolume(videoRef.current.volume)
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [togglePlay, toggleMute])
if (!videoPath) {
return (
<div className="video-window-empty">
<span></span>
</div>
)
}
const progress = duration > 0 ? (currentTime / duration) * 100 : 0
return (
<div className="video-window-container">
<div className="title-bar">
<div className="window-drag-area"></div>
</div>
<div className="video-viewport" onClick={togglePlay}>
{isLoading && (
<div className="video-loading-overlay">
<div className="spinner"></div>
</div>
)}
{error && (
<div className="video-error-overlay">
<span>{error}</span>
</div>
)}
<video
ref={videoRef}
src={videoPath}
onLoadedMetadata={(e) => {
const video = e.currentTarget
setDuration(video.duration)
setIsLoading(false)
// 根据视频尺寸调整窗口大小
if (video.videoWidth && video.videoHeight) {
window.electronAPI.window.resizeToFitVideo(video.videoWidth, video.videoHeight)
}
}}
onTimeUpdate={(e) => setCurrentTime(e.currentTarget.currentTime)}
onPlay={() => setIsPlaying(true)}
onPause={() => setIsPlaying(false)}
onEnded={() => setIsPlaying(false)}
onError={() => {
setError('视频加载失败')
setIsLoading(false)
}}
onWaiting={() => setIsLoading(true)}
onCanPlay={() => setIsLoading(false)}
autoPlay
/>
{!isPlaying && !isLoading && !error && (
<div className="play-overlay">
<Play size={64} fill="white" />
</div>
)}
<div className="video-controls" onClick={(e) => e.stopPropagation()}>
<div
className="progress-bar"
ref={progressRef}
onClick={handleProgressClick}
>
<div className="progress-track">
<div className="progress-fill" style={{ width: `${progress}%` }}></div>
</div>
</div>
<div className="controls-row">
<div className="controls-left">
<button onClick={togglePlay} title={isPlaying ? '暂停 (空格)' : '播放 (空格)'}>
{isPlaying ? <Pause size={18} /> : <Play size={18} />}
</button>
<button onClick={handleReplay} title="重新播放">
<RotateCcw size={16} />
</button>
<span className="time-display">
{formatTime(currentTime)} / {formatTime(duration)}
</span>
</div>
<div className="controls-right">
<div className="volume-control">
<button onClick={toggleMute} title={isMuted ? '取消静音 (M)' : '静音 (M)'}>
{isMuted || volume === 0 ? <VolumeX size={16} /> : <Volume2 size={16} />}
</button>
<input
type="range"
min="0"
max="1"
step="0.1"
value={isMuted ? 0 : volume}
onChange={handleVolumeChange}
className="volume-slider"
/>
</div>
</div>
</div>
</div>
</div>
</div>
)
}

View File

@@ -441,7 +441,7 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
<FolderOpen size={16} /> <FolderOpen size={16} />
</button> </button>
</div> </div>
<div className="field-hint"> xwechat_files </div> <div className="field-hint">--</div>
<div className="field-hint" style={{ color: '#ff6b6b', marginTop: '4px' }}> --</div> <div className="field-hint" style={{ color: '#ff6b6b', marginTop: '4px' }}> --</div>
</div> </div>
)} )}
@@ -507,7 +507,7 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
{dbKeyStatus && <div className="field-hint status-text">{dbKeyStatus}</div>} {dbKeyStatus && <div className="field-hint status-text">{dbKeyStatus}</div>}
<div className="field-hint"></div> <div className="field-hint"></div>
<div className="field-hint"></div> <div className="field-hint"><span style={{color: 'red'}}>hook安装成功</span></div>
</div> </div>
)} )}
@@ -533,7 +533,7 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
{isFetchingImageKey ? '获取中...' : '自动获取图片密钥'} {isFetchingImageKey ? '获取中...' : '自动获取图片密钥'}
</button> </button>
{imageKeyStatus && <div className="field-hint status-text">{imageKeyStatus}</div>} {imageKeyStatus && <div className="field-hint status-text">{imageKeyStatus}</div>}
<div className="field-hint"></div> <div className="field-hint"></div>
{isFetchingImageKey && <div className="field-hint status-text">...</div>} {isFetchingImageKey && <div className="field-hint status-text">...</div>}
</div> </div>
)} )}

View File

@@ -22,7 +22,12 @@ export const CONFIG_KEYS = {
WHISPER_MODEL_DIR: 'whisperModelDir', WHISPER_MODEL_DIR: 'whisperModelDir',
WHISPER_DOWNLOAD_SOURCE: 'whisperDownloadSource', WHISPER_DOWNLOAD_SOURCE: 'whisperDownloadSource',
AUTO_TRANSCRIBE_VOICE: 'autoTranscribeVoice', AUTO_TRANSCRIBE_VOICE: 'autoTranscribeVoice',
TRANSCRIBE_LANGUAGES: 'transcribeLanguages' TRANSCRIBE_LANGUAGES: 'transcribeLanguages',
EXPORT_DEFAULT_FORMAT: 'exportDefaultFormat',
EXPORT_DEFAULT_DATE_RANGE: 'exportDefaultDateRange',
EXPORT_DEFAULT_MEDIA: 'exportDefaultMedia',
EXPORT_DEFAULT_VOICE_AS_TEXT: 'exportDefaultVoiceAsText',
EXPORT_DEFAULT_EXCEL_COMPACT_COLUMNS: 'exportDefaultExcelCompactColumns'
} as const } as const
// 获取解密密钥 // 获取解密密钥
@@ -243,3 +248,61 @@ export async function getTranscribeLanguages(): Promise<string[]> {
export async function setTranscribeLanguages(languages: string[]): Promise<void> { export async function setTranscribeLanguages(languages: string[]): Promise<void> {
await config.set(CONFIG_KEYS.TRANSCRIBE_LANGUAGES, languages) await config.set(CONFIG_KEYS.TRANSCRIBE_LANGUAGES, languages)
} }
// 获取导出默认格式
export async function getExportDefaultFormat(): Promise<string | null> {
const value = await config.get(CONFIG_KEYS.EXPORT_DEFAULT_FORMAT)
return (value as string) || null
}
// 设置导出默认格式
export async function setExportDefaultFormat(format: string): Promise<void> {
await config.set(CONFIG_KEYS.EXPORT_DEFAULT_FORMAT, format)
}
// 获取导出默认时间范围
export async function getExportDefaultDateRange(): Promise<string | null> {
const value = await config.get(CONFIG_KEYS.EXPORT_DEFAULT_DATE_RANGE)
return (value as string) || null
}
// 设置导出默认时间范围
export async function setExportDefaultDateRange(range: string): Promise<void> {
await config.set(CONFIG_KEYS.EXPORT_DEFAULT_DATE_RANGE, range)
}
// 获取导出默认媒体设置
export async function getExportDefaultMedia(): Promise<boolean | null> {
const value = await config.get(CONFIG_KEYS.EXPORT_DEFAULT_MEDIA)
if (typeof value === 'boolean') return value
return null
}
// 设置导出默认媒体设置
export async function setExportDefaultMedia(enabled: boolean): Promise<void> {
await config.set(CONFIG_KEYS.EXPORT_DEFAULT_MEDIA, enabled)
}
// 获取导出默认语音转文字
export async function getExportDefaultVoiceAsText(): Promise<boolean | null> {
const value = await config.get(CONFIG_KEYS.EXPORT_DEFAULT_VOICE_AS_TEXT)
if (typeof value === 'boolean') return value
return null
}
// 设置导出默认语音转文字
export async function setExportDefaultVoiceAsText(enabled: boolean): Promise<void> {
await config.set(CONFIG_KEYS.EXPORT_DEFAULT_VOICE_AS_TEXT, enabled)
}
// 获取导出默认 Excel 列模式
export async function getExportDefaultExcelCompactColumns(): Promise<boolean | null> {
const value = await config.get(CONFIG_KEYS.EXPORT_DEFAULT_EXCEL_COMPACT_COLUMNS)
if (typeof value === 'boolean') return value
return null
}
// 设置导出默认 Excel 列模式
export async function setExportDefaultExcelCompactColumns(enabled: boolean): Promise<void> {
await config.set(CONFIG_KEYS.EXPORT_DEFAULT_EXCEL_COMPACT_COLUMNS, enabled)
}

View File

@@ -9,6 +9,8 @@ export interface ElectronAPI {
completeOnboarding: () => Promise<boolean> completeOnboarding: () => Promise<boolean>
openOnboardingWindow: () => Promise<boolean> openOnboardingWindow: () => Promise<boolean>
setTitleBarOverlay: (options: { symbolColor: string }) => void setTitleBarOverlay: (options: { symbolColor: string }) => void
openVideoPlayerWindow: (videoPath: string, videoWidth?: number, videoHeight?: number) => Promise<void>
resizeToFitVideo: (videoWidth: number, videoHeight: number) => Promise<void>
} }
config: { config: {
get: (key: string) => Promise<unknown> get: (key: string) => Promise<unknown>
@@ -96,7 +98,7 @@ export interface ElectronAPI {
getImageData: (sessionId: string, msgId: string) => Promise<{ success: boolean; data?: string; error?: string }> getImageData: (sessionId: string, msgId: string) => Promise<{ success: boolean; data?: string; error?: string }>
getVoiceData: (sessionId: string, msgId: string, createTime?: number, serverId?: string | number) => Promise<{ success: boolean; data?: string; error?: string }> getVoiceData: (sessionId: string, msgId: string, createTime?: number, serverId?: string | number) => Promise<{ success: boolean; data?: string; error?: string }>
resolveVoiceCache: (sessionId: string, msgId: string) => Promise<{ success: boolean; hasCache: boolean; data?: string }> resolveVoiceCache: (sessionId: string, msgId: string) => Promise<{ success: boolean; hasCache: boolean; data?: string }>
getVoiceTranscript: (sessionId: string, msgId: string) => Promise<{ success: boolean; transcript?: string; error?: string }> getVoiceTranscript: (sessionId: string, msgId: string, createTime?: number) => Promise<{ success: boolean; transcript?: string; error?: string }>
onVoiceTranscriptPartial: (callback: (payload: { msgId: string; text: string }) => void) => () => void onVoiceTranscriptPartial: (callback: (payload: { msgId: string; text: string }) => void) => () => void
} }
@@ -107,6 +109,21 @@ export interface ElectronAPI {
onUpdateAvailable: (callback: (payload: { cacheKey: string; imageMd5?: string; imageDatName?: string }) => void) => () => void onUpdateAvailable: (callback: (payload: { cacheKey: string; imageMd5?: string; imageDatName?: string }) => void) => () => void
onCacheResolved: (callback: (payload: { cacheKey: string; imageMd5?: string; imageDatName?: string; localPath: string }) => void) => () => void onCacheResolved: (callback: (payload: { cacheKey: string; imageMd5?: string; imageDatName?: string; localPath: string }) => void) => () => void
} }
video: {
getVideoInfo: (videoMd5: string) => Promise<{
success: boolean
exists: boolean
videoUrl?: string
coverUrl?: string
thumbUrl?: string
error?: string
}>
parseVideoMd5: (content: string) => Promise<{
success: boolean
md5?: string
error?: string
}>
}
analytics: { analytics: {
getOverallStatistics: (force?: boolean) => Promise<{ getOverallStatistics: (force?: boolean) => Promise<{
success: boolean success: boolean
@@ -297,6 +314,7 @@ export interface ElectronAPI {
success: boolean success: boolean
error?: string error?: string
}> }>
onProgress: (callback: (payload: ExportProgress) => void) => () => void
} }
whisper: { whisper: {
downloadModel: () => Promise<{ success: boolean; modelPath?: string; tokensPath?: string; error?: string }> downloadModel: () => Promise<{ success: boolean; modelPath?: string; tokensPath?: string; error?: string }>
@@ -310,6 +328,19 @@ export interface ExportOptions {
dateRange?: { start: number; end: number } | null dateRange?: { start: number; end: number } | null
exportMedia?: boolean exportMedia?: boolean
exportAvatars?: boolean exportAvatars?: boolean
exportImages?: boolean
exportVoices?: boolean
exportEmojis?: boolean
exportVoiceAsText?: boolean
excelCompactColumns?: boolean
sessionLayout?: 'shared' | 'per-session'
}
export interface ExportProgress {
current: number
total: number
currentSession: string
phase: 'preparing' | 'exporting' | 'writing' | 'complete'
} }
export interface WxidInfo { export interface WxidInfo {

View File

@@ -33,11 +33,14 @@ export interface Message {
isSend: number | null isSend: number | null
senderUsername: string | null senderUsername: string | null
parsedContent: string parsedContent: string
rawContent?: string // 原始消息内容(保留用于兼容)
content?: string // 原始消息内容XML
imageMd5?: string imageMd5?: string
imageDatName?: string imageDatName?: string
emojiCdnUrl?: string emojiCdnUrl?: string
emojiMd5?: string emojiMd5?: string
voiceDurationSeconds?: number voiceDurationSeconds?: number
videoMd5?: string
// 引用消息 // 引用消息
quotedContent?: string quotedContent?: string
quotedSender?: string quotedSender?: string