Compare commits

...

38 Commits

Author SHA1 Message Date
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
cc
32cdbece2c fix: 修复同时解密多个语音时可能引起错误解密的问题 2026-01-18 10:49:44 +08:00
cc
6e7e994cc6 fix: 修复构建日志异常 2026-01-18 10:41:00 +08:00
cc
d95040ffaf fix: 修复构建日志异常的问题 2026-01-18 10:35:41 +08:00
cc
129dfbe1b6 fix: 自动构建修复 2026-01-18 10:32:19 +08:00
cc
f8afce6bfa fix: 修复打包配置错误 2026-01-18 10:25:24 +08:00
cc
0423f23b9c fix: 允许相同版本打包 2026-01-18 10:19:43 +08:00
cc
e3655631bb Merge branch 'dev'
合并
2026-01-18 10:13:09 +08:00
cc
945802f772 chore: 优化自动构建流程 2026-01-18 10:10:42 +08:00
cc
be4d9b510d feat: 优化了语音配置页面的效果;新增语音实际波形图显示;新增语音点击跳转进度
fix: 修复了一个可能导致语音解密错乱的问题
2026-01-18 00:01:07 +08:00
Forrest
0853e049c8 feat(voice-transcribe): 新增语音转写语言过滤配置功能(支持用户自定义允许的转写语言),优化模型下载的超时处理与进度日志,提升下载稳健性,同步更新相关 UI 样式。 2026-01-17 19:54:31 +08:00
Forrest
dc12df0fcf fix: 清理导出服务日志并简化whisper接口参数
- 移除exportService中的冗余console日志输出
- 简化whisper API接口,移除downloadModel和getModelStatus的payload参数
- 清理图片、表情、语音导出过程中的调试日志
- 移除数据库查询和媒体处理中的详细日志记录
- 优化代码可读性,减少控制台输出噪音
2026-01-17 16:24:18 +08:00
cc
82ba0344b9 Merge branch 'dev' of https://github.com/hicccc77/WeFlow into dev 2026-01-17 14:19:34 +08:00
cc
e8babd48b6 feat: 实现语音转文字并支持流式输出;
fix: 修复了语音解密失败的问题
2026-01-17 14:16:54 +08:00
xuncha
7c0ed66dad fix:新增了数据库目录的提醒 2026-01-17 12:41:45 +08:00
xuncha
9402483d87 fix:修复了日期选择的问题 2026-01-17 12:02:17 +08:00
xuncha
650de55202 fix:修复了语音导出 2026-01-17 07:08:23 +08:00
xuncha
af99ab2029 Merge branch 'dev' of https://github.com/hicccc77/WeFlow into dev 2026-01-17 07:04:59 +08:00
xuncha
87a2675236 fix:修复了图片 表情映射的问题 2026-01-17 07:03:46 +08:00
xuncha
25f1256baa fix:处理了xml消息 2026-01-17 06:36:28 +08:00
xuncha
f83a37e714 fix:修复了备注昵称错误的问题 2026-01-17 06:27:19 +08:00
xuncha
1aecfb369b feat: 增加了导出时对excel的支持 2026-01-17 06:18:11 +08:00
xuncha
436b090e26 Merge pull request #45 from xunchahaha/dev
fix:增加了聊天中语音转文字功能
2026-01-17 05:28:37 +08:00
xuncha
c0f2620542 fix:使其在聊天中变得可用 2026-01-17 05:27:20 +08:00
xuncha
72e2d82158 feat: 尝试增加一下聊天里面的语音转文字功能 2026-01-17 05:14:14 +08:00
xuncha
095c8f0db6 fix:修复了清除缓存功能的缺失 2026-01-17 02:45:10 +08:00
xuncha
afa3e089b1 fix: 自己发的语音也可以解密 2026-01-17 02:33:02 +08:00
Forrest
11969ea2d4 fix(imageDecryptService): 优化 ffmpeg 路径检测(多方案 fallback 并验证有效性)与错误处理(捕获 stderr 并输出详细日志),新增ffmpeg-static依赖。 2026-01-17 02:05:41 +08:00
Forrest
6707be2200 fix: 修复图像解密服务中的潜在错误 2026-01-17 01:52:56 +08:00
Forrest
f97e102dbd fix: jpg解密修复 2026-01-17 01:23:38 +08:00
Forrest
3637864f9a Merge branch 'dev' 2026-01-16 22:26:30 +08:00
Forrest
6eabd707f8 feat(ui): 新增支持缩放、拖拽及键盘控制的图片预览组件并优化样式,更新安装程序支持 VC++ 运行库自动安装,多账号支持列表选择。 2026-01-16 22:16:55 +08:00
45 changed files with 6941 additions and 446 deletions

View File

@@ -25,44 +25,73 @@ jobs:
cache: 'npm'
- name: Install Dependencies
run: npm install
run: npm ci
- name: Sync version with tag
shell: bash
run: |
VERSION=${GITHUB_REF_NAME#v}
echo "Syncing package.json version to $VERSION"
npm version $VERSION --no-git-tag-version --allow-same-version
- name: Build Frontend & Type Check
run: |
npx tsc
npx vite build
- name: Build Changelog
id: build_changelog
uses: mikepenz/release-changelog-builder-action@v4
with:
outputFile: "release-notes.md"
configurationJson: |
- name: Create Changelog Config
shell: bash
run: |
cat <<EOF > changelog_config.json
{
"template": "# v${{ github.ref_name }} 版本发布\n\n{{CHANGELOG}}\n\n---\n> 此更新由系统自动构建",
"template": "# ${{ github.ref_name }} 更新日志\n\n{{CHANGELOG}}\n\n---\n> 此更新由系统自动构建",
"categories": [
{
"title": "## 新功能",
"filter": { "pattern": "^feat:.*", "flags": "i" }
"filter": { "pattern": "^feat", "flags": "i" }
},
{
"title": "## 修复",
"filter": { "pattern": "^fix:.*", "flags": "i" }
"filter": { "pattern": "^fix", "flags": "i" }
},
{
"title": "## 性能与维护",
"filter": { "pattern": "^(chore|docs|perf|refactor):.*", "flags": "i" }
"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
shell: bash
run: |
npm pkg set build.releaseInfo.releaseNotesFile=release-notes.md
- name: Package and Publish
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
npx electron-builder --publish always "-c.releaseInfo.releaseNotesFile=release-notes.md"
npx electron-builder --publish always

1
.gitignore vendored
View File

@@ -56,3 +56,4 @@ Thumbs.db
*.aps
wcdb/
*info

View File

@@ -15,6 +15,8 @@ import { groupAnalyticsService } from './services/groupAnalyticsService'
import { annualReportService } from './services/annualReportService'
import { exportService, ExportOptions } from './services/exportService'
import { KeyService } from './services/keyService'
import { voiceTranscribeService } from './services/voiceTranscribeService'
import { videoService } from './services/videoService'
// 配置自动更新
@@ -199,6 +201,107 @@ function createOnboardingWindow() {
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() {
shouldShowMain = true
if (mainWindowReady) {
@@ -355,6 +458,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 () => {
return dbPathService.autoDetect()
@@ -438,8 +614,17 @@ function registerIpcHandlers() {
return chatService.getImageData(sessionId, msgId)
})
ipcMain.handle('chat:getVoiceData', async (_, sessionId: string, msgId: string) => {
return chatService.getVoiceData(sessionId, msgId)
ipcMain.handle('chat:getVoiceData', async (_, sessionId: string, msgId: string, createTime?: number, serverId?: string | number) => {
return chatService.getVoiceData(sessionId, msgId, createTime, serverId)
})
ipcMain.handle('chat:resolveVoiceCache', async (_, sessionId: string, msgId: string) => {
return chatService.resolveVoiceCache(sessionId, msgId)
})
ipcMain.handle('chat:getVoiceTranscript', async (event, sessionId: string, msgId: string, createTime?: number) => {
return chatService.getVoiceTranscript(sessionId, msgId, createTime, (text) => {
event.sender.send('chat:voiceTranscriptPartial', { msgId, text })
})
})
ipcMain.handle('chat:getMessageById', async (_, sessionId: string, localId: number) => {
@@ -482,6 +667,50 @@ function registerIpcHandlers() {
return analyticsService.getTimeDistribution()
})
// 缓存管理
ipcMain.handle('cache:clearAnalytics', async () => {
return analyticsService.clearCache()
})
ipcMain.handle('cache:clearImages', async () => {
const imageResult = await imageDecryptService.clearCache()
const emojiResult = chatService.clearCaches({ includeMessages: false, includeContacts: false, includeEmojis: true })
const errors = [imageResult, emojiResult]
.filter((result) => !result.success)
.map((result) => result.error)
.filter(Boolean) as string[]
if (errors.length > 0) {
return { success: false, error: errors.join('; ') }
}
return { success: true }
})
ipcMain.handle('cache:clearAll', async () => {
const [analyticsResult, imageResult] = await Promise.all([
analyticsService.clearCache(),
imageDecryptService.clearCache()
])
const chatResult = chatService.clearCaches()
const errors = [analyticsResult, imageResult, chatResult]
.filter((result) => !result.success)
.map((result) => result.error)
.filter(Boolean) as string[]
if (errors.length > 0) {
return { success: false, error: errors.join('; ') }
}
return { success: true }
})
ipcMain.handle('whisper:downloadModel', async (event) => {
return voiceTranscribeService.downloadModel((progress) => {
event.sender.send('whisper:downloadProgress', progress)
})
})
ipcMain.handle('whisper:getModelStatus', async () => {
return voiceTranscribeService.getModelStatus()
})
// 群聊分析相关
ipcMain.handle('groupAnalytics:getGroupChats', async () => {
return groupAnalyticsService.getGroupChats()

View File

@@ -53,7 +53,11 @@ contextBridge.exposeInMainWorld('electronAPI', {
openAgreementWindow: () => ipcRenderer.invoke('window:openAgreementWindow'),
completeOnboarding: () => ipcRenderer.invoke('window:completeOnboarding'),
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)
},
// 数据库路径
@@ -106,7 +110,15 @@ contextBridge.exposeInMainWorld('electronAPI', {
close: () => ipcRenderer.invoke('chat:close'),
getSessionDetail: (sessionId: string) => ipcRenderer.invoke('chat:getSessionDetail', sessionId),
getImageData: (sessionId: string, msgId: string) => ipcRenderer.invoke('chat:getImageData', sessionId, msgId),
getVoiceData: (sessionId: string, msgId: string) => ipcRenderer.invoke('chat:getVoiceData', sessionId, msgId)
getVoiceData: (sessionId: string, msgId: string, createTime?: number, serverId?: string | number) =>
ipcRenderer.invoke('chat:getVoiceData', sessionId, msgId, createTime, serverId),
resolveVoiceCache: (sessionId: string, msgId: string) => ipcRenderer.invoke('chat:resolveVoiceCache', sessionId, msgId),
getVoiceTranscript: (sessionId: string, msgId: string, createTime?: number) => ipcRenderer.invoke('chat:getVoiceTranscript', sessionId, msgId, createTime),
onVoiceTranscriptPartial: (callback: (payload: { msgId: string; text: string }) => void) => {
const listener = (_: any, payload: { msgId: string; text: string }) => callback(payload)
ipcRenderer.on('chat:voiceTranscriptPartial', listener)
return () => ipcRenderer.removeListener('chat:voiceTranscriptPartial', listener)
}
},
@@ -129,6 +141,12 @@ contextBridge.exposeInMainWorld('electronAPI', {
}
},
// 视频
video: {
getVideoInfo: (videoMd5: string) => ipcRenderer.invoke('video:getVideoInfo', videoMd5),
parseVideoMd5: (content: string) => ipcRenderer.invoke('video:parseVideoMd5', content)
},
// 数据分析
analytics: {
getOverallStatistics: () => ipcRenderer.invoke('analytics:getOverallStatistics'),
@@ -140,6 +158,13 @@ contextBridge.exposeInMainWorld('electronAPI', {
}
},
// 缓存管理
cache: {
clearAnalytics: () => ipcRenderer.invoke('cache:clearAnalytics'),
clearImages: () => ipcRenderer.invoke('cache:clearImages'),
clearAll: () => ipcRenderer.invoke('cache:clearAll')
},
// 群聊分析
groupAnalytics: {
getGroupChats: () => ipcRenderer.invoke('groupAnalytics:getGroupChats'),
@@ -167,5 +192,16 @@ contextBridge.exposeInMainWorld('electronAPI', {
ipcRenderer.invoke('export:exportSessions', sessionIds, outputDir, options),
exportSession: (sessionId: string, outputPath: string, options: any) =>
ipcRenderer.invoke('export:exportSession', sessionId, outputPath, options)
},
whisper: {
downloadModel: () =>
ipcRenderer.invoke('whisper:downloadModel'),
getModelStatus: () =>
ipcRenderer.invoke('whisper:getModelStatus'),
onDownloadProgress: (callback: (payload: { modelName: string; downloadedBytes: number; totalBytes?: number; percent?: number }) => void) => {
ipcRenderer.on('whisper:downloadProgress', (_, payload) => callback(payload))
return () => ipcRenderer.removeAllListeners('whisper:downloadProgress')
}
}
})

View File

@@ -1,7 +1,7 @@
import { ConfigService } from './config'
import { wcdbService } from './wcdbService'
import { join } from 'path'
import { readFile, writeFile } from 'fs/promises'
import { readFile, writeFile, rm } from 'fs/promises'
import { app } from 'electron'
export interface ChatStatistics {
@@ -324,7 +324,7 @@ class AnalyticsService {
}
private getCacheFilePath(): string {
return join(app.getPath('userData'), 'analytics_cache.json')
return join(app.getPath('documents'), 'WeFlow', 'analytics_cache.json')
}
private async loadCacheFromFile(): Promise<{ key: string; data: any; updatedAt: number } | null> {
@@ -528,6 +528,18 @@ class AnalyticsService {
return { success: false, error: String(e) }
}
}
async clearCache(): Promise<{ success: boolean; error?: string }> {
this.aggregateCache = null
this.fallbackAggregateCache = null
this.aggregatePromise = null
try {
await rm(this.getCacheFilePath(), { force: true })
return { success: true }
} catch (e) {
return { success: false, error: String(e) }
}
}
}
export const analyticsService = new AnalyticsService()

View File

@@ -7,15 +7,12 @@ import * as http from 'http'
import * as fzstd from 'fzstd'
import * as crypto from 'crypto'
import Database from 'better-sqlite3'
import { execFile } from 'child_process'
import { promisify } from 'util'
import { app } from 'electron'
const execFileAsync = promisify(execFile)
import { ConfigService } from './config'
import { wcdbService } from './wcdbService'
import { MessageCacheService } from './messageCacheService'
import { ContactCacheService, ContactCacheEntry } from './contactCacheService'
import { voiceTranscribeService } from './voiceTranscribeService'
type HardlinkState = {
db: Database.Database
@@ -45,6 +42,7 @@ export interface Message {
senderUsername: string | null
parsedContent: string
rawContent: string
content?: string // 原始XML内容与rawContent相同供前端使用
// 表情包相关
emojiCdnUrl?: string
emojiMd5?: string
@@ -55,6 +53,7 @@ export interface Message {
// 图片/视频相关
imageMd5?: string
imageDatName?: string
videoMd5?: string
aesKey?: string
encrypVer?: number
cdnThumbUrl?: string
@@ -83,6 +82,21 @@ class ChatService {
private hardlinkCache = new Map<string, HardlinkState>()
private readonly contactCacheService: ContactCacheService
private readonly messageCacheService: MessageCacheService
private voiceWavCache = new Map<string, Buffer>()
private voiceTranscriptCache = new Map<string, 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
// 缓存 media.db 的表结构信息
private mediaDbSchemaCache = new Map<string, {
voiceTable: string
dataColumn: string
chatNameIdColumn?: string
timeColumn?: string
name2IdTable?: string
}>()
constructor() {
this.configService = new ConfigService()
@@ -139,6 +153,10 @@ class ChatService {
}
this.connected = true
// 预热 listMediaDbs 缓存(后台异步执行,不阻塞连接)
this.warmupMediaDbsCache()
return { success: true }
} catch (e) {
console.error('ChatService: 连接数据库失败:', e)
@@ -146,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 }> {
if (this.connected && wcdbService.isReady()) {
return { success: true }
@@ -381,8 +414,6 @@ class ChatService {
const needNewCursor = !state || offset === 0 || state.batchSize !== batchSize
if (needNewCursor) {
console.log(`[ChatService] 创建新游标: sessionId=${sessionId}, offset=${offset}, batchSize=${batchSize}`)
// 关闭旧游标
if (state) {
try {
@@ -439,7 +470,6 @@ class ChatService {
}
// 获取当前批次的消息
console.log(`[ChatService] 获取消息批次: cursor=${state.cursor}, fetched=${state.fetched}`)
const batch = await wcdbService.fetchMessageBatch(state.cursor)
if (!batch.success) {
console.error('[ChatService] 获取消息批次失败:', batch.error)
@@ -715,6 +745,7 @@ class ChatService {
let quotedSender: string | undefined
let imageMd5: string | undefined
let imageDatName: string | undefined
let videoMd5: string | undefined
let aesKey: string | undefined
let encrypVer: number | undefined
let cdnThumbUrl: string | undefined
@@ -731,6 +762,9 @@ class ChatService {
encrypVer = imageInfo.encrypVer
cdnThumbUrl = imageInfo.cdnThumbUrl
imageDatName = this.parseImageDatNameFromRow(row)
} else if (localType === 43 && content) {
// 视频消息
videoMd5 = this.parseVideoMd5(content)
} else if (localType === 34 && content) {
voiceDurationSeconds = this.parseVoiceDurationSeconds(content)
} else if (localType === 244813135921 || (content && content.includes('<type>57</type>'))) {
@@ -755,6 +789,7 @@ class ChatService {
quotedSender,
imageMd5,
imageDatName,
videoMd5,
voiceDurationSeconds,
aesKey,
encrypVer,
@@ -936,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>
@@ -1418,21 +1473,22 @@ class ChatService {
}
private extractXmlAttribute(xml: string, tagName: string, attrName: string): string {
const tagRegex = new RegExp(`<${tagName}[^>]*>`, 'i')
const tagMatch = tagRegex.exec(xml)
if (!tagMatch) return ''
const attrRegex = new RegExp(`${attrName}\\s*=\\s*['"]([^'"]*)['"]`, 'i')
const attrMatch = attrRegex.exec(tagMatch[0])
return attrMatch ? attrMatch[1] : ''
// 匹配 <tagName ... attrName="value" ... /> 或 <tagName ... attrName="value" ...>
const regex = new RegExp(`<${tagName}[^>]*\\s${attrName}\\s*=\\s*['"]([^'"]*)['"']`, 'i')
const match = regex.exec(xml)
return match ? match[1] : ''
}
private cleanSystemMessage(content: string): string {
return content
.replace(/<img[^>]*>/gi, '')
.replace(/<\/?[a-zA-Z0-9_]+[^>]*>/g, '')
.replace(/\s+/g, ' ')
.trim() || '[系统消息]'
// 移除 XML 声明
let cleaned = content.replace(/<\?xml[^?]*\?>/gi, '')
// 移除所有 XML/HTML 标签
cleaned = cleaned.replace(/<[^>]+>/g, '')
// 移除尾部的数字(如撤回消息后的时间戳)
cleaned = cleaned.replace(/\d+\s*$/, '')
// 清理多余空白
cleaned = cleaned.replace(/\s+/g, ' ').trim()
return cleaned || '[系统消息]'
}
private stripSenderPrefix(content: string): string {
@@ -1690,21 +1746,17 @@ class ChatService {
// 增加 'self' 作为兜底标识符,微信有时将个人信息存储在 'self' 记录中
const fetchList = Array.from(new Set([myWxid, cleanedWxid, 'self']))
console.log(`[ChatService] 尝试获取个人头像, wxids: ${JSON.stringify(fetchList)}`)
const result = await wcdbService.getAvatarUrls(fetchList)
if (result.success && result.map) {
// 按优先级尝试匹配
const avatarUrl = result.map[myWxid] || result.map[cleanedWxid] || result.map['self']
if (avatarUrl) {
console.log(`[ChatService] 成功获取个人头像: ${avatarUrl.substring(0, 50)}...`)
return { success: true, avatarUrl }
}
console.warn(`[ChatService] 未能在 contact.db 中找到个人头像, 请求列表: ${JSON.stringify(fetchList)}`)
return { success: true, avatarUrl: undefined }
}
console.error(`[ChatService] 查询个人头像失败: ${result.error || '未知错误'}`)
return { success: true, avatarUrl: undefined }
} catch (e) {
console.error('ChatService: 获取当前用户头像失败:', e)
@@ -1715,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 {
const cachePath = this.configService.get('cachePath')
if (cachePath) {
@@ -1725,6 +1790,48 @@ class ChatService {
return join(documentsPath, 'WeFlow', 'Emojis')
}
clearCaches(options?: { includeMessages?: boolean; includeContacts?: boolean; includeEmojis?: boolean }): { success: boolean; error?: string } {
const includeMessages = options?.includeMessages !== false
const includeContacts = options?.includeContacts !== false
const includeEmojis = options?.includeEmojis !== false
const errors: string[] = []
if (includeContacts) {
this.avatarCache.clear()
this.contactCacheService.clear()
}
if (includeMessages) {
this.messageCacheService.clear()
this.voiceWavCache.clear()
this.voiceTranscriptCache.clear()
this.voiceTranscriptPending.clear()
}
for (const state of this.hardlinkCache.values()) {
try {
state.db?.close()
} catch { }
}
this.hardlinkCache.clear()
if (includeEmojis) {
emojiCache.clear()
emojiDownloading.clear()
const emojiDir = this.getEmojiCacheDir()
try {
fs.rmSync(emojiDir, { recursive: true, force: true })
} catch (error) {
errors.push(String(error))
}
}
if (errors.length > 0) {
return { success: false, error: errors.join('; ') }
}
return { success: true }
}
/**
* 下载并缓存表情包
*/
@@ -2042,12 +2149,6 @@ class ChatService {
return { success: false, error: '未找到消息' }
}
const msg = msgResult.message
console.info('[ChatService][Image] request', {
sessionId,
localId: msg.localId,
imageMd5: msg.imageMd5,
imageDatName: msg.imageDatName
})
// 2. 确定搜索的基础名
const baseName = msg.imageMd5 || msg.imageDatName || String(msg.localId)
@@ -2064,7 +2165,6 @@ class ChatService {
const datPath = await this.findDatFile(actualAccountDir, baseName, sessionId)
if (!datPath) return { success: false, error: '未找到图片源文件 (.dat)' }
console.info('[ChatService][Image] dat path', datPath)
// 4. 获取解密密钥
const xorKeyRaw = this.configService.get('imageXorKey')
@@ -2092,7 +2192,6 @@ class ChatService {
const aesKey = this.asciiKey16(trimmed)
decrypted = this.decryptDatV4(data, xorKey, aesKey)
}
console.info('[ChatService][Image] decrypted bytes', decrypted.length)
// 返回 base64
return { success: true, data: decrypted.toString('base64') }
@@ -2102,14 +2201,359 @@ class ChatService {
}
}
async getVoiceData(sessionId: string, msgId: string): Promise<{ success: boolean; data?: string; error?: string }> {
/**
* getVoiceData (绕过WCDB的buggy getVoiceData直接用execQuery读取)
*/
async getVoiceData(sessionId: string, msgId: string, createTime?: number, serverId?: string | number): Promise<{ success: boolean; data?: string; error?: string }> {
const startTime = Date.now()
try {
const localId = parseInt(msgId, 10)
if (isNaN(localId)) {
return { success: false, error: '无效的消息ID' }
}
let msgCreateTime = createTime
let senderWxid: string | null = null
// 如果前端没传 createTime才需要查询消息这个很慢
if (!msgCreateTime) {
const t1 = Date.now()
const msgResult = await this.getMessageByLocalId(sessionId, localId)
const t2 = Date.now()
console.log(`[Voice] getMessageByLocalId: ${t2 - t1}ms`)
if (msgResult.success && msgResult.message) {
const msg = msgResult.message as any
msgCreateTime = msg.createTime
senderWxid = msg.senderUsername || null
}
}
if (!msgCreateTime) {
return { success: false, error: '未找到消息时间戳' }
}
// 使用 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 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)) {
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) {
return { success: false, 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) {
return { success: false, error: 'Silk 解码失败' }
}
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) {
console.error('ChatService: getVoiceData 失败:', e)
return { success: false, error: String(e) }
}
}
/**
* 缓存 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 }> {
try {
// 直接用 msgId 生成 cacheKey不查询数据库
// 注意:这里的 cacheKey 可能不准确(因为没有 createTime但只是用来快速检查缓存
// 如果缓存未命中,用户点击时会重新用正确的 cacheKey 查询
const cacheKey = this.getVoiceCacheKey(sessionId, msgId)
// 检查内存缓存
const inMemory = this.voiceWavCache.get(cacheKey)
if (inMemory) {
return { success: true, hasCache: true, data: inMemory.toString('base64') }
}
return { success: true, hasCache: false }
} catch (e) {
return { success: false, hasCache: false }
}
}
async getVoiceData_Legacy(sessionId: string, msgId: string): Promise<{ success: boolean; data?: string; error?: string }> {
try {
const localId = parseInt(msgId, 10)
const msgResult = await this.getMessageByLocalId(sessionId, localId)
if (!msgResult.success || !msgResult.message) return { success: false, error: '未找到该消息' }
const msg = msgResult.message
if (msg.isSend === 1) {
return { success: false, error: '暂不支持解密自己发送的语音' }
console.info('[ChatService][Voice] self-sent voice, continue decrypt flow')
}
const candidates = this.getVoiceLookupCandidates(sessionId, msg)
@@ -2140,12 +2584,10 @@ class ChatService {
for (const dbPath of (mediaDbs.data || [])) {
const voiceTable = await this.resolveVoiceInfoTableName(dbPath)
if (!voiceTable) {
console.warn('[ChatService][Voice] voice table not found', dbPath)
continue
}
const columns = await this.resolveVoiceInfoColumns(dbPath, voiceTable)
if (!columns) {
console.warn('[ChatService][Voice] voice columns not found', { dbPath, voiceTable })
continue
}
for (const candidate of candidates) {
@@ -2186,50 +2628,44 @@ class ChatService {
}
}
if (silkData) break
// 策略 3: 只使用 CreateTime (兜底)
if (!silkData && columns.createTimeColumn) {
const whereClause = `${columns.createTimeColumn} = ${msg.createTime}`
const sql = `SELECT ${columns.dataColumn} AS data FROM ${voiceTable} WHERE ${whereClause} LIMIT 1`
const result = await wcdbService.execQuery('media', dbPath, sql)
if (result.success && result.rows && result.rows.length > 0) {
const raw = result.rows[0]?.data
const decoded = this.decodeVoiceBlob(raw)
if (decoded && decoded.length > 0) {
console.info('[ChatService][Voice] hit by createTime only', { dbPath, voiceTable, whereClause, bytes: decoded.length })
silkData = decoded
}
}
}
if (silkData) break
}
if (!silkData) return { success: false, error: '未找到语音数据' }
// 4. 解码 Silk -> PCM -> WAV
const resourcesPath = app.isPackaged
? join(process.resourcesPath, 'resources')
: join(app.getAppPath(), 'resources')
const decoderPath = join(resourcesPath, 'silk_v3_decoder.exe')
if (!existsSync(decoderPath)) {
return { success: false, error: '找不到语音解码器 (silk_v3_decoder.exe)' }
}
console.info('[ChatService][Voice] decoder path', decoderPath)
const tempDir = app.getPath('temp')
const silkFile = join(tempDir, `voice_${msgId}.silk`)
const pcmFile = join(tempDir, `voice_${msgId}.pcm`)
// 4. 使用 silk-wasm 解码
try {
writeFileSync(silkFile, silkData)
// 执行解码: silk_v3_decoder.exe <silk> <pcm> -Fs_API 24000
console.info('[ChatService][Voice] executing decoder:', decoderPath, [silkFile, pcmFile])
const { stdout, stderr } = await execFileAsync(
decoderPath,
[silkFile, pcmFile, '-Fs_API', '24000'],
{ cwd: dirname(decoderPath) }
)
if (stdout && stdout.trim()) console.info('[ChatService][Voice] decoder stdout:', stdout)
if (stderr && stderr.trim()) console.warn('[ChatService][Voice] decoder stderr:', stderr)
if (!existsSync(pcmFile)) {
return { success: false, error: '语音解码失败' }
const pcmData = await this.decodeSilkToPcm(silkData, 24000)
if (!pcmData) {
return { success: false, error: 'Silk 解码失败' }
}
const pcmData = readFileSync(pcmFile)
const wavHeader = this.createWavHeader(pcmData.length, 24000, 1) // 微信语音通常 24kHz
const wavData = Buffer.concat([wavHeader, pcmData])
// PCM -> WAV
const wavData = this.createWavBuffer(pcmData, 24000)
// 缓存 WAV 数据 (内存缓存)
const cacheKey = this.getVoiceCacheKey(sessionId, msgId)
this.cacheVoiceWav(cacheKey, wavData)
return { success: true, data: wavData.toString('base64') }
} finally {
// 清理临时文件
try { if (existsSync(silkFile)) unlinkSync(silkFile) } catch { }
try { if (existsSync(pcmFile)) unlinkSync(pcmFile) } catch { }
} catch (e) {
console.error('[ChatService][Voice] decoding error:', e)
return { success: false, error: '语音解码失败: ' + String(e) }
}
} catch (e) {
console.error('ChatService: getVoiceData 失败:', e)
@@ -2237,7 +2673,47 @@ class ChatService {
}
}
private createWavHeader(pcmLength: number, sampleRate: number = 24000, channels: number = 1): Buffer {
/**
* 解码 Silk 数据为 PCM (silk-wasm)
*/
private async decodeSilkToPcm(silkData: Buffer, sampleRate: number): Promise<Buffer | null> {
try {
let wasmPath: string
if (app.isPackaged) {
wasmPath = join(process.resourcesPath, 'app.asar.unpacked', 'node_modules', 'silk-wasm', 'lib', 'silk.wasm')
if (!existsSync(wasmPath)) {
wasmPath = join(process.resourcesPath, 'node_modules', 'silk-wasm', 'lib', 'silk.wasm')
}
} else {
wasmPath = join(app.getAppPath(), 'node_modules', 'silk-wasm', 'lib', 'silk.wasm')
}
if (!existsSync(wasmPath)) {
console.error('[ChatService][Voice] silk.wasm not found at:', wasmPath)
return null
}
const silkWasm = require('silk-wasm')
if (!silkWasm || !silkWasm.decode) {
console.error('[ChatService][Voice] silk-wasm module invalid')
return null
}
const result = await silkWasm.decode(silkData, sampleRate)
return Buffer.from(result.data)
} catch (e) {
console.error('[ChatService][Voice] internal decode error:', e)
return null
}
}
/**
* 创建 WAV 文件 Buffer
*/
private createWavBuffer(pcmData: Buffer, sampleRate: number = 24000, channels: number = 1): Buffer {
const pcmLength = pcmData.length
const header = Buffer.alloc(44)
header.write('RIFF', 0)
header.writeUInt32LE(36 + pcmLength, 4)
@@ -2252,13 +2728,160 @@ class ChatService {
header.writeUInt16LE(16, 34)
header.write('data', 36)
header.writeUInt32LE(pcmLength, 40)
return header
return Buffer.concat([header, pcmData])
}
async getVoiceTranscript(
sessionId: string,
msgId: string,
createTime?: number,
onPartial?: (text: string) => void
): Promise<{ success: boolean; transcript?: string; error?: string }> {
const startTime = Date.now()
console.log(`[Transcribe] 开始转写: sessionId=${sessionId}, msgId=${msgId}, createTime=${createTime}`)
try {
let msgCreateTime = createTime
let serverId: string | number | undefined
// 如果前端没传 createTime才需要查询消息这个很慢
if (!msgCreateTime) {
const t1 = Date.now()
const msgResult = await this.getMessageById(sessionId, parseInt(msgId, 10))
const t2 = Date.now()
console.log(`[Transcribe] getMessageById: ${t2 - t1}ms`)
if (msgResult.success && msgResult.message) {
msgCreateTime = msgResult.message.createTime
serverId = msgResult.message.serverId
console.log(`[Transcribe] 获取到 createTime=${msgCreateTime}, serverId=${serverId}`)
}
}
if (!msgCreateTime) {
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, createTime?: number): string {
// 优先使用 createTime 作为key避免不同会话中localId相同导致的混乱
if (createTime) {
return `${sessionId}_${createTime}`
}
return `${sessionId}_${msgId}`
}
private cacheVoiceWav(cacheKey: string, wavData: Buffer): void {
this.voiceWavCache.set(cacheKey, wavData)
if (this.voiceWavCache.size > this.voiceCacheMaxEntries) {
const oldestKey = this.voiceWavCache.keys().next().value
if (oldestKey) this.voiceWavCache.delete(oldestKey)
}
}
private cacheVoiceTranscript(cacheKey: string, transcript: string): void {
this.voiceTranscriptCache.set(cacheKey, transcript)
if (this.voiceTranscriptCache.size > this.voiceCacheMaxEntries) {
const oldestKey = this.voiceTranscriptCache.keys().next().value
if (oldestKey) this.voiceTranscriptCache.delete(oldestKey)
}
}
async getMessageById(sessionId: string, localId: number): Promise<{ success: boolean; message?: Message; error?: string }> {
try {
console.info('[ChatService] getMessageById (SQL)', { sessionId, localId })
// 1. 获取该会话所在的消息表
// 注意:这里使用 getMessageTableStats 而不是 getMessageTables因为前者包含 db_path
const tableStats = await wcdbService.getMessageTableStats(sessionId)
@@ -2281,7 +2904,6 @@ class ChatService {
const message = this.parseMessage(row)
if (message.localId !== 0) {
console.info('[ChatService] getMessageById hit', { tableName, localId: message.localId })
return { success: true, message }
}
}
@@ -2324,6 +2946,7 @@ class ChatService {
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,
rawContent: rawContent,
content: rawContent, // 添加原始内容供视频MD5解析使用
parsedContent: this.parseMessageContent(rawContent, this.getRowInt(row, ['local_type', 'localType', 'type', 'msg_type', 'msgType', 'WCDB_CT_local_type'], 0))
}

View File

@@ -20,6 +20,11 @@ interface ConfigSchema {
language: string
logEnabled: boolean
llmModelPath: string
whisperModelName: string
whisperModelDir: string
whisperDownloadSource: string
autoTranscribeVoice: boolean
transcribeLanguages: string[]
}
export class ConfigService {
@@ -42,7 +47,12 @@ export class ConfigService {
themeId: 'cloud-dancer',
language: 'zh-CN',
logEnabled: false,
llmModelPath: ''
llmModelPath: '',
whisperModelName: 'base',
whisperModelDir: '',
whisperDownloadSource: 'tsinghua',
autoTranscribeVoice: false,
transcribeLanguages: ['zh']
}
})
}

View File

@@ -1,5 +1,5 @@
import { join, dirname } from 'path'
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync } from 'fs'
import { app } from 'electron'
export interface ContactCacheEntry {
@@ -15,7 +15,7 @@ export class ContactCacheService {
constructor(cacheBasePath?: string) {
const basePath = cacheBasePath && cacheBasePath.trim().length > 0
? cacheBasePath
: join(app.getPath('userData'), 'WeFlowCache')
: join(app.getPath('documents'), 'WeFlow')
this.cacheFilePath = join(basePath, 'contacts.json')
this.ensureCacheDir()
this.loadCache()
@@ -72,4 +72,13 @@ export class ContactCacheService {
console.error('ContactCacheService: 保存缓存失败', error)
}
}
clear(): void {
this.cache = {}
try {
rmSync(this.cacheFilePath, { force: true })
} catch (error) {
console.error('ContactCacheService: 清理缓存失败', error)
}
}
}

View File

@@ -1,10 +1,13 @@
import * as fs from 'fs'
import * as fs from 'fs'
import * as path from 'path'
import * as http from 'http'
import * as https from 'https'
import { fileURLToPath } from 'url'
import ExcelJS from 'exceljs'
import { ConfigService } from './config'
import { wcdbService } from './wcdbService'
import { imageDecryptService } from './imageDecryptService'
import { chatService } from './chatService'
// ChatLab 格式类型定义
interface ChatLabHeader {
@@ -64,6 +67,15 @@ export interface ExportOptions {
dateRange?: { start: number; end: number } | null
exportMedia?: boolean
exportAvatars?: boolean
exportImages?: boolean
exportVoices?: boolean
exportEmojis?: boolean
exportVoiceAsText?: boolean
}
interface MediaExportItem {
relativePath: string
kind: 'image' | 'voice' | 'emoji'
}
export interface ExportProgress {
@@ -216,6 +228,7 @@ class ExportService {
/**
* 解析消息内容为可读文本
* 注意:语音消息在这里返回占位符,实际转文字在导出时异步处理
*/
private parseMessageContent(content: string, localType: number): string | null {
if (!content) return null
@@ -224,7 +237,7 @@ class ExportService {
case 1:
return this.stripSenderPrefix(content)
case 3: return '[图片]'
case 34: return '[语音消息]'
case 34: return '[语音消息]' // 占位符,导出时会替换为转文字结果
case 42: return '[名片]'
case 43: return '[视频]'
case 47: return '[动画表情]'
@@ -235,6 +248,7 @@ class ExportService {
}
case 50: return this.parseVoipMessage(content)
case 10000: return this.cleanSystemMessage(content)
case 266287972401: return this.cleanSystemMessage(content) // 拍一拍
default:
if (content.includes('<type>57</type>')) {
const title = this.extractXmlValue(content, 'title')
@@ -258,9 +272,41 @@ class ExportService {
}
private cleanSystemMessage(content: string): string {
if (!content) return '[系统消息]'
// 先尝试提取特定的系统消息内容
// 1. 提取 sysmsg 中的文本内容
const sysmsgTextMatch = /<sysmsg[^>]*>([\s\S]*?)<\/sysmsg>/i.exec(content)
if (sysmsgTextMatch) {
content = sysmsgTextMatch[1]
}
// 2. 提取 revokemsg 撤回消息
const revokeMatch = /<replacemsg><!\[CDATA\[(.*?)\]\]><\/replacemsg>/i.exec(content)
if (revokeMatch) {
return revokeMatch[1].trim()
}
// 3. 提取 pat 拍一拍消息
const patMatch = /<template><!\[CDATA\[(.*?)\]\]><\/template>/i.exec(content)
if (patMatch) {
// 移除模板变量占位符
return patMatch[1]
.replace(/\$\{([^}]+)\}/g, (_, varName) => {
const varMatch = new RegExp(`<${varName}><!\\\[CDATA\\\[([^\]]*)\\\]\\\]><\/${varName}>`, 'i').exec(content)
return varMatch ? varMatch[1] : ''
})
.replace(/<[^>]+>/g, '')
.trim()
}
// 4. 处理 CDATA 内容
content = content.replace(/<!\[CDATA\[/g, '').replace(/\]\]>/g, '')
// 5. 移除所有 XML 标签
return content
.replace(/<img[^>]*>/gi, '')
.replace(/<\/?[a-zA-Z0-9_]+[^>]*>/g, '')
.replace(/<\/?[a-zA-Z0-9_:]+[^>]*>/g, '')
.replace(/\s+/g, ' ')
.trim() || '[系统消息]'
}
@@ -318,7 +364,6 @@ class ExportService {
return `[${callType}]`
} catch (e) {
console.error('[ExportService] Failed to parse VOIP message:', e)
return '[通话]'
}
}
@@ -356,6 +401,348 @@ class ExportService {
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
}
/**
* 导出媒体文件到指定目录
*/
private async exportMediaForMessage(
msg: any,
sessionId: string,
mediaDir: string,
options: { exportImages?: boolean; exportVoices?: boolean; exportEmojis?: boolean; exportVoiceAsText?: boolean }
): Promise<MediaExportItem | null> {
const localType = msg.localType
// 图片消息
if (localType === 3 && options.exportImages) {
const result = await this.exportImage(msg, sessionId, mediaDir)
if (result) {
}
return result
}
// 语音消息
if (localType === 34) {
// 如果开启了语音转文字,优先转文字(不导出语音文件)
if (options.exportVoiceAsText) {
return null // 转文字逻辑在消息内容处理中完成
}
// 否则导出语音文件
if (options.exportVoices) {
return this.exportVoice(msg, sessionId, mediaDir)
}
}
// 动画表情
if (localType === 47 && options.exportEmojis) {
const result = await this.exportEmoji(msg, sessionId, mediaDir)
if (result) {
}
return result
}
return null
}
/**
* 导出图片文件
*/
private async exportImage(msg: any, sessionId: string, mediaDir: string): Promise<MediaExportItem | null> {
try {
const imagesDir = path.join(mediaDir, 'media', 'images')
if (!fs.existsSync(imagesDir)) {
fs.mkdirSync(imagesDir, { recursive: true })
}
// 使用消息对象中已提取的字段
const imageMd5 = msg.imageMd5
const imageDatName = msg.imageDatName
if (!imageMd5 && !imageDatName) {
return null
}
const result = await imageDecryptService.decryptImage({
sessionId,
imageMd5,
imageDatName,
force: false // 先尝试缩略图
})
if (!result.success || !result.localPath) {
// 尝试获取缩略图
const thumbResult = await imageDecryptService.resolveCachedImage({
sessionId,
imageMd5,
imageDatName
})
if (!thumbResult.success || !thumbResult.localPath) {
return null
}
result.localPath = thumbResult.localPath
}
// 从 data URL 或 file URL 获取实际路径
let sourcePath = result.localPath
if (sourcePath.startsWith('data:')) {
// 是 data URL需要保存为文件
const base64Data = sourcePath.split(',')[1]
const ext = this.getExtFromDataUrl(sourcePath)
const fileName = `${imageMd5 || imageDatName || msg.localId}${ext}`
const destPath = path.join(imagesDir, fileName)
fs.writeFileSync(destPath, Buffer.from(base64Data, 'base64'))
return {
relativePath: `media/images/${fileName}`,
kind: 'image'
}
} else if (sourcePath.startsWith('file://')) {
sourcePath = fileURLToPath(sourcePath)
}
// 复制文件
if (fs.existsSync(sourcePath)) {
const ext = path.extname(sourcePath) || '.jpg'
const fileName = `${imageMd5 || imageDatName || msg.localId}${ext}`
const destPath = path.join(imagesDir, fileName)
if (!fs.existsSync(destPath)) {
fs.copyFileSync(sourcePath, destPath)
}
return {
relativePath: `media/images/${fileName}`,
kind: 'image'
}
}
return null
} catch (e) {
return null
}
}
/**
* 导出语音文件
*/
private async exportVoice(msg: any, sessionId: string, mediaDir: string): Promise<MediaExportItem | null> {
try {
const voicesDir = path.join(mediaDir, 'media', 'voices')
if (!fs.existsSync(voicesDir)) {
fs.mkdirSync(voicesDir, { recursive: true })
}
const msgId = String(msg.localId)
const fileName = `voice_${msgId}.wav`
const destPath = path.join(voicesDir, fileName)
// 如果已存在则跳过
if (fs.existsSync(destPath)) {
return {
relativePath: `media/voices/${fileName}`,
kind: 'voice'
}
}
// 调用 chatService 获取语音数据
const voiceResult = await chatService.getVoiceData(sessionId, msgId)
if (!voiceResult.success || !voiceResult.data) {
return null
}
// voiceResult.data 是 base64 编码的 wav 数据
const wavBuffer = Buffer.from(voiceResult.data, 'base64')
fs.writeFileSync(destPath, wavBuffer)
return {
relativePath: `media/voices/${fileName}`,
kind: 'voice'
}
} catch (e) {
return null
}
}
/**
* 转写语音为文字
*/
private async transcribeVoice(sessionId: string, msgId: string): Promise<string> {
try {
const transcript = await chatService.getVoiceTranscript(sessionId, msgId)
if (transcript.success && transcript.transcript) {
return `[语音转文字] ${transcript.transcript}`
}
return '[语音消息 - 转文字失败]'
} catch (e) {
return '[语音消息 - 转文字失败]'
}
}
/**
* 导出表情文件
*/
private async exportEmoji(msg: any, sessionId: string, mediaDir: string): Promise<MediaExportItem | null> {
try {
const emojisDir = path.join(mediaDir, 'media', 'emojis')
if (!fs.existsSync(emojisDir)) {
fs.mkdirSync(emojisDir, { recursive: true })
}
// 使用消息对象中已提取的字段
const emojiUrl = msg.emojiCdnUrl
const emojiMd5 = msg.emojiMd5
if (!emojiUrl && !emojiMd5) {
console.log('[ExportService] 表情消息缺少 url 和 md5, localId:', msg.localId, 'content:', msg.content?.substring(0, 200))
return null
}
console.log('[ExportService] 导出表情:', { localId: msg.localId, emojiMd5, emojiUrl: emojiUrl?.substring(0, 100) })
const key = emojiMd5 || String(msg.localId)
// 根据 URL 判断扩展名
let ext = '.gif'
if (emojiUrl) {
if (emojiUrl.includes('.png')) ext = '.png'
else if (emojiUrl.includes('.jpg') || emojiUrl.includes('.jpeg')) ext = '.jpg'
}
const fileName = `${key}${ext}`
const destPath = path.join(emojisDir, fileName)
// 如果已存在则跳过
if (fs.existsSync(destPath)) {
return {
relativePath: `media/emojis/${fileName}`,
kind: 'emoji'
}
}
// 下载表情
if (emojiUrl) {
const downloaded = await this.downloadFile(emojiUrl, destPath)
if (downloaded) {
return {
relativePath: `media/emojis/${fileName}`,
kind: 'emoji'
}
} else {
}
}
return null
} catch (e) {
return null
}
}
/**
* 从消息内容提取图片 MD5
*/
private extractImageMd5(content: string): string | undefined {
if (!content) return undefined
const match = /md5="([^"]+)"/i.exec(content)
return match?.[1]
}
/**
* 从消息内容提取图片 DAT 文件名
*/
private extractImageDatName(content: string): string | undefined {
if (!content) return undefined
// 尝试从 cdnthumburl 或其他字段提取
const urlMatch = /cdnthumburl[^>]*>([^<]+)/i.exec(content)
if (urlMatch) {
const urlParts = urlMatch[1].split('/')
const last = urlParts[urlParts.length - 1]
if (last && last.includes('_')) {
return last.split('_')[0]
}
}
return undefined
}
/**
* 从消息内容提取表情 URL
*/
private extractEmojiUrl(content: string): string | undefined {
if (!content) return undefined
// 参考 echotrace 的正则cdnurl\s*=\s*['"]([^'"]+)['"]
const attrMatch = /cdnurl\s*=\s*['"]([^'"]+)['"]/i.exec(content)
if (attrMatch) {
// 解码 &amp; 等实体
let url = attrMatch[1].replace(/&amp;/g, '&')
// URL 解码
try {
if (url.includes('%')) {
url = decodeURIComponent(url)
}
} catch { }
return url
}
// 备用:尝试 XML 标签形式
const tagMatch = /cdnurl[^>]*>([^<]+)/i.exec(content)
return tagMatch?.[1]
}
/**
* 从消息内容提取表情 MD5
*/
private extractEmojiMd5(content: string): string | undefined {
if (!content) return undefined
const match = /md5="([^"]+)"/i.exec(content) || /<md5>([^<]+)<\/md5>/i.exec(content)
return match?.[1]
}
/**
* 从 data URL 获取扩展名
*/
private getExtFromDataUrl(dataUrl: string): string {
if (dataUrl.includes('image/png')) return '.png'
if (dataUrl.includes('image/gif')) return '.gif'
if (dataUrl.includes('image/webp')) return '.webp'
return '.jpg'
}
/**
* 下载文件
*/
private async downloadFile(url: string, destPath: string): Promise<boolean> {
return new Promise((resolve) => {
try {
const protocol = url.startsWith('https') ? https : http
const request = protocol.get(url, { timeout: 30000 }, (response) => {
if (response.statusCode === 301 || response.statusCode === 302) {
const redirectUrl = response.headers.location
if (redirectUrl) {
this.downloadFile(redirectUrl, destPath).then(resolve)
return
}
}
if (response.statusCode !== 200) {
resolve(false)
return
}
const fileStream = fs.createWriteStream(destPath)
response.pipe(fileStream)
fileStream.on('finish', () => {
fileStream.close()
resolve(true)
})
fileStream.on('error', () => {
resolve(false)
})
})
request.on('error', () => resolve(false))
request.on('timeout', () => {
request.destroy()
resolve(false)
})
} catch {
resolve(false)
}
})
}
private async collectMessages(
sessionId: string,
cleanedMyWxid: string,
@@ -393,6 +780,7 @@ class ExportService {
const senderUsername = row.sender_username || ''
const isSendRaw = row.computed_is_send ?? row.is_send ?? '0'
const isSend = parseInt(isSendRaw, 10) === 1
const localId = parseInt(row.local_id || row.localId || '0', 10)
const actualSender = isSend ? cleanedMyWxid : (senderUsername || sessionId)
const memberInfo = await this.getContactInfo(actualSender)
@@ -406,12 +794,33 @@ class ExportService {
})
}
// 提取媒体相关字段
let imageMd5: string | undefined
let imageDatName: string | undefined
let emojiCdnUrl: string | undefined
let emojiMd5: string | undefined
if (localType === 3 && content) {
// 图片消息
imageMd5 = this.extractImageMd5(content)
imageDatName = this.extractImageDatName(content)
} else if (localType === 47 && content) {
// 动画表情
emojiCdnUrl = this.extractEmojiUrl(content)
emojiMd5 = this.extractEmojiMd5(content)
}
rows.push({
localId,
createTime,
localType,
content,
senderUsername: actualSender,
isSend
isSend,
imageMd5,
imageDatName,
emojiCdnUrl,
emojiMd5
})
if (firstTime === null || createTime < firstTime) firstTime = createTime
@@ -653,6 +1062,31 @@ class ExportService {
}
}
/**
* 生成通用的导出元数据 (参考 ChatLab 格式)
*/
private getExportMeta(
sessionId: string,
sessionInfo: { displayName: string },
isGroup: boolean,
sessionAvatar?: string
): { chatlab: ChatLabHeader; meta: ChatLabMeta } {
return {
chatlab: {
version: '0.0.2',
exportedAt: Math.floor(Date.now() / 1000),
generator: 'WeFlow'
},
meta: {
name: sessionInfo.displayName,
platform: 'wechat',
type: isGroup ? 'group' : 'private',
...(isGroup && { groupId: sessionId }),
...(sessionAvatar && { groupAvatar: sessionAvatar })
}
}
}
/**
* 导出单个会话为 ChatLab 格式
*/
@@ -693,21 +1127,29 @@ class ExportService {
phase: 'exporting'
})
const chatLabMessages: ChatLabMessage[] = allMessages.map((msg) => {
const chatLabMessages: ChatLabMessage[] = []
for (const msg of allMessages) {
const memberInfo = collected.memberSet.get(msg.senderUsername)?.member || {
platformId: msg.senderUsername,
accountName: msg.senderUsername,
groupNickname: undefined
}
return {
let content = this.parseMessageContent(msg.content, msg.localType)
// 如果是语音消息且开启了转文字
if (msg.localType === 34 && options.exportVoiceAsText) {
content = await this.transcribeVoice(sessionId, String(msg.localId))
}
chatLabMessages.push({
sender: msg.senderUsername,
accountName: memberInfo.accountName,
groupNickname: memberInfo.groupNickname,
timestamp: msg.createTime,
type: this.convertMessageType(msg.localType, msg.content),
content: this.parseMessageContent(msg.content, msg.localType)
}
content: content
})
}
const avatarMap = options.exportAvatars
? await this.exportAvatars(
@@ -727,19 +1169,11 @@ class ExportService {
return avatar ? { ...info.member, avatar } : info.member
})
const { chatlab, meta } = this.getExportMeta(sessionId, sessionInfo, isGroup, sessionAvatar)
const chatLabExport: ChatLabExport = {
chatlab: {
version: '0.0.1',
exportedAt: Math.floor(Date.now() / 1000),
generator: 'WeFlow'
},
meta: {
name: sessionInfo.displayName,
platform: 'wechat',
type: isGroup ? 'group' : 'private',
...(isGroup && { groupId: sessionId }),
...(sessionAvatar && { groupAvatar: sessionAvatar })
},
chatlab,
meta,
members,
messages: chatLabMessages
}
@@ -778,7 +1212,6 @@ class ExportService {
return { success: true }
} catch (e) {
console.error('ExportService: 导出失败:', e)
return { success: false, error: String(e) }
}
}
@@ -841,7 +1274,11 @@ class ExportService {
phase: 'writing'
})
const detailedExport = {
const { chatlab, meta } = this.getExportMeta(sessionId, sessionInfo, isGroup)
const detailedExport: any = {
chatlab,
meta,
session: {
wxid: sessionId,
nickname: sessionInfo.displayName,
@@ -889,7 +1326,285 @@ class ExportService {
return { success: true }
} catch (e) {
console.error('ExportService: 导出失败:', e)
return { success: false, error: String(e) }
}
}
/**
* 导出单个会话为 Excel 格式(参考 echotrace 格式)
*/
async exportSessionToExcel(
sessionId: string,
outputPath: string,
options: ExportOptions,
onProgress?: (progress: ExportProgress) => void
): Promise<{ success: boolean; error?: string }> {
try {
const conn = await this.ensureConnected()
if (!conn.success || !conn.cleanedWxid) return { success: false, error: conn.error }
const cleanedMyWxid = conn.cleanedWxid
const isGroup = sessionId.includes('@chatroom')
const sessionInfo = await this.getContactInfo(sessionId)
const myInfo = await this.getContactInfo(cleanedMyWxid)
// 获取会话的备注信息
const sessionContact = await wcdbService.getContact(sessionId)
const sessionRemark = sessionContact.success && sessionContact.contact?.remark ? sessionContact.contact.remark : ''
const sessionNickname = sessionContact.success && sessionContact.contact?.nickName ? sessionContact.contact.nickName : sessionId
onProgress?.({
current: 0,
total: 100,
currentSession: sessionInfo.displayName,
phase: 'preparing'
})
const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange)
onProgress?.({
current: 30,
total: 100,
currentSession: sessionInfo.displayName,
phase: 'exporting'
})
// 创建 Excel 工作簿
const workbook = new ExcelJS.Workbook()
workbook.creator = 'WeFlow'
workbook.created = new Date()
const worksheet = workbook.addWorksheet('聊天记录')
let currentRow = 1
// 第一行:会话信息标题
worksheet.mergeCells(currentRow, 1, currentRow, 8)
const titleCell = worksheet.getCell(currentRow, 1)
titleCell.value = '会话信息'
titleCell.font = { name: 'Calibri', bold: true, size: 11 }
titleCell.alignment = { vertical: 'middle', horizontal: 'left' }
worksheet.getRow(currentRow).height = 25
currentRow++
// 第二行:会话详细信息
worksheet.getCell(currentRow, 1).value = '微信ID'
worksheet.getCell(currentRow, 1).font = { name: 'Calibri', bold: true, size: 11 }
worksheet.mergeCells(currentRow, 2, currentRow, 3)
worksheet.getCell(currentRow, 2).value = sessionId
worksheet.getCell(currentRow, 2).font = { name: 'Calibri', size: 11 }
worksheet.getCell(currentRow, 4).value = '昵称'
worksheet.getCell(currentRow, 4).font = { name: 'Calibri', bold: true, size: 11 }
worksheet.getCell(currentRow, 5).value = sessionNickname
worksheet.getCell(currentRow, 5).font = { name: 'Calibri', size: 11 }
if (isGroup) {
worksheet.getCell(currentRow, 6).value = '备注'
worksheet.getCell(currentRow, 6).font = { name: 'Calibri', bold: true, size: 11 }
worksheet.mergeCells(currentRow, 7, currentRow, 8)
worksheet.getCell(currentRow, 7).value = sessionRemark
worksheet.getCell(currentRow, 7).font = { name: 'Calibri', size: 11 }
}
worksheet.getRow(currentRow).height = 20
currentRow++
// 第三行:导出元数据
const { chatlab, meta: exportMeta } = this.getExportMeta(sessionId, sessionInfo, isGroup)
worksheet.getCell(currentRow, 1).value = '导出工具'
worksheet.getCell(currentRow, 1).font = { name: 'Calibri', bold: true, size: 11 }
worksheet.getCell(currentRow, 2).value = chatlab.generator
worksheet.getCell(currentRow, 2).font = { name: 'Calibri', size: 10 }
worksheet.getCell(currentRow, 3).value = '导出版本'
worksheet.getCell(currentRow, 3).font = { name: 'Calibri', bold: true, size: 11 }
worksheet.getCell(currentRow, 4).value = chatlab.version
worksheet.getCell(currentRow, 4).font = { name: 'Calibri', size: 10 }
worksheet.getCell(currentRow, 5).value = '平台'
worksheet.getCell(currentRow, 5).font = { name: 'Calibri', bold: true, size: 11 }
worksheet.getCell(currentRow, 6).value = exportMeta.platform
worksheet.getCell(currentRow, 6).font = { name: 'Calibri', size: 10 }
worksheet.getCell(currentRow, 7).value = '导出时间'
worksheet.getCell(currentRow, 7).font = { name: 'Calibri', bold: true, size: 11 }
worksheet.getCell(currentRow, 8).value = this.formatTimestamp(chatlab.exportedAt)
worksheet.getCell(currentRow, 8).font = { name: 'Calibri', size: 10 }
worksheet.getRow(currentRow).height = 20
currentRow++
// 表头行
const headers = ['序号', '时间', '发送者昵称', '发送者微信ID', '发送者备注', '发送者身份', '消息类型', '内容']
const headerRow = worksheet.getRow(currentRow)
headerRow.height = 22
headers.forEach((header, index) => {
const cell = headerRow.getCell(index + 1)
cell.value = header
cell.font = { name: 'Calibri', bold: true, size: 11 }
cell.fill = {
type: 'pattern',
pattern: 'solid',
fgColor: { argb: 'FFE8F5E9' }
}
cell.alignment = { vertical: 'middle', horizontal: 'center' }
})
currentRow++
// 设置列宽
worksheet.getColumn(1).width = 8 // 序号
worksheet.getColumn(2).width = 20 // 时间
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 exportMediaEnabled = options.exportImages || options.exportVoices || options.exportEmojis
const sessionDir = path.dirname(outputPath) // 会话目录,用于媒体导出
// 媒体导出缓存
const mediaCache = new Map<string, MediaExportItem | null>()
for (let i = 0; i < sortedMessages.length; i++) {
const msg = sortedMessages[i]
// 导出媒体文件
let mediaItem: MediaExportItem | null = null
if (exportMediaEnabled) {
const mediaKey = `${msg.localType}_${msg.localId}`
if (mediaCache.has(mediaKey)) {
mediaItem = mediaCache.get(mediaKey) || null
} else {
mediaItem = await this.exportMediaForMessage(msg, sessionId, sessionDir, {
exportImages: options.exportImages,
exportVoices: options.exportVoices,
exportEmojis: options.exportEmojis,
exportVoiceAsText: options.exportVoiceAsText
})
mediaCache.set(mediaKey, mediaItem)
}
}
// 确定发送者信息
let senderRole: string
let senderWxid: string
let senderNickname: string
let senderRemark: string = ''
if (msg.isSend) {
// 我发送的消息
senderRole = '我'
senderWxid = cleanedMyWxid
senderNickname = myInfo.displayName || cleanedMyWxid
senderRemark = ''
} else if (isGroup && msg.senderUsername) {
// 群消息
senderWxid = msg.senderUsername
// 用 getContact 获取联系人详情,分别取昵称和备注
const contactDetail = await wcdbService.getContact(msg.senderUsername)
if (contactDetail.success && contactDetail.contact) {
// nickName 才是真正的昵称
senderNickname = contactDetail.contact.nickName || msg.senderUsername
senderRemark = contactDetail.contact.remark || ''
// 身份:有备注显示备注,没有显示昵称
senderRole = senderRemark || senderNickname
} else {
senderNickname = msg.senderUsername
senderRemark = ''
senderRole = msg.senderUsername
}
} else {
// 单聊对方消息 - 用 getContact 获取联系人详情
senderWxid = sessionId
const contactDetail = await wcdbService.getContact(sessionId)
if (contactDetail.success && contactDetail.contact) {
senderNickname = contactDetail.contact.nickName || sessionId
senderRemark = contactDetail.contact.remark || ''
senderRole = senderRemark || senderNickname
} else {
senderNickname = sessionInfo.displayName || sessionId
senderRemark = ''
senderRole = senderNickname
}
}
const row = worksheet.getRow(currentRow)
row.height = 24
// 确定内容:如果有媒体文件导出成功则显示相对路径,否则显示解析后的内容
const contentValue = mediaItem
? mediaItem.relativePath
: (this.parseMessageContent(msg.content, msg.localType) || '')
// 调试日志
if (msg.localType === 3 || msg.localType === 47) {
}
worksheet.getCell(currentRow, 1).value = i + 1
worksheet.getCell(currentRow, 2).value = this.formatTimestamp(msg.createTime)
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 cell = worksheet.getCell(currentRow, col)
cell.font = { name: 'Calibri', size: 11 }
cell.alignment = { vertical: 'middle', wrapText: false }
}
currentRow++
// 每处理 100 条消息报告一次进度
if ((i + 1) % 100 === 0) {
const progress = 30 + Math.floor((i + 1) / sortedMessages.length * 50)
onProgress?.({
current: progress,
total: 100,
currentSession: sessionInfo.displayName,
phase: 'exporting'
})
}
}
onProgress?.({
current: 90,
total: 100,
currentSession: sessionInfo.displayName,
phase: 'writing'
})
// 写入文件
await workbook.xlsx.writeFile(outputPath)
onProgress?.({
current: 100,
total: 100,
currentSession: sessionInfo.displayName,
phase: 'complete'
})
return { success: true }
} catch (e) {
// 处理文件被占用的错误
if (e instanceof Error) {
if (e.message.includes('EBUSY') || e.message.includes('resource busy') || e.message.includes('locked')) {
return { success: false, error: '文件已经打开,请关闭后再导出' }
}
}
return { success: false, error: String(e) }
}
}
@@ -928,15 +1643,25 @@ class ExportService {
})
const safeName = sessionInfo.displayName.replace(/[<>:"/\\|?*]/g, '_')
// 为每个会话创建单独的文件夹
const sessionDir = path.join(outputDir, safeName)
if (!fs.existsSync(sessionDir)) {
fs.mkdirSync(sessionDir, { recursive: true })
}
let ext = '.json'
if (options.format === 'chatlab-jsonl') ext = '.jsonl'
const outputPath = path.join(outputDir, `${safeName}${ext}`)
else if (options.format === 'excel') ext = '.xlsx'
const outputPath = path.join(sessionDir, `${safeName}${ext}`)
let result: { success: boolean; error?: string }
if (options.format === 'json') {
result = await this.exportSessionToDetailedJson(sessionId, outputPath, options)
} else if (options.format === 'chatlab' || options.format === 'chatlab-jsonl') {
result = await this.exportSessionToChatLab(sessionId, outputPath, options)
} else if (options.format === 'excel') {
result = await this.exportSessionToExcel(sessionId, outputPath, options)
} else {
result = { success: false, error: `不支持的格式: ${options.format}` }
}
@@ -964,3 +1689,4 @@ class ExportService {
}
export const exportService = new ExportService()

View File

@@ -2,12 +2,44 @@ import { app, BrowserWindow } from 'electron'
import { basename, dirname, extname, join } from 'path'
import { pathToFileURL } from 'url'
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, appendFileSync } from 'fs'
import { writeFile } from 'fs/promises'
import { writeFile, rm, readdir } from 'fs/promises'
import crypto from 'crypto'
import { Worker } from 'worker_threads'
import { ConfigService } from './config'
import { wcdbService } from './wcdbService'
// 获取 ffmpeg-static 的路径
function getStaticFfmpegPath(): string | null {
try {
// 方法1: 直接 require ffmpeg-static
// eslint-disable-next-line @typescript-eslint/no-var-requires
const ffmpegStatic = require('ffmpeg-static')
if (typeof ffmpegStatic === 'string' && existsSync(ffmpegStatic)) {
return ffmpegStatic
}
// 方法2: 手动构建路径(开发环境)
const devPath = join(process.cwd(), 'node_modules', 'ffmpeg-static', 'ffmpeg.exe')
if (existsSync(devPath)) {
return devPath
}
// 方法3: 打包后的路径
if (app.isPackaged) {
const resourcesPath = process.resourcesPath
const packedPath = join(resourcesPath, 'app.asar.unpacked', 'node_modules', 'ffmpeg-static', 'ffmpeg.exe')
if (existsSync(packedPath)) {
return packedPath
}
}
return null
} catch {
return null
}
}
type DecryptResult = {
success: boolean
localPath?: string
@@ -36,14 +68,7 @@ export class ImageDecryptService {
const metaStr = meta ? ` ${JSON.stringify(meta)}` : ''
const logLine = `[${timestamp}] [ImageDecrypt] ${message}${metaStr}\n`
// 同时输出到控制台
if (meta) {
console.info(message, meta)
} else {
console.info(message)
}
// 写入日志文件
// 只写入文件,不输出到控制台
this.writeLog(logLine)
}
@@ -83,7 +108,6 @@ export class ImageDecryptService {
for (const key of cacheKeys) {
const cached = this.resolvedCache.get(key)
if (cached && existsSync(cached) && this.isImageFile(cached)) {
this.logInfo('缓存命中(从Map)', { key, path: cached, isThumb: this.isThumbnailPath(cached) })
const dataUrl = this.fileToDataUrl(cached)
const isThumb = this.isThumbnailPath(cached)
const hasUpdate = isThumb ? (this.updateFlags.get(key) ?? false) : false
@@ -103,7 +127,6 @@ export class ImageDecryptService {
for (const key of cacheKeys) {
const existing = this.findCachedOutput(key, false, payload.sessionId)
if (existing) {
this.logInfo('缓存命中(文件系统)', { key, path: existing, isThumb: this.isThumbnailPath(existing) })
this.cacheResolvedPaths(key, payload.imageMd5, payload.imageDatName, existing)
const dataUrl = this.fileToDataUrl(existing)
const isThumb = this.isThumbnailPath(existing)
@@ -238,20 +261,39 @@ export class ImageDecryptService {
const aesKey = this.resolveAesKey(aesKeyRaw)
this.logInfo('开始解密DAT文件', { datPath, xorKey, hasAesKey: !!aesKey })
const decrypted = await this.decryptDatAuto(datPath, xorKey, aesKey)
let decrypted = await this.decryptDatAuto(datPath, xorKey, aesKey)
const ext = this.detectImageExtension(decrypted) || '.jpg'
// 检查是否是 wxgf 格式,如果是则尝试提取真实图片数据
const wxgfResult = await this.unwrapWxgf(decrypted)
decrypted = wxgfResult.data
const outputPath = this.getCacheOutputPathFromDat(datPath, ext, payload.sessionId)
let ext = this.detectImageExtension(decrypted)
// 如果是 wxgf 格式且没检测到扩展名
if (wxgfResult.isWxgf && !ext) {
ext = '.hevc'
}
const finalExt = ext || '.jpg'
const outputPath = this.getCacheOutputPathFromDat(datPath, finalExt, payload.sessionId)
await writeFile(outputPath, decrypted)
this.logInfo('解密成功', { outputPath, size: decrypted.length })
// 对于 hevc 格式,返回错误提示
if (finalExt === '.hevc') {
return {
success: false,
error: '此图片为微信新格式(wxgf),需要安装 ffmpeg 才能显示',
isThumb: this.isThumbnailPath(datPath)
}
}
const isThumb = this.isThumbnailPath(datPath)
this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, outputPath)
if (!isThumb) {
this.clearUpdateFlags(cacheKey, payload.imageMd5, payload.imageDatName)
}
const dataUrl = this.bufferToDataUrl(decrypted, ext)
const dataUrl = this.bufferToDataUrl(decrypted, finalExt)
const localPath = dataUrl || this.filePathToUrl(outputPath)
this.emitCacheResolved(payload, cacheKey, localPath)
return { success: true, localPath, isThumb }
@@ -904,6 +946,18 @@ export class ImageDecryptService {
extensions: string[],
preferHd: boolean
): string | null {
// 先检查并删除旧的 .hevc 文件ffmpeg 转换失败时遗留的)
const hevcThumb = join(dirPath, `${normalizedKey}_thumb.hevc`)
const hevcHd = join(dirPath, `${normalizedKey}_hd.hevc`)
try {
if (existsSync(hevcThumb)) {
require('fs').unlinkSync(hevcThumb)
}
if (existsSync(hevcHd)) {
require('fs').unlinkSync(hevcHd)
}
} catch { }
for (const ext of extensions) {
if (preferHd) {
const hdPath = join(dirPath, `${normalizedKey}_hd${ext}`)
@@ -1406,6 +1460,159 @@ export class ImageDecryptService {
return mostCommonKey
}
/**
* 解包 wxgf 格式
* wxgf 是微信的图片格式,内部使用 HEVC 编码
*/
private async unwrapWxgf(buffer: Buffer): Promise<{ data: Buffer; isWxgf: boolean }> {
// 检查是否是 wxgf 格式 (77 78 67 66 = "wxgf")
if (buffer.length < 20 ||
buffer[0] !== 0x77 || buffer[1] !== 0x78 ||
buffer[2] !== 0x67 || buffer[3] !== 0x66) {
return { data: buffer, isWxgf: false }
}
// 先尝试搜索内嵌的传统图片签名
for (let i = 4; i < Math.min(buffer.length - 12, 4096); i++) {
if (buffer[i] === 0xff && buffer[i + 1] === 0xd8 && buffer[i + 2] === 0xff) {
return { data: buffer.subarray(i), isWxgf: false }
}
if (buffer[i] === 0x89 && buffer[i + 1] === 0x50 &&
buffer[i + 2] === 0x4e && buffer[i + 3] === 0x47) {
return { data: buffer.subarray(i), isWxgf: false }
}
}
// 提取 HEVC NALU 裸流
const hevcData = this.extractHevcNalu(buffer)
if (!hevcData || hevcData.length < 100) {
return { data: buffer, isWxgf: true }
}
// 尝试用 ffmpeg 转换
try {
const jpgData = await this.convertHevcToJpg(hevcData)
if (jpgData && jpgData.length > 0) {
return { data: jpgData, isWxgf: false }
}
} catch {
// ffmpeg 转换失败
}
return { data: hevcData, isWxgf: true }
}
/**
* 从 wxgf 数据中提取 HEVC NALU 裸流
*/
private extractHevcNalu(buffer: Buffer): Buffer | null {
const nalUnits: Buffer[] = []
let i = 4
while (i < buffer.length - 4) {
if (buffer[i] === 0x00 && buffer[i + 1] === 0x00 &&
buffer[i + 2] === 0x00 && buffer[i + 3] === 0x01) {
let nalStart = i
let nalEnd = buffer.length
for (let j = i + 4; j < buffer.length - 3; j++) {
if (buffer[j] === 0x00 && buffer[j + 1] === 0x00) {
if (buffer[j + 2] === 0x01 ||
(buffer[j + 2] === 0x00 && j + 3 < buffer.length && buffer[j + 3] === 0x01)) {
nalEnd = j
break
}
}
}
const nalUnit = buffer.subarray(nalStart, nalEnd)
if (nalUnit.length > 3) {
nalUnits.push(nalUnit)
}
i = nalEnd
} else {
i++
}
}
if (nalUnits.length === 0) {
for (let j = 4; j < buffer.length - 4; j++) {
if (buffer[j] === 0x00 && buffer[j + 1] === 0x00 &&
buffer[j + 2] === 0x00 && buffer[j + 3] === 0x01) {
return buffer.subarray(j)
}
}
return null
}
return Buffer.concat(nalUnits)
}
/**
* 获取 ffmpeg 可执行文件路径
*/
private getFfmpegPath(): string {
const staticPath = getStaticFfmpegPath()
this.logInfo('ffmpeg 路径检测', { staticPath, exists: staticPath ? existsSync(staticPath) : false })
if (staticPath) {
return staticPath
}
// 回退到系统 ffmpeg
return 'ffmpeg'
}
/**
* 使用 ffmpeg 将 HEVC 裸流转换为 JPG
*/
private convertHevcToJpg(hevcData: Buffer): Promise<Buffer | null> {
const ffmpeg = this.getFfmpegPath()
this.logInfo('ffmpeg 转换开始', { ffmpegPath: ffmpeg, hevcSize: hevcData.length })
return new Promise((resolve) => {
const { spawn } = require('child_process')
const chunks: Buffer[] = []
const errChunks: Buffer[] = []
const proc = spawn(ffmpeg, [
'-hide_banner',
'-loglevel', 'error',
'-f', 'hevc',
'-i', 'pipe:0',
'-vframes', '1',
'-q:v', '3',
'-f', 'mjpeg',
'pipe:1'
], {
stdio: ['pipe', 'pipe', 'pipe'],
windowsHide: true
})
proc.stdout.on('data', (chunk: Buffer) => chunks.push(chunk))
proc.stderr.on('data', (chunk: Buffer) => errChunks.push(chunk))
proc.on('close', (code: number) => {
if (code === 0 && chunks.length > 0) {
this.logInfo('ffmpeg 转换成功', { outputSize: Buffer.concat(chunks).length })
resolve(Buffer.concat(chunks))
} else {
const errMsg = Buffer.concat(errChunks).toString()
this.logInfo('ffmpeg 转换失败', { code, error: errMsg })
resolve(null)
}
})
proc.on('error', (err: Error) => {
this.logInfo('ffmpeg 进程错误', { error: err.message })
resolve(null)
})
proc.stdin.write(hevcData)
proc.stdin.end()
})
}
// 保留原有的解密到文件方法(用于兼容)
async decryptToFile(inputPath: string, outputPath: string, xorKey: number, aesKey?: Buffer): Promise<void> {
const version = this.getDatVersion(inputPath)
@@ -1430,6 +1637,71 @@ export class ImageDecryptService {
await writeFile(outputPath, decrypted)
}
async clearCache(): Promise<{ success: boolean; error?: string }> {
this.resolvedCache.clear()
this.hardlinkCache.clear()
this.pending.clear()
this.updateFlags.clear()
this.cacheIndexed = false
this.cacheIndexing = null
const configured = this.configService.get('cachePath')
const root = configured
? join(configured, 'Images')
: join(app.getPath('documents'), 'WeFlow', 'Images')
try {
if (!existsSync(root)) {
return { success: true }
}
const monthPattern = /^\d{4}-\d{2}$/
const clearFilesInDir = async (dirPath: string): Promise<void> => {
let entries: Array<{ name: string; isDirectory: () => boolean }>
try {
entries = await readdir(dirPath, { withFileTypes: true })
} catch {
return
}
for (const entry of entries) {
const fullPath = join(dirPath, entry.name)
if (entry.isDirectory()) {
await clearFilesInDir(fullPath)
continue
}
try {
await rm(fullPath, { force: true })
} catch { }
}
}
const traverse = async (dirPath: string): Promise<void> => {
let entries: Array<{ name: string; isDirectory: () => boolean }>
try {
entries = await readdir(dirPath, { withFileTypes: true })
} catch {
return
}
for (const entry of entries) {
const fullPath = join(dirPath, entry.name)
if (entry.isDirectory()) {
if (monthPattern.test(entry.name)) {
await clearFilesInDir(fullPath)
} else {
await traverse(fullPath)
}
continue
}
try {
await rm(fullPath, { force: true })
} catch { }
}
}
await traverse(root)
return { success: true }
} catch (e) {
return { success: false, error: String(e) }
}
}
}
export const imageDecryptService = new ImageDecryptService()

View File

@@ -1,5 +1,5 @@
import { join, dirname } from 'path'
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync } from 'fs'
import { app } from 'electron'
export interface SessionMessageCacheEntry {
@@ -15,7 +15,7 @@ export class MessageCacheService {
constructor(cacheBasePath?: string) {
const basePath = cacheBasePath && cacheBasePath.trim().length > 0
? cacheBasePath
: join(app.getPath('userData'), 'WeFlowCache')
: join(app.getPath('documents'), 'WeFlow')
this.cacheFilePath = join(basePath, 'session-messages.json')
this.ensureCacheDir()
this.loadCache()
@@ -65,4 +65,13 @@ export class MessageCacheService {
console.error('MessageCacheService: 保存缓存失败', error)
}
}
clear(): void {
this.cache = {}
try {
rmSync(this.cacheFilePath, { force: true })
} catch (error) {
console.error('MessageCacheService: 清理缓存失败', error)
}
}
}

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

@@ -0,0 +1,377 @@
import { app } from 'electron'
import { existsSync, mkdirSync, statSync, unlinkSync, createWriteStream } from 'fs'
import { join } from 'path'
import * as https from 'https'
import * as http from 'http'
import { ConfigService } from './config'
// Sherpa-onnx 类型定义
type OfflineRecognizer = any
type OfflineStream = any
type ModelInfo = {
name: string
files: {
model: string
tokens: string
}
sizeBytes: number
sizeLabel: string
}
type DownloadProgress = {
modelName: string
downloadedBytes: number
totalBytes?: number
percent?: number
}
const SENSEVOICE_MODEL: ModelInfo = {
name: 'SenseVoiceSmall',
files: {
model: 'model.int8.onnx',
tokens: 'tokens.txt'
},
sizeBytes: 245_000_000,
sizeLabel: '245 MB'
}
const MODEL_DOWNLOAD_URLS = {
model: 'https://modelscope.cn/models/pengzhendong/sherpa-onnx-sense-voice-zh-en-ja-ko-yue/resolve/master/model.int8.onnx',
tokens: 'https://modelscope.cn/models/pengzhendong/sherpa-onnx-sense-voice-zh-en-ja-ko-yue/resolve/master/tokens.txt'
}
export class VoiceTranscribeService {
private configService = new ConfigService()
private downloadTasks = new Map<string, Promise<{ success: boolean; path?: string; error?: string }>>()
private recognizer: OfflineRecognizer | null = null
private isInitializing = false
private resolveModelDir(): string {
const configured = this.configService.get('whisperModelDir') as string | undefined
if (configured) return configured
return join(app.getPath('documents'), 'WeFlow', 'models', 'sensevoice')
}
private resolveModelPath(fileName: string): string {
return join(this.resolveModelDir(), fileName)
}
/**
* 检查模型状态
*/
async getModelStatus(): Promise<{
success: boolean
exists?: boolean
modelPath?: string
tokensPath?: string
sizeBytes?: number
error?: string
}> {
try {
const modelPath = this.resolveModelPath(SENSEVOICE_MODEL.files.model)
const tokensPath = this.resolveModelPath(SENSEVOICE_MODEL.files.tokens)
const modelExists = existsSync(modelPath)
const tokensExists = existsSync(tokensPath)
const exists = modelExists && tokensExists
if (!exists) {
return { success: true, exists: false, modelPath, tokensPath }
}
const modelSize = statSync(modelPath).size
const tokensSize = statSync(tokensPath).size
const totalSize = modelSize + tokensSize
return {
success: true,
exists: true,
modelPath,
tokensPath,
sizeBytes: totalSize
}
} catch (error) {
return { success: false, error: String(error) }
}
}
/**
* 下载模型文件
*/
async downloadModel(
onProgress?: (progress: DownloadProgress) => void
): Promise<{ success: boolean; modelPath?: string; tokensPath?: string; error?: string }> {
const cacheKey = 'sensevoice'
const pending = this.downloadTasks.get(cacheKey)
if (pending) return pending
const task = (async () => {
try {
const modelDir = this.resolveModelDir()
if (!existsSync(modelDir)) {
mkdirSync(modelDir, { recursive: true })
}
const modelPath = this.resolveModelPath(SENSEVOICE_MODEL.files.model)
const tokensPath = this.resolveModelPath(SENSEVOICE_MODEL.files.tokens)
// 初始进度
onProgress?.({
modelName: SENSEVOICE_MODEL.name,
downloadedBytes: 0,
totalBytes: SENSEVOICE_MODEL.sizeBytes,
percent: 0
})
// 下载模型文件 (40%)
console.info('[VoiceTranscribe] 开始下载模型文件...')
await this.downloadToFile(
MODEL_DOWNLOAD_URLS.model,
modelPath,
'model',
(downloaded, total) => {
const percent = total ? (downloaded / total) * 40 : undefined
onProgress?.({
modelName: SENSEVOICE_MODEL.name,
downloadedBytes: downloaded,
totalBytes: SENSEVOICE_MODEL.sizeBytes,
percent
})
}
)
// 下载 tokens 文件 (30%)
console.info('[VoiceTranscribe] 开始下载 tokens 文件...')
await this.downloadToFile(
MODEL_DOWNLOAD_URLS.tokens,
tokensPath,
'tokens',
(downloaded, total) => {
const modelSize = existsSync(modelPath) ? statSync(modelPath).size : 0
const percent = total ? 40 + (downloaded / total) * 30 : 40
onProgress?.({
modelName: SENSEVOICE_MODEL.name,
downloadedBytes: modelSize + downloaded,
totalBytes: SENSEVOICE_MODEL.sizeBytes,
percent
})
}
)
console.info('[VoiceTranscribe] 模型下载完成')
console.info('[VoiceTranscribe] 所有文件下载完成')
return { success: true, modelPath, tokensPath }
} catch (error) {
const modelPath = this.resolveModelPath(SENSEVOICE_MODEL.files.model)
const tokensPath = this.resolveModelPath(SENSEVOICE_MODEL.files.tokens)
try {
if (existsSync(modelPath)) unlinkSync(modelPath)
if (existsSync(tokensPath)) unlinkSync(tokensPath)
} catch { }
return { success: false, error: String(error) }
} finally {
this.downloadTasks.delete(cacheKey)
}
})()
this.downloadTasks.set(cacheKey, task)
return task
}
/**
* 转写 WAV 音频数据 (后台 Worker Threads 版本)
*/
async transcribeWavBuffer(
wavData: Buffer,
onPartial?: (text: string) => void,
languages?: string[]
): Promise<{ success: boolean; transcript?: string; error?: string }> {
return new Promise((resolve) => {
try {
const modelPath = this.resolveModelPath(SENSEVOICE_MODEL.files.model)
const tokensPath = this.resolveModelPath(SENSEVOICE_MODEL.files.tokens)
if (!existsSync(modelPath) || !existsSync(tokensPath)) {
resolve({ success: false, error: '模型文件不存在,请先下载模型' })
return
}
// 获取配置的语言列表,如果没有传入则从配置读取
let supportedLanguages = languages
if (!supportedLanguages || supportedLanguages.length === 0) {
supportedLanguages = this.configService.get('transcribeLanguages')
// 如果配置中也没有或为空,使用默认值
if (!supportedLanguages || supportedLanguages.length === 0) {
supportedLanguages = ['zh', 'yue']
}
}
const { Worker } = require('worker_threads')
// main.js 和 transcribeWorker.js 同在 dist-electron 目录下
const workerPath = join(__dirname, 'transcribeWorker.js')
const worker = new Worker(workerPath, {
workerData: {
modelPath,
tokensPath,
wavData,
sampleRate: 16000,
languages: supportedLanguages
}
})
let finalTranscript = ''
worker.on('message', (msg: any) => {
console.log('[VoiceTranscribe] Worker 消息:', msg)
if (msg.type === 'partial') {
onPartial?.(msg.text)
} else if (msg.type === 'final') {
finalTranscript = msg.text
console.log('[VoiceTranscribe] 最终文本:', finalTranscript)
resolve({ success: true, transcript: finalTranscript })
worker.terminate()
} else if (msg.type === 'error') {
console.error('[VoiceTranscribe] Worker 错误:', msg.error)
resolve({ success: false, error: msg.error })
worker.terminate()
}
})
worker.on('error', (err: Error) => {
resolve({ success: false, error: String(err) })
})
worker.on('exit', (code: number) => {
if (code !== 0) {
console.error(`[VoiceTranscribe] Worker stopped with exit code ${code}`)
resolve({ success: false, error: `Worker exited with code ${code}` })
}
})
} catch (error) {
resolve({ success: false, error: String(error) })
}
})
}
/**
* 下载文件
*/
private downloadToFile(
url: string,
targetPath: string,
fileName: string,
onProgress?: (downloaded: number, total?: number) => void,
remainingRedirects = 5
): Promise<void> {
return new Promise((resolve, reject) => {
const protocol = url.startsWith('https') ? https : http
console.info(`[VoiceTranscribe] 下载 ${fileName}:`, url)
const options = {
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
},
timeout: 30000 // 30秒连接超时
}
const request = protocol.get(url, options, (response) => {
console.info(`[VoiceTranscribe] ${fileName} 响应状态:`, response.statusCode)
// 处理重定向
if ([301, 302, 303, 307, 308].includes(response.statusCode || 0) && response.headers.location) {
if (remainingRedirects <= 0) {
reject(new Error('重定向次数过多'))
return
}
console.info(`[VoiceTranscribe] 重定向到:`, response.headers.location)
this.downloadToFile(response.headers.location, targetPath, fileName, onProgress, remainingRedirects - 1)
.then(resolve)
.catch(reject)
return
}
if (response.statusCode !== 200) {
reject(new Error(`下载失败: HTTP ${response.statusCode}`))
return
}
const totalBytes = Number(response.headers['content-length'] || 0) || undefined
let downloadedBytes = 0
console.info(`[VoiceTranscribe] ${fileName} 文件大小:`, totalBytes ? `${(totalBytes / 1024 / 1024).toFixed(2)} MB` : '未知')
const writer = createWriteStream(targetPath)
// 设置数据接收超时60秒没有数据则超时
let lastDataTime = Date.now()
const dataTimeout = setInterval(() => {
if (Date.now() - lastDataTime > 60000) {
clearInterval(dataTimeout)
response.destroy()
writer.close()
reject(new Error('下载超时60秒内未收到数据'))
}
}, 5000)
response.on('data', (chunk) => {
lastDataTime = Date.now()
downloadedBytes += chunk.length
onProgress?.(downloadedBytes, totalBytes)
})
response.on('error', (error) => {
clearInterval(dataTimeout)
try { writer.close() } catch { }
console.error(`[VoiceTranscribe] ${fileName} 响应错误:`, error)
reject(error)
})
writer.on('error', (error) => {
clearInterval(dataTimeout)
try { writer.close() } catch { }
console.error(`[VoiceTranscribe] ${fileName} 写入错误:`, error)
reject(error)
})
writer.on('finish', () => {
clearInterval(dataTimeout)
writer.close()
console.info(`[VoiceTranscribe] ${fileName} 下载完成:`, targetPath)
resolve()
})
response.pipe(writer)
})
request.on('timeout', () => {
request.destroy()
console.error(`[VoiceTranscribe] ${fileName} 连接超时`)
reject(new Error('连接超时'))
})
request.on('error', (error) => {
console.error(`[VoiceTranscribe] ${fileName} 请求错误:`, error)
reject(error)
})
})
}
/**
* 清理资源
*/
dispose() {
if (this.recognizer) {
try {
// sherpa-onnx 的 recognizer 可能需要手动释放
this.recognizer = null
} catch (error) {
}
}
}
}
export const voiceTranscribeService = new VoiceTranscribeService()

View File

@@ -48,6 +48,7 @@ export class WcdbCore {
private wcdbGetMessageById: any = null
private wcdbGetEmoticonCdnUrl: any = null
private wcdbGetDbStatus: any = null
private wcdbGetVoiceData: any = null
private avatarUrlCache: Map<string, { url?: string; updatedAt: number }> = new Map()
private readonly avatarCacheTtlMs = 10 * 60 * 1000
private logTimer: NodeJS.Timeout | null = null
@@ -108,12 +109,13 @@ export class WcdbCore {
private writeLog(message: string, force = false): void {
if (!force && !this.isLogEnabled()) return
const line = `[${new Date().toISOString()}] ${message}`
// 移除控制台日志,只写入文件
try {
const base = this.userDataPath || process.env.WCDB_LOG_DIR || process.cwd()
const dir = join(base, 'logs')
if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
const line = `[${new Date().toISOString()}] ${message}\n`
appendFileSync(join(dir, 'wcdb.log'), line, { encoding: 'utf8' })
appendFileSync(join(dir, 'wcdb.log'), line + '\n', { encoding: 'utf8' })
} catch { }
}
@@ -345,6 +347,13 @@ export class WcdbCore {
this.wcdbGetDbStatus = null
}
// wcdb_status wcdb_get_voice_data(wcdb_handle handle, const char* session_id, int32_t create_time, int32_t local_id, int64_t svr_id, const char* candidates_json, char** out_hex)
try {
this.wcdbGetVoiceData = this.lib.func('int32 wcdb_get_voice_data(int64 handle, const char* sessionId, int32 createTime, int32 localId, int64 svrId, const char* candidatesJson, _Out_ void** outHex)')
} catch {
this.wcdbGetVoiceData = null
}
// 初始化
const initResult = this.wcdbInit()
if (initResult !== 0) {
@@ -611,7 +620,7 @@ export class WcdbCore {
try {
this.wcdbSetMyWxid(this.handle, wxid)
} catch (e) {
console.warn('设置 wxid 失败:', e)
// 静默失败
}
}
if (this.isLogEnabled()) {
@@ -790,7 +799,6 @@ export class WcdbCore {
await new Promise(resolve => setImmediate(resolve))
if (result !== 0 || !outPtr[0]) {
console.warn(`[wcdbCore] getAvatarUrls DLL调用失败: result=${result}, usernames=${toFetch.length}`)
if (Object.keys(resultMap).length > 0) {
return { success: true, map: resultMap, error: `获取头像失败: ${result}` }
}
@@ -798,25 +806,18 @@ export class WcdbCore {
}
const jsonStr = this.decodeJsonPtr(outPtr[0])
if (!jsonStr) {
console.error('[wcdbCore] getAvatarUrls 解析JSON失败')
return { success: false, error: '解析头像失败' }
}
const map = JSON.parse(jsonStr) as Record<string, string>
let successCount = 0
let emptyCount = 0
for (const username of toFetch) {
const url = map[username]
if (url && url.trim()) {
resultMap[username] = url
// 只缓存有效的URL
this.avatarUrlCache.set(username, { url, updatedAt: now })
successCount++
} else {
emptyCount++
}
// 不缓存空URL,下次可以重新尝试
}
}
console.log(`[wcdbCore] getAvatarUrls 成功: ${successCount}个, 空结果: ${emptyCount}个, 总请求: ${toFetch.length}`)
return { success: true, map: resultMap }
} catch (e) {
console.error('[wcdbCore] getAvatarUrls 异常:', e)
@@ -1295,9 +1296,7 @@ export class WcdbCore {
} catch (e) {
return { success: false, error: String(e) }
}
}
async getMessageById(sessionId: string, localId: number): Promise<{ success: boolean; message?: any; error?: string }> {
} async getMessageById(sessionId: string, localId: number): Promise<{ success: boolean; message?: any; error?: string }> {
if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' }
try {
const outPtr = [null as any]
@@ -1313,5 +1312,21 @@ export class WcdbCore {
return { success: false, error: String(e) }
}
}
}
async getVoiceData(sessionId: string, createTime: number, candidates: string[], localId: number = 0, svrId: string | number = 0): Promise<{ success: boolean; hex?: string; error?: string }> {
if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' }
if (!this.wcdbGetVoiceData) return { success: false, error: '当前 DLL 版本不支持获取语音数据' }
try {
const outPtr = [null as any]
const result = this.wcdbGetVoiceData(this.handle, sessionId, createTime, localId, BigInt(svrId || 0), JSON.stringify(candidates), outPtr)
if (result !== 0 || !outPtr[0]) {
return { success: false, error: `获取语音数据失败: ${result}` }
}
const hex = this.decodeJsonPtr(outPtr[0])
if (hex === null) return { success: false, error: '解析语音数据失败' }
return { success: true, hex: hex || undefined }
} catch (e) {
return { success: false, error: String(e) }
}
}
}

View File

@@ -58,11 +58,13 @@ export class WcdbService {
})
this.worker.on('error', (err) => {
console.error('WCDB Worker 错误:', err)
// Worker error
})
this.worker.on('exit', (code) => {
if (code !== 0) console.error(`WCDB Worker 异常退出,退出码: ${code}`)
if (code !== 0) {
// Worker exited with error
}
this.worker = null
})
@@ -73,7 +75,7 @@ export class WcdbService {
this.setLogEnabled(this.logEnabled)
} catch (e) {
console.error('创建 WCDB Worker 失败:', e)
// Failed to create worker
}
}
@@ -97,7 +99,7 @@ export class WcdbService {
setPaths(resourcesPath: string, userDataPath: string): void {
this.resourcesPath = resourcesPath
this.userDataPath = userDataPath
this.callWorker('setPaths', { resourcesPath, userDataPath }).catch(console.error)
this.callWorker('setPaths', { resourcesPath, userDataPath }).catch(() => { })
}
/**
@@ -105,7 +107,7 @@ export class WcdbService {
*/
setLogEnabled(enabled: boolean): void {
this.logEnabled = enabled
this.callWorker('setLogEnabled', { enabled }).catch(console.error)
this.callWorker('setLogEnabled', { enabled }).catch(() => { })
}
/**
@@ -341,6 +343,13 @@ export class WcdbService {
return this.callWorker('getMessageById', { sessionId, localId })
}
/**
* 获取语音数据
*/
async getVoiceData(sessionId: string, createTime: number, candidates: string[], localId: number = 0, svrId: string | number = 0): Promise<{ success: boolean; hex?: string; error?: string }> {
return this.callWorker('getVoiceData', { sessionId, createTime, candidates, localId, svrId })
}
}
export const wcdbService = new WcdbService()

View File

@@ -0,0 +1,166 @@
import { parentPort, workerData } from 'worker_threads'
interface WorkerParams {
modelPath: string
tokensPath: string
wavData: Buffer
sampleRate: number
languages?: string[]
}
// 语言标记映射
const LANGUAGE_TAGS: Record<string, string> = {
'zh': '<|zh|>',
'en': '<|en|>',
'ja': '<|ja|>',
'ko': '<|ko|>',
'yue': '<|yue|>' // 粤语
}
// 技术标签识别语言、语速、ITN等需要从最终文本中移除
const TECH_TAGS = [
'<|zh|>', '<|en|>', '<|ja|>', '<|ko|>', '<|yue|>',
'<|nospeech|>', '<|speech|>',
'<|itn|>', '<|wo_itn|>',
'<|NORMAL|>'
]
// 情感与事件标签映射,转换为直观的 Emoji
const RICH_TAG_MAP: Record<string, string> = {
'<|HAPPY|>': '😊',
'<|SAD|>': '😔',
'<|ANGRY|>': '😠',
'<|NEUTRAL|>': '', // 中性情感不特别标记
'<|FEARFUL|>': '😨',
'<|DISGUSTED|>': '🤢',
'<|SURPRISED|>': '😮',
'<|BGM|>': '🎵',
'<|Applause|>': '👏',
'<|Laughter|>': '😂',
'<|Cry|>': '😭',
'<|Cough|>': ' (咳嗽) ',
'<|Sneeze|>': ' (喷嚏) ',
}
/**
* 富文本后处理:移除技术标签,转换识别出的情感和声音事件
*/
function richTranscribePostProcess(text: string): string {
if (!text) return ''
let processed = text
// 1. 转换情感和事件标签
for (const [tag, replacement] of Object.entries(RICH_TAG_MAP)) {
// 使用正则全局替换,不区分大小写以防不同版本差异
const escapedTag = tag.replace(/[|<>]/g, '\\$&')
processed = processed.replace(new RegExp(escapedTag, 'gi'), replacement)
}
// 2. 移除所有剩余的技术标签
for (const tag of TECH_TAGS) {
const escapedTag = tag.replace(/[|<>]/g, '\\$&')
processed = processed.replace(new RegExp(escapedTag, 'gi'), '')
}
// 3. 清理多余空格并返回
return processed.replace(/\s+/g, ' ').trim()
}
// 检查识别结果是否在允许的语言列表中
function isLanguageAllowed(result: any, allowedLanguages: string[]): boolean {
if (!result || !result.lang) {
// 如果没有语言信息,默认允许(或从文本开头尝试提取)
return true
}
// 如果没有指定语言或语言列表为空,默认允许中文和粤语
if (!allowedLanguages || allowedLanguages.length === 0) {
allowedLanguages = ['zh', 'yue']
}
const langTag = result.lang
console.log('[TranscribeWorker] 检测到语言标记:', langTag)
// 检查是否在允许的语言列表中
for (const lang of allowedLanguages) {
if (LANGUAGE_TAGS[lang] === langTag) {
console.log('[TranscribeWorker] 语言匹配,允许:', lang)
return true
}
}
console.log('[TranscribeWorker] 语言不在白名单中,过滤掉')
return false
}
async function run() {
if (!parentPort) {
return;
}
try {
// 动态加载以捕获可能的加载错误(如 C++ 运行库缺失等)
let sherpa: any;
try {
sherpa = require('sherpa-onnx-node');
} catch (requireError) {
parentPort.postMessage({ type: 'error', error: 'Failed to load speech engine: ' + String(requireError) });
return;
}
const { modelPath, tokensPath, wavData: rawWavData, sampleRate, languages } = workerData as WorkerParams
const wavData = Buffer.from(rawWavData);
// 确保有有效的语言列表,默认只允许中文
let allowedLanguages = languages || ['zh']
if (allowedLanguages.length === 0) {
allowedLanguages = ['zh']
}
console.log('[TranscribeWorker] 使用的语言白名单:', allowedLanguages)
// 1. 初始化识别器 (SenseVoiceSmall)
const recognizerConfig = {
modelConfig: {
senseVoice: {
model: modelPath,
useInverseTextNormalization: 1
},
tokens: tokensPath,
numThreads: 2,
debug: 0
}
}
const recognizer = new sherpa.OfflineRecognizer(recognizerConfig)
// 2. 处理音频数据 (全量识别)
const pcmData = wavData.slice(44)
const samples = new Float32Array(pcmData.length / 2)
for (let i = 0; i < samples.length; i++) {
samples[i] = pcmData.readInt16LE(i * 2) / 32768.0
}
const stream = recognizer.createStream()
stream.acceptWaveform({ sampleRate, samples })
recognizer.decode(stream)
const result = recognizer.getResult(stream)
console.log('[TranscribeWorker] 识别完成 - 结果对象:', JSON.stringify(result, null, 2))
// 3. 检查语言是否在白名单中
if (isLanguageAllowed(result, allowedLanguages)) {
const processedText = richTranscribePostProcess(result.text)
console.log('[TranscribeWorker] 语言匹配,返回文本:', processedText)
parentPort.postMessage({ type: 'final', text: processedText })
} else {
console.log('[TranscribeWorker] 语言不匹配,返回空文本')
parentPort.postMessage({ type: 'final', text: '' })
}
} catch (error) {
parentPort.postMessage({ type: 'error', error: String(error) })
}
}
run();

4
electron/types/sherpa-onnx-node.d.ts vendored Normal file
View File

@@ -0,0 +1,4 @@
declare module 'sherpa-onnx-node' {
const content: any;
export = content;
}

22
electron/types/whisper-node.d.ts vendored Normal file
View File

@@ -0,0 +1,22 @@
declare module 'whisper-node' {
export type WhisperSegment = {
start: string
end: string
speech: string
}
export type WhisperOptions = {
modelName?: string
modelPath?: string
whisperOptions?: {
language?: string
gen_file_txt?: boolean
gen_file_subtitle?: boolean
gen_file_vtt?: boolean
word_timestamps?: boolean
timestamp_size?: number
}
}
export default function whisper(filePath: string, options?: WhisperOptions): Promise<WhisperSegment[]>
}

View File

@@ -110,6 +110,12 @@ if (parentPort) {
case 'getMessageById':
result = await core.getMessageById(payload.sessionId, payload.localId)
break
case 'getVoiceData':
result = await core.getVoiceData(payload.sessionId, payload.createTime, payload.candidates, payload.localId, payload.svrId)
if (!result.success) {
console.error('[wcdbWorker] getVoiceData failed:', result.error)
}
break
default:
result = { success: false, error: `Unknown method: ${type}` }
}

View File

@@ -2,6 +2,7 @@
ManifestDPIAware true
!include "WordFunc.nsh"
!include "nsDialogs.nsh"
!macro customInit
; 设置 DPI 感知
@@ -16,3 +17,49 @@ ManifestDPIAware true
StrCpy $INSTDIR "$INSTDIR\WeFlow"
${EndIf}
!macroend
; 安装完成后检测并安装 VC++ Redistributable
!macro customInstall
; 检查 VC++ 2015-2022 x64 是否已安装
ReadRegStr $0 HKLM "SOFTWARE\Microsoft\VisualStudio\14.0\VC\Runtimes\x64" "Installed"
${If} $0 != "1"
; 未安装,显示提示并下载
MessageBox MB_YESNO|MB_ICONQUESTION "检测到系统缺少 Visual C++ 运行库,这可能导致程序无法正常运行。$\n$\n是否立即下载并安装约 24MB" IDYES downloadVC IDNO skipVC
downloadVC:
DetailPrint "正在下载 Visual C++ Redistributable..."
SetOutPath "$TEMP"
; 从微软官方下载 VC++ Redistributable x64
inetc::get /TIMEOUT=30000 /CAPTION "下载 Visual C++ 运行库" /BANNER "正在下载,请稍候..." \
"https://aka.ms/vs/17/release/vc_redist.x64.exe" "$TEMP\vc_redist.x64.exe" /END
Pop $0
${If} $0 == "OK"
DetailPrint "下载完成,正在安装..."
; 使用 ShellExecute 以管理员权限运行
ExecShell "runas" '"$TEMP\vc_redist.x64.exe"' "/install /quiet /norestart" SW_HIDE
; 等待安装完成
Sleep 5000
; 检查是否安装成功
ReadRegStr $1 HKLM "SOFTWARE\Microsoft\VisualStudio\14.0\VC\Runtimes\x64" "Installed"
${If} $1 == "1"
DetailPrint "Visual C++ Redistributable 安装成功"
MessageBox MB_OK|MB_ICONINFORMATION "Visual C++ 运行库安装成功!"
${Else}
MessageBox MB_OK|MB_ICONEXCLAMATION "Visual C++ 运行库安装失败,您可能需要手动安装。"
${EndIf}
Delete "$TEMP\vc_redist.x64.exe"
${Else}
MessageBox MB_OK|MB_ICONEXCLAMATION "下载失败:$0$\n$\n您可以稍后手动下载安装 Visual C++ Redistributable。"
${EndIf}
Goto doneVC
skipVC:
DetailPrint "用户跳过 Visual C++ Redistributable 安装"
doneVC:
${Else}
DetailPrint "Visual C++ Redistributable 已安装"
${EndIf}
!macroend

609
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "weflow",
"version": "1.0.4",
"version": "1.2.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "weflow",
"version": "1.0.4",
"version": "1.2.0",
"hasInstallScript": true,
"dependencies": {
"better-sqlite3": "^12.5.0",
@@ -14,6 +14,8 @@
"echarts-for-react": "^3.0.2",
"electron-store": "^10.0.0",
"electron-updater": "^6.3.9",
"exceljs": "^4.4.0",
"ffmpeg-static": "^5.3.0",
"fzstd": "^0.1.1",
"html2canvas": "^1.4.1",
"jieba-wasm": "^2.2.0",
@@ -23,6 +25,8 @@
"react": "^19.2.3",
"react-dom": "^19.2.3",
"react-router-dom": "^7.1.1",
"sherpa-onnx-node": "^1.10.38",
"silk-wasm": "^3.7.1",
"wechat-emojis": "^1.0.2",
"zustand": "^5.0.2"
},
@@ -344,6 +348,21 @@
"node": ">=6.9.0"
}
},
"node_modules/@derhuerst/http-basic": {
"version": "8.2.4",
"resolved": "https://registry.npmmirror.com/@derhuerst/http-basic/-/http-basic-8.2.4.tgz",
"integrity": "sha512-F9rL9k9Xjf5blCz8HsJRO4diy111cayL2vkY2XE4r4t3n0yPXVYy3KD3nJ1qbrSn9743UWSXH4IwuCa/HWlGFw==",
"license": "MIT",
"dependencies": {
"caseless": "^0.12.0",
"concat-stream": "^2.0.0",
"http-response-object": "^3.0.1",
"parse-cache-control": "^1.0.1"
},
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@develar/schema-utils": {
"version": "2.6.5",
"resolved": "https://registry.npmmirror.com/@develar/schema-utils/-/schema-utils-2.6.5.tgz",
@@ -1124,6 +1143,47 @@
"node": ">=18"
}
},
"node_modules/@fast-csv/format": {
"version": "4.3.5",
"resolved": "https://registry.npmmirror.com/@fast-csv/format/-/format-4.3.5.tgz",
"integrity": "sha512-8iRn6QF3I8Ak78lNAa+Gdl5MJJBM5vRHivFtMRUWINdevNo00K7OXxS2PshawLKTejVwieIlPmK5YlLu6w4u8A==",
"license": "MIT",
"dependencies": {
"@types/node": "^14.0.1",
"lodash.escaperegexp": "^4.1.2",
"lodash.isboolean": "^3.0.3",
"lodash.isequal": "^4.5.0",
"lodash.isfunction": "^3.0.9",
"lodash.isnil": "^4.0.0"
}
},
"node_modules/@fast-csv/format/node_modules/@types/node": {
"version": "14.18.63",
"resolved": "https://registry.npmmirror.com/@types/node/-/node-14.18.63.tgz",
"integrity": "sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ==",
"license": "MIT"
},
"node_modules/@fast-csv/parse": {
"version": "4.3.6",
"resolved": "https://registry.npmmirror.com/@fast-csv/parse/-/parse-4.3.6.tgz",
"integrity": "sha512-uRsLYksqpbDmWaSmzvJcuApSEe38+6NQZBUsuAyMZKqHxH0g1wcJgsKUvN3WC8tewaqFjBMMGrkHmC+T7k8LvA==",
"license": "MIT",
"dependencies": {
"@types/node": "^14.0.1",
"lodash.escaperegexp": "^4.1.2",
"lodash.groupby": "^4.6.0",
"lodash.isfunction": "^3.0.9",
"lodash.isnil": "^4.0.0",
"lodash.isundefined": "^3.0.1",
"lodash.uniq": "^4.5.0"
}
},
"node_modules/@fast-csv/parse/node_modules/@types/node": {
"version": "14.18.63",
"resolved": "https://registry.npmmirror.com/@types/node/-/node-14.18.63.tgz",
"integrity": "sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ==",
"license": "MIT"
},
"node_modules/@gar/promisify": {
"version": "1.1.3",
"resolved": "https://registry.npmmirror.com/@gar/promisify/-/promisify-1.1.3.tgz",
@@ -3564,9 +3624,7 @@
"version": "5.3.2",
"resolved": "https://registry.npmmirror.com/archiver/-/archiver-5.3.2.tgz",
"integrity": "sha512-+25nxyyznAXF7Nef3y0EbBeqmGZgeN/BxHX29Rs39djAfaFalmQ89SE6CWyDCHzGL0yt/ycBtNOmGTW0FyGWNw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"archiver-utils": "^2.1.0",
"async": "^3.2.4",
@@ -3584,9 +3642,7 @@
"version": "2.1.0",
"resolved": "https://registry.npmmirror.com/archiver-utils/-/archiver-utils-2.1.0.tgz",
"integrity": "sha512-bEL/yUb/fNNiNTuUz979Z0Yg5L+LzLxGJz8x79lYmR54fmTIb6ob/hNQgkQnIUDWIFjZVQwl9Xs356I6BAMHfw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"glob": "^7.1.4",
"graceful-fs": "^4.2.0",
@@ -3607,9 +3663,7 @@
"version": "2.3.8",
"resolved": "https://registry.npmmirror.com/readable-stream/-/readable-stream-2.3.8.tgz",
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"core-util-is": "~1.0.0",
"inherits": "~2.0.3",
@@ -3624,17 +3678,13 @@
"version": "5.1.2",
"resolved": "https://registry.npmmirror.com/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"dev": true,
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/archiver-utils/node_modules/string_decoder": {
"version": "1.1.1",
"resolved": "https://registry.npmmirror.com/string_decoder/-/string_decoder-1.1.1.tgz",
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"safe-buffer": "~5.1.0"
}
@@ -3686,7 +3736,6 @@
"version": "3.2.6",
"resolved": "https://registry.npmmirror.com/async/-/async-3.2.6.tgz",
"integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==",
"dev": true,
"license": "MIT"
},
"node_modules/async-exit-hook": {
@@ -3730,7 +3779,6 @@
"version": "1.0.2",
"resolved": "https://registry.npmmirror.com/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"dev": true,
"license": "MIT"
},
"node_modules/base64-arraybuffer": {
@@ -3786,6 +3834,28 @@
"node": "20.x || 22.x || 23.x || 24.x || 25.x"
}
},
"node_modules/big-integer": {
"version": "1.6.52",
"resolved": "https://registry.npmmirror.com/big-integer/-/big-integer-1.6.52.tgz",
"integrity": "sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==",
"license": "Unlicense",
"engines": {
"node": ">=0.6"
}
},
"node_modules/binary": {
"version": "0.3.0",
"resolved": "https://registry.npmmirror.com/binary/-/binary-0.3.0.tgz",
"integrity": "sha512-D4H1y5KYwpJgK8wk1Cue5LLPgmwHKYSChkbspQg5JtVuR5ulGckxfR62H3AE9UDkdMC8yyXlqYihuz3Aqg2XZg==",
"license": "MIT",
"dependencies": {
"buffers": "~0.1.1",
"chainsaw": "~0.1.0"
},
"engines": {
"node": "*"
}
},
"node_modules/bindings": {
"version": "1.5.0",
"resolved": "https://registry.npmmirror.com/bindings/-/bindings-1.5.0.tgz",
@@ -3836,7 +3906,6 @@
"version": "1.1.12",
"resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-1.1.12.tgz",
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0",
@@ -3919,7 +3988,6 @@
"version": "0.2.13",
"resolved": "https://registry.npmmirror.com/buffer-crc32/-/buffer-crc32-0.2.13.tgz",
"integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": "*"
@@ -3929,9 +3997,25 @@
"version": "1.1.2",
"resolved": "https://registry.npmmirror.com/buffer-from/-/buffer-from-1.1.2.tgz",
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
"dev": true,
"license": "MIT"
},
"node_modules/buffer-indexof-polyfill": {
"version": "1.0.2",
"resolved": "https://registry.npmmirror.com/buffer-indexof-polyfill/-/buffer-indexof-polyfill-1.0.2.tgz",
"integrity": "sha512-I7wzHwA3t1/lwXQh+A5PbNvJxgfo5r3xulgpYDB5zckTu/Z9oUK9biouBKQUjEqzaz3HnAT6TYoovmE+GqSf7A==",
"license": "MIT",
"engines": {
"node": ">=0.10"
}
},
"node_modules/buffers": {
"version": "0.1.1",
"resolved": "https://registry.npmmirror.com/buffers/-/buffers-0.1.1.tgz",
"integrity": "sha512-9q/rDEGSb/Qsvv2qvzIzdluL5k7AaJOTrw23z9reQthrbF7is4CtlT0DXyO1oei2DCp4uojjzQ7igaSHp1kAEQ==",
"engines": {
"node": ">=0.2.0"
}
},
"node_modules/builder-util": {
"version": "25.1.7",
"resolved": "https://registry.npmmirror.com/builder-util/-/builder-util-25.1.7.tgz",
@@ -4188,6 +4272,24 @@
],
"license": "CC-BY-4.0"
},
"node_modules/caseless": {
"version": "0.12.0",
"resolved": "https://registry.npmmirror.com/caseless/-/caseless-0.12.0.tgz",
"integrity": "sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==",
"license": "Apache-2.0"
},
"node_modules/chainsaw": {
"version": "0.1.0",
"resolved": "https://registry.npmmirror.com/chainsaw/-/chainsaw-0.1.0.tgz",
"integrity": "sha512-75kWfWt6MEKNC8xYXIdRpDehRYY/tNSgwKaJq+dbbDcxORuVrrQ+SEHoWsniVn9XPYfP4gmdWIeDk/4YNp1rNQ==",
"license": "MIT/X11",
"dependencies": {
"traverse": ">=0.3.0 <0.4"
},
"engines": {
"node": "*"
}
},
"node_modules/chalk": {
"version": "4.1.2",
"resolved": "https://registry.npmmirror.com/chalk/-/chalk-4.1.2.tgz",
@@ -4413,9 +4515,7 @@
"version": "4.1.2",
"resolved": "https://registry.npmmirror.com/compress-commons/-/compress-commons-4.1.2.tgz",
"integrity": "sha512-D3uMHtGc/fcO1Gt1/L7i1e33VOvD4A9hfQLP+6ewd+BvG/gQ84Yh4oftEhAdjSMgBgwGL+jsppT7JYNpo6MHHg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"buffer-crc32": "^0.2.13",
"crc32-stream": "^4.0.2",
@@ -4430,9 +4530,23 @@
"version": "0.0.1",
"resolved": "https://registry.npmmirror.com/concat-map/-/concat-map-0.0.1.tgz",
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
"dev": true,
"license": "MIT"
},
"node_modules/concat-stream": {
"version": "2.0.0",
"resolved": "https://registry.npmmirror.com/concat-stream/-/concat-stream-2.0.0.tgz",
"integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==",
"engines": [
"node >= 6.0"
],
"license": "MIT",
"dependencies": {
"buffer-from": "^1.0.0",
"inherits": "^2.0.3",
"readable-stream": "^3.0.2",
"typedarray": "^0.0.6"
}
},
"node_modules/conf": {
"version": "14.0.0",
"resolved": "https://registry.npmmirror.com/conf/-/conf-14.0.0.tgz",
@@ -4596,9 +4710,7 @@
"version": "1.2.2",
"resolved": "https://registry.npmmirror.com/crc-32/-/crc-32-1.2.2.tgz",
"integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"crc32": "bin/crc32.njs"
},
@@ -4610,9 +4722,7 @@
"version": "4.0.3",
"resolved": "https://registry.npmmirror.com/crc32-stream/-/crc32-stream-4.0.3.tgz",
"integrity": "sha512-NT7w2JVU7DFroFdYkeq8cywxrgjPHWkdX1wjpRQXPX5Asews3tA+Ght6lddQO5Mkumffp3X7GEqku3epj2toIw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"crc-32": "^1.2.0",
"readable-stream": "^3.4.0"
@@ -4652,6 +4762,12 @@
"devOptional": true,
"license": "MIT"
},
"node_modules/dayjs": {
"version": "1.11.19",
"resolved": "https://registry.npmmirror.com/dayjs/-/dayjs-1.11.19.tgz",
"integrity": "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==",
"license": "MIT"
},
"node_modules/debounce-fn": {
"version": "6.0.0",
"resolved": "https://registry.npmmirror.com/debounce-fn/-/debounce-fn-6.0.0.tgz",
@@ -4981,6 +5097,45 @@
"node": ">= 0.4"
}
},
"node_modules/duplexer2": {
"version": "0.1.4",
"resolved": "https://registry.npmmirror.com/duplexer2/-/duplexer2-0.1.4.tgz",
"integrity": "sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA==",
"license": "BSD-3-Clause",
"dependencies": {
"readable-stream": "^2.0.2"
}
},
"node_modules/duplexer2/node_modules/readable-stream": {
"version": "2.3.8",
"resolved": "https://registry.npmmirror.com/readable-stream/-/readable-stream-2.3.8.tgz",
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
"license": "MIT",
"dependencies": {
"core-util-is": "~1.0.0",
"inherits": "~2.0.3",
"isarray": "~1.0.0",
"process-nextick-args": "~2.0.0",
"safe-buffer": "~5.1.1",
"string_decoder": "~1.1.1",
"util-deprecate": "~1.0.1"
}
},
"node_modules/duplexer2/node_modules/safe-buffer": {
"version": "5.1.2",
"resolved": "https://registry.npmmirror.com/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"license": "MIT"
},
"node_modules/duplexer2/node_modules/string_decoder": {
"version": "1.1.1",
"resolved": "https://registry.npmmirror.com/string_decoder/-/string_decoder-1.1.1.tgz",
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.1.0"
}
},
"node_modules/eastasianwidth": {
"version": "0.2.0",
"resolved": "https://registry.npmmirror.com/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
@@ -5355,7 +5510,6 @@
"version": "2.2.1",
"resolved": "https://registry.npmmirror.com/env-paths/-/env-paths-2.2.1.tgz",
"integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
@@ -5491,6 +5645,26 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/exceljs": {
"version": "4.4.0",
"resolved": "https://registry.npmmirror.com/exceljs/-/exceljs-4.4.0.tgz",
"integrity": "sha512-XctvKaEMaj1Ii9oDOqbW/6e1gXknSY4g/aLCDicOXqBE4M0nRWkUu0PTp++UPNzoFY12BNHMfs/VadKIS6llvg==",
"license": "MIT",
"dependencies": {
"archiver": "^5.0.0",
"dayjs": "^1.8.34",
"fast-csv": "^4.3.1",
"jszip": "^3.10.1",
"readable-stream": "^3.6.0",
"saxes": "^5.0.1",
"tmp": "^0.2.0",
"unzipper": "^0.10.11",
"uuid": "^8.3.0"
},
"engines": {
"node": ">=8.3.0"
}
},
"node_modules/expand-template": {
"version": "2.0.3",
"resolved": "https://registry.npmmirror.com/expand-template/-/expand-template-2.0.3.tgz",
@@ -5539,6 +5713,19 @@
"license": "MIT",
"optional": true
},
"node_modules/fast-csv": {
"version": "4.3.6",
"resolved": "https://registry.npmmirror.com/fast-csv/-/fast-csv-4.3.6.tgz",
"integrity": "sha512-2RNSpuwwsJGP0frGsOmTb9oUF+VkFSM4SyLTDgwf2ciHWTarN0lQTC+F2f/t5J9QjW+c65VFIAAu85GsvMIusw==",
"license": "MIT",
"dependencies": {
"@fast-csv/format": "4.3.5",
"@fast-csv/parse": "4.3.6"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmmirror.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@@ -5578,6 +5765,47 @@
"pend": "~1.2.0"
}
},
"node_modules/ffmpeg-static": {
"version": "5.3.0",
"resolved": "https://registry.npmmirror.com/ffmpeg-static/-/ffmpeg-static-5.3.0.tgz",
"integrity": "sha512-H+K6sW6TiIX6VGend0KQwthe+kaceeH/luE8dIZyOP35ik7ahYojDuqlTV1bOrtEwl01sy2HFNGQfi5IDJvotg==",
"hasInstallScript": true,
"license": "GPL-3.0-or-later",
"dependencies": {
"@derhuerst/http-basic": "^8.2.0",
"env-paths": "^2.2.0",
"https-proxy-agent": "^5.0.0",
"progress": "^2.0.3"
},
"engines": {
"node": ">=16"
}
},
"node_modules/ffmpeg-static/node_modules/agent-base": {
"version": "6.0.2",
"resolved": "https://registry.npmmirror.com/agent-base/-/agent-base-6.0.2.tgz",
"integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
"license": "MIT",
"dependencies": {
"debug": "4"
},
"engines": {
"node": ">= 6.0.0"
}
},
"node_modules/ffmpeg-static/node_modules/https-proxy-agent": {
"version": "5.0.1",
"resolved": "https://registry.npmmirror.com/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
"integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==",
"license": "MIT",
"dependencies": {
"agent-base": "6",
"debug": "4"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/file-uri-to-path": {
"version": "1.0.0",
"resolved": "https://registry.npmmirror.com/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
@@ -5716,7 +5944,6 @@
"version": "1.0.0",
"resolved": "https://registry.npmmirror.com/fs.realpath/-/fs.realpath-1.0.0.tgz",
"integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==",
"dev": true,
"license": "ISC"
},
"node_modules/fsevents": {
@@ -5734,6 +5961,47 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/fstream": {
"version": "1.0.12",
"resolved": "https://registry.npmmirror.com/fstream/-/fstream-1.0.12.tgz",
"integrity": "sha512-WvJ193OHa0GHPEL+AycEJgxvBEwyfRkN1vhjca23OaPVMCaLCXTd5qAu82AjTcgP1UJmytkOKb63Ypde7raDIg==",
"deprecated": "This package is no longer supported.",
"license": "ISC",
"dependencies": {
"graceful-fs": "^4.1.2",
"inherits": "~2.0.0",
"mkdirp": ">=0.5 0",
"rimraf": "2"
},
"engines": {
"node": ">=0.6"
}
},
"node_modules/fstream/node_modules/mkdirp": {
"version": "0.5.6",
"resolved": "https://registry.npmmirror.com/mkdirp/-/mkdirp-0.5.6.tgz",
"integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
"license": "MIT",
"dependencies": {
"minimist": "^1.2.6"
},
"bin": {
"mkdirp": "bin/cmd.js"
}
},
"node_modules/fstream/node_modules/rimraf": {
"version": "2.7.1",
"resolved": "https://registry.npmmirror.com/rimraf/-/rimraf-2.7.1.tgz",
"integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==",
"deprecated": "Rimraf versions prior to v4 are no longer supported",
"license": "ISC",
"dependencies": {
"glob": "^7.1.3"
},
"bin": {
"rimraf": "bin.js"
}
},
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmmirror.com/function-bind/-/function-bind-1.1.2.tgz",
@@ -5857,7 +6125,6 @@
"resolved": "https://registry.npmmirror.com/glob/-/glob-7.2.3.tgz",
"integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
"deprecated": "Glob versions prior to v9 are no longer supported",
"dev": true,
"license": "ISC",
"dependencies": {
"fs.realpath": "^1.0.0",
@@ -5878,7 +6145,6 @@
"version": "3.1.2",
"resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-3.1.2.tgz",
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^1.1.7"
@@ -6109,6 +6375,21 @@
"node": ">= 14"
}
},
"node_modules/http-response-object": {
"version": "3.0.2",
"resolved": "https://registry.npmmirror.com/http-response-object/-/http-response-object-3.0.2.tgz",
"integrity": "sha512-bqX0XTF6fnXSQcEJ2Iuyr75yVakyjIDCqroJQ/aHfSdlM743Cwqoi2nDYMzLGWUcuTWGWy8AAvOKXTfiv6q9RA==",
"license": "MIT",
"dependencies": {
"@types/node": "^10.0.3"
}
},
"node_modules/http-response-object/node_modules/@types/node": {
"version": "10.17.60",
"resolved": "https://registry.npmmirror.com/@types/node/-/node-10.17.60.tgz",
"integrity": "sha512-F0KIgDJfy2nA3zMLmWGKxcH2ZVEtCZXHHdOQs2gSaQ27+lNeEfGxzkIw90aXswATX7AZ33tahPbzy6KAfUreVw==",
"license": "MIT"
},
"node_modules/http2-wrapper": {
"version": "1.0.3",
"resolved": "https://registry.npmmirror.com/http2-wrapper/-/http2-wrapper-1.0.3.tgz",
@@ -6243,7 +6524,6 @@
"resolved": "https://registry.npmmirror.com/inflight/-/inflight-1.0.6.tgz",
"integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==",
"deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.",
"dev": true,
"license": "ISC",
"dependencies": {
"once": "^1.3.0",
@@ -6582,9 +6862,7 @@
"version": "1.0.1",
"resolved": "https://registry.npmmirror.com/lazystream/-/lazystream-1.0.1.tgz",
"integrity": "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"readable-stream": "^2.0.5"
},
@@ -6596,9 +6874,7 @@
"version": "2.3.8",
"resolved": "https://registry.npmmirror.com/readable-stream/-/readable-stream-2.3.8.tgz",
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"core-util-is": "~1.0.0",
"inherits": "~2.0.3",
@@ -6613,17 +6889,13 @@
"version": "5.1.2",
"resolved": "https://registry.npmmirror.com/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"dev": true,
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/lazystream/node_modules/string_decoder": {
"version": "1.1.1",
"resolved": "https://registry.npmmirror.com/string_decoder/-/string_decoder-1.1.1.tgz",
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"safe-buffer": "~5.1.0"
}
@@ -6637,6 +6909,12 @@
"immediate": "~3.0.5"
}
},
"node_modules/listenercount": {
"version": "1.0.1",
"resolved": "https://registry.npmmirror.com/listenercount/-/listenercount-1.0.1.tgz",
"integrity": "sha512-3mk/Zag0+IJxeDrxSgaDPy4zZ3w05PRZeJNnlWhzFz5OkX49J4krc+A8X2d2M69vGMBEX0uyl8M+W+8gH+kBqQ==",
"license": "ISC"
},
"node_modules/lodash": {
"version": "4.17.21",
"resolved": "https://registry.npmmirror.com/lodash/-/lodash-4.17.21.tgz",
@@ -6648,17 +6926,13 @@
"version": "4.2.0",
"resolved": "https://registry.npmmirror.com/lodash.defaults/-/lodash.defaults-4.2.0.tgz",
"integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==",
"dev": true,
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/lodash.difference": {
"version": "4.5.0",
"resolved": "https://registry.npmmirror.com/lodash.difference/-/lodash.difference-4.5.0.tgz",
"integrity": "sha512-dS2j+W26TQ7taQBGN8Lbbq04ssV3emRw4NY58WErlTO29pIqS0HmoT5aJ9+TUQ1N3G+JOZSji4eugsWwGp9yPA==",
"dev": true,
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/lodash.escaperegexp": {
"version": "4.1.2",
@@ -6670,9 +6944,19 @@
"version": "4.4.0",
"resolved": "https://registry.npmmirror.com/lodash.flatten/-/lodash.flatten-4.4.0.tgz",
"integrity": "sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g==",
"dev": true,
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/lodash.groupby": {
"version": "4.6.0",
"resolved": "https://registry.npmmirror.com/lodash.groupby/-/lodash.groupby-4.6.0.tgz",
"integrity": "sha512-5dcWxm23+VAoz+awKmBaiBvzox8+RqMgFhi7UvX9DHZr2HdxHXM/Wrf8cfKpsW37RNrvtPn6hSwNqurSILbmJw==",
"license": "MIT"
},
"node_modules/lodash.isboolean": {
"version": "3.0.3",
"resolved": "https://registry.npmmirror.com/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
"integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
"license": "MIT"
},
"node_modules/lodash.isequal": {
"version": "4.5.0",
@@ -6681,21 +6965,41 @@
"deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.",
"license": "MIT"
},
"node_modules/lodash.isfunction": {
"version": "3.0.9",
"resolved": "https://registry.npmmirror.com/lodash.isfunction/-/lodash.isfunction-3.0.9.tgz",
"integrity": "sha512-AirXNj15uRIMMPihnkInB4i3NHeb4iBtNg9WRWuK2o31S+ePwwNmDPaTL3o7dTJ+VXNZim7rFs4rxN4YU1oUJw==",
"license": "MIT"
},
"node_modules/lodash.isnil": {
"version": "4.0.0",
"resolved": "https://registry.npmmirror.com/lodash.isnil/-/lodash.isnil-4.0.0.tgz",
"integrity": "sha512-up2Mzq3545mwVnMhTDMdfoG1OurpA/s5t88JmQX809eH3C8491iu2sfKhTfhQtKY78oPNhiaHJUpT/dUDAAtng==",
"license": "MIT"
},
"node_modules/lodash.isplainobject": {
"version": "4.0.6",
"resolved": "https://registry.npmmirror.com/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
"integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
"dev": true,
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/lodash.isundefined": {
"version": "3.0.1",
"resolved": "https://registry.npmmirror.com/lodash.isundefined/-/lodash.isundefined-3.0.1.tgz",
"integrity": "sha512-MXB1is3s899/cD8jheYYE2V9qTHwKvt+npCwpD+1Sxm3Q3cECXCiYHjeHWXNwr6Q0SOBPrYUDxendrO6goVTEA==",
"license": "MIT"
},
"node_modules/lodash.union": {
"version": "4.6.0",
"resolved": "https://registry.npmmirror.com/lodash.union/-/lodash.union-4.6.0.tgz",
"integrity": "sha512-c4pB2CdGrGdjMKYLA+XiRDO7Y0PRQbm/Gzg8qMj+QH+pFVAoTp5sBpO0odL3FjoPCGjK96p6qsP+yQoiLoOBcw==",
"dev": true,
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/lodash.uniq": {
"version": "4.5.0",
"resolved": "https://registry.npmmirror.com/lodash.uniq/-/lodash.uniq-4.5.0.tgz",
"integrity": "sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==",
"license": "MIT"
},
"node_modules/log-symbols": {
"version": "4.1.0",
@@ -7257,9 +7561,7 @@
"version": "3.0.0",
"resolved": "https://registry.npmmirror.com/normalize-path/-/normalize-path-3.0.0.tgz",
"integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -7406,11 +7708,15 @@
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
"license": "(MIT AND Zlib)"
},
"node_modules/parse-cache-control": {
"version": "1.0.1",
"resolved": "https://registry.npmmirror.com/parse-cache-control/-/parse-cache-control-1.0.1.tgz",
"integrity": "sha512-60zvsJReQPX5/QP0Kzfd/VrpjScIQ7SHBW6bFCYfEP+fp0Eppr1SHhIO5nd1PjZtvclzSzES9D/p5nFJurwfWg=="
},
"node_modules/path-is-absolute": {
"version": "1.0.1",
"resolved": "https://registry.npmmirror.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
"integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@@ -7595,7 +7901,6 @@
"version": "2.0.3",
"resolved": "https://registry.npmmirror.com/progress/-/progress-2.0.3.tgz",
"integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.4.0"
@@ -7770,9 +8075,7 @@
"version": "1.1.3",
"resolved": "https://registry.npmmirror.com/readdir-glob/-/readdir-glob-1.1.3.tgz",
"integrity": "sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"minimatch": "^5.1.0"
}
@@ -7781,9 +8084,7 @@
"version": "2.0.2",
"resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-2.0.2.tgz",
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"balanced-match": "^1.0.0"
}
@@ -7792,9 +8093,7 @@
"version": "5.1.6",
"resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-5.1.6.tgz",
"integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==",
"dev": true,
"license": "ISC",
"peer": true,
"dependencies": {
"brace-expansion": "^2.0.1"
},
@@ -8042,6 +8341,18 @@
"integrity": "sha512-yqYn1JhPczigF94DMS+shiDMjDowYO6y9+wB/4WgO0Y19jWYk0lQ4tuG5KI7kj4FTp1wxPj5IFfcrz/s1c3jjQ==",
"license": "BlueOak-1.0.0"
},
"node_modules/saxes": {
"version": "5.0.1",
"resolved": "https://registry.npmmirror.com/saxes/-/saxes-5.0.1.tgz",
"integrity": "sha512-5LBh1Tls8c9xgGjw3QrMwETmTMVk0oFgvrFSvWx62llR2hcEInrKNZ2GZCCuuy2lvWrdl5jhbpeqc5hRYKFOcw==",
"license": "ISC",
"dependencies": {
"xmlchars": "^2.2.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/scheduler": {
"version": "0.27.0",
"resolved": "https://registry.npmmirror.com/scheduler/-/scheduler-0.27.0.tgz",
@@ -8186,6 +8497,78 @@
"node": ">=8"
}
},
"node_modules/sherpa-onnx-darwin-arm64": {
"version": "1.12.23",
"resolved": "https://registry.npmmirror.com/sherpa-onnx-darwin-arm64/-/sherpa-onnx-darwin-arm64-1.12.23.tgz",
"integrity": "sha512-zbjNUUH/IXhjRyRJ9mpcWVOGIVr31a/qXBPsfOYc7U8cgwcq33Vmj2OzoLYWQF6T+puqCAE4nMxFAxJvdZekhg==",
"cpu": [
"arm64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/sherpa-onnx-linux-x64": {
"version": "1.12.23",
"resolved": "https://registry.npmmirror.com/sherpa-onnx-linux-x64/-/sherpa-onnx-linux-x64-1.12.23.tgz",
"integrity": "sha512-pUZIdDvPtyRXQDGo9R9MIBf2AFUzfgcGmutoulsEdH3hpK6JteR7Z/5pfrZIIqe/O99djAjEHK4AlwLHC2jiZw==",
"cpu": [
"x64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
]
},
"node_modules/sherpa-onnx-node": {
"version": "1.12.23",
"resolved": "https://registry.npmmirror.com/sherpa-onnx-node/-/sherpa-onnx-node-1.12.23.tgz",
"integrity": "sha512-09SRixVSjsajxeCV8Hy9R5J4IHPtw7vNgaIcEokdh/LpU7sY+e12z9uHHIMMMgNiInyGEH74wIwjLXms+W7qRA==",
"license": "Apache-2.0",
"optionalDependencies": {
"sherpa-onnx-darwin-arm64": "^1.12.23",
"sherpa-onnx-darwin-x64": "^1.12.23",
"sherpa-onnx-linux-arm64": "^1.12.23",
"sherpa-onnx-linux-x64": "^1.12.23",
"sherpa-onnx-win-ia32": "^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": {
"version": "1.12.23",
"resolved": "https://registry.npmmirror.com/sherpa-onnx-win-ia32/-/sherpa-onnx-win-ia32-1.12.23.tgz",
"integrity": "sha512-MyLsK7r6dd7paglyTgb8UHTXTEFqOzA91u6VDV64Lq8rDGuOFVYioxX7vlwmGe1A9o7VhuOPNaKcRjEPtVDhBQ==",
"cpu": [
"ia32"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"win32"
]
},
"node_modules/sherpa-onnx-win-x64": {
"version": "1.12.23",
"resolved": "https://registry.npmmirror.com/sherpa-onnx-win-x64/-/sherpa-onnx-win-x64-1.12.23.tgz",
"integrity": "sha512-pdHEYMJiYy8+xzH2WkBVS4/hnRwqjY8FaWnjs0NBgQZnPmc/k4M+TAiauTOuFDNK4GPwFQnjwrCGx6jI9AOkOg==",
"cpu": [
"x64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"win32"
]
},
"node_modules/signal-exit": {
"version": "3.0.7",
"resolved": "https://registry.npmmirror.com/signal-exit/-/signal-exit-3.0.7.tgz",
@@ -8193,6 +8576,15 @@
"dev": true,
"license": "ISC"
},
"node_modules/silk-wasm": {
"version": "3.7.1",
"resolved": "https://registry.npmmirror.com/silk-wasm/-/silk-wasm-3.7.1.tgz",
"integrity": "sha512-mXPwLRtZxrYV3TZx41jMAeKc80wvmyrcXIcs8HctFxK15Ahz2OJQENYhNgEPeCEOdI6Mbx1NxQsqxzwc3DKerw==",
"license": "MIT",
"engines": {
"node": ">=16.11.0"
}
},
"node_modules/simple-concat": {
"version": "1.0.1",
"resolved": "https://registry.npmmirror.com/simple-concat/-/simple-concat-1.0.1.tgz",
@@ -8731,7 +9123,6 @@
"version": "0.2.5",
"resolved": "https://registry.npmmirror.com/tmp/-/tmp-0.2.5.tgz",
"integrity": "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14.14"
@@ -8761,6 +9152,15 @@
"node": ">=8.0"
}
},
"node_modules/traverse": {
"version": "0.3.9",
"resolved": "https://registry.npmmirror.com/traverse/-/traverse-0.3.9.tgz",
"integrity": "sha512-iawgk0hLP3SxGKDfnDJf8wTz4p2qImnyihM5Hh/sGvQ3K37dPi/w8sRhdNIxYA1TwFwc5mDhIJq+O0RsvXBKdQ==",
"license": "MIT/X11",
"engines": {
"node": "*"
}
},
"node_modules/truncate-utf8-bytes": {
"version": "1.0.2",
"resolved": "https://registry.npmmirror.com/truncate-utf8-bytes/-/truncate-utf8-bytes-1.0.2.tgz",
@@ -8801,6 +9201,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/typedarray": {
"version": "0.0.6",
"resolved": "https://registry.npmmirror.com/typedarray/-/typedarray-0.0.6.tgz",
"integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==",
"license": "MIT"
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.9.3.tgz",
@@ -8870,6 +9276,60 @@
"node": ">= 4.0.0"
}
},
"node_modules/unzipper": {
"version": "0.10.14",
"resolved": "https://registry.npmmirror.com/unzipper/-/unzipper-0.10.14.tgz",
"integrity": "sha512-ti4wZj+0bQTiX2KmKWuwj7lhV+2n//uXEotUmGuQqrbVZSEGFMbI68+c6JCQ8aAmUWYvtHEz2A8K6wXvueR/6g==",
"license": "MIT",
"dependencies": {
"big-integer": "^1.6.17",
"binary": "~0.3.0",
"bluebird": "~3.4.1",
"buffer-indexof-polyfill": "~1.0.0",
"duplexer2": "~0.1.4",
"fstream": "^1.0.12",
"graceful-fs": "^4.2.2",
"listenercount": "~1.0.1",
"readable-stream": "~2.3.6",
"setimmediate": "~1.0.4"
}
},
"node_modules/unzipper/node_modules/bluebird": {
"version": "3.4.7",
"resolved": "https://registry.npmmirror.com/bluebird/-/bluebird-3.4.7.tgz",
"integrity": "sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA==",
"license": "MIT"
},
"node_modules/unzipper/node_modules/readable-stream": {
"version": "2.3.8",
"resolved": "https://registry.npmmirror.com/readable-stream/-/readable-stream-2.3.8.tgz",
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
"license": "MIT",
"dependencies": {
"core-util-is": "~1.0.0",
"inherits": "~2.0.3",
"isarray": "~1.0.0",
"process-nextick-args": "~2.0.0",
"safe-buffer": "~5.1.1",
"string_decoder": "~1.1.1",
"util-deprecate": "~1.0.1"
}
},
"node_modules/unzipper/node_modules/safe-buffer": {
"version": "5.1.2",
"resolved": "https://registry.npmmirror.com/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"license": "MIT"
},
"node_modules/unzipper/node_modules/string_decoder": {
"version": "1.1.1",
"resolved": "https://registry.npmmirror.com/string_decoder/-/string_decoder-1.1.1.tgz",
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.1.0"
}
},
"node_modules/update-browserslist-db": {
"version": "1.2.3",
"resolved": "https://registry.npmmirror.com/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
@@ -8933,6 +9393,15 @@
"base64-arraybuffer": "^1.0.2"
}
},
"node_modules/uuid": {
"version": "8.3.2",
"resolved": "https://registry.npmmirror.com/uuid/-/uuid-8.3.2.tgz",
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
"license": "MIT",
"bin": {
"uuid": "dist/bin/uuid"
}
},
"node_modules/verror": {
"version": "1.10.1",
"resolved": "https://registry.npmmirror.com/verror/-/verror-1.10.1.tgz",
@@ -9181,6 +9650,12 @@
"node": ">=8.0"
}
},
"node_modules/xmlchars": {
"version": "2.2.0",
"resolved": "https://registry.npmmirror.com/xmlchars/-/xmlchars-2.2.0.tgz",
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
"license": "MIT"
},
"node_modules/y18n": {
"version": "5.0.8",
"resolved": "https://registry.npmmirror.com/y18n/-/y18n-5.0.8.tgz",
@@ -9255,9 +9730,7 @@
"version": "4.1.1",
"resolved": "https://registry.npmmirror.com/zip-stream/-/zip-stream-4.1.1.tgz",
"integrity": "sha512-9qv4rlDiopXg4E69k+vMHjNN63YFMe9sZMrdlvKnCjlCRWeCBswPPMPUfx+ipsAWq1LXHe70RcbaHdJJpS6hyQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"archiver-utils": "^3.0.4",
"compress-commons": "^4.1.2",
@@ -9271,9 +9744,7 @@
"version": "3.0.4",
"resolved": "https://registry.npmmirror.com/archiver-utils/-/archiver-utils-3.0.4.tgz",
"integrity": "sha512-KVgf4XQVrTjhyWmx6cte4RxonPLR9onExufI1jhvw/MQ4BB6IsZD5gT8Lq+u/+pRkWna/6JoHpiQioaqFP5Rzw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"glob": "^7.2.3",
"graceful-fs": "^4.2.0",

View File

@@ -1,6 +1,6 @@
{
"name": "weflow",
"version": "1.1.0",
"version": "1.2.0",
"description": "WeFlow",
"main": "dist-electron/main.js",
"author": "cc",
@@ -19,6 +19,8 @@
"echarts-for-react": "^3.0.2",
"electron-store": "^10.0.0",
"electron-updater": "^6.3.9",
"exceljs": "^4.4.0",
"ffmpeg-static": "^5.3.0",
"fzstd": "^0.1.1",
"html2canvas": "^1.4.1",
"jieba-wasm": "^2.2.0",
@@ -28,6 +30,8 @@
"react": "^19.2.3",
"react-dom": "^19.2.3",
"react-router-dom": "^7.1.1",
"sherpa-onnx-node": "^1.10.38",
"silk-wasm": "^3.7.1",
"wechat-emojis": "^1.0.2",
"zustand": "^5.0.2"
},
@@ -97,6 +101,10 @@
"files": [
"dist/**/*",
"dist-electron/**/*"
],
"asarUnpack": [
"node_modules/silk-wasm/**/*",
"node_modules/sherpa-onnx-node/**/*"
]
}
}

BIN
resources/SDL2.dll Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -15,6 +15,7 @@ import GroupAnalyticsPage from './pages/GroupAnalyticsPage'
import DataManagementPage from './pages/DataManagementPage'
import SettingsPage from './pages/SettingsPage'
import ExportPage from './pages/ExportPage'
import VideoWindow from './pages/VideoWindow'
import { useAppStore } from './stores/appStore'
import { themes, useThemeStore, type ThemeId } from './stores/themeStore'
@@ -29,6 +30,7 @@ function App() {
const { currentTheme, themeMode, setTheme, setThemeMode } = useThemeStore()
const isAgreementWindow = location.pathname === '/agreement-window'
const isOnboardingWindow = location.pathname === '/onboarding-window'
const isVideoPlayerWindow = location.pathname === '/video-player-window'
const [themeHydrated, setThemeHydrated] = useState(false)
// 协议同意状态
@@ -219,6 +221,11 @@ function App() {
return <WelcomePage standalone />
}
// 独立视频播放窗口
if (isVideoPlayerWindow) {
return <VideoWindow />
}
// 主窗口 - 完整布局
return (
<div className="app-container">

View File

@@ -0,0 +1,73 @@
import React, { memo, useEffect, useState, useRef } from 'react'
interface AnimatedStreamingTextProps {
text: string
className?: string
loading?: boolean
}
export const AnimatedStreamingText = memo(({ text, className, loading }: AnimatedStreamingTextProps) => {
const [displayedSegments, setDisplayedSegments] = useState<string[]>([])
const prevTextRef = useRef('')
useEffect(() => {
const currentText = (text || '').trim()
const prevText = prevTextRef.current
if (currentText === prevText) return
if (!currentText.startsWith(prevText) && prevText !== '') {
// 如果不是追加而是全新的文本(比如重新识别),则重置
setDisplayedSegments([currentText])
prevTextRef.current = currentText
return
}
const newPart = currentText.slice(prevText.length)
if (newPart) {
// 将新部分作为单独的段加入,以触发动画
setDisplayedSegments(prev => [...prev, newPart])
}
prevTextRef.current = currentText
}, [text])
// 处理 loading 状态的显示
if (loading && !text) {
return <span className={className}><span className="dot-flashing">...</span></span>
}
return (
<span className={className}>
{displayedSegments.map((segment, index) => (
<span key={index} className="fade-in-text">
{segment}
</span>
))}
<style>{`
.fade-in-text {
animation: premiumFadeIn 0.8s cubic-bezier(0.16, 1, 0.3, 1) forwards;
opacity: 0;
display: inline-block;
filter: blur(4px);
}
@keyframes premiumFadeIn {
from {
opacity: 0;
transform: translateY(4px) scale(0.98);
filter: blur(4px);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
filter: blur(0);
}
}
.dot-flashing {
animation: blink 1s infinite;
}
@keyframes blink { 50% { opacity: 0; } }
`}</style>
</span>
)
})
AnimatedStreamingText.displayName = 'AnimatedStreamingText'

View File

@@ -0,0 +1,46 @@
.image-preview-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.9);
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
user-select: none;
}
.preview-image {
max-width: 90vw;
max-height: 90vh;
object-fit: contain;
transition: transform 0.15s ease-out;
&.dragging {
transition: none;
}
}
.image-preview-close {
position: absolute;
bottom: 40px;
left: 50%;
transform: translateX(-50%);
width: 48px;
height: 48px;
border-radius: 50%;
border: 2px solid rgba(255, 255, 255, 0.3);
background: rgba(0, 0, 0, 0.7);
color: #fff;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
backdrop-filter: blur(10px);
&:hover {
background: rgba(255, 255, 255, 0.2);
border-color: rgba(255, 255, 255, 0.5);
transform: translateX(-50%) scale(1.1);
}
}

View File

@@ -0,0 +1,101 @@
import React, { useState, useRef, useCallback, useEffect } from 'react'
import { X } from 'lucide-react'
import { createPortal } from 'react-dom'
import './ImagePreview.scss'
interface ImagePreviewProps {
src: string
onClose: () => void
}
export const ImagePreview: React.FC<ImagePreviewProps> = ({ src, onClose }) => {
const [scale, setScale] = useState(1)
const [position, setPosition] = useState({ x: 0, y: 0 })
const [isDragging, setIsDragging] = useState(false)
const dragStart = useRef({ x: 0, y: 0 })
const positionStart = useRef({ x: 0, y: 0 })
const containerRef = useRef<HTMLDivElement>(null)
// 滚轮缩放
const handleWheel = useCallback((e: React.WheelEvent) => {
e.preventDefault()
const delta = e.deltaY > 0 ? 0.9 : 1.1
setScale(prev => Math.min(Math.max(prev * delta, 0.5), 5))
}, [])
// 开始拖动
const handleMouseDown = useCallback((e: React.MouseEvent) => {
if (scale <= 1) return
e.preventDefault()
setIsDragging(true)
dragStart.current = { x: e.clientX, y: e.clientY }
positionStart.current = { ...position }
}, [scale, position])
// 拖动中
const handleMouseMove = useCallback((e: React.MouseEvent) => {
if (!isDragging) return
const dx = e.clientX - dragStart.current.x
const dy = e.clientY - dragStart.current.y
setPosition({
x: positionStart.current.x + dx,
y: positionStart.current.y + dy
})
}, [isDragging])
// 结束拖动
const handleMouseUp = useCallback(() => {
setIsDragging(false)
}, [])
// 双击重置
const handleDoubleClick = useCallback(() => {
setScale(1)
setPosition({ x: 0, y: 0 })
}, [])
// 点击背景关闭
const handleOverlayClick = useCallback((e: React.MouseEvent) => {
if (e.target === containerRef.current) {
onClose()
}
}, [onClose])
// ESC 关闭
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [onClose])
return createPortal(
<div
ref={containerRef}
className="image-preview-overlay"
onClick={handleOverlayClick}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
>
<img
src={src}
alt="图片预览"
className={`preview-image ${isDragging ? 'dragging' : ''}`}
style={{
transform: `translate(${position.x}px, ${position.y}px) scale(${scale})`,
cursor: scale > 1 ? (isDragging ? 'grabbing' : 'grab') : 'default'
}}
onWheel={handleWheel}
onMouseDown={handleMouseDown}
onDoubleClick={handleDoubleClick}
draggable={false}
/>
<button className="image-preview-close" onClick={onClose}>
<X size={20} />
</button>
</div>,
document.body
)
}

View File

@@ -0,0 +1,262 @@
.voice-transcribe-dialog-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
z-index: 10000;
animation: fadeIn 0.2s ease-out;
}
.voice-transcribe-dialog {
background: var(--bg-secondary);
border-radius: 16px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
width: 90%;
max-width: 480px;
animation: slideUp 0.3s ease-out;
overflow: hidden;
}
.dialog-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20px 24px;
border-bottom: 1px solid var(--border-color);
h3 {
margin: 0;
font-size: 18px;
font-weight: 600;
color: var(--text-primary);
}
.close-button {
background: none;
border: none;
cursor: pointer;
padding: 4px;
color: var(--text-secondary);
border-radius: 6px;
transition: all 0.15s ease;
&:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
}
}
.dialog-content {
padding: 24px;
}
.info-section {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
gap: 16px;
.info-icon {
color: var(--primary);
opacity: 0.8;
}
.info-text {
font-size: 15px;
color: var(--text-primary);
margin: 0;
}
.model-info {
width: 100%;
background: var(--bg-tertiary);
border-radius: 12px;
padding: 16px;
display: flex;
flex-direction: column;
gap: 12px;
.model-item {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 14px;
.label {
color: var(--text-secondary);
}
.value {
color: var(--text-primary);
font-weight: 500;
}
}
}
}
.download-section {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
padding: 20px 0;
.download-icon {
.downloading-icon {
color: var(--primary);
animation: bounce 1s ease-in-out infinite;
}
}
.download-text {
font-size: 15px;
color: var(--text-primary);
margin: 0;
}
.progress-bar {
width: 100%;
height: 6px;
background: var(--bg-tertiary);
border-radius: 3px;
overflow: hidden;
.progress-fill {
height: 100%;
background: var(--primary-gradient);
border-radius: 3px;
transition: width 0.3s ease;
}
}
.progress-text {
font-size: 14px;
color: var(--text-secondary);
margin: 0;
font-variant-numeric: tabular-nums;
}
.download-hint {
font-size: 12px;
color: var(--text-tertiary);
margin: 8px 0 0 0;
text-align: center;
}
}
.complete-section {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
padding: 20px 0;
.complete-icon {
color: #10b981;
}
.complete-text {
font-size: 15px;
color: var(--text-primary);
margin: 0;
}
}
.error-message {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 16px;
background: rgba(239, 68, 68, 0.1);
border: 1px solid rgba(239, 68, 68, 0.3);
border-radius: 8px;
color: #ef4444;
font-size: 14px;
margin-top: 16px;
}
.dialog-actions {
display: flex;
gap: 12px;
margin-top: 24px;
button {
flex: 1;
padding: 12px 20px;
border-radius: 8px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s ease;
border: none;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
&.btn-secondary {
background: var(--bg-tertiary);
color: var(--text-primary);
&:hover {
background: var(--bg-hover);
}
}
&.btn-primary {
background: var(--primary);
color: white;
&:hover {
opacity: 0.9;
transform: translateY(-1px);
}
&:active {
transform: translateY(0);
}
}
}
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes bounce {
0%,
100% {
transform: translateY(0);
}
50% {
transform: translateY(-10px);
}
}

View File

@@ -0,0 +1,160 @@
import React, { useState, useEffect } from 'react'
import { Download, X, CheckCircle, AlertCircle } from 'lucide-react'
import './VoiceTranscribeDialog.scss'
interface VoiceTranscribeDialogProps {
onClose: () => void
onDownloadComplete: () => void
}
export const VoiceTranscribeDialog: React.FC<VoiceTranscribeDialogProps> = ({
onClose,
onDownloadComplete
}) => {
const [isDownloading, setIsDownloading] = useState(false)
const [downloadProgress, setDownloadProgress] = useState(0)
const [downloadError, setDownloadError] = useState<string | null>(null)
const [isComplete, setIsComplete] = useState(false)
useEffect(() => {
// 监听下载进度
if (!window.electronAPI?.whisper?.onDownloadProgress) {
console.warn('[VoiceTranscribeDialog] whisper API 不可用')
return
}
const removeListener = window.electronAPI.whisper.onDownloadProgress((payload) => {
if (payload.percent !== undefined) {
setDownloadProgress(payload.percent)
}
})
return () => {
removeListener?.()
}
}, [])
const handleDownload = async () => {
if (!window.electronAPI?.whisper?.downloadModel) {
setDownloadError('语音转文字功能不可用')
return
}
setIsDownloading(true)
setDownloadError(null)
setDownloadProgress(0)
try {
const result = await window.electronAPI.whisper.downloadModel()
if (result?.success) {
setIsComplete(true)
setDownloadProgress(100)
// 延迟关闭弹窗并触发转写
setTimeout(() => {
onDownloadComplete()
}, 1000)
} else {
setDownloadError(result?.error || '下载失败')
setIsDownloading(false)
}
} catch (error) {
setDownloadError(String(error))
setIsDownloading(false)
}
}
const handleCancel = () => {
if (!isDownloading && !isComplete) {
onClose()
}
}
return (
<div className="voice-transcribe-dialog-overlay" onClick={handleCancel}>
<div className="voice-transcribe-dialog" onClick={(e) => e.stopPropagation()}>
<div className="dialog-header">
<h3></h3>
{!isDownloading && !isComplete && (
<button className="close-button" onClick={onClose}>
<X size={20} />
</button>
)}
</div>
<div className="dialog-content">
{!isDownloading && !isComplete && (
<>
<div className="info-section">
<AlertCircle size={48} className="info-icon" />
<p className="info-text">
使 AI
</p>
<div className="model-info">
<div className="model-item">
<span className="label"></span>
<span className="value">SenseVoiceSmall</span>
</div>
<div className="model-item">
<span className="label"></span>
<span className="value"> 240 MB</span>
</div>
<div className="model-item">
<span className="label"></span>
<span className="value"></span>
</div>
</div>
</div>
{downloadError && (
<div className="error-message">
<AlertCircle size={16} />
<span>{downloadError}</span>
</div>
)}
<div className="dialog-actions">
<button className="btn-secondary" onClick={onClose}>
</button>
<button className="btn-primary" onClick={handleDownload}>
<Download size={16} />
<span></span>
</button>
</div>
</>
)}
{isDownloading && !isComplete && (
<div className="download-section">
<div className="download-icon">
<Download size={48} className="downloading-icon" />
</div>
<p className="download-text">
{downloadProgress < 1 ? '正在连接服务器...' : '正在下载模型...'}
</p>
<div className="progress-bar">
<div
className="progress-fill"
style={{ width: `${downloadProgress}%` }}
/>
</div>
<p className="progress-text">{downloadProgress.toFixed(1)}%</p>
{downloadProgress < 1 && (
<p className="download-hint"></p>
)}
</div>
)}
{isComplete && (
<div className="complete-section">
<CheckCircle size={48} className="complete-icon" />
<p className="complete-text">...</p>
</div>
)}
</div>
</div>
</div>
)
}

View File

@@ -1,4 +1,4 @@
.chat-page {
.chat-page {
display: flex;
height: 100%;
gap: 16px;
@@ -370,9 +370,23 @@
}
.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 {
flex-direction: row-reverse;
.bubble-content {
background: var(--primary-gradient);
color: #fff;
@@ -382,6 +396,10 @@
line-height: 1.5;
box-shadow: 0 2px 10px var(--primary-light);
}
.bubble-body {
align-items: flex-end;
}
}
&.received {
@@ -395,6 +413,10 @@
backdrop-filter: blur(10px);
border: 1px solid var(--border-color);
}
.bubble-body {
align-items: flex-start;
}
}
&.system {
@@ -428,6 +450,11 @@
font-size: 12px;
color: var(--text-secondary);
margin-bottom: 4px;
// 防止名字撑开气泡宽度
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.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 {
flex: 1 1 70%;
display: flex;
@@ -1108,6 +1228,14 @@
border-radius: 16px;
}
}
// 使发送的语音消息和转文字也使用接收者的样式 (浅色)
&.sent.voice {
.bubble-content {
background: var(--bg-secondary);
color: var(--text-primary);
}
}
}
.bubble-avatar {
@@ -1291,36 +1419,6 @@
color: var(--text-quaternary);
}
.image-preview-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.75);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
img {
max-width: 88vw;
max-height: 88vh;
border-radius: 12px;
box-shadow: 0 16px 40px rgba(0, 0, 0, 0.35);
}
}
.image-preview-close {
position: absolute;
top: 20px;
right: 20px;
width: 36px;
height: 36px;
border-radius: 50%;
border: none;
background: rgba(0, 0, 0, 0.6);
color: #fff;
cursor: pointer;
}
// 语音消息
.voice-message {
display: flex;
@@ -1333,8 +1431,10 @@
cursor: pointer;
}
.message-bubble.sent .voice-message {
background: rgba(255, 255, 255, 0.18);
.voice-stack {
display: flex;
flex-direction: column;
gap: 6px;
}
.voice-play-btn {
@@ -1369,6 +1469,50 @@
}
}
.voice-waveform {
flex: 1;
display: flex;
align-items: center;
gap: 2px;
height: 24px;
min-width: 120px;
}
.waveform-bar {
flex: 1;
width: 2px;
background: rgba(0, 0, 0, 0.1);
border-radius: 1px;
transition: transform 0.2s ease, background 0.2s ease;
&.played {
background: var(--primary);
}
}
.message-bubble.sent.voice .waveform-bar {
background: rgba(0, 0, 0, 0.1); // 基色改为透明黑
&.played {
background: var(--primary);
}
}
.voice-wave-placeholder {
display: flex;
align-items: flex-end;
gap: 3px;
height: 18px;
span {
width: 3px;
height: 8px;
border-radius: 2px;
background: var(--text-tertiary);
opacity: 0.6;
}
}
.voice-message.playing .voice-wave span {
animation: voicePulse 0.9s ease-in-out infinite;
}
@@ -1421,6 +1565,24 @@
color: #d9480f;
}
.voice-transcript {
max-width: 260px;
padding: 8px 12px;
border-radius: 14px;
font-size: 13px;
line-height: 1.5;
background: var(--card-bg);
color: var(--text-primary);
border: 1px solid var(--border-color);
word-break: break-word;
white-space: pre-wrap;
}
.voice-transcript.error {
color: #d9480f;
cursor: pointer;
}
@keyframes voicePulse {
0% {
height: 6px;
@@ -1443,6 +1605,11 @@
font-size: 12px;
color: var(--text-tertiary);
margin-bottom: 4px;
// 防止名字撑开气泡宽度
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
// 引用消息样式
@@ -1491,7 +1658,11 @@
display: flex;
flex-direction: column;
max-width: 100%;
min-width: 0; // 允许收缩
-webkit-app-region: no-drag;
// 让气泡宽度由内容决定,而不是被父容器撑开
width: fit-content;
}
.bubble-content {
@@ -1878,3 +2049,113 @@
transform: translateX(0);
}
}
/* 语音转文字按钮样式 */
.voice-transcribe-btn {
width: 28px;
height: 28px;
padding: 0;
margin-left: 8px;
border: none;
background: var(--primary-light);
border-radius: 50%;
color: var(--primary);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
flex-shrink: 0;
&:hover {
background: var(--primary);
color: #fff;
transform: scale(1.05);
}
svg {
width: 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,11 +1,26 @@
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 { useChatStore } from '../stores/chatStore'
import type { ChatSession, Message } from '../types/models'
import { getEmojiPath } from 'wechat-emojis'
import { ImagePreview } from '../components/ImagePreview'
import { VoiceTranscribeDialog } from '../components/VoiceTranscribeDialog'
import { AnimatedStreamingText } from '../components/AnimatedStreamingText'
import * as configService from '../services/config'
import './ChatPage.scss'
// 系统消息类型常量
const SYSTEM_MESSAGE_TYPES = [
10000, // 系统消息
266287972401, // 拍一拍
]
// 判断是否为系统消息
function isSystemMessage(localType: number): boolean {
return SYSTEM_MESSAGE_TYPES.includes(localType)
}
interface ChatPageProps {
// 保留接口以备将来扩展
}
@@ -137,6 +152,8 @@ function ChatPage(_props: ChatPageProps) {
const [highlightedMessageKeys, setHighlightedMessageKeys] = useState<string[]>([])
const [isRefreshingSessions, setIsRefreshingSessions] = useState(false)
const [hasInitialMessages, setHasInitialMessages] = useState(false)
const [showVoiceTranscribeDialog, setShowVoiceTranscribeDialog] = useState(false)
const [pendingVoiceTranscriptRequest, setPendingVoiceTranscriptRequest] = useState<{ sessionId: string; messageId: string } | null>(null)
// 联系人信息加载控制
const isEnrichingRef = useRef(false)
@@ -970,6 +987,11 @@ function ChatPage(_props: ChatPageProps) {
})
}
const handleRequireModelDownload = useCallback((sessionId: string, messageId: string) => {
setPendingVoiceTranscriptRequest({ sessionId, messageId })
setShowVoiceTranscribeDialog(true)
}, [])
return (
<div className={`chat-page ${isResizing ? 'resizing' : ''}`}>
{/* 左侧会话列表 */}
@@ -1127,10 +1149,10 @@ function ChatPage(_props: ChatPageProps) {
const prevMsg = index > 0 ? messages[index - 1] : undefined
const showDateDivider = shouldShowDateDivider(msg, prevMsg)
// 显示时间第一条消息或者与上一条消息间隔超过5分钟
// 显示时间:第一条消息,或者与上一条消息间隔超过5分钟
const showTime = !prevMsg || (msg.createTime - prevMsg.createTime > 300)
const isSent = msg.isSend === 1
const isSystem = msg.localType === 10000
const isSystem = isSystemMessage(msg.localType)
// 系统消息居中显示
const wrapperClass = isSystem ? 'system' : (isSent ? 'sent' : 'received')
@@ -1149,6 +1171,7 @@ function ChatPage(_props: ChatPageProps) {
showTime={!showDateDivider && showTime}
myAvatarUrl={myAvatarUrl}
isGroupChat={isGroupChat(currentSession.username)}
onRequireModelDownload={handleRequireModelDownload}
/>
</div>
)
@@ -1271,6 +1294,31 @@ function ChatPage(_props: ChatPageProps) {
</div>
)}
</div>
{/* 语音转文字模型下载弹窗 */}
{showVoiceTranscribeDialog && (
<VoiceTranscribeDialog
onClose={() => {
setShowVoiceTranscribeDialog(false)
setPendingVoiceTranscriptRequest(null)
}}
onDownloadComplete={async () => {
setShowVoiceTranscribeDialog(false)
// 下载完成后,触发页面刷新让组件重新尝试转写
// 通过更新缓存触发组件重新检查
if (pendingVoiceTranscriptRequest) {
// 清除缓存中的请求标记,让组件可以重新尝试
const cacheKey = `voice-transcript:${pendingVoiceTranscriptRequest.messageId}`
// 不直接调用转写,而是让组件自己重试
// 通过触发一个自定义事件来通知所有 MessageBubble 组件
window.dispatchEvent(new CustomEvent('model-downloaded', {
detail: { messageId: pendingVoiceTranscriptRequest.messageId }
}))
}
setPendingVoiceTranscriptRequest(null)
}}
/>
)}
</div>
)
}
@@ -1279,20 +1327,23 @@ function ChatPage(_props: ChatPageProps) {
const emojiDataUrlCache = new Map<string, string>()
const imageDataUrlCache = new Map<string, string>()
const voiceDataUrlCache = new Map<string, string>()
const voiceTranscriptCache = new Map<string, string>()
const senderAvatarCache = new Map<string, { avatarUrl?: string; displayName?: string }>()
const senderAvatarLoading = new Map<string, Promise<{ avatarUrl?: string; displayName?: string } | null>>()
// 消息气泡组件
function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat }: {
function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, onRequireModelDownload }: {
message: Message;
session: ChatSession;
showTime?: boolean;
myAvatarUrl?: string;
isGroupChat?: boolean;
onRequireModelDownload?: (sessionId: string, messageId: string) => void;
}) {
const isSystem = message.localType === 10000
const isSystem = isSystemMessage(message.localType)
const isEmoji = message.localType === 47
const isImage = message.localType === 3
const isVideo = message.localType === 43
const isVoice = message.localType === 34
const isSent = message.isSend === 1
const [senderAvatarUrl, setSenderAvatarUrl] = useState<string | undefined>(undefined)
@@ -1305,11 +1356,80 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat }:
const [imageClicked, setImageClicked] = useState(false)
const imageUpdateCheckedRef = useRef<string | null>(null)
const imageClickTimerRef = useRef<number | null>(null)
const imageContainerRef = useRef<HTMLDivElement>(null)
const imageAutoDecryptTriggered = useRef(false)
const [voiceError, setVoiceError] = useState(false)
const [voiceLoading, setVoiceLoading] = useState(false)
const [isVoicePlaying, setIsVoicePlaying] = useState(false)
const voiceAudioRef = useRef<HTMLAudioElement | null>(null)
const [voiceTranscriptLoading, setVoiceTranscriptLoading] = useState(false)
const [voiceTranscriptError, setVoiceTranscriptError] = useState(false)
const voiceTranscriptRequestedRef = useRef(false)
const [showImagePreview, setShowImagePreview] = useState(false)
const [autoTranscribeVoice, setAutoTranscribeVoice] = useState(true)
const [voiceCurrentTime, setVoiceCurrentTime] = useState(0)
const [voiceDuration, setVoiceDuration] = useState(0)
const [voiceWaveform, setVoiceWaveform] = useState<number[]>([])
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(() => {
const loadConfig = async () => {
const enabled = await configService.getAutoTranscribeVoice()
setAutoTranscribeVoice(enabled)
}
loadConfig()
}, [])
// 从缓存获取表情包 data URL
const cacheKey = message.emojiMd5 || message.emojiCdnUrl || ''
@@ -1324,6 +1444,10 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat }:
const [voiceDataUrl, setVoiceDataUrl] = useState<string | undefined>(
() => voiceDataUrlCache.get(voiceCacheKey)
)
const voiceTranscriptCacheKey = `voice-transcript:${message.localId}`
const [voiceTranscript, setVoiceTranscript] = useState<string | undefined>(
() => voiceTranscriptCache.get(voiceTranscriptCacheKey)
)
const formatTime = (timestamp: number): string => {
if (!Number.isFinite(timestamp) || timestamp <= 0) return '未知时间'
@@ -1554,6 +1678,31 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat }:
}
}, [isImage, imageCacheKey, message.imageDatName, message.imageMd5])
// 图片进入视野前自动解密(懒加载)
useEffect(() => {
if (!isImage) return
if (imageLocalPath) return // 已有图片,不需要解密
if (!message.imageMd5 && !message.imageDatName) return
const container = imageContainerRef.current
if (!container) return
const observer = new IntersectionObserver(
(entries) => {
const entry = entries[0]
// rootMargin 设置为 200px提前触发解密
if (entry.isIntersecting && !imageAutoDecryptTriggered.current) {
imageAutoDecryptTriggered.current = true
void requestImageDecrypt()
}
},
{ rootMargin: '200px', threshold: 0 }
)
observer.observe(container)
return () => observer.disconnect()
}, [isImage, imageLocalPath, message.imageMd5, message.imageDatName, requestImageDecrypt])
useEffect(() => {
if (!isVoice) return
@@ -1564,18 +1713,257 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat }:
if (!audio) return
const handlePlay = () => setIsVoicePlaying(true)
const handlePause = () => setIsVoicePlaying(false)
const handleEnded = () => setIsVoicePlaying(false)
const handleEnded = () => {
setIsVoicePlaying(false)
setVoiceCurrentTime(0)
}
const handleTimeUpdate = () => {
setVoiceCurrentTime(audio.currentTime)
}
const handleLoadedMetadata = () => {
setVoiceDuration(audio.duration)
}
audio.addEventListener('play', handlePlay)
audio.addEventListener('pause', handlePause)
audio.addEventListener('ended', handleEnded)
audio.addEventListener('timeupdate', handleTimeUpdate)
audio.addEventListener('loadedmetadata', handleLoadedMetadata)
return () => {
audio.pause()
audio.removeEventListener('play', handlePlay)
audio.removeEventListener('pause', handlePause)
audio.removeEventListener('ended', handleEnded)
audio.removeEventListener('timeupdate', handleTimeUpdate)
audio.removeEventListener('loadedmetadata', handleLoadedMetadata)
}
}, [isVoice])
// 生成波形数据
useEffect(() => {
if (!voiceDataUrl) {
setVoiceWaveform([])
return
}
const generateWaveform = async () => {
try {
// 从 data:audio/wav;base64,... 提取 base64
const base64 = voiceDataUrl.split(',')[1]
const binaryString = window.atob(base64)
const bytes = new Uint8Array(binaryString.length)
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i)
}
const audioCtx = new (window.AudioContext || (window as any).webkitAudioContext)()
const audioBuffer = await audioCtx.decodeAudioData(bytes.buffer)
const rawData = audioBuffer.getChannelData(0) // 获取单声道数据
const samples = 35 // 波形柱子数量
const blockSize = Math.floor(rawData.length / samples)
const filteredData: number[] = []
for (let i = 0; i < samples; i++) {
let blockStart = blockSize * i
let sum = 0
for (let j = 0; j < blockSize; j++) {
sum = sum + Math.abs(rawData[blockStart + j])
}
filteredData.push(sum / blockSize)
}
// 归一化
const multiplier = Math.pow(Math.max(...filteredData), -1)
const normalizedData = filteredData.map(n => n * multiplier)
setVoiceWaveform(normalizedData)
void audioCtx.close()
} catch (e) {
console.error('Failed to generate waveform:', e)
// 降级:生成随机但平滑的波形
setVoiceWaveform(Array.from({ length: 35 }, () => 0.2 + Math.random() * 0.8))
}
}
void generateWaveform()
}, [voiceDataUrl])
// 消息加载时自动检测语音缓存
useEffect(() => {
if (!isVoice || voiceDataUrl) return
window.electronAPI.chat.resolveVoiceCache(session.username, String(message.localId))
.then(result => {
if (result.success && result.hasCache && result.data) {
const url = `data:audio/wav;base64,${result.data}`
voiceDataUrlCache.set(voiceCacheKey, url)
setVoiceDataUrl(url)
}
})
}, [isVoice, message.localId, session.username, voiceCacheKey, voiceDataUrl])
// 监听流式转写结果
useEffect(() => {
if (!isVoice) return
const removeListener = window.electronAPI.chat.onVoiceTranscriptPartial?.((payload: { msgId: string; text: string }) => {
if (payload.msgId === String(message.localId)) {
setVoiceTranscript(payload.text)
voiceTranscriptCache.set(voiceTranscriptCacheKey, payload.text)
}
})
return () => removeListener?.()
}, [isVoice, message.localId, voiceTranscriptCacheKey])
const requestVoiceTranscript = useCallback(async () => {
if (voiceTranscriptLoading || voiceTranscriptRequestedRef.current) return
// 检查 whisper API 是否可用
if (!window.electronAPI?.whisper?.getModelStatus) {
console.warn('[ChatPage] whisper API 不可用')
setVoiceTranscriptError(true)
return
}
voiceTranscriptRequestedRef.current = true
setVoiceTranscriptLoading(true)
setVoiceTranscriptError(false)
try {
// 检查模型状态
const modelStatus = await window.electronAPI.whisper.getModelStatus()
if (!modelStatus?.exists) {
const error: any = new Error('MODEL_NOT_DOWNLOADED')
error.requiresDownload = true
error.sessionId = session.username
error.messageId = String(message.localId)
throw error
}
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) {
const transcriptText = (result.transcript || '').trim()
voiceTranscriptCache.set(voiceTranscriptCacheKey, transcriptText)
setVoiceTranscript(transcriptText)
} else {
setVoiceTranscriptError(true)
voiceTranscriptRequestedRef.current = false
}
} catch (error: any) {
// 检查是否是模型未下载错误
if (error?.requiresDownload) {
// 模型未下载,触发下载弹窗
onRequireModelDownload?.(error.sessionId, error.messageId)
// 不要重置 voiceTranscriptRequestedRef避免重复触发
setVoiceTranscriptLoading(false)
return
}
setVoiceTranscriptError(true)
voiceTranscriptRequestedRef.current = false
} finally {
setVoiceTranscriptLoading(false)
}
}, [message.localId, session.username, voiceTranscriptCacheKey, voiceTranscriptLoading, onRequireModelDownload])
// 监听模型下载完成事件
useEffect(() => {
if (!isVoice) return
const handleModelDownloaded = (event: CustomEvent) => {
if (event.detail?.messageId === String(message.localId)) {
// 重置状态,允许重新尝试转写
voiceTranscriptRequestedRef.current = false
setVoiceTranscriptError(false)
// 立即尝试转写
void requestVoiceTranscript()
}
}
window.addEventListener('model-downloaded', handleModelDownloaded as EventListener)
return () => {
window.removeEventListener('model-downloaded', handleModelDownloaded as EventListener)
}
}, [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)
useEffect(() => {
window.electronAPI.config.get('autoTranscribeVoice').then((value) => {
setAutoTranscribeEnabled(value === true)
})
}, [])
useEffect(() => {
if (!autoTranscribeEnabled) return
if (!isVoice) return
if (!voiceDataUrl) return
if (!autoTranscribeVoice) return // 如果自动转文字已关闭,不自动转文字
if (voiceTranscriptError) return
if (voiceTranscriptLoading || voiceTranscript !== undefined || voiceTranscriptRequestedRef.current) return
void requestVoiceTranscript()
}, [autoTranscribeEnabled, isVoice, voiceDataUrl, voiceTranscript, voiceTranscriptError, voiceTranscriptLoading, requestVoiceTranscript])
if (isSystem) {
return (
<div className="message-bubble system">
@@ -1584,6 +1972,10 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat }:
)
}
// 检测是否为链接卡片消息
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'
// 头像逻辑:
@@ -1597,6 +1989,7 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat }:
? '我'
: getAvatarLetter(isGroupChat ? (senderName || message.senderUsername || '?') : (session.displayName || session.username))
// 是否有引用消息
const hasQuote = message.quotedContent && message.quotedContent.length > 0
@@ -1636,15 +2029,13 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat }:
// 渲染消息内容
const renderContent = () => {
if (isImage) {
if (imageLoading) {
return (
<div ref={imageContainerRef}>
{imageLoading ? (
<div className="image-loading">
<Loader2 size={20} className="spin" />
</div>
)
}
if (imageError || !imageLocalPath) {
return (
) : imageError || !imageLocalPath ? (
<button
className={`image-unavailable ${imageClicked ? 'clicked' : ''}`}
onClick={handleImageClick}
@@ -1655,9 +2046,7 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat }:
<span></span>
<span className="image-action">{imageClicked ? '已点击…' : '点击解密'}</span>
</button>
)
}
return (
) : (
<>
<div className="image-message-wrapper">
<img
@@ -1682,16 +2071,78 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat }:
</button>
)}
</div>
{showImagePreview && createPortal(
<div className="image-preview-overlay" onClick={() => setShowImagePreview(false)}>
<img src={imageLocalPath} alt="图片预览" onClick={(e) => e.stopPropagation()} />
<button className="image-preview-close" onClick={() => setShowImagePreview(false)}>
<X size={16} />
</button>
</div>,
document.body
{showImagePreview && (
<ImagePreview src={imageLocalPath} onClose={() => setShowImagePreview(false)} />
)}
</>
)}
</div>
)
}
// 视频消息
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>
)
}
@@ -1712,7 +2163,12 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat }:
setVoiceLoading(true)
setVoiceError(false)
try {
const result = await window.electronAPI.chat.getVoiceData(session.username, String(message.localId))
const result = await window.electronAPI.chat.getVoiceData(
session.username,
String(message.localId),
message.createTime,
message.serverId
)
if (result.success && result.data) {
const url = `data:audio/wav;base64,${result.data}`
voiceDataUrlCache.set(voiceCacheKey, url)
@@ -1741,9 +2197,33 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat }:
}
}
const handleSeek = (e: React.MouseEvent<HTMLDivElement>) => {
if (!voiceDataUrl || !voiceAudioRef.current) return
e.stopPropagation()
const rect = e.currentTarget.getBoundingClientRect()
const x = e.clientX - rect.left
const percentage = x / rect.width
const newTime = percentage * voiceDuration
voiceAudioRef.current.currentTime = newTime
setVoiceCurrentTime(newTime)
}
const showDecryptHint = !voiceDataUrl && !voiceLoading && !isVoicePlaying
const showTranscript = Boolean(voiceDataUrl) && (voiceTranscriptLoading || voiceTranscriptError || voiceTranscript !== undefined)
const transcriptText = (voiceTranscript || '').trim()
const transcriptDisplay = voiceTranscriptLoading
? '转写中...'
: voiceTranscriptError
? '转写失败,点击重试'
: (transcriptText || '未识别到文字')
const handleTranscriptRetry = () => {
if (!voiceTranscriptError) return
voiceTranscriptRequestedRef.current = false
void requestVoiceTranscript()
}
return (
<div className="voice-stack">
<div className={`voice-message ${isVoicePlaying ? 'playing' : ''}`} onClick={handleToggle}>
<button
className="voice-play-btn"
@@ -1756,13 +2236,31 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat }:
>
{isVoicePlaying ? <Pause size={16} /> : <Play size={16} />}
</button>
<div className="voice-wave">
<div className="voice-wave" onClick={handleSeek}>
{voiceDataUrl && voiceWaveform.length > 0 ? (
<div className="voice-waveform">
{voiceWaveform.map((amplitude, i) => {
const progress = (voiceCurrentTime / (voiceDuration || 1))
const isPlayed = (i / voiceWaveform.length) < progress
return (
<div
key={i}
className={`waveform-bar ${isPlayed ? 'played' : ''}`}
style={{ height: `${Math.max(20, amplitude * 100)}%` }}
/>
)
})}
</div>
) : (
<div className="voice-wave-placeholder">
<span />
<span />
<span />
<span />
<span />
</div>
)}
</div>
<div className="voice-info">
<span className="voice-label"></span>
{durationText && <span className="voice-duration">{durationText}</span>}
@@ -1770,6 +2268,41 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat }:
{showDecryptHint && <span className="voice-hint"></span>}
{voiceError && <span className="voice-error"></span>}
</div>
{/* 转文字按钮 */}
{voiceDataUrl && !voiceTranscript && !voiceTranscriptLoading && (
<button
className="voice-transcribe-btn"
onClick={(e) => {
e.stopPropagation()
void requestVoiceTranscript()
}}
title="转文字"
type="button"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
</svg>
</button>
)}
</div>
{showTranscript && (
<div
className={`voice-transcript ${isSent ? 'sent' : 'received'}${voiceTranscriptError ? ' error' : ''}`}
onClick={handleTranscriptRetry}
title={voiceTranscriptError ? '点击重试语音转写' : undefined}
>
{voiceTranscriptError ? (
'转写失败,点击重试'
) : !voiceTranscript ? (
voiceTranscriptLoading ? '转写中...' : '未识别到文字'
) : (
<AnimatedStreamingText
text={transcriptText}
loading={voiceTranscriptLoading}
/>
)}
</div>
)}
</div>
)
}
@@ -1811,6 +2344,10 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat }:
/>
)
}
// 解析引用消息Links / App Messages
// localType: 21474836529 corresponds to AppMessage which often contains links
// 带引用的消息
if (hasQuote) {
return (
@@ -1823,6 +2360,68 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat }:
</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>
}

View File

@@ -920,3 +920,140 @@
transform: rotate(360deg);
}
}
// 媒体导出选项卡片样式
.setting-subtitle {
font-size: 12px;
color: var(--text-tertiary);
margin: 4px 0 12px 0;
}
.media-options-card {
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: 12px;
overflow: hidden;
}
.media-switch-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 16px;
}
.media-switch-info {
display: flex;
flex-direction: column;
gap: 2px;
}
.media-switch-title {
font-size: 14px;
font-weight: 500;
color: var(--text-primary);
}
.media-switch-desc {
font-size: 11px;
color: var(--text-tertiary);
}
.media-option-divider {
height: 1px;
background: var(--border-color);
margin-left: 16px;
}
.media-checkbox-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
cursor: pointer;
transition: background 0.2s;
&:hover:not(.disabled) {
background: var(--bg-hover);
}
&.disabled {
opacity: 0.5;
cursor: not-allowed;
}
input[type="checkbox"] {
width: 18px;
height: 18px;
accent-color: var(--primary);
cursor: pointer;
&:disabled {
cursor: not-allowed;
}
}
}
.media-checkbox-info {
display: flex;
flex-direction: column;
gap: 2px;
}
.media-checkbox-title {
font-size: 14px;
color: var(--text-primary);
}
.media-checkbox-desc {
font-size: 11px;
color: var(--text-tertiary);
}
// Switch 开关样式
.switch {
position: relative;
display: inline-block;
width: 44px;
height: 24px;
flex-shrink: 0;
input {
opacity: 0;
width: 0;
height: 0;
}
.slider {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: var(--bg-tertiary);
transition: 0.3s;
border-radius: 24px;
&::before {
position: absolute;
content: "";
height: 18px;
width: 18px;
left: 3px;
bottom: 3px;
background-color: white;
transition: 0.3s;
border-radius: 50%;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
}
}
input:checked + .slider {
background-color: var(--primary);
}
input:checked + .slider::before {
transform: translateX(20px);
}
}

View File

@@ -16,6 +16,11 @@ interface ExportOptions {
dateRange: { start: Date; end: Date } | null
useAllTime: boolean
exportAvatars: boolean
exportMedia: boolean
exportImages: boolean
exportVoices: boolean
exportEmojis: boolean
exportVoiceAsText: boolean
}
interface ExportResult {
@@ -46,7 +51,12 @@ function ExportPage() {
end: new Date()
},
useAllTime: true,
exportAvatars: true
exportAvatars: true,
exportMedia: false,
exportImages: true,
exportVoices: true,
exportEmojis: true,
exportVoiceAsText: false
})
const loadSessions = useCallback(async () => {
@@ -146,6 +156,11 @@ function ExportPage() {
const exportOptions = {
format: options.format,
exportAvatars: options.exportAvatars,
exportMedia: options.exportMedia,
exportImages: options.exportMedia && options.exportImages,
exportVoices: options.exportMedia && options.exportVoices,
exportEmojis: options.exportMedia && options.exportEmojis,
exportVoiceAsText: options.exportVoiceAsText, // 独立于 exportMedia
dateRange: options.useAllTime ? null : options.dateRange ? {
start: Math.floor(options.dateRange.start.getTime() / 1000),
// 将结束日期设置为当天的 23:59:59,以包含当天的所有消息
@@ -153,7 +168,7 @@ function ExportPage() {
} : null
}
if (options.format === 'chatlab' || options.format === 'chatlab-jsonl' || options.format === 'json') {
if (options.format === 'chatlab' || options.format === 'chatlab-jsonl' || options.format === 'json' || options.format === 'excel') {
const result = await window.electronAPI.export.exportSessions(
sessionList,
exportFolder,
@@ -203,18 +218,54 @@ function ExportPage() {
const year = calendarDate.getFullYear()
const month = calendarDate.getMonth()
const selectedDate = new Date(year, month, day)
// 设置时间为当天的开始或结束
selectedDate.setHours(selectingStart ? 0 : 23, selectingStart ? 0 : 59, selectingStart ? 0 : 59, selectingStart ? 0 : 999)
const now = new Date()
// 如果选择的日期晚于当前时间,限制为当前时间
if (selectedDate > now) {
selectedDate.setTime(now.getTime())
}
if (selectingStart) {
// 选择开始日期
const currentEnd = options.dateRange?.end || new Date()
// 如果选择的开始日期晚于结束日期,则同时更新结束日期
if (selectedDate > currentEnd) {
const newEnd = new Date(selectedDate)
newEnd.setHours(23, 59, 59, 999)
// 确保结束日期也不晚于当前时间
if (newEnd > now) {
newEnd.setTime(now.getTime())
}
setOptions({
...options,
dateRange: { start: selectedDate, end: newEnd }
})
} else {
setOptions({
...options,
dateRange: options.dateRange ? { ...options.dateRange, start: selectedDate } : { start: selectedDate, end: new Date() }
})
}
setSelectingStart(false)
} else {
// 选择结束日期
const currentStart = options.dateRange?.start || new Date(Date.now() - 7 * 24 * 60 * 60 * 1000)
// 如果选择的结束日期早于开始日期,则同时更新开始日期
if (selectedDate < currentStart) {
const newStart = new Date(selectedDate)
newStart.setHours(0, 0, 0, 0)
setOptions({
...options,
dateRange: { start: newStart, end: selectedDate }
})
} else {
setOptions({
...options,
dateRange: options.dateRange ? { ...options.dateRange, end: selectedDate } : { start: new Date(), end: selectedDate }
})
}
setSelectingStart(true)
}
}
@@ -343,18 +394,105 @@ function ExportPage() {
</div>
<div className="setting-section">
<h3></h3>
<div className="time-options">
<label className="checkbox-item">
<h3></h3>
<p className="setting-subtitle">//</p>
<div className="media-options-card">
<div className="media-switch-row">
<div className="media-switch-info">
<span className="media-switch-title"></span>
<span className="media-switch-desc"></span>
</div>
<label className="switch">
<input
type="checkbox"
checked={options.exportMedia}
onChange={e => setOptions({ ...options, exportMedia: e.target.checked })}
/>
<span className="slider"></span>
</label>
</div>
<div className="media-option-divider"></div>
<label className={`media-checkbox-row ${!options.exportMedia ? 'disabled' : ''}`}>
<div className="media-checkbox-info">
<span className="media-checkbox-title"></span>
<span className="media-checkbox-desc"></span>
</div>
<input
type="checkbox"
checked={options.exportImages}
disabled={!options.exportMedia}
onChange={e => setOptions({ ...options, exportImages: e.target.checked })}
/>
</label>
<div className="media-option-divider"></div>
<label className={`media-checkbox-row ${!options.exportMedia ? 'disabled' : ''}`}>
<div className="media-checkbox-info">
<span className="media-checkbox-title"></span>
<span className="media-checkbox-desc"> MP3</span>
</div>
<input
type="checkbox"
checked={options.exportVoices}
disabled={!options.exportMedia}
onChange={e => setOptions({ ...options, exportVoices: e.target.checked })}
/>
</label>
<div className="media-option-divider"></div>
<label className="media-checkbox-row">
<div className="media-checkbox-info">
<span className="media-checkbox-title"></span>
<span className="media-checkbox-desc"></span>
</div>
<input
type="checkbox"
checked={options.exportVoiceAsText}
onChange={e => setOptions({ ...options, exportVoiceAsText: e.target.checked })}
/>
</label>
<div className="media-option-divider"></div>
<label className={`media-checkbox-row ${!options.exportMedia ? 'disabled' : ''}`}>
<div className="media-checkbox-info">
<span className="media-checkbox-title"></span>
<span className="media-checkbox-desc"></span>
</div>
<input
type="checkbox"
checked={options.exportEmojis}
disabled={!options.exportMedia}
onChange={e => setOptions({ ...options, exportEmojis: e.target.checked })}
/>
</label>
</div>
</div>
<div className="setting-section">
<h3></h3>
<p className="setting-subtitle"></p>
<div className="media-options-card">
<div className="media-switch-row">
<div className="media-switch-info">
<span className="media-switch-title"></span>
<span className="media-switch-desc"></span>
</div>
<label className="switch">
<input
type="checkbox"
checked={options.exportAvatars}
onChange={e => setOptions({ ...options, exportAvatars: e.target.checked })}
/>
<span></span>
<span className="slider"></span>
</label>
</div>
</div>
</div>
<div className="setting-section">
<h3></h3>
@@ -462,6 +600,9 @@ function ExportPage() {
<div className="export-overlay" onClick={() => setShowDatePicker(false)}>
<div className="date-picker-modal" onClick={e => e.stopPropagation()}>
<h3></h3>
<p style={{ fontSize: '13px', color: 'var(--text-secondary)', margin: '8px 0 16px 0' }}>
</p>
<div className="quick-select">
<button
className="quick-btn"
@@ -556,12 +697,16 @@ function ExportPage() {
const isStart = options.dateRange?.start.toDateString() === currentDate.toDateString()
const isEnd = options.dateRange?.end.toDateString() === currentDate.toDateString()
const isInRange = options.dateRange && currentDate >= options.dateRange.start && currentDate <= options.dateRange.end
const today = new Date()
today.setHours(0, 0, 0, 0)
const isFuture = currentDate > today
return (
<div
key={day}
className={`calendar-day ${isStart ? 'start' : ''} ${isEnd ? 'end' : ''} ${isInRange ? 'in-range' : ''}`}
onClick={() => handleDateSelect(day)}
className={`calendar-day ${isStart ? 'start' : ''} ${isEnd ? 'end' : ''} ${isInRange ? 'in-range' : ''} ${isFuture ? 'disabled' : ''}`}
onClick={() => !isFuture && handleDateSelect(day)}
style={{ cursor: isFuture ? 'not-allowed' : 'pointer', opacity: isFuture ? 0.3 : 1 }}
>
{day}
</div>

View File

@@ -204,6 +204,23 @@
}
}
select {
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);
margin-bottom: 10px;
cursor: pointer;
&:focus {
outline: none;
border-color: var(--primary);
}
}
.input-with-toggle {
position: relative;
display: flex;
@@ -235,6 +252,184 @@
}
}
.whisper-section {
background: color-mix(in srgb, var(--primary) 3%, transparent);
border: 1px solid var(--border-color);
border-radius: 16px;
padding: 20px;
margin-top: 24px;
label {
font-size: 15px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 4px;
}
.whisper-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 12px;
}
.whisper-field {
display: flex;
flex-direction: column;
}
.field-label {
font-size: 12px;
color: var(--text-tertiary);
margin-bottom: 6px;
}
.whisper-status-line {
display: flex;
align-items: center;
gap: 10px;
font-size: 12px;
color: var(--text-secondary);
margin: 12px 0 16px;
padding: 10px 14px;
background: var(--bg-primary);
border-radius: 12px;
border: 1px solid var(--border-color);
.status {
padding: 4px 10px;
border-radius: 999px;
font-size: 11px;
font-weight: 600;
white-space: nowrap;
}
.status.ok {
background: rgba(16, 185, 129, 0.1);
color: #10b981;
border: 1px solid rgba(16, 185, 129, 0.2);
}
.status.warn {
background: rgba(245, 158, 11, 0.1);
color: #f59e0b;
border: 1px solid rgba(245, 158, 11, 0.2);
}
.path {
flex: 1;
min-width: 0;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 11px;
color: var(--text-tertiary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
opacity: 0.8;
}
}
.whisper-progress {
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
margin-top: 10px;
.progress-bar-container {
display: flex;
align-items: center;
gap: 12px;
}
.progress-bar {
flex: 1;
height: 8px;
background: var(--bg-tertiary);
border-radius: 999px;
overflow: hidden;
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.1);
.progress-fill {
height: 100%;
background: linear-gradient(90deg, var(--primary) 0%, var(--primary-hover) 100%);
border-radius: 999px;
transition: width 0.3s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
&::after {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(90deg,
rgba(255, 255, 255, 0) 0%,
rgba(255, 255, 255, 0.2) 50%,
rgba(255, 255, 255, 0) 100%);
animation: progress-shimmer 2s infinite;
}
}
}
.progress-info {
display: flex;
justify-content: space-between;
align-items: center;
span {
font-size: 12px;
color: var(--text-secondary);
font-weight: 500;
&.percent {
color: var(--primary);
font-weight: 600;
}
}
}
}
.btn-download-model {
width: 100%;
height: 44px;
justify-content: center;
font-size: 15px;
font-weight: 600;
margin-top: 8px;
background: linear-gradient(135deg, var(--primary) 0%, var(--primary-hover) 100%);
box-shadow: 0 4px 12px color-mix(in srgb, var(--primary) 20%, transparent);
border: 1px solid rgba(255, 255, 255, 0.1);
&:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: 0 6px 16px color-mix(in srgb, var(--primary) 30%, transparent);
}
&:active:not(:disabled) {
transform: translateY(0);
}
svg {
transition: transform 0.2s;
}
&:hover svg {
transform: translateY(2px);
}
}
}
@keyframes progress-shimmer {
from {
transform: translateX(-100%);
}
to {
transform: translateX(100%);
}
}
.log-toggle-line {
display: flex;
align-items: center;
@@ -248,6 +443,72 @@
color: var(--text-secondary);
}
.language-checkboxes {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-top: 10px;
}
.language-checkbox {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
user-select: none;
position: relative;
input[type="checkbox"] {
position: absolute;
opacity: 0;
cursor: pointer;
height: 0;
width: 0;
}
.checkbox-custom {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
background: var(--bg-primary);
border: 1.5px solid var(--border-color);
border-radius: 12px;
font-size: 14px;
font-weight: 500;
color: var(--text-secondary);
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
svg {
opacity: 0;
transform: scale(0.5);
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
}
&:hover .checkbox-custom {
border-color: var(--text-tertiary);
background: var(--bg-tertiary);
color: var(--text-primary);
}
input:checked+.checkbox-custom {
background: color-mix(in srgb, var(--primary) 10%, transparent);
border-color: var(--primary);
color: var(--primary);
box-shadow: 0 4px 12px color-mix(in srgb, var(--primary) 10%, transparent);
svg {
opacity: 1;
transform: scale(1);
}
}
&:active .checkbox-custom {
transform: scale(0.96);
}
}
.switch {
position: relative;
width: 46px;
@@ -287,12 +548,12 @@
transition: all 0.2s ease;
}
.switch-input:checked + .switch-slider {
.switch-input:checked+.switch-slider {
background: var(--primary);
border-color: var(--primary);
}
.switch-input:checked + .switch-slider::before {
.switch-input:checked+.switch-slider::before {
transform: translateX(22px);
background: #ffffff;
}
@@ -331,18 +592,33 @@
.btn-primary {
background: var(--primary);
color: white;
box-shadow: 0 2px 6px color-mix(in srgb, var(--primary) 15%, transparent);
&:hover:not(:disabled) {
background: var(--primary-hover);
box-shadow: 0 4px 12px color-mix(in srgb, var(--primary) 25%, transparent);
transform: translateY(-1px);
}
&:active:not(:disabled) {
transform: translateY(0);
}
}
.btn-secondary {
background: var(--bg-tertiary);
color: var(--text-primary);
border: 1px solid var(--border-color);
&:hover:not(:disabled) {
background: var(--border-color);
background: var(--bg-primary);
border-color: var(--text-tertiary);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
transform: translateY(-1px);
}
&:active:not(:disabled) {
transform: translateY(0);
}
}
@@ -395,6 +671,7 @@
opacity: 0;
transform: translateX(-50%) translateY(-10px);
}
to {
opacity: 1;
transform: translateX(-50%) translateY(0);
@@ -402,9 +679,12 @@
}
@keyframes pulse {
0%, 100% {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.6;
}
@@ -667,8 +947,13 @@
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@@ -767,3 +1052,168 @@
}
}
}
// wxid 输入框下拉
.wxid-input-wrapper {
position: relative;
display: flex;
align-items: center;
input {
flex: 1;
padding-right: 36px;
}
}
.wxid-dropdown-btn {
position: absolute;
right: 8px;
width: 28px;
height: 28px;
border: none;
border-radius: 6px;
background: transparent;
color: var(--text-secondary);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
&:hover {
color: var(--text-primary);
}
&.open {
transform: rotate(180deg);
}
}
.wxid-dropdown {
position: absolute;
top: 100%;
left: 0;
right: 0;
margin-top: 4px;
background: var(--bg-secondary);
border: 1px solid var(--border-primary);
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
z-index: 100;
max-height: 200px;
overflow-y: auto;
}
.wxid-option {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 12px;
cursor: pointer;
transition: background 0.15s;
&:hover {
background: var(--bg-tertiary);
}
&.active {
background: var(--primary-light);
color: var(--primary);
}
.wxid-value {
font-weight: 500;
font-size: 13px;
}
.wxid-time {
font-size: 11px;
color: var(--text-tertiary);
}
}
// 多账号选择对话框
.wxid-dialog-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.wxid-dialog {
background: var(--bg-primary);
border-radius: 16px;
width: 400px;
max-width: 90vw;
max-height: 80vh;
overflow: hidden;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.2);
}
.wxid-dialog-header {
padding: 20px 24px;
border-bottom: 1px solid var(--border-primary);
h3 {
margin: 0 0 4px;
font-size: 18px;
font-weight: 600;
color: var(--text-primary);
}
p {
margin: 0;
font-size: 13px;
color: var(--text-secondary);
}
}
.wxid-dialog-list {
padding: 8px;
max-height: 300px;
overflow-y: auto;
}
.wxid-dialog-item {
display: flex;
flex-direction: column;
gap: 4px;
padding: 14px 16px;
border-radius: 10px;
cursor: pointer;
transition: all 0.15s;
&:hover {
background: var(--bg-tertiary);
}
&.active {
background: var(--primary-light);
.wxid-id {
color: var(--primary);
}
}
.wxid-id {
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
}
.wxid-date {
font-size: 12px;
color: var(--text-tertiary);
}
}
.wxid-dialog-footer {
padding: 16px 24px;
border-top: 1px solid var(--border-primary);
display: flex;
justify-content: flex-end;
}

View File

@@ -1,27 +1,35 @@
import { useState, useEffect } from 'react'
import { useState, useEffect, useRef } from 'react'
import { useAppStore } from '../stores/appStore'
import { useThemeStore, themes } from '../stores/themeStore'
import { useAnalyticsStore } from '../stores/analyticsStore'
import { dialog } from '../services/ipc'
import * as configService from '../services/config'
import {
Eye, EyeOff, FolderSearch, FolderOpen, Search, Copy,
RotateCcw, Trash2, Save, Plug, Check, Sun, Moon,
Palette, Database, Download, HardDrive, Info, RefreshCw
Palette, Database, Download, HardDrive, Info, RefreshCw, ChevronDown, Mic
} from 'lucide-react'
import './SettingsPage.scss'
type SettingsTab = 'appearance' | 'database' | 'cache' | 'about'
type SettingsTab = 'appearance' | 'database' | 'whisper' | 'cache' | 'about'
const tabs: { id: SettingsTab; label: string; icon: React.ElementType }[] = [
{ id: 'appearance', label: '外观', icon: Palette },
{ id: 'database', label: '数据库连接', icon: Database },
{ id: 'whisper', label: '语音识别模型', icon: Mic },
{ id: 'cache', label: '缓存', icon: HardDrive },
{ id: 'about', label: '关于', icon: Info }
]
interface WxidOption {
wxid: string
modifiedTime: number
}
function SettingsPage() {
const { setDbConnected, setLoading, reset } = useAppStore()
const { currentTheme, themeMode, setTheme, setThemeMode } = useThemeStore()
const clearAnalyticsStoreCache = useAnalyticsStore((state) => state.clearCache)
const [activeTab, setActiveTab] = useState<SettingsTab>('appearance')
const [decryptKey, setDecryptKey] = useState('')
@@ -29,8 +37,18 @@ function SettingsPage() {
const [imageAesKey, setImageAesKey] = useState('')
const [dbPath, setDbPath] = useState('')
const [wxid, setWxid] = useState('')
const [wxidOptions, setWxidOptions] = useState<WxidOption[]>([])
const [showWxidSelect, setShowWxidSelect] = useState(false)
const wxidDropdownRef = useRef<HTMLDivElement>(null)
const [cachePath, setCachePath] = useState('')
const [logEnabled, setLogEnabled] = useState(false)
const [whisperModelName, setWhisperModelName] = useState('base')
const [whisperModelDir, setWhisperModelDir] = useState('')
const [isWhisperDownloading, setIsWhisperDownloading] = useState(false)
const [whisperDownloadProgress, setWhisperDownloadProgress] = useState(0)
const [whisperModelStatus, setWhisperModelStatus] = useState<{ exists: boolean; modelPath?: string; tokensPath?: string } | null>(null)
const [autoTranscribeVoice, setAutoTranscribeVoice] = useState(false)
const [transcribeLanguages, setTranscribeLanguages] = useState<string[]>(['zh'])
const [isLoading, setIsLoadingState] = useState(false)
const [isTesting, setIsTesting] = useState(false)
@@ -47,12 +65,28 @@ function SettingsPage() {
const [dbKeyStatus, setDbKeyStatus] = useState('')
const [imageKeyStatus, setImageKeyStatus] = useState('')
const [isManualStartPrompt, setIsManualStartPrompt] = useState(false)
const [isClearingAnalyticsCache, setIsClearingAnalyticsCache] = useState(false)
const [isClearingImageCache, setIsClearingImageCache] = useState(false)
const [isClearingAllCache, setIsClearingAllCache] = useState(false)
const isClearingCache = isClearingAnalyticsCache || isClearingImageCache || isClearingAllCache
useEffect(() => {
loadConfig()
loadAppVersion()
}, [])
// 点击外部关闭下拉框
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (showWxidSelect && wxidDropdownRef.current && !wxidDropdownRef.current.contains(e.target as Node)) {
setShowWxidSelect(false)
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [showWxidSelect])
useEffect(() => {
const removeDb = window.electronAPI.key.onDbKeyStatus((payload) => {
setDbKeyStatus(payload.message)
@@ -76,6 +110,10 @@ function SettingsPage() {
const savedLogEnabled = await configService.getLogEnabled()
const savedImageXorKey = await configService.getImageXorKey()
const savedImageAesKey = await configService.getImageAesKey()
const savedWhisperModelName = await configService.getWhisperModelName()
const savedWhisperModelDir = await configService.getWhisperModelDir()
const savedAutoTranscribe = await configService.getAutoTranscribeVoice()
const savedTranscribeLanguages = await configService.getTranscribeLanguages()
if (savedKey) setDecryptKey(savedKey)
if (savedPath) setDbPath(savedPath)
@@ -86,6 +124,17 @@ function SettingsPage() {
}
if (savedImageAesKey) setImageAesKey(savedImageAesKey)
setLogEnabled(savedLogEnabled)
setAutoTranscribeVoice(savedAutoTranscribe)
setTranscribeLanguages(savedTranscribeLanguages)
// 如果语言列表为空,保存默认值
if (!savedTranscribeLanguages || savedTranscribeLanguages.length === 0) {
const defaultLanguages = ['zh']
setTranscribeLanguages(defaultLanguages)
await configService.setTranscribeLanguages(defaultLanguages)
}
if (savedWhisperModelDir) setWhisperModelDir(savedWhisperModelDir)
} catch (e) {
console.error('加载配置失败:', e)
}
@@ -93,6 +142,21 @@ function SettingsPage() {
const refreshWhisperStatus = async (modelDirValue = whisperModelDir) => {
try {
const result = await window.electronAPI.whisper?.getModelStatus()
if (result?.success) {
setWhisperModelStatus({
exists: Boolean(result.exists),
modelPath: result.modelPath,
tokensPath: result.tokensPath
})
}
} catch {
setWhisperModelStatus(null)
}
}
const loadAppVersion = async () => {
try {
const version = await window.electronAPI.app.getVersion()
@@ -110,6 +174,19 @@ function SettingsPage() {
return () => removeListener?.()
}, [])
useEffect(() => {
const removeListener = window.electronAPI.whisper?.onDownloadProgress?.((payload) => {
if (typeof payload.percent === 'number') {
setWhisperDownloadProgress(payload.percent)
}
})
return () => removeListener?.()
}, [])
useEffect(() => {
void refreshWhisperStatus(whisperModelDir)
}, [whisperModelDir])
const handleCheckUpdate = async () => {
setIsCheckingUpdate(true)
setUpdateInfo(null)
@@ -117,9 +194,9 @@ function SettingsPage() {
const result = await window.electronAPI.app.checkForUpdates()
if (result.hasUpdate) {
setUpdateInfo(result)
showMessage(`发现新版${result.version}`, true)
showMessage(`发现新版${result.version}`, true)
} else {
showMessage('当前已是最新版', true)
showMessage('当前已是最新版', true)
}
} catch (e) {
showMessage(`检查更新失败: ${e}`, false)
@@ -156,12 +233,14 @@ function SettingsPage() {
showMessage(`自动检测成功:${result.path}`, true)
const wxids = await window.electronAPI.dbPath.scanWxids(result.path)
setWxidOptions(wxids)
if (wxids.length === 1) {
setWxid(wxids[0].wxid)
await configService.setMyWxid(wxids[0].wxid)
showMessage(`已检测到账号:${wxids[0].wxid}`, true)
} else if (wxids.length > 1) {
showMessage(`检测到 ${wxids.length} 个账号,请手动选择`, true)
// 多账号时弹出选择对话框
setShowWxidSelect(true)
}
} else {
showMessage(result.error || '未能自动检测到数据库目录', false)
@@ -192,12 +271,14 @@ function SettingsPage() {
}
try {
const wxids = await window.electronAPI.dbPath.scanWxids(dbPath)
setWxidOptions(wxids)
if (wxids.length === 1) {
setWxid(wxids[0].wxid)
await configService.setMyWxid(wxids[0].wxid)
if (!silent) showMessage(`已检测到账号:${wxids[0].wxid}`, true)
} else if (wxids.length > 1) {
if (!silent) showMessage(`检测到 ${wxids.length} 个账号,请手动选择`, true)
// 多账号时弹出选择对话框
setShowWxidSelect(true)
} else {
if (!silent) showMessage('未检测到账号目录,请检查路径', false)
}
@@ -206,6 +287,13 @@ function SettingsPage() {
}
}
const handleSelectWxid = async (selectedWxid: string) => {
setWxid(selectedWxid)
await configService.setMyWxid(selectedWxid)
setShowWxidSelect(false)
showMessage(`已选择账号:${selectedWxid}`, true)
}
const handleSelectCachePath = async () => {
try {
const result = await dialog.openFile({ title: '选择缓存目录', properties: ['openDirectory'] })
@@ -220,6 +308,51 @@ function SettingsPage() {
const handleSelectWhisperModelDir = async () => {
try {
const result = await dialog.openFile({ title: '选择 Whisper 模型下载目录', properties: ['openDirectory'] })
if (!result.canceled && result.filePaths.length > 0) {
const dir = result.filePaths[0]
setWhisperModelDir(dir)
await configService.setWhisperModelDir(dir)
showMessage('已选择 Whisper 模型目录', true)
}
} catch (e) {
showMessage('选择目录失败', false)
}
}
const handleWhisperModelChange = async (value: string) => {
setWhisperModelName(value)
setWhisperDownloadProgress(0)
await configService.setWhisperModelName(value)
}
const handleDownloadWhisperModel = async () => {
if (isWhisperDownloading) return
setIsWhisperDownloading(true)
setWhisperDownloadProgress(0)
try {
const result = await window.electronAPI.whisper.downloadModel()
if (result.success) {
setWhisperDownloadProgress(100)
showMessage('SenseVoiceSmall 模型下载完成', true)
await refreshWhisperStatus(whisperModelDir)
} else {
showMessage(result.error || '模型下载失败', false)
}
} catch (e) {
showMessage(`模型下载失败: ${e}`, false)
} finally {
setIsWhisperDownloading(false)
}
}
const handleResetWhisperModelDir = async () => {
setWhisperModelDir('')
await configService.setWhisperModelDir('')
}
const handleAutoGetDbKey = async () => {
if (isFetchingDbKey) return
setIsFetchingDbKey(true)
@@ -330,6 +463,9 @@ function SettingsPage() {
} else {
await configService.setImageAesKey('')
}
await configService.setWhisperModelDir(whisperModelDir)
await configService.setAutoTranscribeVoice(autoTranscribeVoice)
await configService.setTranscribeLanguages(transcribeLanguages)
await configService.setOnboardingDone(true)
showMessage('配置保存成功,正在测试连接...', true)
@@ -350,7 +486,7 @@ function SettingsPage() {
}
const handleClearConfig = async () => {
const confirmed = window.confirm('确定要清除当前配置吗?清除后需要重新完成首次配置')
const confirmed = window.confirm('确定要清除当前配置吗?清除后需要重新完成首次配置')
if (!confirmed) return
setIsLoadingState(true)
setLoading(true, '正在清除配置...')
@@ -365,6 +501,12 @@ function SettingsPage() {
setWxid('')
setCachePath('')
setLogEnabled(false)
setAutoTranscribeVoice(false)
setTranscribeLanguages(['zh'])
setWhisperModelDir('')
setWhisperModelStatus(null)
setWhisperDownloadProgress(0)
setIsWhisperDownloading(false)
setDbConnected(false)
await window.electronAPI.window.openOnboardingWindow()
} catch (e) {
@@ -398,6 +540,59 @@ function SettingsPage() {
}
}
const handleClearAnalyticsCache = async () => {
if (isClearingCache) return
setIsClearingAnalyticsCache(true)
try {
const result = await window.electronAPI.cache.clearAnalytics()
if (result.success) {
clearAnalyticsStoreCache()
showMessage('已清除分析缓存', true)
} else {
showMessage(`清除分析缓存失败: ${result.error || '未知错误'}`, false)
}
} catch (e) {
showMessage(`清除分析缓存失败: ${e}`, false)
} finally {
setIsClearingAnalyticsCache(false)
}
}
const handleClearImageCache = async () => {
if (isClearingCache) return
setIsClearingImageCache(true)
try {
const result = await window.electronAPI.cache.clearImages()
if (result.success) {
showMessage('已清除图片缓存', true)
} else {
showMessage(`清除图片缓存失败: ${result.error || '未知错误'}`, false)
}
} catch (e) {
showMessage(`清除图片缓存失败: ${e}`, false)
} finally {
setIsClearingImageCache(false)
}
}
const handleClearAllCache = async () => {
if (isClearingCache) return
setIsClearingAllCache(true)
try {
const result = await window.electronAPI.cache.clearAll()
if (result.success) {
clearAnalyticsStoreCache()
showMessage('已清除所有缓存', true)
} else {
showMessage(`清除所有缓存失败: ${result.error || '未知错误'}`, false)
}
} catch (e) {
showMessage(`清除所有缓存失败: ${e}`, false)
} finally {
setIsClearingAllCache(false)
}
}
const renderAppearanceTab = () => (
<div className="tab-content">
<div className="theme-mode-toggle">
@@ -454,6 +649,7 @@ function SettingsPage() {
<div className="form-group">
<label></label>
<span className="form-hint">xwechat_files </span>
<span className="form-hint" style={{ color: '#ff6b6b' }}> --</span>
<input type="text" placeholder="例如: C:\Users\xxx\Documents\xwechat_files" value={dbPath} onChange={(e) => setDbPath(e.target.value)} />
<div className="btn-row">
<button className="btn btn-primary" onClick={handleAutoDetectPath} disabled={isDetectingPath}>
@@ -466,7 +662,38 @@ function SettingsPage() {
<div className="form-group">
<label> wxid</label>
<span className="form-hint"></span>
<input type="text" placeholder="例如: wxid_xxxxxx" value={wxid} onChange={(e) => setWxid(e.target.value)} />
<div className="wxid-input-wrapper" ref={wxidDropdownRef}>
<input
type="text"
placeholder="例如: wxid_xxxxxx"
value={wxid}
onChange={(e) => setWxid(e.target.value)}
/>
<button
type="button"
className={`wxid-dropdown-btn ${showWxidSelect ? 'open' : ''}`}
onClick={() => wxidOptions.length > 0 ? setShowWxidSelect(!showWxidSelect) : handleScanWxid()}
title={wxidOptions.length > 0 ? "选择已检测到的账号" : "扫描账号"}
>
<ChevronDown size={16} />
</button>
{showWxidSelect && wxidOptions.length > 0 && (
<div className="wxid-dropdown">
{wxidOptions.map((opt) => (
<div
key={opt.wxid}
className={`wxid-option ${opt.wxid === wxid ? 'active' : ''}`}
onClick={() => handleSelectWxid(opt.wxid)}
>
<span className="wxid-value">{opt.wxid}</span>
<span className="wxid-time">
{new Date(opt.modifiedTime).toLocaleDateString()}
</span>
</div>
))}
</div>
)}
</div>
<button className="btn btn-secondary btn-sm" onClick={() => handleScanWxid()}><Search size={14} /> wxid</button>
</div>
@@ -487,16 +714,6 @@ function SettingsPage() {
{isFetchingImageKey && <div className="form-hint status-text">...</div>}
</div>
<div className="form-group">
<label> <span className="optional">()</span></label>
<span className="form-hint">使</span>
<input type="text" placeholder="留空使用默认目录" value={cachePath} onChange={(e) => setCachePath(e.target.value)} />
<div className="btn-row">
<button className="btn btn-secondary" onClick={handleSelectCachePath}><FolderOpen size={16} /> </button>
<button className="btn btn-secondary" onClick={() => setCachePath('')}><RotateCcw size={16} /> </button>
</div>
</div>
<div className="form-group">
<label></label>
<span className="form-hint"> WCDB 便</span>
@@ -529,16 +746,135 @@ function SettingsPage() {
</div>
</div>
)
const renderWhisperTab = () => (
<div className="tab-content">
<div className="form-group">
<label></label>
<span className="form-hint"></span>
<div className="log-toggle-line">
<span className="log-status">{autoTranscribeVoice ? '已开启' : '已关闭'}</span>
<label className="switch" htmlFor="auto-transcribe-toggle">
<input
id="auto-transcribe-toggle"
className="switch-input"
type="checkbox"
checked={autoTranscribeVoice}
onChange={async (e) => {
const enabled = e.target.checked
setAutoTranscribeVoice(enabled)
await configService.setAutoTranscribeVoice(enabled)
showMessage(enabled ? '已开启自动转文字' : '已关闭自动转文字', true)
}}
/>
<span className="switch-slider" />
</label>
</div>
</div>
<div className="form-group">
<label></label>
<span className="form-hint"></span>
<div className="language-checkboxes">
{[
{ code: 'zh', name: '中文' },
{ code: 'yue', name: '粤语' },
{ code: 'en', name: '英文' },
{ code: 'ja', name: '日文' },
{ code: 'ko', name: '韩文' }
].map((lang) => (
<label key={lang.code} className="language-checkbox">
<input
type="checkbox"
checked={transcribeLanguages.includes(lang.code)}
onChange={async (e) => {
const checked = e.target.checked
let newLanguages: string[]
if (checked) {
newLanguages = [...transcribeLanguages, lang.code]
} else {
if (transcribeLanguages.length <= 1) {
showMessage('至少需要选择一种语言', false)
return
}
newLanguages = transcribeLanguages.filter(l => l !== lang.code)
}
setTranscribeLanguages(newLanguages)
await configService.setTranscribeLanguages(newLanguages)
showMessage(`${checked ? '添加' : '移除'}${lang.name}`, true)
}}
/>
<div className="checkbox-custom">
<Check size={14} />
<span>{lang.name}</span>
</div>
</label>
))}
</div>
</div>
<div className="form-group whisper-section">
<label> (SenseVoiceSmall)</label>
<span className="form-hint"> Sherpa-onnx/</span>
<span className="form-hint"></span>
<input
type="text"
placeholder="留空使用默认目录"
value={whisperModelDir}
onChange={(e) => setWhisperModelDir(e.target.value)}
onBlur={() => configService.setWhisperModelDir(whisperModelDir)}
/>
<div className="btn-row">
<button className="btn btn-secondary" onClick={handleSelectWhisperModelDir}><FolderOpen size={16} /> </button>
<button className="btn btn-secondary" onClick={handleResetWhisperModelDir}><RotateCcw size={16} /> </button>
</div>
<div className="whisper-status-line">
<span className={`status ${whisperModelStatus?.exists ? 'ok' : 'warn'}`}>
{whisperModelStatus?.exists ? '已下载 (240 MB)' : '未下载 (240 MB)'}
</span>
{whisperModelStatus?.modelPath && <span className="path">{whisperModelStatus.modelPath}</span>}
</div>
{isWhisperDownloading ? (
<div className="whisper-progress">
<div className="progress-info">
<span>...</span>
<span className="percent">{whisperDownloadProgress.toFixed(0)}%</span>
</div>
<div className="progress-bar-container">
<div className="progress-bar">
<div className="progress-fill" style={{ width: `${whisperDownloadProgress}%` }} />
</div>
</div>
</div>
) : (
<button className="btn btn-primary btn-download-model" onClick={handleDownloadWhisperModel}>
<Download size={18} />
</button>
)}
</div>
</div>
)
const renderCacheTab = () => (
<div className="tab-content">
<p className="section-desc"></p>
<div className="form-group">
<label> <span className="optional">()</span></label>
<span className="form-hint">使</span>
<input type="text" placeholder="留空使用默认目录" value={cachePath} onChange={(e) => setCachePath(e.target.value)} />
<div className="btn-row">
<button className="btn btn-secondary"><Trash2 size={16} /> </button>
<button className="btn btn-secondary"><Trash2 size={16} /> </button>
<button className="btn btn-danger"><Trash2 size={16} /> </button>
<button className="btn btn-secondary" onClick={handleSelectCachePath}><FolderOpen size={16} /> </button>
<button className="btn btn-secondary" onClick={() => setCachePath('')}><RotateCcw size={16} /> </button>
</div>
</div>
<div className="btn-row">
<button className="btn btn-secondary" onClick={handleClearAnalyticsCache} disabled={isClearingCache}>
<Trash2 size={16} />
</button>
<button className="btn btn-secondary" onClick={handleClearImageCache} disabled={isClearingCache}>
<Trash2 size={16} />
</button>
<button className="btn btn-danger" onClick={handleClearAllCache} disabled={isClearingCache}>
<Trash2 size={16} /> </button>
</div>
<div className="divider" />
<p className="section-desc"></p>
@@ -563,7 +899,7 @@ function SettingsPage() {
<div className="about-update">
{updateInfo?.hasUpdate ? (
<>
<p className="update-hint"> v{updateInfo.version} </p>
<p className="update-hint"> v{updateInfo.version} </p>
{isDownloading ? (
<div className="download-progress">
<div className="progress-bar">
@@ -604,6 +940,33 @@ function SettingsPage() {
<div className="settings-page">
{message && <div className={`message-toast ${message.success ? 'success' : 'error'}`}>{message.text}</div>}
{/* 多账号选择对话框 */}
{showWxidSelect && wxidOptions.length > 1 && (
<div className="wxid-dialog-overlay" onClick={() => setShowWxidSelect(false)}>
<div className="wxid-dialog" onClick={(e) => e.stopPropagation()}>
<div className="wxid-dialog-header">
<h3></h3>
<p>使</p>
</div>
<div className="wxid-dialog-list">
{wxidOptions.map((opt) => (
<div
key={opt.wxid}
className={`wxid-dialog-item ${opt.wxid === wxid ? 'active' : ''}`}
onClick={() => handleSelectWxid(opt.wxid)}
>
<span className="wxid-id">{opt.wxid}</span>
<span className="wxid-date"> {new Date(opt.modifiedTime).toLocaleString()}</span>
</div>
))}
</div>
<div className="wxid-dialog-footer">
<button className="btn btn-secondary" onClick={() => setShowWxidSelect(false)}></button>
</div>
</div>
</div>
)}
<div className="settings-header">
<h1></h1>
<div className="settings-actions">
@@ -628,6 +991,7 @@ function SettingsPage() {
<div className="settings-body">
{activeTab === 'appearance' && renderAppearanceTab()}
{activeTab === 'database' && renderDatabaseTab()}
{activeTab === 'whisper' && renderWhisperTab()}
{activeTab === 'cache' && renderCacheTab()}
{activeTab === 'about' && renderAboutTab()}
</div>
@@ -636,3 +1000,5 @@ function 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

@@ -442,6 +442,7 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
</button>
</div>
<div className="field-hint"> xwechat_files </div>
<div className="field-hint" style={{ color: '#ff6b6b', marginTop: '4px' }}> --</div>
</div>
)}

View File

@@ -17,7 +17,12 @@ export const CONFIG_KEYS = {
ONBOARDING_DONE: 'onboardingDone',
LLM_MODEL_PATH: 'llmModelPath',
IMAGE_XOR_KEY: 'imageXorKey',
IMAGE_AES_KEY: 'imageAesKey'
IMAGE_AES_KEY: 'imageAesKey',
WHISPER_MODEL_NAME: 'whisperModelName',
WHISPER_MODEL_DIR: 'whisperModelDir',
WHISPER_DOWNLOAD_SOURCE: 'whisperDownloadSource',
AUTO_TRANSCRIBE_VOICE: 'autoTranscribeVoice',
TRANSCRIBE_LANGUAGES: 'transcribeLanguages'
} as const
// 获取解密密钥
@@ -144,6 +149,39 @@ export async function setLlmModelPath(path: string): Promise<void> {
await config.set(CONFIG_KEYS.LLM_MODEL_PATH, path)
}
// 获取 Whisper 模型名称
export async function getWhisperModelName(): Promise<string | null> {
const value = await config.get(CONFIG_KEYS.WHISPER_MODEL_NAME)
return (value as string) || null
}
// 设置 Whisper 模型名称
export async function setWhisperModelName(name: string): Promise<void> {
await config.set(CONFIG_KEYS.WHISPER_MODEL_NAME, name)
}
// 获取 Whisper 模型目录
export async function getWhisperModelDir(): Promise<string | null> {
const value = await config.get(CONFIG_KEYS.WHISPER_MODEL_DIR)
return (value as string) || null
}
// 设置 Whisper 模型目录
export async function setWhisperModelDir(dir: string): Promise<void> {
await config.set(CONFIG_KEYS.WHISPER_MODEL_DIR, dir)
}
// 获取 Whisper 下载源
export async function getWhisperDownloadSource(): Promise<string | null> {
const value = await config.get(CONFIG_KEYS.WHISPER_DOWNLOAD_SOURCE)
return (value as string) || null
}
// 设置 Whisper 下载源
export async function setWhisperDownloadSource(source: string): Promise<void> {
await config.set(CONFIG_KEYS.WHISPER_DOWNLOAD_SOURCE, source)
}
// 清除所有配置
export async function clearConfig(): Promise<void> {
await config.clear()
@@ -182,3 +220,26 @@ export async function getOnboardingDone(): Promise<boolean> {
export async function setOnboardingDone(done: boolean): Promise<void> {
await config.set(CONFIG_KEYS.ONBOARDING_DONE, done)
}
// 获取自动语音转文字开关
export async function getAutoTranscribeVoice(): Promise<boolean> {
const value = await config.get(CONFIG_KEYS.AUTO_TRANSCRIBE_VOICE)
return value === true
}
// 设置自动语音转文字开关
export async function setAutoTranscribeVoice(enabled: boolean): Promise<void> {
await config.set(CONFIG_KEYS.AUTO_TRANSCRIBE_VOICE, enabled)
}
// 获取语音转文字支持的语言列表
export async function getTranscribeLanguages(): Promise<string[]> {
const value = await config.get(CONFIG_KEYS.TRANSCRIBE_LANGUAGES)
// 默认只支持中文
return (value as string[]) || ['zh']
}
// 设置语音转文字支持的语言列表
export async function setTranscribeLanguages(languages: string[]): Promise<void> {
await config.set(CONFIG_KEYS.TRANSCRIBE_LANGUAGES, languages)
}

View File

@@ -9,6 +9,8 @@ export interface ElectronAPI {
completeOnboarding: () => Promise<boolean>
openOnboardingWindow: () => Promise<boolean>
setTitleBarOverlay: (options: { symbolColor: string }) => void
openVideoPlayerWindow: (videoPath: string, videoWidth?: number, videoHeight?: number) => Promise<void>
resizeToFitVideo: (videoWidth: number, videoHeight: number) => Promise<void>
}
config: {
get: (key: string) => Promise<unknown>
@@ -94,7 +96,10 @@ export interface ElectronAPI {
error?: string
}>
getImageData: (sessionId: string, msgId: string) => Promise<{ success: boolean; data?: string; error?: string }>
getVoiceData: (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 }>
resolveVoiceCache: (sessionId: string, msgId: string) => Promise<{ success: boolean; hasCache: boolean; data?: string }>
getVoiceTranscript: (sessionId: string, msgId: string, createTime?: number) => Promise<{ success: boolean; transcript?: string; error?: string }>
onVoiceTranscriptPartial: (callback: (payload: { msgId: string; text: string }) => void) => () => void
}
image: {
@@ -104,6 +109,21 @@ export interface ElectronAPI {
onUpdateAvailable: (callback: (payload: { cacheKey: string; imageMd5?: string; imageDatName?: 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: {
getOverallStatistics: (force?: boolean) => Promise<{
success: boolean
@@ -148,6 +168,11 @@ export interface ElectronAPI {
}>
onProgress: (callback: (payload: { status: string; progress: number }) => void) => () => void
}
cache: {
clearAnalytics: () => Promise<{ success: boolean; error?: string }>
clearImages: () => Promise<{ success: boolean; error?: string }>
clearAll: () => Promise<{ success: boolean; error?: string }>
}
groupAnalytics: {
getGroupChats: () => Promise<{
success: boolean
@@ -290,6 +315,11 @@ export interface ElectronAPI {
error?: string
}>
}
whisper: {
downloadModel: () => Promise<{ success: boolean; modelPath?: string; tokensPath?: string; error?: string }>
getModelStatus: () => Promise<{ success: boolean; exists?: boolean; modelPath?: string; tokensPath?: string; sizeBytes?: number; error?: string }>
onDownloadProgress: (callback: (payload: { modelName: string; downloadedBytes: number; totalBytes?: number; percent?: number }) => void) => () => void
}
}
export interface ExportOptions {

View File

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

View File

@@ -13,6 +13,7 @@
},
"include": [
"vite.config.ts",
"electron/**/*.ts"
"electron/**/*.ts",
"electron/**/*.d.ts"
]
}

View File

@@ -30,7 +30,10 @@ export default defineConfig({
external: [
'better-sqlite3',
'koffi',
'fsevents'
'fsevents',
'whisper-node',
'shelljs',
'exceljs'
]
}
}
@@ -87,6 +90,23 @@ export default defineConfig({
}
}
},
{
entry: 'electron/transcribeWorker.ts',
vite: {
build: {
outDir: 'dist-electron',
rollupOptions: {
external: [
'sherpa-onnx-node'
],
output: {
entryFileNames: 'transcribeWorker.js',
inlineDynamicImports: true
}
}
}
}
},
{
entry: 'electron/preload.ts',
onstart(options) {