diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 91df761..378a8ec 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -16,7 +16,7 @@ jobs: - name: Check out git repository uses: actions/checkout@v4 with: - fetch-depth: 0 + fetch-depth: 0 - name: Install Node.js uses: actions/setup-node@v4 @@ -25,7 +25,14 @@ 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 - name: Build Frontend & Type Check run: | @@ -39,30 +46,37 @@ jobs: outputFile: "release-notes.md" configurationJson: | { - "template": "# v${{ github.ref_name }} 版本发布\n\n{{CHANGELOG}}\n\n---\n> 此更新由系统自动构建", + "template": "# v${{ github.ref_name }} 更新日志\n\n{{CHANGELOG}}\n\n---\n> 此更新由系统自动构建", "categories": [ { - "title": "## 新功能", - "filter": { "pattern": "^feat:.*", "flags": "i" } + "title": "## 新功能", + "filter": { "pattern": "^feat.*:.*", "flags": "i" } }, { - "title": "## 修复", - "filter": { "pattern": "^fix:.*", "flags": "i" } + "title": "## 修复", + "filter": { "pattern": "^fix.*:.*", "flags": "i" } }, { - "title": "## 性能与维护", - "filter": { "pattern": "^(chore|docs|perf|refactor):.*", "flags": "i" } + "title": "## 性能与维护", + "filter": { "pattern": "^(chore|docs|perf|refactor|ci|style|test).*:.*", "flags": "i" } } ], "ignore_labels": [], "commitMode": true, - "empty_summary": "## 更新详情\n- 常规代码优化与维护" + "empty_summary": "## 更新详情\n- 常规代码优化与维护" } env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Check Changelog Content + shell: bash + run: | + echo "=== RELEASE NOTES CONTENT START ===" + cat release-notes.md + echo "=== RELEASE NOTES CONTENT END ===" + - name: Package and Publish env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - npx electron-builder --publish always "-c.releaseInfo.releaseNotesFile=release-notes.md" \ No newline at end of file + npx electron-builder --publish always -c.releaseInfo.releaseNotesFile=release-notes.md \ No newline at end of file diff --git a/.gitignore b/.gitignore index 66440f0..ae42d85 100644 --- a/.gitignore +++ b/.gitignore @@ -56,3 +56,4 @@ Thumbs.db *.aps wcdb/ +*info diff --git a/electron/main.ts b/electron/main.ts index 84332c9..8cade14 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -15,6 +15,7 @@ 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' // 配置自动更新 @@ -438,8 +439,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) => { + return chatService.getVoiceTranscript(sessionId, msgId, (text) => { + event.sender.send('chat:voiceTranscriptPartial', { msgId, text }) + }) }) ipcMain.handle('chat:getMessageById', async (_, sessionId: string, localId: number) => { @@ -482,6 +492,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() diff --git a/electron/preload.ts b/electron/preload.ts index 897d9b7..775e19a 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -106,7 +106,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) => ipcRenderer.invoke('chat:getVoiceTranscript', sessionId, msgId), + 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) + } }, @@ -140,6 +148,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 +182,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') + } } }) diff --git a/electron/services/analyticsService.ts b/electron/services/analyticsService.ts index 9e83f64..d52508a 100644 --- a/electron/services/analyticsService.ts +++ b/electron/services/analyticsService.ts @@ -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() diff --git a/electron/services/chatService.ts b/electron/services/chatService.ts index 1e7457a..cfe251b 100644 --- a/electron/services/chatService.ts +++ b/electron/services/chatService.ts @@ -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 @@ -83,6 +80,10 @@ class ChatService { private hardlinkCache = new Map() private readonly contactCacheService: ContactCacheService private readonly messageCacheService: MessageCacheService + private voiceWavCache = new Map() + private voiceTranscriptCache = new Map() + private voiceTranscriptPending = new Map>() + private readonly voiceCacheMaxEntries = 50 constructor() { this.configService = new ConfigService() @@ -1725,6 +1726,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 } + } + /** * 下载并缓存表情包 */ @@ -2102,14 +2145,141 @@ class ChatService { } } - async getVoiceData(sessionId: string, msgId: string): Promise<{ success: boolean; data?: string; error?: string }> { + /** + * getVoiceData (优化的 C++ 实现 + 文件缓存) + */ + async getVoiceData(sessionId: string, msgId: string, createTime?: number, serverId?: string | number): Promise<{ success: boolean; data?: string; error?: string }> { + + try { + const localId = parseInt(msgId, 10) + if (isNaN(localId)) { + return { success: false, error: '无效的消息ID' } + } + + // 检查文件缓存 + const cacheKey = this.getVoiceCacheKey(sessionId, msgId) + const cachedFile = this.getVoiceCacheFilePath(cacheKey) + if (existsSync(cachedFile)) { + try { + const wavData = readFileSync(cachedFile) + console.info('[ChatService][Voice] 使用缓存文件:', cachedFile) + return { success: true, data: wavData.toString('base64') } + } catch (e) { + console.error('[ChatService][Voice] 读取缓存失败:', e) + // 继续重新解密 + } + } + + // 1. 确定 createTime 和 svrId + let msgCreateTime = createTime + let msgSvrId: string | number = serverId || 0 + + // 如果提供了传来的参数,验证其有效性 + if (!msgCreateTime || msgCreateTime === 0) { + const msgResult = await this.getMessageByLocalId(sessionId, localId) + if (msgResult.success && msgResult.message) { + const msg = msgResult.message as any + msgCreateTime = msg.createTime || msg.create_time + // 尝试获取各种可能的 server id 列名 (只有在没有传入 serverId 时才查找) + if (!msgSvrId || msgSvrId === 0) { + msgSvrId = msg.serverId || msg.svr_id || msg.msg_svr_id || msg.message_id || 0 + } + } + } + + if (!msgCreateTime) { + return { success: false, error: '未找到消息时间戳' } + } + + // 2. 构建查找候选 (sessionId, myWxid) + const candidates: string[] = [] + if (sessionId) candidates.push(sessionId) + const myWxid = this.configService.get('myWxid') as string + if (myWxid && !candidates.includes(myWxid)) { + candidates.push(myWxid) + } + + + + // 3. 调用 C++ 接口获取语音 (Hex) + const voiceRes = await wcdbService.getVoiceData(sessionId, msgCreateTime, candidates, localId, msgSvrId) + if (!voiceRes.success || !voiceRes.hex) { + return { success: false, error: voiceRes.error || '未找到语音数据' } + } + + + + // 4. Hex 转 Buffer (Silk) + const silkData = Buffer.from(voiceRes.hex, 'hex') + + // 5. 使用 silk-wasm 解码 + try { + const pcmData = await this.decodeSilkToPcm(silkData, 24000) + if (!pcmData) { + return { success: false, error: 'Silk 解码失败' } + } + + // PCM -> WAV + const wavData = this.createWavBuffer(pcmData, 24000) + + // 保存到文件缓存 + try { + this.saveVoiceCache(cacheKey, wavData) + console.info('[ChatService][Voice] 已保存缓存:', cachedFile) + } catch (e) { + console.error('[ChatService][Voice] 保存缓存失败:', e) + // 不影响返回 + } + + // 缓存 WAV 数据 (内存缓存) + this.cacheVoiceWav(cacheKey, wavData) + + return { success: true, data: wavData.toString('base64') } + } catch (e) { + console.error('[ChatService][Voice] decoding error:', e) + return { success: false, error: '语音解码失败: ' + String(e) } + } + } catch (e) { + console.error('ChatService: getVoiceData 失败:', e) + return { success: false, error: String(e) } + } + } + + /** + * 检查语音是否已有缓存 + */ + async resolveVoiceCache(sessionId: string, msgId: string): Promise<{ success: boolean; hasCache: boolean; data?: string }> { + try { + const cacheKey = this.getVoiceCacheKey(sessionId, msgId) + + // 1. 检查内存缓存 + const inMemory = this.voiceWavCache.get(cacheKey) + if (inMemory) { + return { success: true, hasCache: true, data: inMemory.toString('base64') } + } + + // 2. 检查文件缓存 + const cachedFile = this.getVoiceCacheFilePath(cacheKey) + if (existsSync(cachedFile)) { + const wavData = readFileSync(cachedFile) + this.cacheVoiceWav(cacheKey, wavData) // 回甜内存 + return { success: true, hasCache: true, data: wavData.toString('base64') } + } + + return { success: true, hasCache: false } + } 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 +2310,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 +2354,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 -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 +2399,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 { + 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,7 +2454,109 @@ 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, + onPartial?: (text: string) => void + ): Promise<{ success: boolean; transcript?: string; error?: string }> { + const cacheKey = this.getVoiceCacheKey(sessionId, msgId) + const cached = this.voiceTranscriptCache.get(cacheKey) + if (cached) { + return { success: true, transcript: cached } + } + + const pending = this.voiceTranscriptPending.get(cacheKey) + if (pending) { + return pending + } + + const task = (async () => { + try { + let wavData = this.voiceWavCache.get(cacheKey) + if (!wavData) { + // 获取消息详情以拿到 createTime 和 serverId + let cTime: number | undefined + let sId: string | number | undefined + const msgResult = await this.getMessageById(sessionId, parseInt(msgId, 10)) + if (msgResult.success && msgResult.message) { + cTime = msgResult.message.createTime + sId = msgResult.message.serverId + } + + const voiceResult = await this.getVoiceData(sessionId, msgId, cTime, sId) + if (!voiceResult.success || !voiceResult.data) { + return { success: false, error: voiceResult.error || '语音解码失败' } + } + wavData = Buffer.from(voiceResult.data, 'base64') + } + + const result = await voiceTranscribeService.transcribeWavBuffer(wavData, (text) => { + onPartial?.(text) + }) + if (result.success && result.transcript) { + this.cacheVoiceTranscript(cacheKey, result.transcript) + } + return result + } catch (error) { + return { success: false, error: String(error) } + } finally { + this.voiceTranscriptPending.delete(cacheKey) + } + })() + + this.voiceTranscriptPending.set(cacheKey, task) + return task + } + + + + private getVoiceCacheKey(sessionId: string, msgId: string): string { + 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 getVoiceCacheFilePath(cacheKey: string): string { + const cachePath = this.configService.get('cachePath') as string | undefined + let baseDir: string + if (cachePath && cachePath.trim()) { + baseDir = join(cachePath, 'Voices') + } else { + const documentsPath = app.getPath('documents') + baseDir = join(documentsPath, 'WeFlow', 'Voices') + } + if (!existsSync(baseDir)) { + mkdirSync(baseDir, { recursive: true }) + } + return join(baseDir, `${cacheKey}.wav`) + } + + /** + * 保存语音到文件缓存 + */ + private saveVoiceCache(cacheKey: string, wavData: Buffer): void { + const filePath = this.getVoiceCacheFilePath(cacheKey) + writeFileSync(filePath, wavData) + } + + private cacheVoiceTranscript(cacheKey: string, transcript: string): void { + 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 }> { diff --git a/electron/services/config.ts b/electron/services/config.ts index bbb7bb7..c6233ef 100644 --- a/electron/services/config.ts +++ b/electron/services/config.ts @@ -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'] } }) } diff --git a/electron/services/contactCacheService.ts b/electron/services/contactCacheService.ts index e29e4a1..0dad44f 100644 --- a/electron/services/contactCacheService.ts +++ b/electron/services/contactCacheService.ts @@ -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) + } + } } diff --git a/electron/services/exportService.ts b/electron/services/exportService.ts index b022107..a3bfa4e 100644 --- a/electron/services/exportService.ts +++ b/electron/services/exportService.ts @@ -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('57')) { const title = this.extractXmlValue(content, 'title') @@ -258,9 +272,41 @@ class ExportService { } private cleanSystemMessage(content: string): string { + if (!content) return '[系统消息]' + + // 先尝试提取特定的系统消息内容 + // 1. 提取 sysmsg 中的文本内容 + const sysmsgTextMatch = /]*>([\s\S]*?)<\/sysmsg>/i.exec(content) + if (sysmsgTextMatch) { + content = sysmsgTextMatch[1] + } + + // 2. 提取 revokemsg 撤回消息 + const revokeMatch = /<\/replacemsg>/i.exec(content) + if (revokeMatch) { + return revokeMatch[1].trim() + } + + // 3. 提取 pat 拍一拍消息 + const patMatch = /