diff --git a/electron/main.ts b/electron/main.ts index 1332dfa..2e4038b 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' // 配置自动更新 @@ -442,6 +443,10 @@ function registerIpcHandlers() { return chatService.getVoiceData(sessionId, msgId) }) + ipcMain.handle('chat:getVoiceTranscript', async (_, sessionId: string, msgId: string) => { + return chatService.getVoiceTranscript(sessionId, msgId) + }) + ipcMain.handle('chat:getMessageById', async (_, sessionId: string, localId: number) => { return chatService.getMessageById(sessionId, localId) }) @@ -516,6 +521,16 @@ function registerIpcHandlers() { return { success: true } }) + ipcMain.handle('whisper:downloadModel', async (event, payload: { modelName: string; downloadDir?: string; source?: string }) => { + return voiceTranscribeService.downloadModel(payload, (progress) => { + event.sender.send('whisper:downloadProgress', progress) + }) + }) + + ipcMain.handle('whisper:getModelStatus', async (_, payload: { modelName: string; downloadDir?: string }) => { + return voiceTranscribeService.getModelStatus(payload) + }) + // 群聊分析相关 ipcMain.handle('groupAnalytics:getGroupChats', async () => { return groupAnalyticsService.getGroupChats() diff --git a/electron/preload.ts b/electron/preload.ts index f8883e2..81fbd1e 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -106,7 +106,8 @@ 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) => ipcRenderer.invoke('chat:getVoiceData', sessionId, msgId), + getVoiceTranscript: (sessionId: string, msgId: string) => ipcRenderer.invoke('chat:getVoiceTranscript', sessionId, msgId) }, @@ -174,5 +175,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: (payload: { modelName: string; downloadDir?: string; source?: string }) => + ipcRenderer.invoke('whisper:downloadModel', payload), + getModelStatus: (payload: { modelName: string; downloadDir?: string }) => + ipcRenderer.invoke('whisper:getModelStatus', payload), + 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/chatService.ts b/electron/services/chatService.ts index d4ef5f5..8953759 100644 --- a/electron/services/chatService.ts +++ b/electron/services/chatService.ts @@ -16,6 +16,7 @@ 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 +84,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() @@ -1738,6 +1743,9 @@ class ChatService { if (includeMessages) { this.messageCacheService.clear() + this.voiceWavCache.clear() + this.voiceTranscriptCache.clear() + this.voiceTranscriptPending.clear() } for (const state of this.hardlinkCache.values()) { @@ -2263,6 +2271,8 @@ class ChatService { const pcmData = readFileSync(pcmFile) const wavHeader = this.createWavHeader(pcmData.length, 24000, 1) // 微信语音通常 24kHz const wavData = Buffer.concat([wavHeader, pcmData]) + const cacheKey = this.getVoiceCacheKey(sessionId, msgId) + this.cacheVoiceWav(cacheKey, wavData) return { success: true, data: wavData.toString('base64') } } finally { @@ -2276,6 +2286,45 @@ class ChatService { } } + async getVoiceTranscript(sessionId: string, msgId: string): 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) { + const voiceResult = await this.getVoiceData(sessionId, msgId) + if (!voiceResult.success || !voiceResult.data) { + return { success: false, error: voiceResult.error || '语音解码失败' } + } + wavData = Buffer.from(voiceResult.data, 'base64') + } + + const result = await voiceTranscribeService.transcribeWavBuffer(wavData) + 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 createWavHeader(pcmLength: number, sampleRate: number = 24000, channels: number = 1): Buffer { const header = Buffer.alloc(44) header.write('RIFF', 0) @@ -2294,6 +2343,26 @@ class ChatService { return header } + 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 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 }) diff --git a/electron/services/config.ts b/electron/services/config.ts index bbb7bb7..b4944f1 100644 --- a/electron/services/config.ts +++ b/electron/services/config.ts @@ -20,6 +20,9 @@ interface ConfigSchema { language: string logEnabled: boolean llmModelPath: string + whisperModelName: string + whisperModelDir: string + whisperDownloadSource: string } export class ConfigService { @@ -42,7 +45,10 @@ export class ConfigService { themeId: 'cloud-dancer', language: 'zh-CN', logEnabled: false, - llmModelPath: '' + llmModelPath: '', + whisperModelName: 'base', + whisperModelDir: '', + whisperDownloadSource: 'tsinghua' } }) } diff --git a/electron/services/voiceTranscribeService.ts b/electron/services/voiceTranscribeService.ts new file mode 100644 index 0000000..7665023 --- /dev/null +++ b/electron/services/voiceTranscribeService.ts @@ -0,0 +1,285 @@ +import { app } from 'electron' +import { createWriteStream, existsSync, mkdirSync, statSync, unlinkSync, writeFileSync } from 'fs' +import { join, dirname } from 'path' +import { promisify } from 'util' +import { execFile, spawnSync } from 'child_process' +import * as https from 'https' +import * as http from 'http' +import { ConfigService } from './config' + +const execFileAsync = promisify(execFile) + +type WhisperModelInfo = { + name: string + fileName: string + sizeLabel: string + sizeBytes?: number +} + +type DownloadProgress = { + modelName: string + downloadedBytes: number + totalBytes?: number + percent?: number +} + +const WHISPER_MODELS: Record = { + tiny: { name: 'tiny', fileName: 'ggml-tiny.bin', sizeLabel: '75 MB', sizeBytes: 75_000_000 }, + base: { name: 'base', fileName: 'ggml-base.bin', sizeLabel: '142 MB', sizeBytes: 142_000_000 }, + small: { name: 'small', fileName: 'ggml-small.bin', sizeLabel: '466 MB', sizeBytes: 466_000_000 }, + medium: { name: 'medium', fileName: 'ggml-medium.bin', sizeLabel: '1.5 GB', sizeBytes: 1_500_000_000 }, + 'large-v3': { name: 'large-v3', fileName: 'ggml-large-v3.bin', sizeLabel: '2.9 GB', sizeBytes: 2_900_000_000 } +} + +const WHISPER_SOURCES: Record = { + official: 'https://huggingface.co/ggerganov/whisper.cpp/resolve/main', + tsinghua: 'https://hf-mirror.com/ggerganov/whisper.cpp/resolve/main' +} + +function getStaticFfmpegPath(): string | null { + try { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const ffmpegStatic = require('ffmpeg-static') + if (typeof ffmpegStatic === 'string' && existsSync(ffmpegStatic)) { + return ffmpegStatic + } + const devPath = join(process.cwd(), 'node_modules', 'ffmpeg-static', 'ffmpeg.exe') + if (existsSync(devPath)) { + return devPath + } + 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 + } +} + +export class VoiceTranscribeService { + private configService = new ConfigService() + private downloadTasks = new Map>() + + private resolveModelInfo(modelName: string): WhisperModelInfo | null { + return WHISPER_MODELS[modelName] || null + } + + private resolveModelDir(overrideDir?: string): string { + const configured = overrideDir || this.configService.get('whisperModelDir') + if (configured) return configured + return join(app.getPath('userData'), 'models', 'whisper') + } + + private resolveModelPath(modelName: string, overrideDir?: string): string | null { + const info = this.resolveModelInfo(modelName) + if (!info) return null + return join(this.resolveModelDir(overrideDir), info.fileName) + } + + private resolveSourceUrl(overrideSource?: string): string { + const configured = overrideSource || this.configService.get('whisperDownloadSource') + if (configured && WHISPER_SOURCES[configured]) return WHISPER_SOURCES[configured] + return WHISPER_SOURCES.official + } + + async getModelStatus(payload: { modelName: string; downloadDir?: string }): Promise<{ + success: boolean + exists?: boolean + path?: string + sizeBytes?: number + error?: string + }> { + const modelPath = this.resolveModelPath(payload.modelName, payload.downloadDir) + if (!modelPath) { + return { success: false, error: '未知模型名称' } + } + if (!existsSync(modelPath)) { + return { success: true, exists: false, path: modelPath } + } + const sizeBytes = statSync(modelPath).size + return { success: true, exists: true, path: modelPath, sizeBytes } + } + + async downloadModel( + payload: { modelName: string; downloadDir?: string; source?: string }, + onProgress?: (progress: DownloadProgress) => void + ): Promise<{ success: boolean; path?: string; error?: string }> { + const info = this.resolveModelInfo(payload.modelName) + if (!info) { + return { success: false, error: '未知模型名称' } + } + + const modelPath = this.resolveModelPath(payload.modelName, payload.downloadDir) + if (!modelPath) { + return { success: false, error: '模型路径生成失败' } + } + + if (existsSync(modelPath)) { + return { success: true, path: modelPath } + } + + const cacheKey = `${payload.modelName}:${modelPath}` + const pending = this.downloadTasks.get(cacheKey) + if (pending) return pending + + const task = (async () => { + try { + const targetDir = this.resolveModelDir(payload.downloadDir) + if (!existsSync(targetDir)) { + mkdirSync(targetDir, { recursive: true }) + } + + const baseUrl = this.resolveSourceUrl(payload.source) + const url = `${baseUrl}/${info.fileName}` + await this.downloadToFile(url, modelPath, payload.modelName, onProgress) + return { success: true, path: modelPath } + } catch (error) { + try { if (existsSync(modelPath)) unlinkSync(modelPath) } catch { } + return { success: false, error: String(error) } + } finally { + this.downloadTasks.delete(cacheKey) + } + })() + + this.downloadTasks.set(cacheKey, task) + return task + } + + async transcribeWavBuffer(wavData: Buffer): Promise<{ success: boolean; transcript?: string; error?: string }> { + const modelName = this.configService.get('whisperModelName') || 'base' + const modelPath = this.resolveModelPath(modelName) + console.info('[VoiceTranscribe] check model', { modelName, modelPath, exists: modelPath ? existsSync(modelPath) : false }) + if (!modelPath || !existsSync(modelPath)) { + return { success: false, error: '未下载语音模型,请在设置中下载' } + } + + // 使用内置的预编译 whisper-cli.exe + const resourcesPath = app.isPackaged + ? join(process.resourcesPath, 'resources') + : join(app.getAppPath(), 'resources') + const whisperExe = join(resourcesPath, 'whisper-cli.exe') + + if (!existsSync(whisperExe)) { + return { success: false, error: '找不到语音转写程序,请重新安装应用' } + } + + const ffmpegPath = getStaticFfmpegPath() || 'ffmpeg' + console.info('[VoiceTranscribe] ffmpeg path', ffmpegPath) + + const tempDir = app.getPath('temp') + const fileToken = `${Date.now()}_${Math.random().toString(16).slice(2)}` + const inputPath = join(tempDir, `weflow_voice_${fileToken}.wav`) + const outputPath = join(tempDir, `weflow_voice_${fileToken}_16k.wav`) + + try { + writeFileSync(inputPath, wavData) + console.info('[VoiceTranscribe] converting to 16kHz', { inputPath, outputPath }) + await execFileAsync(ffmpegPath, ['-y', '-i', inputPath, '-ar', '16000', '-ac', '1', outputPath]) + + console.info('[VoiceTranscribe] transcribing with whisper', { whisperExe, modelPath }) + const { stdout, stderr } = await execFileAsync(whisperExe, [ + '-m', modelPath, + '-f', outputPath, + '-l', 'zh', + '-otxt', + '-np' // no prints (只输出结果) + ], { + maxBuffer: 10 * 1024 * 1024, + cwd: dirname(whisperExe), // 设置工作目录为 whisper-cli.exe 所在目录,确保能找到 DLL + env: { ...process.env, PATH: `${dirname(whisperExe)};${process.env.PATH}` } + }) + + console.info('[VoiceTranscribe] whisper stdout:', stdout) + if (stderr) console.warn('[VoiceTranscribe] whisper stderr:', stderr) + + // 解析输出文本 + const outputBase = outputPath.replace(/\.[^.]+$/, '') + const txtFile = `${outputBase}.txt` + let transcript = '' + if (existsSync(txtFile)) { + const { readFileSync } = await import('fs') + transcript = readFileSync(txtFile, 'utf-8').trim() + unlinkSync(txtFile) + } else { + // 从 stdout 提取(使用 -np 参数后,stdout 只有转写结果) + transcript = stdout.trim() + } + + console.info('[VoiceTranscribe] success', { transcript }) + return { success: true, transcript } + } catch (error: any) { + console.error('[VoiceTranscribe] failed', error) + console.error('[VoiceTranscribe] stderr:', error.stderr) + console.error('[VoiceTranscribe] stdout:', error.stdout) + return { success: false, error: String(error) } + } finally { + try { if (existsSync(inputPath)) unlinkSync(inputPath) } catch { } + try { if (existsSync(outputPath)) unlinkSync(outputPath) } catch { } + } + } + + private downloadToFile( + url: string, + targetPath: string, + modelName: string, + onProgress?: (progress: DownloadProgress) => void, + remainingRedirects = 3 + ): Promise { + return new Promise((resolve, reject) => { + const protocol = url.startsWith('https') ? https : http + const request = protocol.get(url, (response) => { + if ([301, 302, 303, 307, 308].includes(response.statusCode || 0) && response.headers.location) { + if (remainingRedirects <= 0) { + reject(new Error('下载重定向次数过多')) + return + } + this.downloadToFile(response.headers.location, targetPath, modelName, onProgress, remainingRedirects - 1) + .then(resolve) + .catch(reject) + return + } + + if (response.statusCode !== 200) { + reject(new Error(`下载失败: ${response.statusCode}`)) + return + } + + const totalBytes = Number(response.headers['content-length'] || 0) || undefined + let downloadedBytes = 0 + + const writer = createWriteStream(targetPath) + + response.on('data', (chunk) => { + downloadedBytes += chunk.length + const percent = totalBytes ? (downloadedBytes / totalBytes) * 100 : undefined + onProgress?.({ modelName, downloadedBytes, totalBytes, percent }) + }) + + response.on('error', (error) => { + try { writer.close() } catch { } + reject(error) + }) + + writer.on('error', (error) => { + try { writer.close() } catch { } + reject(error) + }) + + writer.on('finish', () => { + writer.close() + resolve() + }) + + response.pipe(writer) + }) + + request.on('error', reject) + }) + } +} + +export const voiceTranscribeService = new VoiceTranscribeService() diff --git a/electron/types/whisper-node.d.ts b/electron/types/whisper-node.d.ts new file mode 100644 index 0000000..70d7081 --- /dev/null +++ b/electron/types/whisper-node.d.ts @@ -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 +} diff --git a/package-lock.json b/package-lock.json index d7e0928..ed80dcd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,6 +25,7 @@ "react-dom": "^19.2.3", "react-router-dom": "^7.1.1", "wechat-emojis": "^1.0.2", + "whisper-node": "^1.1.1", "zustand": "^5.0.2" }, "devDependencies": { @@ -3746,7 +3747,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": { @@ -3852,7 +3852,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", @@ -4451,7 +4450,6 @@ "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": { @@ -5792,7 +5790,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": { @@ -5814,7 +5811,6 @@ "version": "1.1.2", "resolved": "https://registry.npmmirror.com/function-bind/-/function-bind-1.1.2.tgz", "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" @@ -5933,7 +5929,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", @@ -5954,7 +5949,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,7 +6103,6 @@ "version": "2.0.2", "resolved": "https://registry.npmmirror.com/hasown/-/hasown-2.0.2.tgz", "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", - "dev": true, "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -6334,7 +6327,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", @@ -6353,6 +6345,15 @@ "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", "license": "ISC" }, + "node_modules/interpret": { + "version": "1.4.0", + "resolved": "https://registry.npmmirror.com/interpret/-/interpret-1.4.0.tgz", + "integrity": "sha512-agE4QfB2Lkp9uICn7BAqoscw4SZP9kTE2hxiFI3jBPmXJfdqiahTbUuKGsMoN2GtqL9AxhYioAcVvgsb1HvRbA==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, "node_modules/ip-address": { "version": "10.1.0", "resolved": "https://registry.npmmirror.com/ip-address/-/ip-address-10.1.0.tgz", @@ -6376,6 +6377,21 @@ "is-ci": "bin.js" } }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmmirror.com/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmmirror.com/is-extglob/-/is-extglob-2.1.1.tgz", @@ -7506,7 +7522,6 @@ "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" @@ -7522,6 +7537,12 @@ "node": ">=8" } }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmmirror.com/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "license": "MIT" + }, "node_modules/path-scurry": { "version": "1.11.1", "resolved": "https://registry.npmmirror.com/path-scurry/-/path-scurry-1.11.1.tgz", @@ -7911,6 +7932,26 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/readline-sync": { + "version": "1.4.10", + "resolved": "https://registry.npmmirror.com/readline-sync/-/readline-sync-1.4.10.tgz", + "integrity": "sha512-gNva8/6UAe8QYepIQH/jQ2qn91Qj0B9sYjMBBs3QOB8F2CXcKgLxQaJRP76sWVRQt+QU+8fAkCbCvjjMFu7Ycw==", + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/rechoir": { + "version": "0.6.2", + "resolved": "https://registry.npmmirror.com/rechoir/-/rechoir-0.6.2.tgz", + "integrity": "sha512-HFM8rkZ+i3zrV+4LQjwQ0W+ez98pApMGM3HUrN04j3CqzPOzl9nmP15Y8YXNm8QHGv/eacOVEjqhmWpkRV0NAw==", + "dependencies": { + "resolve": "^1.1.6" + }, + "engines": { + "node": ">= 0.10" + } + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmmirror.com/require-directory/-/require-directory-2.1.1.tgz", @@ -7948,6 +7989,26 @@ "url": "https://github.com/sponsors/jet2jet" } }, + "node_modules/resolve": { + "version": "1.22.11", + "resolved": "https://registry.npmmirror.com/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/resolve-alpn": { "version": "1.2.1", "resolved": "https://registry.npmmirror.com/resolve-alpn/-/resolve-alpn-1.2.1.tgz", @@ -8281,6 +8342,23 @@ "node": ">=8" } }, + "node_modules/shelljs": { + "version": "0.8.5", + "resolved": "https://registry.npmmirror.com/shelljs/-/shelljs-0.8.5.tgz", + "integrity": "sha512-TiwcRcrkhHvbrZbnRcFYMLl30Dfov3HKqzp5tO5b4pt6G/SezKcYhmDg15zXVBswHmctSAQKznqNW2LO5tTDow==", + "license": "BSD-3-Clause", + "dependencies": { + "glob": "^7.0.0", + "interpret": "^1.0.0", + "rechoir": "^0.6.2" + }, + "bin": { + "shjs": "bin/shjs" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/signal-exit": { "version": "3.0.7", "resolved": "https://registry.npmmirror.com/signal-exit/-/signal-exit-3.0.7.tgz", @@ -8588,6 +8666,18 @@ "node": ">=8" } }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/tar": { "version": "6.2.1", "resolved": "https://registry.npmmirror.com/tar/-/tar-6.2.1.tgz", @@ -9219,6 +9309,19 @@ "node": ">= 8" } }, + "node_modules/whisper-node": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/whisper-node/-/whisper-node-1.1.1.tgz", + "integrity": "sha512-s1czx7pL0g63QOz0X9oAu7vOf4GzmFfQIy6J7msOAH5Yyiy+4a3w6+Uv0hiHvHkfBWk/+hG8nY3VEFdIapF83g==", + "license": "MIT", + "dependencies": { + "readline-sync": "^1.4.10", + "shelljs": "^0.8.5" + }, + "bin": { + "download": "dist/download.js" + } + }, "node_modules/wide-align": { "version": "1.1.5", "resolved": "https://registry.npmmirror.com/wide-align/-/wide-align-1.1.5.tgz", diff --git a/package.json b/package.json index c0d253d..eb64b9e 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "react-dom": "^19.2.3", "react-router-dom": "^7.1.1", "wechat-emojis": "^1.0.2", + "whisper-node": "^1.1.1", "zustand": "^5.0.2" }, "devDependencies": { @@ -103,4 +104,4 @@ "node_modules/ffmpeg-static/**/*" ] } -} \ No newline at end of file +} diff --git a/resources/SDL2.dll b/resources/SDL2.dll new file mode 100644 index 0000000..e26bcb1 Binary files /dev/null and b/resources/SDL2.dll differ diff --git a/resources/ggml-base.dll b/resources/ggml-base.dll new file mode 100644 index 0000000..78f5141 Binary files /dev/null and b/resources/ggml-base.dll differ diff --git a/resources/ggml-cpu.dll b/resources/ggml-cpu.dll new file mode 100644 index 0000000..a91f426 Binary files /dev/null and b/resources/ggml-cpu.dll differ diff --git a/resources/ggml.dll b/resources/ggml.dll new file mode 100644 index 0000000..1b174f1 Binary files /dev/null and b/resources/ggml.dll differ diff --git a/resources/whisper-cli.exe b/resources/whisper-cli.exe new file mode 100644 index 0000000..071e436 Binary files /dev/null and b/resources/whisper-cli.exe differ diff --git a/resources/whisper-main.exe b/resources/whisper-main.exe new file mode 100644 index 0000000..6977ffa Binary files /dev/null and b/resources/whisper-main.exe differ diff --git a/resources/whisper.dll b/resources/whisper.dll new file mode 100644 index 0000000..caa17f3 Binary files /dev/null and b/resources/whisper.dll differ diff --git a/src/pages/ChatPage.scss b/src/pages/ChatPage.scss index 02d0235..d41c9ec 100644 --- a/src/pages/ChatPage.scss +++ b/src/pages/ChatPage.scss @@ -1303,6 +1303,12 @@ cursor: pointer; } +.voice-stack { + display: flex; + flex-direction: column; + gap: 6px; +} + .message-bubble.sent .voice-message { background: rgba(255, 255, 255, 0.18); } @@ -1391,6 +1397,34 @@ color: #d9480f; } +.voice-transcript { + max-width: 260px; + padding: 8px 12px; + border-radius: 14px; + font-size: 13px; + line-height: 1.5; + background: var(--bg-secondary); + color: var(--text-primary); + border: 1px solid var(--border-color); + word-break: break-word; + white-space: pre-wrap; +} + +.voice-transcript.sent { + background: rgba(255, 255, 255, 0.9); + color: var(--text-primary); + border-color: transparent; +} + +.voice-transcript.received { + background: var(--card-bg); +} + +.voice-transcript.error { + color: #d9480f; + cursor: pointer; +} + @keyframes voicePulse { 0% { height: 6px; @@ -1847,4 +1881,4 @@ opacity: 1; transform: translateX(0); } -} \ No newline at end of file +} diff --git a/src/pages/ChatPage.tsx b/src/pages/ChatPage.tsx index 72dabe4..ff1e36f 100644 --- a/src/pages/ChatPage.tsx +++ b/src/pages/ChatPage.tsx @@ -1280,6 +1280,7 @@ function ChatPage(_props: ChatPageProps) { const emojiDataUrlCache = new Map() const imageDataUrlCache = new Map() const voiceDataUrlCache = new Map() +const voiceTranscriptCache = new Map() const senderAvatarCache = new Map() const senderAvatarLoading = new Map>() @@ -1312,6 +1313,9 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat }: const [voiceLoading, setVoiceLoading] = useState(false) const [isVoicePlaying, setIsVoicePlaying] = useState(false) const voiceAudioRef = useRef(null) + const [voiceTranscriptLoading, setVoiceTranscriptLoading] = useState(false) + const [voiceTranscriptError, setVoiceTranscriptError] = useState(false) + const voiceTranscriptRequestedRef = useRef(false) const [showImagePreview, setShowImagePreview] = useState(false) // 从缓存获取表情包 data URL @@ -1327,6 +1331,10 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat }: const [voiceDataUrl, setVoiceDataUrl] = useState( () => voiceDataUrlCache.get(voiceCacheKey) ) + const voiceTranscriptCacheKey = `voice-transcript:${message.localId}` + const [voiceTranscript, setVoiceTranscript] = useState( + () => voiceTranscriptCache.get(voiceTranscriptCacheKey) + ) const formatTime = (timestamp: number): string => { if (!Number.isFinite(timestamp) || timestamp <= 0) return '未知时间' @@ -1604,6 +1612,37 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat }: } }, [isVoice]) + const requestVoiceTranscript = useCallback(async () => { + if (voiceTranscriptLoading || voiceTranscriptRequestedRef.current) return + voiceTranscriptRequestedRef.current = true + setVoiceTranscriptLoading(true) + setVoiceTranscriptError(false) + try { + const result = await window.electronAPI.chat.getVoiceTranscript(session.username, String(message.localId)) + if (result.success) { + const transcriptText = (result.transcript || '').trim() + voiceTranscriptCache.set(voiceTranscriptCacheKey, transcriptText) + setVoiceTranscript(transcriptText) + } else { + setVoiceTranscriptError(true) + voiceTranscriptRequestedRef.current = false + } + } catch { + setVoiceTranscriptError(true) + voiceTranscriptRequestedRef.current = false + } finally { + setVoiceTranscriptLoading(false) + } + }, [message.localId, session.username, voiceTranscriptCacheKey, voiceTranscriptLoading]) + + useEffect(() => { + if (!isVoice) return + if (!voiceDataUrl) return + if (voiceTranscriptError) return + if (voiceTranscriptLoading || voiceTranscript !== undefined || voiceTranscriptRequestedRef.current) return + void requestVoiceTranscript() + }, [isVoice, voiceDataUrl, voiceTranscript, voiceTranscriptError, voiceTranscriptLoading, requestVoiceTranscript]) + if (isSystem) { return (
@@ -1762,34 +1801,57 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat }: } 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 ( -
- -
- - - - - -
-
- 语音 - {durationText && {durationText}} - {voiceLoading && 解码中...} - {showDecryptHint && 点击解密} - {voiceError && 播放失败} +
+
+ +
+ + + + + +
+
+ 语音 + {durationText && {durationText}} + {voiceLoading && 解码中...} + {showDecryptHint && 点击解密} + {voiceError && 播放失败} +
+ {showTranscript && ( +
+ {transcriptDisplay} +
+ )}
) } diff --git a/src/pages/SettingsPage.scss b/src/pages/SettingsPage.scss index 55547c6..bb30c2d 100644 --- a/src/pages/SettingsPage.scss +++ b/src/pages/SettingsPage.scss @@ -203,6 +203,23 @@ cursor: pointer; } } + + 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; @@ -235,6 +252,93 @@ } } +.whisper-section { + .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: 8px; + font-size: 12px; + color: var(--text-secondary); + margin: 4px 0 10px; + + .status { + padding: 2px 8px; + border-radius: 999px; + font-size: 11px; + font-weight: 500; + background: var(--bg-tertiary); + color: var(--text-secondary); + } + + .status.ok { + background: rgba(16, 185, 129, 0.12); + color: #059669; + } + + .status.warn { + background: rgba(245, 158, 11, 0.12); + color: #d97706; + } + + .path { + flex: 1; + min-width: 0; + font-size: 11px; + color: var(--text-tertiary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + } + + .whisper-progress { + display: flex; + align-items: center; + gap: 12px; + width: 100%; + max-width: 320px; + + .progress-bar { + flex: 1; + height: 6px; + background: var(--bg-tertiary); + border-radius: 999px; + overflow: hidden; + + .progress-fill { + height: 100%; + background: var(--primary); + border-radius: 999px; + transition: width 0.2s ease; + } + } + + span { + font-size: 12px; + color: var(--text-secondary); + min-width: 36px; + text-align: right; + } + } +} + .log-toggle-line { display: flex; align-items: center; diff --git a/src/pages/SettingsPage.tsx b/src/pages/SettingsPage.tsx index c84a6f4..0d818d7 100644 --- a/src/pages/SettingsPage.tsx +++ b/src/pages/SettingsPage.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useRef } from 'react' +import { useState, useEffect, useRef } from 'react' import { useAppStore } from '../stores/appStore' import { useThemeStore, themes } from '../stores/themeStore' import { useAnalyticsStore } from '../stores/analyticsStore' @@ -7,15 +7,29 @@ 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, ChevronDown + 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 whisperModels = [ + { value: 'tiny', label: 'tiny (75 MB)' }, + { value: 'base', label: 'base (142 MB)' }, + { value: 'small', label: 'small (466 MB)' }, + { value: 'medium', label: 'medium (1.5 GB)' }, + { value: 'large-v3', label: 'large-v3 (2.9 GB)' } +] + +const whisperSources = [ + { value: 'official', label: 'HuggingFace 官方' }, + { value: 'tsinghua', label: '清华镜像 (hf-mirror)' } +] 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 } ] @@ -41,6 +55,12 @@ function SettingsPage() { const wxidDropdownRef = useRef(null) const [cachePath, setCachePath] = useState('') const [logEnabled, setLogEnabled] = useState(false) + const [whisperModelName, setWhisperModelName] = useState('base') + const [whisperModelDir, setWhisperModelDir] = useState('') + const [whisperDownloadSource, setWhisperDownloadSource] = useState('tsinghua') + const [isWhisperDownloading, setIsWhisperDownloading] = useState(false) + const [whisperDownloadProgress, setWhisperDownloadProgress] = useState(0) + const [whisperModelStatus, setWhisperModelStatus] = useState<{ exists: boolean; path?: string } | null>(null) const [isLoading, setIsLoadingState] = useState(false) const [isTesting, setIsTesting] = useState(false) @@ -102,6 +122,9 @@ 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 savedWhisperSource = await configService.getWhisperDownloadSource() if (savedKey) setDecryptKey(savedKey) if (savedPath) setDbPath(savedPath) @@ -112,6 +135,9 @@ function SettingsPage() { } if (savedImageAesKey) setImageAesKey(savedImageAesKey) setLogEnabled(savedLogEnabled) + if (savedWhisperModelName) setWhisperModelName(savedWhisperModelName) + if (savedWhisperModelDir) setWhisperModelDir(savedWhisperModelDir) + if (savedWhisperSource) setWhisperDownloadSource(savedWhisperSource) } catch (e) { console.error('加载配置失败:', e) } @@ -119,6 +145,20 @@ function SettingsPage() { + const refreshWhisperStatus = async (modelNameValue = whisperModelName, modelDirValue = whisperModelDir) => { + try { + const result = await window.electronAPI.whisper?.getModelStatus({ + modelName: modelNameValue, + downloadDir: modelDirValue || undefined + }) + if (result?.success) { + setWhisperModelStatus({ exists: Boolean(result.exists), path: result.path }) + } + } catch { + setWhisperModelStatus(null) + } + } + const loadAppVersion = async () => { try { const version = await window.electronAPI.app.getVersion() @@ -136,6 +176,20 @@ function SettingsPage() { return () => removeListener?.() }, []) + useEffect(() => { + const removeListener = window.electronAPI.whisper?.onDownloadProgress?.((payload) => { + if (payload.modelName !== whisperModelName) return + if (typeof payload.percent === 'number') { + setWhisperDownloadProgress(payload.percent) + } + }) + return () => removeListener?.() + }, [whisperModelName]) + + useEffect(() => { + void refreshWhisperStatus(whisperModelName, whisperModelDir) + }, [whisperModelName, whisperModelDir]) + const handleCheckUpdate = async () => { setIsCheckingUpdate(true) setUpdateInfo(null) @@ -143,9 +197,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) @@ -257,6 +311,60 @@ 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 handleWhisperSourceChange = async (value: string) => { + setWhisperDownloadSource(value) + await configService.setWhisperDownloadSource(value) + } + + const handleDownloadWhisperModel = async () => { + if (isWhisperDownloading) return + setIsWhisperDownloading(true) + setWhisperDownloadProgress(0) + try { + const result = await window.electronAPI.whisper.downloadModel({ + modelName: whisperModelName, + downloadDir: whisperModelDir || undefined, + source: whisperDownloadSource + }) + if (result.success) { + setWhisperDownloadProgress(100) + showMessage('Whisper 模型下载完成', true) + await refreshWhisperStatus(whisperModelName, whisperModelDir) + } else { + showMessage(result.error || 'Whisper 模型下载失败', false) + } + } catch (e) { + showMessage(`Whisper 模型下载失败: ${e}`, false) + } finally { + setIsWhisperDownloading(false) + } + } + + const handleResetWhisperModelDir = async () => { + setWhisperModelDir('') + await configService.setWhisperModelDir('') + } + const handleAutoGetDbKey = async () => { if (isFetchingDbKey) return setIsFetchingDbKey(true) @@ -367,6 +475,9 @@ function SettingsPage() { } else { await configService.setImageAesKey('') } + await configService.setWhisperModelName(whisperModelName) + await configService.setWhisperModelDir(whisperModelDir) + await configService.setWhisperDownloadSource(whisperDownloadSource) await configService.setOnboardingDone(true) showMessage('配置保存成功,正在测试连接...', true) @@ -387,7 +498,7 @@ function SettingsPage() { } const handleClearConfig = async () => { - const confirmed = window.confirm('确定要清除当前配置吗?清除后需要重新完成首次配置。') + const confirmed = window.confirm('确定要清除当前配置吗?清除后需要重新完成首次配置?') if (!confirmed) return setIsLoadingState(true) setLoading(true, '正在清除配置...') @@ -402,6 +513,12 @@ function SettingsPage() { setWxid('') setCachePath('') setLogEnabled(false) + setWhisperModelName('base') + setWhisperModelDir('') + setWhisperDownloadSource('tsinghua') + setWhisperModelStatus(null) + setWhisperDownloadProgress(0) + setIsWhisperDownloading(false) setDbConnected(false) await window.electronAPI.window.openOnboardingWindow() } catch (e) { @@ -608,16 +725,6 @@ function SettingsPage() { {isFetchingImageKey &&
正在扫描内存,请稍候...
}
-
- - 留空使用默认目录 - setCachePath(e.target.value)} /> -
- - -
-
-
开启后写入 WCDB 调试日志,便于排查连接问题 @@ -650,12 +757,82 @@ function SettingsPage() {
) - - - + const renderWhisperTab = () => ( +
+

语音解密后自动转写为文字

+
+ + 语音解密后自动转文字,模型越大越准确但下载更慢 +
+
+ 模型 + +
+
+ 下载源 + +
+
+ 模型下载目录 + setWhisperModelDir(e.target.value)} + onBlur={() => configService.setWhisperModelDir(whisperModelDir)} + /> +
+ + +
+
+ + {whisperModelStatus?.exists ? '已下载' : '未下载'} + + {whisperModelStatus?.path && {whisperModelStatus.path}} +
+ {isWhisperDownloading ? ( +
+
+
+
+ {whisperDownloadProgress.toFixed(0)}% +
+ ) : ( + + )} +
+
+ ) const renderCacheTab = () => (

管理应用缓存数据

+
+ + 留空使用默认目录 + setCachePath(e.target.value)} /> +
+ + +
+
+
+ 清除所有缓存

清除当前配置并重新开始首次引导

@@ -690,7 +866,7 @@ function SettingsPage() {
{updateInfo?.hasUpdate ? ( <> -

新版本 v{updateInfo.version} 可用

+

新版 v{updateInfo.version} 可用

{isDownloading ? (
@@ -747,7 +923,7 @@ function SettingsPage() { onClick={() => handleSelectWxid(opt.wxid)} > {opt.wxid} - 最后修改: {new Date(opt.modifiedTime).toLocaleString()} + 最后修改 {new Date(opt.modifiedTime).toLocaleString()}
))}
@@ -782,6 +958,7 @@ function SettingsPage() {
{activeTab === 'appearance' && renderAppearanceTab()} {activeTab === 'database' && renderDatabaseTab()} + {activeTab === 'whisper' && renderWhisperTab()} {activeTab === 'cache' && renderCacheTab()} {activeTab === 'about' && renderAboutTab()}
@@ -790,3 +967,5 @@ function SettingsPage() { } export default SettingsPage + + diff --git a/src/services/config.ts b/src/services/config.ts index 92bf69d..c571d57 100644 --- a/src/services/config.ts +++ b/src/services/config.ts @@ -17,7 +17,10 @@ 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' } as const // 获取解密密钥 @@ -144,6 +147,39 @@ export async function setLlmModelPath(path: string): Promise { await config.set(CONFIG_KEYS.LLM_MODEL_PATH, path) } +// 获取 Whisper 模型名称 +export async function getWhisperModelName(): Promise { + const value = await config.get(CONFIG_KEYS.WHISPER_MODEL_NAME) + return (value as string) || null +} + +// 设置 Whisper 模型名称 +export async function setWhisperModelName(name: string): Promise { + await config.set(CONFIG_KEYS.WHISPER_MODEL_NAME, name) +} + +// 获取 Whisper 模型目录 +export async function getWhisperModelDir(): Promise { + const value = await config.get(CONFIG_KEYS.WHISPER_MODEL_DIR) + return (value as string) || null +} + +// 设置 Whisper 模型目录 +export async function setWhisperModelDir(dir: string): Promise { + await config.set(CONFIG_KEYS.WHISPER_MODEL_DIR, dir) +} + +// 获取 Whisper 下载源 +export async function getWhisperDownloadSource(): Promise { + const value = await config.get(CONFIG_KEYS.WHISPER_DOWNLOAD_SOURCE) + return (value as string) || null +} + +// 设置 Whisper 下载源 +export async function setWhisperDownloadSource(source: string): Promise { + await config.set(CONFIG_KEYS.WHISPER_DOWNLOAD_SOURCE, source) +} + // 清除所有配置 export async function clearConfig(): Promise { await config.clear() diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts index ddb14ff..b5029be 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -95,6 +95,7 @@ export interface ElectronAPI { }> getImageData: (sessionId: string, msgId: string) => Promise<{ success: boolean; data?: string; error?: string }> getVoiceData: (sessionId: string, msgId: string) => Promise<{ success: boolean; data?: string; error?: string }> + getVoiceTranscript: (sessionId: string, msgId: string) => Promise<{ success: boolean; transcript?: string; error?: string }> } image: { @@ -295,6 +296,11 @@ export interface ElectronAPI { error?: string }> } + whisper: { + downloadModel: (payload: { modelName: string; downloadDir?: string; source?: string }) => Promise<{ success: boolean; path?: string; error?: string }> + getModelStatus: (payload: { modelName: string; downloadDir?: string }) => Promise<{ success: boolean; exists?: boolean; path?: string; sizeBytes?: number; error?: string }> + onDownloadProgress: (callback: (payload: { modelName: string; downloadedBytes: number; totalBytes?: number; percent?: number }) => void) => () => void + } } export interface ExportOptions { diff --git a/tsconfig.node.json b/tsconfig.node.json index 4f7d977..688b227 100644 --- a/tsconfig.node.json +++ b/tsconfig.node.json @@ -13,6 +13,7 @@ }, "include": [ "vite.config.ts", - "electron/**/*.ts" + "electron/**/*.ts", + "electron/**/*.d.ts" ] -} \ No newline at end of file +} diff --git a/vite.config.ts b/vite.config.ts index 52a386c..bc3aa75 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -30,7 +30,9 @@ export default defineConfig({ external: [ 'better-sqlite3', 'koffi', - 'fsevents' + 'fsevents', + 'whisper-node', + 'shelljs' ] } }